diff --git a/Cargo.lock b/Cargo.lock index 4e16552cf..fa453e9ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,6 +16,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -94,6 +103,29 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + [[package]] name = "const-random" version = "0.1.18" @@ -114,6 +146,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -449,6 +487,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -660,6 +722,24 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1038,6 +1118,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1179,6 +1265,8 @@ version = "1.6.0" dependencies = [ "async-trait", "bytes", + "chrono", + "chrono-tz", "dotenvy", "futures", "reqwest", @@ -1504,12 +1592,65 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 3992d9075..5813e0722 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,12 +52,19 @@ rusqlite = { version = "0.40", features = ["bundled"], optional = true } # by the `repl` feature to keep the default build light. rhai = { version = "1", features = ["sync"], optional = true } +# Optional builtin tool family for deterministic time/date helpers. +chrono = { version = "0.4", optional = true } +chrono-tz = { version = "0.10", optional = true } + [features] default = [] # Embedded SQLite-backed checkpointer (`graph::checkpoint::SqliteCheckpointer`). sqlite = ["dep:rusqlite"] # Embedded Rhai-backed `.ragsh` REPL session runtime (`repl::session`). repl = ["dep:rhai"] +# Builtin generic tools (`harness::tools`) kept out of the default dependency +# graph so host applications can choose whether they want these implementations. +tools = ["dep:chrono", "dep:chrono-tz"] [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "test-util"] } diff --git a/src/harness/mod.rs b/src/harness/mod.rs index a6bb93322..45c350f95 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -38,6 +38,8 @@ pub mod subagent; pub mod summarization; pub mod testkit; pub mod tool; +#[cfg(feature = "tools")] +pub mod tools; pub mod usage; pub mod workspace; diff --git a/src/harness/tools/mod.rs b/src/harness/tools/mod.rs new file mode 100644 index 000000000..1fbedf00b --- /dev/null +++ b/src/harness/tools/mod.rs @@ -0,0 +1,13 @@ +//! Optional builtin harness tools. +//! +//! These tools are generic implementations that depend only on TinyAgents' +//! [`Tool`][crate::harness::tool::Tool] interface. They are behind the `tools` +//! Cargo feature so applications that provide their own tool surface do not pull +//! in extra dependencies by default. + +mod time; + +pub use time::{CurrentTimeTool, ResolveTimeTool, register_time_tools, time_tools}; + +#[cfg(test)] +mod time_test; diff --git a/src/harness/tools/time.rs b/src/harness/tools/time.rs new file mode 100644 index 000000000..c40af31de --- /dev/null +++ b/src/harness/tools/time.rs @@ -0,0 +1,408 @@ +//! Builtin time/date tools. + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, SecondsFormat, Utc}; +use chrono_tz::Tz; +use serde_json::json; + +use crate::Result; +use crate::harness::tool::{Tool, ToolCall, ToolPolicy, ToolRegistry, ToolResult, ToolSchema}; + +const CURRENT_TIME_NAME: &str = "current_time"; +const RESOLVE_TIME_NAME: &str = "resolve_time"; + +/// Tool that returns the current time in UTC and local time, optionally +/// converted to an IANA timezone. +pub struct CurrentTimeTool; + +impl CurrentTimeTool { + /// Creates a current-time tool. + pub fn new() -> Self { + Self + } +} + +impl Default for CurrentTimeTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for CurrentTimeTool { + fn name(&self) -> &str { + CURRENT_TIME_NAME + } + + fn description(&self) -> &str { + "Get the current date and time in UTC and the machine's local timezone. \ + Optionally convert to a specific IANA timezone such as \ + 'America/Los_Angeles' or 'Asia/Kolkata'. Use before scheduling tasks or \ + when a user refers to relative times like 'in 10 minutes', 'tomorrow', \ + or 'tonight'." + } + + fn schema(&self) -> ToolSchema { + ToolSchema::new( + CURRENT_TIME_NAME, + >::description(self), + json!({ + "type": "object", + "properties": { + "timezone": { + "type": "string", + "description": "Optional IANA timezone name, for example 'Europe/London'." + } + } + }), + ) + } + + fn policy(&self) -> ToolPolicy { + ToolPolicy::read_only() + } + + async fn call(&self, _state: &State, call: ToolCall) -> Result { + let payload = current_time_payload(&call.arguments); + Ok(ToolResult::text( + call.id, + CURRENT_TIME_NAME, + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()), + )) + } +} + +fn current_time_payload(args: &serde_json::Value) -> serde_json::Value { + let now_utc = Utc::now(); + let now_local = Local::now(); + + let mut payload = json!({ + "utc": now_utc.to_rfc3339_opts(SecondsFormat::Secs, true), + "local": now_local.to_rfc3339_opts(SecondsFormat::Secs, true), + "local_timezone": now_local.format("%Z").to_string(), + "unix_seconds": now_utc.timestamp(), + "weekday": now_local.format("%A").to_string(), + }); + + if let Some(tz_name) = args.get("timezone").and_then(|value| value.as_str()) { + let trimmed = tz_name.trim(); + if !trimmed.is_empty() { + match trimmed.parse::() { + Ok(tz) => { + let converted = now_utc.with_timezone(&tz); + payload["requested_timezone"] = json!({ + "name": trimmed, + "time": converted.to_rfc3339_opts(SecondsFormat::Secs, true), + "weekday": converted.format("%A").to_string(), + }); + } + Err(_) => { + payload["requested_timezone_error"] = json!(format!( + "Unknown IANA timezone '{trimmed}' - use names like 'America/Los_Angeles'." + )); + } + } + } + } + + payload +} + +/// Tool that resolves relative or absolute time expressions to exact +/// timestamps. +pub struct ResolveTimeTool; + +impl ResolveTimeTool { + /// Creates a resolve-time tool. + pub fn new() -> Self { + Self + } +} + +impl Default for ResolveTimeTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for ResolveTimeTool { + fn name(&self) -> &str { + RESOLVE_TIME_NAME + } + + fn description(&self) -> &str { + "Resolve a relative or absolute time expression into an exact timestamp. \ + Use this to produce date/time arguments for other tools instead of \ + hand-computing Unix seconds. Accepted expressions include 'now', \ + '24h ago', '7d', '2 weeks ago', 'in 10 minutes', '30m from now', \ + 'today', 'yesterday', 'tomorrow', RFC-3339 timestamps, bare dates, and \ + 'YYYY-MM-DD HH:MM:SS'." + } + + fn schema(&self) -> ToolSchema { + ToolSchema::new( + RESOLVE_TIME_NAME, + >::description(self), + json!({ + "type": "object", + "properties": { + "expr": { + "type": "string", + "description": "Time expression to resolve." + }, + "format": { + "type": "string", + "enum": ["unix_s", "unix_ms", "slack_ts", "rfc3339"], + "description": "Representation to place in the top-level value field. Defaults to unix_s." + }, + "timezone": { + "type": "string", + "description": "Optional IANA timezone used to interpret offset-less inputs." + } + }, + "required": ["expr"] + }), + ) + } + + fn policy(&self) -> ToolPolicy { + ToolPolicy::read_only() + } + + async fn call(&self, _state: &State, call: ToolCall) -> Result { + let expr = match call.arguments.get("expr").and_then(|value| value.as_str()) { + Some(expr) => expr, + None => { + return Ok(ToolResult::error( + call.id, + RESOLVE_TIME_NAME, + "resolve_time: `expr` is required", + )); + } + }; + + let zone = match call + .arguments + .get("timezone") + .and_then(|value| value.as_str()) + { + Some(tz_name) if !tz_name.trim().is_empty() => match tz_name.trim().parse::() { + Ok(tz) => ResolveZone::Iana(tz), + Err(_) => { + return Ok(ToolResult::error( + call.id, + RESOLVE_TIME_NAME, + format!( + "resolve_time: unknown IANA timezone '{}' - use names like 'America/Los_Angeles'.", + tz_name.trim() + ), + )); + } + }, + _ => ResolveZone::Local, + }; + + let dt = match resolve_expr(expr, zone) { + Ok(dt) => dt, + Err(error) => { + return Ok(ToolResult::error( + call.id, + RESOLVE_TIME_NAME, + format!("resolve_time: {error}"), + )); + } + }; + + let payload = resolve_time_payload(expr, &call.arguments, dt); + Ok(ToolResult::text( + call.id, + RESOLVE_TIME_NAME, + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()), + )) + } +} + +fn resolve_time_payload( + expr: &str, + args: &serde_json::Value, + dt: DateTime, +) -> serde_json::Value { + let unix_s = dt.timestamp(); + let unix_ms = dt.timestamp_millis(); + let slack_ts = format!("{unix_s}.000000"); + let rfc3339 = dt.to_rfc3339_opts(SecondsFormat::Secs, true); + + let format = args + .get("format") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|format| !format.is_empty()) + .unwrap_or("unix_s"); + let value = match format { + "unix_ms" => unix_ms.to_string(), + "slack_ts" => slack_ts.clone(), + "rfc3339" => rfc3339.clone(), + _ => unix_s.to_string(), + }; + + json!({ + "interpreted": expr, + "value": value, + "unix_s": unix_s, + "unix_ms": unix_ms, + "slack_ts": slack_ts, + "rfc3339": rfc3339, + }) +} + +pub(crate) fn parse_relative_duration(raw: &str) -> Option { + let mut text = raw.trim().to_ascii_lowercase(); + let mut future = false; + + if let Some(rest) = text.strip_suffix(" ago") { + text = rest.trim().to_string(); + } else if let Some(rest) = text.strip_suffix(" from now") { + future = true; + text = rest.trim().to_string(); + } + + for (prefix, is_future) in [ + ("in ", true), + ("next ", true), + ("last ", false), + ("past ", false), + ] { + if let Some(rest) = text.strip_prefix(prefix) { + future = is_future; + text = rest.trim().to_string(); + break; + } + } + + if let Some(rest) = text.strip_prefix('+') { + future = true; + text = rest.trim().to_string(); + } else if let Some(rest) = text.strip_prefix('-') { + text = rest.trim().to_string(); + } + + let text = text.split_whitespace().collect::>().join(" "); + let split_at = text.find(|ch: char| !ch.is_ascii_digit())?; + if split_at == 0 { + return None; + } + + let (num_str, unit_str) = text.split_at(split_at); + let n: i64 = num_str.trim().parse().ok()?; + let seconds_per = match unit_str.trim() { + "s" | "sec" | "secs" | "second" | "seconds" => 1, + "m" | "min" | "mins" | "minute" | "minutes" => 60, + "h" | "hr" | "hrs" | "hour" | "hours" => 3_600, + "d" | "day" | "days" => 86_400, + "w" | "wk" | "wks" | "week" | "weeks" => 604_800, + _ => return None, + }; + + let magnitude = Duration::seconds(n.saturating_mul(seconds_per)); + Some(if future { magnitude } else { -magnitude }) +} + +pub(crate) fn resolve_expr( + expr: &str, + zone: ResolveZone, +) -> std::result::Result, String> { + let trimmed = expr.trim(); + if trimmed.is_empty() { + return Err("`expr` is required".to_string()); + } + let lower = trimmed.to_ascii_lowercase(); + + if lower == "now" { + return Ok(Utc::now()); + } + + if let Some(duration) = parse_relative_duration(trimmed) { + return Ok(Utc::now() + duration); + } + + if lower == "today" || lower == "yesterday" || lower == "tomorrow" { + let offset_days = match lower.as_str() { + "yesterday" => -1, + "tomorrow" => 1, + _ => 0, + }; + return zone.civil_midnight_to_utc(zone.now_civil_date() + Duration::days(offset_days)); + } + + if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) { + return Ok(dt.with_timezone(&Utc)); + } + + for format in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M"] { + if let Ok(naive) = NaiveDateTime::parse_from_str(trimmed, format) { + return zone.naive_to_utc(naive); + } + } + + if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") { + return zone.civil_midnight_to_utc(date); + } + + Err(format!("could not parse time expression {trimmed:?}")) +} + +#[derive(Clone, Copy)] +pub(crate) enum ResolveZone { + Local, + Iana(Tz), +} + +impl ResolveZone { + fn now_civil_date(&self) -> NaiveDate { + match self { + ResolveZone::Local => Local::now().date_naive(), + ResolveZone::Iana(tz) => Utc::now().with_timezone(tz).date_naive(), + } + } + + fn civil_midnight_to_utc(&self, date: NaiveDate) -> std::result::Result, String> { + let naive = date + .and_hms_opt(0, 0, 0) + .ok_or_else(|| "invalid civil midnight".to_string())?; + self.naive_to_utc(naive) + } + + fn naive_to_utc(&self, naive: NaiveDateTime) -> std::result::Result, String> { + use chrono::TimeZone; + match self { + ResolveZone::Local => Local + .from_local_datetime(&naive) + .single() + .map(|dt| dt.with_timezone(&Utc)) + .ok_or_else(|| format!("ambiguous or invalid local time {naive}")), + ResolveZone::Iana(tz) => tz + .from_local_datetime(&naive) + .single() + .map(|dt| dt.with_timezone(&Utc)) + .ok_or_else(|| format!("ambiguous or invalid time {naive} in {tz:?}")), + } + } +} + +/// Returns the builtin time tool set. +pub fn time_tools() -> Vec>> { + vec![ + Arc::new(CurrentTimeTool::new()), + Arc::new(ResolveTimeTool::new()), + ] +} + +/// Registers the builtin time tool set into an existing registry. +pub fn register_time_tools(registry: &mut ToolRegistry) { + for tool in time_tools() { + registry.register(tool); + } +} diff --git a/src/harness/tools/time_test.rs b/src/harness/tools/time_test.rs new file mode 100644 index 000000000..46a0ba9b8 --- /dev/null +++ b/src/harness/tools/time_test.rs @@ -0,0 +1,198 @@ +//! Tests for builtin time/date tools. + +use super::time; +use super::*; +use chrono::{SecondsFormat, Utc}; +use chrono_tz::Tz; +use serde_json::json; + +use crate::harness::tool::{Tool, ToolCall, ToolRegistry}; + +fn call(id: &str, name: &str, arguments: serde_json::Value) -> ToolCall { + ToolCall::new(id, name, arguments) +} + +#[test] +fn time_tools_register_expected_names() { + let mut registry: ToolRegistry<()> = ToolRegistry::new(); + register_time_tools(&mut registry); + + assert_eq!( + registry.names(), + vec!["current_time".to_string(), "resolve_time".to_string()] + ); + assert!(registry.policies()["current_time"].side_effects.read_only); + assert!(registry.policies()["resolve_time"].side_effects.read_only); +} + +#[tokio::test] +async fn current_time_returns_utc_local_and_unix_seconds() { + let tool = CurrentTimeTool::new(); + let result = tool + .call(&(), call("c1", "current_time", json!({}))) + .await + .unwrap(); + + assert!(!result.is_error()); + let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap(); + assert!(payload["utc"].is_string()); + assert!(payload["local"].is_string()); + assert!(payload["local_timezone"].is_string()); + assert!(payload["unix_seconds"].is_number()); +} + +#[tokio::test] +async fn current_time_converts_requested_timezone() { + let tool = CurrentTimeTool::new(); + let result = tool + .call( + &(), + call("c1", "current_time", json!({ "timezone": "Asia/Kolkata" })), + ) + .await + .unwrap(); + + let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(payload["requested_timezone"]["name"], "Asia/Kolkata"); + assert!(payload["requested_timezone"]["time"].is_string()); +} + +#[tokio::test] +async fn current_time_unknown_timezone_reports_error_field() { + let tool = CurrentTimeTool::new(); + let result = tool + .call( + &(), + call("c1", "current_time", json!({ "timezone": "Not/AZone" })), + ) + .await + .unwrap(); + + let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap(); + assert!(payload["requested_timezone_error"].is_string()); +} + +#[test] +fn relative_past_variants_are_negative_offsets() { + for expr in [ + "24h ago", + "last 24 hours", + "past 24 hours", + "-24h", + "24 hours ago", + "24h", + ] { + let duration = time::parse_relative_duration(expr).unwrap(); + assert_eq!(duration.num_seconds(), -86_400, "{expr}"); + } + assert_eq!( + time::parse_relative_duration("2 weeks") + .unwrap() + .num_seconds(), + -1_209_600 + ); +} + +#[test] +fn relative_future_variants_are_positive_offsets() { + assert_eq!( + time::parse_relative_duration("in 10 minutes") + .unwrap() + .num_seconds(), + 600 + ); + assert_eq!( + time::parse_relative_duration("30m from now") + .unwrap() + .num_seconds(), + 1_800 + ); + assert_eq!( + time::parse_relative_duration("+2h").unwrap().num_seconds(), + 7_200 + ); + assert_eq!( + time::parse_relative_duration("next 7d") + .unwrap() + .num_seconds(), + 604_800 + ); +} + +#[test] +fn resolve_expr_handles_exact_rfc3339_and_explicit_zone_dates() { + let dt = time::resolve_expr("2026-06-09T19:12:00Z", time::ResolveZone::Local).unwrap(); + assert_eq!(dt.timestamp(), 1_781_032_320); + + let tz: Tz = "Asia/Kolkata".parse().unwrap(); + let dt = time::resolve_expr("2026-06-09", time::ResolveZone::Iana(tz)).unwrap(); + assert_eq!( + dt.to_rfc3339_opts(SecondsFormat::Secs, true), + "2026-06-08T18:30:00Z" + ); +} + +#[test] +fn relative_resolution_tracks_now_with_expected_sign() { + let before = Utc::now().timestamp(); + let past = time::resolve_expr("24h ago", time::ResolveZone::Local).unwrap(); + let future = time::resolve_expr("in 10 minutes", time::ResolveZone::Local).unwrap(); + let after = Utc::now().timestamp(); + + assert!(past.timestamp() >= before - 86_400 - 2); + assert!(past.timestamp() <= after - 86_400 + 2); + assert!(future.timestamp() >= before + 600 - 2); + assert!(future.timestamp() <= after + 600 + 2); +} + +#[tokio::test] +async fn resolve_time_returns_all_formats_and_selected_value() { + let tool = ResolveTimeTool::new(); + let result = tool + .call( + &(), + call( + "c1", + "resolve_time", + json!({ + "expr": "2026-06-09T19:12:00Z", + "format": "slack_ts" + }), + ), + ) + .await + .unwrap(); + + assert!(!result.is_error()); + let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(payload["unix_s"], 1_781_032_320_i64); + assert_eq!(payload["unix_ms"], 1_781_032_320_000_i64); + assert_eq!(payload["slack_ts"], "1781032320.000000"); + assert_eq!(payload["value"], "1781032320.000000"); +} + +#[tokio::test] +async fn resolve_time_errors_for_missing_expr_and_bad_timezone() { + let tool = ResolveTimeTool::new(); + + let missing = tool + .call(&(), call("c1", "resolve_time", json!({}))) + .await + .unwrap(); + assert!(missing.is_error()); + assert!(missing.content.contains("`expr` is required")); + + let bad_zone = tool + .call( + &(), + call( + "c2", + "resolve_time", + json!({ "expr": "today", "timezone": "Not/AZone" }), + ), + ) + .await + .unwrap(); + assert!(bad_zone.is_error()); + assert!(bad_zone.content.contains("unknown IANA timezone")); +}