Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecbef64a7c | |||
| 633d711faa | |||
| 37594fd520 | |||
| 64ecb76773 | |||
| 128b33e717 | |||
| aa6e3024d4 | |||
| bc7f797603 | |||
| 2d4a1b1ad7 | |||
| c7e8f3d235 | |||
| ff5c1c6e9b | |||
| b3e3a0a335 | |||
| 4d89f27f21 | |||
| 8d8d010196 | |||
| ff9904c740 | |||
| e411e80acb | |||
| 073c8cccbc | |||
| 58be05a6e0 | |||
| c794d54821 | |||
| 5f0f88de3d |
@@ -65,10 +65,6 @@ LICENSE
|
||||
coverage
|
||||
lcov.info
|
||||
|
||||
# Firmware and hardware crates (not needed for Docker runtime)
|
||||
firmware/
|
||||
crates/robot-kit/
|
||||
|
||||
# Application and script directories (not needed for Docker runtime)
|
||||
apps/
|
||||
python/
|
||||
|
||||
Generated
+43
-28
@@ -1104,19 +1104,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.15.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"unicode-width 0.2.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.3"
|
||||
@@ -1771,7 +1758,7 @@ version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
|
||||
dependencies = [
|
||||
"console 0.16.3",
|
||||
"console",
|
||||
"fuzzy-matcher",
|
||||
"shell-words",
|
||||
"tempfile",
|
||||
@@ -3247,14 +3234,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indicatif"
|
||||
version = "0.17.11"
|
||||
version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
|
||||
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
|
||||
dependencies = [
|
||||
"console 0.15.11",
|
||||
"number_prefix",
|
||||
"console",
|
||||
"portable-atomic",
|
||||
"unicode-width 0.2.2",
|
||||
"unit-prefix",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
@@ -4435,12 +4422,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "number_prefix"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
|
||||
|
||||
[[package]]
|
||||
name = "nusb"
|
||||
version = "0.2.3"
|
||||
@@ -7021,6 +7002,18 @@ name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
@@ -7028,7 +7021,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite 0.28.0",
|
||||
"tungstenite 0.29.0",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
@@ -7362,6 +7355,23 @@ name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
@@ -7373,7 +7383,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7508,6 +7517,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unit-prefix"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
@@ -9176,7 +9191,7 @@ dependencies = [
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"console 0.16.3",
|
||||
"console",
|
||||
"criterion",
|
||||
"cron",
|
||||
"dialoguer",
|
||||
@@ -9235,7 +9250,7 @@ dependencies = [
|
||||
"tokio-rustls",
|
||||
"tokio-serial",
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tokio-util",
|
||||
"toml 1.0.7+spec-1.1.0",
|
||||
"tower",
|
||||
|
||||
+2
-2
@@ -84,7 +84,7 @@ nanohtml2text = "0.2"
|
||||
fantoccini = { version = "0.22.1", optional = true, default-features = false, features = ["rustls-tls"] }
|
||||
|
||||
# Progress bars (update pipeline)
|
||||
indicatif = "0.17"
|
||||
indicatif = "0.18"
|
||||
|
||||
# Temp files (update pipeline rollback)
|
||||
tempfile = "3.26"
|
||||
@@ -143,7 +143,7 @@ glob = "0.3"
|
||||
which = "8.0"
|
||||
|
||||
# WebSocket client channels (Discord/Lark/DingTalk/Nostr)
|
||||
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
|
||||
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
|
||||
nostr-sdk = { version = "0.44", default-features = false, features = ["nip04", "nip59"], optional = true }
|
||||
regex = "1.10"
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
edition = "2021"
|
||||
|
||||
# Formatting constraints (stable)
|
||||
max_width = 100
|
||||
tab_spaces = 4
|
||||
hard_tabs = false
|
||||
|
||||
# Code style (stable)
|
||||
use_field_init_shorthand = true
|
||||
use_try_shorthand = true
|
||||
reorder_imports = true
|
||||
reorder_modules = true
|
||||
|
||||
# Match arm formatting (stable)
|
||||
match_arm_leading_pipes = "Never"
|
||||
|
||||
+10
-10
@@ -571,6 +571,16 @@ impl Agent {
|
||||
)));
|
||||
}
|
||||
|
||||
let context = self
|
||||
.memory_loader
|
||||
.load_context(
|
||||
self.memory.as_ref(),
|
||||
user_message,
|
||||
self.memory_session_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
if self.auto_save {
|
||||
let _ = self
|
||||
.memory
|
||||
@@ -583,16 +593,6 @@ impl Agent {
|
||||
.await;
|
||||
}
|
||||
|
||||
let context = self
|
||||
.memory_loader
|
||||
.load_context(
|
||||
self.memory.as_ref(),
|
||||
user_message,
|
||||
self.memory_session_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
|
||||
let enriched = if context.is_empty() {
|
||||
format!("[{now}] {user_message}")
|
||||
|
||||
@@ -251,6 +251,7 @@ impl BlueskyChannel {
|
||||
channel: "bluesky".to_string(),
|
||||
timestamp,
|
||||
thread_ts: Some(notif.uri.clone()),
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ impl Channel for CliChannel {
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
@@ -111,6 +112,7 @@ mod tests {
|
||||
channel: "cli".into(),
|
||||
timestamp: 1_234_567_890,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
assert_eq!(msg.id, "test-id");
|
||||
assert_eq!(msg.sender, "user");
|
||||
@@ -130,6 +132,7 @@ mod tests {
|
||||
channel: "ch".into(),
|
||||
timestamp: 0,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
let cloned = msg.clone();
|
||||
assert_eq!(cloned.id, msg.id);
|
||||
|
||||
@@ -275,6 +275,7 @@ impl Channel for DingTalkChannel {
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
|
||||
@@ -789,6 +789,7 @@ impl Channel for DiscordChannel {
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
|
||||
@@ -467,6 +467,7 @@ impl EmailChannel {
|
||||
channel: "email".to_string(),
|
||||
timestamp: email.timestamp,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
|
||||
@@ -294,6 +294,7 @@ end tell"#
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
|
||||
@@ -580,6 +580,7 @@ impl Channel for IrcChannel {
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
|
||||
@@ -823,6 +823,7 @@ impl LarkChannel {
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
tracing::debug!("Lark WS: message in {}", lark_msg.chat_id);
|
||||
@@ -1120,6 +1121,7 @@ impl LarkChannel {
|
||||
channel: self.channel_name().to_string(),
|
||||
timestamp,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
});
|
||||
|
||||
messages
|
||||
|
||||
@@ -267,6 +267,7 @@ impl LinqChannel {
|
||||
channel: "linq".to_string(),
|
||||
timestamp,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
});
|
||||
|
||||
messages
|
||||
|
||||
@@ -783,7 +783,13 @@ impl Channel for MatrixChannel {
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
|
||||
Ok(bytes) => match tokio::fs::write(&dest, &bytes).await {
|
||||
Ok(()) => format!("{} — saved to {}", body, dest.display()),
|
||||
Ok(()) => {
|
||||
if body.starts_with("[IMAGE:") {
|
||||
format!("[IMAGE:{}]", dest.display())
|
||||
} else {
|
||||
format!("{} — saved to {}", body, dest.display())
|
||||
}
|
||||
}
|
||||
Err(_) => format!("{} — failed to write to disk", body),
|
||||
},
|
||||
Err(_) => format!("{} — download failed", body),
|
||||
@@ -893,7 +899,8 @@ impl Channel for MatrixChannel {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts,
|
||||
thread_ts: thread_ts.clone(),
|
||||
interruption_scope_id: thread_ts,
|
||||
};
|
||||
|
||||
let _ = tx.send(msg).await;
|
||||
|
||||
@@ -322,6 +322,7 @@ impl MattermostChannel {
|
||||
#[allow(clippy::cast_sign_loss)]
|
||||
timestamp: (create_at / 1000) as u64,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ impl Channel for MochatChannel {
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
|
||||
+212
-13
@@ -394,10 +394,13 @@ fn conversation_memory_key(msg: &traits::ChannelMessage) -> String {
|
||||
}
|
||||
|
||||
fn conversation_history_key(msg: &traits::ChannelMessage) -> String {
|
||||
// Include thread_ts for per-topic session isolation in forum groups
|
||||
// Include reply_target for per-channel isolation (e.g. distinct Discord/Slack
|
||||
// channels) and thread_ts for per-topic isolation in forum groups.
|
||||
match &msg.thread_ts {
|
||||
Some(tid) => format!("{}_{}_{}", msg.channel, tid, msg.sender),
|
||||
None => format!("{}_{}", msg.channel, msg.sender),
|
||||
Some(tid) => format!(
|
||||
"{}_{}_{}_{}", msg.channel, msg.reply_target, tid, msg.sender
|
||||
),
|
||||
None => format!("{}_{}_{}", msg.channel, msg.reply_target, msg.sender),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +409,13 @@ fn followup_thread_id(msg: &traits::ChannelMessage) -> Option<String> {
|
||||
}
|
||||
|
||||
fn interruption_scope_key(msg: &traits::ChannelMessage) -> String {
|
||||
format!("{}_{}_{}", msg.channel, msg.reply_target, msg.sender)
|
||||
match &msg.interruption_scope_id {
|
||||
Some(scope) => format!(
|
||||
"{}_{}_{}_{}",
|
||||
msg.channel, msg.reply_target, msg.sender, scope
|
||||
),
|
||||
None => format!("{}_{}_{}", msg.channel, msg.reply_target, msg.sender),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when `content` is a `/stop` command (with optional `@botname` suffix).
|
||||
@@ -1456,6 +1465,11 @@ async fn handle_runtime_command_if_needed(
|
||||
}
|
||||
ChannelRuntimeCommand::NewSession => {
|
||||
clear_sender_history(ctx, &sender_key);
|
||||
if let Some(ref store) = ctx.session_store {
|
||||
if let Err(e) = store.delete_session(&sender_key) {
|
||||
tracing::warn!("Failed to delete persisted session for {sender_key}: {e}");
|
||||
}
|
||||
}
|
||||
mark_sender_for_new_session(ctx, &sender_key);
|
||||
"Conversation history cleared. Starting fresh.".to_string()
|
||||
}
|
||||
@@ -2766,10 +2780,7 @@ async fn run_message_dispatch_loop(
|
||||
String,
|
||||
InFlightSenderTaskState,
|
||||
>::new()));
|
||||
#[cfg(target_has_atomic = "64")]
|
||||
let task_sequence = Arc::new(AtomicU64::new(1));
|
||||
#[cfg(not(target_has_atomic = "64"))]
|
||||
let task_sequence = Arc::new(AtomicU32::new(1));
|
||||
|
||||
while let Some(msg) = rx.recv().await {
|
||||
// Fast path: /stop cancels the in-flight task for this sender scope without
|
||||
@@ -5592,6 +5603,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -5670,6 +5682,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -5685,7 +5698,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let turns = histories
|
||||
.get("telegram_alice")
|
||||
.get("telegram_chat-telegram_alice")
|
||||
.expect("telegram history should be stored");
|
||||
let assistant_turn = turns
|
||||
.iter()
|
||||
@@ -5762,6 +5775,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 3,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -5839,6 +5853,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 2,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -5926,6 +5941,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -5935,7 +5951,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert!(sent[0].contains("Provider switched to `openrouter`"));
|
||||
|
||||
let route_key = "telegram_alice";
|
||||
let route_key = "telegram_chat-1_alice";
|
||||
let route = runtime_ctx
|
||||
.route_overrides
|
||||
.lock()
|
||||
@@ -5967,7 +5983,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
|
||||
provider_cache_seed.insert("openrouter".to_string(), routed_provider);
|
||||
|
||||
let route_key = "telegram_alice".to_string();
|
||||
let route_key = "telegram_chat-1_alice".to_string();
|
||||
let mut route_overrides = HashMap::new();
|
||||
route_overrides.insert(
|
||||
route_key,
|
||||
@@ -6034,6 +6050,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 2,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -6123,6 +6140,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 3,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -6227,6 +6245,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 4,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -6316,6 +6335,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -6395,6 +6415,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 2,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -6584,6 +6605,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6595,6 +6617,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 2,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6683,6 +6706,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6695,6 +6719,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 2,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6796,6 +6821,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "slack".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: Some("1741234567.100001".to_string()),
|
||||
interruption_scope_id: Some("1741234567.100001".to_string()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6808,6 +6834,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "slack".to_string(),
|
||||
timestamp: 2,
|
||||
thread_ts: Some("1741234567.100001".to_string()),
|
||||
interruption_scope_id: Some("1741234567.100001".to_string()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6906,6 +6933,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6918,6 +6946,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 2,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -6998,6 +7027,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -7075,6 +7105,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -7596,6 +7627,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "slack".into(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(conversation_memory_key(&msg), "slack_U123_msg_abc123");
|
||||
@@ -7611,6 +7643,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "slack".into(),
|
||||
timestamp: 1,
|
||||
thread_ts: Some("1741234567.123456".into()),
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -7629,6 +7662,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "cli".into(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(followup_thread_id(&msg).as_deref(), Some("msg_abc123"));
|
||||
@@ -7644,6 +7678,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "slack".into(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
let msg2 = traits::ChannelMessage {
|
||||
id: "msg_2".into(),
|
||||
@@ -7653,6 +7688,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "slack".into(),
|
||||
timestamp: 2,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
assert_ne!(
|
||||
@@ -7674,6 +7710,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "slack".into(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
let msg2 = traits::ChannelMessage {
|
||||
id: "msg_2".into(),
|
||||
@@ -7683,6 +7720,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "slack".into(),
|
||||
timestamp: 2,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
mem.store(
|
||||
@@ -7828,6 +7866,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -7843,6 +7882,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 2,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -7955,6 +7995,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -7986,6 +8027,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 2,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -8023,6 +8065,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 3,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -8121,6 +8164,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -8142,7 +8186,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let turns = histories
|
||||
.get("test-channel_alice")
|
||||
.get("test-channel_chat-ctx_alice")
|
||||
.expect("history should be stored for sender");
|
||||
assert_eq!(turns[0].role, "user");
|
||||
assert_eq!(turns[0].content, "hello");
|
||||
@@ -8160,7 +8204,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
let provider_impl = Arc::new(HistoryCaptureProvider::default());
|
||||
let mut histories = HashMap::new();
|
||||
histories.insert(
|
||||
"telegram_alice".to_string(),
|
||||
"telegram_chat-telegram_alice".to_string(),
|
||||
vec![
|
||||
ChatMessage::assistant("stale assistant"),
|
||||
ChatMessage::user("earlier user question"),
|
||||
@@ -8224,6 +8268,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -8793,6 +8838,7 @@ This is an example JSON object for profile settings."#;
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -8876,6 +8922,7 @@ This is an example JSON object for profile settings."#;
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -8891,6 +8938,7 @@ This is an example JSON object for profile settings."#;
|
||||
channel: "test-channel".to_string(),
|
||||
timestamp: 2,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -8915,7 +8963,7 @@ This is an example JSON object for profile settings."#;
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let turns = histories
|
||||
.get("test-channel_zeroclaw_user")
|
||||
.get("test-channel_chat-photo_zeroclaw_user")
|
||||
.expect("history should exist for sender");
|
||||
assert_eq!(turns.len(), 2);
|
||||
assert_eq!(turns[0].role, "user");
|
||||
@@ -9034,6 +9082,7 @@ This is an example JSON object for profile settings."#;
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -9142,6 +9191,7 @@ This is an example JSON object for profile settings."#;
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -9242,6 +9292,7 @@ This is an example JSON object for profile settings."#;
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -9362,6 +9413,7 @@ This is an example JSON object for profile settings."#;
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
CancellationToken::new(),
|
||||
)
|
||||
@@ -9500,4 +9552,151 @@ This is an example JSON object for profile settings."#;
|
||||
};
|
||||
assert!(!cfg.enabled_for_channel("discord"));
|
||||
}
|
||||
|
||||
// ── interruption_scope_key tests ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn interruption_scope_key_without_scope_id_is_three_component() {
|
||||
let msg = traits::ChannelMessage {
|
||||
id: "1".into(),
|
||||
sender: "alice".into(),
|
||||
reply_target: "room".into(),
|
||||
content: "hi".into(),
|
||||
channel: "matrix".into(),
|
||||
timestamp: 0,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
assert_eq!(interruption_scope_key(&msg), "matrix_room_alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interruption_scope_key_with_scope_id_is_four_component() {
|
||||
let msg = traits::ChannelMessage {
|
||||
id: "1".into(),
|
||||
sender: "alice".into(),
|
||||
reply_target: "room".into(),
|
||||
content: "hi".into(),
|
||||
channel: "matrix".into(),
|
||||
timestamp: 0,
|
||||
thread_ts: Some("$thread1".into()),
|
||||
interruption_scope_id: Some("$thread1".into()),
|
||||
};
|
||||
assert_eq!(interruption_scope_key(&msg), "matrix_room_alice_$thread1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interruption_scope_key_thread_ts_alone_does_not_affect_key() {
|
||||
// thread_ts used for reply anchoring should not bleed into scope key
|
||||
let msg = traits::ChannelMessage {
|
||||
id: "1".into(),
|
||||
sender: "alice".into(),
|
||||
reply_target: "C123".into(),
|
||||
content: "hi".into(),
|
||||
channel: "slack".into(),
|
||||
timestamp: 0,
|
||||
thread_ts: Some("1234567890.000100".into()), // Slack top-level fallback
|
||||
interruption_scope_id: None, // but NOT a thread reply
|
||||
};
|
||||
assert_eq!(interruption_scope_key(&msg), "slack_C123_alice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_dispatch_different_threads_do_not_cancel_each_other() {
|
||||
let channel_impl = Arc::new(SlackRecordingChannel::default());
|
||||
let channel: Arc<dyn Channel> = channel_impl.clone();
|
||||
|
||||
let mut channels_by_name = HashMap::new();
|
||||
channels_by_name.insert(channel.name().to_string(), channel);
|
||||
|
||||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Arc::new(SlowProvider {
|
||||
delay: Duration::from_millis(150),
|
||||
}),
|
||||
default_provider: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
observer: Arc::new(NoopObserver),
|
||||
system_prompt: Arc::new("test-system-prompt".to_string()),
|
||||
model: Arc::new("test-model".to_string()),
|
||||
temperature: 0.0,
|
||||
auto_save_memory: false,
|
||||
max_tool_iterations: 10,
|
||||
min_relevance_score: 0.0,
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
|
||||
workspace_dir: Arc::new(std::env::temp_dir()),
|
||||
prompt_config: Arc::new(crate::config::Config::default()),
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
interrupt_on_new_message: InterruptOnNewMessageConfig {
|
||||
telegram: false,
|
||||
slack: true,
|
||||
discord: false,
|
||||
mattermost: false,
|
||||
},
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
autonomy_level: AutonomyLevel::default(),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
query_classification: crate::config::QueryClassificationConfig::default(),
|
||||
ack_reactions: true,
|
||||
show_tool_calls: true,
|
||||
session_store: None,
|
||||
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
|
||||
&crate::config::AutonomyConfig::default(),
|
||||
)),
|
||||
activated_tools: None,
|
||||
});
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
|
||||
let send_task = tokio::spawn(async move {
|
||||
// Two messages from same sender but in different Slack threads —
|
||||
// they must NOT cancel each other.
|
||||
tx.send(traits::ChannelMessage {
|
||||
id: "1741234567.100001".to_string(),
|
||||
sender: "alice".to_string(),
|
||||
reply_target: "C123".to_string(),
|
||||
content: "thread-a question".to_string(),
|
||||
channel: "slack".to_string(),
|
||||
timestamp: 1,
|
||||
thread_ts: Some("1741234567.100001".to_string()),
|
||||
interruption_scope_id: Some("1741234567.100001".to_string()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
tx.send(traits::ChannelMessage {
|
||||
id: "1741234567.200002".to_string(),
|
||||
sender: "alice".to_string(),
|
||||
reply_target: "C123".to_string(),
|
||||
content: "thread-b question".to_string(),
|
||||
channel: "slack".to_string(),
|
||||
timestamp: 2,
|
||||
thread_ts: Some("1741234567.200002".to_string()),
|
||||
interruption_scope_id: Some("1741234567.200002".to_string()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
run_message_dispatch_loop(rx, runtime_ctx, 4).await;
|
||||
send_task.await.unwrap();
|
||||
|
||||
// Both tasks should have completed — different threads, no cancellation.
|
||||
let sent_messages = channel_impl.sent_messages.lock().await;
|
||||
assert_eq!(
|
||||
sent_messages.len(),
|
||||
2,
|
||||
"both Slack thread messages should complete, got: {sent_messages:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ impl NextcloudTalkChannel {
|
||||
channel: "nextcloud_talk".to_string(),
|
||||
timestamp: Self::now_unix_secs(),
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
});
|
||||
|
||||
messages
|
||||
@@ -294,6 +295,7 @@ impl NextcloudTalkChannel {
|
||||
channel: "nextcloud_talk".to_string(),
|
||||
timestamp,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
});
|
||||
|
||||
messages
|
||||
|
||||
@@ -253,6 +253,7 @@ impl Channel for NostrChannel {
|
||||
channel: "nostr".to_string(),
|
||||
timestamp,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
if tx.send(msg).await.is_err() {
|
||||
tracing::info!("Nostr listener: message bus closed, stopping");
|
||||
|
||||
@@ -360,6 +360,7 @@ impl Channel for NotionChannel {
|
||||
channel: "notion".into(),
|
||||
timestamp,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
|
||||
@@ -465,6 +465,7 @@ impl Channel for QQChannel {
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
@@ -503,6 +504,7 @@ impl Channel for QQChannel {
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
|
||||
@@ -225,6 +225,7 @@ impl RedditChannel {
|
||||
channel: "reddit".to_string(),
|
||||
timestamp,
|
||||
thread_ts: item.parent_id.clone(),
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,16 @@ impl SessionStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a session's JSONL file. Returns `true` if the file existed.
|
||||
pub fn delete_session(&self, session_key: &str) -> std::io::Result<bool> {
|
||||
let path = self.session_path(session_key);
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// List all session keys that have files on disk.
|
||||
pub fn list_sessions(&self) -> Vec<String> {
|
||||
let entries = match std::fs::read_dir(&self.sessions_dir) {
|
||||
@@ -147,6 +157,10 @@ impl SessionBackend for SessionStore {
|
||||
fn compact(&self, session_key: &str) -> std::io::Result<()> {
|
||||
self.compact(session_key)
|
||||
}
|
||||
|
||||
fn delete_session(&self, session_key: &str) -> std::io::Result<bool> {
|
||||
self.delete_session(session_key)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -308,4 +322,44 @@ mod tests {
|
||||
assert_eq!(messages[0].content, "hello");
|
||||
assert_eq!(messages[1].content, "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_jsonl_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
let key = "delete_test";
|
||||
|
||||
store.append(key, &ChatMessage::user("hello")).unwrap();
|
||||
assert_eq!(store.load(key).len(), 1);
|
||||
|
||||
let deleted = store.delete_session(key).unwrap();
|
||||
assert!(deleted);
|
||||
assert!(store.load(key).is_empty());
|
||||
assert!(!store.session_path(key).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_nonexistent_returns_false() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
|
||||
let deleted = store.delete_session("nonexistent").unwrap();
|
||||
assert!(!deleted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_via_trait() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
let backend: &dyn SessionBackend = &store;
|
||||
|
||||
backend
|
||||
.append("trait_delete", &ChatMessage::user("hello"))
|
||||
.unwrap();
|
||||
assert_eq!(backend.load("trait_delete").len(), 1);
|
||||
|
||||
let deleted = backend.delete_session("trait_delete").unwrap();
|
||||
assert!(deleted);
|
||||
assert!(backend.load("trait_delete").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,7 @@ impl SignalChannel {
|
||||
channel: "signal".to_string(),
|
||||
timestamp: timestamp / 1000, // millis → secs
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,23 @@ impl SlackChannel {
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Returns the interruption scope identifier for a Slack message.
|
||||
///
|
||||
/// Returns `Some(thread_ts)` only when the message is a genuine thread reply
|
||||
/// (Slack's `thread_ts` field is present and differs from the message's own `ts`).
|
||||
/// Returns `None` for top-level messages and thread parent messages (where
|
||||
/// `thread_ts == ts`), placing them in the 3-component scope key
|
||||
/// (`channel_reply_target_sender`).
|
||||
///
|
||||
/// Intentional: top-level messages and threaded replies are separate conversational
|
||||
/// scopes and should not cancel each other's in-flight tasks.
|
||||
fn inbound_interruption_scope_id(msg: &serde_json::Value, ts: &str) -> Option<String> {
|
||||
msg.get("thread_ts")
|
||||
.and_then(|t| t.as_str())
|
||||
.filter(|&t| t != ts)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn normalized_channel_id(input: Option<&str>) -> Option<String> {
|
||||
input
|
||||
.map(str::trim)
|
||||
@@ -1792,6 +1809,7 @@ impl SlackChannel {
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: Self::inbound_thread_ts(event, ts),
|
||||
interruption_scope_id: Self::inbound_interruption_scope_id(event, ts),
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
@@ -2356,6 +2374,7 @@ impl Channel for SlackChannel {
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: Self::inbound_thread_ts(msg, ts),
|
||||
interruption_scope_id: Self::inbound_interruption_scope_id(msg, ts),
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
@@ -2440,6 +2459,7 @@ impl Channel for SlackChannel {
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: Some(thread_ts.clone()),
|
||||
interruption_scope_id: Some(thread_ts.clone()),
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
|
||||
@@ -1142,6 +1142,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: thread_id,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1264,6 +1265,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: thread_id,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1425,6 +1427,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: thread_id,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ pub struct ChannelMessage {
|
||||
/// Platform thread identifier (e.g. Slack `ts`, Discord thread ID).
|
||||
/// When set, replies should be posted as threaded responses.
|
||||
pub thread_ts: Option<String>,
|
||||
/// Thread scope identifier for interruption/cancellation grouping.
|
||||
/// Distinct from `thread_ts` (reply anchor): this is `Some` only when the message
|
||||
/// is genuinely inside a reply thread and should be isolated from other threads.
|
||||
/// `None` means top-level — scope is sender+channel only.
|
||||
pub interruption_scope_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Message to send through a channel
|
||||
@@ -182,6 +187,7 @@ mod tests {
|
||||
channel: "dummy".into(),
|
||||
timestamp: 123,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
@@ -198,6 +204,7 @@ mod tests {
|
||||
channel: "dummy".into(),
|
||||
timestamp: 999,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
let cloned = message.clone();
|
||||
|
||||
@@ -288,6 +288,7 @@ impl Channel for TwitterChannel {
|
||||
.get("conversation_id")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
|
||||
@@ -163,6 +163,7 @@ impl WatiChannel {
|
||||
channel: "wati".to_string(),
|
||||
timestamp,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
});
|
||||
|
||||
messages
|
||||
|
||||
@@ -237,6 +237,7 @@ impl Channel for WebhookChannel {
|
||||
channel: "webhook".to_string(),
|
||||
timestamp,
|
||||
thread_ts: payload.thread_id,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
if state.tx.send(msg).await.is_err() {
|
||||
|
||||
@@ -142,6 +142,7 @@ impl WhatsAppChannel {
|
||||
channel: "whatsapp".to_string(),
|
||||
timestamp,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,6 +741,7 @@ impl Channel for WhatsAppWebChannel {
|
||||
content,
|
||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
||||
+64
-9
@@ -6285,8 +6285,7 @@ async fn load_persisted_workspace_dirs(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let expanded_dir = shellexpand::tilde(raw_config_dir);
|
||||
let parsed_dir = PathBuf::from(expanded_dir.as_ref());
|
||||
let parsed_dir = expand_tilde_path(raw_config_dir);
|
||||
let config_dir = if parsed_dir.is_absolute() {
|
||||
parsed_dir
|
||||
} else {
|
||||
@@ -6422,6 +6421,35 @@ impl ConfigResolutionSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand tilde in paths, falling back to `UserDirs` when HOME is unset.
|
||||
///
|
||||
/// In non-TTY environments (e.g. cron), HOME may not be set, causing
|
||||
/// `shellexpand::tilde` to return the literal `~` unexpanded. This helper
|
||||
/// detects that case and uses `directories::UserDirs` as a fallback.
|
||||
fn expand_tilde_path(path: &str) -> PathBuf {
|
||||
let expanded = shellexpand::tilde(path);
|
||||
let expanded_str = expanded.as_ref();
|
||||
|
||||
// If the path still starts with '~', tilde expansion failed (HOME unset)
|
||||
if expanded_str.starts_with('~') {
|
||||
if let Some(user_dirs) = UserDirs::new() {
|
||||
let home = user_dirs.home_dir();
|
||||
// Replace leading ~ with home directory
|
||||
if let Some(rest) = expanded_str.strip_prefix('~') {
|
||||
return home.join(rest.trim_start_matches(['/', '\\']));
|
||||
}
|
||||
}
|
||||
// If UserDirs also fails, log a warning and use the literal path
|
||||
tracing::warn!(
|
||||
path = path,
|
||||
"Failed to expand tilde: HOME environment variable is not set and UserDirs failed. \
|
||||
In cron/non-TTY environments, use absolute paths or set HOME explicitly."
|
||||
);
|
||||
}
|
||||
|
||||
PathBuf::from(expanded_str)
|
||||
}
|
||||
|
||||
async fn resolve_runtime_config_dirs(
|
||||
default_zeroclaw_dir: &Path,
|
||||
default_workspace_dir: &Path,
|
||||
@@ -6429,7 +6457,7 @@ async fn resolve_runtime_config_dirs(
|
||||
if let Ok(custom_config_dir) = std::env::var("ZEROCLAW_CONFIG_DIR") {
|
||||
let custom_config_dir = custom_config_dir.trim();
|
||||
if !custom_config_dir.is_empty() {
|
||||
let zeroclaw_dir = PathBuf::from(shellexpand::tilde(custom_config_dir).as_ref());
|
||||
let zeroclaw_dir = expand_tilde_path(custom_config_dir);
|
||||
return Ok((
|
||||
zeroclaw_dir.clone(),
|
||||
zeroclaw_dir.join("workspace"),
|
||||
@@ -6440,9 +6468,8 @@ async fn resolve_runtime_config_dirs(
|
||||
|
||||
if let Ok(custom_workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
|
||||
if !custom_workspace.is_empty() {
|
||||
let expanded = shellexpand::tilde(&custom_workspace);
|
||||
let (zeroclaw_dir, workspace_dir) =
|
||||
resolve_config_dir_for_workspace(&PathBuf::from(expanded.as_ref()));
|
||||
let expanded = expand_tilde_path(&custom_workspace);
|
||||
let (zeroclaw_dir, workspace_dir) = resolve_config_dir_for_workspace(&expanded);
|
||||
return Ok((
|
||||
zeroclaw_dir,
|
||||
workspace_dir,
|
||||
@@ -7694,9 +7721,8 @@ impl Config {
|
||||
// Workspace directory: ZEROCLAW_WORKSPACE
|
||||
if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
|
||||
if !workspace.is_empty() {
|
||||
let expanded = shellexpand::tilde(&workspace);
|
||||
let (_, workspace_dir) =
|
||||
resolve_config_dir_for_workspace(&PathBuf::from(expanded.as_ref()));
|
||||
let expanded = expand_tilde_path(&workspace);
|
||||
let (_, workspace_dir) = resolve_config_dir_for_workspace(&expanded);
|
||||
self.workspace_dir = workspace_dir;
|
||||
}
|
||||
}
|
||||
@@ -8450,6 +8476,35 @@ mod tests {
|
||||
use tokio_stream::wrappers::ReadDirStream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
// ── Tilde expansion ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
async fn expand_tilde_path_handles_absolute_path() {
|
||||
let path = expand_tilde_path("/absolute/path");
|
||||
assert_eq!(path, PathBuf::from("/absolute/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn expand_tilde_path_handles_relative_path() {
|
||||
let path = expand_tilde_path("relative/path");
|
||||
assert_eq!(path, PathBuf::from("relative/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn expand_tilde_path_expands_tilde_when_home_set() {
|
||||
// This test verifies that tilde expansion works when HOME is set.
|
||||
// In normal environments, HOME is set, so ~ should expand.
|
||||
let path = expand_tilde_path("~/.zeroclaw");
|
||||
// The path should not literally start with '~' if HOME is set
|
||||
// (it should be expanded to the actual home directory)
|
||||
if std::env::var("HOME").is_ok() {
|
||||
assert!(
|
||||
!path.to_string_lossy().starts_with('~'),
|
||||
"Tilde should be expanded when HOME is set"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Defaults ─────────────────────────────────────────────
|
||||
|
||||
fn has_test_table(raw: &str, table: &str) -> bool {
|
||||
|
||||
@@ -2173,6 +2173,7 @@ mod tests {
|
||||
channel: "whatsapp".into(),
|
||||
timestamp: 1,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
let key = whatsapp_memory_key(&msg);
|
||||
|
||||
+32
-13
@@ -783,10 +783,15 @@ fn allows_unauthenticated_model_fetch(provider_name: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Pick a sensible default model for the given provider.
|
||||
const MINIMAX_ONBOARD_MODELS: [(&str, &str); 5] = [
|
||||
("MiniMax-M2.5", "MiniMax M2.5 (latest, recommended)"),
|
||||
const MINIMAX_ONBOARD_MODELS: [(&str, &str); 7] = [
|
||||
(
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax M2.7 (latest flagship, recommended)",
|
||||
),
|
||||
("MiniMax-M2.7-highspeed", "MiniMax M2.7 High-Speed (faster)"),
|
||||
("MiniMax-M2.5", "MiniMax M2.5 (stable)"),
|
||||
("MiniMax-M2.5-highspeed", "MiniMax M2.5 High-Speed (faster)"),
|
||||
("MiniMax-M2.1", "MiniMax M2.1 (stable)"),
|
||||
("MiniMax-M2.1", "MiniMax M2.1 (previous gen)"),
|
||||
("MiniMax-M2.1-highspeed", "MiniMax M2.1 High-Speed (faster)"),
|
||||
("MiniMax-M2", "MiniMax M2 (legacy)"),
|
||||
];
|
||||
@@ -803,12 +808,12 @@ fn default_model_for_provider(provider: &str) -> String {
|
||||
"xai" => "grok-4-1-fast-reasoning".into(),
|
||||
"perplexity" => "sonar-pro".into(),
|
||||
"fireworks" => "accounts/fireworks/models/llama-v3p3-70b-instruct".into(),
|
||||
"novita" => "minimax/minimax-m2.5".into(),
|
||||
"novita" => "minimax/minimax-m2.7".into(),
|
||||
"together-ai" => "meta-llama/Llama-3.3-70B-Instruct-Turbo".into(),
|
||||
"cohere" => "command-a-03-2025".into(),
|
||||
"moonshot" => "kimi-k2.5".into(),
|
||||
"glm" | "zai" => "glm-5".into(),
|
||||
"minimax" => "MiniMax-M2.5".into(),
|
||||
"minimax" => "MiniMax-M2.7".into(),
|
||||
"qwen" => "qwen-plus".into(),
|
||||
"qwen-code" => "qwen3-coder-plus".into(),
|
||||
"ollama" => "llama3.2".into(),
|
||||
@@ -997,10 +1002,16 @@ fn curated_models_for_provider(provider_name: &str) -> Vec<(String, String)> {
|
||||
"Mixtral 8x22B".to_string(),
|
||||
),
|
||||
],
|
||||
"novita" => vec![(
|
||||
"minimax/minimax-m2.5".to_string(),
|
||||
"MiniMax M2.5".to_string(),
|
||||
)],
|
||||
"novita" => vec![
|
||||
(
|
||||
"minimax/minimax-m2.7".to_string(),
|
||||
"MiniMax M2.7 (latest flagship)".to_string(),
|
||||
),
|
||||
(
|
||||
"minimax/minimax-m2.5".to_string(),
|
||||
"MiniMax M2.5".to_string(),
|
||||
),
|
||||
],
|
||||
"together-ai" => vec![
|
||||
(
|
||||
"meta-llama/Llama-3.3-70B-Instruct-Turbo".to_string(),
|
||||
@@ -1065,9 +1076,17 @@ fn curated_models_for_provider(provider_name: &str) -> Vec<(String, String)> {
|
||||
),
|
||||
],
|
||||
"minimax" => vec![
|
||||
(
|
||||
"MiniMax-M2.7".to_string(),
|
||||
"MiniMax M2.7 (latest flagship)".to_string(),
|
||||
),
|
||||
(
|
||||
"MiniMax-M2.7-highspeed".to_string(),
|
||||
"MiniMax M2.7 High-Speed (fast)".to_string(),
|
||||
),
|
||||
(
|
||||
"MiniMax-M2.5".to_string(),
|
||||
"MiniMax M2.5 (latest flagship)".to_string(),
|
||||
"MiniMax M2.5 (stable)".to_string(),
|
||||
),
|
||||
(
|
||||
"MiniMax-M2.5-highspeed".to_string(),
|
||||
@@ -1075,7 +1094,7 @@ fn curated_models_for_provider(provider_name: &str) -> Vec<(String, String)> {
|
||||
),
|
||||
(
|
||||
"MiniMax-M2.1".to_string(),
|
||||
"MiniMax M2.1 (strong coding/reasoning)".to_string(),
|
||||
"MiniMax M2.1 (previous gen)".to_string(),
|
||||
),
|
||||
],
|
||||
"qwen" => vec![
|
||||
@@ -1621,7 +1640,7 @@ fn fetch_live_models_for_provider(
|
||||
"qwen3-coder-next:cloud".to_string(),
|
||||
"qwen3-coder:480b:cloud".to_string(),
|
||||
"kimi-k2.5:cloud".to_string(),
|
||||
"minimax-m2.5:cloud".to_string(),
|
||||
"minimax-m2.7:cloud".to_string(),
|
||||
"deepseek-v3.1:671b:cloud".to_string(),
|
||||
]
|
||||
} else {
|
||||
@@ -6651,7 +6670,7 @@ mod tests {
|
||||
assert_eq!(default_model_for_provider("qwen-intl"), "qwen-plus");
|
||||
assert_eq!(default_model_for_provider("qwen-code"), "qwen3-coder-plus");
|
||||
assert_eq!(default_model_for_provider("glm-cn"), "glm-5");
|
||||
assert_eq!(default_model_for_provider("minimax-cn"), "MiniMax-M2.5");
|
||||
assert_eq!(default_model_for_provider("minimax-cn"), "MiniMax-M2.7");
|
||||
assert_eq!(default_model_for_provider("zai-cn"), "glm-5");
|
||||
assert_eq!(default_model_for_provider("gemini"), "gemini-2.5-pro");
|
||||
assert_eq!(default_model_for_provider("google"), "gemini-2.5-pro");
|
||||
|
||||
@@ -186,6 +186,12 @@ impl OpenAiCompatibleProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable native tool calling, forcing prompt-guided tool use instead.
|
||||
pub fn without_native_tools(mut self) -> Self {
|
||||
self.native_tool_calling = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the HTTP request timeout for LLM API calls.
|
||||
pub fn with_timeout_secs(mut self, timeout_secs: u64) -> Self {
|
||||
self.timeout_secs = timeout_secs;
|
||||
|
||||
+11
-4
@@ -1160,9 +1160,12 @@ fn create_provider_with_url_and_options(
|
||||
"telnyx" => Ok(Box::new(telnyx::TelnyxProvider::new(key))),
|
||||
|
||||
// ── OpenAI-compatible providers ──────────────────────
|
||||
"venice" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Venice", "https://api.venice.ai", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"venice" => Ok(compat(
|
||||
OpenAiCompatibleProvider::new(
|
||||
"Venice", "https://api.venice.ai", key, AuthStyle::Bearer,
|
||||
)
|
||||
.without_native_tools(),
|
||||
)),
|
||||
"vercel" | "vercel-ai" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Vercel AI Gateway",
|
||||
VERCEL_AI_GATEWAY_BASE_URL,
|
||||
@@ -2457,7 +2460,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn factory_venice() {
|
||||
assert!(create_provider("venice", Some("vn-key")).is_ok());
|
||||
let provider = create_provider("venice", Some("vn-key")).unwrap();
|
||||
assert!(
|
||||
!provider.capabilities().native_tool_calling,
|
||||
"Venice should use prompt-guided tools, not native tool calling"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+121
-6
@@ -119,6 +119,14 @@ fn install(config: &Config, init_system: InitSystem) -> Result<()> {
|
||||
|
||||
fn start(config: &Config, init_system: InitSystem) -> Result<()> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// Ensure the Homebrew var directory exists before launchd tries to use it.
|
||||
// The plist may reference this path for WorkingDirectory and log files.
|
||||
let exe = std::env::current_exe().ok();
|
||||
if let Some(ref exe_path) = exe {
|
||||
if let Some(var_dir) = detect_homebrew_var_dir(exe_path) {
|
||||
let _ = fs::create_dir_all(&var_dir);
|
||||
}
|
||||
}
|
||||
let plist = macos_service_file()?;
|
||||
run_checked(Command::new("launchctl").arg("load").arg("-w").arg(&plist))?;
|
||||
run_checked(Command::new("launchctl").arg("start").arg(SERVICE_LABEL))?;
|
||||
@@ -374,6 +382,46 @@ fn uninstall_linux(config: &Config, init_system: InitSystem) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detect if the executable lives under a Homebrew prefix and return the
|
||||
/// corresponding `var/zeroclaw` directory.
|
||||
///
|
||||
/// Homebrew installs binaries into `<prefix>/Cellar/<formula>/<version>/bin/`
|
||||
/// and symlinks them to `<prefix>/bin/`. The canonical `var` directory is
|
||||
/// `<prefix>/var`. We check for both layouts.
|
||||
fn detect_homebrew_var_dir(exe: &Path) -> Option<PathBuf> {
|
||||
let path_str = exe.to_string_lossy();
|
||||
|
||||
// Symlinked binary: <prefix>/bin/zeroclaw
|
||||
// Cellar binary: <prefix>/Cellar/zeroclaw/<version>/bin/zeroclaw
|
||||
let prefix = if path_str.contains("/Cellar/") {
|
||||
// Walk up from .../Cellar/zeroclaw/<ver>/bin/zeroclaw to the prefix
|
||||
let mut ancestor = exe.to_path_buf();
|
||||
while let Some(parent) = ancestor.parent() {
|
||||
ancestor = parent.to_path_buf();
|
||||
if ancestor.file_name().map_or(false, |n| n == "Cellar") {
|
||||
// prefix is one level above Cellar
|
||||
return ancestor.parent().map(|p| p.join("var").join("zeroclaw"));
|
||||
}
|
||||
}
|
||||
return None;
|
||||
} else if let Some(bin_parent) = exe.parent() {
|
||||
// <prefix>/bin/zeroclaw → check if <prefix>/Cellar exists (Homebrew marker)
|
||||
if let Some(prefix) = bin_parent.parent() {
|
||||
if prefix.join("Cellar").is_dir() {
|
||||
Some(prefix.to_path_buf())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
prefix.map(|p| p.join("var").join("zeroclaw"))
|
||||
}
|
||||
|
||||
fn install_macos(config: &Config) -> Result<()> {
|
||||
let file = macos_service_file()?;
|
||||
if let Some(parent) = file.parent() {
|
||||
@@ -381,16 +429,52 @@ fn install_macos(config: &Config) -> Result<()> {
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().context("Failed to resolve current executable")?;
|
||||
let logs_dir = config
|
||||
.config_path
|
||||
.parent()
|
||||
.map_or_else(|| PathBuf::from("."), PathBuf::from)
|
||||
.join("logs");
|
||||
|
||||
// When installed via Homebrew, use the Homebrew var directory for runtime
|
||||
// data so that `brew services start zeroclaw` works out of the box.
|
||||
let homebrew_var_dir = detect_homebrew_var_dir(&exe);
|
||||
if let Some(ref var_dir) = homebrew_var_dir {
|
||||
fs::create_dir_all(var_dir).with_context(|| {
|
||||
format!(
|
||||
"Failed to create Homebrew var directory: {}",
|
||||
var_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let logs_dir = if let Some(ref var_dir) = homebrew_var_dir {
|
||||
var_dir.join("logs")
|
||||
} else {
|
||||
config
|
||||
.config_path
|
||||
.parent()
|
||||
.map_or_else(|| PathBuf::from("."), PathBuf::from)
|
||||
.join("logs")
|
||||
};
|
||||
fs::create_dir_all(&logs_dir)?;
|
||||
|
||||
let stdout = logs_dir.join("daemon.stdout.log");
|
||||
let stderr = logs_dir.join("daemon.stderr.log");
|
||||
|
||||
// When running under Homebrew, inject ZEROCLAW_CONFIG_DIR and
|
||||
// WorkingDirectory so the daemon finds its data in the Homebrew prefix.
|
||||
let env_section = if let Some(ref var_dir) = homebrew_var_dir {
|
||||
format!(
|
||||
r#" <key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>ZEROCLAW_CONFIG_DIR</key>
|
||||
<string>{config_dir}</string>
|
||||
</dict>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{working_dir}</string>
|
||||
"#,
|
||||
config_dir = xml_escape(&var_dir.display().to_string()),
|
||||
working_dir = xml_escape(&var_dir.display().to_string()),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let plist = format!(
|
||||
r#"<?xml version=\"1.0\" encoding=\"UTF-8\"?>
|
||||
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
|
||||
@@ -407,7 +491,7 @@ fn install_macos(config: &Config) -> Result<()> {
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
{env_section} <key>StandardOutPath</key>
|
||||
<string>{stdout}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{stderr}</string>
|
||||
@@ -416,12 +500,16 @@ fn install_macos(config: &Config) -> Result<()> {
|
||||
"#,
|
||||
label = SERVICE_LABEL,
|
||||
exe = xml_escape(&exe.display().to_string()),
|
||||
env_section = env_section,
|
||||
stdout = xml_escape(&stdout.display().to_string()),
|
||||
stderr = xml_escape(&stderr.display().to_string())
|
||||
);
|
||||
|
||||
fs::write(&file, plist)?;
|
||||
println!("✅ Installed launchd service: {}", file.display());
|
||||
if let Some(ref var_dir) = homebrew_var_dir {
|
||||
println!(" Homebrew var: {}", var_dir.display());
|
||||
}
|
||||
println!(" Start with: zeroclaw service start");
|
||||
Ok(())
|
||||
}
|
||||
@@ -473,6 +561,9 @@ fn install_linux_systemd(config: &Config) -> Result<()> {
|
||||
/// Check if the current process is running as root (Unix only)
|
||||
#[cfg(unix)]
|
||||
fn is_root() -> bool {
|
||||
// SAFETY: `getuid()` is a simple system call that returns the real user ID of the calling
|
||||
// process. It is always safe to call as it takes no arguments and returns a scalar value.
|
||||
// This is a well-established pattern in Rust for getting the current user ID.
|
||||
unsafe { libc::getuid() == 0 }
|
||||
}
|
||||
|
||||
@@ -1192,6 +1283,9 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn is_root_matches_system_uid() {
|
||||
// SAFETY: `getuid()` is a simple system call that returns the real user ID of the calling
|
||||
// process. It is always safe to call as it takes no arguments and returns a scalar value.
|
||||
// This test verifies our `is_root()` wrapper returns the same result as the raw syscall.
|
||||
assert_eq!(is_root(), unsafe { libc::getuid() == 0 });
|
||||
}
|
||||
|
||||
@@ -1325,6 +1419,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_homebrew_var_dir_from_cellar_path() {
|
||||
let exe = PathBuf::from("/opt/homebrew/Cellar/zeroclaw/1.2.3/bin/zeroclaw");
|
||||
let var_dir = detect_homebrew_var_dir(&exe);
|
||||
assert_eq!(var_dir, Some(PathBuf::from("/opt/homebrew/var/zeroclaw")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_homebrew_var_dir_intel_cellar_path() {
|
||||
let exe = PathBuf::from("/usr/local/Cellar/zeroclaw/1.0.0/bin/zeroclaw");
|
||||
let var_dir = detect_homebrew_var_dir(&exe);
|
||||
assert_eq!(var_dir, Some(PathBuf::from("/usr/local/var/zeroclaw")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_homebrew_var_dir_non_homebrew_path() {
|
||||
let exe = PathBuf::from("/home/user/.cargo/bin/zeroclaw");
|
||||
let var_dir = detect_homebrew_var_dir(&exe);
|
||||
assert_eq!(var_dir, None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn openrc_writability_probe_falls_back_to_su() {
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
pub struct CalculatorTool;
|
||||
|
||||
impl CalculatorTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CalculatorTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CalculatorTool {
|
||||
fn name(&self) -> &str {
|
||||
"calculator"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Perform arithmetic and statistical calculations. Supports 25 functions: \
|
||||
add, subtract, divide, multiply, pow, sqrt, abs, modulo, round, \
|
||||
log, ln, exp, factorial, sum, average, median, mode, min, max, \
|
||||
range, variance, stdev, percentile, count, percentage_change, clamp. \
|
||||
Use this tool whenever you need to compute a numeric result instead of guessing."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function": {
|
||||
"type": "string",
|
||||
"description": "Calculation to perform. \
|
||||
Arithmetic: add(values), subtract(values), divide(values), multiply(values), pow(a,b), sqrt(x), abs(x), modulo(a,b), round(x,decimals). \
|
||||
Logarithmic/exponential: log(x,base?), ln(x), exp(x), factorial(x). \
|
||||
Aggregation: sum(values), average(values), count(values), min(values), max(values), range(values). \
|
||||
Statistics: median(values), mode(values), variance(values), stdev(values), percentile(values,p). \
|
||||
Utility: percentage_change(a,b), clamp(x,min_val,max_val).",
|
||||
"enum": [
|
||||
"add", "subtract", "divide", "multiply", "pow", "sqrt",
|
||||
"abs", "modulo", "round", "log", "ln", "exp", "factorial",
|
||||
"sum", "average", "median", "mode", "min", "max", "range",
|
||||
"variance", "stdev", "percentile", "count",
|
||||
"percentage_change", "clamp"
|
||||
]
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": { "type": "number" },
|
||||
"description": "Array of numeric values. Required for: add, subtract, divide, multiply, sum, average, median, mode, min, max, range, variance, stdev, percentile, count."
|
||||
},
|
||||
"a": {
|
||||
"type": "number",
|
||||
"description": "First operand. Required for: pow, modulo, percentage_change."
|
||||
},
|
||||
"b": {
|
||||
"type": "number",
|
||||
"description": "Second operand. Required for: pow, modulo, percentage_change."
|
||||
},
|
||||
"x": {
|
||||
"type": "number",
|
||||
"description": "Input number. Required for: sqrt, abs, exp, ln, log, factorial."
|
||||
},
|
||||
"base": {
|
||||
"type": "number",
|
||||
"description": "Logarithm base (default: 10). Optional for: log."
|
||||
},
|
||||
"decimals": {
|
||||
"type": "integer",
|
||||
"description": "Number of decimal places for rounding. Required for: round."
|
||||
},
|
||||
"p": {
|
||||
"type": "integer",
|
||||
"description": "Percentile rank (0-100). Required for: percentile."
|
||||
},
|
||||
"min_val": {
|
||||
"type": "number",
|
||||
"description": "Minimum bound. Required for: clamp."
|
||||
},
|
||||
"max_val": {
|
||||
"type": "number",
|
||||
"description": "Maximum bound. Required for: clamp."
|
||||
}
|
||||
},
|
||||
"required": ["function"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let function = match args.get("function").and_then(|v| v.as_str()) {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Missing required parameter: function".to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let result = match function {
|
||||
"add" => calc_add(&args),
|
||||
"subtract" => calc_subtract(&args),
|
||||
"divide" => calc_divide(&args),
|
||||
"multiply" => calc_multiply(&args),
|
||||
"pow" => calc_pow(&args),
|
||||
"sqrt" => calc_sqrt(&args),
|
||||
"abs" => calc_abs(&args),
|
||||
"modulo" => calc_modulo(&args),
|
||||
"round" => calc_round(&args),
|
||||
"log" => calc_log(&args),
|
||||
"ln" => calc_ln(&args),
|
||||
"exp" => calc_exp(&args),
|
||||
"factorial" => calc_factorial(&args),
|
||||
"sum" => calc_sum(&args),
|
||||
"average" => calc_average(&args),
|
||||
"median" => calc_median(&args),
|
||||
"mode" => calc_mode(&args),
|
||||
"min" => calc_min(&args),
|
||||
"max" => calc_max(&args),
|
||||
"range" => calc_range(&args),
|
||||
"variance" => calc_variance(&args),
|
||||
"stdev" => calc_stdev(&args),
|
||||
"percentile" => calc_percentile(&args),
|
||||
"count" => calc_count(&args),
|
||||
"percentage_change" => calc_percentage_change(&args),
|
||||
"clamp" => calc_clamp(&args),
|
||||
other => Err(format!("Unknown function: {other}")),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(output) => Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
}),
|
||||
Err(err) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(err),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_f64(args: &serde_json::Value, key: &str, name: &str) -> Result<f64, String> {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_f64())
|
||||
.ok_or_else(|| format!("Missing required parameter: {name}"))
|
||||
}
|
||||
|
||||
fn extract_i64(args: &serde_json::Value, key: &str, name: &str) -> Result<i64, String> {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| format!("Missing required parameter: {name}"))
|
||||
}
|
||||
|
||||
fn extract_values(args: &serde_json::Value, min_len: usize) -> Result<Vec<f64>, String> {
|
||||
let values = args
|
||||
.get("values")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| "Missing required parameter: values (array of numbers)".to_string())?;
|
||||
if values.len() < min_len {
|
||||
return Err(format!(
|
||||
"Expected at least {min_len} value(s), got {}",
|
||||
values.len()
|
||||
));
|
||||
}
|
||||
let mut nums = Vec::with_capacity(values.len());
|
||||
for (i, v) in values.iter().enumerate() {
|
||||
match v.as_f64() {
|
||||
Some(n) => nums.push(n),
|
||||
None => return Err(format!("values[{i}] is not a valid number")),
|
||||
}
|
||||
}
|
||||
Ok(nums)
|
||||
}
|
||||
|
||||
fn format_num(n: f64) -> String {
|
||||
if n == n.floor() && n.abs() < 1e15 {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let rounded = n.round() as i128;
|
||||
format!("{rounded}")
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
|
||||
fn calc_add(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 2)?;
|
||||
Ok(format_num(values.iter().sum()))
|
||||
}
|
||||
|
||||
fn calc_subtract(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 2)?;
|
||||
let mut iter = values.iter();
|
||||
let mut result = *iter.next().unwrap();
|
||||
for v in iter {
|
||||
result -= v;
|
||||
}
|
||||
Ok(format_num(result))
|
||||
}
|
||||
|
||||
fn calc_divide(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 2)?;
|
||||
let mut iter = values.iter();
|
||||
let mut result = *iter.next().unwrap();
|
||||
for v in iter {
|
||||
if *v == 0.0 {
|
||||
return Err("Division by zero".to_string());
|
||||
}
|
||||
result /= v;
|
||||
}
|
||||
Ok(format_num(result))
|
||||
}
|
||||
|
||||
fn calc_multiply(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 2)?;
|
||||
let mut result = 1.0;
|
||||
for v in &values {
|
||||
result *= v;
|
||||
}
|
||||
Ok(format_num(result))
|
||||
}
|
||||
|
||||
fn calc_pow(args: &serde_json::Value) -> Result<String, String> {
|
||||
let base = extract_f64(args, "a", "a (base)")?;
|
||||
let exp = extract_f64(args, "b", "b (exponent)")?;
|
||||
Ok(format_num(base.powf(exp)))
|
||||
}
|
||||
|
||||
fn calc_sqrt(args: &serde_json::Value) -> Result<String, String> {
|
||||
let x = extract_f64(args, "x", "x")?;
|
||||
if x < 0.0 {
|
||||
return Err("Cannot compute square root of a negative number".to_string());
|
||||
}
|
||||
Ok(format_num(x.sqrt()))
|
||||
}
|
||||
|
||||
fn calc_abs(args: &serde_json::Value) -> Result<String, String> {
|
||||
let x = extract_f64(args, "x", "x")?;
|
||||
Ok(format_num(x.abs()))
|
||||
}
|
||||
|
||||
fn calc_modulo(args: &serde_json::Value) -> Result<String, String> {
|
||||
let a = extract_f64(args, "a", "a")?;
|
||||
let b = extract_f64(args, "b", "b")?;
|
||||
if b == 0.0 {
|
||||
return Err("Modulo by zero".to_string());
|
||||
}
|
||||
Ok(format_num(a % b))
|
||||
}
|
||||
|
||||
fn calc_round(args: &serde_json::Value) -> Result<String, String> {
|
||||
let x = extract_f64(args, "x", "x")?;
|
||||
let decimals = extract_i64(args, "decimals", "decimals")?;
|
||||
if decimals < 0 {
|
||||
return Err("decimals must be non-negative".to_string());
|
||||
}
|
||||
let multiplier = 10_f64.powi(i32::try_from(decimals).unwrap_or(i32::MAX));
|
||||
Ok(format_num((x * multiplier).round() / multiplier))
|
||||
}
|
||||
|
||||
fn calc_log(args: &serde_json::Value) -> Result<String, String> {
|
||||
let x = extract_f64(args, "x", "x")?;
|
||||
if x <= 0.0 {
|
||||
return Err("Logarithm requires a positive number".to_string());
|
||||
}
|
||||
let base = args.get("base").and_then(|v| v.as_f64()).unwrap_or(10.0);
|
||||
if base <= 0.0 || base == 1.0 {
|
||||
return Err("Logarithm base must be positive and not equal to 1".to_string());
|
||||
}
|
||||
Ok(format_num(x.log(base)))
|
||||
}
|
||||
|
||||
fn calc_ln(args: &serde_json::Value) -> Result<String, String> {
|
||||
let x = extract_f64(args, "x", "x")?;
|
||||
if x <= 0.0 {
|
||||
return Err("Natural logarithm requires a positive number".to_string());
|
||||
}
|
||||
Ok(format_num(x.ln()))
|
||||
}
|
||||
|
||||
fn calc_exp(args: &serde_json::Value) -> Result<String, String> {
|
||||
let x = extract_f64(args, "x", "x")?;
|
||||
Ok(format_num(x.exp()))
|
||||
}
|
||||
|
||||
fn calc_factorial(args: &serde_json::Value) -> Result<String, String> {
|
||||
let x = extract_f64(args, "x", "x")?;
|
||||
if x < 0.0 || x != x.floor() {
|
||||
return Err("Factorial requires a non-negative integer".to_string());
|
||||
}
|
||||
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||
let n = x.round() as u128;
|
||||
if n > 170 {
|
||||
return Err("Factorial result exceeds f64 range (max input: 170)".to_string());
|
||||
}
|
||||
let mut result: u128 = 1;
|
||||
for i in 2..=n {
|
||||
result *= i;
|
||||
}
|
||||
Ok(result.to_string())
|
||||
}
|
||||
|
||||
fn calc_sum(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 1)?;
|
||||
Ok(format_num(values.iter().sum()))
|
||||
}
|
||||
|
||||
fn calc_average(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 1)?;
|
||||
if values.is_empty() {
|
||||
return Err("Cannot compute average of an empty array".to_string());
|
||||
}
|
||||
Ok(format_num(values.iter().sum::<f64>() / values.len() as f64))
|
||||
}
|
||||
|
||||
fn calc_median(args: &serde_json::Value) -> Result<String, String> {
|
||||
let mut values = extract_values(args, 1)?;
|
||||
if values.is_empty() {
|
||||
return Err("Cannot compute median of an empty array".to_string());
|
||||
}
|
||||
values.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let len = values.len();
|
||||
if len % 2 == 0 {
|
||||
Ok(format_num(f64::midpoint(
|
||||
values[len / 2 - 1],
|
||||
values[len / 2],
|
||||
)))
|
||||
} else {
|
||||
Ok(format_num(values[len / 2]))
|
||||
}
|
||||
}
|
||||
|
||||
fn calc_mode(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 1)?;
|
||||
if values.is_empty() {
|
||||
return Err("Cannot compute mode of an empty array".to_string());
|
||||
}
|
||||
let mut freq: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
|
||||
for &v in &values {
|
||||
let key = v.to_bits();
|
||||
*freq.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
let max_freq = *freq.values().max().unwrap();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut modes = Vec::new();
|
||||
for &v in &values {
|
||||
let key = v.to_bits();
|
||||
if freq[&key] == max_freq && seen.insert(key) {
|
||||
modes.push(v);
|
||||
}
|
||||
}
|
||||
if modes.len() == 1 {
|
||||
Ok(format_num(modes[0]))
|
||||
} else {
|
||||
let formatted: Vec<String> = modes.iter().map(|v| format_num(*v)).collect();
|
||||
Ok(format!("Modes: {}", formatted.join(", ")))
|
||||
}
|
||||
}
|
||||
|
||||
fn calc_min(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 1)?;
|
||||
let Some(min_val) = values.iter().copied().reduce(f64::min) else {
|
||||
return Err("Cannot compute min of an empty array".to_string());
|
||||
};
|
||||
Ok(format_num(min_val))
|
||||
}
|
||||
|
||||
fn calc_max(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 1)?;
|
||||
let Some(max_val) = values.iter().copied().reduce(f64::max) else {
|
||||
return Err("Cannot compute max of an empty array".to_string());
|
||||
};
|
||||
Ok(format_num(max_val))
|
||||
}
|
||||
|
||||
fn calc_range(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 1)?;
|
||||
if values.is_empty() {
|
||||
return Err("Cannot compute range of an empty array".to_string());
|
||||
}
|
||||
let min_val = values.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let max_val = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
Ok(format_num(max_val - min_val))
|
||||
}
|
||||
|
||||
fn calc_variance(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 1)?;
|
||||
if values.len() < 2 {
|
||||
return Err("Variance requires at least 2 values".to_string());
|
||||
}
|
||||
let mean = values.iter().sum::<f64>() / values.len() as f64;
|
||||
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
|
||||
Ok(format_num(variance))
|
||||
}
|
||||
|
||||
fn calc_stdev(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 1)?;
|
||||
if values.len() < 2 {
|
||||
return Err("Standard deviation requires at least 2 values".to_string());
|
||||
}
|
||||
let mean = values.iter().sum::<f64>() / values.len() as f64;
|
||||
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
|
||||
Ok(format_num(variance.sqrt()))
|
||||
}
|
||||
|
||||
fn calc_percentile(args: &serde_json::Value) -> Result<String, String> {
|
||||
let mut values = extract_values(args, 1)?;
|
||||
if values.is_empty() {
|
||||
return Err("Cannot compute percentile of an empty array".to_string());
|
||||
}
|
||||
let p = extract_i64(args, "p", "p (percentile rank 0-100)")?;
|
||||
if !(0..=100).contains(&p) {
|
||||
return Err("Percentile rank must be between 0 and 100".to_string());
|
||||
}
|
||||
values.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
|
||||
let idx_f = p as f64 / 100.0 * (values.len() - 1) as f64;
|
||||
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||
let index = idx_f.round().clamp(0.0, (values.len() - 1) as f64) as usize;
|
||||
Ok(format_num(values[index]))
|
||||
}
|
||||
|
||||
fn calc_count(args: &serde_json::Value) -> Result<String, String> {
|
||||
let values = extract_values(args, 1)?;
|
||||
Ok(values.len().to_string())
|
||||
}
|
||||
|
||||
fn calc_percentage_change(args: &serde_json::Value) -> Result<String, String> {
|
||||
let old = extract_f64(args, "a", "a (old value)")?;
|
||||
let new = extract_f64(args, "b", "b (new value)")?;
|
||||
if old == 0.0 {
|
||||
return Err("Cannot compute percentage change from zero".to_string());
|
||||
}
|
||||
Ok(format_num((new - old) / old.abs() * 100.0))
|
||||
}
|
||||
|
||||
fn calc_clamp(args: &serde_json::Value) -> Result<String, String> {
|
||||
let x = extract_f64(args, "x", "x")?;
|
||||
let min_val = extract_f64(args, "min_val", "min_val")?;
|
||||
let max_val = extract_f64(args, "max_val", "max_val")?;
|
||||
if min_val > max_val {
|
||||
return Err("min_val must be less than or equal to max_val".to_string());
|
||||
}
|
||||
Ok(format_num(x.clamp(min_val, max_val)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "add", "values": [1.0, 2.0, 3.5]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "6.5");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_subtract() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "subtract", "values": [10.0, 3.0, 1.5]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "5.5");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_divide() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "divide", "values": [100.0, 4.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "25");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_divide_by_zero() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "divide", "values": [10.0, 0.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("zero"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiply() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "multiply", "values": [3.0, 4.0, 5.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "60");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pow() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "pow", "a": 2.0, "b": 10.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "1024");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sqrt() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "sqrt", "x": 144.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "12");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sqrt_negative() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "sqrt", "x": -4.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_abs() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "abs", "x": -42.5}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "42.5");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_modulo() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "modulo", "a": 17.0, "b": 5.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_round() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "round", "x": 2.715, "decimals": 2}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "2.72");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_base10() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "log", "x": 100.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_custom_base() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "log", "x": 8.0, "base": 2.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "3");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ln() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "ln", "x": 1.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exp() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "exp", "x": 0.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_factorial() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "factorial", "x": 5.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "120");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_average() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "average", "values": [10.0, 20.0, 30.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "20");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_median_odd() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "median", "values": [3.0, 1.0, 2.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_median_even() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "median", "values": [4.0, 1.0, 3.0, 2.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "2.5");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mode() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "mode", "values": [1.0, 2.0, 2.0, 3.0, 3.0, 3.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "3");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_min() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "min", "values": [5.0, 2.0, 8.0, 1.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_max() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "max", "values": [5.0, 2.0, 8.0, 1.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "8");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_range() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "range", "values": [1.0, 5.0, 10.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "9");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_variance() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({"function": "variance", "values": [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "4");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stdev() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({"function": "stdev", "values": [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_percentile_50() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({"function": "percentile", "values": [1.0, 2.0, 3.0, 4.0, 5.0], "p": 50}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "3");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_count() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "count", "values": [1.0, 2.0, 3.0, 4.0, 5.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "5");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_percentage_change() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "percentage_change", "a": 50.0, "b": 75.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "50");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clamp_within_range() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "clamp", "x": 5.0, "min_val": 1.0, "max_val": 10.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "5");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clamp_below_min() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "clamp", "x": -5.0, "min_val": 0.0, "max_val": 10.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clamp_above_max() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "clamp", "x": 15.0, "min_val": 0.0, "max_val": 10.0}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "10");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unknown_function() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool.execute(json!({"function": "unknown"})).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("Unknown function"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sum() {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool
|
||||
.execute(json!({"function": "sum", "values": [1.0, 2.0, 3.0, 4.0, 5.0]}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "15");
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,8 @@ pub fn build_deferred_tools_section(deferred: &DeferredMcpToolSet) -> String {
|
||||
out.push_str("<available-deferred-tools>\n");
|
||||
for stub in &deferred.stubs {
|
||||
out.push_str(&stub.prefixed_name);
|
||||
out.push_str(" - ");
|
||||
out.push_str(&stub.description);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("</available-deferred-tools>\n");
|
||||
@@ -421,8 +423,8 @@ mod tests {
|
||||
};
|
||||
let section = build_deferred_tools_section(&set);
|
||||
assert!(section.contains("<available-deferred-tools>"));
|
||||
assert!(section.contains("fs__read_file"));
|
||||
assert!(section.contains("git__status"));
|
||||
assert!(section.contains("fs__read_file - Read a file"));
|
||||
assert!(section.contains("git__status - Git status"));
|
||||
assert!(section.contains("</available-deferred-tools>"));
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod backup_tool;
|
||||
pub mod browser;
|
||||
pub mod browser_delegate;
|
||||
pub mod browser_open;
|
||||
pub mod calculator;
|
||||
pub mod cli_discovery;
|
||||
pub mod cloud_ops;
|
||||
pub mod cloud_patterns;
|
||||
@@ -86,6 +87,7 @@ pub use browser::{BrowserTool, ComputerUseConfig};
|
||||
#[allow(unused_imports)]
|
||||
pub use browser_delegate::{BrowserDelegateConfig, BrowserDelegateTool};
|
||||
pub use browser_open::BrowserOpenTool;
|
||||
pub use calculator::CalculatorTool;
|
||||
pub use cloud_ops::CloudOpsTool;
|
||||
pub use cloud_patterns::CloudPatternsTool;
|
||||
pub use composio::ComposioTool;
|
||||
@@ -323,6 +325,7 @@ pub fn all_tools_with_runtime(
|
||||
security.clone(),
|
||||
workspace_dir.to_path_buf(),
|
||||
)),
|
||||
Arc::new(CalculatorTool::new()),
|
||||
];
|
||||
|
||||
if matches!(
|
||||
|
||||
@@ -124,6 +124,7 @@ impl Channel for MatrixTestChannel {
|
||||
channel: self.channel_name.clone(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
@@ -564,6 +565,7 @@ fn channel_message_thread_ts_preserved_on_clone() {
|
||||
channel: "slack".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: Some("1700000000.000001".into()),
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
let cloned = msg.clone();
|
||||
@@ -580,6 +582,7 @@ fn channel_message_none_thread_ts_preserved() {
|
||||
channel: "telegram".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
assert!(msg.clone().thread_ts.is_none());
|
||||
@@ -633,6 +636,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "telegram".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"discord" => ChannelMessage {
|
||||
id: "dc_1".into(),
|
||||
@@ -642,6 +646,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "discord".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"slack" => ChannelMessage {
|
||||
id: "sl_1".into(),
|
||||
@@ -651,6 +656,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "slack".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: Some("1700000000.000001".into()),
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"imessage" => ChannelMessage {
|
||||
id: "im_1".into(),
|
||||
@@ -660,6 +666,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "imessage".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"irc" => ChannelMessage {
|
||||
id: "irc_1".into(),
|
||||
@@ -669,6 +676,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "irc".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"email" => ChannelMessage {
|
||||
id: "email_1".into(),
|
||||
@@ -678,6 +686,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "email".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"signal" => ChannelMessage {
|
||||
id: "sig_1".into(),
|
||||
@@ -687,6 +696,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "signal".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"mattermost" => ChannelMessage {
|
||||
id: "mm_1".into(),
|
||||
@@ -696,6 +706,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "mattermost".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: Some("root_msg_id".into()),
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"whatsapp" => ChannelMessage {
|
||||
id: "wa_1".into(),
|
||||
@@ -705,6 +716,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "whatsapp".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"nextcloud_talk" => ChannelMessage {
|
||||
id: "nc_1".into(),
|
||||
@@ -714,6 +726,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "nextcloud_talk".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"wecom" => ChannelMessage {
|
||||
id: "wc_1".into(),
|
||||
@@ -723,6 +736,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "wecom".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"dingtalk" => ChannelMessage {
|
||||
id: "dt_1".into(),
|
||||
@@ -732,6 +746,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "dingtalk".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"qq" => ChannelMessage {
|
||||
id: "qq_1".into(),
|
||||
@@ -741,6 +756,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "qq".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"linq" => ChannelMessage {
|
||||
id: "lq_1".into(),
|
||||
@@ -750,6 +766,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "linq".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"wati" => ChannelMessage {
|
||||
id: "wt_1".into(),
|
||||
@@ -759,6 +776,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "wati".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
"cli" => ChannelMessage {
|
||||
id: "cli_1".into(),
|
||||
@@ -768,6 +786,7 @@ fn make_platform_message(platform: &str) -> ChannelMessage {
|
||||
channel: "cli".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
},
|
||||
_ => panic!("Unknown platform: {platform}"),
|
||||
}
|
||||
@@ -1068,6 +1087,7 @@ fn channel_message_zero_timestamp() {
|
||||
channel: "ch".into(),
|
||||
timestamp: 0,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
assert_eq!(msg.timestamp, 0);
|
||||
}
|
||||
@@ -1082,6 +1102,7 @@ fn channel_message_max_timestamp() {
|
||||
channel: "ch".into(),
|
||||
timestamp: u64::MAX,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
assert_eq!(msg.timestamp, u64::MAX);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ fn channel_message_sender_field_holds_platform_user_id() {
|
||||
channel: "telegram".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(msg.sender, "123456789");
|
||||
@@ -47,6 +48,7 @@ fn channel_message_reply_target_distinct_from_sender() {
|
||||
channel: "discord".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
assert_ne!(
|
||||
@@ -67,6 +69,7 @@ fn channel_message_fields_not_swapped() {
|
||||
channel: "test".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -93,6 +96,7 @@ fn channel_message_preserves_all_fields_on_clone() {
|
||||
channel: "test_channel".into(),
|
||||
timestamp: 1700000001,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
};
|
||||
|
||||
let cloned = original.clone();
|
||||
@@ -186,6 +190,7 @@ impl Channel for CapturingChannel {
|
||||
channel: "capturing".into(),
|
||||
timestamp: 1700000000,
|
||||
thread_ts: None,
|
||||
interruption_scope_id: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
|
||||
@@ -172,12 +172,42 @@ export default function AgentChat() {
|
||||
};
|
||||
|
||||
const handleCopy = useCallback((msgId: string, content: string) => {
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
const onSuccess = () => {
|
||||
setCopiedId(msgId);
|
||||
setTimeout(() => setCopiedId((prev) => (prev === msgId ? null : prev)), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(content).then(onSuccess).catch(() => {
|
||||
// Fallback for insecure contexts (HTTP)
|
||||
fallbackCopy(content) && onSuccess();
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(content) && onSuccess();
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fallback copy using a temporary textarea for HTTP contexts
|
||||
* where navigator.clipboard is unavailable.
|
||||
*/
|
||||
function fallbackCopy(text: string): boolean {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||
{/* Connection status bar */}
|
||||
|
||||
Reference in New Issue
Block a user