diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 5b20345e..bc705df7 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -54,7 +54,7 @@ jobs:
- group: features
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py"
- group: extensions
- files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
+ files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_oauth_url_parameters.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
- group: routines
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
steps:
diff --git a/.github/workflows/regression-test-check.yml b/.github/workflows/regression-test-check.yml
index ef1a4d92..75b8eb55 100644
--- a/.github/workflows/regression-test-check.yml
+++ b/.github/workflows/regression-test-check.yml
@@ -121,6 +121,7 @@ jobs:
fi
# Whole-function context: detect edits inside existing test functions.
+ # Uses -W (whole function) which works when git recognises function boundaries.
if git diff "${BASE_REF}...${HEAD_REF}" -W -- '*.rs' | awk '
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
@@ -132,6 +133,40 @@ jobs:
exit 0
fi
+ # Line-level check: detect changes inside #[cfg(test)] mod blocks.
+ # git -W relies on function boundary detection which misses Rust mod blocks,
+ # so this fallback checks whether changed line numbers fall within test modules.
+ # We specifically match #[cfg(test)] that is followed by `mod` (same or next
+ # line) to avoid false positives from standalone #[cfg(test)] items like
+ # individual statics or functions.
+ CHANGED_RS=$(echo "$CHANGED_FILES" | grep '\.rs$' || true)
+ if [ -n "$CHANGED_RS" ]; then
+ while IFS= read -r rs_file; do
+ [ -f "$rs_file" ] || continue
+
+ # Find the line where #[cfg(test)] precedes a `mod` declaration.
+ # Handles both `#[cfg(test)] mod tests` (same line) and the two-line form.
+ TEST_MOD_START=$(awk '
+ /^[[:space:]]*#\[cfg\(test\)\].*mod / { print NR; exit }
+ /^[[:space:]]*#\[cfg\(test\)\][[:space:]]*$/ { pending=NR; next }
+ pending && /^[[:space:]]*mod / { print pending; exit }
+ { pending=0 }
+ ' "$rs_file")
+ [ -n "$TEST_MOD_START" ] || continue
+
+ # Get changed line numbers in this file from the diff hunk headers.
+ # Each @@ line looks like: @@ -old,count +new,count @@
+ while IFS= read -r hunk_line; do
+ line_no=$(echo "$hunk_line" | sed -E 's/^@@ -[0-9,]+ \+([0-9]+).*/\1/')
+ [ -n "$line_no" ] || continue
+ if [ "$line_no" -ge "$TEST_MOD_START" ]; then
+ echo "Test changes found: $rs_file has changes at line $line_no inside #[cfg(test)] mod block (starts at line $TEST_MOD_START)."
+ exit 0
+ fi
+ done < <(git diff "${BASE_REF}...${HEAD_REF}" -U0 -- "$rs_file" | grep -E '^@@')
+ done <<< "$CHANGED_RS"
+ fi
+
if grep -qE '^tests/' <<< "$CHANGED_FILES"; then
echo "Test file changes found under tests/."
exit 0
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 00488c70..5d4eabc0 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -12,6 +12,7 @@ jobs:
tests:
name: Tests (${{ matrix.name }})
runs-on: ubuntu-latest
+ timeout-minutes: 45
strategy:
fail-fast: false
matrix:
@@ -40,11 +41,14 @@ jobs:
- name: Build WASM channels (for integration tests)
run: ./scripts/build-wasm-extensions.sh --channels
- name: Run Tests
- run: cargo test ${{ matrix.flags }} -- --nocapture
+ run: |
+ timeout --signal=INT --kill-after=30s 40m \
+ cargo test ${{ matrix.flags }} -- --nocapture
heavy-integration-tests:
name: Heavy Integration Tests
runs-on: ubuntu-latest
+ timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -58,9 +62,13 @@ jobs:
- name: Build Telegram WASM channel
run: cargo build --manifest-path channels-src/telegram/Cargo.toml --target wasm32-wasip2 --release
- name: Run thread scheduling integration tests
- run: cargo test --no-default-features --features libsql,integration --test e2e_thread_scheduling -- --nocapture
+ run: |
+ timeout --signal=INT --kill-after=30s 15m \
+ cargo test --no-default-features --features libsql,integration --test e2e_thread_scheduling -- --nocapture
- name: Run Telegram thread-scope regression test
- run: cargo test --features integration --test telegram_auth_integration test_private_messages_use_chat_id_as_thread_scope -- --exact
+ run: |
+ timeout --signal=INT --kill-after=30s 10m \
+ cargo test --features integration --test telegram_auth_integration test_private_messages_use_chat_id_as_thread_scope -- --exact
telegram-tests:
name: Telegram Channel Tests
@@ -68,6 +76,7 @@ jobs:
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -75,7 +84,9 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run Telegram Channel Tests
- run: cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
+ run: |
+ timeout --signal=INT --kill-after=30s 10m \
+ cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
windows-build:
name: Windows Build (${{ matrix.name }})
@@ -110,6 +121,7 @@ jobs:
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
runs-on: ubuntu-latest
+ timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -125,7 +137,9 @@ jobs:
- name: Build all WASM extensions against current WIT
run: ./scripts/build-wasm-extensions.sh
- name: Instantiation test (host linker compatibility)
- run: cargo test --all-features wit_compat -- --nocapture
+ run: |
+ timeout --signal=INT --kill-after=30s 20m \
+ cargo test --all-features wit_compat -- --nocapture
bench-compile:
name: Benchmark Compilation
diff --git a/Cargo.lock b/Cargo.lock
index 76754db7..27c258c1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -157,7 +157,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -168,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -2136,7 +2136,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -2323,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -3428,6 +3428,7 @@ dependencies = [
"hyper-util",
"iana-time-zone",
"insta",
+ "ironclaw_common",
"ironclaw_safety",
"json5",
"libsql",
@@ -3485,6 +3486,14 @@ dependencies = [
"zip",
]
+[[package]]
+name = "ironclaw_common"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "ironclaw_safety"
version = "0.1.0"
@@ -4134,7 +4143,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5472,7 +5481,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -6154,7 +6163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -6354,9 +6363,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
-version = "0.4.44"
+version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
+checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
@@ -6379,7 +6388,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -7179,7 +7188,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -8029,7 +8038,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.61.2",
+ "windows-sys 0.48.0",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 99992a40..395e42d3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = [".", "crates/ironclaw_safety"]
+members = [".", "crates/ironclaw_common", "crates/ironclaw_safety"]
exclude = [
"channels-src/discord",
"channels-src/telegram",
@@ -100,6 +100,9 @@ tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
# Cron scheduling for routines
cron = "0.13"
+# Shared types
+ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
+
# Safety/sanitization
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.1.0" }
regex = "1"
diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md
index a7f5fb32..ad2db551 100644
--- a/FEATURE_PARITY.md
+++ b/FEATURE_PARITY.md
@@ -161,7 +161,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
| `channels` | ✅ | 🚧 | P2 | `list` implemented; `enable`/`disable`/`status` deferred pending config source unification |
-| `models` | ✅ | 🚧 | - | Model selector in TUI |
+| `models` | ✅ | 🚧 | P1 | `models list []` (`--verbose`, `--json`; fetches live model list when provider specified), `models status` (`--json`), `models set `, `models set-provider [--model model]` (alias normalization, config.toml + .env persistence). Remaining: `set` doesn't validate model against live list. |
| `status` | ✅ | ✅ | - | System status (enriched session details) |
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
diff --git a/README.md b/README.md
index 6e14d9ea..cb759236 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,9 @@
+
+
+
diff --git a/channels-src/feishu/feishu.capabilities.json b/channels-src/feishu/feishu.capabilities.json
index 82b1be4e..a228cc4e 100644
--- a/channels-src/feishu/feishu.capabilities.json
+++ b/channels-src/feishu/feishu.capabilities.json
@@ -3,11 +3,11 @@
"wit_version": "0.3.0",
"type": "channel",
"name": "feishu",
- "description": "Feishu/Lark Bot channel for receiving and responding to Feishu messages",
+ "description": "Feishu/Lark Bot channel for receiving and responding to Feishu messages via Event Subscription webhooks",
"auth": {
"secret_name": "feishu_app_id",
"display_name": "Feishu / Lark",
- "instructions": "Create a bot at https://open.feishu.cn/app (Feishu) or https://open.larksuite.com/app (Lark). You need the App ID and App Secret.",
+ "instructions": "Create a bot at https://open.feishu.cn/app (Feishu) or https://open.larksuite.com/app (Lark). You need the App ID and App Secret. Note: IronClaw supports Event Subscription webhook delivery, but not Feishu's long-connection websocket mode.",
"setup_url": "https://open.feishu.cn/app",
"token_hint": "App ID looks like cli_XXXX, App Secret is a long alphanumeric string",
"env_var": "FEISHU_APP_ID"
@@ -16,17 +16,17 @@
"required_secrets": [
{
"name": "feishu_app_id",
- "prompt": "Enter your Feishu/Lark App ID (from https://open.feishu.cn/app)",
+ "prompt": "Enter your Feishu/Lark App ID (from https://open.feishu.cn/app). Use webhook-based Event Subscription, not long-connection websocket mode.",
"optional": false
},
{
"name": "feishu_app_secret",
- "prompt": "Enter your Feishu/Lark App Secret",
+ "prompt": "Enter your Feishu/Lark App Secret (from your app settings at open.feishu.cn)",
"optional": false
},
{
"name": "feishu_verification_token",
- "prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription settings)",
+ "prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription webhook settings)",
"optional": true
}
],
diff --git a/channels-src/feishu/src/lib.rs b/channels-src/feishu/src/lib.rs
index 3094eaa0..62440d2c 100644
--- a/channels-src/feishu/src/lib.rs
+++ b/channels-src/feishu/src/lib.rs
@@ -5,7 +5,9 @@
//!
//! This WASM component implements the channel interface for handling Feishu
//! webhooks (Event Subscription v2.0) and sending messages back via the
-//! Feishu/Lark Bot API.
+//! Feishu/Lark Bot API. IronClaw currently does not connect to Feishu's
+//! long-connection websocket subscription mode; use Event Subscription
+//! webhooks for this channel.
//!
//! # Features
//!
diff --git a/crates/ironclaw_common/Cargo.toml b/crates/ironclaw_common/Cargo.toml
new file mode 100644
index 00000000..353ab747
--- /dev/null
+++ b/crates/ironclaw_common/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "ironclaw_common"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.92"
+description = "Shared types and utilities for the IronClaw workspace"
+authors = ["NEAR AI "]
+license = "MIT OR Apache-2.0"
+homepage = "https://github.com/nearai/ironclaw"
+repository = "https://github.com/nearai/ironclaw"
+publish = false
+
+[package.metadata.dist]
+dist = false
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
diff --git a/crates/ironclaw_common/src/event.rs b/crates/ironclaw_common/src/event.rs
new file mode 100644
index 00000000..256aba3d
--- /dev/null
+++ b/crates/ironclaw_common/src/event.rs
@@ -0,0 +1,393 @@
+//! Application-wide event types.
+//!
+//! `AppEvent` is the real-time event protocol used across the entire
+//! application. The web gateway serialises these to SSE / WebSocket
+//! frames, but other subsystems (agent loop, orchestrator, extensions)
+//! produce and consume them too.
+
+use serde::{Deserialize, Serialize};
+
+/// A single tool decision in a reasoning update (SSE DTO).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ToolDecisionDto {
+ pub tool_name: String,
+ pub rationale: String,
+}
+
+impl ToolDecisionDto {
+ /// Parse a list of tool decisions from a JSON array value.
+ pub fn from_json_array(value: &serde_json::Value) -> Vec {
+ value
+ .as_array()
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|d| {
+ Some(Self {
+ tool_name: d.get("tool_name")?.as_str()?.to_string(),
+ rationale: d.get("rationale")?.as_str()?.to_string(),
+ })
+ })
+ .collect()
+ })
+ .unwrap_or_default()
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type")]
+pub enum AppEvent {
+ #[serde(rename = "response")]
+ Response { content: String, thread_id: String },
+ #[serde(rename = "thinking")]
+ Thinking {
+ message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "tool_started")]
+ ToolStarted {
+ name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "tool_completed")]
+ ToolCompleted {
+ name: String,
+ success: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ parameters: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "tool_result")]
+ ToolResult {
+ name: String,
+ preview: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "stream_chunk")]
+ StreamChunk {
+ content: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "status")]
+ Status {
+ message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "job_started")]
+ JobStarted {
+ job_id: String,
+ title: String,
+ browse_url: String,
+ },
+ #[serde(rename = "approval_needed")]
+ ApprovalNeeded {
+ request_id: String,
+ tool_name: String,
+ description: String,
+ parameters: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ /// Whether the "always" auto-approve option should be shown.
+ allow_always: bool,
+ },
+ #[serde(rename = "auth_required")]
+ AuthRequired {
+ extension_name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ instructions: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ auth_url: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ setup_url: Option,
+ },
+ #[serde(rename = "auth_completed")]
+ AuthCompleted {
+ extension_name: String,
+ success: bool,
+ message: String,
+ },
+ #[serde(rename = "error")]
+ Error {
+ message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+ #[serde(rename = "heartbeat")]
+ Heartbeat,
+
+ // Sandbox job streaming events (worker + Claude Code bridge)
+ #[serde(rename = "job_message")]
+ JobMessage {
+ job_id: String,
+ role: String,
+ content: String,
+ },
+ #[serde(rename = "job_tool_use")]
+ JobToolUse {
+ job_id: String,
+ tool_name: String,
+ input: serde_json::Value,
+ },
+ #[serde(rename = "job_tool_result")]
+ JobToolResult {
+ job_id: String,
+ tool_name: String,
+ output: String,
+ },
+ #[serde(rename = "job_status")]
+ JobStatus { job_id: String, message: String },
+ #[serde(rename = "job_result")]
+ JobResult {
+ job_id: String,
+ status: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ session_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ fallback_deliverable: Option,
+ },
+
+ /// An image was generated by a tool.
+ #[serde(rename = "image_generated")]
+ ImageGenerated {
+ data_url: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ path: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+
+ /// Suggested follow-up messages for the user.
+ #[serde(rename = "suggestions")]
+ Suggestions {
+ suggestions: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+
+ /// Per-turn token usage and cost summary.
+ #[serde(rename = "turn_cost")]
+ TurnCost {
+ input_tokens: u64,
+ output_tokens: u64,
+ cost_usd: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+
+ /// Extension activation status change (WASM channels).
+ #[serde(rename = "extension_status")]
+ ExtensionStatus {
+ extension_name: String,
+ status: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ message: Option,
+ },
+
+ /// Agent reasoning update (why it chose specific tools).
+ #[serde(rename = "reasoning_update")]
+ ReasoningUpdate {
+ narrative: String,
+ decisions: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ thread_id: Option,
+ },
+
+ /// Reasoning update for a sandbox job.
+ #[serde(rename = "job_reasoning")]
+ JobReasoning {
+ job_id: String,
+ narrative: String,
+ decisions: Vec,
+ },
+}
+
+impl AppEvent {
+ /// The wire-format event type string (matches the `#[serde(rename)]` value).
+ pub fn event_type(&self) -> &'static str {
+ match self {
+ Self::Response { .. } => "response",
+ Self::Thinking { .. } => "thinking",
+ Self::ToolStarted { .. } => "tool_started",
+ Self::ToolCompleted { .. } => "tool_completed",
+ Self::ToolResult { .. } => "tool_result",
+ Self::StreamChunk { .. } => "stream_chunk",
+ Self::Status { .. } => "status",
+ Self::JobStarted { .. } => "job_started",
+ Self::ApprovalNeeded { .. } => "approval_needed",
+ Self::AuthRequired { .. } => "auth_required",
+ Self::AuthCompleted { .. } => "auth_completed",
+ Self::Error { .. } => "error",
+ Self::Heartbeat => "heartbeat",
+ Self::JobMessage { .. } => "job_message",
+ Self::JobToolUse { .. } => "job_tool_use",
+ Self::JobToolResult { .. } => "job_tool_result",
+ Self::JobStatus { .. } => "job_status",
+ Self::JobResult { .. } => "job_result",
+ Self::ImageGenerated { .. } => "image_generated",
+ Self::Suggestions { .. } => "suggestions",
+ Self::TurnCost { .. } => "turn_cost",
+ Self::ExtensionStatus { .. } => "extension_status",
+ Self::ReasoningUpdate { .. } => "reasoning_update",
+ Self::JobReasoning { .. } => "job_reasoning",
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Verify that `event_type()` returns the same string as the serde
+ /// `"type"` field for every variant. This catches drift between the
+ /// `#[serde(rename)]` attributes and the manual match arms.
+ #[test]
+ fn event_type_matches_serde_type_field() {
+ let variants: Vec = vec![
+ AppEvent::Response {
+ content: String::new(),
+ thread_id: String::new(),
+ },
+ AppEvent::Thinking {
+ message: String::new(),
+ thread_id: None,
+ },
+ AppEvent::ToolStarted {
+ name: String::new(),
+ thread_id: None,
+ },
+ AppEvent::ToolCompleted {
+ name: String::new(),
+ success: true,
+ error: None,
+ parameters: None,
+ thread_id: None,
+ },
+ AppEvent::ToolResult {
+ name: String::new(),
+ preview: String::new(),
+ thread_id: None,
+ },
+ AppEvent::StreamChunk {
+ content: String::new(),
+ thread_id: None,
+ },
+ AppEvent::Status {
+ message: String::new(),
+ thread_id: None,
+ },
+ AppEvent::JobStarted {
+ job_id: String::new(),
+ title: String::new(),
+ browse_url: String::new(),
+ },
+ AppEvent::ApprovalNeeded {
+ request_id: String::new(),
+ tool_name: String::new(),
+ description: String::new(),
+ parameters: String::new(),
+ thread_id: None,
+ allow_always: false,
+ },
+ AppEvent::AuthRequired {
+ extension_name: String::new(),
+ instructions: None,
+ auth_url: None,
+ setup_url: None,
+ },
+ AppEvent::AuthCompleted {
+ extension_name: String::new(),
+ success: true,
+ message: String::new(),
+ },
+ AppEvent::Error {
+ message: String::new(),
+ thread_id: None,
+ },
+ AppEvent::Heartbeat,
+ AppEvent::JobMessage {
+ job_id: String::new(),
+ role: String::new(),
+ content: String::new(),
+ },
+ AppEvent::JobToolUse {
+ job_id: String::new(),
+ tool_name: String::new(),
+ input: serde_json::Value::Null,
+ },
+ AppEvent::JobToolResult {
+ job_id: String::new(),
+ tool_name: String::new(),
+ output: String::new(),
+ },
+ AppEvent::JobStatus {
+ job_id: String::new(),
+ message: String::new(),
+ },
+ AppEvent::JobResult {
+ job_id: String::new(),
+ status: String::new(),
+ session_id: None,
+ fallback_deliverable: None,
+ },
+ AppEvent::ImageGenerated {
+ data_url: String::new(),
+ path: None,
+ thread_id: None,
+ },
+ AppEvent::Suggestions {
+ suggestions: vec![],
+ thread_id: None,
+ },
+ AppEvent::TurnCost {
+ input_tokens: 0,
+ output_tokens: 0,
+ cost_usd: String::new(),
+ thread_id: None,
+ },
+ AppEvent::ExtensionStatus {
+ extension_name: String::new(),
+ status: String::new(),
+ message: None,
+ },
+ AppEvent::ReasoningUpdate {
+ narrative: String::new(),
+ decisions: vec![],
+ thread_id: None,
+ },
+ AppEvent::JobReasoning {
+ job_id: String::new(),
+ narrative: String::new(),
+ decisions: vec![],
+ },
+ ];
+
+ for variant in &variants {
+ let json: serde_json::Value = serde_json::to_value(variant).unwrap();
+ let serde_type = json["type"].as_str().unwrap();
+ assert_eq!(
+ variant.event_type(),
+ serde_type,
+ "event_type() mismatch for variant: {:?}",
+ variant
+ );
+ }
+ }
+
+ #[test]
+ fn round_trip_deserialize() {
+ let original = AppEvent::Response {
+ content: "hello".to_string(),
+ thread_id: "t1".to_string(),
+ };
+ let json = serde_json::to_string(&original).unwrap();
+ let deserialized: AppEvent = serde_json::from_str(&json).unwrap();
+ assert_eq!(deserialized.event_type(), "response");
+ }
+}
diff --git a/crates/ironclaw_common/src/lib.rs b/crates/ironclaw_common/src/lib.rs
new file mode 100644
index 00000000..f52dc0aa
--- /dev/null
+++ b/crates/ironclaw_common/src/lib.rs
@@ -0,0 +1,7 @@
+//! Shared types and utilities for the IronClaw workspace.
+
+mod event;
+mod util;
+
+pub use event::{AppEvent, ToolDecisionDto};
+pub use util::truncate_preview;
diff --git a/crates/ironclaw_common/src/util.rs b/crates/ironclaw_common/src/util.rs
new file mode 100644
index 00000000..4f054671
--- /dev/null
+++ b/crates/ironclaw_common/src/util.rs
@@ -0,0 +1,100 @@
+//! Shared utility functions.
+
+/// Truncate a string to at most `max_bytes` bytes at a char boundary, appending "...".
+///
+/// If the input is wrapped in `...` and truncation
+/// removes the closing tag, the tag is re-appended so downstream XML parsers
+/// never see an unclosed element.
+pub fn truncate_preview(s: &str, max_bytes: usize) -> String {
+ if s.len() <= max_bytes {
+ return s.to_string();
+ }
+ // Walk backwards from max_bytes to find a valid char boundary
+ let mut end = max_bytes;
+ while end > 0 && !s.is_char_boundary(end) {
+ end -= 1;
+ }
+ let mut result = format!("{}...", &s[..end]);
+
+ // Re-close if truncation cut through the closing tag.
+ if s.starts_with("") {
+ result.push_str("\n");
+ }
+
+ result
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_truncate_preview_short_string() {
+ assert_eq!(truncate_preview("hello", 10), "hello");
+ }
+
+ #[test]
+ fn test_truncate_preview_exact_boundary() {
+ assert_eq!(truncate_preview("hello", 5), "hello");
+ }
+
+ #[test]
+ fn test_truncate_preview_truncates_ascii() {
+ assert_eq!(truncate_preview("hello world", 5), "hello...");
+ }
+
+ #[test]
+ fn test_truncate_preview_empty_string() {
+ assert_eq!(truncate_preview("", 10), "");
+ }
+
+ #[test]
+ fn test_truncate_preview_multibyte_char_boundary() {
+ let s = "a\u{20AC}b";
+ let result = truncate_preview(s, 3);
+ assert_eq!(result, "a...");
+ }
+
+ #[test]
+ fn test_truncate_preview_emoji() {
+ let s = "hi\u{1F980}";
+ let result = truncate_preview(s, 4);
+ assert_eq!(result, "hi...");
+ }
+
+ #[test]
+ fn test_truncate_preview_cjk() {
+ let s = "\u{4F60}\u{597D}\u{4E16}\u{754C}";
+ let result = truncate_preview(s, 7);
+ assert_eq!(result, "\u{4F60}\u{597D}...");
+ }
+
+ #[test]
+ fn test_truncate_preview_zero_max_bytes() {
+ assert_eq!(truncate_preview("hello", 0), "...");
+ }
+
+ #[test]
+ fn test_truncate_preview_closes_tool_output_tag() {
+ let s = "\nSome very long content here\n";
+ let result = truncate_preview(s, 60);
+ assert!(result.ends_with(""));
+ assert!(result.contains("..."));
+ }
+
+ #[test]
+ fn test_truncate_preview_no_extra_close_when_intact() {
+ let s = "\nshort\n";
+ let result = truncate_preview(s, 500);
+ assert_eq!(result, s);
+ assert_eq!(result.matches("").count(), 1);
+ }
+
+ #[test]
+ fn test_truncate_preview_non_xml_unaffected() {
+ let s = "Just a plain long string that gets truncated";
+ let result = truncate_preview(s, 10);
+ assert_eq!(result, "Just a pla...");
+ assert!(!result.contains(""));
+ }
+}
diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs
index 54575ecc..e28f11d0 100644
--- a/src/agent/agent_loop.rs
+++ b/src/agent/agent_loop.rs
@@ -16,6 +16,7 @@ use crate::agent::context_monitor::ContextMonitor;
use crate::agent::heartbeat::spawn_heartbeat;
use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker};
use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
+use crate::agent::session::ThreadState;
use crate::agent::session_manager::SessionManager;
use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult};
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler, SchedulerDeps};
@@ -84,6 +85,15 @@ fn resolve_owner_scope_notification_user(
trimmed_option(explicit_user).or_else(|| trimmed_option(owner_fallback))
}
+fn is_single_message_repl(message: &IncomingMessage) -> bool {
+ message.channel == "repl"
+ && message
+ .metadata
+ .get("single_message_mode")
+ .and_then(|value| value.as_bool())
+ .unwrap_or(false)
+}
+
async fn resolve_channel_notification_user(
extension_manager: Option<&Arc>,
channel: Option<&str>,
@@ -157,18 +167,21 @@ pub struct AgentDeps {
pub hooks: Arc,
/// Cost enforcement guardrails (daily budget, hourly rate limits).
pub cost_guard: Arc,
- /// SSE broadcast sender for live job event streaming to the web gateway.
- pub sse_tx: Option>,
+ /// SSE manager for live job event streaming to the web gateway.
+ pub sse_tx: Option>,
/// HTTP interceptor for trace recording/replay.
pub http_interceptor: Option>,
/// Audio transcription middleware for voice messages.
- pub transcription: Option>,
+ pub transcription: Option>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option>,
/// Sandbox readiness state for full-job routine dispatch.
pub sandbox_readiness: crate::agent::routine_engine::SandboxReadiness,
/// Software builder for self-repair tool rebuilding.
pub builder: Option>,
+ /// Resolved LLM backend identifier (e.g., "nearai", "openai", "groq").
+ /// Used by `/model` persistence to determine which env var to update.
+ pub llm_backend: String,
}
/// The main agent that coordinates all components.
@@ -235,8 +248,8 @@ impl Agent {
hooks: deps.hooks.clone(),
},
);
- if let Some(ref tx) = deps.sse_tx {
- scheduler.set_sse_sender(tx.clone());
+ if let Some(ref sse) = deps.sse_tx {
+ scheduler.set_sse_sender(Arc::clone(sse));
}
if let Some(ref interceptor) = deps.http_interceptor {
scheduler.set_http_interceptor(Arc::clone(interceptor));
@@ -1052,10 +1065,11 @@ impl Agent {
} else {
drop(sess);
self.session_manager
- .resolve_thread(
+ .resolve_thread_with_parsed_uuid(
&message.user_id,
&message.channel,
message.conversation_scope(),
+ approval_thread_uuid,
)
.await
}
@@ -1136,9 +1150,14 @@ impl Agent {
&& let Submission::UserInput { ref content } = submission
&& let Some(engine) = self.routine_engine().await
{
- let fired = engine
- .check_event_triggers(&message.user_id, &message.channel, content)
- .await;
+ let single_message_repl = is_single_message_repl(message);
+ // Use post-hook content so that BeforeInbound hooks that rewrite
+ // input are respected by event trigger matching.
+ let fired = if single_message_repl {
+ engine.check_event_triggers_and_wait(message, content).await
+ } else {
+ engine.check_event_triggers(message, content).await
+ };
if fired > 0 {
tracing::debug!(
channel = %message.channel,
@@ -1146,10 +1165,16 @@ impl Agent {
fired,
"Consumed inbound user message with matching event-triggered routine(s)"
);
- return Ok(Some(String::new()));
+ return if single_message_repl {
+ Ok(None)
+ } else {
+ Ok(Some(String::new()))
+ };
}
}
+ let session_for_empty_exit = Arc::clone(&session);
+
// Process based on submission type
let result = match submission {
Submission::UserInput { content } => {
@@ -1246,6 +1271,28 @@ impl Agent {
command,
message.channel
);
+ // /reasoning is special-cased here (not in handle_system_command)
+ // because it needs the session + thread_id to read turn reasoning
+ // data, which handle_system_command's signature doesn't provide.
+ if command == "reasoning" {
+ let result = self
+ .handle_reasoning_command(&args, &session, thread_id)
+ .await;
+ return match result {
+ SubmissionResult::Response { content } => Ok(Some(content)),
+ SubmissionResult::Ok { message } => Ok(message),
+ SubmissionResult::Error { message } => {
+ Ok(Some(format!("Error: {}", message)))
+ }
+ _ => {
+ if is_single_message_repl(message) {
+ Ok(None)
+ } else {
+ Ok(Some(String::new()))
+ }
+ }
+ };
+ }
// Authorization checks (including restart channel check) are enforced in handle_system_command
self.handle_system_command(&command, &args, &message.channel)
.await
@@ -1305,7 +1352,26 @@ impl Agent {
Ok(Some(content))
}
}
- SubmissionResult::Ok { message } => Ok(message),
+ SubmissionResult::Ok {
+ message: output_message,
+ } => {
+ let should_exit =
+ if output_message.as_deref() == Some("") && is_single_message_repl(message) {
+ let sess = session_for_empty_exit.lock().await;
+ sess.threads
+ .get(&thread_id)
+ .map(|thread| thread.state != ThreadState::AwaitingApproval)
+ .unwrap_or(true)
+ } else {
+ false
+ };
+
+ if should_exit {
+ Ok(None)
+ } else {
+ Ok(output_message)
+ }
+ }
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
SubmissionResult::Interrupted => Ok(Some("Interrupted.".into())),
SubmissionResult::NeedApproval { .. } => {
@@ -1321,7 +1387,7 @@ impl Agent {
#[cfg(test)]
mod tests {
use super::{
- chat_tool_execution_metadata, resolve_routine_notification_user,
+ chat_tool_execution_metadata, is_single_message_repl, resolve_routine_notification_user,
should_fallback_routine_notification, truncate_for_preview,
};
use crate::channels::IncomingMessage;
@@ -1483,4 +1549,17 @@ mod tests {
assert!(should_fallback_routine_notification(&error)); // safety: test-only assertion
}
+
+ #[test]
+ fn single_message_repl_detection_requires_repl_channel_and_metadata_flag() {
+ let repl = IncomingMessage::new("repl", "owner-scope", "hello")
+ .with_metadata(serde_json::json!({ "single_message_mode": true }));
+ let gateway = IncomingMessage::new("gateway", "owner-scope", "hello")
+ .with_metadata(serde_json::json!({ "single_message_mode": true }));
+ let plain_repl = IncomingMessage::new("repl", "owner-scope", "hello");
+
+ assert!(is_single_message_repl(&repl)); // safety: test-only assertion
+ assert!(!is_single_message_repl(&gateway)); // safety: test-only assertion
+ assert!(!is_single_message_repl(&plain_repl)); // safety: test-only assertion
+ }
}
diff --git a/src/agent/agentic_loop.rs b/src/agent/agentic_loop.rs
index cc6fd486..e61856dc 100644
--- a/src/agent/agentic_loop.rs
+++ b/src/agent/agentic_loop.rs
@@ -414,6 +414,7 @@ mod tests {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
+ reasoning: None,
};
let delegate = MockDelegate::new(vec![
tool_calls_output(vec![tool_call]),
diff --git a/src/agent/commands.rs b/src/agent/commands.rs
index 75c99359..e02b33db 100644
--- a/src/agent/commands.rs
+++ b/src/agent/commands.rs
@@ -465,6 +465,94 @@ impl Agent {
}
}
+ /// Handle `/reasoning [N|all]` — show reasoning history for the active thread.
+ pub(super) async fn handle_reasoning_command(
+ &self,
+ args: &[String],
+ session: &Arc>,
+ thread_id: Uuid,
+ ) -> SubmissionResult {
+ // Clone the turn data we need, then drop the session lock.
+ let turns_snapshot: Vec<(
+ usize,
+ Option,
+ Vec,
+ )>;
+ {
+ let sess = session.lock().await;
+ let thread = match sess.threads.get(&thread_id) {
+ Some(t) => t,
+ None => return SubmissionResult::error("No active thread."),
+ };
+
+ if thread.turns.is_empty() {
+ return SubmissionResult::ok_with_message("No turns yet.");
+ }
+
+ // Parse argument: default=last turn, "all"=all turns, N=specific turn (1-based).
+ let selected: Vec<&crate::agent::session::Turn> = match args.first().map(|s| s.as_str())
+ {
+ Some("all") => thread.turns.iter().collect(),
+ Some(n) => match n.parse::() {
+ Ok(0) => return SubmissionResult::error("Turn numbers start at 1."),
+ Ok(num) if num > thread.turns.len() => {
+ return SubmissionResult::error(format!(
+ "Turn {} does not exist (max: {}).",
+ num,
+ thread.turns.len()
+ ));
+ }
+ Ok(num) => vec![&thread.turns[num - 1]],
+ Err(_) => return SubmissionResult::error("Usage: /reasoning [N|all]"),
+ },
+ None => {
+ // Default: last turn that has tool calls
+ match thread.turns.iter().rev().find(|t| !t.tool_calls.is_empty()) {
+ Some(t) => vec![t],
+ None => {
+ return SubmissionResult::ok_with_message("No turns with tool calls.");
+ }
+ }
+ }
+ };
+
+ turns_snapshot = selected
+ .into_iter()
+ .map(|t| (t.turn_number, t.narrative.clone(), t.tool_calls.clone()))
+ .collect();
+ }
+ // Session lock is now dropped — format output without holding it.
+
+ let mut output = String::new();
+ for (turn_number, narrative, tool_calls) in &turns_snapshot {
+ output.push_str(&format!("--- Turn {} ---\n", turn_number + 1));
+ if let Some(narrative) = narrative {
+ output.push_str(&format!("Reasoning: {}\n", narrative));
+ }
+ if tool_calls.is_empty() {
+ output.push_str(" (no tool calls)\n");
+ } else {
+ for tc in tool_calls {
+ let status = if tc.error.is_some() {
+ "error"
+ } else if tc.result.is_some() {
+ "ok"
+ } else {
+ "pending"
+ };
+ output.push_str(&format!(" {} [{}]", tc.name, status));
+ if let Some(ref rationale) = tc.rationale {
+ output.push_str(&format!(" — {}", rationale));
+ }
+ output.push('\n');
+ }
+ }
+ output.push('\n');
+ }
+
+ SubmissionResult::response(output.trim_end())
+ }
+
/// Handle system commands that bypass thread-state checks entirely.
pub(super) async fn handle_system_command(
&self,
@@ -480,6 +568,7 @@ impl Agent {
" /version Show version info\n",
" /tools List available tools\n",
" /debug Toggle debug mode\n",
+ " /reasoning [N|all] Show agent reasoning for turns\n",
" /ping Connectivity check\n",
"\n",
"Jobs:\n",
@@ -841,12 +930,50 @@ impl Agent {
.await
{
tracing::warn!("Failed to persist model to DB: {}", e);
+ } else {
+ tracing::debug!("Persisted selected_model to DB: {}", model);
}
+ } else {
+ tracing::warn!("No database store available — model choice will not persist to DB");
}
- // 2. Update TOML config file if it exists (sync I/O in spawn_blocking).
+ // 2. Update .env and TOML config file (sync I/O in spawn_blocking).
let model_owned = model.to_string();
+ let backend = self.deps.llm_backend.clone();
if let Err(e) = tokio::task::spawn_blocking(move || {
+ // 2a. Update the backend-specific model env var in ~/.ironclaw/.env.
+ //
+ // Env vars have the HIGHEST priority in LlmConfig::resolve_model()
+ // (env var > TOML > DB > default). If the .env file has e.g.
+ // NEARAI_MODEL=old-model, it shadows everything else. We must
+ // update this var or the /model change is invisible on restart.
+ let registry = crate::llm::ProviderRegistry::load();
+ let model_env = registry.model_env_var(&backend);
+ let env_var_prefix = format!("{}=", model_env);
+
+ // Only update the .env file if the var is actually set there
+ // (avoid injecting new vars the user never configured).
+ let env_path = crate::bootstrap::ironclaw_env_path();
+ let env_has_var = std::fs::read_to_string(&env_path)
+ .ok()
+ .is_some_and(|content| {
+ content.lines().any(|line| {
+ let trimmed = line.trim_start();
+ !trimmed.starts_with('#') && trimmed.starts_with(&env_var_prefix)
+ })
+ });
+ if env_has_var {
+ if let Err(e) = crate::bootstrap::upsert_bootstrap_var(model_env, &model_owned) {
+ tracing::warn!("Failed to update {} in .env: {}", model_env, e);
+ } else {
+ tracing::debug!("Updated {} in .env to {}", model_env, model_owned);
+ }
+ }
+
+ // 2b. Update (or create) the TOML config file.
+ //
+ // The TOML overlay has higher priority than DB settings on
+ // startup, so it MUST stay in sync with the DB.
let toml_path = crate::settings::Settings::default_toml_path();
match crate::settings::Settings::load_toml(&toml_path) {
Ok(Some(mut settings)) => {
@@ -856,7 +983,15 @@ impl Agent {
}
}
Ok(None) => {
- // No config file on disk; nothing to update.
+ // No config file yet — create one so the model choice
+ // survives restarts even when the DB is unavailable.
+ let settings = crate::settings::Settings {
+ selected_model: Some(model_owned),
+ ..Default::default()
+ };
+ if let Err(e) = settings.save_toml(&toml_path) {
+ tracing::warn!("Failed to create config.toml for model persistence: {}", e);
+ }
}
Err(e) => {
tracing::warn!("Failed to load config.toml for model persistence: {}", e);
@@ -865,7 +1000,7 @@ impl Agent {
})
.await
{
- tracing::warn!("Model TOML persistence task failed: {}", e);
+ tracing::warn!("Model persistence task failed: {}", e);
}
}
}
diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs
index 7fc8e0ca..fe208c1b 100644
--- a/src/agent/dispatcher.rs
+++ b/src/agent/dispatcher.rs
@@ -63,7 +63,12 @@ impl Agent {
);
let system_prompt = if let Some(ws) = self.workspace() {
- match ws
+ let scoped_workspace = if ws.user_id() == message.user_id {
+ Arc::clone(ws)
+ } else {
+ Arc::new(ws.scoped_to_user(&message.user_id))
+ };
+ match scoped_workspace
.system_prompt_for_context_tz(is_group_chat, user_tz)
.await
{
@@ -420,6 +425,19 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
content: Option,
reason_ctx: &mut ReasoningContext,
) -> Result