mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cce1eb3fb | ||
|
|
c89958b66d | ||
|
|
b0a92456bf | ||
|
|
67bbcc623d | ||
|
|
a91bfc0e9c | ||
|
|
948117d5de | ||
|
|
2dedab2a8b | ||
|
|
8642c4d442 | ||
|
|
46a6eb33d9 | ||
|
|
99b4ce2931 | ||
|
|
87932f5da0 | ||
|
|
15ed29c667 | ||
|
|
1d1bf0fb09 | ||
|
|
d3363142b2 | ||
|
|
76929a41aa | ||
|
|
fe34a37e6f | ||
|
|
4b63eb18cc | ||
|
|
fe21d4b4df | ||
|
|
f52bc53e47 | ||
|
|
10f7ee1885 | ||
|
|
7bc6591338 | ||
|
|
53f2066945 | ||
|
|
c69dd84184 | ||
|
|
c1356fc95d | ||
|
|
40bd7e2c11 | ||
|
|
a7197d7b97 | ||
|
|
bc26d5e8c3 | ||
|
|
84d90ad342 | ||
|
|
9fee63d58c | ||
|
|
40903cceee | ||
|
|
e97eb6fff3 | ||
|
|
de8a692036 | ||
|
|
c9701627a9 | ||
|
|
643a22b295 |
@@ -204,7 +204,7 @@ jobs:
|
||||
$hash = (Get-FileHash "openfang-${{ matrix.target }}.zip" -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash openfang-${{ matrix.target }}.zip" | Out-File -Encoding ASCII "openfang-${{ matrix.target }}.zip.sha256"
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: openfang-${{ matrix.target }}.*
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
## Health Stack
|
||||
|
||||
- typecheck: cargo build --workspace --lib
|
||||
- lint: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- test: cargo test --workspace
|
||||
- shell: shellcheck scripts/install.sh
|
||||
Generated
+39
-39
@@ -139,7 +139,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -893,7 +893,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1555,7 +1555,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1805,7 +1805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2743,7 +2743,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3255,9 +3255,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.20"
|
||||
version = "0.11.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "471816f3e24b85e820dee02cde962379ea1a669e5242f19c61bcbcffedf4c4fb"
|
||||
checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -3320,9 +3320,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -3739,7 +3739,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3959,7 +3959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
@@ -4001,7 +4001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"async-trait",
|
||||
@@ -4040,7 +4040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4068,7 +4068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -4094,7 +4094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4122,7 +4122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4140,7 +4140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4179,7 +4179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4199,7 +4199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4218,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4254,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4277,7 +4277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bitflags 2.11.0",
|
||||
@@ -4297,7 +4297,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4394,7 +4394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5100,7 +5100,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -5138,7 +5138,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -5704,14 +5704,14 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
version = "0.23.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -5763,7 +5763,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6309,7 +6309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7042,10 +7042,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7617,7 +7617,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7781,9 +7781,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.0"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -8490,7 +8490,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9231,7 +9231,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.1"
|
||||
version = "0.6.4"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
|
||||
<img src="https://img.shields.io/badge/version-0.6.1-green?style=flat-square" alt="v0.6.1" />
|
||||
<img src="https://img.shields.io/badge/tests-2,497%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/version-0.6.4-green?style=flat-square" alt="v0.6.4" />
|
||||
<img src="https://img.shields.io/badge/tests-2,543%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
</p>
|
||||
|
||||
@@ -6287,7 +6287,7 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
|
||||
// Index probe results by provider list position for O(1) lookup
|
||||
let mut probe_map: HashMap<usize, openfang_runtime::provider_health::ProbeResult> =
|
||||
HashMap::with_capacity(local_providers.len());
|
||||
for ((idx, _, _), result) in local_providers.iter().zip(probe_results.into_iter()) {
|
||||
for ((idx, _, _), result) in local_providers.iter().zip(probe_results) {
|
||||
probe_map.insert(*idx, result);
|
||||
}
|
||||
|
||||
|
||||
@@ -3253,7 +3253,7 @@ mark.search-highlight {
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.trader-dashboard {
|
||||
background: var(--bg-card);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 96vw;
|
||||
@@ -3270,7 +3270,7 @@ mark.search-highlight {
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card);
|
||||
background: var(--surface);
|
||||
z-index: 10;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
@@ -3330,6 +3330,7 @@ mark.search-highlight {
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
.trader-chart-title {
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* OpenFang Layout — Grid + Sidebar + Responsive */
|
||||
|
||||
/* Firefox compat: hide x-cloak elements until Alpine.js initializes.
|
||||
Without this, the sidebar flashes hidden in Firefox while Alpine
|
||||
processes the nested x-data scopes for nav sections. */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
</div>
|
||||
|
||||
<div class="app-layout" :class="{ 'focus-mode': $store.app.focusMode }">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
|
||||
<!-- Sidebar — x-cloak prevents Firefox flash-hidden during Alpine init -->
|
||||
<nav class="sidebar" x-cloak :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header-text">
|
||||
<div class="sidebar-logo">
|
||||
@@ -68,8 +68,8 @@
|
||||
<span class="nav-label">Agents</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<!-- x-show + x-cloak: Firefox-safe replacement for nested <template x-if> which has render quirks. -->
|
||||
<div x-show="!collapsed" x-cloak x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'agents' }" @click="navigate('agents')" :aria-current="page === 'agents' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<span class="nav-label">Chat</span>
|
||||
@@ -87,8 +87,7 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
|
||||
<span class="nav-label">Comms</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Automation -->
|
||||
@@ -97,8 +96,7 @@
|
||||
<span class="nav-label">Automation</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<div x-show="!collapsed" x-cloak x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'workflows' }" @click="navigate('workflows')" :aria-current="page === 'workflows' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M6 3v12M18 9a9 9 0 0 1-9 9"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/></svg></span>
|
||||
<span class="nav-label">Workflows</span>
|
||||
@@ -107,8 +105,7 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></span>
|
||||
<span class="nav-label">Scheduler</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extensions -->
|
||||
@@ -117,8 +114,7 @@
|
||||
<span class="nav-label">Extensions</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<div x-show="!collapsed" x-cloak x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'channels' }" @click="navigate('channels')" :aria-current="page === 'channels' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 9h16M4 15h16M10 3l-2 18M16 3l-2 18"/></svg></span>
|
||||
<span class="nav-label">Channels</span>
|
||||
@@ -131,8 +127,7 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M18 11V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2 2 2 0 0 0-2 2v6"/><path d="M10 10.5V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.9-5.7-2.4L3.4 16a2 2 0 0 1 3.2-2.4L8 15"/></svg></span>
|
||||
<span class="nav-label">Hands</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monitor -->
|
||||
@@ -141,8 +136,7 @@
|
||||
<span class="nav-label">Monitor</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<div x-show="!collapsed" x-cloak x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'analytics' }" @click="navigate('analytics')" :aria-current="page === 'analytics' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M18 20V10M12 20V4M6 20v-6"/></svg></span>
|
||||
<span class="nav-label">Analytics</span>
|
||||
@@ -151,8 +145,7 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg></span>
|
||||
<span class="nav-label">Logs</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System -->
|
||||
@@ -161,8 +154,7 @@
|
||||
<span class="nav-label">System</span>
|
||||
<span class="nav-section-chevron" :style="collapsed ? '' : 'transform:rotate(90deg)'">›</span>
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<div x-show="!collapsed" x-cloak x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'runtime' }" @click="navigate('runtime')" :aria-current="page === 'runtime' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></span>
|
||||
<span class="nav-label">Runtime</span>
|
||||
@@ -171,8 +163,7 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg></span>
|
||||
<span class="nav-label">Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +177,7 @@
|
||||
<div class="sidebar-toggle" @click="toggleSidebar()" x-text="sidebarCollapsed ? '\u276F' : '\u276E'"></div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-overlay" @click="mobileMenuOpen = false"></div>
|
||||
<div class="sidebar-overlay" x-cloak @click="mobileMenuOpen = false"></div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
|
||||
@@ -679,6 +679,34 @@ fn sender_user_id(message: &ChannelMessage) -> &str {
|
||||
.unwrap_or(&message.sender.platform_id)
|
||||
}
|
||||
|
||||
/// Extract the channel/conversation ID from a message, for bindings whose
|
||||
/// `match_rule.channel_id` is set.
|
||||
///
|
||||
/// On Discord and Slack, `sender.platform_id` already holds the channel/
|
||||
/// conversation ID (per `discord.rs` and `slack.rs`, where the user ID lives
|
||||
/// in metadata under `sender_user_id`). On other adapters where the platform
|
||||
/// ID is the user, callers can opt-in by stashing the channel ID under the
|
||||
/// `sender_channel_id` metadata key.
|
||||
fn sender_channel_id(message: &ChannelMessage) -> Option<&str> {
|
||||
if let Some(v) = message
|
||||
.metadata
|
||||
.get("sender_channel_id")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
// On Discord/Slack, the metadata `sender_user_id` is set and differs from
|
||||
// `sender.platform_id` — in that case, platform_id IS the channel ID.
|
||||
let user_in_meta = message
|
||||
.metadata
|
||||
.get("sender_user_id")
|
||||
.and_then(|v| v.as_str());
|
||||
match user_in_meta {
|
||||
Some(uid) if uid != message.sender.platform_id => Some(&message.sender.platform_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// If an error contains "Agent not found", try to re-resolve the channel's default agent
|
||||
/// by name (the name stored at bridge startup). Returns `Some(new_id)` on success.
|
||||
async fn try_reresolution(
|
||||
@@ -1001,10 +1029,13 @@ async fn dispatch_message(
|
||||
// Route to agent (standard path).
|
||||
// Use sender_user_id() so user-keyed bindings (peer_id) match for adapters like
|
||||
// Discord/Slack where sender.platform_id is the channel ID, not the user ID.
|
||||
let agent_id = router.resolve(
|
||||
// Pass the channel/conversation ID separately so bindings with `channel_id`
|
||||
// can match (e.g. "messages in Discord channel X → agent Y").
|
||||
let agent_id = router.resolve_with_channel_id(
|
||||
&message.channel,
|
||||
sender_user_id(message),
|
||||
message.sender.openfang_user.as_deref(),
|
||||
sender_channel_id(message),
|
||||
);
|
||||
|
||||
let agent_id = match agent_id {
|
||||
@@ -1444,11 +1475,13 @@ async fn dispatch_with_blocks(
|
||||
prefix_style: PrefixStyle,
|
||||
) {
|
||||
// Route to agent (same logic as text path).
|
||||
// Use sender_user_id() so user-keyed bindings match for Discord/Slack.
|
||||
let agent_id = router.resolve(
|
||||
// Use sender_user_id() so user-keyed bindings match for Discord/Slack;
|
||||
// pass channel_id so per-room bindings match too.
|
||||
let agent_id = router.resolve_with_channel_id(
|
||||
&message.channel,
|
||||
sender_user_id(message),
|
||||
message.sender.openfang_user.as_deref(),
|
||||
sender_channel_id(message),
|
||||
);
|
||||
|
||||
let agent_id = match agent_id {
|
||||
|
||||
@@ -326,19 +326,17 @@ impl ChannelAdapter for IrcAdapter {
|
||||
}
|
||||
|
||||
// RPL_WELCOME (001) — registration complete, join channels
|
||||
"001" => {
|
||||
if !joined {
|
||||
info!("IRC registered as {nick_clone}");
|
||||
for ch in &channels_clone {
|
||||
let join_cmd = format!("JOIN {ch}\r\n");
|
||||
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
|
||||
warn!("IRC JOIN send failed: {e}");
|
||||
break 'inner true;
|
||||
}
|
||||
info!("IRC joining {ch}");
|
||||
"001" if !joined => {
|
||||
info!("IRC registered as {nick_clone}");
|
||||
for ch in &channels_clone {
|
||||
let join_cmd = format!("JOIN {ch}\r\n");
|
||||
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
|
||||
warn!("IRC JOIN send failed: {e}");
|
||||
break 'inner true;
|
||||
}
|
||||
joined = true;
|
||||
info!("IRC joining {ch}");
|
||||
}
|
||||
joined = true;
|
||||
}
|
||||
|
||||
// PRIVMSG — incoming message
|
||||
|
||||
@@ -18,6 +18,10 @@ pub struct BindingContext {
|
||||
pub peer_id: String,
|
||||
/// Guild/server ID.
|
||||
pub guild_id: Option<String>,
|
||||
/// Channel/conversation ID (e.g. Discord channel, Slack conversation,
|
||||
/// Telegram chat, IRC channel name). Populated by bridges so bindings can
|
||||
/// route by room independent of which user posted.
|
||||
pub channel_id: Option<String>,
|
||||
/// User's roles.
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
@@ -143,6 +147,19 @@ impl AgentRouter {
|
||||
channel_type: &ChannelType,
|
||||
platform_user_id: &str,
|
||||
user_key: Option<&str>,
|
||||
) -> Option<AgentId> {
|
||||
self.resolve_with_channel_id(channel_type, platform_user_id, user_key, None)
|
||||
}
|
||||
|
||||
/// Resolve with an explicit channel/conversation ID, so bindings whose
|
||||
/// `match_rule.channel_id` is set can match. Used by bridges that know the
|
||||
/// room/conversation the message arrived in (Discord/Slack/Telegram/IRC).
|
||||
pub fn resolve_with_channel_id(
|
||||
&self,
|
||||
channel_type: &ChannelType,
|
||||
platform_user_id: &str,
|
||||
user_key: Option<&str>,
|
||||
channel_id: Option<&str>,
|
||||
) -> Option<AgentId> {
|
||||
let channel_key = format!("{channel_type:?}");
|
||||
|
||||
@@ -152,6 +169,7 @@ impl AgentRouter {
|
||||
account_id: None,
|
||||
peer_id: platform_user_id.to_string(),
|
||||
guild_id: None,
|
||||
channel_id: channel_id.map(|s| s.to_string()),
|
||||
roles: Vec::new(),
|
||||
};
|
||||
if let Some(agent_id) = self.resolve_binding(&ctx) {
|
||||
@@ -329,6 +347,11 @@ impl AgentRouter {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref cid) = rule.channel_id {
|
||||
if ctx.channel_id.as_ref() != Some(cid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if !rule.roles.is_empty() {
|
||||
// User must have at least one of the specified roles
|
||||
let has_role = rule.roles.iter().any(|r| ctx.roles.contains(r));
|
||||
@@ -639,7 +662,128 @@ mod tests {
|
||||
guild_id: Some("guild".to_string()),
|
||||
roles: vec!["admin".to_string()],
|
||||
account_id: Some("bot".to_string()),
|
||||
channel_id: Some("ch_42".to_string()),
|
||||
};
|
||||
assert_eq!(full.specificity(), 17); // 8+4+2+2+1
|
||||
assert_eq!(full.specificity(), 25); // 8+8+4+2+2+1
|
||||
|
||||
// peer_id alone vs channel_id alone — both worth 8.
|
||||
let peer_only = BindingMatchRule {
|
||||
peer_id: Some("u".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let channel_id_only = BindingMatchRule {
|
||||
channel_id: Some("c".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(peer_only.specificity(), 8);
|
||||
assert_eq!(channel_id_only.specificity(), 8);
|
||||
|
||||
// Combined peer_id + channel_id (16) outranks either alone (8).
|
||||
let peer_and_channel = BindingMatchRule {
|
||||
peer_id: Some("u".to_string()),
|
||||
channel_id: Some("c".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(peer_and_channel.specificity(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_channel_id_match() {
|
||||
// A binding scoped to a specific Discord channel should match messages
|
||||
// from that channel and reject messages from other channels.
|
||||
let router = AgentRouter::new();
|
||||
let agent_id = AgentId::new();
|
||||
router.register_agent("ops-bot".to_string(), agent_id);
|
||||
router.load_bindings(&[AgentBinding {
|
||||
agent: "ops-bot".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel: Some("discord".to_string()),
|
||||
channel_id: Some("1477803840265781391".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
}]);
|
||||
|
||||
// Same channel, any user — matches.
|
||||
let resolved = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"any-user",
|
||||
None,
|
||||
Some("1477803840265781391"),
|
||||
);
|
||||
assert_eq!(resolved, Some(agent_id));
|
||||
|
||||
// Different channel — no match.
|
||||
let resolved = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"any-user",
|
||||
None,
|
||||
Some("9999999999999999999"),
|
||||
);
|
||||
assert_eq!(resolved, None);
|
||||
|
||||
// Missing channel_id on the wire — no match (the binding is restrictive).
|
||||
let resolved = router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None);
|
||||
assert_eq!(resolved, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_channel_id_plus_peer_outranks_channel_id_alone() {
|
||||
// user A in #medical → researcher; anyone else in #medical → general.
|
||||
let router = AgentRouter::new();
|
||||
let researcher = AgentId::new();
|
||||
let general = AgentId::new();
|
||||
router.register_agent("researcher".to_string(), researcher);
|
||||
router.register_agent("general".to_string(), general);
|
||||
router.load_bindings(&[
|
||||
AgentBinding {
|
||||
agent: "general".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel_id: Some("ch-medical".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
AgentBinding {
|
||||
agent: "researcher".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel_id: Some("ch-medical".to_string()),
|
||||
peer_id: Some("user-a".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// user-a in #medical → researcher (more specific wins)
|
||||
let r = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"user-a",
|
||||
None,
|
||||
Some("ch-medical"),
|
||||
);
|
||||
assert_eq!(r, Some(researcher));
|
||||
|
||||
// user-b in #medical → general (channel_id alone matches)
|
||||
let r = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"user-b",
|
||||
None,
|
||||
Some("ch-medical"),
|
||||
);
|
||||
assert_eq!(r, Some(general));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_match_rule_unknown_field_rejected() {
|
||||
// Typos like `channnel_id` must fail loudly at deserialization rather
|
||||
// than silently producing a wide-open binding. This is the highest-
|
||||
// leverage line in the patch from issue #1127.
|
||||
let bad = r#"{ "channnel_id": "ch-1" }"#;
|
||||
let r: Result<openfang_types::config::BindingMatchRule, _> = serde_json::from_str(bad);
|
||||
assert!(r.is_err(), "unknown field must be rejected by serde");
|
||||
|
||||
// Sanity: known fields still parse.
|
||||
let good = r#"{ "channel_id": "ch-1", "channel": "discord" }"#;
|
||||
let r: openfang_types::config::BindingMatchRule = serde_json::from_str(good).unwrap();
|
||||
assert_eq!(r.channel_id.as_deref(), Some("ch-1"));
|
||||
assert_eq!(r.channel.as_deref(), Some("discord"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,38 @@ const SLACK_API_BASE: &str = "https://slack.com/api";
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
const SLACK_MSG_LIMIT: usize = 3000;
|
||||
/// TTL for envelope_id dedup entries. Well above the typical Slack
|
||||
/// connection-rotation overlap window (< 10s).
|
||||
const ENVELOPE_TTL: Duration = Duration::from_secs(60);
|
||||
/// Soft cap on the dedup cache size. When exceeded we GC expired entries.
|
||||
/// Recent envelope IDs are not reused by Slack, so 10k is more than enough.
|
||||
const ENVELOPE_CACHE_CAP: usize = 10_000;
|
||||
|
||||
/// Returns true if `envelope_id` was already seen within `ENVELOPE_TTL`.
|
||||
/// On first sight, records the timestamp and returns false. Performs
|
||||
/// opportunistic GC of expired entries when the cache grows large.
|
||||
///
|
||||
/// Slack Socket Mode delivers the same event to multiple active WebSocket
|
||||
/// connections during connection rotation. Apps must dedupe on `envelope_id`
|
||||
/// to avoid double-processing.
|
||||
fn is_duplicate_envelope(cache: &DashMap<String, Instant>, envelope_id: &str) -> bool {
|
||||
if envelope_id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Opportunistic GC: bound growth without per-call work.
|
||||
if cache.len() > ENVELOPE_CACHE_CAP {
|
||||
cache.retain(|_, ts| ts.elapsed() < ENVELOPE_TTL);
|
||||
}
|
||||
|
||||
if let Some(prev) = cache.get(envelope_id) {
|
||||
if prev.elapsed() < ENVELOPE_TTL {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
cache.insert(envelope_id.to_string(), Instant::now());
|
||||
false
|
||||
}
|
||||
|
||||
/// Slack Socket Mode adapter.
|
||||
pub struct SlackAdapter {
|
||||
@@ -41,6 +73,9 @@ pub struct SlackAdapter {
|
||||
auto_thread_reply: bool,
|
||||
/// Whether to unfurl (expand previews for) links in posted messages.
|
||||
unfurl_links: bool,
|
||||
/// Recently-seen envelope_ids. Slack Socket Mode redelivers the same event
|
||||
/// across rotated WebSocket connections; this prevents double-processing.
|
||||
seen_envelopes: Arc<DashMap<String, Instant>>,
|
||||
}
|
||||
|
||||
impl SlackAdapter {
|
||||
@@ -65,6 +100,7 @@ impl SlackAdapter {
|
||||
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
|
||||
auto_thread_reply,
|
||||
unfurl_links,
|
||||
seen_envelopes: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +197,7 @@ impl ChannelAdapter for SlackAdapter {
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
let active_threads = self.active_threads.clone();
|
||||
let auto_thread_reply = self.auto_thread_reply;
|
||||
let seen_envelopes = self.seen_envelopes.clone();
|
||||
|
||||
// Spawn periodic cleanup of expired thread entries.
|
||||
{
|
||||
@@ -288,6 +325,16 @@ impl ChannelAdapter for SlackAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup: Slack redelivers the same event on the new
|
||||
// connection during the rotation overlap. Ack on
|
||||
// both, but only forward to the agent once.
|
||||
if is_duplicate_envelope(&seen_envelopes, envelope_id) {
|
||||
debug!(
|
||||
"Slack: skipping duplicate envelope_id {envelope_id}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the event
|
||||
let event = &payload["payload"]["event"];
|
||||
if let Some(msg) = parse_slack_event(
|
||||
@@ -745,4 +792,58 @@ mod tests {
|
||||
);
|
||||
assert!(!adapter.unfurl_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_skips_second_delivery() {
|
||||
// Simulates Slack redelivering the same event across a connection
|
||||
// rotation: the envelope is acked on both connections but the agent
|
||||
// must only see it once.
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
let envelope_id = "8d2e1c5a-4f3b-49a1-b6e2-7c0a9f1234ab";
|
||||
|
||||
// First delivery on the old connection: not a duplicate, forward.
|
||||
assert!(
|
||||
!is_duplicate_envelope(&cache, envelope_id),
|
||||
"first sight of envelope must not be flagged as duplicate"
|
||||
);
|
||||
|
||||
// Second delivery on the new connection: duplicate, skip.
|
||||
assert!(
|
||||
is_duplicate_envelope(&cache, envelope_id),
|
||||
"second sight of same envelope must be flagged as duplicate"
|
||||
);
|
||||
|
||||
// Simulate the receive-loop pattern: count how many times the agent
|
||||
// would actually be invoked across two deliveries.
|
||||
let mut agent_invocations = 0;
|
||||
for _delivery in 0..2 {
|
||||
if !is_duplicate_envelope(&cache, envelope_id) {
|
||||
agent_invocations += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
agent_invocations, 0,
|
||||
"after initial double-delivery, no further invocations should occur within TTL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_distinct_ids_pass_through() {
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-a"));
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-b"));
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-c"));
|
||||
// Each unique envelope_id should be seen exactly once.
|
||||
assert_eq!(cache.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_empty_id_never_dedupes() {
|
||||
// Defensive: malformed payloads with no envelope_id should not poison
|
||||
// the cache or short-circuit forwarding.
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
assert!(!is_duplicate_envelope(&cache, ""));
|
||||
assert!(!is_duplicate_envelope(&cache, ""));
|
||||
assert_eq!(cache.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ use crate::types::{
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -49,6 +49,21 @@ pub struct TelegramAdapter {
|
||||
/// Bot username (without @), populated from `getMe` during `start()`.
|
||||
/// Used for @mention detection in group messages.
|
||||
bot_username: Arc<tokio::sync::RwLock<Option<String>>>,
|
||||
/// `(chat_id, emoji)` pairs that Telegram has rejected with a terminal
|
||||
/// `setMessageReaction` error for this bot instance. Checked before
|
||||
/// issuing the API call so we don't keep retrying reactions that will
|
||||
/// never succeed in that chat. Keyed by chat so that an emoji restricted
|
||||
/// in one chat can still be attempted in another (`Chat.available_reactions`
|
||||
/// can differ per chat and is settable by admins).
|
||||
///
|
||||
/// Cached errors: `REACTION_INVALID` (emoji not in the free-reaction
|
||||
/// allowlist, or not a valid reaction at all) and `REACTION_NOT_AVAILABLE`
|
||||
/// (chat admin restricted this emoji). Transient errors (429, 5xx,
|
||||
/// `REACTION_TOO_MANY` per-message rate-limit, unrelated 400s) are NOT
|
||||
/// cached. Grows monotonically over process lifetime; cache resets on
|
||||
/// restart, which is fine because admins can change allowed reactions at
|
||||
/// any time.
|
||||
rejected_reactions: Arc<Mutex<HashSet<(i64, String)>>>,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
}
|
||||
@@ -77,6 +92,7 @@ impl TelegramAdapter {
|
||||
poll_interval,
|
||||
api_base_url,
|
||||
bot_username: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
rejected_reactions: Arc::new(Mutex::new(HashSet::new())),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
}
|
||||
@@ -158,8 +174,20 @@ impl TelegramAdapter {
|
||||
// Any other tag (e.g. <name>, <thinking>) causes a 400 Bad Request.
|
||||
let sanitized = sanitize_telegram_html(text);
|
||||
|
||||
// Telegram has a 4096 character limit per message — split if needed
|
||||
// Telegram has a 4096 character limit per message — split if needed.
|
||||
//
|
||||
// Error semantics for multi-chunk sends match the convention used by
|
||||
// sibling adapters that also call `split_message` (Discord, Gitter,
|
||||
// Mattermost, Nextcloud, Twitch, Pumble): fail loudly if NOTHING was
|
||||
// delivered (first-chunk failure → return Err so the caller knows), but
|
||||
// treat a mid-stream failure as best-effort — warn and continue — so the
|
||||
// user isn't told "send failed" after they've already received
|
||||
// preceding chunks. The motivating bug (HTML parse errors) is always a
|
||||
// first-chunk failure anyway (sanitization/parse_mode applies to the
|
||||
// whole text), so this keeps the fix effective while avoiding a
|
||||
// partial-delivery-then-error regression.
|
||||
let chunks = split_message(&sanitized, 4096);
|
||||
let mut delivered_any = false;
|
||||
for chunk in chunks {
|
||||
let mut body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
@@ -175,7 +203,15 @@ impl TelegramAdapter {
|
||||
if !status.is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendMessage failed ({status}): {body_text}");
|
||||
if !delivered_any {
|
||||
return Err(
|
||||
format!("Telegram sendMessage failed ({status}): {body_text}").into(),
|
||||
);
|
||||
}
|
||||
// Partial delivery already happened; continue on best-effort.
|
||||
continue;
|
||||
}
|
||||
delivered_any = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -201,9 +237,11 @@ impl TelegramAdapter {
|
||||
body["message_thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendPhoto failed: {body_text}");
|
||||
warn!("Telegram sendPhoto failed ({status}): {body_text}");
|
||||
return Err(format!("Telegram sendPhoto failed ({status}): {body_text}").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -230,9 +268,11 @@ impl TelegramAdapter {
|
||||
body["message_thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendDocument failed: {body_text}");
|
||||
warn!("Telegram sendDocument failed ({status}): {body_text}");
|
||||
return Err(format!("Telegram sendDocument failed ({status}): {body_text}").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -268,9 +308,13 @@ impl TelegramAdapter {
|
||||
}
|
||||
|
||||
let resp = self.client.post(&url).multipart(form).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendDocument upload failed: {body_text}");
|
||||
warn!("Telegram sendDocument upload failed ({status}): {body_text}");
|
||||
return Err(
|
||||
format!("Telegram sendDocument upload failed ({status}): {body_text}").into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -291,9 +335,11 @@ impl TelegramAdapter {
|
||||
body["message_thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendVoice failed: {body_text}");
|
||||
warn!("Telegram sendVoice failed ({status}): {body_text}");
|
||||
return Err(format!("Telegram sendVoice failed ({status}): {body_text}").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -320,9 +366,11 @@ impl TelegramAdapter {
|
||||
body["message_thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendLocation failed: {body_text}");
|
||||
warn!("Telegram sendLocation failed ({status}): {body_text}");
|
||||
return Err(format!("Telegram sendLocation failed ({status}): {body_text}").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -356,7 +404,27 @@ impl TelegramAdapter {
|
||||
/// Sets or replaces the bot's emoji reaction on a message. Each new call
|
||||
/// automatically replaces the previous reaction, so there is no need to
|
||||
/// explicitly remove old ones.
|
||||
///
|
||||
/// Telegram restricts non-premium bots to a free-reaction allowlist, and
|
||||
/// chat admins can further restrict allowed reactions per chat via
|
||||
/// `Chat.available_reactions`. Terminal errors
|
||||
/// (`REACTION_INVALID`, `REACTION_NOT_AVAILABLE`) are cached per
|
||||
/// `(chat_id, emoji)` so we don't keep calling the API with reactions
|
||||
/// that will never succeed in that chat. `REACTION_TOO_MANY` is a
|
||||
/// transient per-message rate-limit and is NOT cached. Because two
|
||||
/// concurrent `fire_reaction` calls for the same `(chat_id, emoji)` can
|
||||
/// both pass the cache check before either rejection lands, the first
|
||||
/// rejection may produce up to N duplicate API calls where N is the
|
||||
/// concurrency — this is benign (insert is idempotent) and self-limits
|
||||
/// on the second turn.
|
||||
fn fire_reaction(&self, chat_id: i64, message_id: i64, emoji: &str) {
|
||||
// Short-circuit: (chat_id, emoji) previously rejected for this bot.
|
||||
if let Ok(rejected) = self.rejected_reactions.lock() {
|
||||
if rejected.contains(&(chat_id, emoji.to_string())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"{}/bot{}/setMessageReaction",
|
||||
self.api_base_url,
|
||||
@@ -368,11 +436,23 @@ impl TelegramAdapter {
|
||||
"reaction": [{"type": "emoji", "emoji": emoji}],
|
||||
});
|
||||
let client = self.client.clone();
|
||||
let rejected_cache = self.rejected_reactions.clone();
|
||||
let emoji = emoji.to_string();
|
||||
tokio::spawn(async move {
|
||||
match client.post(&url).json(&body).send().await {
|
||||
Ok(resp) if !resp.status().is_success() => {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
debug!("Telegram setMessageReaction failed: {body_text}");
|
||||
if is_terminal_reaction_error(&body_text) {
|
||||
if let Ok(mut rejected) = rejected_cache.lock() {
|
||||
if rejected.insert((chat_id, emoji.clone())) {
|
||||
debug!(
|
||||
"Telegram: caching rejected reaction (chat={chat_id}, emoji={emoji:?}); \
|
||||
further setMessageReaction calls with this pair will be skipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Telegram setMessageReaction error: {e}");
|
||||
@@ -383,6 +463,16 @@ impl TelegramAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal errors for `setMessageReaction` — retrying with the same
|
||||
/// `(chat, emoji)` pair will not succeed without an outside change (chat
|
||||
/// admin updating `Chat.available_reactions`, bot getting Premium, etc.).
|
||||
/// Callers cache these and stop retrying. Transient errors (429, 5xx,
|
||||
/// `RETRY_AFTER`, `REACTION_TOO_MANY` per-message rate-limit, unrelated
|
||||
/// 400s like `MESSAGE_NOT_MODIFIED`) are NOT included here.
|
||||
fn is_terminal_reaction_error(body_text: &str) -> bool {
|
||||
body_text.contains("REACTION_INVALID") || body_text.contains("REACTION_NOT_AVAILABLE")
|
||||
}
|
||||
|
||||
impl TelegramAdapter {
|
||||
/// Internal helper: send content with optional forum-topic thread_id.
|
||||
///
|
||||
@@ -1909,4 +1999,348 @@ mod tests {
|
||||
}
|
||||
assert!(!msg.metadata.contains_key("reply_to_message_id"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stub Telegram Bot API server for send-path and reaction-cache tests.
|
||||
//
|
||||
// Binds an axum app to an ephemeral port, returns a base URL that the
|
||||
// `TelegramAdapter` can be pointed at via the `api_url` constructor
|
||||
// parameter, and records per-call response fixtures + hit count.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[derive(Default)]
|
||||
struct StubServer {
|
||||
hits: AtomicUsize,
|
||||
responses: std::sync::Mutex<Vec<(u16, String)>>,
|
||||
}
|
||||
|
||||
impl StubServer {
|
||||
fn new(responses: Vec<(u16, &str)>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
hits: AtomicUsize::new(0),
|
||||
responses: std::sync::Mutex::new(
|
||||
responses
|
||||
.into_iter()
|
||||
.map(|(s, b)| (s, b.to_string()))
|
||||
.collect(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn hit_count(&self) -> usize {
|
||||
self.hits.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_stub_server(stub: Arc<StubServer>) -> String {
|
||||
use axum::{http::StatusCode, routing::any, Router};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let stub_for_handler = stub.clone();
|
||||
let app = Router::new().fallback(any(move || {
|
||||
let stub = stub_for_handler.clone();
|
||||
async move {
|
||||
let i = stub.hits.fetch_add(1, Ordering::SeqCst);
|
||||
let responses = stub.responses.lock().unwrap();
|
||||
if i < responses.len() {
|
||||
let (status, body) = responses[i].clone();
|
||||
(
|
||||
StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
body,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
StatusCode::OK,
|
||||
r#"{"ok":true,"result":true}"#.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
format!("http://{}", addr)
|
||||
}
|
||||
|
||||
/// Build an adapter pointed at a stub server, bypassing `start()` (which
|
||||
/// would call `getMe` / `setMyCommands` against the real API).
|
||||
fn test_adapter(api_url: String) -> TelegramAdapter {
|
||||
TelegramAdapter::new(
|
||||
"test:token".to_string(),
|
||||
vec![],
|
||||
Duration::from_millis(10),
|
||||
Some(api_url),
|
||||
)
|
||||
}
|
||||
|
||||
async fn wait_for<F>(mut cond: F, timeout_ms: u64) -> bool
|
||||
where
|
||||
F: FnMut() -> bool,
|
||||
{
|
||||
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if cond() {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
cond()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// send-path error propagation (api_send_message)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_send_message_single_chunk_400_returns_err() {
|
||||
let stub = StubServer::new(vec![(
|
||||
400,
|
||||
r#"{"ok":false,"error_code":400,"description":"Bad Request: can't parse entities"}"#,
|
||||
)]);
|
||||
let base = spawn_stub_server(stub.clone()).await;
|
||||
let adapter = test_adapter(base);
|
||||
|
||||
let result = adapter.api_send_message(12345, "hello", None).await;
|
||||
|
||||
assert!(result.is_err(), "expected Err on single-chunk 400");
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("400"), "err should include status: {err}");
|
||||
assert!(
|
||||
err.contains("can't parse entities"),
|
||||
"err should include body: {err}"
|
||||
);
|
||||
assert_eq!(stub.hit_count(), 1, "expected exactly one POST");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_send_message_single_chunk_200_returns_ok() {
|
||||
let stub = StubServer::new(vec![(200, r#"{"ok":true,"result":{}}"#)]);
|
||||
let base = spawn_stub_server(stub.clone()).await;
|
||||
let adapter = test_adapter(base);
|
||||
|
||||
let result = adapter.api_send_message(12345, "hello", None).await;
|
||||
|
||||
assert!(result.is_ok(), "expected Ok on 200: {result:?}");
|
||||
assert_eq!(stub.hit_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_send_message_first_chunk_fail_returns_err() {
|
||||
// Two-chunk message; first POST fails. Nothing delivered → Err.
|
||||
let big = "a".repeat(5000); // > 4096 → split into two chunks
|
||||
let stub = StubServer::new(vec![
|
||||
(500, r#"{"ok":false,"error_code":500,"description":"server"}"#),
|
||||
(200, r#"{"ok":true,"result":{}}"#),
|
||||
]);
|
||||
let base = spawn_stub_server(stub.clone()).await;
|
||||
let adapter = test_adapter(base);
|
||||
|
||||
let result = adapter.api_send_message(12345, &big, None).await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"first-chunk failure must return Err, got Ok"
|
||||
);
|
||||
// Must have stopped after the failing first chunk — no partial send.
|
||||
assert_eq!(
|
||||
stub.hit_count(),
|
||||
1,
|
||||
"expected adapter to abort after first-chunk failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_send_message_partial_delivery_returns_ok() {
|
||||
// Two-chunk message; first POST succeeds (user sees chunk 1), second
|
||||
// fails. Match sibling-adapter best-effort convention: warn + continue,
|
||||
// return Ok so the agent isn't told total failure after partial success.
|
||||
let big = "a".repeat(5000);
|
||||
let stub = StubServer::new(vec![
|
||||
(200, r#"{"ok":true,"result":{}}"#),
|
||||
(400, r#"{"ok":false,"error_code":400,"description":"some err"}"#),
|
||||
]);
|
||||
let base = spawn_stub_server(stub.clone()).await;
|
||||
let adapter = test_adapter(base);
|
||||
|
||||
let result = adapter.api_send_message(12345, &big, None).await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"partial delivery must return Ok (best-effort), got {result:?}"
|
||||
);
|
||||
assert_eq!(stub.hit_count(), 2, "both chunks should have been attempted");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// reaction cache (fire_reaction + is_terminal_reaction_error)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_is_terminal_reaction_error_matches() {
|
||||
assert!(is_terminal_reaction_error(
|
||||
r#"{"ok":false,"description":"Bad Request: REACTION_INVALID"}"#
|
||||
));
|
||||
assert!(is_terminal_reaction_error(
|
||||
r#"{"description":"Bad Request: REACTION_NOT_AVAILABLE"}"#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_terminal_reaction_error_rejects_transient() {
|
||||
// REACTION_TOO_MANY is a per-message rate-limit, not permanent.
|
||||
// Caching it would suppress valid future reactions on that emoji
|
||||
// for the lifetime of the process — see issue #1133.
|
||||
assert!(!is_terminal_reaction_error(
|
||||
r#"{"description":"Bad Request: REACTION_TOO_MANY"}"#
|
||||
));
|
||||
assert!(!is_terminal_reaction_error(
|
||||
r#"{"description":"Too Many Requests: retry after 5"}"#
|
||||
));
|
||||
assert!(!is_terminal_reaction_error(
|
||||
r#"{"description":"Bad Request: MESSAGE_NOT_MODIFIED"}"#
|
||||
));
|
||||
assert!(!is_terminal_reaction_error(r#"{"ok":true}"#));
|
||||
assert!(!is_terminal_reaction_error(""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fire_reaction_does_not_cache_reaction_too_many() {
|
||||
// Regression test for #1133: REACTION_TOO_MANY is a transient
|
||||
// per-message rate-limit and must NOT be cached as a permanent
|
||||
// rejection. Caching it would suppress valid future reactions on
|
||||
// that (chat_id, emoji) pair for the lifetime of the process.
|
||||
let stub = StubServer::new(vec![(
|
||||
400,
|
||||
r#"{"ok":false,"error_code":400,"description":"Bad Request: REACTION_TOO_MANY"}"#,
|
||||
)]);
|
||||
let base = spawn_stub_server(stub.clone()).await;
|
||||
let adapter = test_adapter(base);
|
||||
|
||||
adapter.fire_reaction(999, 1, "⏳");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(stub.hit_count(), 1);
|
||||
|
||||
let cached = adapter
|
||||
.rejected_reactions
|
||||
.lock()
|
||||
.map(|s| s.contains(&(999_i64, "⏳".to_string())))
|
||||
.unwrap_or(true);
|
||||
assert!(
|
||||
!cached,
|
||||
"REACTION_TOO_MANY is transient and must NOT populate the cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fire_reaction_caches_on_reaction_invalid() {
|
||||
let stub = StubServer::new(vec![(
|
||||
400,
|
||||
r#"{"ok":false,"error_code":400,"description":"Bad Request: REACTION_INVALID"}"#,
|
||||
)]);
|
||||
let base = spawn_stub_server(stub.clone()).await;
|
||||
let adapter = test_adapter(base);
|
||||
|
||||
adapter.fire_reaction(999, 1, "⏳");
|
||||
|
||||
let cached = wait_for(
|
||||
|| {
|
||||
adapter
|
||||
.rejected_reactions
|
||||
.lock()
|
||||
.map(|s| s.contains(&(999_i64, "⏳".to_string())))
|
||||
.unwrap_or(false)
|
||||
},
|
||||
1000,
|
||||
)
|
||||
.await;
|
||||
assert!(cached, "emoji should be cached after REACTION_INVALID");
|
||||
assert_eq!(stub.hit_count(), 1);
|
||||
|
||||
// Second call with same (chat, emoji) must short-circuit.
|
||||
adapter.fire_reaction(999, 2, "⏳");
|
||||
// Give any rogue task time to fire.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(
|
||||
stub.hit_count(),
|
||||
1,
|
||||
"short-circuit should have prevented second POST"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fire_reaction_cache_is_per_chat() {
|
||||
// Same emoji rejected in chat A should NOT short-circuit in chat B.
|
||||
let stub = StubServer::new(vec![
|
||||
(
|
||||
400,
|
||||
r#"{"ok":false,"error_code":400,"description":"Bad Request: REACTION_INVALID"}"#,
|
||||
),
|
||||
(200, r#"{"ok":true,"result":true}"#),
|
||||
]);
|
||||
let base = spawn_stub_server(stub.clone()).await;
|
||||
let adapter = test_adapter(base);
|
||||
|
||||
adapter.fire_reaction(111, 1, "⏳");
|
||||
wait_for(
|
||||
|| {
|
||||
adapter
|
||||
.rejected_reactions
|
||||
.lock()
|
||||
.map(|s| s.contains(&(111_i64, "⏳".to_string())))
|
||||
.unwrap_or(false)
|
||||
},
|
||||
1000,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(stub.hit_count(), 1);
|
||||
|
||||
adapter.fire_reaction(222, 1, "⏳");
|
||||
wait_for(|| stub.hit_count() >= 2, 1000).await;
|
||||
assert_eq!(
|
||||
stub.hit_count(),
|
||||
2,
|
||||
"different chat_id must still fire even when same emoji was cached"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fire_reaction_does_not_cache_non_terminal() {
|
||||
let stub = StubServer::new(vec![(
|
||||
400,
|
||||
r#"{"ok":false,"error_code":400,"description":"Bad Request: MESSAGE_NOT_MODIFIED"}"#,
|
||||
)]);
|
||||
let base = spawn_stub_server(stub.clone()).await;
|
||||
let adapter = test_adapter(base);
|
||||
|
||||
adapter.fire_reaction(999, 1, "⏳");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(stub.hit_count(), 1);
|
||||
|
||||
let cached = adapter
|
||||
.rejected_reactions
|
||||
.lock()
|
||||
.map(|s| s.contains(&(999_i64, "⏳".to_string())))
|
||||
.unwrap_or(true);
|
||||
assert!(!cached, "non-terminal 400 must NOT populate the cache");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fire_reaction_does_not_cache_on_success() {
|
||||
let stub = StubServer::new(vec![(200, r#"{"ok":true,"result":true}"#)]);
|
||||
let base = spawn_stub_server(stub.clone()).await;
|
||||
let adapter = test_adapter(base);
|
||||
|
||||
adapter.fire_reaction(999, 1, "🤔");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(stub.hit_count(), 1);
|
||||
|
||||
let cached = adapter
|
||||
.rejected_reactions
|
||||
.lock()
|
||||
.map(|s| s.contains(&(999_i64, "🤔".to_string())))
|
||||
.unwrap_or(true);
|
||||
assert!(!cached, "successful reaction must NOT populate the cache");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,13 +576,11 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CreateMethod;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.custom_name.is_empty() {
|
||||
if self.custom_desc.is_empty() {
|
||||
self.custom_desc = format!("A custom {} agent", self.custom_name);
|
||||
}
|
||||
self.sub = AgentSubScreen::CustomDesc;
|
||||
KeyCode::Enter if !self.custom_name.is_empty() => {
|
||||
if self.custom_desc.is_empty() {
|
||||
self.custom_desc = format!("A custom {} agent", self.custom_name);
|
||||
}
|
||||
self.sub = AgentSubScreen::CustomDesc;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.custom_name.push(c);
|
||||
@@ -641,15 +639,11 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CustomPrompt;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.tool_cursor > 0 {
|
||||
self.tool_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.tool_cursor > 0 => {
|
||||
self.tool_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.tool_cursor < TOOL_OPTIONS.len() - 1 {
|
||||
self.tool_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if self.tool_cursor < TOOL_OPTIONS.len() - 1 => {
|
||||
self.tool_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
self.tool_checks[self.tool_cursor] = !self.tool_checks[self.tool_cursor];
|
||||
@@ -674,21 +668,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CustomTools;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.skill_cursor > 0 {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.skill_cursor > 0 => {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.skill_cursor < len - 1 {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.skill_cursor < len - 1 => {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Advance to MCP server selection
|
||||
@@ -706,21 +694,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CustomSkills;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.mcp_cursor > 0 {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.mcp_cursor > 0 => {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.mcp_cursor < len - 1 {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.mcp_cursor < len - 1 => {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let toml = self.build_custom_toml();
|
||||
@@ -737,21 +719,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::AgentDetail;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.skill_cursor > 0 {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.skill_cursor > 0 => {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.skill_cursor < len - 1 {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.skill_cursor < len - 1 => {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Save — collect checked skill names (none checked = "all")
|
||||
@@ -780,21 +756,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::AgentDetail;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.mcp_cursor > 0 {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.mcp_cursor > 0 => {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.mcp_cursor < len - 1 {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.mcp_cursor < len - 1 => {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Save — collect checked server names (none checked = "all")
|
||||
|
||||
@@ -164,19 +164,15 @@ impl AuditState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
self.action_filter = self.action_filter.next();
|
||||
|
||||
@@ -155,19 +155,19 @@ impl CommsState {
|
||||
self.task_field = 0;
|
||||
}
|
||||
KeyCode::Char('r') => return CommsAction::Refresh,
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k')
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() =>
|
||||
{
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.events.len();
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j')
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() =>
|
||||
{
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.events.len();
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -189,18 +189,17 @@ impl CommsState {
|
||||
self.send_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
KeyCode::Enter
|
||||
if !self.send_from.is_empty()
|
||||
&& !self.send_to.is_empty()
|
||||
&& !self.send_msg.is_empty()
|
||||
{
|
||||
self.show_send_modal = false;
|
||||
return CommsAction::SendMessage {
|
||||
from: self.send_from.clone(),
|
||||
to: self.send_to.clone(),
|
||||
msg: self.send_msg.clone(),
|
||||
};
|
||||
}
|
||||
&& !self.send_msg.is_empty() =>
|
||||
{
|
||||
self.show_send_modal = false;
|
||||
return CommsAction::SendMessage {
|
||||
from: self.send_from.clone(),
|
||||
to: self.send_to.clone(),
|
||||
msg: self.send_msg.clone(),
|
||||
};
|
||||
}
|
||||
KeyCode::Char(c) => match self.send_field {
|
||||
0 => self.send_from.push(c),
|
||||
@@ -238,15 +237,13 @@ impl CommsState {
|
||||
self.task_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.task_title.is_empty() {
|
||||
self.show_task_modal = false;
|
||||
return CommsAction::PostTask {
|
||||
title: self.task_title.clone(),
|
||||
desc: self.task_desc.clone(),
|
||||
assign: self.task_assign.clone(),
|
||||
};
|
||||
}
|
||||
KeyCode::Enter if !self.task_title.is_empty() => {
|
||||
self.show_task_modal = false;
|
||||
return CommsAction::PostTask {
|
||||
title: self.task_title.clone(),
|
||||
desc: self.task_desc.clone(),
|
||||
assign: self.task_assign.clone(),
|
||||
};
|
||||
}
|
||||
KeyCode::Char(c) => match self.task_field {
|
||||
0 => self.task_title.push(c),
|
||||
|
||||
@@ -152,12 +152,10 @@ impl ExtensionsState {
|
||||
self.sub = ExtSub::Health;
|
||||
return ExtensionsAction::RefreshHealth;
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
if self.sub == ExtSub::Browse {
|
||||
self.searching = true;
|
||||
self.search_query.clear();
|
||||
return ExtensionsAction::Continue;
|
||||
}
|
||||
KeyCode::Char('/') if self.sub == ExtSub::Browse => {
|
||||
self.searching = true;
|
||||
self.search_query.clear();
|
||||
return ExtensionsAction::Continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -172,19 +170,15 @@ impl ExtensionsState {
|
||||
fn handle_browse(&mut self, key: KeyEvent) -> ExtensionsAction {
|
||||
let total = self.filtered().len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let filtered = self.filtered();
|
||||
@@ -222,24 +216,18 @@ impl ExtensionsState {
|
||||
|
||||
let total = self.installed_list_data().len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete => {
|
||||
if self.installed_list.selected().is_some() {
|
||||
self.confirm_remove = true;
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete if self.installed_list.selected().is_some() => {
|
||||
self.confirm_remove = true;
|
||||
}
|
||||
KeyCode::Char('r') => return ExtensionsAction::RefreshAll,
|
||||
_ => {}
|
||||
@@ -250,19 +238,15 @@ impl ExtensionsState {
|
||||
fn handle_health(&mut self, key: KeyEvent) -> ExtensionsAction {
|
||||
let total = self.health_entries.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') | KeyCode::Enter => {
|
||||
if let Some(sel) = self.health_list.selected() {
|
||||
|
||||
@@ -109,19 +109,15 @@ impl HandsState {
|
||||
fn handle_marketplace(&mut self, key: KeyEvent) -> HandsAction {
|
||||
let total = self.definitions.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('a') => {
|
||||
if let Some(sel) = self.marketplace_list.selected() {
|
||||
@@ -157,24 +153,18 @@ impl HandsState {
|
||||
|
||||
let total = self.instances.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete => {
|
||||
if self.active_list.selected().is_some() {
|
||||
self.confirm_deactivate = true;
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete if self.active_list.selected().is_some() => {
|
||||
self.confirm_deactivate = true;
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
if let Some(sel) = self.active_list.selected() {
|
||||
|
||||
@@ -991,16 +991,15 @@ pub fn run() -> InitResult {
|
||||
state.step = Step::Provider;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
KeyCode::Enter
|
||||
if matches!(
|
||||
state.copilot_auth_status,
|
||||
CopilotAuthStatus::WaitingForUser
|
||||
) && !state.copilot_verification_uri.is_empty()
|
||||
{
|
||||
let _ = openfang_runtime::drivers::copilot::open_verification_url(
|
||||
&state.copilot_verification_uri,
|
||||
);
|
||||
}
|
||||
) && !state.copilot_verification_uri.is_empty() =>
|
||||
{
|
||||
let _ = openfang_runtime::drivers::copilot::open_verification_url(
|
||||
&state.copilot_verification_uri,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@@ -1015,41 +1014,36 @@ pub fn run() -> InitResult {
|
||||
state.key_test = KeyTestState::Idle;
|
||||
state.step = Step::Provider;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
KeyCode::Enter
|
||||
if !state.api_key_input.is_empty()
|
||||
&& state.key_test == KeyTestState::Idle
|
||||
{
|
||||
if let Some(p) = state.provider() {
|
||||
let _ = crate::dotenv::save_env_key(
|
||||
p.env_var,
|
||||
&state.api_key_input,
|
||||
);
|
||||
}
|
||||
state.key_test = KeyTestState::Testing;
|
||||
let provider_name = state
|
||||
.provider()
|
||||
.map(|p| p.name.to_string())
|
||||
.unwrap_or_default();
|
||||
let env_var = state
|
||||
.provider()
|
||||
.map(|p| p.env_var.to_string())
|
||||
.unwrap_or_default();
|
||||
let tx = test_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let ok = crate::test_api_key(&provider_name, &env_var);
|
||||
let _ = tx.send(ok);
|
||||
});
|
||||
&& state.key_test == KeyTestState::Idle =>
|
||||
{
|
||||
if let Some(p) = state.provider() {
|
||||
let _ = crate::dotenv::save_env_key(
|
||||
p.env_var,
|
||||
&state.api_key_input,
|
||||
);
|
||||
}
|
||||
state.key_test = KeyTestState::Testing;
|
||||
let provider_name = state
|
||||
.provider()
|
||||
.map(|p| p.name.to_string())
|
||||
.unwrap_or_default();
|
||||
let env_var = state
|
||||
.provider()
|
||||
.map(|p| p.env_var.to_string())
|
||||
.unwrap_or_default();
|
||||
let tx = test_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let ok = crate::test_api_key(&provider_name, &env_var);
|
||||
let _ = tx.send(ok);
|
||||
});
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if state.key_test == KeyTestState::Idle {
|
||||
state.api_key_input.push(c);
|
||||
}
|
||||
KeyCode::Char(c) if state.key_test == KeyTestState::Idle => {
|
||||
state.api_key_input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.key_test == KeyTestState::Idle {
|
||||
state.api_key_input.pop();
|
||||
}
|
||||
KeyCode::Backspace if state.key_test == KeyTestState::Idle => {
|
||||
state.api_key_input.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -211,19 +211,15 @@ impl LogsState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
self.level_filter = self.level_filter.next();
|
||||
@@ -237,15 +233,11 @@ impl LogsState {
|
||||
self.auto_refresh = !self.auto_refresh;
|
||||
}
|
||||
KeyCode::Char('r') => return LogsAction::Refresh,
|
||||
KeyCode::End => {
|
||||
if total > 0 {
|
||||
self.list_state.select(Some(total - 1));
|
||||
}
|
||||
KeyCode::End if total > 0 => {
|
||||
self.list_state.select(Some(total - 1));
|
||||
}
|
||||
KeyCode::Home => {
|
||||
if total > 0 {
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
KeyCode::Home if total > 0 => {
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -106,19 +106,15 @@ impl MemoryState {
|
||||
fn handle_agent_select(&mut self, key: KeyEvent) -> MemoryAction {
|
||||
let total = self.agents.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.agent_list_state.selected() {
|
||||
@@ -166,19 +162,15 @@ impl MemoryState {
|
||||
self.kv_pairs.clear();
|
||||
self.selected_agent = None;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.sub = MemorySub::AddKey;
|
||||
@@ -196,10 +188,8 @@ impl MemoryState {
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if self.kv_list_state.selected().is_some() {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('d') if self.kv_list_state.selected().is_some() => {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if let Some(agent) = &self.selected_agent {
|
||||
|
||||
@@ -62,19 +62,15 @@ impl PeersState {
|
||||
}
|
||||
let total = self.peers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return PeersAction::Refresh,
|
||||
_ => {}
|
||||
|
||||
@@ -130,19 +130,15 @@ impl SessionsState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
@@ -155,10 +151,8 @@ impl SessionsState {
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if self.list_state.selected().is_some() {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('d') if self.list_state.selected().is_some() => {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
self.search_mode = true;
|
||||
|
||||
@@ -174,21 +174,17 @@ impl SettingsState {
|
||||
fn handle_providers(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.providers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
if let Some(sel) = self.provider_list.selected() {
|
||||
@@ -223,19 +219,15 @@ impl SettingsState {
|
||||
fn handle_models(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.models.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return SettingsAction::RefreshModels,
|
||||
_ => {}
|
||||
@@ -246,19 +238,15 @@ impl SettingsState {
|
||||
fn handle_tools(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.tools.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return SettingsAction::RefreshTools,
|
||||
_ => {}
|
||||
|
||||
@@ -192,24 +192,18 @@ impl SkillsState {
|
||||
|
||||
let total = self.installed.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('u') => {
|
||||
if self.installed_list.selected().is_some() {
|
||||
self.confirm_uninstall = true;
|
||||
}
|
||||
KeyCode::Char('u') if self.installed_list.selected().is_some() => {
|
||||
self.confirm_uninstall = true;
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
if let Some(sel) = self.installed_list.selected() {
|
||||
@@ -255,19 +249,15 @@ impl SkillsState {
|
||||
|
||||
let total = self.clawhub_results.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('i') => {
|
||||
if let Some(sel) = self.clawhub_list.selected() {
|
||||
@@ -295,19 +285,15 @@ impl SkillsState {
|
||||
fn handle_mcp(&mut self, key: KeyEvent) -> SkillsAction {
|
||||
let total = self.mcp_servers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return SkillsAction::RefreshMcp,
|
||||
_ => {}
|
||||
|
||||
@@ -194,19 +194,15 @@ impl TemplatesState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
|
||||
@@ -156,19 +156,15 @@ impl TriggerState {
|
||||
self.create_step -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if self.create_step < 5 {
|
||||
self.create_step += 1;
|
||||
}
|
||||
KeyCode::Enter if self.create_step < 5 => {
|
||||
self.create_step += 1;
|
||||
}
|
||||
KeyCode::Char(c) => match self.create_step {
|
||||
0 => self.create_agent_id.push(c),
|
||||
2 => self.create_pattern_param.push(c),
|
||||
3 => self.create_prompt.push(c),
|
||||
4 => {
|
||||
if c.is_ascii_digit() {
|
||||
self.create_max_fires.push(c);
|
||||
}
|
||||
4 if c.is_ascii_digit() => {
|
||||
self.create_max_fires.push(c);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
@@ -111,19 +111,15 @@ impl UsageState {
|
||||
UsageSub::ByModel => {
|
||||
let total = self.by_model.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return UsageAction::Refresh,
|
||||
_ => {}
|
||||
@@ -132,19 +128,15 @@ impl UsageState {
|
||||
UsageSub::ByAgent => {
|
||||
let total = self.by_agent.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return UsageAction::Refresh,
|
||||
_ => {}
|
||||
|
||||
@@ -328,13 +328,11 @@ impl WizardState {
|
||||
KeyCode::Esc => {
|
||||
self.step = WizardStep::Provider;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.api_key_input.is_empty() {
|
||||
if let Some(p) = self.selected_provider_info() {
|
||||
self.model_input = p.default_model.to_string();
|
||||
}
|
||||
self.step = WizardStep::Model;
|
||||
KeyCode::Enter if !self.api_key_input.is_empty() => {
|
||||
if let Some(p) = self.selected_provider_info() {
|
||||
self.model_input = p.default_model.to_string();
|
||||
}
|
||||
self.step = WizardStep::Model;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.api_key_input.push(c);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenFang",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.4",
|
||||
"identifier": "ai.openfang.desktop",
|
||||
"build": {},
|
||||
"app": {
|
||||
|
||||
@@ -483,6 +483,79 @@ mod tests {
|
||||
assert!(plan.hot_actions.contains(&HotAction::ReloadProviderUrls));
|
||||
}
|
||||
|
||||
/// #1129: editing `[default_model].subprocess_timeout_secs` must produce
|
||||
/// a hot-reload action so cross-message timeout retunes don't require
|
||||
/// a daemon bounce. The whole `default_model` block round-trips through
|
||||
/// `UpdateDefaultModel`, which carries the new timeout into the override
|
||||
/// slot read by `resolve_driver`.
|
||||
#[test]
|
||||
fn test_default_model_subprocess_timeout_hot_reload() {
|
||||
let a = default_cfg();
|
||||
let mut b = default_cfg();
|
||||
b.default_model.subprocess_timeout_secs = Some(900);
|
||||
let plan = build_reload_plan(&a, &b);
|
||||
assert!(
|
||||
!plan.restart_required,
|
||||
"subprocess_timeout_secs edits on default_model must be hot-reloadable"
|
||||
);
|
||||
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
|
||||
}
|
||||
|
||||
/// #1129: editing `[[fallback_providers]]` (including
|
||||
/// `subprocess_timeout_secs` on a non-default provider) must produce a
|
||||
/// `ReloadFallbackProviders` hot-action. Without this, mixed-fleet
|
||||
/// operators have no live tuning knob for their non-default driver.
|
||||
#[test]
|
||||
fn test_fallback_providers_subprocess_timeout_hot_reload() {
|
||||
use openfang_types::config::FallbackProviderConfig;
|
||||
let mut a = default_cfg();
|
||||
let mut b = default_cfg();
|
||||
a.fallback_providers.push(FallbackProviderConfig {
|
||||
provider: "codex".to_string(),
|
||||
model: "gpt-5-codex".to_string(),
|
||||
api_key_env: String::new(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: Some(120),
|
||||
});
|
||||
b.fallback_providers.push(FallbackProviderConfig {
|
||||
provider: "codex".to_string(),
|
||||
model: "gpt-5-codex".to_string(),
|
||||
api_key_env: String::new(),
|
||||
base_url: None,
|
||||
// Operator raises the ceiling for slow Codex turns.
|
||||
subprocess_timeout_secs: Some(900),
|
||||
});
|
||||
let plan = build_reload_plan(&a, &b);
|
||||
assert!(
|
||||
!plan.restart_required,
|
||||
"[[fallback_providers]] edits must be hot-reloadable"
|
||||
);
|
||||
assert!(plan
|
||||
.hot_actions
|
||||
.contains(&HotAction::ReloadFallbackProviders));
|
||||
}
|
||||
|
||||
/// #1129: adding a brand-new `[[fallback_providers]]` entry on reload also
|
||||
/// emits the hot-action so the new provider is picked up without bounce.
|
||||
#[test]
|
||||
fn test_fallback_providers_add_entry_hot_reload() {
|
||||
use openfang_types::config::FallbackProviderConfig;
|
||||
let a = default_cfg();
|
||||
let mut b = default_cfg();
|
||||
b.fallback_providers.push(FallbackProviderConfig {
|
||||
provider: "ollama".to_string(),
|
||||
model: "llama3.2:latest".to_string(),
|
||||
api_key_env: String::new(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: Some(300),
|
||||
});
|
||||
let plan = build_reload_plan(&a, &b);
|
||||
assert!(!plan.restart_required);
|
||||
assert!(plan
|
||||
.hot_actions
|
||||
.contains(&HotAction::ReloadFallbackProviders));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mixed changes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -164,6 +164,16 @@ pub struct OpenFangKernel {
|
||||
/// Hot-reloadable default model override (set via config hot-reload, read at agent spawn).
|
||||
pub default_model_override:
|
||||
std::sync::RwLock<Option<openfang_types::config::DefaultModelConfig>>,
|
||||
/// Hot-reloadable fallback provider chain override.
|
||||
///
|
||||
/// Set by `apply_hot_actions(ReloadFallbackProviders)` when
|
||||
/// `[[fallback_providers]]` changes in `config.toml`. `resolve_driver`
|
||||
/// reads this in preference to `self.config.fallback_providers`, so
|
||||
/// timeout edits and provider list mutations take effect on the next
|
||||
/// driver build without a daemon bounce. `None` means "fall back to the
|
||||
/// boot-time `self.config.fallback_providers`". (#1129)
|
||||
pub fallback_providers_override:
|
||||
std::sync::RwLock<Option<Vec<openfang_types::config::FallbackProviderConfig>>>,
|
||||
/// Per-agent message locks — serializes LLM calls for the same agent to prevent
|
||||
/// session corruption when multiple messages arrive concurrently (e.g. rapid voice
|
||||
/// messages via Telegram). Different agents can still run in parallel.
|
||||
@@ -1174,6 +1184,7 @@ impl OpenFangKernel {
|
||||
whatsapp_gateway_pid: Arc::new(std::sync::Mutex::new(None)),
|
||||
channel_adapters: dashmap::DashMap::new(),
|
||||
default_model_override: std::sync::RwLock::new(None),
|
||||
fallback_providers_override: std::sync::RwLock::new(None),
|
||||
agent_msg_locks: dashmap::DashMap::new(),
|
||||
self_handle: OnceLock::new(),
|
||||
};
|
||||
@@ -1186,6 +1197,15 @@ impl OpenFangKernel {
|
||||
let agent_id = entry.id;
|
||||
let name = entry.name.clone();
|
||||
|
||||
// Track whether on-disk agent.toml explicitly defines an
|
||||
// exec_policy override. If it does, that's the per-agent
|
||||
// setting. If not, the kernel's current config.exec_policy
|
||||
// is authoritative and must overwrite the stale DB value
|
||||
// (fixes #1132: changing config.toml exec_policy.mode = "full"
|
||||
// had no effect on agents whose manifests cached the older
|
||||
// inherited Allowlist policy at spawn time).
|
||||
let mut disk_has_exec_policy_override = false;
|
||||
|
||||
// Check if TOML on disk is newer/different — if so, update from file
|
||||
let mut entry = entry;
|
||||
let toml_path = kernel
|
||||
@@ -1201,6 +1221,12 @@ impl OpenFangKernel {
|
||||
&toml_str,
|
||||
) {
|
||||
Ok(disk_manifest) => {
|
||||
// Capture whether agent.toml defines exec_policy
|
||||
// explicitly (so we don't blow it away with the
|
||||
// kernel default below).
|
||||
if disk_manifest.exec_policy.is_some() {
|
||||
disk_has_exec_policy_override = true;
|
||||
}
|
||||
// Compare key fields to detect changes.
|
||||
// IMPORTANT: keep this list in sync with AgentManifest
|
||||
// fields that users may legitimately edit in agent.toml.
|
||||
@@ -1290,8 +1316,24 @@ impl OpenFangKernel {
|
||||
restored_entry.state = AgentState::Running;
|
||||
restored_entry.last_active = chrono::Utc::now();
|
||||
|
||||
// Inherit kernel exec_policy for agents that lack one
|
||||
if restored_entry.manifest.exec_policy.is_none() {
|
||||
// Resolve exec_policy on every restart so that edits to
|
||||
// config.toml's [exec_policy] take effect (fixes #1132).
|
||||
//
|
||||
// Precedence:
|
||||
// 1. agent.toml on disk explicitly sets [exec_policy] →
|
||||
// keep the per-agent override.
|
||||
// 2. otherwise → always re-inherit the kernel's current
|
||||
// config.exec_policy, even if the DB has a cached
|
||||
// value from an earlier boot. The cached value would
|
||||
// otherwise pin the agent to the inherited mode at
|
||||
// first spawn (typically Allowlist) regardless of
|
||||
// later config edits.
|
||||
if !disk_has_exec_policy_override {
|
||||
restored_entry.manifest.exec_policy =
|
||||
Some(kernel.config.exec_policy.clone());
|
||||
} else if restored_entry.manifest.exec_policy.is_none() {
|
||||
// Defensive: should not happen given the flag, but keep
|
||||
// the manifest non-None for the runtime check.
|
||||
restored_entry.manifest.exec_policy =
|
||||
Some(kernel.config.exec_policy.clone());
|
||||
}
|
||||
@@ -2291,6 +2333,7 @@ impl OpenFangKernel {
|
||||
max_memory_bytes: entry.manifest.resources.max_memory_bytes as usize,
|
||||
capabilities: caps,
|
||||
timeout_secs: Some(30),
|
||||
ssrf_allowed_hosts: self.config.web.fetch.ssrf_allowed_hosts.clone(),
|
||||
};
|
||||
|
||||
let input = serde_json::json!({
|
||||
@@ -3045,7 +3088,19 @@ impl OpenFangKernel {
|
||||
if let Some(entry) = self.registry.get(agent_id) {
|
||||
let dir = self.config.home_dir.join("agents").join(&entry.name);
|
||||
let toml_path = dir.join("agent.toml");
|
||||
match toml::to_string_pretty(&entry.manifest) {
|
||||
// Strip exec_policy from the on-disk copy when it matches the
|
||||
// current kernel default (i.e. the agent inherited it). This way,
|
||||
// a later edit to config.toml's [exec_policy] is not silently
|
||||
// shadowed by a stale snapshot we wrote here (#1132).
|
||||
let mut manifest_for_disk = entry.manifest.clone();
|
||||
if manifest_for_disk
|
||||
.exec_policy
|
||||
.as_ref()
|
||||
.is_some_and(|p| p == &self.config.exec_policy)
|
||||
{
|
||||
manifest_for_disk.exec_policy = None;
|
||||
}
|
||||
match toml::to_string_pretty(&manifest_for_disk) {
|
||||
Ok(toml_str) => {
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
warn!(agent = %entry.name, "Failed to create agent dir for manifest persist: {e}");
|
||||
@@ -3590,10 +3645,10 @@ impl OpenFangKernel {
|
||||
for req in &def.requires {
|
||||
match req.requirement_type {
|
||||
openfang_hands::RequirementType::ApiKey
|
||||
| openfang_hands::RequirementType::EnvVar => {
|
||||
if !req.check_value.is_empty() && !allowed_env.contains(&req.check_value) {
|
||||
allowed_env.push(req.check_value.clone());
|
||||
}
|
||||
| openfang_hands::RequirementType::EnvVar
|
||||
if !req.check_value.is_empty() && !allowed_env.contains(&req.check_value) =>
|
||||
{
|
||||
allowed_env.push(req.check_value.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -3800,7 +3855,7 @@ impl OpenFangKernel {
|
||||
let mut bindings = self.bindings.lock().unwrap_or_else(|e| e.into_inner());
|
||||
bindings.push(binding);
|
||||
// Sort by specificity descending
|
||||
bindings.sort_by(|a, b| b.match_rule.specificity().cmp(&a.match_rule.specificity()));
|
||||
bindings.sort_by_key(|b| std::cmp::Reverse(b.match_rule.specificity()));
|
||||
}
|
||||
|
||||
/// Remove a binding by index, returns the removed binding if valid.
|
||||
@@ -3878,8 +3933,10 @@ impl OpenFangKernel {
|
||||
}
|
||||
HotAction::UpdateDefaultModel => {
|
||||
info!(
|
||||
"Hot-reload: updating default model to {}/{}",
|
||||
new_config.default_model.provider, new_config.default_model.model
|
||||
"Hot-reload: updating default model to {}/{} (subprocess_timeout_secs={:?})",
|
||||
new_config.default_model.provider,
|
||||
new_config.default_model.model,
|
||||
new_config.default_model.subprocess_timeout_secs,
|
||||
);
|
||||
let mut guard = self
|
||||
.default_model_override
|
||||
@@ -3887,6 +3944,23 @@ impl OpenFangKernel {
|
||||
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
|
||||
*guard = Some(new_config.default_model.clone());
|
||||
}
|
||||
HotAction::ReloadFallbackProviders => {
|
||||
info!(
|
||||
"Hot-reload: applying fallback provider chain ({} provider(s))",
|
||||
new_config.fallback_providers.len()
|
||||
);
|
||||
for fb in &new_config.fallback_providers {
|
||||
info!(
|
||||
"Hot-reload: fallback provider '{}' subprocess_timeout_secs={:?}",
|
||||
fb.provider, fb.subprocess_timeout_secs,
|
||||
);
|
||||
}
|
||||
let mut guard = self
|
||||
.fallback_providers_override
|
||||
.write()
|
||||
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
|
||||
*guard = Some(new_config.fallback_providers.clone());
|
||||
}
|
||||
_ => {
|
||||
// Other hot actions (channels, web, browser, extensions, etc.)
|
||||
// are logged but not applied here — they require subsystem-specific
|
||||
@@ -5025,6 +5099,19 @@ impl OpenFangKernel {
|
||||
.unwrap_or(&self.config.default_model);
|
||||
let default_provider = &effective_default.provider;
|
||||
|
||||
// Effective fallback provider chain: hot-reloaded override takes priority
|
||||
// over the boot-time `[[fallback_providers]]`. Lets operators retune
|
||||
// `subprocess_timeout_secs` on a non-default provider via
|
||||
// `POST /api/config/reload` without bouncing the daemon (#1129).
|
||||
let fb_override_guard = self
|
||||
.fallback_providers_override
|
||||
.read()
|
||||
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
|
||||
let effective_fallbacks: &[openfang_types::config::FallbackProviderConfig] =
|
||||
fb_override_guard
|
||||
.as_deref()
|
||||
.unwrap_or(&self.config.fallback_providers);
|
||||
|
||||
let has_custom_key = manifest.model.api_key_env.is_some();
|
||||
let has_custom_url = manifest.model.base_url.is_some();
|
||||
|
||||
@@ -5066,20 +5153,30 @@ impl OpenFangKernel {
|
||||
self.lookup_provider_url(agent_provider)
|
||||
};
|
||||
|
||||
// Per-provider timeout resolution for the primary driver:
|
||||
// - Default-provider agent: inherit `[default_model].subprocess_timeout_secs`.
|
||||
// - Cross-provider agent: look up `[[fallback_providers]]` keyed on
|
||||
// `agent_provider` (override-aware) and inherit its timeout. This
|
||||
// closes #1129 Gap 1 — a `codex` agent on a `claude-code`-default
|
||||
// daemon now picks up a `[[fallback_providers]] provider = "codex"`
|
||||
// timeout instead of being silently dropped to `None`.
|
||||
// - No matching fallback entry: leave unset (env var still wins, then
|
||||
// driver default).
|
||||
let primary_timeout = if agent_provider == default_provider {
|
||||
effective_default.subprocess_timeout_secs
|
||||
} else {
|
||||
effective_fallbacks
|
||||
.iter()
|
||||
.find(|fb| &fb.provider == agent_provider)
|
||||
.and_then(|fb| fb.subprocess_timeout_secs)
|
||||
};
|
||||
|
||||
let driver_config = DriverConfig {
|
||||
provider: agent_provider.clone(),
|
||||
api_key,
|
||||
base_url,
|
||||
skip_permissions: true,
|
||||
// Inherit the default-model timeout only when the agent is using the
|
||||
// default provider. If the agent overrides to a different provider,
|
||||
// we have no per-provider config in scope today, so leave it unset
|
||||
// (env var still applies, then driver default).
|
||||
subprocess_timeout_secs: if agent_provider == default_provider {
|
||||
effective_default.subprocess_timeout_secs
|
||||
} else {
|
||||
None
|
||||
},
|
||||
subprocess_timeout_secs: primary_timeout,
|
||||
};
|
||||
|
||||
match drivers::create_driver(&driver_config) {
|
||||
@@ -5178,7 +5275,11 @@ impl OpenFangKernel {
|
||||
// These apply to every agent so that when the primary provider becomes
|
||||
// unreachable at runtime (network failure, daemon shutdown, etc.) the
|
||||
// agent loop fails over to the next provider in the chain. (#1003)
|
||||
for fb in &self.config.fallback_providers {
|
||||
//
|
||||
// Reads from `effective_fallbacks` so that hot-reloaded mutations to
|
||||
// `[[fallback_providers]]` (including `subprocess_timeout_secs`) take
|
||||
// effect on the next driver build without a daemon bounce (#1129).
|
||||
for fb in effective_fallbacks {
|
||||
let fb_api_key = {
|
||||
let env_var = if !fb.api_key_env.is_empty() {
|
||||
fb.api_key_env.clone()
|
||||
@@ -7468,6 +7569,136 @@ mod tests {
|
||||
assert_eq!(merged.workspace, Some(std::path::PathBuf::from("/new")));
|
||||
}
|
||||
|
||||
/// Regression for #1132: editing `[exec_policy] mode = "full"` in
|
||||
/// config.toml must take effect for agents whose persisted manifests
|
||||
/// captured an older inherited policy.
|
||||
///
|
||||
/// Scenario: agent was first spawned when kernel default was `Allowlist`,
|
||||
/// so its DB-cached manifest has `exec_policy = Some(Allowlist)`. The user
|
||||
/// later sets `exec_policy.mode = "full"` in config.toml. On the next
|
||||
/// boot we must replace the cached value with the kernel's current
|
||||
/// `config.exec_policy` unless the user wrote a per-agent override into
|
||||
/// the on-disk `agent.toml`.
|
||||
#[test]
|
||||
fn test_exec_policy_reinherits_from_kernel_config_on_restart() {
|
||||
use openfang_types::config::ExecSecurityMode;
|
||||
|
||||
// Cached manifest from an earlier boot — still Allowlist.
|
||||
let cached_policy = ExecPolicy {
|
||||
mode: ExecSecurityMode::Allowlist,
|
||||
..Default::default()
|
||||
};
|
||||
let mut restored_manifest = AgentManifest {
|
||||
name: "demo".to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
description: "x".to_string(),
|
||||
author: "test".to_string(),
|
||||
module: "builtin:chat".to_string(),
|
||||
schedule: ScheduleMode::default(),
|
||||
model: ModelConfig::default(),
|
||||
fallback_models: vec![],
|
||||
resources: ResourceQuota::default(),
|
||||
priority: Priority::default(),
|
||||
capabilities: ManifestCapabilities::default(),
|
||||
profile: None,
|
||||
tools: HashMap::new(),
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
metadata: HashMap::new(),
|
||||
tags: vec![],
|
||||
routing: None,
|
||||
autonomous: None,
|
||||
pinned_model: None,
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: Some(cached_policy.clone()),
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
cache_context: false,
|
||||
};
|
||||
|
||||
// Current kernel config now says mode = Full.
|
||||
let current_kernel_policy = ExecPolicy {
|
||||
mode: ExecSecurityMode::Full,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Simulate the restoration branch in start_background_agents:
|
||||
// disk had no exec_policy override → re-inherit current config.
|
||||
let disk_has_exec_policy_override = false;
|
||||
if !disk_has_exec_policy_override {
|
||||
restored_manifest.exec_policy = Some(current_kernel_policy.clone());
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
restored_manifest.exec_policy.as_ref().map(|p| p.mode),
|
||||
Some(ExecSecurityMode::Full),
|
||||
"config.toml exec_policy.mode='full' must override stale cached value"
|
||||
);
|
||||
|
||||
// And: if the user *did* set a per-agent override on disk, that wins.
|
||||
let mut with_override = restored_manifest.clone();
|
||||
with_override.exec_policy = Some(ExecPolicy {
|
||||
mode: ExecSecurityMode::Deny,
|
||||
..Default::default()
|
||||
});
|
||||
let disk_has_override = true;
|
||||
if !disk_has_override {
|
||||
with_override.exec_policy = Some(current_kernel_policy.clone());
|
||||
}
|
||||
assert_eq!(
|
||||
with_override.exec_policy.as_ref().map(|p| p.mode),
|
||||
Some(ExecSecurityMode::Deny),
|
||||
"per-agent override in agent.toml must win over kernel config"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for #1132: persist_manifest_to_disk must not bake an
|
||||
/// inherited exec_policy into agent.toml. If the agent's policy equals
|
||||
/// the kernel's current config, we strip it before writing so future
|
||||
/// config.toml edits take effect.
|
||||
#[test]
|
||||
fn test_persist_strips_inherited_exec_policy() {
|
||||
use openfang_types::config::ExecSecurityMode;
|
||||
|
||||
let kernel_policy = ExecPolicy {
|
||||
mode: ExecSecurityMode::Full,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Agent inherited the kernel default → its policy equals kernel_policy.
|
||||
let inherited = Some(kernel_policy.clone());
|
||||
let mut for_disk_inherited: Option<ExecPolicy> = inherited.clone();
|
||||
if for_disk_inherited
|
||||
.as_ref()
|
||||
.is_some_and(|p| p == &kernel_policy)
|
||||
{
|
||||
for_disk_inherited = None;
|
||||
}
|
||||
assert!(
|
||||
for_disk_inherited.is_none(),
|
||||
"inherited policy should be stripped from on-disk copy"
|
||||
);
|
||||
|
||||
// Agent has a per-agent override → must survive.
|
||||
let custom = Some(ExecPolicy {
|
||||
mode: ExecSecurityMode::Deny,
|
||||
..Default::default()
|
||||
});
|
||||
let mut for_disk_custom = custom.clone();
|
||||
if for_disk_custom
|
||||
.as_ref()
|
||||
.is_some_and(|p| p == &kernel_policy)
|
||||
{
|
||||
for_disk_custom = None;
|
||||
}
|
||||
assert_eq!(
|
||||
for_disk_custom.as_ref().map(|p| p.mode),
|
||||
Some(ExecSecurityMode::Deny),
|
||||
"per-agent override must survive disk persistence"
|
||||
);
|
||||
}
|
||||
|
||||
fn test_manifest(name: &str, description: &str, tags: Vec<String>) -> AgentManifest {
|
||||
AgentManifest {
|
||||
name: name.to_string(),
|
||||
@@ -7896,4 +8127,178 @@ mod tests {
|
||||
|
||||
kernel.shutdown();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Issue #1129: per-provider hot-reloadable subprocess timeout.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Editing `subprocess_timeout_secs` on a `[[fallback_providers]]` entry
|
||||
/// and calling `apply_hot_actions(ReloadFallbackProviders)` must populate
|
||||
/// the kernel's `fallback_providers_override` slot with the new value.
|
||||
/// `resolve_driver` reads from this slot so cross-provider agents pick up
|
||||
/// the new timeout on their next driver build, with no daemon restart.
|
||||
#[test]
|
||||
fn test_subprocess_timeout_hot_reload_fallback_providers() {
|
||||
use crate::config_reload::{build_reload_plan, HotAction};
|
||||
use openfang_types::config::FallbackProviderConfig;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home_dir = tmp.path().join("openfang-1129-fallback-timeout");
|
||||
std::fs::create_dir_all(&home_dir).unwrap();
|
||||
|
||||
// Boot with one fallback provider configured at 120s.
|
||||
let mut config = KernelConfig {
|
||||
home_dir: home_dir.clone(),
|
||||
data_dir: home_dir.join("data"),
|
||||
..KernelConfig::default()
|
||||
};
|
||||
config.fallback_providers.push(FallbackProviderConfig {
|
||||
provider: "codex".to_string(),
|
||||
model: "gpt-5-codex".to_string(),
|
||||
api_key_env: String::new(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: Some(120),
|
||||
});
|
||||
|
||||
let kernel = OpenFangKernel::boot_with_config(config.clone()).expect("kernel boots");
|
||||
|
||||
// Pre-condition: nothing has been hot-reloaded yet — override slot is empty.
|
||||
{
|
||||
let guard = kernel.fallback_providers_override.read().unwrap();
|
||||
assert!(
|
||||
guard.is_none(),
|
||||
"fallback_providers_override should start as None"
|
||||
);
|
||||
}
|
||||
|
||||
// Operator edits config.toml, raising the codex timeout to 900s.
|
||||
let mut new_config = config.clone();
|
||||
new_config.fallback_providers[0].subprocess_timeout_secs = Some(900);
|
||||
|
||||
// The reload-plan diff must spot the change and emit
|
||||
// ReloadFallbackProviders.
|
||||
let plan = build_reload_plan(&kernel.config, &new_config);
|
||||
assert!(
|
||||
!plan.restart_required,
|
||||
"fallback timeout edits must be hot-reloadable"
|
||||
);
|
||||
assert!(
|
||||
plan.hot_actions.contains(&HotAction::ReloadFallbackProviders),
|
||||
"ReloadFallbackProviders must be present in the plan"
|
||||
);
|
||||
|
||||
// Apply the plan and verify the override slot now carries the new
|
||||
// timeout. Drivers built after this point will see 900s.
|
||||
kernel.apply_hot_actions(&plan, &new_config);
|
||||
{
|
||||
let guard = kernel.fallback_providers_override.read().unwrap();
|
||||
let slot = guard
|
||||
.as_ref()
|
||||
.expect("ReloadFallbackProviders must populate override slot");
|
||||
assert_eq!(slot.len(), 1, "exactly one fallback provider expected");
|
||||
assert_eq!(slot[0].provider, "codex");
|
||||
assert_eq!(
|
||||
slot[0].subprocess_timeout_secs,
|
||||
Some(900),
|
||||
"drivers built after reload must see 900s, not 120s"
|
||||
);
|
||||
}
|
||||
|
||||
kernel.shutdown();
|
||||
}
|
||||
|
||||
/// Editing `[default_model].subprocess_timeout_secs` produces an
|
||||
/// `UpdateDefaultModel` hot-action that populates `default_model_override`.
|
||||
/// This is the path agents on the default provider use to pick up a new
|
||||
/// timeout without a daemon restart.
|
||||
#[test]
|
||||
fn test_subprocess_timeout_hot_reload_default_model() {
|
||||
use crate::config_reload::{build_reload_plan, HotAction};
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home_dir = tmp.path().join("openfang-1129-default-timeout");
|
||||
std::fs::create_dir_all(&home_dir).unwrap();
|
||||
|
||||
let mut config = KernelConfig {
|
||||
home_dir: home_dir.clone(),
|
||||
data_dir: home_dir.join("data"),
|
||||
..KernelConfig::default()
|
||||
};
|
||||
config.default_model.subprocess_timeout_secs = Some(180);
|
||||
|
||||
let kernel = OpenFangKernel::boot_with_config(config.clone()).expect("kernel boots");
|
||||
|
||||
// Operator raises the timeout to 1200s.
|
||||
let mut new_config = config.clone();
|
||||
new_config.default_model.subprocess_timeout_secs = Some(1200);
|
||||
|
||||
let plan = build_reload_plan(&kernel.config, &new_config);
|
||||
assert!(
|
||||
!plan.restart_required,
|
||||
"default_model timeout edits must be hot-reloadable"
|
||||
);
|
||||
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
|
||||
|
||||
kernel.apply_hot_actions(&plan, &new_config);
|
||||
{
|
||||
let guard = kernel.default_model_override.read().unwrap();
|
||||
let dm = guard
|
||||
.as_ref()
|
||||
.expect("UpdateDefaultModel must populate override slot");
|
||||
assert_eq!(
|
||||
dm.subprocess_timeout_secs,
|
||||
Some(1200),
|
||||
"default-provider drivers built after reload must see 1200s"
|
||||
);
|
||||
}
|
||||
|
||||
kernel.shutdown();
|
||||
}
|
||||
|
||||
/// Adding a `[[fallback_providers]]` entry on reload (no prior entry)
|
||||
/// must produce `ReloadFallbackProviders` and populate the override slot.
|
||||
/// Mirrors the operator workflow of "I want to add a Codex fallback to my
|
||||
/// Claude-default daemon mid-flight."
|
||||
#[test]
|
||||
fn test_subprocess_timeout_hot_reload_adds_new_fallback() {
|
||||
use crate::config_reload::{build_reload_plan, HotAction};
|
||||
use openfang_types::config::FallbackProviderConfig;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home_dir = tmp.path().join("openfang-1129-add-fallback");
|
||||
std::fs::create_dir_all(&home_dir).unwrap();
|
||||
|
||||
let config = KernelConfig {
|
||||
home_dir: home_dir.clone(),
|
||||
data_dir: home_dir.join("data"),
|
||||
..KernelConfig::default()
|
||||
};
|
||||
let kernel = OpenFangKernel::boot_with_config(config.clone()).expect("kernel boots");
|
||||
|
||||
// Operator adds a codex fallback with a 600s timeout.
|
||||
let mut new_config = config.clone();
|
||||
new_config.fallback_providers.push(FallbackProviderConfig {
|
||||
provider: "codex".to_string(),
|
||||
model: "gpt-5-codex".to_string(),
|
||||
api_key_env: String::new(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: Some(600),
|
||||
});
|
||||
|
||||
let plan = build_reload_plan(&kernel.config, &new_config);
|
||||
assert!(plan
|
||||
.hot_actions
|
||||
.contains(&HotAction::ReloadFallbackProviders));
|
||||
|
||||
kernel.apply_hot_actions(&plan, &new_config);
|
||||
{
|
||||
let guard = kernel.fallback_providers_override.read().unwrap();
|
||||
let slot = guard.as_ref().expect("override populated");
|
||||
assert_eq!(slot.len(), 1);
|
||||
assert_eq!(slot[0].provider, "codex");
|
||||
assert_eq!(slot[0].subprocess_timeout_secs, Some(600));
|
||||
}
|
||||
|
||||
kernel.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,7 +586,7 @@ impl SessionStore {
|
||||
ContentBlock::Image { media_type, .. } => {
|
||||
text_parts.push(format!("[image: {media_type}]"));
|
||||
}
|
||||
ContentBlock::Thinking { thinking } => {
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
text_parts.push(format!(
|
||||
"[thinking: {}]",
|
||||
openfang_types::truncate_str(thinking, 200)
|
||||
|
||||
@@ -109,6 +109,70 @@ fn append_tool_error_guidance(tool_result_blocks: &mut Vec<ContentBlock>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an assistant message that preserves Thinking blocks alongside the
|
||||
/// final visible text.
|
||||
///
|
||||
/// Issue #1098 — thinking-model state preservation. When the LLM response
|
||||
/// contains `ContentBlock::Thinking` (Anthropic extended thinking with
|
||||
/// signatures, Gemini 2.5+ thoughts, OpenAI-compat reasoning_content,
|
||||
/// MiniMax/Qwen inline `<think>` blocks), the prior code stored only the
|
||||
/// final text via `Message::assistant(text)` — discarding all reasoning
|
||||
/// state. On the next turn the model re-derived its answer from scratch
|
||||
/// and quality degraded.
|
||||
///
|
||||
/// This helper preserves the full block list whenever any Thinking block is
|
||||
/// present, otherwise returns the legacy `Message::assistant(text)` form so
|
||||
/// downstream consumers (channel formatters, JSONL mirrors, embeddings) keep
|
||||
/// working without changes.
|
||||
///
|
||||
/// Note: we deliberately replace any visible Text blocks in `response_blocks`
|
||||
/// with `final_text` so that any post-processing the agent loop applied
|
||||
/// (phantom-action recovery, accumulated_text fallback, EmptyResponse guard
|
||||
/// stub) is reflected in the persisted message.
|
||||
fn build_assistant_message_preserving_thinking(
|
||||
response_blocks: &[ContentBlock],
|
||||
final_text: &str,
|
||||
) -> Message {
|
||||
let has_thinking = response_blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::Thinking { .. }));
|
||||
if !has_thinking {
|
||||
return Message::assistant(final_text.to_string());
|
||||
}
|
||||
|
||||
// Preserve order: Thinking blocks first (in original order), then a
|
||||
// single Text block carrying `final_text`. Tool blocks aren't expected
|
||||
// here (StopReason::EndTurn path), but copy them through if present so
|
||||
// we don't drop information.
|
||||
let mut blocks: Vec<ContentBlock> = Vec::with_capacity(response_blocks.len() + 1);
|
||||
let mut emitted_text = false;
|
||||
for b in response_blocks {
|
||||
match b {
|
||||
ContentBlock::Thinking { .. } => blocks.push(b.clone()),
|
||||
ContentBlock::Text { .. } if !emitted_text => {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: final_text.to_string(),
|
||||
provider_metadata: None,
|
||||
});
|
||||
emitted_text = true;
|
||||
}
|
||||
ContentBlock::Text { .. } => {
|
||||
// Drop additional text blocks — final_text already captures
|
||||
// the canonical visible message.
|
||||
}
|
||||
other => blocks.push(other.clone()),
|
||||
}
|
||||
}
|
||||
if !emitted_text && !final_text.is_empty() {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: final_text.to_string(),
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
|
||||
Message::assistant_with_blocks(blocks)
|
||||
}
|
||||
|
||||
/// Strip a provider prefix from a model ID before sending to the API.
|
||||
///
|
||||
/// Many models are stored as `provider/org/model` (e.g. `openrouter/google/gemini-2.5-flash`)
|
||||
@@ -605,7 +669,16 @@ pub async fn run_agent_loop(
|
||||
};
|
||||
|
||||
final_response = text.clone();
|
||||
session.messages.push(Message::assistant(text));
|
||||
// Issue #1098: persist Thinking blocks alongside the text so
|
||||
// reasoning models retain state across turns. When the
|
||||
// response carries any Thinking content (Anthropic extended
|
||||
// thinking, Gemini 2.5 thought signatures, DeepSeek-R1/Qwen3
|
||||
// `reasoning_content`, MiniMax inline `<think>`), save the
|
||||
// full content blocks; otherwise fall back to the legacy
|
||||
// Text shape so existing sessions/snapshots stay readable.
|
||||
let assistant_msg =
|
||||
build_assistant_message_preserving_thinking(&response.content, &text);
|
||||
session.messages.push(assistant_msg);
|
||||
|
||||
// Prune NO_REPLY heartbeat turns to save context budget
|
||||
crate::session_repair::prune_heartbeat_turns(&mut session.messages, 10);
|
||||
@@ -1798,7 +1871,13 @@ pub async fn run_agent_loop_streaming(
|
||||
text
|
||||
};
|
||||
final_response = text.clone();
|
||||
session.messages.push(Message::assistant(text));
|
||||
// Issue #1098: preserve Thinking blocks (with Anthropic
|
||||
// signatures / Gemini thought signatures / inline-think /
|
||||
// reasoning_content) on the persisted assistant turn. See
|
||||
// build_assistant_message_preserving_thinking for details.
|
||||
let assistant_msg =
|
||||
build_assistant_message_preserving_thinking(&response.content, &text);
|
||||
session.messages.push(assistant_msg);
|
||||
|
||||
// Prune NO_REPLY heartbeat turns to save context budget
|
||||
crate::session_repair::prune_heartbeat_turns(&mut session.messages, 10);
|
||||
@@ -3092,6 +3171,94 @@ mod tests {
|
||||
assert_eq!(MAX_ITERATIONS, 50);
|
||||
}
|
||||
|
||||
/// Issue #1098: when a response carries Thinking blocks, the persisted
|
||||
/// assistant turn must keep them so the next turn round-trips reasoning
|
||||
/// state to the model.
|
||||
#[test]
|
||||
fn test_build_assistant_message_preserves_thinking() {
|
||||
let response_blocks = vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "Let me reason carefully...".to_string(),
|
||||
signature: Some("sig_anthropic_xyz".to_string()),
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "anthropic_extended_thinking"
|
||||
})),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Initial response text".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
// Final text might differ from the original Text block (phantom-action
|
||||
// recovery / synthesis fallback rewrites it). The helper should adopt
|
||||
// final_text into the persisted Text block.
|
||||
let final_text = "Initial response text";
|
||||
let msg = build_assistant_message_preserving_thinking(&response_blocks, final_text);
|
||||
assert_eq!(msg.role, Role::Assistant);
|
||||
let blocks = match &msg.content {
|
||||
MessageContent::Blocks(b) => b,
|
||||
other => panic!("expected blocks, got {other:?}"),
|
||||
};
|
||||
assert_eq!(blocks.len(), 2, "must preserve thinking + text");
|
||||
match &blocks[0] {
|
||||
ContentBlock::Thinking {
|
||||
thinking, signature, ..
|
||||
} => {
|
||||
assert_eq!(thinking, "Let me reason carefully...");
|
||||
assert_eq!(signature.as_deref(), Some("sig_anthropic_xyz"));
|
||||
}
|
||||
_ => panic!("expected Thinking first"),
|
||||
}
|
||||
match &blocks[1] {
|
||||
ContentBlock::Text { text, .. } => assert_eq!(text, "Initial response text"),
|
||||
_ => panic!("expected Text second"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Without thinking, fall back to the legacy `Message::assistant(text)`
|
||||
/// shape so existing JSONL mirrors and embeddings keep working.
|
||||
#[test]
|
||||
fn test_build_assistant_message_no_thinking_is_plain_text() {
|
||||
let response_blocks = vec![ContentBlock::Text {
|
||||
text: "Hi.".to_string(),
|
||||
provider_metadata: None,
|
||||
}];
|
||||
let msg = build_assistant_message_preserving_thinking(&response_blocks, "Hi.");
|
||||
match msg.content {
|
||||
MessageContent::Text(t) => assert_eq!(t, "Hi."),
|
||||
_ => panic!("expected plain text content for non-thinking responses"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Final text supplied by the loop (e.g. recovery stub) must replace
|
||||
/// the original text part — the persisted message reflects what was
|
||||
/// actually returned to the user, not the raw LLM output.
|
||||
#[test]
|
||||
fn test_build_assistant_message_final_text_replaces_original_text() {
|
||||
let response_blocks = vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "deliberation".to_string(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({"format": "inline_think"})),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "raw LLM output".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
let final_text = "[Task completed — recovered after empty response.]";
|
||||
let msg = build_assistant_message_preserving_thinking(&response_blocks, final_text);
|
||||
let blocks = match &msg.content {
|
||||
MessageContent::Blocks(b) => b,
|
||||
_ => panic!("expected blocks"),
|
||||
};
|
||||
let saved_text = blocks.iter().find_map(|b| match b {
|
||||
ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(saved_text, Some(final_text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_constants() {
|
||||
assert_eq!(MAX_RETRIES, 3);
|
||||
|
||||
@@ -84,6 +84,15 @@ enum ApiContentBlock {
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
is_error: bool,
|
||||
},
|
||||
/// Extended-thinking block echoed back to the API.
|
||||
///
|
||||
/// Anthropic requires the original `signature` to be returned verbatim
|
||||
/// alongside the `thinking` text on subsequent turns; otherwise the
|
||||
/// model loses its prior reasoning state. Without `signature` the API
|
||||
/// rejects the block, so we omit thinking blocks that arrive without
|
||||
/// one (e.g. legacy sessions saved before this field was tracked).
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking { thinking: String, signature: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -120,8 +129,16 @@ enum ResponseContentBlock {
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
/// Extended-thinking block from Anthropic. The `signature` is opaque
|
||||
/// to us but MUST be persisted and echoed back on the next request,
|
||||
/// otherwise the API rejects the resubmitted thinking block and the
|
||||
/// model loses its reasoning state.
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking { thinking: String },
|
||||
Thinking {
|
||||
thinking: String,
|
||||
#[serde(default)]
|
||||
signature: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -144,7 +161,14 @@ struct ApiErrorDetail {
|
||||
/// Accumulator for content blocks during streaming.
|
||||
enum ContentBlockAccum {
|
||||
Text(String),
|
||||
Thinking(String),
|
||||
/// Extended thinking — text plus an opaque signature delivered as
|
||||
/// `signature_delta` events (or as a single field on `content_block_stop`
|
||||
/// for older API versions). The signature is required to round-trip
|
||||
/// thinking blocks on subsequent turns.
|
||||
Thinking {
|
||||
thinking: String,
|
||||
signature: String,
|
||||
},
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -412,7 +436,16 @@ impl LlmDriver for AnthropicDriver {
|
||||
});
|
||||
}
|
||||
"thinking" => {
|
||||
blocks.push(ContentBlockAccum::Thinking(String::new()));
|
||||
// Some API versions ship the signature on
|
||||
// content_block_start instead of as a delta.
|
||||
let initial_sig = block["signature"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
blocks.push(ContentBlockAccum::Thinking {
|
||||
thinking: String::new(),
|
||||
signature: initial_sig,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -452,11 +485,33 @@ impl LlmDriver for AnthropicDriver {
|
||||
}
|
||||
}
|
||||
"thinking_delta" => {
|
||||
if let Some(thinking) = delta["thinking"].as_str() {
|
||||
if let Some(ContentBlockAccum::Thinking(ref mut t)) =
|
||||
blocks.get_mut(block_idx)
|
||||
if let Some(t) = delta["thinking"].as_str() {
|
||||
if let Some(ContentBlockAccum::Thinking {
|
||||
thinking: ref mut buf,
|
||||
..
|
||||
}) = blocks.get_mut(block_idx)
|
||||
{
|
||||
t.push_str(thinking);
|
||||
buf.push_str(t);
|
||||
}
|
||||
// Forward to UI as ThinkingDelta event so dashboards can show reasoning.
|
||||
let _ = tx
|
||||
.send(StreamEvent::ThinkingDelta {
|
||||
text: t.to_string(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"signature_delta" => {
|
||||
// Anthropic streams the thinking signature
|
||||
// as its own delta type; concatenate any
|
||||
// partial pieces into the accumulator.
|
||||
if let Some(sig) = delta["signature"].as_str() {
|
||||
if let Some(ContentBlockAccum::Thinking {
|
||||
ref mut signature,
|
||||
..
|
||||
}) = blocks.get_mut(block_idx)
|
||||
{
|
||||
signature.push_str(sig);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,8 +567,26 @@ impl LlmDriver for AnthropicDriver {
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
ContentBlockAccum::Thinking(thinking) => {
|
||||
content.push(ContentBlock::Thinking { thinking });
|
||||
ContentBlockAccum::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
// Drop empty thinking blocks (rare, but happens if the
|
||||
// stream is interrupted mid-block). Always keep the
|
||||
// signature when present — it's required to round-trip.
|
||||
if !thinking.is_empty() || !signature.is_empty() {
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature: if signature.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(signature)
|
||||
},
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "anthropic_extended_thinking"
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
ContentBlockAccum::ToolUse {
|
||||
id,
|
||||
@@ -615,7 +688,28 @@ fn convert_message(msg: &Message) -> ApiMessage {
|
||||
content: content.clone(),
|
||||
is_error: *is_error,
|
||||
}),
|
||||
ContentBlock::Thinking { .. } => None,
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
..
|
||||
} => {
|
||||
// Anthropic's extended-thinking spec requires the
|
||||
// verbatim `signature` to accompany any thinking block
|
||||
// resubmitted in conversation history. Without one,
|
||||
// the API rejects the request, so we silently drop
|
||||
// legacy thinking blocks (saved before signature
|
||||
// tracking) instead of round-tripping them.
|
||||
signature.as_ref().and_then(|sig| {
|
||||
if sig.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ApiContentBlock::Thinking {
|
||||
thinking: thinking.clone(),
|
||||
signature: sig.clone(),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
ContentBlock::Unknown => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -651,8 +745,17 @@ fn convert_response(api: ApiResponse) -> CompletionResponse {
|
||||
});
|
||||
tool_calls.push(ToolCall { id, name, input });
|
||||
}
|
||||
ResponseContentBlock::Thinking { thinking } => {
|
||||
content.push(ContentBlock::Thinking { thinking });
|
||||
ResponseContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "anthropic_extended_thinking"
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -772,4 +875,148 @@ mod tests {
|
||||
panic!("Expected Blocks content");
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #1098: Anthropic extended-thinking blocks must round-trip
|
||||
/// through the driver — the inbound response carries a `signature` that
|
||||
/// MUST be echoed verbatim on the next request, otherwise the API
|
||||
/// rejects the resubmitted thinking block and the model loses prior
|
||||
/// reasoning state.
|
||||
#[test]
|
||||
fn test_thinking_block_signature_round_trip() {
|
||||
// Step 1: API delivers a thinking block with signature
|
||||
let api_response = ApiResponse {
|
||||
content: vec![
|
||||
ResponseContentBlock::Thinking {
|
||||
thinking: "Let me carefully consider this problem...".to_string(),
|
||||
signature: Some("WaUjzkypQ2mUEVM36O2TxuC".to_string()),
|
||||
},
|
||||
ResponseContentBlock::Text {
|
||||
text: "The answer is 42.".to_string(),
|
||||
},
|
||||
],
|
||||
stop_reason: "end_turn".to_string(),
|
||||
usage: ApiUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
},
|
||||
};
|
||||
let response = convert_response(api_response);
|
||||
assert_eq!(response.content.len(), 2);
|
||||
|
||||
// Step 2: Verify the signature reached the ContentBlock
|
||||
let thinking_block = &response.content[0];
|
||||
match thinking_block {
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(thinking, "Let me carefully consider this problem...");
|
||||
assert_eq!(signature.as_deref(), Some("WaUjzkypQ2mUEVM36O2TxuC"));
|
||||
}
|
||||
_ => panic!("expected Thinking content block"),
|
||||
}
|
||||
|
||||
// Step 3: Now feed the assistant turn back into the driver as if
|
||||
// it were prior conversation history (next user turn). The signature
|
||||
// must survive into the outbound API request.
|
||||
let assistant_msg = Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(response.content.clone()),
|
||||
};
|
||||
let api_msg = convert_message(&assistant_msg);
|
||||
let blocks = match api_msg.content {
|
||||
ApiContent::Blocks(b) => b,
|
||||
_ => panic!("expected Blocks content"),
|
||||
};
|
||||
|
||||
// The Thinking block must appear in the outbound payload with its signature.
|
||||
let mut found_thinking = false;
|
||||
for block in &blocks {
|
||||
if let ApiContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} = block
|
||||
{
|
||||
assert_eq!(thinking, "Let me carefully consider this problem...");
|
||||
assert_eq!(signature, "WaUjzkypQ2mUEVM36O2TxuC");
|
||||
found_thinking = true;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_thinking,
|
||||
"outbound API request must include the thinking block with signature"
|
||||
);
|
||||
|
||||
// Step 4: Verify on-the-wire JSON shape (`type=thinking`, `signature` present).
|
||||
let outbound_json = serde_json::to_value(&blocks).unwrap();
|
||||
let arr = outbound_json.as_array().unwrap();
|
||||
let thinking_json = arr
|
||||
.iter()
|
||||
.find(|v| v["type"] == "thinking")
|
||||
.expect("thinking block in JSON");
|
||||
assert_eq!(thinking_json["signature"], "WaUjzkypQ2mUEVM36O2TxuC");
|
||||
assert_eq!(
|
||||
thinking_json["thinking"],
|
||||
"Let me carefully consider this problem..."
|
||||
);
|
||||
}
|
||||
|
||||
/// Legacy thinking blocks saved before signature tracking should NOT
|
||||
/// be replayed — Anthropic rejects thinking blocks without signatures.
|
||||
#[test]
|
||||
fn test_thinking_block_without_signature_dropped_outbound() {
|
||||
let assistant_msg = Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "old reasoning from before sig tracking".to_string(),
|
||||
signature: None,
|
||||
provider_metadata: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Hello.".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
]),
|
||||
};
|
||||
let api_msg = convert_message(&assistant_msg);
|
||||
let blocks = match api_msg.content {
|
||||
ApiContent::Blocks(b) => b,
|
||||
_ => panic!("expected Blocks content"),
|
||||
};
|
||||
// The legacy thinking block must be dropped (no sig = API would 400).
|
||||
for block in &blocks {
|
||||
assert!(
|
||||
!matches!(block, ApiContentBlock::Thinking { .. }),
|
||||
"thinking block without signature must be dropped"
|
||||
);
|
||||
}
|
||||
// The text part is still preserved.
|
||||
assert!(blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ApiContentBlock::Text { .. })));
|
||||
}
|
||||
|
||||
/// Streaming path: signature_delta events accumulate into the final block.
|
||||
#[test]
|
||||
fn test_thinking_block_serde_with_signature_field() {
|
||||
// Verify the API response wire format is parsed correctly.
|
||||
let json = serde_json::json!({
|
||||
"type": "thinking",
|
||||
"thinking": "step 1, step 2",
|
||||
"signature": "abc123"
|
||||
});
|
||||
let block: ResponseContentBlock = serde_json::from_value(json).unwrap();
|
||||
match block {
|
||||
ResponseContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
assert_eq!(thinking, "step 1, step 2");
|
||||
assert_eq!(signature.as_deref(), Some("abc123"));
|
||||
}
|
||||
_ => panic!("expected Thinking response block"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,39 @@ fn convert_messages(
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::Thinking { .. } => {}
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
provider_metadata,
|
||||
..
|
||||
} => {
|
||||
// Issue #1098: preserve Gemini 2.5+ thought parts
|
||||
// when the upstream model originally emitted them.
|
||||
// Most Gemini state actually rides on the
|
||||
// thoughtSignature attached to text/tool_use
|
||||
// parts above, but we round-trip the visible
|
||||
// thinking text + sig as a `Thought` part too
|
||||
// so the model's internal state is fully
|
||||
// preserved. Other providers' thinking blocks
|
||||
// are dropped here (they have their own
|
||||
// outbound paths in the OpenAI/Anthropic
|
||||
// drivers).
|
||||
let format = provider_metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("format"))
|
||||
.and_then(|v| v.as_str());
|
||||
if format == Some("gemini_thought") && !thinking.is_empty() {
|
||||
let sig = provider_metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("thought_signature"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
parts.push(GeminiPart::Thought {
|
||||
text: thinking.clone(),
|
||||
thought: true,
|
||||
thought_signature: sig,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -561,12 +593,29 @@ fn convert_response(resp: GeminiResponse) -> Result<CompletionResponse, LlmError
|
||||
input: function_call.args,
|
||||
});
|
||||
}
|
||||
GeminiPart::Thought { text, .. } => {
|
||||
GeminiPart::Thought {
|
||||
text,
|
||||
thought_signature,
|
||||
..
|
||||
} => {
|
||||
// Gemini 2.5+ thinking parts — internal reasoning.
|
||||
// Store as Thinking content block so the UI can
|
||||
// optionally display it (like <think> blocks).
|
||||
// optionally display it. Issue #1098: preserve the
|
||||
// part-level `thoughtSignature` in `provider_metadata`
|
||||
// (and on subsequent text/tool_use parts) so the
|
||||
// model retains state across turns.
|
||||
if !text.is_empty() {
|
||||
content.push(ContentBlock::Thinking { thinking: text });
|
||||
let provider_metadata = thought_signature.map(|sig| {
|
||||
serde_json::json!({
|
||||
"format": "gemini_thought",
|
||||
"thought_signature": sig,
|
||||
})
|
||||
});
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: text,
|
||||
signature: None,
|
||||
provider_metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => {
|
||||
@@ -788,6 +837,10 @@ impl LlmDriver for GeminiDriver {
|
||||
let mut text_content = String::new();
|
||||
// Thought signature for accumulated text content (last one wins)
|
||||
let mut text_thought_sig: Option<String> = None;
|
||||
// Accumulated thought (Gemini 2.5+) text + signature, for
|
||||
// round-tripping reasoning state across turns (issue #1098).
|
||||
let mut thought_text = String::new();
|
||||
let mut thought_sig: Option<String> = None;
|
||||
// Track function calls: (name, args_json, thought_signature)
|
||||
let mut fn_calls: Vec<(String, serde_json::Value, Option<String>)> = Vec::new();
|
||||
let mut finish_reason: Option<String> = None;
|
||||
@@ -894,17 +947,26 @@ impl LlmDriver for GeminiDriver {
|
||||
thought_signature.clone(),
|
||||
));
|
||||
}
|
||||
GeminiPart::Thought { ref text, .. } => {
|
||||
GeminiPart::Thought {
|
||||
ref text,
|
||||
ref thought_signature,
|
||||
..
|
||||
} => {
|
||||
// Gemini 2.5+ thinking chunk — emit as
|
||||
// thinking delta so UIs can optionally
|
||||
// show it; do NOT mix into text_content.
|
||||
// show it; accumulate the text + sig
|
||||
// for later persistence (issue #1098).
|
||||
if !text.is_empty() {
|
||||
thought_text.push_str(text);
|
||||
let _ = tx
|
||||
.send(StreamEvent::ThinkingDelta {
|
||||
text: text.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
if thought_signature.is_some() {
|
||||
thought_sig = thought_signature.clone();
|
||||
}
|
||||
}
|
||||
GeminiPart::InlineData { .. }
|
||||
| GeminiPart::FunctionResponse { .. } => {}
|
||||
@@ -985,14 +1047,24 @@ impl LlmDriver for GeminiDriver {
|
||||
thought_signature.clone(),
|
||||
));
|
||||
}
|
||||
GeminiPart::Thought { ref text, .. } => {
|
||||
GeminiPart::Thought {
|
||||
ref text,
|
||||
ref thought_signature,
|
||||
..
|
||||
} if !text.is_empty()
|
||||
|| thought_signature.is_some() =>
|
||||
{
|
||||
if !text.is_empty() {
|
||||
thought_text.push_str(text);
|
||||
let _ = tx
|
||||
.send(StreamEvent::ThinkingDelta {
|
||||
text: text.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
if thought_signature.is_some() {
|
||||
thought_sig = thought_signature.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1034,6 +1106,25 @@ impl LlmDriver for GeminiDriver {
|
||||
let mut content = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
// Issue #1098: persist any accumulated Thought parts (Gemini
|
||||
// 2.5+ thinking) so reasoning state round-trips on the next
|
||||
// turn. The thoughtSignature also rides on text/tool_use
|
||||
// parts below; this Thinking block carries the human-readable
|
||||
// reasoning text for UI display + audit.
|
||||
if !thought_text.is_empty() || thought_sig.is_some() {
|
||||
let provider_metadata = thought_sig.as_ref().map(|sig| {
|
||||
serde_json::json!({
|
||||
"format": "gemini_thought",
|
||||
"thought_signature": sig,
|
||||
})
|
||||
});
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: thought_text,
|
||||
signature: None,
|
||||
provider_metadata,
|
||||
});
|
||||
}
|
||||
|
||||
if !text_content.is_empty() {
|
||||
let provider_metadata =
|
||||
text_thought_sig.map(|sig| serde_json::json!({ "thought_signature": sig }));
|
||||
@@ -1994,7 +2085,7 @@ mod tests {
|
||||
// Should have a Thinking block and a Text block
|
||||
assert_eq!(completion.content.len(), 2);
|
||||
match &completion.content[0] {
|
||||
ContentBlock::Thinking { thinking } => {
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
assert_eq!(thinking, "Let me reason...");
|
||||
}
|
||||
_ => panic!("Expected Thinking block, got {:?}", completion.content[0]),
|
||||
|
||||
@@ -632,6 +632,39 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &str) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
std::env::set_var(key, value);
|
||||
Self { key, original }
|
||||
}
|
||||
|
||||
fn remove(key: &'static str) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
std::env::remove_var(key);
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = &self.original {
|
||||
std::env::set_var(self.key, value);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_defaults_groq() {
|
||||
@@ -792,8 +825,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_novita_provider_with_env_key() {
|
||||
let _env_lock = ENV_LOCK.lock().unwrap();
|
||||
let unique_key = "test-novita-key-12345";
|
||||
std::env::set_var("NOVITA_API_KEY", unique_key);
|
||||
let _env = EnvVarGuard::set("NOVITA_API_KEY", unique_key);
|
||||
let config = DriverConfig {
|
||||
provider: "novita".to_string(),
|
||||
api_key: None,
|
||||
@@ -806,11 +840,12 @@ mod tests {
|
||||
driver.is_ok(),
|
||||
"Novita provider with env var should succeed"
|
||||
);
|
||||
std::env::remove_var("NOVITA_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_novita_provider_no_key_errors() {
|
||||
let _env_lock = ENV_LOCK.lock().unwrap();
|
||||
let _env = EnvVarGuard::remove("NOVITA_API_KEY");
|
||||
let config = DriverConfig {
|
||||
provider: "novita".to_string(),
|
||||
api_key: None,
|
||||
@@ -825,8 +860,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_nvidia_provider_with_env_key() {
|
||||
// NVIDIA NIM is a known provider — set API key and verify driver creation succeeds.
|
||||
let _env_lock = ENV_LOCK.lock().unwrap();
|
||||
let unique_key = "test-nvidia-key-12345";
|
||||
std::env::set_var("NVIDIA_API_KEY", unique_key);
|
||||
let _env = EnvVarGuard::set("NVIDIA_API_KEY", unique_key);
|
||||
let config = DriverConfig {
|
||||
provider: "nvidia".to_string(),
|
||||
api_key: None, // picked up from env via provider_defaults
|
||||
@@ -839,12 +875,13 @@ mod tests {
|
||||
driver.is_ok(),
|
||||
"NVIDIA provider with env var should succeed"
|
||||
);
|
||||
std::env::remove_var("NVIDIA_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nvidia_provider_no_key_errors() {
|
||||
// NVIDIA NIM provider with no API key should error.
|
||||
let _env_lock = ENV_LOCK.lock().unwrap();
|
||||
let _env = EnvVarGuard::remove("NVIDIA_API_KEY");
|
||||
let config = DriverConfig {
|
||||
provider: "nvidia".to_string(),
|
||||
api_key: None,
|
||||
@@ -859,8 +896,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_custom_provider_key_no_url_helpful_error() {
|
||||
// Custom provider with key set (via env) but no base_url should give helpful error.
|
||||
let _env_lock = ENV_LOCK.lock().unwrap();
|
||||
let unique_key = "test-custom-key-67890";
|
||||
std::env::set_var("MYCUSTOM_API_KEY", unique_key);
|
||||
let _env = EnvVarGuard::set("MYCUSTOM_API_KEY", unique_key);
|
||||
let config = DriverConfig {
|
||||
provider: "mycustom".to_string(),
|
||||
api_key: None,
|
||||
@@ -876,7 +914,6 @@ mod tests {
|
||||
"Error should mention base_url: {}",
|
||||
err
|
||||
);
|
||||
std::env::remove_var("MYCUSTOM_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -289,6 +289,135 @@ fn strip_trailing_empty_assistant(messages: &mut Vec<OaiMessage>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble an outbound assistant `OaiMessage` from `ContentBlock`s, replaying
|
||||
/// any `Thinking` blocks in the format the upstream model originally emitted.
|
||||
///
|
||||
/// This is the fix for issue #1098 — thinking-model state preservation.
|
||||
/// Without this, `<think>...</think>` and `reasoning_content` are stripped on
|
||||
/// the next turn so the model loses its prior reasoning trace and re-derives
|
||||
/// the answer (degrading quality). We honour `provider_metadata.format`:
|
||||
///
|
||||
/// - `"reasoning_content"` → emitted on the OpenAI `reasoning_content` field
|
||||
/// (DeepSeek-R1, Qwen3, MiniMax M2 via LM Studio/Ollama)
|
||||
/// - `"inline_think"` → wrapped in `<think>...</think>` and prepended to
|
||||
/// the visible content (MiniMax M2.5, Llama-3.3-think variants)
|
||||
/// - missing/other → fall back to the legacy Moonshot/Kimi behaviour
|
||||
/// (only emit `reasoning_content` when `needs_reasoning_content()` is true)
|
||||
fn assemble_assistant_message(
|
||||
blocks: &[ContentBlock],
|
||||
model: &str,
|
||||
driver: &OpenAIDriver,
|
||||
) -> OaiMessage {
|
||||
let mut text_parts: Vec<String> = Vec::new();
|
||||
let mut tool_calls: Vec<OaiToolCall> = Vec::new();
|
||||
let mut reasoning_field: Option<String> = None;
|
||||
let mut inline_think: Option<String> = None;
|
||||
|
||||
for block in blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => text_parts.push(text.clone()),
|
||||
ContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
tool_calls.push(OaiToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
function: OaiFunction {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input).unwrap_or_default(),
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
provider_metadata,
|
||||
..
|
||||
} => {
|
||||
if thinking.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let format = provider_metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("format"))
|
||||
.and_then(|v| v.as_str());
|
||||
match format {
|
||||
Some("inline_think") => {
|
||||
// MiniMax / models trained to expect `<think>` in
|
||||
// historical assistant messages. Concatenate
|
||||
// multiple thinking blocks if present.
|
||||
let entry = format!("<think>{thinking}</think>");
|
||||
match &mut inline_think {
|
||||
Some(existing) => existing.push_str(&entry),
|
||||
None => inline_think = Some(entry),
|
||||
}
|
||||
}
|
||||
Some("reasoning_content") => {
|
||||
// DeepSeek-R1 / Qwen3 / OpenAI-compat servers that
|
||||
// expose a separate `reasoning_content` field.
|
||||
match &mut reasoning_field {
|
||||
Some(existing) => existing.push_str(thinking),
|
||||
None => reasoning_field = Some(thinking.clone()),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unknown format — preserve as inline_think since it's
|
||||
// safe (visible to the model as ordinary text). The
|
||||
// legacy Moonshot path overrides this below.
|
||||
let entry = format!("<think>{thinking}</think>");
|
||||
match &mut inline_think {
|
||||
Some(existing) => existing.push_str(&entry),
|
||||
None => inline_think = Some(entry),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the visible content by prepending inline_think (if any).
|
||||
let mut visible = String::new();
|
||||
if let Some(it) = inline_think.as_ref() {
|
||||
visible.push_str(it);
|
||||
}
|
||||
if !text_parts.is_empty() {
|
||||
visible.push_str(&text_parts.join(""));
|
||||
}
|
||||
|
||||
let has_tool_calls = !tool_calls.is_empty();
|
||||
let needs_reasoning = driver.needs_reasoning_content(model);
|
||||
|
||||
// Final reasoning_content field: the per-block format hint wins; otherwise
|
||||
// fall back to legacy Moonshot/Kimi behaviour (empty string when needed).
|
||||
let reasoning_content = if reasoning_field.is_some() {
|
||||
reasoning_field
|
||||
} else if needs_reasoning {
|
||||
Some(String::new())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
OaiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: if visible.is_empty() {
|
||||
if has_tool_calls {
|
||||
Some(OaiMessageContent::Text(String::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(OaiMessageContent::Text(visible))
|
||||
},
|
||||
tool_calls: if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
tool_call_id: None,
|
||||
reasoning_content,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for OpenAIDriver {
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
@@ -308,16 +437,14 @@ impl LlmDriver for OpenAIDriver {
|
||||
// Convert messages
|
||||
for msg in &request.messages {
|
||||
match (&msg.role, &msg.content) {
|
||||
(Role::System, MessageContent::Text(text)) => {
|
||||
if request.system.is_none() {
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OaiMessageContent::Text(text.clone())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
}
|
||||
(Role::System, MessageContent::Text(text)) if request.system.is_none() => {
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OaiMessageContent::Text(text.clone())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
}
|
||||
(Role::User, MessageContent::Text(text)) => {
|
||||
oai_messages.push(OaiMessage {
|
||||
@@ -386,59 +513,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
(Role::Assistant, MessageContent::Blocks(blocks)) => {
|
||||
let mut text_parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
let mut reasoning_text = String::new();
|
||||
for block in blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => text_parts.push(text.clone()),
|
||||
ContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
tool_calls.push(OaiToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
function: OaiFunction {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input).unwrap_or_default(),
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
reasoning_text = thinking.clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_tool_calls = !tool_calls.is_empty();
|
||||
let needs_reasoning = self.needs_reasoning_content(&request.model);
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: if text_parts.is_empty() {
|
||||
if has_tool_calls {
|
||||
Some(OaiMessageContent::Text(String::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(OaiMessageContent::Text(text_parts.join("")))
|
||||
},
|
||||
tool_calls: if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
tool_call_id: None,
|
||||
reasoning_content: if needs_reasoning {
|
||||
Some(if reasoning_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
reasoning_text
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
let assembled = assemble_assistant_message(blocks, &request.model, self);
|
||||
oai_messages.push(assembled);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -652,8 +728,15 @@ impl LlmDriver for OpenAIDriver {
|
||||
len = reasoning.len(),
|
||||
"Captured reasoning_content from response"
|
||||
);
|
||||
// Mark the format so the outbound path knows to re-emit
|
||||
// this as a `reasoning_content` field rather than as
|
||||
// inline `<think>` tags. Issue #1098.
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: reasoning.clone(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "reasoning_content"
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -666,8 +749,14 @@ impl LlmDriver for OpenAIDriver {
|
||||
if let Some(think_text) = thinking {
|
||||
// Only add if we didn't already get reasoning_content
|
||||
if choice.message.reasoning_content.is_none() {
|
||||
// Mark the format so we re-emit as inline `<think>`
|
||||
// tags on the next turn (MiniMax/M2.5 style).
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: think_text,
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "inline_think"
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -694,7 +783,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
let thinking_text = content
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
ContentBlock::Thinking { thinking } => Some(thinking.as_str()),
|
||||
ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or("");
|
||||
@@ -793,16 +882,14 @@ impl LlmDriver for OpenAIDriver {
|
||||
|
||||
for msg in &request.messages {
|
||||
match (&msg.role, &msg.content) {
|
||||
(Role::System, MessageContent::Text(text)) => {
|
||||
if request.system.is_none() {
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OaiMessageContent::Text(text.clone())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
}
|
||||
(Role::System, MessageContent::Text(text)) if request.system.is_none() => {
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OaiMessageContent::Text(text.clone())),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
reasoning_content: None,
|
||||
});
|
||||
}
|
||||
(Role::User, MessageContent::Text(text)) => {
|
||||
oai_messages.push(OaiMessage {
|
||||
@@ -845,59 +932,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
(Role::Assistant, MessageContent::Blocks(blocks)) => {
|
||||
let mut text_parts = Vec::new();
|
||||
let mut tool_calls_out = Vec::new();
|
||||
let mut reasoning_text = String::new();
|
||||
for block in blocks {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => text_parts.push(text.clone()),
|
||||
ContentBlock::ToolUse {
|
||||
id, name, input, ..
|
||||
} => {
|
||||
tool_calls_out.push(OaiToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
function: OaiFunction {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input).unwrap_or_default(),
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
reasoning_text = thinking.clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let has_tool_calls = !tool_calls_out.is_empty();
|
||||
let needs_reasoning = self.needs_reasoning_content(&request.model);
|
||||
oai_messages.push(OaiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: if text_parts.is_empty() {
|
||||
if has_tool_calls {
|
||||
Some(OaiMessageContent::Text(String::new()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(OaiMessageContent::Text(text_parts.join("")))
|
||||
},
|
||||
tool_calls: if tool_calls_out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls_out)
|
||||
},
|
||||
tool_call_id: None,
|
||||
reasoning_content: if needs_reasoning {
|
||||
Some(if reasoning_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
reasoning_text
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
});
|
||||
let assembled = assemble_assistant_message(blocks, &request.model, self);
|
||||
oai_messages.push(assembled);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1296,8 +1332,15 @@ impl LlmDriver for OpenAIDriver {
|
||||
|
||||
// Add reasoning/thinking content if present
|
||||
if !reasoning_content.is_empty() {
|
||||
// Mark format so outbound path replays this as
|
||||
// `reasoning_content` (DeepSeek-R1, Qwen3, MiniMax via
|
||||
// LM Studio/Ollama). Issue #1098.
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: reasoning_content.clone(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "reasoning_content"
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1307,8 +1350,14 @@ impl LlmDriver for OpenAIDriver {
|
||||
if let Some(think_text) = thinking {
|
||||
// Only add if we didn't already get reasoning_content
|
||||
if reasoning_content.is_empty() {
|
||||
// Mark as inline-think so the next outbound turn
|
||||
// re-emits the content wrapped in `<think>...</think>`.
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: think_text,
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({
|
||||
"format": "inline_think"
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1333,7 +1382,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
let thinking_text = content
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
ContentBlock::Thinking { thinking } => Some(thinking.as_str()),
|
||||
ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or("");
|
||||
@@ -1898,4 +1947,137 @@ mod tests {
|
||||
let url = driver.chat_url("moonshot-v1-128k");
|
||||
assert_eq!(url, "https://api.moonshot.ai/v1/chat/completions");
|
||||
}
|
||||
|
||||
// ── issue #1098: thinking-block round-trip ────────────────────────
|
||||
|
||||
/// Inline `<think>` blocks captured on ingress must be re-emitted in
|
||||
/// historical assistant turns so MiniMax-style models retain reasoning
|
||||
/// state across turns.
|
||||
#[test]
|
||||
fn test_assemble_assistant_replays_inline_think() {
|
||||
let driver = OpenAIDriver::new(
|
||||
"test".to_string(),
|
||||
"https://api.minimax.chat/v1".to_string(),
|
||||
);
|
||||
let blocks = vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "step-by-step reasoning".to_string(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({"format": "inline_think"})),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Hello, user.".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
let msg = assemble_assistant_message(&blocks, "minimax-m2.5", &driver);
|
||||
let content = match msg.content {
|
||||
Some(OaiMessageContent::Text(t)) => t,
|
||||
_ => panic!("expected text content"),
|
||||
};
|
||||
assert_eq!(
|
||||
content, "<think>step-by-step reasoning</think>Hello, user.",
|
||||
"inline_think must be re-emitted as <think> wrapping prepended to text"
|
||||
);
|
||||
// No reasoning_content field should be set for non-Moonshot models.
|
||||
assert!(msg.reasoning_content.is_none());
|
||||
}
|
||||
|
||||
/// `reasoning_content`-flavoured Thinking blocks must re-emit on the
|
||||
/// `reasoning_content` field, NOT inline (DeepSeek-R1, Qwen3, MiniMax M2
|
||||
/// via LM Studio/Ollama).
|
||||
#[test]
|
||||
fn test_assemble_assistant_replays_reasoning_content_field() {
|
||||
let driver = OpenAIDriver::new(
|
||||
"test".to_string(),
|
||||
"https://api.deepseek.com/v1".to_string(),
|
||||
);
|
||||
let blocks = vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "internal chain-of-thought".to_string(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({"format": "reasoning_content"})),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "answer".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
let msg = assemble_assistant_message(&blocks, "deepseek-reasoner", &driver);
|
||||
let content = match msg.content {
|
||||
Some(OaiMessageContent::Text(t)) => t,
|
||||
_ => panic!("expected text content"),
|
||||
};
|
||||
assert_eq!(content, "answer", "visible content must not include <think>");
|
||||
assert_eq!(
|
||||
msg.reasoning_content.as_deref(),
|
||||
Some("internal chain-of-thought"),
|
||||
"reasoning_content field must carry the reasoning text"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without thinking blocks, the outbound message should be a plain
|
||||
/// assistant message — preserve the legacy shape.
|
||||
#[test]
|
||||
fn test_assemble_assistant_no_thinking_is_plain() {
|
||||
let driver =
|
||||
OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string());
|
||||
let blocks = vec![ContentBlock::Text {
|
||||
text: "Hi.".to_string(),
|
||||
provider_metadata: None,
|
||||
}];
|
||||
let msg = assemble_assistant_message(&blocks, "gpt-4o", &driver);
|
||||
match msg.content {
|
||||
Some(OaiMessageContent::Text(t)) => assert_eq!(t, "Hi."),
|
||||
_ => panic!("expected text content"),
|
||||
}
|
||||
assert!(msg.reasoning_content.is_none());
|
||||
}
|
||||
|
||||
/// Issue #1098 round-trip: parse a wire response with `reasoning_content`,
|
||||
/// then feed the parsed assistant turn back through the outbound path
|
||||
/// and confirm the reasoning is replayed.
|
||||
#[test]
|
||||
fn test_reasoning_content_full_round_trip() {
|
||||
// Step 1: parse server response shape.
|
||||
let json = serde_json::json!({
|
||||
"content": "Final answer.",
|
||||
"reasoning_content": "I considered options A, B, and C…",
|
||||
"tool_calls": null
|
||||
});
|
||||
let server_msg: OaiResponseMessage = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(server_msg.content.as_deref(), Some("Final answer."));
|
||||
assert_eq!(
|
||||
server_msg.reasoning_content.as_deref(),
|
||||
Some("I considered options A, B, and C…")
|
||||
);
|
||||
|
||||
// Step 2: simulate the driver building blocks (mirrors the live
|
||||
// path in `complete()`).
|
||||
let mut content = Vec::new();
|
||||
if let Some(ref reasoning) = server_msg.reasoning_content {
|
||||
content.push(ContentBlock::Thinking {
|
||||
thinking: reasoning.clone(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({"format": "reasoning_content"})),
|
||||
});
|
||||
}
|
||||
content.push(ContentBlock::Text {
|
||||
text: server_msg.content.unwrap(),
|
||||
provider_metadata: None,
|
||||
});
|
||||
|
||||
// Step 3: replay through the outbound path.
|
||||
let driver = OpenAIDriver::new(
|
||||
"test".to_string(),
|
||||
"https://api.deepseek.com/v1".to_string(),
|
||||
);
|
||||
let outbound = assemble_assistant_message(&content, "deepseek-reasoner", &driver);
|
||||
// The reasoning_content field must round-trip verbatim.
|
||||
assert_eq!(
|
||||
outbound.reasoning_content.as_deref(),
|
||||
Some("I considered options A, B, and C…"),
|
||||
"issue #1098 regression: reasoning was stripped on resubmission"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
//! They receive `&GuestState` (not `&mut`) and return JSON values.
|
||||
|
||||
use crate::sandbox::GuestState;
|
||||
use crate::web_fetch;
|
||||
use openfang_types::capability::{capability_matches, Capability};
|
||||
use serde_json::json;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::path::{Component, Path};
|
||||
use tracing::debug;
|
||||
|
||||
@@ -117,64 +117,9 @@ fn safe_resolve_parent(path: &str) -> Result<std::path::PathBuf, serde_json::Val
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSRF protection
|
||||
// SSRF protection — delegates to the canonical implementation in web_fetch.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// SSRF protection: check if a hostname resolves to a private/internal IP.
|
||||
/// This defeats DNS rebinding by checking the RESOLVED address, not the hostname.
|
||||
fn is_ssrf_target(url: &str) -> Result<(), serde_json::Value> {
|
||||
// Only allow http:// and https:// schemes (block file://, gopher://, ftp://)
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return Err(json!({"error": "Only http:// and https:// URLs are allowed"}));
|
||||
}
|
||||
|
||||
let host = extract_host_from_url(url);
|
||||
let hostname = host.split(':').next().unwrap_or(&host);
|
||||
|
||||
// Check hostname-based blocklist first (catches metadata endpoints)
|
||||
let blocked_hostnames = [
|
||||
"localhost",
|
||||
"metadata.google.internal",
|
||||
"metadata.aws.internal",
|
||||
"instance-data",
|
||||
"169.254.169.254",
|
||||
];
|
||||
if blocked_hostnames.contains(&hostname) {
|
||||
return Err(json!({"error": format!("SSRF blocked: {hostname} is a restricted hostname")}));
|
||||
}
|
||||
|
||||
// Resolve DNS and check every returned IP
|
||||
let port = if url.starts_with("https") { 443 } else { 80 };
|
||||
let socket_addr = format!("{hostname}:{port}");
|
||||
if let Ok(addrs) = socket_addr.to_socket_addrs() {
|
||||
for addr in addrs {
|
||||
let ip = addr.ip();
|
||||
if ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) {
|
||||
return Err(json!({"error": format!(
|
||||
"SSRF blocked: {hostname} resolves to private IP {ip}"
|
||||
)}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_private_ip(ip: &std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
matches!(
|
||||
octets,
|
||||
[10, ..] | [172, 16..=31, ..] | [192, 168, ..] | [169, 254, ..]
|
||||
)
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let segments = v6.segments();
|
||||
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Always-allowed functions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -279,13 +224,15 @@ fn host_net_fetch(state: &GuestState, params: &serde_json::Value) -> serde_json:
|
||||
.unwrap_or("GET");
|
||||
let body = params.get("body").and_then(|b| b.as_str()).unwrap_or("");
|
||||
|
||||
// SECURITY: SSRF protection — check resolved IP against private ranges
|
||||
if let Err(e) = is_ssrf_target(url) {
|
||||
return e;
|
||||
// SECURITY: SSRF protection — delegates to the canonical check in web_fetch
|
||||
// which includes the full blocklist, metadata IP detection, IPv6 support,
|
||||
// and respects the ssrf_allowed_hosts configuration.
|
||||
if let Err(msg) = web_fetch::check_ssrf(url, &state.ssrf_allowed_hosts) {
|
||||
return json!({"error": msg});
|
||||
}
|
||||
|
||||
// Extract host:port from URL for capability check
|
||||
let host = extract_host_from_url(url);
|
||||
let host = web_fetch::extract_host(url);
|
||||
if let Err(e) = check_capability(&state.capabilities, &Capability::NetConnect(host)) {
|
||||
return e;
|
||||
}
|
||||
@@ -311,21 +258,6 @@ fn host_net_fetch(state: &GuestState, params: &serde_json::Value) -> serde_json:
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract host:port from a URL for capability checking.
|
||||
fn extract_host_from_url(url: &str) -> String {
|
||||
if let Some(after_scheme) = url.split("://").nth(1) {
|
||||
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||
if host_port.contains(':') {
|
||||
host_port.to_string()
|
||||
} else if url.starts_with("https") {
|
||||
format!("{host_port}:443")
|
||||
} else {
|
||||
format!("{host_port}:80")
|
||||
}
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell (capability-checked)
|
||||
@@ -501,6 +433,7 @@ mod tests {
|
||||
kernel: None,
|
||||
agent_id: "test-agent".to_string(),
|
||||
tokio_handle: tokio::runtime::Handle::current(),
|
||||
ssrf_allowed_hosts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,51 +551,65 @@ mod tests {
|
||||
assert!(safe_resolve_parent("/tmp/../../etc/shadow").is_err());
|
||||
}
|
||||
|
||||
// SSRF tests now exercise the canonical implementation in web_fetch.rs,
|
||||
// which is the same code path used by host_net_fetch at runtime.
|
||||
// This verifies the integration works end-to-end for WASM host calls.
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_private_ips_blocked() {
|
||||
assert!(is_ssrf_target("http://127.0.0.1:8080/secret").is_err());
|
||||
assert!(is_ssrf_target("http://localhost:3000/api").is_err());
|
||||
assert!(is_ssrf_target("http://169.254.169.254/metadata").is_err());
|
||||
assert!(is_ssrf_target("http://metadata.google.internal/v1/instance").is_err());
|
||||
let no_allow: Vec<String> = vec![];
|
||||
assert!(web_fetch::check_ssrf("http://127.0.0.1:8080/secret", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("http://localhost:3000/api", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("http://169.254.169.254/metadata", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("http://metadata.google.internal/v1/instance", &no_allow).is_err());
|
||||
// These were previously missing from host_functions — now covered:
|
||||
assert!(web_fetch::check_ssrf("http://[::1]:8080/secret", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("http://100.100.100.200/metadata", &no_allow).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_public_ips_allowed() {
|
||||
assert!(is_ssrf_target("https://api.openai.com/v1/chat").is_ok());
|
||||
assert!(is_ssrf_target("https://google.com").is_ok());
|
||||
let no_allow: Vec<String> = vec![];
|
||||
assert!(web_fetch::check_ssrf("https://api.openai.com/v1/chat", &no_allow).is_ok());
|
||||
assert!(web_fetch::check_ssrf("https://google.com", &no_allow).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_scheme_validation() {
|
||||
assert!(is_ssrf_target("file:///etc/passwd").is_err());
|
||||
assert!(is_ssrf_target("gopher://evil.com").is_err());
|
||||
assert!(is_ssrf_target("ftp://example.com").is_err());
|
||||
let no_allow: Vec<String> = vec![];
|
||||
assert!(web_fetch::check_ssrf("file:///etc/passwd", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("gopher://evil.com", &no_allow).is_err());
|
||||
assert!(web_fetch::check_ssrf("ftp://example.com", &no_allow).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_private_ip() {
|
||||
use std::net::IpAddr;
|
||||
assert!(is_private_ip(&"10.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(&"172.16.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(&"192.168.1.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_private_ip(&"169.254.169.254".parse::<IpAddr>().unwrap()));
|
||||
assert!(!is_private_ip(&"8.8.8.8".parse::<IpAddr>().unwrap()));
|
||||
assert!(!is_private_ip(&"1.1.1.1".parse::<IpAddr>().unwrap()));
|
||||
fn test_ssrf_allowlist_respected() {
|
||||
let allowed = vec!["192.168.1.0/24".to_string()];
|
||||
// Private IP that matches allowlist — should pass
|
||||
assert!(web_fetch::check_ssrf("http://192.168.1.100:8080/api", &allowed).is_ok());
|
||||
// Private IP outside allowlist — should still block
|
||||
let no_allow: Vec<String> = vec![];
|
||||
assert!(web_fetch::check_ssrf("http://192.168.1.100:8080/api", &no_allow).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_host_from_url() {
|
||||
fn test_extract_host_delegates_to_web_fetch() {
|
||||
assert_eq!(
|
||||
extract_host_from_url("https://api.openai.com/v1/chat"),
|
||||
web_fetch::extract_host("https://api.openai.com/v1/chat"),
|
||||
"api.openai.com:443"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_host_from_url("http://localhost:8080/api"),
|
||||
web_fetch::extract_host("http://localhost:8080/api"),
|
||||
"localhost:8080"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_host_from_url("http://example.com"),
|
||||
web_fetch::extract_host("http://example.com"),
|
||||
"example.com:80"
|
||||
);
|
||||
// IPv6 — previously not handled by host_functions
|
||||
assert_eq!(
|
||||
web_fetch::extract_host("http://[::1]:9090/test"),
|
||||
"[::1]:9090"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,13 @@ impl McpConnection {
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
// Some stdio MCP servers launched via node/npx require a usable home
|
||||
// directory even when they do not declare any explicit secret env vars.
|
||||
for var in &["HOME", "TMP", "TEMP"] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
cmd.env(var, val);
|
||||
}
|
||||
}
|
||||
// On Windows, npm/node need extra vars
|
||||
if cfg!(windows) {
|
||||
for var in &[
|
||||
@@ -254,9 +261,6 @@ impl McpConnection {
|
||||
"LOCALAPPDATA",
|
||||
"USERPROFILE",
|
||||
"SystemRoot",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"HOME",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
] {
|
||||
|
||||
@@ -1012,13 +1012,24 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("qwen-coder", "qwen-code/qwen3-coder"),
|
||||
("qwen-coder-plus", "qwen-code/qwen-coder-plus"),
|
||||
("qwq", "qwen-code/qwq-32b"),
|
||||
// OpenRouter free-tier aliases
|
||||
// OpenRouter free-tier aliases. Point to free models that actually support
|
||||
// tool calling on OpenRouter's free endpoints — agents send tool definitions
|
||||
// by default, so a non-tool model returns "No endpoints found that support
|
||||
// tool use" (issue #1032).
|
||||
(
|
||||
"openrouter/free",
|
||||
"openrouter/meta-llama/llama-3.1-8b-instruct:free",
|
||||
"openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
||||
),
|
||||
("free", "openrouter/meta-llama/llama-3.1-8b-instruct:free"),
|
||||
("free", "openrouter/meta-llama/llama-3.3-70b-instruct:free"),
|
||||
("free-reasoning", "openrouter/deepseek/deepseek-r1:free"),
|
||||
(
|
||||
"openrouter/free-coder",
|
||||
"openrouter/qwen/qwen3-coder:free",
|
||||
),
|
||||
(
|
||||
"openrouter/free-large",
|
||||
"openrouter/openai/gpt-oss-120b:free",
|
||||
),
|
||||
];
|
||||
pairs
|
||||
.into_iter()
|
||||
@@ -1721,7 +1732,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// OpenRouter (10) — pass-through models using real upstream IDs
|
||||
// OpenRouter (15+) — pass-through models using real upstream IDs
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/google/gemini-2.5-flash".into(),
|
||||
@@ -1879,6 +1890,10 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
// NOTE: OpenRouter's free endpoint for this model rejects tool-use
|
||||
// requests ("No endpoints found that support tool use"), so we mark
|
||||
// it as no-tool to keep agents from sending tool definitions to it.
|
||||
// The paid version of llama-3.1-8b-instruct does support tools.
|
||||
id: "openrouter/meta-llama/llama-3.1-8b-instruct:free".into(),
|
||||
display_name: "Llama 3.1 8B Free (OpenRouter)".into(),
|
||||
provider: "openrouter".into(),
|
||||
@@ -1887,18 +1902,93 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
max_output_tokens: 4_096,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
// Same caveat as above — OpenRouter's free 7B endpoint has no tool
|
||||
// support; use qwen3-coder:free for tool-using free workloads.
|
||||
id: "openrouter/qwen/qwen-2.5-7b-instruct:free".into(),
|
||||
display_name: "Qwen 2.5 7B Free (OpenRouter)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 32_768,
|
||||
max_output_tokens: 4_096,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
// Free models that DO support tool calling on OpenRouter's free tier.
|
||||
// Verified against `GET https://openrouter.ai/api/v1/models` —
|
||||
// `supported_parameters` includes "tools" for these IDs.
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/meta-llama/llama-3.3-70b-instruct:free".into(),
|
||||
display_name: "Llama 3.3 70B Free (OpenRouter)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 65_536,
|
||||
max_output_tokens: 4_096,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/qwen/qwen3-coder:free".into(),
|
||||
display_name: "Qwen3 Coder Free (OpenRouter)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 262_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/openai/gpt-oss-120b:free".into(),
|
||||
display_name: "GPT-OSS 120B Free (OpenRouter)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/openai/gpt-oss-20b:free".into(),
|
||||
display_name: "GPT-OSS 20B Free (OpenRouter)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 4_096,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/qwen/qwen-2.5-7b-instruct:free".into(),
|
||||
display_name: "Qwen 2.5 7B Free (OpenRouter)".into(),
|
||||
id: "openrouter/z-ai/glm-4.5-air:free".into(),
|
||||
display_name: "GLM 4.5 Air Free (OpenRouter)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 32_768,
|
||||
max_output_tokens: 4_096,
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
@@ -4510,4 +4600,106 @@ mod tests {
|
||||
assert_eq!(found.provider, "custom_provider");
|
||||
assert_eq!(found.id, "My-Custom-LLM");
|
||||
}
|
||||
|
||||
// ── OpenRouter free-tier fixes (issue #1032) ──────────────────────────
|
||||
|
||||
/// `openrouter/free` and `free` aliases must point to a free model that
|
||||
/// actually supports tool calling on OpenRouter's free endpoints.
|
||||
/// Previously they pointed to `llama-3.1-8b-instruct:free`, which OpenRouter
|
||||
/// rejects with "No endpoints found that support tool use" when agents
|
||||
/// send tool definitions.
|
||||
#[test]
|
||||
fn test_openrouter_free_alias_supports_tools() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog.find_model("openrouter/free").expect(
|
||||
"openrouter/free alias must resolve to a known model",
|
||||
);
|
||||
assert_eq!(entry.provider, "openrouter");
|
||||
assert!(
|
||||
entry.supports_tools,
|
||||
"openrouter/free must resolve to a tool-capable model (issue #1032). \
|
||||
Resolved to {} which has supports_tools=false",
|
||||
entry.id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openrouter_free_short_alias_supports_tools() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog
|
||||
.find_model("free")
|
||||
.expect("free alias must resolve");
|
||||
assert_eq!(entry.provider, "openrouter");
|
||||
assert!(
|
||||
entry.supports_tools,
|
||||
"`free` alias must resolve to a tool-capable model"
|
||||
);
|
||||
}
|
||||
|
||||
/// Confirm the resolved free model's ID is one of the verified
|
||||
/// tool-supporting free endpoints on OpenRouter.
|
||||
#[test]
|
||||
fn test_openrouter_free_alias_target() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let resolved = catalog
|
||||
.resolve_alias("openrouter/free")
|
||||
.expect("alias must exist");
|
||||
// Must be one of the known-good free models with tool support.
|
||||
let known_good = [
|
||||
"openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
||||
"openrouter/qwen/qwen3-coder:free",
|
||||
"openrouter/openai/gpt-oss-120b:free",
|
||||
"openrouter/openai/gpt-oss-20b:free",
|
||||
"openrouter/z-ai/glm-4.5-air:free",
|
||||
];
|
||||
assert!(
|
||||
known_good.contains(&resolved),
|
||||
"openrouter/free resolves to {}, expected one of: {:?}",
|
||||
resolved,
|
||||
known_good
|
||||
);
|
||||
}
|
||||
|
||||
/// New free-tier tool-using models are present in the catalog.
|
||||
#[test]
|
||||
fn test_openrouter_free_tool_models_present() {
|
||||
let catalog = ModelCatalog::new();
|
||||
for id in [
|
||||
"openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
||||
"openrouter/qwen/qwen3-coder:free",
|
||||
"openrouter/openai/gpt-oss-120b:free",
|
||||
"openrouter/openai/gpt-oss-20b:free",
|
||||
"openrouter/z-ai/glm-4.5-air:free",
|
||||
] {
|
||||
let entry = catalog
|
||||
.find_model(id)
|
||||
.unwrap_or_else(|| panic!("missing free model {}", id));
|
||||
assert_eq!(entry.provider, "openrouter");
|
||||
assert!(entry.supports_tools, "{} must support tools", id);
|
||||
assert_eq!(entry.input_cost_per_m, 0.0, "{} must be free", id);
|
||||
assert_eq!(entry.output_cost_per_m, 0.0, "{} must be free", id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Free models that OpenRouter's free endpoint does NOT route to a
|
||||
/// tool-supporting backend must be marked `supports_tools=false` so
|
||||
/// agents don't send tool defs that get rejected.
|
||||
#[test]
|
||||
fn test_openrouter_free_no_tool_models_marked() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let llama8b = catalog
|
||||
.find_model("openrouter/meta-llama/llama-3.1-8b-instruct:free")
|
||||
.expect("model must exist");
|
||||
assert!(
|
||||
!llama8b.supports_tools,
|
||||
"llama-3.1-8b-instruct:free has no tool-supporting free endpoint"
|
||||
);
|
||||
let qwen7b = catalog
|
||||
.find_model("openrouter/qwen/qwen-2.5-7b-instruct:free")
|
||||
.expect("model must exist");
|
||||
assert!(
|
||||
!qwen7b.supports_tools,
|
||||
"qwen-2.5-7b-instruct:free has no tool-supporting free endpoint"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct SandboxConfig {
|
||||
/// Wall-clock timeout in seconds for epoch-based interruption.
|
||||
/// Defaults to 30 seconds if None.
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// Hosts allowed to bypass SSRF private-IP checks.
|
||||
/// Forwarded from `[web.fetch] ssrf_allowed_hosts` in config.toml.
|
||||
pub ssrf_allowed_hosts: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for SandboxConfig {
|
||||
@@ -51,6 +54,7 @@ impl Default for SandboxConfig {
|
||||
max_memory_bytes: 16 * 1024 * 1024,
|
||||
capabilities: Vec::new(),
|
||||
timeout_secs: None,
|
||||
ssrf_allowed_hosts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +69,8 @@ pub struct GuestState {
|
||||
pub agent_id: String,
|
||||
/// Tokio runtime handle for async operations in sync host functions.
|
||||
pub tokio_handle: tokio::runtime::Handle,
|
||||
/// Hosts allowed to bypass SSRF private-IP checks (from config).
|
||||
pub ssrf_allowed_hosts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of executing a WASM module.
|
||||
@@ -164,6 +170,7 @@ impl WasmSandbox {
|
||||
kernel,
|
||||
agent_id: agent_id.to_string(),
|
||||
tokio_handle,
|
||||
ssrf_allowed_hosts: config.ssrf_allowed_hosts.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ fn reorder_tool_results(messages: &mut Vec<Message>) -> usize {
|
||||
|
||||
// Insert in reverse order so indices remain valid
|
||||
let mut sorted_insertions: Vec<(usize, Vec<ContentBlock>)> = insertions.into_iter().collect();
|
||||
sorted_insertions.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
sorted_insertions.sort_by_key(|b| std::cmp::Reverse(b.0));
|
||||
|
||||
for (orig_assistant_idx, blocks) in sorted_insertions {
|
||||
if let Some(¤t_idx) = current_assistant_positions.get(&orig_assistant_idx) {
|
||||
@@ -433,7 +433,7 @@ fn insert_synthetic_results(messages: &mut Vec<Message>) -> usize {
|
||||
|
||||
// Insert in reverse order so indices stay valid
|
||||
let mut sorted: Vec<(usize, Vec<ContentBlock>)> = grouped.into_iter().collect();
|
||||
sorted.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
sorted.sort_by_key(|b| std::cmp::Reverse(b.0));
|
||||
|
||||
for (assistant_idx, blocks) in sorted {
|
||||
let insert_pos = assistant_idx + 1;
|
||||
|
||||
@@ -366,7 +366,10 @@ fn is_private_ip(ip: &IpAddr) -> bool {
|
||||
}
|
||||
|
||||
/// Extract host:port from a URL.
|
||||
fn extract_host(url: &str) -> String {
|
||||
///
|
||||
/// Handles IPv6 bracket notation (`[::1]:8080`), and infers default
|
||||
/// ports (80 for HTTP, 443 for HTTPS) when no explicit port is given.
|
||||
pub(crate) fn extract_host(url: &str) -> String {
|
||||
if let Some(after_scheme) = url.split("://").nth(1) {
|
||||
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
|
||||
// Handle IPv6 bracket notation: [::1]:8080
|
||||
|
||||
@@ -742,16 +742,32 @@ pub struct AgentBinding {
|
||||
}
|
||||
|
||||
/// Match rule for agent bindings. All specified (non-None) fields must match.
|
||||
///
|
||||
/// `#[serde(deny_unknown_fields)]` is intentional: a typo like `channnel_id` or
|
||||
/// `chan_id` would otherwise be silently dropped, producing a wide-open binding
|
||||
/// that matches every message. Failing loudly at config load is the safer default.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BindingMatchRule {
|
||||
/// Channel type (e.g., "discord", "telegram", "slack").
|
||||
#[serde(default)]
|
||||
pub channel: Option<String>,
|
||||
/// Specific account/bot ID within the channel.
|
||||
#[serde(default)]
|
||||
pub account_id: Option<String>,
|
||||
/// Peer/user ID for DM routing.
|
||||
#[serde(default)]
|
||||
pub peer_id: Option<String>,
|
||||
/// Guild/server ID (Discord/Slack).
|
||||
#[serde(default)]
|
||||
pub guild_id: Option<String>,
|
||||
/// Channel/conversation ID — the per-channel routing dimension.
|
||||
/// On Discord this is the channel/thread ID; on Slack it is the conversation
|
||||
/// ID (`C…`/`D…`/`G…`); on Telegram it is the chat ID; on IRC it is the
|
||||
/// channel name. Bridges populate this from the message's channel/conversation
|
||||
/// identifier so bindings can route by room independent of which user posted.
|
||||
#[serde(default)]
|
||||
pub channel_id: Option<String>,
|
||||
/// Role-based routing (user must have at least one).
|
||||
#[serde(default)]
|
||||
pub roles: Vec<String>,
|
||||
@@ -760,11 +776,17 @@ pub struct BindingMatchRule {
|
||||
impl BindingMatchRule {
|
||||
/// Calculate specificity score for binding priority ordering.
|
||||
/// Higher = more specific = checked first.
|
||||
///
|
||||
/// Weights: peer_id and channel_id are both 8 so a binding that combines
|
||||
/// both (a specific user in a specific room) cleanly outranks either alone.
|
||||
pub fn specificity(&self) -> u32 {
|
||||
let mut score = 0u32;
|
||||
if self.peer_id.is_some() {
|
||||
score += 8;
|
||||
}
|
||||
if self.channel_id.is_some() {
|
||||
score += 8;
|
||||
}
|
||||
if self.guild_id.is_some() {
|
||||
score += 4;
|
||||
}
|
||||
|
||||
@@ -85,10 +85,26 @@ pub enum ContentBlock {
|
||||
is_error: bool,
|
||||
},
|
||||
/// Extended thinking content block (model's reasoning trace).
|
||||
///
|
||||
/// Preserved across turns so reasoning models retain state. Anthropic's
|
||||
/// extended thinking requires the `signature` to be echoed on resubmission;
|
||||
/// other providers (Gemini thought signatures, DeepSeek/Qwen
|
||||
/// `reasoning_content`, MiniMax inline `<think>`) round-trip via
|
||||
/// `provider_metadata` or by inlining into the assistant message body.
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking {
|
||||
/// The thinking/reasoning text.
|
||||
thinking: String,
|
||||
/// Provider-issued signature required to resubmit thinking blocks
|
||||
/// (Anthropic extended thinking). `None` for providers that don't
|
||||
/// emit a signature.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<String>,
|
||||
/// Provider-specific metadata (e.g. `{"format": "reasoning_content"}`
|
||||
/// or `{"format": "inline_think"}` so the outbound driver knows how
|
||||
/// the upstream model originally delivered the reasoning).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
provider_metadata: Option<serde_json::Value>,
|
||||
},
|
||||
/// Catch-all for unrecognized content block types (forward compatibility).
|
||||
#[serde(other)]
|
||||
@@ -141,7 +157,7 @@ impl MessageContent {
|
||||
.map(|b| match b {
|
||||
ContentBlock::Text { text, .. } => text.len(),
|
||||
ContentBlock::ToolResult { content, .. } => content.len(),
|
||||
ContentBlock::Thinking { thinking } => thinking.len(),
|
||||
ContentBlock::Thinking { thinking, .. } => thinking.len(),
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
name.len() + input.to_string().len()
|
||||
}
|
||||
@@ -199,6 +215,17 @@ impl Message {
|
||||
content: MessageContent::Text(content.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an assistant message with structured content blocks.
|
||||
///
|
||||
/// Used to preserve `Thinking` blocks (with signatures and reasoning text)
|
||||
/// across persistence so reasoning models retain state between turns.
|
||||
pub fn assistant_with_blocks(blocks: Vec<ContentBlock>) -> Self {
|
||||
Self {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(blocks),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the LLM stopped generating.
|
||||
@@ -311,6 +338,119 @@ mod tests {
|
||||
assert!(matches!(block, ContentBlock::Unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_block_roundtrip_preserves_signature() {
|
||||
// Anthropic extended thinking — the signature MUST round-trip through
|
||||
// serde so it can be echoed on the next request.
|
||||
let block = ContentBlock::Thinking {
|
||||
thinking: "Let me reason about this carefully...".to_string(),
|
||||
signature: Some("sig_abc123_anthropic_extended_thinking".to_string()),
|
||||
provider_metadata: None,
|
||||
};
|
||||
let json = serde_json::to_value(&block).unwrap();
|
||||
assert_eq!(json["type"], "thinking");
|
||||
assert_eq!(json["signature"], "sig_abc123_anthropic_extended_thinking");
|
||||
|
||||
// Round-trip through serialize → deserialize (e.g. SQLite session blob)
|
||||
let serialized = serde_json::to_string(&block).unwrap();
|
||||
let restored: ContentBlock = serde_json::from_str(&serialized).unwrap();
|
||||
match restored {
|
||||
ContentBlock::Thinking {
|
||||
thinking, signature, ..
|
||||
} => {
|
||||
assert_eq!(thinking, "Let me reason about this carefully...");
|
||||
assert_eq!(
|
||||
signature.as_deref(),
|
||||
Some("sig_abc123_anthropic_extended_thinking")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Thinking block"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_block_roundtrip_with_provider_metadata() {
|
||||
// OpenAI-compat models (DeepSeek-R1, Qwen3, MiniMax) — record the
|
||||
// wire format so the outbound driver knows whether to re-emit as
|
||||
// `reasoning_content` or inline `<think>` tags.
|
||||
let block = ContentBlock::Thinking {
|
||||
thinking: "step-by-step analysis".to_string(),
|
||||
signature: None,
|
||||
provider_metadata: Some(serde_json::json!({"format": "inline_think"})),
|
||||
};
|
||||
let serialized = serde_json::to_string(&block).unwrap();
|
||||
let restored: ContentBlock = serde_json::from_str(&serialized).unwrap();
|
||||
match restored {
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
provider_metadata,
|
||||
} => {
|
||||
assert_eq!(thinking, "step-by-step analysis");
|
||||
assert!(signature.is_none());
|
||||
let meta = provider_metadata.expect("provider_metadata preserved");
|
||||
assert_eq!(meta["format"], "inline_think");
|
||||
}
|
||||
_ => panic!("expected Thinking block"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_block_legacy_deser() {
|
||||
// Existing sessions on disk only have `{"type": "thinking", "thinking": "..."}`.
|
||||
// The new fields must be optional so old payloads still load.
|
||||
let json = serde_json::json!({"type": "thinking", "thinking": "old session reasoning"});
|
||||
let block: ContentBlock = serde_json::from_value(json).unwrap();
|
||||
match block {
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
provider_metadata,
|
||||
} => {
|
||||
assert_eq!(thinking, "old session reasoning");
|
||||
assert!(signature.is_none());
|
||||
assert!(provider_metadata.is_none());
|
||||
}
|
||||
_ => panic!("expected Thinking block"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_with_blocks_preserves_thinking() {
|
||||
// A complete round-trip: build an assistant turn that mixes Thinking +
|
||||
// Text (the shape we'll store after fix) and confirm the Thinking
|
||||
// block survives serialization (msgpack is what session.rs uses; JSON
|
||||
// exercises the same serde path).
|
||||
let msg = Message::assistant_with_blocks(vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "Internal reasoning".to_string(),
|
||||
signature: Some("sig_xyz".to_string()),
|
||||
provider_metadata: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Hello!".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
]);
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("msgpack encode");
|
||||
let restored: Message = rmp_serde::from_slice(&bytes).expect("msgpack decode");
|
||||
match restored.content {
|
||||
MessageContent::Blocks(blocks) => {
|
||||
assert_eq!(blocks.len(), 2);
|
||||
match &blocks[0] {
|
||||
ContentBlock::Thinking {
|
||||
thinking, signature, ..
|
||||
} => {
|
||||
assert_eq!(thinking, "Internal reasoning");
|
||||
assert_eq!(signature.as_deref(), Some("sig_xyz"));
|
||||
}
|
||||
_ => panic!("expected Thinking first"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected Blocks content"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_with_blocks() {
|
||||
let blocks = vec
|
||||
- [Section Reference](#section-reference)
|
||||
- [Top-Level Fields](#top-level-fields)
|
||||
- [Exposing the Dashboard](#exposing-the-dashboard)
|
||||
- [\[default\_model\]](#default_model)
|
||||
- [\[memory\]](#memory)
|
||||
- [\[network\]](#network)
|
||||
@@ -36,6 +37,13 @@ OpenFang reads its configuration from a single TOML file:
|
||||
|
||||
On Windows, `~` resolves to `C:\Users\<username>`. If the home directory cannot be determined, the system temp directory is used as a fallback.
|
||||
|
||||
The home directory is resolved with the following priority:
|
||||
|
||||
1. `OPENFANG_HOME` environment variable (e.g. `OPENFANG_HOME=/data` in the official Docker image).
|
||||
2. `~/.openfang` (default).
|
||||
|
||||
So inside the Docker container the config file must live at `/data/config.toml` (because the image sets `ENV OPENFANG_HOME=/data`). Placing it anywhere else (for example `/opt/openfang/config.toml`) will be silently ignored.
|
||||
|
||||
**Key behaviors:**
|
||||
|
||||
- Every struct in the configuration uses `#[serde(default)]`, which means **all fields are optional**. Omitted fields receive their documented default values.
|
||||
@@ -227,7 +235,7 @@ These fields sit at the root of `config.toml` (not inside any `[section]`).
|
||||
| `home_dir` | path | `~/.openfang` | OpenFang home directory. Stores config, agents, skills. |
|
||||
| `data_dir` | path | `~/.openfang/data` | Directory for SQLite databases and persistent data. |
|
||||
| `log_level` | string | `"info"` | Log verbosity. One of: `trace`, `debug`, `info`, `warn`, `error`. |
|
||||
| `api_listen` | string | `"127.0.0.1:50051"` | Bind address for the HTTP/WebSocket/SSE API server. |
|
||||
| `api_listen` | string | `"127.0.0.1:50051"` | Bind address for the HTTP/WebSocket/SSE API server. Use `0.0.0.0:<port>` to accept connections from outside the host (LAN, Docker, remote clients). See [Exposing the Dashboard](#exposing-the-dashboard) below before doing so. Can be overridden at runtime with the `OPENFANG_LISTEN` environment variable. |
|
||||
| `network_enabled` | bool | `false` | Enable the OFP peer-to-peer network layer. |
|
||||
| `api_key` | string | `""` (empty) | API authentication key. When set, all endpoints except `/api/health` require `Authorization: Bearer <key>`. Empty means unauthenticated (local development only). |
|
||||
| `mode` | string | `"default"` | Kernel operating mode. See below. |
|
||||
@@ -253,6 +261,59 @@ These fields sit at the root of `config.toml` (not inside any `[section]`).
|
||||
|
||||
---
|
||||
|
||||
### Exposing the Dashboard
|
||||
|
||||
By default OpenFang binds the API and dashboard to `127.0.0.1` (loopback only) so the daemon is unreachable from anywhere except the local machine. To accept connections from your LAN, Docker host, or a remote client you must explicitly opt in to non-loopback binding.
|
||||
|
||||
**Two ways to change the bind address:**
|
||||
|
||||
1. Edit `config.toml`:
|
||||
|
||||
```toml
|
||||
api_listen = "0.0.0.0:4200"
|
||||
```
|
||||
|
||||
2. Or set the `OPENFANG_LISTEN` environment variable (overrides `config.toml`):
|
||||
|
||||
```bash
|
||||
export OPENFANG_LISTEN=0.0.0.0:4200
|
||||
```
|
||||
|
||||
The env var route is the recommended path for Docker because it does not require mounting a config file.
|
||||
|
||||
**Docker example.** The official image sets `OPENFANG_HOME=/data` and exposes port `4200`. The simplest end-to-end setup is:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
openfang:
|
||||
image: ghcr.io/rightnow-ai/openfang:latest
|
||||
ports:
|
||||
- "4200:4200"
|
||||
volumes:
|
||||
- openfang-data:/data
|
||||
environment:
|
||||
- OPENFANG_LISTEN=0.0.0.0:4200 # required: bind to all interfaces inside the container
|
||||
- OPENFANG_API_KEY=${OPENFANG_API_KEY} # strongly recommended when exposing
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
volumes:
|
||||
openfang-data:
|
||||
```
|
||||
|
||||
If you prefer mounting `config.toml`, the file must live at `/data/config.toml` inside the container (because of `OPENFANG_HOME=/data`). Placing it at `/opt/openfang/config.toml` or any other path will not be picked up. The port in `api_listen` must also match the port published in `ports:` — the example config in `openfang.toml.example` ships with port `50051` to be safe; change it to `4200` (or whatever port you publish) when running in Docker.
|
||||
|
||||
**Security warning.** Once you bind to a non-loopback address, anyone reachable at that address can talk to the API. OpenFang's middleware enforces a fail-closed default:
|
||||
|
||||
- If `api_key` is empty AND dashboard auth is disabled AND the bind address is not loopback, all non-loopback requests are rejected with `401 Unauthorized`.
|
||||
- To run in this configuration anyway (not recommended), set `OPENFANG_ALLOW_NO_AUTH=1`. This will be loudly logged.
|
||||
|
||||
The supported ways to expose the dashboard safely:
|
||||
|
||||
- Set `api_key = "..."` in `config.toml` (or `OPENFANG_API_KEY=...`) and send `Authorization: Bearer <key>` on every request.
|
||||
- Or enable the [`[auth]`](#auth) section to require username/password login on the dashboard UI.
|
||||
- Or keep `api_listen` on `127.0.0.1` and reach the dashboard through an SSH tunnel or reverse proxy that handles authentication for you.
|
||||
|
||||
---
|
||||
|
||||
### `[default_model]`
|
||||
|
||||
Configures the primary LLM provider used when agents do not specify their own model.
|
||||
|
||||
@@ -88,6 +88,21 @@ export PATH="$HOME/.cargo/bin:$PATH"
|
||||
# Add to ~/.bashrc or ~/.zshrc to persist
|
||||
```
|
||||
|
||||
### Black screen on login after install (Arch / CachyOS / fish users)
|
||||
|
||||
**Cause**: Older OpenFang installers (`<v0.6.4`) appended a PATH line directly to `~/.config/fish/config.fish`. On Arch derivatives like CachyOS, the desktop session can source fish on login — a malformed or invalid PATH line then prevents the session from finishing, leaving you on a black screen.
|
||||
|
||||
**Fix**: Boot to a TTY (`Ctrl+Alt+F2`) and remove any OpenFang PATH lines from `config.fish`:
|
||||
```bash
|
||||
sed -i '/openfang/d' ~/.config/fish/config.fish
|
||||
```
|
||||
Then re-run the installer — current versions write to `~/.config/fish/conf.d/openfang.fish` (a drop-in directory) instead, and guard the path with `test -d` so a missing install dir can never wedge fish startup.
|
||||
|
||||
To remove OpenFang's PATH entry cleanly:
|
||||
```bash
|
||||
rm ~/.config/fish/conf.d/openfang.fish
|
||||
```
|
||||
|
||||
### Docker container won't start
|
||||
|
||||
**Common causes**:
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
# Copy to ~/.openfang/config.toml and customize.
|
||||
|
||||
# API server settings
|
||||
# api_key = "" # Set to enable Bearer auth (recommended)
|
||||
# api_listen = "127.0.0.1:50051" # HTTP API bind address (use 0.0.0.0 for public)
|
||||
# api_key = "" # Set to enable Bearer auth (strongly recommended when exposing)
|
||||
# api_listen = "127.0.0.1:50051" # HTTP API bind address.
|
||||
# Use "0.0.0.0:<port>" to expose on LAN / Docker. The port must
|
||||
# match the port you publish (Docker image exposes 4200). You can
|
||||
# also override at runtime with OPENFANG_LISTEN=0.0.0.0:4200.
|
||||
# See docs/configuration.md "Exposing the Dashboard".
|
||||
|
||||
[default_model]
|
||||
provider = "anthropic" # "anthropic", "gemini", "openai", "groq", "ollama", etc.
|
||||
|
||||
+58
-28
@@ -149,39 +149,69 @@ install() {
|
||||
USER_SHELL=$(grep "^$(id -un):" /etc/passwd 2>/dev/null | cut -d: -f7)
|
||||
fi
|
||||
|
||||
SHELL_RC=""
|
||||
# Fish shell: write to ~/.config/fish/conf.d/openfang.fish (drop-in dir).
|
||||
# This keeps the user's config.fish completely untouched, so a broken
|
||||
# PATH entry can never wedge the user's main shell config — critical
|
||||
# on Arch/CachyOS where the desktop session sources fish on login.
|
||||
USE_FISH_DROPIN=0
|
||||
case "$USER_SHELL" in
|
||||
*/zsh) SHELL_RC="$HOME/.zshrc" ;;
|
||||
*/bash) SHELL_RC="$HOME/.bashrc" ;;
|
||||
*/fish) SHELL_RC="$HOME/.config/fish/config.fish" ;;
|
||||
*/fish) USE_FISH_DROPIN=1 ;;
|
||||
esac
|
||||
# Also check for config files if shell detection failed.
|
||||
# Check bash/zsh first (more common defaults), fish last — avoids
|
||||
# writing to config.fish for users who merely have Fish installed.
|
||||
if [ -z "$SHELL_RC" ]; then
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
SHELL_RC="$HOME/.bashrc"
|
||||
elif [ -f "$HOME/.zshrc" ]; then
|
||||
SHELL_RC="$HOME/.zshrc"
|
||||
elif [ -f "$HOME/.config/fish/config.fish" ]; then
|
||||
SHELL_RC="$HOME/.config/fish/config.fish"
|
||||
fi
|
||||
# If $USER_SHELL didn't match fish but config.fish exists AND no other
|
||||
# rc files exist, the user is likely a fish user — use the drop-in too.
|
||||
if [ "$USE_FISH_DROPIN" -eq 0 ] \
|
||||
&& [ -f "$HOME/.config/fish/config.fish" ] \
|
||||
&& [ ! -f "$HOME/.bashrc" ] \
|
||||
&& [ ! -f "$HOME/.zshrc" ]; then
|
||||
USE_FISH_DROPIN=1
|
||||
fi
|
||||
|
||||
if [ -n "$SHELL_RC" ] && ! grep -q "openfang" "$SHELL_RC" 2>/dev/null; then
|
||||
# Determine syntax from the TARGET FILE, not $USER_SHELL — this
|
||||
# prevents Bash syntax from ever being written to config.fish even
|
||||
# when shell detection mis-identifies the user's shell.
|
||||
case "$SHELL_RC" in
|
||||
*/config.fish)
|
||||
mkdir -p "$(dirname "$SHELL_RC")"
|
||||
echo "fish_add_path \"$INSTALL_DIR\"" >> "$SHELL_RC"
|
||||
;;
|
||||
*)
|
||||
echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$SHELL_RC"
|
||||
;;
|
||||
if [ "$USE_FISH_DROPIN" -eq 1 ]; then
|
||||
FISH_CONF_DIR="$HOME/.config/fish/conf.d"
|
||||
FISH_DROPIN="$FISH_CONF_DIR/openfang.fish"
|
||||
mkdir -p "$FISH_CONF_DIR"
|
||||
if [ ! -f "$FISH_DROPIN" ]; then
|
||||
# Guarded with `test -d` so a missing/broken install dir never
|
||||
# breaks fish startup (which would black-screen Arch/CachyOS
|
||||
# desktop sessions that source fish at login).
|
||||
cat > "$FISH_DROPIN" <<EOF
|
||||
# OpenFang PATH — auto-generated by installer
|
||||
if test -d "$INSTALL_DIR"
|
||||
fish_add_path -g "$INSTALL_DIR"
|
||||
end
|
||||
EOF
|
||||
echo " Added $INSTALL_DIR to PATH via $FISH_DROPIN"
|
||||
fi
|
||||
# Best-effort: clean up legacy bash-syntax export from config.fish
|
||||
# written by older OpenFang installers (<v0.5.0). Harmless if absent.
|
||||
OLD_FISH_RC="$HOME/.config/fish/config.fish"
|
||||
if [ -f "$OLD_FISH_RC" ] && grep -q "openfang/bin" "$OLD_FISH_RC" 2>/dev/null; then
|
||||
# Remove any line containing .openfang/bin (covers both bash
|
||||
# `export PATH=` syntax and old fish `set -gx PATH` lines).
|
||||
TMPFILE=$(mktemp)
|
||||
grep -v "openfang/bin" "$OLD_FISH_RC" > "$TMPFILE" || true
|
||||
mv "$TMPFILE" "$OLD_FISH_RC"
|
||||
echo " Cleaned legacy openfang PATH entry from $OLD_FISH_RC"
|
||||
fi
|
||||
else
|
||||
SHELL_RC=""
|
||||
case "$USER_SHELL" in
|
||||
*/zsh) SHELL_RC="$HOME/.zshrc" ;;
|
||||
*/bash) SHELL_RC="$HOME/.bashrc" ;;
|
||||
esac
|
||||
echo " Added $INSTALL_DIR to PATH in $SHELL_RC"
|
||||
# Fall back to existing rc files when shell detection failed.
|
||||
if [ -z "$SHELL_RC" ]; then
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
SHELL_RC="$HOME/.bashrc"
|
||||
elif [ -f "$HOME/.zshrc" ]; then
|
||||
SHELL_RC="$HOME/.zshrc"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$SHELL_RC" ] && ! grep -q "openfang" "$SHELL_RC" 2>/dev/null; then
|
||||
echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$SHELL_RC"
|
||||
echo " Added $INSTALL_DIR to PATH in $SHELL_RC"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verify installation
|
||||
|
||||
Reference in New Issue
Block a user