Compare commits

..
26 changed files with 316 additions and 1008 deletions
+2 -439
View File
@@ -24,8 +24,6 @@ use std::time::Duration;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
use crate::error::RoutineError; use crate::error::RoutineError;
@@ -54,55 +52,6 @@ pub struct Routine {
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
const ROUTINE_VERIFICATION_STATE_KEY: &str = "_verification";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RoutineVerificationRecord {
current_fingerprint: String,
#[serde(default)]
verified_fingerprint: Option<String>,
#[serde(default)]
last_verified_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoutineVerificationStatus {
Verified,
Unverified,
}
impl RoutineVerificationStatus {
pub fn as_str(self) -> &'static str {
match self {
RoutineVerificationStatus::Verified => "verified",
RoutineVerificationStatus::Unverified => "unverified",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoutineDisplayStatus {
Disabled,
Running,
Unverified,
Failing,
Attention,
Active,
}
impl RoutineDisplayStatus {
pub fn as_str(self) -> &'static str {
match self {
RoutineDisplayStatus::Disabled => "disabled",
RoutineDisplayStatus::Running => "running",
RoutineDisplayStatus::Unverified => "unverified",
RoutineDisplayStatus::Failing => "failing",
RoutineDisplayStatus::Attention => "attention",
RoutineDisplayStatus::Active => "active",
}
}
}
/// When a routine should fire. /// When a routine should fire.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
@@ -568,155 +517,6 @@ pub fn content_hash(content: &str) -> u64 {
hasher.finish() hasher.finish()
} }
fn routine_state_as_object(state: &Value) -> Map<String, Value> {
state.as_object().cloned().unwrap_or_default()
}
fn routine_verification_record(state: &Value) -> Option<RoutineVerificationRecord> {
state
.as_object()
.and_then(|obj| obj.get(ROUTINE_VERIFICATION_STATE_KEY))
.cloned()
.and_then(|value| serde_json::from_value(value).ok())
}
fn write_routine_verification_record(
state: &Value,
record: RoutineVerificationRecord,
) -> serde_json::Value {
let mut obj = routine_state_as_object(state);
if let Ok(value) = serde_json::to_value(record) {
obj.insert(ROUTINE_VERIFICATION_STATE_KEY.to_string(), value);
}
Value::Object(obj)
}
fn canonicalize_json_value(value: Value) -> Value {
match value {
Value::Array(items) => {
Value::Array(items.into_iter().map(canonicalize_json_value).collect())
}
Value::Object(obj) => {
let mut keys: Vec<String> = obj.keys().cloned().collect();
keys.sort();
let mut canonical = Map::new();
for key in keys {
if let Some(value) = obj.get(&key) {
canonical.insert(key, canonicalize_json_value(value.clone()));
}
}
Value::Object(canonical)
}
other => other,
}
}
pub fn routine_verification_fingerprint(routine: &Routine) -> String {
let canonical = canonicalize_json_value(serde_json::json!({
"trigger_type": routine.trigger.type_tag(),
"trigger": routine.trigger.to_config_json(),
"action_type": routine.action.type_tag(),
"action": routine.action.to_config_json(),
"guardrails": {
"cooldown_secs": routine.guardrails.cooldown.as_secs(),
"max_concurrent": routine.guardrails.max_concurrent,
"dedup_window_secs": routine.guardrails.dedup_window.map(|d| d.as_secs()),
},
}))
.to_string();
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
hex::encode(hasher.finalize())
}
pub fn reset_routine_verification_state(
state: &Value,
current_fingerprint: String,
) -> serde_json::Value {
let mut record = routine_verification_record(state).unwrap_or(RoutineVerificationRecord {
current_fingerprint: current_fingerprint.clone(),
verified_fingerprint: None,
last_verified_at: None,
});
record.current_fingerprint = current_fingerprint;
write_routine_verification_record(state, record)
}
pub fn apply_routine_verification_result(
state: &Value,
current_fingerprint: String,
status: RunStatus,
now: DateTime<Utc>,
) -> serde_json::Value {
if let Some(mut record) = routine_verification_record(state) {
record.current_fingerprint = current_fingerprint.clone();
if status == RunStatus::Ok {
record.verified_fingerprint = Some(current_fingerprint);
record.last_verified_at = Some(now);
}
write_routine_verification_record(state, record)
} else if status == RunStatus::Ok {
write_routine_verification_record(
state,
RoutineVerificationRecord {
current_fingerprint: current_fingerprint.clone(),
verified_fingerprint: Some(current_fingerprint),
last_verified_at: Some(now),
},
)
} else {
state.clone()
}
}
pub fn routine_verification_status(routine: &Routine) -> RoutineVerificationStatus {
let fingerprint = routine_verification_fingerprint(routine);
let verified =
routine_verification_record(&routine.state).map_or(routine.run_count > 0, |record| {
record.current_fingerprint == fingerprint
&& record.verified_fingerprint.as_deref() == Some(fingerprint.as_str())
});
if verified {
RoutineVerificationStatus::Verified
} else {
RoutineVerificationStatus::Unverified
}
}
pub fn routine_display_status(
routine: &Routine,
last_run_status: Option<RunStatus>,
) -> RoutineDisplayStatus {
routine_display_status_for_verification(
routine,
routine_verification_status(routine),
last_run_status,
)
}
pub fn routine_display_status_for_verification(
routine: &Routine,
verification_status: RoutineVerificationStatus,
last_run_status: Option<RunStatus>,
) -> RoutineDisplayStatus {
if !routine.enabled {
return RoutineDisplayStatus::Disabled;
}
if last_run_status == Some(RunStatus::Running) {
return RoutineDisplayStatus::Running;
}
if verification_status == RoutineVerificationStatus::Unverified {
return RoutineDisplayStatus::Unverified;
}
if routine.consecutive_failures > 0 {
return RoutineDisplayStatus::Failing;
}
if last_run_status == Some(RunStatus::Attention) {
return RoutineDisplayStatus::Attention;
}
RoutineDisplayStatus::Active
}
/// Normalize a cron expression to the 7-field format expected by the `cron` crate. /// Normalize a cron expression to the 7-field format expected by the `cron` crate.
/// ///
/// The `cron` crate requires: `sec min hour day-of-month month day-of-week year`. /// The `cron` crate requires: `sec min hour day-of-month month day-of-week year`.
@@ -925,14 +725,9 @@ pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::agent::routine::{ use crate::agent::routine::{
MAX_TOOL_ROUNDS_LIMIT, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
RoutineVerificationStatus, RunStatus, Trigger, apply_routine_verification_result, describe_cron, next_cron_fire, normalize_cron_expression,
content_hash, describe_cron, next_cron_fire, normalize_cron_expression,
reset_routine_verification_state, routine_verification_fingerprint,
routine_verification_status,
}; };
use chrono::Utc;
use uuid::Uuid;
#[test] #[test]
fn test_trigger_roundtrip() { fn test_trigger_roundtrip() {
@@ -1066,69 +861,6 @@ mod tests {
assert_ne!(h1, h3); assert_ne!(h1, h3);
} }
#[test]
fn test_verification_fingerprint_is_digest_not_prompt_content() {
let routine = Routine {
id: Uuid::new_v4(),
name: "hashed".to_string(),
description: "hash test".to_string(),
user_id: "test-user".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "super-secret-routine-prompt".to_string(),
context_paths: Vec::new(),
max_tokens: 256,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails::default(),
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
let fingerprint = routine_verification_fingerprint(&routine);
assert_eq!(fingerprint.len(), 64);
assert!(!fingerprint.contains("super-secret-routine-prompt"));
}
#[test]
fn test_system_event_fingerprint_is_stable_when_filter_insertion_order_differs() {
let mut first_filters = std::collections::HashMap::new();
first_filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
first_filters.insert("action".to_string(), "opened".to_string());
let mut second_filters = std::collections::HashMap::new();
second_filters.insert("action".to_string(), "opened".to_string());
second_filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
let mut first = make_verification_test_routine();
first.trigger = Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue".to_string(),
filters: first_filters,
};
let mut second = make_verification_test_routine();
second.trigger = Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue".to_string(),
filters: second_filters,
};
assert_eq!(
routine_verification_fingerprint(&first),
routine_verification_fingerprint(&second)
);
}
#[test] #[test]
fn test_next_cron_fire_valid() { fn test_next_cron_fire_valid() {
// Every minute should always have a next fire // Every minute should always have a next fire
@@ -1385,173 +1117,4 @@ mod tests {
_ => panic!("expected Lightweight"), _ => panic!("expected Lightweight"),
} }
} }
fn make_verification_test_routine() -> Routine {
Routine {
id: Uuid::new_v4(),
name: "verify-me".to_string(),
description: "verification test".to_string(),
user_id: "test-user".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "Check routine output".to_string(),
context_paths: Vec::new(),
max_tokens: 1024,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails::default(),
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_reset_verification_state_marks_new_routine_unverified() {
let mut routine = make_verification_test_routine();
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_successful_run_verifies_current_fingerprint() {
let mut routine = make_verification_test_routine();
let fingerprint = routine_verification_fingerprint(&routine);
routine.state = reset_routine_verification_state(&routine.state, fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
fingerprint,
RunStatus::Ok,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
}
#[test]
fn test_behavior_change_resets_prior_verification() {
let mut routine = make_verification_test_routine();
let original_fingerprint = routine_verification_fingerprint(&routine);
routine.state =
reset_routine_verification_state(&routine.state, original_fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
original_fingerprint,
RunStatus::Ok,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
if let RoutineAction::Lightweight { prompt, .. } = &mut routine.action {
*prompt = "Updated prompt".to_string();
}
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_failed_unverified_run_stays_unverified() {
let mut routine = make_verification_test_routine();
let fingerprint = routine_verification_fingerprint(&routine);
routine.state = reset_routine_verification_state(&routine.state, fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
fingerprint,
RunStatus::Failed,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_schedule_change_resets_verification() {
let mut routine = make_verification_test_routine();
routine.trigger = Trigger::Cron {
schedule: "0 0 9 * * MON-FRI *".to_string(),
timezone: Some("UTC".to_string()),
};
let original_fingerprint = routine_verification_fingerprint(&routine);
routine.state =
reset_routine_verification_state(&routine.state, original_fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
original_fingerprint,
RunStatus::Ok,
Utc::now(),
);
routine.trigger = Trigger::Cron {
schedule: "0 0 10 * * MON-FRI *".to_string(),
timezone: Some("UTC".to_string()),
};
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_legacy_routine_with_runs_is_treated_as_verified_without_metadata() {
let mut routine = make_verification_test_routine();
routine.run_count = 3;
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
}
#[test]
fn test_failed_legacy_run_preserves_implicit_verification() {
let mut routine = make_verification_test_routine();
routine.run_count = 2;
let fingerprint = routine_verification_fingerprint(&routine);
routine.state = apply_routine_verification_result(
&routine.state,
fingerprint,
RunStatus::Failed,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
}
} }
+4 -18
View File
@@ -23,8 +23,7 @@ use uuid::Uuid;
use crate::agent::Scheduler; use crate::agent::Scheduler;
use crate::agent::routine::{ use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
apply_routine_verification_result, next_cron_fire, routine_verification_fingerprint,
}; };
use crate::channels::{IncomingMessage, OutgoingResponse}; use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::config::RoutineConfig; use crate::config::RoutineConfig;
@@ -622,7 +621,7 @@ impl RoutineEngine {
); );
// Load the routine to update consecutive_failures and send notification // Load the routine to update consecutive_failures and send notification
let mut routine = match self.store.get_routine(run.routine_id).await { let routine = match self.store.get_routine(run.routine_id).await {
Ok(Some(r)) => r, Ok(Some(r)) => r,
Ok(None) => { Ok(None) => {
tracing::warn!( tracing::warn!(
@@ -650,12 +649,6 @@ impl RoutineEngine {
}; };
let now = Utc::now(); let now = Utc::now();
routine.state = apply_routine_verification_result(
&routine.state,
routine_verification_fingerprint(&routine),
status,
now,
);
let next_fire = if let Trigger::Cron { let next_fire = if let Trigger::Cron {
ref schedule, ref schedule,
ref timezone, ref timezone,
@@ -1092,7 +1085,7 @@ struct EngineContext {
} }
/// Execute a routine run. Handles both lightweight and full_job modes. /// Execute a routine run. Handles both lightweight and full_job modes.
async fn execute_routine(ctx: EngineContext, mut routine: Routine, run: RoutineRun) { async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) {
// Increment running count (atomic: survives panics in the execution below) // Increment running count (atomic: survives panics in the execution below)
ctx.running_count.fetch_add(1, Ordering::Relaxed); ctx.running_count.fetch_add(1, Ordering::Relaxed);
@@ -1150,15 +1143,8 @@ async fn execute_routine(ctx: EngineContext, mut routine: Routine, run: RoutineR
tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e); tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e);
} }
let now = Utc::now();
routine.state = apply_routine_verification_result(
&routine.state,
routine_verification_fingerprint(&routine),
status,
now,
);
// Update routine runtime state // Update routine runtime state
let now = Utc::now();
let next_fire = if let Trigger::Cron { let next_fire = if let Trigger::Cron {
ref schedule, ref schedule,
ref timezone, ref timezone,
+1 -1
View File
@@ -84,7 +84,7 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/routines` | List routines | | GET | `/api/routines` | List routines |
| GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/unverified/failing/runs_today) | | GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/failing/runs_today) |
| GET | `/api/routines/{id}` | Routine detail with recent run history | | GET | `/api/routines/{id}` | Routine detail with recent run history |
| POST | `/api/routines/{id}/trigger` | Manually trigger a routine | | POST | `/api/routines/{id}/trigger` | Manually trigger a routine |
| POST | `/api/routines/{id}/toggle` | Enable/disable a routine | | POST | `/api/routines/{id}/toggle` | Enable/disable a routine |
+9 -52
View File
@@ -10,10 +10,7 @@ use axum::{
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::agent::routine::{ use crate::agent::routine::{Trigger, next_cron_fire};
RoutineDisplayStatus, RoutineVerificationStatus, Trigger, next_cron_fire,
routine_display_status_for_verification, routine_verification_status,
};
use crate::channels::web::auth::AuthenticatedUser; use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState; use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*; use crate::channels::web::types::*;
@@ -33,18 +30,7 @@ pub async fn routines_list_handler(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect(); let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
let last_run_statuses = store
.batch_get_last_run_status(&routine_ids)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let items: Vec<RoutineInfo> = routines
.iter()
.map(|routine| {
RoutineInfo::from_routine(routine, last_run_statuses.get(&routine.id).copied())
})
.collect();
Ok(Json(RoutineListResponse { routines: items })) Ok(Json(RoutineListResponse { routines: items }))
} }
@@ -63,39 +49,13 @@ pub async fn routines_summary_handler(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
let last_run_statuses = store
.batch_get_last_run_status(&routine_ids)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total = routines.len() as u64; let total = routines.len() as u64;
let mut enabled = 0u64; let enabled = routines.iter().filter(|r| r.enabled).count() as u64;
let mut disabled = 0u64; let disabled = total - enabled;
let mut unverified = 0u64; let failing = routines
let mut failing = 0u64; .iter()
.filter(|r| r.consecutive_failures > 0)
for routine in &routines { .count() as u64;
let verification_status = routine_verification_status(routine);
if routine.enabled {
enabled += 1;
} else {
disabled += 1;
}
if verification_status == RoutineVerificationStatus::Unverified {
unverified += 1;
}
if routine_display_status_for_verification(
routine,
verification_status,
last_run_statuses.get(&routine.id).copied(),
) == RoutineDisplayStatus::Failing
{
failing += 1;
}
}
let today_start = chrono::Utc::now() let today_start = chrono::Utc::now()
.date_naive() .date_naive()
@@ -114,7 +74,6 @@ pub async fn routines_summary_handler(
total, total,
enabled, enabled,
disabled, disabled,
unverified,
failing, failing,
runs_today, runs_today,
})) }))
@@ -161,7 +120,7 @@ pub async fn routines_detail_handler(
job_id: run.job_id, job_id: run.job_id,
}) })
.collect(); .collect();
let routine_info = RoutineInfo::from_routine(&routine, runs.first().map(|run| run.status)); let routine_info = RoutineInfo::from_routine(&routine);
Ok(Json(RoutineDetailResponse { Ok(Json(RoutineDetailResponse {
id: routine.id, id: routine.id,
@@ -179,8 +138,6 @@ pub async fn routines_detail_handler(
next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()), next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: routine.run_count, run_count: routine.run_count,
consecutive_failures: routine.consecutive_failures, consecutive_failures: routine.consecutive_failures,
status: routine_info.status.clone(),
verification_status: routine_info.verification_status.clone(),
created_at: routine.created_at.to_rfc3339(), created_at: routine.created_at.to_rfc3339(),
recent_runs, recent_runs,
})) }))
+7 -27
View File
@@ -4141,7 +4141,6 @@ function renderRoutinesSummary(s) {
+ summaryCard(I18n.t('routines.summary.total'), s.total, '') + summaryCard(I18n.t('routines.summary.total'), s.total, '')
+ summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active') + summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active')
+ summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '') + summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '')
+ summaryCard(I18n.t('routines.summary.unverified'), s.unverified, 'pending')
+ summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed') + summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed')
+ summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed'); + summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed');
} }
@@ -4160,8 +4159,6 @@ function renderRoutinesList(routines) {
tbody.innerHTML = routines.map((r) => { tbody.innerHTML = routines.map((r) => {
const statusClass = r.status === 'active' ? 'completed' const statusClass = r.status === 'active' ? 'completed'
: r.status === 'failing' ? 'failed' : r.status === 'failing' ? 'failed'
: r.status === 'attention' ? 'stuck'
: r.status === 'running' ? 'in_progress'
: 'pending'; : 'pending';
const toggleLabel = r.enabled ? 'Disable' : 'Enable'; const toggleLabel = r.enabled ? 'Disable' : 'Enable';
@@ -4169,9 +4166,6 @@ function renderRoutinesList(routines) {
const triggerTitle = (r.trigger_type === 'cron' && r.trigger_raw) const triggerTitle = (r.trigger_type === 'cron' && r.trigger_raw)
? ' title="' + escapeHtml(r.trigger_raw) + '"' ? ' title="' + escapeHtml(r.trigger_raw) + '"'
: ''; : '';
const runLabel = (r.verification_status === 'unverified' || r.status === 'unverified')
? 'Verify now'
: 'Run';
return '<tr class="routine-row" data-action="open-routine" data-id="' + escapeHtml(r.id) + '">' return '<tr class="routine-row" data-action="open-routine" data-id="' + escapeHtml(r.id) + '">'
+ '<td>' + escapeHtml(r.name) + '</td>' + '<td>' + escapeHtml(r.name) + '</td>'
@@ -4183,7 +4177,7 @@ function renderRoutinesList(routines) {
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>' + '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>'
+ '<td>' + '<td>'
+ '<button class="' + toggleClass + '" data-action="toggle-routine" data-id="' + escapeHtml(r.id) + '">' + toggleLabel + '</button> ' + '<button class="' + toggleClass + '" data-action="toggle-routine" data-id="' + escapeHtml(r.id) + '">' + toggleLabel + '</button> '
+ '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">' + runLabel + '</button> ' + '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">Run</button> '
+ '<button class="btn-cancel" data-action="delete-routine" data-id="' + escapeHtml(r.id) + '" data-name="' + escapeHtml(r.name) + '">Delete</button>' + '<button class="btn-cancel" data-action="delete-routine" data-id="' + escapeHtml(r.id) + '" data-name="' + escapeHtml(r.name) + '">Delete</button>'
+ '</td>' + '</td>'
+ '</tr>'; + '</tr>';
@@ -4212,12 +4206,12 @@ function renderRoutineDetail(routine) {
const detail = document.getElementById('routine-detail'); const detail = document.getElementById('routine-detail');
detail.style.display = 'block'; detail.style.display = 'block';
const statusClass = routine.status === 'active' ? 'completed' const statusClass = !routine.enabled ? 'pending'
: routine.status === 'failing' ? 'failed' : routine.consecutive_failures > 0 ? 'failed'
: routine.status === 'attention' ? 'stuck' : 'completed';
: routine.status === 'running' ? 'in_progress' const statusLabel = !routine.enabled ? 'disabled'
: 'pending'; : routine.consecutive_failures > 0 ? 'failing'
const statusLabel = routine.status || 'active'; : 'active';
let html = '<div class="job-detail-header">' let html = '<div class="job-detail-header">'
+ '<button class="btn-back" data-action="close-routine-detail">&larr; Back</button>' + '<button class="btn-back" data-action="close-routine-detail">&larr; Back</button>'
@@ -4242,20 +4236,6 @@ function renderRoutineDetail(routine) {
+ '<div class="job-description-body">' + escapeHtml(routine.description) + '</div></div>'; + '<div class="job-description-body">' + escapeHtml(routine.description) + '</div></div>';
} }
if (routine.verification_status === 'unverified') {
let verificationCopy = 'Created or updated, but not yet verified with a successful run.';
if (routine.recent_runs && routine.recent_runs.length > 0) {
const latestRun = routine.recent_runs[0];
if (latestRun.status === 'failed') {
verificationCopy = 'The latest verification attempt failed. Review the run details and verify again after fixing it.';
} else if (latestRun.status === 'attention') {
verificationCopy = 'The latest verification attempt needs attention. Review the run details and verify again when ready.';
}
}
html += '<div class="job-description"><h3>Verification</h3>'
+ '<div class="job-description-body">' + escapeHtml(verificationCopy) + '</div></div>';
}
// Trigger config // Trigger config
if (routine.trigger_type === 'cron') { if (routine.trigger_type === 'cron') {
const summary = routine.trigger_summary || 'cron'; const summary = routine.trigger_summary || 'cron';
-1
View File
@@ -207,7 +207,6 @@ I18n.register('en', {
'routines.summary.total': 'Total', 'routines.summary.total': 'Total',
'routines.summary.enabled': 'Enabled', 'routines.summary.enabled': 'Enabled',
'routines.summary.disabled': 'Disabled', 'routines.summary.disabled': 'Disabled',
'routines.summary.unverified': 'Unverified',
'routines.summary.failing': 'Failing', 'routines.summary.failing': 'Failing',
'routines.summary.runsToday': 'Runs Today', 'routines.summary.runsToday': 'Runs Today',
-1
View File
@@ -207,7 +207,6 @@ I18n.register('zh-CN', {
'routines.summary.total': '总计', 'routines.summary.total': '总计',
'routines.summary.enabled': '已启用', 'routines.summary.enabled': '已启用',
'routines.summary.disabled': '已禁用', 'routines.summary.disabled': '已禁用',
'routines.summary.unverified': '未验证',
'routines.summary.failing': '失败', 'routines.summary.failing': '失败',
'routines.summary.runsToday': '今日运行', 'routines.summary.runsToday': '今日运行',
+8 -143
View File
@@ -662,15 +662,11 @@ pub struct RoutineInfo {
pub run_count: u64, pub run_count: u64,
pub consecutive_failures: u32, pub consecutive_failures: u32,
pub status: String, pub status: String,
pub verification_status: String,
} }
impl RoutineInfo { impl RoutineInfo {
/// Convert a `Routine` to the trimmed `RoutineInfo` for list display. /// Convert a `Routine` to the trimmed `RoutineInfo` for list display.
pub fn from_routine( pub fn from_routine(r: &crate::agent::routine::Routine) -> Self {
r: &crate::agent::routine::Routine,
last_run_status: Option<crate::agent::routine::RunStatus>,
) -> Self {
let (trigger_type, trigger_raw, trigger_summary) = match &r.trigger { let (trigger_type, trigger_raw, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, timezone } => ( crate::agent::routine::Trigger::Cron { schedule, timezone } => (
"cron".to_string(), "cron".to_string(),
@@ -714,13 +710,13 @@ impl RoutineInfo {
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job", crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
}; };
let verification_status = crate::agent::routine::routine_verification_status(r); let status = if !r.enabled {
let status = crate::agent::routine::routine_display_status_for_verification( "disabled"
r, } else if r.consecutive_failures > 0 {
verification_status, "failing"
last_run_status, } else {
) "active"
.as_str(); };
RoutineInfo { RoutineInfo {
id: r.id, id: r.id,
@@ -736,7 +732,6 @@ impl RoutineInfo {
run_count: r.run_count, run_count: r.run_count,
consecutive_failures: r.consecutive_failures, consecutive_failures: r.consecutive_failures,
status: status.to_string(), status: status.to_string(),
verification_status: verification_status.as_str().to_string(),
} }
} }
} }
@@ -751,7 +746,6 @@ pub struct RoutineSummaryResponse {
pub total: u64, pub total: u64,
pub enabled: u64, pub enabled: u64,
pub disabled: u64, pub disabled: u64,
pub unverified: u64,
pub failing: u64, pub failing: u64,
pub runs_today: u64, pub runs_today: u64,
} }
@@ -773,8 +767,6 @@ pub struct RoutineDetailResponse {
pub next_fire_at: Option<String>, pub next_fire_at: Option<String>,
pub run_count: u64, pub run_count: u64,
pub consecutive_failures: u32, pub consecutive_failures: u32,
pub status: String,
pub verification_status: String,
pub created_at: String, pub created_at: String,
pub recent_runs: Vec<RoutineRunInfo>, pub recent_runs: Vec<RoutineRunInfo>,
} }
@@ -831,7 +823,6 @@ pub struct HealthResponse {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Utc;
// ---- WsClientMessage deserialization tests ---- // ---- WsClientMessage deserialization tests ----
@@ -1182,130 +1173,4 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("channel").is_none()); assert!(parsed.get("channel").is_none());
} }
fn make_routine_for_status_tests() -> crate::agent::routine::Routine {
crate::agent::routine::Routine {
id: Uuid::new_v4(),
name: "status-check".to_string(),
description: "routine status test".to_string(),
user_id: "test-user".to_string(),
enabled: true,
trigger: crate::agent::routine::Trigger::Manual,
action: crate::agent::routine::RoutineAction::Lightweight {
prompt: "Check status".to_string(),
context_paths: Vec::new(),
max_tokens: 256,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: crate::agent::routine::RoutineGuardrails::default(),
notify: crate::agent::routine::NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_routine_info_marks_new_routine_unverified() {
let mut routine = make_routine_for_status_tests();
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
crate::agent::routine::routine_verification_fingerprint(&routine),
);
let info = RoutineInfo::from_routine(&routine, None);
assert_eq!(info.status, "unverified");
assert_eq!(info.verification_status, "unverified");
}
#[test]
fn test_routine_info_preserves_verified_state_for_description_only_changes() {
let mut routine = make_routine_for_status_tests();
let fingerprint = crate::agent::routine::routine_verification_fingerprint(&routine);
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
fingerprint.clone(),
);
routine.state = crate::agent::routine::apply_routine_verification_result(
&routine.state,
fingerprint,
crate::agent::routine::RunStatus::Ok,
Utc::now(),
);
routine.description = "Updated description".to_string();
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
assert_eq!(info.status, "active");
assert_eq!(info.verification_status, "verified");
}
#[test]
fn test_routine_info_surfaces_running_before_unverified() {
let mut routine = make_routine_for_status_tests();
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
crate::agent::routine::routine_verification_fingerprint(&routine),
);
let info =
RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Running));
assert_eq!(info.status, "running");
assert_eq!(info.verification_status, "unverified");
}
#[test]
fn test_routine_info_keeps_verified_state_when_disabled() {
let mut routine = make_routine_for_status_tests();
let fingerprint = crate::agent::routine::routine_verification_fingerprint(&routine);
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
fingerprint.clone(),
);
routine.state = crate::agent::routine::apply_routine_verification_result(
&routine.state,
fingerprint,
crate::agent::routine::RunStatus::Ok,
Utc::now(),
);
routine.enabled = false;
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
assert_eq!(info.status, "disabled");
assert_eq!(info.verification_status, "verified");
}
#[test]
fn test_routine_info_treats_legacy_run_history_as_verified() {
let mut routine = make_routine_for_status_tests();
routine.run_count = 2;
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
assert_eq!(info.status, "active");
assert_eq!(info.verification_status, "verified");
}
#[test]
fn test_routine_info_keeps_unverified_state_when_disabled() {
let mut routine = make_routine_for_status_tests();
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
crate::agent::routine::routine_verification_fingerprint(&routine),
);
routine.enabled = false;
let info = RoutineInfo::from_routine(&routine, None);
assert_eq!(info.status, "disabled");
assert_eq!(info.verification_status, "unverified");
}
} }
+20 -114
View File
@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use libsql::{params, params_from_iter}; use libsql::params;
use uuid::Uuid; use uuid::Uuid;
use super::{ use super::{
@@ -471,33 +471,25 @@ impl RoutineStore for LibSqlBackend {
} }
let conn = self.connect().await?; let conn = self.connect().await?;
let requested_rows = (1..=routine_ids.len())
.map(|i| format!("(?{i})")) // SQLite doesn't support ANY($1), so we query all latest runs and filter in memory.
.collect::<Vec<_>>() // Uses a subquery to pick only the most recent run per routine.
.join(", ");
let requested_ids = routine_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>();
let sql = format!(
"WITH requested(routine_id) AS (VALUES {requested_rows})
SELECT r1.routine_id, r1.status
FROM routine_runs r1
JOIN (
SELECT rr.routine_id, MAX(rr.started_at) AS max_started_at
FROM routine_runs rr
JOIN requested req ON req.routine_id = rr.routine_id
GROUP BY rr.routine_id
) latest
ON latest.routine_id = r1.routine_id
AND latest.max_started_at = r1.started_at"
);
let mut rows = conn let mut rows = conn
.query(&sql, params_from_iter(requested_ids)) .query(
"SELECT routine_id, status FROM routine_runs r1
WHERE started_at = (
SELECT MAX(started_at) FROM routine_runs r2
WHERE r2.routine_id = r1.routine_id
)
GROUP BY routine_id",
params![],
)
.await .await
.map_err(|e| { .map_err(|e| {
DatabaseError::Query(format!("Failed to batch get last run status: {}", e)) DatabaseError::Query(format!("Failed to batch get last run status: {}", e))
})?; })?;
let routine_id_set: HashSet<Uuid> = routine_ids.iter().copied().collect();
let mut statuses = HashMap::new(); let mut statuses = HashMap::new();
while let Some(row) = rows while let Some(row) = rows
@@ -509,9 +501,11 @@ impl RoutineStore for LibSqlBackend {
let id = Uuid::parse_str(&id_str) let id = Uuid::parse_str(&id_str)
.map_err(|e| DatabaseError::Query(format!("Invalid routine UUID: {}", e)))?; .map_err(|e| DatabaseError::Query(format!("Invalid routine UUID: {}", e)))?;
let status_str: String = get_text(&row, 1); if routine_id_set.contains(&id) {
if let std::result::Result::Ok(status) = status_str.parse::<RunStatus>() { let status_str: String = get_text(&row, 1);
statuses.insert(id, status); if let std::result::Result::Ok(status) = status_str.parse::<RunStatus>() {
statuses.insert(id, status);
}
} }
} }
@@ -600,91 +594,3 @@ impl RoutineStore for LibSqlBackend {
Ok(runs) Ok(runs)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, Trigger,
};
use crate::db::{Database, RoutineStore};
fn test_routine(user_id: &str, name: &str) -> Routine {
Routine {
id: Uuid::new_v4(),
name: name.to_string(),
description: "test routine".to_string(),
user_id: user_id.to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "test".to_string(),
context_paths: Vec::new(),
max_tokens: 128,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails::default(),
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn test_run(routine_id: Uuid, status: RunStatus, started_at: DateTime<Utc>) -> RoutineRun {
RoutineRun {
id: Uuid::new_v4(),
routine_id,
trigger_type: "manual".to_string(),
trigger_detail: None,
started_at,
completed_at: None,
status,
result_summary: None,
tokens_used: None,
job_id: None,
created_at: started_at,
}
}
#[tokio::test]
async fn batch_get_last_run_status_is_scoped_to_requested_routines() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("routine-status.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();
let requested = test_routine("user-1", "requested");
let other = test_routine("user-1", "other");
backend.create_routine(&requested).await.unwrap();
backend.create_routine(&other).await.unwrap();
let now = Utc::now();
backend
.create_routine_run(&test_run(requested.id, RunStatus::Ok, now))
.await
.unwrap();
backend
.create_routine_run(&test_run(
other.id,
RunStatus::Failed,
now + chrono::Duration::seconds(1),
))
.await
.unwrap();
let statuses = backend
.batch_get_last_run_status(&[requested.id])
.await
.unwrap();
assert_eq!(statuses.len(), 1);
assert_eq!(statuses.get(&requested.id), Some(&RunStatus::Ok));
assert!(!statuses.contains_key(&other.id));
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
//! and activation of channels, tools, and MCP servers. //! and activation of channels, tools, and MCP servers.
//! //!
//! Extensions are the user-facing abstraction that unifies three runtime kinds: //! Extensions are the user-facing abstraction that unifies three runtime kinds:
//! - **Channels** (Telegram, Slack, Discord) — messaging integrations (WASM) //! - **Channels** (Telegram, Slack, Discord) — messaging platform connections
//! and conversation transports (WASM)
//! - **Tools** — sandboxed capabilities (WASM) //! - **Tools** — sandboxed capabilities (WASM)
//! - **MCP servers** — external API integrations via Model Context Protocol //! - **MCP servers** — external API integrations via Model Context Protocol
//! //!
+64 -6
View File
@@ -1017,8 +1017,11 @@ Example:
"\n\n## Extensions\n\ "\n\n## Extensions\n\
You can search, install, and activate extensions to add new capabilities:\n\ You can search, install, and activate extensions to add new capabilities:\n\
- **Channels** (Telegram, Slack, Discord) — messaging integrations. \ - **Channels** (Telegram, Slack, Discord) — connect messaging platforms so users can \
When users ask about connecting a messaging platform, search for it as a channel.\n\ talk to you there. When users ask about connecting a messaging platform, search for it \
as a channel. Channels are not separate send-message tools; use normal assistant output \
to reply in the current conversation, and use the `message` tool only for proactive, \
background, or cross-channel outbound sends.\n\
- **Tools** — sandboxed functions that extend your abilities.\n\ - **Tools** — sandboxed functions that extend your abilities.\n\
- **MCP servers** — external API integrations via the Model Context Protocol.\n\n\ - **MCP servers** — external API integrations via the Model Context Protocol.\n\n\
Use `tool_search` to find extensions by name. Refer to them by their kind \ Use `tool_search` to find extensions by name. Refer to them by their kind \
@@ -1059,15 +1062,20 @@ Example:
let message_tool_hint = "\ let message_tool_hint = "\
\n\n## Proactive Messaging\n\ \n\n## Proactive Messaging\n\
For ordinary replies in the current conversation, respond normally without calling `message`.\n\
Send messages via Signal, Telegram, Slack, or other connected channels:\n\ Send messages via Signal, Telegram, Slack, or other connected channels:\n\
- `content` (required): the message text\n\ - `content` (required): the message text\n\
- `attachments` (optional): array of file paths to send\n\ - `attachments` (optional): array of file paths to send\n\
- `channel` (optional): which channel to use (signal, telegram, slack, etc.)\n\ - `channel` (optional): which channel to use (signal, telegram, slack, etc.)\n\
- `target` (optional): who to send to (phone number, group ID, etc.)\n\ - `target` (optional): who to send to (phone number, group ID, etc.)\n\
\nOmit both `channel` and `target` to send to the current conversation.\n\ \nOmit both `channel` and `target` for a proactive follow-up in the current conversation.\n\
Target formats:\n\
- Signal: E.164 phone number (`+1234567890`) or group ID\n\
- Telegram: username or chat ID\n\
- Slack: channel name (`#general`) or user ID\n\
Examples (tool calls use JSON format):\n\ Examples (tool calls use JSON format):\n\
- Reply here: {\"content\": \"Hi!\"}\n\ - Proactive follow-up here: {\"content\": \"Hi again!\"}\n\
- Send file here: {\"content\": \"Here's the file\", \"attachments\": [\"/path/to/file.txt\"]}\n\ - Send file here proactively: {\"content\": \"Here's the file\", \"attachments\": [\"/path/to/file.txt\"]}\n\
- Message a different user: {\"channel\": \"signal\", \"target\": \"+1234567890\", \"content\": \"Hi!\"}\n\ - Message a different user: {\"channel\": \"signal\", \"target\": \"+1234567890\", \"content\": \"Hi!\"}\n\
- Message a different group: {\"channel\": \"signal\", \"target\": \"group:abc123\", \"content\": \"Hi!\"}"; - Message a different group: {\"channel\": \"signal\", \"target\": \"group:abc123\", \"content\": \"Hi!\"}";
@@ -1105,7 +1113,9 @@ Examples (tool calls use JSON format):\n\
format!( format!(
"\n\n## Current Conversation\n\ "\n\n## Current Conversation\n\
This is who you're talking to (omit 'target' to send here):\n{}", This is who you're talking to in the active conversation. Use normal assistant \
output to reply here; only use the `message` tool for proactive, background, or \
cross-channel outbound sends:\n{}",
lines.join("\n") lines.join("\n")
) )
} }
@@ -2452,6 +2462,54 @@ That's my plan."#;
); );
} }
#[test]
fn test_extensions_section_clarifies_channels_are_not_send_tools() {
let reasoning = make_test_reasoning();
let tool_defs = vec![ToolDefinition {
name: "tool_search".to_string(),
description: "Search extensions".to_string(),
parameters: serde_json::json!({}),
}];
let section = reasoning.build_extensions_section_for_tools(&tool_defs);
assert!(section.contains("connect messaging platforms so users can talk to you there"));
assert!(section.contains("Channels are not separate send-message tools"));
assert!(
section.contains("use normal assistant output to reply in the current conversation")
);
assert!(section.contains(
"`message` tool only for proactive, background, or cross-channel outbound sends"
));
}
#[test]
fn test_channel_section_separates_normal_replies_from_message_tool() {
let reasoning = make_test_reasoning().with_channel("telegram");
let section = reasoning.build_channel_section();
assert!(section.contains("respond normally without calling `message`"));
assert!(section.contains("proactive follow-up in the current conversation"));
assert!(section.contains("Target formats:"));
assert!(section.contains("Signal: E.164 phone number"));
assert!(section.contains("Telegram: username or chat ID"));
assert!(section.contains("Slack: channel name"));
assert!(section.contains("Proactive follow-up here"));
}
#[test]
fn test_current_conversation_section_does_not_imply_message_tool_for_replies() {
let reasoning = make_test_reasoning()
.with_channel("telegram")
.with_conversation_data("User", "telegram-user");
let section = reasoning.build_conversation_section();
assert!(section.contains("Use normal assistant output to reply here"));
assert!(section.contains(
"only use the `message` tool for proactive, background, or cross-channel outbound sends"
));
assert!(!section.contains("omit 'target' to send here"));
}
// ---- plan/evaluate bypass clean_response (Bug #564-2) ---- // ---- plan/evaluate bypass clean_response (Bug #564-2) ----
#[test] #[test]
+16 -2
View File
@@ -31,8 +31,11 @@ impl Tool for ToolSearchTool {
fn description(&self) -> &str { fn description(&self) -> &str {
"Search for available extensions to add new capabilities. Extensions include \ "Search for available extensions to add new capabilities. Extensions include \
channels (Telegram, Slack, Discord — for messaging), tools, and MCP servers. \ channels (Telegram, Slack, Discord — connect messaging platforms so IronClaw can \
Use discover:true to search online if the built-in registry has no results." receive and reply there), tools, and MCP servers. Use `tool_install` and \
`tool_activate` to install and enable channels; use the `message` tool for proactive \
outbound sends. Use discover:true to search online if the built-in registry has no \
results."
} }
fn parameters_schema(&self) -> serde_json::Value { fn parameters_schema(&self) -> serde_json::Value {
@@ -634,6 +637,17 @@ mod tests {
assert!(schema["properties"].get("query").is_some()); assert!(schema["properties"].get("query").is_some());
} }
#[test]
fn test_tool_search_description_clarifies_channel_setup_vs_sending() {
let tool = ToolSearchTool {
manager: test_manager_stub(),
};
let description = tool.description();
assert!(description.contains("Use `tool_install` and `tool_activate`"));
assert!(description.contains("use the `message` tool for proactive outbound sends"));
}
#[test] #[test]
fn test_tool_install_schema() { fn test_tool_install_schema() {
use crate::tools::tool::ApprovalRequirement; use crate::tools::tool::ApprovalRequirement;
+13 -5
View File
@@ -181,11 +181,15 @@ impl Tool for MessageTool {
} }
fn description(&self) -> &str { fn description(&self) -> &str {
"Send a message to a channel. If channel/target omitted, uses the current conversation's \ "Send a proactive message to a channel. Use normal assistant output to reply in the \
channel and sender/group. Use to proactively message users on any connected channel. \ active conversation; use this tool for proactive notifications, routine/background \
follow-ups, attachments, or sending to a different channel/recipient. If channel/target \
are omitted, reuses the current conversation's channel and sender/group when available. \
If you provide `target` without `channel` and no scoped channel can be resolved, the \
message may be broadcast across connected channels instead of sent to just one. \
Supports file attachments: first download the file with the http tool using save_to \ Supports file attachments: first download the file with the http tool using save_to \
(e.g., http GET https://picsum.photos/800/600 save_to=/tmp/photo.jpg), then pass \ (e.g., http GET https://picsum.photos/800/600 save_to=/tmp/photo.jpg), then pass the \
the file path in the attachments array. Images are sent as photos on Telegram. \ file path in the attachments array. Images are sent as photos on Telegram. \
- Signal: target accepts E.164 (+1234567890) or group ID \ - Signal: target accepts E.164 (+1234567890) or group ID \
- Telegram: target accepts username or chat ID \ - Telegram: target accepts username or chat ID \
- Slack: target accepts channel (#general) or user ID" - Slack: target accepts channel (#general) or user ID"
@@ -451,7 +455,11 @@ mod tests {
#[test] #[test]
fn message_tool_description() { fn message_tool_description() {
let tool = MessageTool::new(Arc::new(ChannelManager::new())); let tool = MessageTool::new(Arc::new(ChannelManager::new()));
assert!(!tool.description().is_empty()); let description = tool.description();
assert!(!description.is_empty());
assert!(description.contains("Use normal assistant output to reply"));
assert!(description.contains("proactive notifications"));
assert!(description.contains("provide `target` without `channel`"));
} }
#[test] #[test]
+9 -76
View File
@@ -20,8 +20,7 @@ use uuid::Uuid;
use crate::agent::routine::{ use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
normalize_cron_expression, reset_routine_verification_state, routine_verification_fingerprint, normalize_cron_expression,
routine_verification_status,
}; };
use crate::agent::routine_engine::RoutineEngine; use crate::agent::routine_engine::RoutineEngine;
use crate::context::JobContext; use crate::context::JobContext;
@@ -415,29 +414,12 @@ fn routine_create_tool_summary() -> ToolDiscoverySummary {
"Set execution.use_tools=false to keep a new lightweight routine text-only.".into(), "Set execution.use_tools=false to keep a new lightweight routine text-only.".into(),
"Omitting delivery.user falls back to the owner's last-seen notification target.".into(), "Omitting delivery.user falls back to the owner's last-seen notification target.".into(),
"advanced.cooldown_secs defaults to 300.".into(), "advanced.cooldown_secs defaults to 300.".into(),
"Creating a routine only saves the configuration. It does not prove the routine can execute successfully.".into(),
"After routine_create, tell the user the routine is unverified and offer to test it now unless they asked not to.".into(),
"Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(), "Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(),
], ],
examples: routine_create_examples(), examples: routine_create_examples(),
} }
} }
fn verification_result_payload(routine: &Routine, verification_reset: bool) -> Value {
let verification_status = routine_verification_status(routine);
serde_json::json!({
"verification_status": verification_status.as_str(),
"verification_reset": verification_reset,
"verification_hint": if verification_reset {
"The routine configuration changed and should be re-tested before being treated as reliable."
} else if verification_status == crate::agent::routine::RoutineVerificationStatus::Verified {
"The current routine configuration has already been verified with a successful run."
} else {
"The routine has been saved, but it has not been verified yet. Offer to test it now."
}
})
}
fn routine_create_schema(include_compatibility_aliases: bool) -> Value { fn routine_create_schema(include_compatibility_aliases: bool) -> Value {
let mut schema = serde_json::json!({ let mut schema = serde_json::json!({
"type": "object", "type": "object",
@@ -1081,8 +1063,7 @@ impl Tool for RoutineCreateTool {
fn description(&self) -> &str { fn description(&self) -> &str {
"Create a new routine (scheduled or event-driven task). \ "Create a new routine (scheduled or event-driven task). \
Supports cron schedules, event pattern matching, system events, and manual triggers. \ Supports cron schedules, event pattern matching, system events, and manual triggers. \
Use this when the user wants something to happen periodically or reactively. \ Use this when the user wants something to happen periodically or reactively."
Creation saves the routine, but does not verify that it will execute successfully."
} }
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement { fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
@@ -1127,7 +1108,7 @@ impl Tool for RoutineCreateTool {
None None
}; };
let mut routine = Routine { let routine = Routine {
id: Uuid::new_v4(), id: Uuid::new_v4(),
name: normalized.name.clone(), name: normalized.name.clone(),
description: normalized.description.clone(), description: normalized.description.clone(),
@@ -1153,10 +1134,6 @@ impl Tool for RoutineCreateTool {
created_at: Utc::now(), created_at: Utc::now(),
updated_at: Utc::now(), updated_at: Utc::now(),
}; };
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
self.store self.store
.create_routine(&routine) .create_routine(&routine)
@@ -1171,14 +1148,12 @@ impl Tool for RoutineCreateTool {
self.engine.refresh_event_cache().await; self.engine.refresh_event_cache().await;
} }
let verification = verification_result_payload(&routine, false);
let result = serde_json::json!({ let result = serde_json::json!({
"id": routine.id.to_string(), "id": routine.id.to_string(),
"name": routine.name.clone(), "name": routine.name,
"trigger_type": routine.trigger.type_tag(), "trigger_type": routine.trigger.type_tag(),
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()), "next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
"status": "created", "status": "created",
"verification": verification,
}); });
Ok(ToolOutput::success(result, start.elapsed())) Ok(ToolOutput::success(result, start.elapsed()))
@@ -1231,24 +1206,10 @@ impl Tool for RoutineListTool {
.list_routines(&ctx.user_id) .list_routines(&ctx.user_id)
.await .await
.map_err(|e| ToolError::ExecutionFailed(format!("failed to list routines: {e}")))?; .map_err(|e| ToolError::ExecutionFailed(format!("failed to list routines: {e}")))?;
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
let last_run_statuses = self
.store
.batch_get_last_run_status(&routine_ids)
.await
.map_err(|e| {
ToolError::ExecutionFailed(format!("failed to read routine statuses: {e}"))
})?;
let list: Vec<serde_json::Value> = routines let list: Vec<serde_json::Value> = routines
.iter() .iter()
.map(|r| { .map(|r| {
let verification_status = routine_verification_status(r);
let status = crate::agent::routine::routine_display_status_for_verification(
r,
verification_status,
last_run_statuses.get(&r.id).copied(),
);
serde_json::json!({ serde_json::json!({
"id": r.id.to_string(), "id": r.id.to_string(),
"name": r.name, "name": r.name,
@@ -1260,8 +1221,6 @@ impl Tool for RoutineListTool {
"next_fire_at": r.next_fire_at.map(|t| t.to_rfc3339()), "next_fire_at": r.next_fire_at.map(|t| t.to_rfc3339()),
"run_count": r.run_count, "run_count": r.run_count,
"consecutive_failures": r.consecutive_failures, "consecutive_failures": r.consecutive_failures,
"status": status.as_str(),
"verification_status": verification_status.as_str(),
}) })
}) })
.collect(); .collect();
@@ -1300,8 +1259,7 @@ impl Tool for RoutineUpdateTool {
fn description(&self) -> &str { fn description(&self) -> &str {
"Update an existing routine. Can change prompt, description, enabled state, cron schedule/timezone, \ "Update an existing routine. Can change prompt, description, enabled state, cron schedule/timezone, \
Pass the routine name and only the fields you want to change. This does not convert trigger types. \ Pass the routine name and only the fields you want to change. This does not convert trigger types."
Behavior-changing edits should leave the routine marked unverified until it is tested again."
} }
fn parameters_schema(&self) -> serde_json::Value { fn parameters_schema(&self) -> serde_json::Value {
@@ -1324,9 +1282,6 @@ impl Tool for RoutineUpdateTool {
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))? .map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?; .ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
let original_fingerprint = routine_verification_fingerprint(&routine);
let mut verification_reset = false;
// Apply updates // Apply updates
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) { if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
routine.enabled = enabled; routine.enabled = enabled;
@@ -1338,18 +1293,8 @@ impl Tool for RoutineUpdateTool {
if let Some(prompt) = params.get("prompt").and_then(|v| v.as_str()) { if let Some(prompt) = params.get("prompt").and_then(|v| v.as_str()) {
match &mut routine.action { match &mut routine.action {
RoutineAction::Lightweight { prompt: p, .. } => { RoutineAction::Lightweight { prompt: p, .. } => *p = prompt.to_string(),
if p != prompt { RoutineAction::FullJob { description: d, .. } => *d = prompt.to_string(),
verification_reset = true;
*p = prompt.to_string();
}
}
RoutineAction::FullJob { description: d, .. } => {
if d != prompt {
verification_reset = true;
*d = prompt.to_string();
}
}
} }
} }
@@ -1380,16 +1325,12 @@ impl Tool for RoutineUpdateTool {
if let Some((old_schedule, old_tz)) = existing_cron { if let Some((old_schedule, old_tz)) = existing_cron {
let effective_schedule = new_schedule.as_deref().unwrap_or(&old_schedule); let effective_schedule = new_schedule.as_deref().unwrap_or(&old_schedule);
let effective_tz = new_timezone.clone().or(old_tz.clone()); let effective_tz = new_timezone.or(old_tz);
// Validate // Validate
next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| { next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| {
ToolError::InvalidParameters(format!("invalid cron schedule: {e}")) ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
})?; })?;
if effective_schedule != old_schedule || effective_tz != old_tz {
verification_reset = true;
}
routine.trigger = Trigger::Cron { routine.trigger = Trigger::Cron {
schedule: effective_schedule.to_string(), schedule: effective_schedule.to_string(),
timezone: effective_tz.clone(), timezone: effective_tz.clone(),
@@ -1403,12 +1344,6 @@ impl Tool for RoutineUpdateTool {
} }
} }
let updated_fingerprint = routine_verification_fingerprint(&routine);
if updated_fingerprint != original_fingerprint {
verification_reset = true;
routine.state = reset_routine_verification_state(&routine.state, updated_fingerprint);
}
self.store self.store
.update_routine(&routine) .update_routine(&routine)
.await .await
@@ -1417,14 +1352,12 @@ impl Tool for RoutineUpdateTool {
// Refresh event cache in case trigger changed // Refresh event cache in case trigger changed
self.engine.refresh_event_cache().await; self.engine.refresh_event_cache().await;
let verification = verification_result_payload(&routine, verification_reset);
let result = serde_json::json!({ let result = serde_json::json!({
"name": routine.name.clone(), "name": routine.name,
"enabled": routine.enabled, "enabled": routine.enabled,
"trigger_type": routine.trigger.type_tag(), "trigger_type": routine.trigger.type_tag(),
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()), "next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
"status": "updated", "status": "updated",
"verification": verification,
}); });
Ok(ToolOutput::success(result, start.elapsed())) Ok(ToolOutput::success(result, start.elapsed()))
+109 -1
View File
@@ -13,7 +13,7 @@ mod tests {
use ironclaw::agent::routine::{RoutineAction, Trigger}; use ironclaw::agent::routine::{RoutineAction, Trigger};
use crate::support::test_rig::TestRigBuilder; use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace; use crate::support::trace_llm::{LlmTrace, TraceResponse, TraceStep, TraceToolCall, TraceTurn};
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Test 1: time_parse_and_diff // Test 1: time_parse_and_diff
@@ -838,4 +838,112 @@ mod tests {
rig.shutdown(); rig.shutdown();
} }
#[tokio::test]
async fn tool_info_clarifies_message_and_channel_setup_roles() {
let trace = LlmTrace::new(
"test-tool-info-channel-message-clarity",
vec![TraceTurn {
user_input: "How do message and channels differ?".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_tool_info_message".to_string(),
name: "tool_info".to_string(),
arguments: serde_json::json!({"name": "message"}),
}],
input_tokens: 100,
output_tokens: 20,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_tool_info_tool_search".to_string(),
name: "tool_info".to_string(),
arguments: serde_json::json!({"name": "tool_search"}),
}],
input_tokens: 140,
output_tokens: 20,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "I checked both tool descriptions.".to_string(),
input_tokens: 220,
output_tokens: 30,
},
expected_tool_results: Vec::new(),
},
],
expects: Default::default(),
}],
);
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("How do message and channels differ?")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let results = rig.tool_results();
let info_results: Vec<_> = results.iter().filter(|(n, _)| n == "tool_info").collect();
assert_eq!(info_results.len(), 2, "Expected two tool_info results");
let info_json: Vec<serde_json::Value> = info_results
.iter()
.map(|(_, preview)| {
serde_json::from_str(preview)
.expect("tool_info result preview should be valid JSON")
})
.collect();
let message_json = info_json
.iter()
.find(|info| info["name"] == "message")
.expect("tool_info result should contain 'message'");
let message_description = message_json["description"]
.as_str()
.expect("message description should be a string");
assert!(
message_description.contains("Use normal assistant output to reply"),
"message description should distinguish normal replies: {message_description}"
);
assert!(
message_description.contains("proactive notifications"),
"message description should describe proactive sends: {message_description}"
);
let tool_search_json = info_json
.iter()
.find(|info| info["name"] == "tool_search")
.expect("tool_info result should contain 'tool_search'");
let tool_search_description = tool_search_json["description"]
.as_str()
.expect("tool_search description should be a string");
assert!(
tool_search_description.contains("`tool_install`")
&& tool_search_description.contains("`tool_activate`"),
"tool_search description should describe setup/activation via tool_install and \
tool_activate: {tool_search_description}"
);
assert!(
tool_search_description.contains("use the `message` tool for proactive outbound sends"),
"tool_search description should point outbound sends to message: {tool_search_description}"
);
rig.shutdown();
}
} }
@@ -29,7 +29,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the any-channel-bug-watcher routine for bug messages, but it is not verified yet. It should stay unverified until it has a successful run.", "content": "Created the any-channel-bug-watcher routine for bug messages.",
"input_tokens": 170, "input_tokens": 170,
"output_tokens": 18 "output_tokens": 18
} }
@@ -30,7 +30,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the telegram-bug-watcher routine for Telegram bug messages, but it is not verified yet. I can test it the next time you want to fire it.", "content": "Created the telegram-bug-watcher routine for Telegram bug messages.",
"input_tokens": 180, "input_tokens": 180,
"output_tokens": 20 "output_tokens": 20
} }
@@ -37,7 +37,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the **morning-tech-news** routine with manual trigger and full_job mode. The `message` and `http` tools are available, but the routine is not verified yet.", "content": "Created the **morning-tech-news** routine with manual trigger and full_job mode. The `message` and `http` tools are pre-authorized.",
"input_tokens": 200, "input_tokens": 200,
"output_tokens": 50 "output_tokens": 50
} }
@@ -57,7 +57,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the weekday-digest routine with a grouped cron request and listed the routines. It is not verified yet, so it should stay unverified until it has a successful run.", "content": "Created the weekday-digest routine with a grouped cron request and listed the active routines.",
"input_tokens": 250, "input_tokens": 250,
"output_tokens": 24 "output_tokens": 24
} }
+1 -1
View File
@@ -52,7 +52,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "I created the daily-check routine, but it is not verified yet. It is scheduled for 9 AM every day, and the routine list should show it as unverified until it has a successful run.", "content": "I created a daily-check routine that runs at 9 AM every day. The routine list shows it as active.",
"input_tokens": 300, "input_tokens": 300,
"output_tokens": 25 "output_tokens": 25
} }
+1 -1
View File
@@ -41,7 +41,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "The history-test routine was created, but it is not verified yet. Its run history is empty since it hasn't been triggered yet.", "content": "The history-test routine was created. Its run history is empty since it hasn't been triggered yet.",
"input_tokens": 300, "input_tokens": 300,
"output_tokens": 25 "output_tokens": 25
} }
+1 -1
View File
@@ -27,7 +27,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the manual-triage routine, but it is not verified yet. It will only run when explicitly fired, so I can test it for you when you're ready.", "content": "Created the manual-triage routine. It will only run when explicitly fired.",
"input_tokens": 140, "input_tokens": 140,
"output_tokens": 18 "output_tokens": 18
} }
@@ -30,7 +30,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the manual-triage-no-tools routine, but it is not verified yet. It will only run when explicitly fired and stay text-only until you decide to test it.", "content": "Created the manual-triage-no-tools routine. It will only run when explicitly fired and stay text-only.",
"input_tokens": 140, "input_tokens": 140,
"output_tokens": 18 "output_tokens": 18
} }
+1 -1
View File
@@ -59,7 +59,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created, updated, and then deleted the temp-routine successfully. The update would have left it unverified until it was tested again.", "content": "Created, updated, and then deleted the temp-routine successfully.",
"input_tokens": 400, "input_tokens": 400,
"output_tokens": 20 "output_tokens": 20
} }
-112
View File
@@ -16,7 +16,6 @@ mod tests {
use chrono::Utc; use chrono::Utc;
use ironclaw::agent::routine::{ use ironclaw::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
reset_routine_verification_state, routine_verification_fingerprint,
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -339,115 +338,4 @@ mod tests {
harness.shutdown().await; harness.shutdown().await;
mock.shutdown().await; mock.shutdown().await;
} }
#[tokio::test]
async fn routines_api_surfaces_unverified_status_for_new_routine() {
let mock = MockOpenAiServerBuilder::new()
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
.start()
.await;
let harness =
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
.await;
let mut routine = Routine {
id: Uuid::new_v4(),
name: "wf-unverified".to_string(),
description: "Unverified status regression test".to_string(),
user_id: harness.user_id.clone(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "Check verification status".to_string(),
context_paths: Vec::new(),
max_tokens: 512,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails {
cooldown: Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
harness
.db
.create_routine(&routine)
.await
.expect("create routine");
let mut disabled_routine = routine.clone();
disabled_routine.id = Uuid::new_v4();
disabled_routine.name = "wf-unverified-disabled".to_string();
disabled_routine.enabled = false;
disabled_routine.state = reset_routine_verification_state(
&disabled_routine.state,
routine_verification_fingerprint(&disabled_routine),
);
harness
.db
.create_routine(&disabled_routine)
.await
.expect("create disabled routine");
let list = harness.list_routines().await;
let routine_id = routine.id.to_string();
let listed = list["routines"]
.as_array()
.expect("routines array")
.iter()
.find(|item| item["id"].as_str() == Some(routine_id.as_str()))
.expect("routine should be listed");
assert_eq!(listed["status"].as_str(), Some("unverified"));
assert_eq!(listed["verification_status"].as_str(), Some("unverified"));
let summary = harness
.client
.get(format!("{}/api/routines/summary", harness.base_url()))
.bearer_auth(&harness.auth_token)
.send()
.await
.expect("summary request failed")
.error_for_status()
.expect("summary non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid summary response");
assert_eq!(summary["unverified"].as_u64(), Some(2));
let detail = harness
.client
.get(format!(
"{}/api/routines/{}",
harness.base_url(),
routine_id
))
.bearer_auth(&harness.auth_token)
.send()
.await
.expect("detail request failed")
.error_for_status()
.expect("detail non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid detail response");
assert_eq!(detail["status"].as_str(), Some("unverified"));
assert_eq!(detail["verification_status"].as_str(), Some("unverified"));
harness.shutdown().await;
mock.shutdown().await;
}
} }
+43
View File
@@ -237,4 +237,47 @@ mod tests {
rig.shutdown(); rig.shutdown();
} }
#[tokio::test]
async fn telegram_system_prompt_clarifies_reply_vs_proactive_message_tool() {
let trace = simple_trace(1);
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let msg = IncomingMessage::new("telegram", "telegram-user", "Hello there");
rig.send_incoming(msg).await;
let _responses = rig.wait_for_responses(1, TIMEOUT).await;
let requests = rig.captured_llm_requests();
let system_prompt =
extract_system_prompt(&requests).expect("Expected a system prompt in the LLM request");
assert!(
system_prompt.contains("Channels are not separate send-message tools"),
"System prompt should describe channels as setup/integration surfaces.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
system_prompt
.contains("use normal assistant output to reply in the current conversation"),
"System prompt should route ordinary replies through normal assistant output.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
system_prompt.contains("respond normally without calling `message`"),
"System prompt should say normal replies do not use the message tool.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
system_prompt.contains("proactive follow-up in the current conversation"),
"System prompt should reserve omitted channel/target for proactive follow-ups.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
!system_prompt.contains("omit 'target' to send here"),
"System prompt should not imply the message tool is the default way to reply \
in-thread.\nActual system prompt:\n{system_prompt}"
);
rig.shutdown();
}
} }