Compare commits

...

19 Commits

Author SHA1 Message Date
Tim ecbef64a7c fix(channels): include reply_target in conversation history key (#2891)
conversation_history_key() now includes reply_target to isolate
conversation histories across distinct Discord/Slack/Mattermost
channels for the same sender. Previously all channels produced
the same key {channel}_{sender}, causing cross-channel context bleed.

New key format: {channel}_{reply_target}_{sender} (without thread)
or {channel}_{reply_target}_{thread_ts}_{sender} (with thread).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-19 22:58:06 -04:00
guoraymon 633d711faa fix(agent): prevent memory context duplication by reordering operations (#2649)
Swap auto_save and load_context order to avoid retrieving the just-stored
user message in memory recall.

Before: load_context → auto_save (could retrieve own message)
After: auto_save → load_context (query first, store after)

This fixes the issue where "[Memory context]\n- user_msg: <current>\n<current>"
would appear, causing the user's input to be duplicated.
2026-03-19 22:54:28 -04:00
Argenis 37594fd520 fix(tool): include descriptions in deferred MCP tools system prompt (#4018)
Add tool descriptions alongside names in the deferred tools section so
the LLM can better identify which tool to activate via tool_search.

Original author: @mark-linyb
2026-03-19 22:35:55 -04:00
Octopus 64ecb76773 feat: upgrade MiniMax default model to M2.7 (#3865)
Add MiniMax-M2.7 and M2.7-highspeed to model selection lists. Set M2.7 as the new default for MiniMax, Novita, and Ollama cloud providers. Retain all previous models (M2.5, M2.1, M2) as alternatives.
2026-03-19 22:33:29 -04:00
Eddie's AI Agent 128b33e717 fix(channel): remove dead AtomicU32 fallback in channels mod (#3521)
The conditional cfg branches for AtomicU32 (32-bit fallback) and
AtomicU64 (64-bit) became dead code after portable_atomic::AtomicU64
was adopted in bd757996. The AtomicU32 branch would fail to compile
on 32-bit targets because the import was removed but the usage remained.

Use portable_atomic::AtomicU64 unconditionally, which works on all
targets.

Fixes #3452

Co-authored-by: SpaceLobster <spacelobster@SpaceLobsters-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:33:05 -04:00
Eddie's AI Agent aa6e3024d4 fix(channel): use uppercase IMAGE tag in Matrix channel for multimodal handling (#3523)
Closes #3486

Co-authored-by: SpaceLobster <spacelobster@SpaceLobsters-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:32:48 -04:00
Eddie's AI Agent bc7f797603 fix(channel): align Matrix reply channel key with dispatch key (#3522)
The Matrix channel was setting `channel: format!("matrix:{}", room_id)`
on incoming messages, but the reply router looks up channels by name
using `channels_by_name.get(&msg.channel)` where the key is just
"matrix". This mismatch caused replies to silently drop.

Align with how all other channels (telegram, discord, slack, etc.)
set the channel field to just the channel name.

Closes #3477

Co-authored-by: SpaceLobster <spacelobster@SpaceLobsters-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:32:44 -04:00
Eddie's AI Agent 2d4a1b1ad7 fix(packaging): ensure Homebrew var directory exists on first start (#3524)
* fix(packaging): ensure Homebrew var directory exists on first start

When zeroclaw is installed via Homebrew, the service plist references
/opt/homebrew/var/zeroclaw (or /usr/local/var/zeroclaw on Intel) for
runtime data, but this directory was never created. This caused
`brew services start zeroclaw` to fail on first use.

The fix detects Homebrew installations by checking if the binary lives
under a Homebrew prefix (Cellar path or symlinked bin/ with Cellar
sibling). When detected:

- install_macos() creates the var directory and sets
  ZEROCLAW_CONFIG_DIR + WorkingDirectory in the generated plist
- start() defensively ensures the var directory exists before
  invoking launchctl

Closes #3464

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style(service): fix cargo fmt formatting in Homebrew var dir tests

Collapse multi-line assert_eq! macros to single-line form as
required by cargo fmt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: SpaceLobster <spacelobster@SpaceLobsters-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:32:13 -04:00
Darren.Zeng c7e8f3d235 docs(service): add SAFETY comments to unsafe libc::getuid() calls (#3869)
Add proper SAFETY documentation comments to the two unsafe blocks using
libc::getuid() in src/service/mod.rs:

1. In is_root() function: documents why getuid() is safe to call
2. In is_root_matches_system_uid() test: documents the test's purpose

This addresses the best practices audit finding about unsafe code without
safety comments. While we could use the nix crate's safe wrapper, adding
safety comments is a minimal change that satisfies the audit requirement
without introducing new dependencies.

As noted in refactor-candidates.md, the nix crate alternative would also
be acceptable, but this change follows the principle of minimal intervention
for documentation-only improvements.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-19 22:12:02 -04:00
Darren.Zeng ff5c1c6e9b style: enhance rustfmt.toml with additional stable formatting options (#3870)
Expand rustfmt.toml configuration beyond just 'edition = \"2021\"' to
include additional stable formatting options that improve code consistency:

- max_width = 100: consistent line length
- tab_spaces = 4, hard_tabs = false: standard indentation
- use_field_init_shorthand = true: cleaner struct initialization
- use_try_shorthand = true: cleaner try operator usage
- reorder_imports = true, reorder_modules = true: consistent ordering
- match_arm_leading_pipes = \"Never\": cleaner match syntax

These options are all available on stable Rust and follow common Rust
formatting conventions. This addresses the finding in refactor-candidates.md
about the minimal rustfmt.toml configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-19 22:12:00 -04:00
linyibin b3e3a0a335 fix(config): handle tilde expansion in non-TTY environments (cron) (#3849)
When HOME environment variable is not set (common in cron jobs or
other non-TTY environments), `shellexpand::tilde` returns the literal
`~` unexpanded. This caused SOUL.md, IDENTITY.md, and other workspace
files to not be loaded because the workspace_dir path was invalid.

This fix adds `expand_tilde_path()` helper that falls back to
`directories::UserDirs` when shellexpand fails to expand tilde.
If both methods fail, a warning is logged advising users to use
absolute paths or set HOME explicitly in cron environments.

Closes #3819
2026-03-19 22:10:59 -04:00
linyibin 4d89f27f21 fix(docker): remove conflicting .dockerignore entries (closes #3836) (#3844)
The Dockerfile requires firmware/ and crates/robot-kit/ for the build:
- crates/robot-kit/Cargo.toml is needed for workspace manifest parsing
- firmware/ is copied as part of the build context

These entries in .dockerignore caused COPY commands to fail with
"file not found" errors, resulting in 100% Docker build failure.

Made-with: Cursor
2026-03-19 22:10:56 -04:00
dependabot[bot] 8d8d010196 chore(deps): bump indicatif from 0.17.11 to 0.18.4 (#3857)
Bumps [indicatif](https://github.com/console-rs/indicatif) from 0.17.11 to 0.18.4.
- [Release notes](https://github.com/console-rs/indicatif/releases)
- [Commits](https://github.com/console-rs/indicatif/compare/0.17.11...0.18.4)

---
updated-dependencies:
- dependency-name: indicatif
  dependency-version: 0.18.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 22:10:09 -04:00
dependabot[bot] ff9904c740 chore(deps): bump tokio-tungstenite from 0.28.0 to 0.29.0 (#3858)
Bumps [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite) from 0.28.0 to 0.29.0.
- [Changelog](https://github.com/snapview/tokio-tungstenite/blob/master/CHANGELOG.md)
- [Commits](https://github.com/snapview/tokio-tungstenite/compare/v0.28.0...v0.29.0)

---
updated-dependencies:
- dependency-name: tokio-tungstenite
  dependency-version: 0.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 22:10:06 -04:00
Argenis e411e80acb fix(provider): disable native tool calling for Venice (#4016)
Venice's API accepts the OpenAI-compatible tool format without error
but models ignore the tool specs and hallucinate tool usage in prose
instead. Disable native tool calling so Venice uses prompt-guided
tools, which work reliably.

Add without_native_tools() builder method to OpenAiCompatibleProvider
for providers that support system messages but not native function
calling.

Closes #4007
2026-03-19 21:48:38 -04:00
Argenis 073c8cccbc fix(web): add clipboard fallback for HTTP contexts (#4015)
The copy button in the web dashboard silently failed when served over
HTTP because navigator.clipboard requires a secure context (HTTPS).
Add a textarea-based fallback using document.execCommand('copy') and
proper error handling for the clipboard promise.

Closes #4008
2026-03-19 21:48:35 -04:00
Argenis 58be05a6e0 fix(channel): clear persisted JSONL session on /new command (#4014)
The /new command only cleared in-memory conversation history but left
the JSONL session file on disk. On daemon restart, stale history was
rehydrated, negating the user's session reset. This caused context
pollution and degraded tool calling reliability.

Add delete_session() to SessionStore and call it from the /new handler
so both in-memory and persisted state are cleared.

Closes #4009
2026-03-19 21:48:33 -04:00
Argenis c794d54821 feat(tools): add calculator tool with arithmetic and statistical functions (#4012)
Provides 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)
as a single always-enabled tool to avoid LLM miscalculations and reduce
token waste on numeric tasks.

Co-authored-by: Anatolii <anatolii@Anatoliis-MacBook.local>
2026-03-19 21:40:27 -04:00
Argenis 5f0f88de3d fix(channel): add interruption_scope_id for thread-aware cancellation scoping (#4017)
Add interruption_scope_id to ChannelMessage for thread-aware cancellation. Slack genuine thread replies and Matrix threads get scoped keys, preventing cross-thread cancellation. All other channels preserve existing behavior.

Supersedes #3900. Depends on #3891.
2026-03-19 21:34:04 -04:00
45 changed files with 1525 additions and 95 deletions
-4
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+14
View File
@@ -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
View File
@@ -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}")
+1
View File
@@ -251,6 +251,7 @@ impl BlueskyChannel {
channel: "bluesky".to_string(),
timestamp,
thread_ts: Some(notif.uri.clone()),
interruption_scope_id: None,
})
}
+3
View File
@@ -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);
+1
View File
@@ -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() {
+1
View File
@@ -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() {
+1
View File
@@ -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() {
+1
View File
@@ -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() {
+1
View File
@@ -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() {
+2
View File
@@ -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
+1
View File
@@ -267,6 +267,7 @@ impl LinqChannel {
channel: "linq".to_string(),
timestamp,
thread_ts: None,
interruption_scope_id: None,
});
messages
+9 -2
View File
@@ -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;
+1
View File
@@ -322,6 +322,7 @@ impl MattermostChannel {
#[allow(clippy::cast_sign_loss)]
timestamp: (create_at / 1000) as u64,
thread_ts: None,
interruption_scope_id: None,
})
}
}
+1
View File
@@ -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
View File
@@ -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:?}"
);
}
}
+2
View File
@@ -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
+1
View File
@@ -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");
+1
View File
@@ -360,6 +360,7 @@ impl Channel for NotionChannel {
channel: "notion".into(),
timestamp,
thread_ts: None,
interruption_scope_id: None,
})
.await
.is_err()
+2
View File
@@ -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() {
+1
View File
@@ -225,6 +225,7 @@ impl RedditChannel {
channel: "reddit".to_string(),
timestamp,
thread_ts: item.parent_id.clone(),
interruption_scope_id: None,
})
}
}
+54
View File
@@ -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());
}
}
+1
View File
@@ -266,6 +266,7 @@ impl SignalChannel {
channel: "signal".to_string(),
timestamp: timestamp / 1000, // millis → secs
thread_ts: None,
interruption_scope_id: None,
})
}
}
+20
View File
@@ -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() {
+3
View File
@@ -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,
})
}
+7
View File
@@ -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();
+1
View File
@@ -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() {
+1
View File
@@ -163,6 +163,7 @@ impl WatiChannel {
channel: "wati".to_string(),
timestamp,
thread_ts: None,
interruption_scope_id: None,
});
messages
+1
View File
@@ -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() {
+1
View File
@@ -142,6 +142,7 @@ impl WhatsAppChannel {
channel: "whatsapp".to_string(),
timestamp,
thread_ts: None,
interruption_scope_id: None,
});
}
}
+1
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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
View File
@@ -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");
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+824
View File
@@ -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");
}
}
+4 -2
View File
@@ -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>"));
}
+3
View File
@@ -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!(
+21
View File
@@ -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);
}
+5
View File
@@ -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()))
+32 -2
View File
@@ -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 */}