Files
optimclaw/src/channels/channel.rs
T
a09c023642 feat(ux): complete UX overhaul — design system, onboarding, web polish (#1277)
* feat(ux): complete UX overhaul — design system, boot screen, onboarding, web polish

Shared design system: CSS custom properties for spacing, typography,
transitions, and color tokens used across web UI and boot screen.

Boot screen: compact feature-tags line showing enabled subsystems
(db, tools, routines, heartbeat, skills, sandbox, embeddings) at a
glance. Downgrade startup info logs (libSQL, webhook, workspace seed)
to debug level since the boot screen now covers this.

Onboarding wizard: model picker with live API fetch, provider-aware
auth flow, improved error recovery and progress display.

Web UI: ARIA attributes, welcome card, streaming debounce,
connection status banner, skeleton loaders, send cooldown.

CLI: doctor command enhancements, status command cleanup,
REPL banner consolidation, shared fmt module.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(ux): Apple-level design refinements — spring physics, glass morphism, chat polish

Merge staging theme support (dark/light/system toggle) and layer UX
polish on top: spring-physics motion, glass morphism depth, chat
experience improvements, and responsive mobile refinements.

Design system:
- Restore and extend design token system (spacing, typography, timing,
  easing) with legacy aliases for theme compatibility
- Add shadow tiers, accent glow, glass morphism, spring easing tokens
- Tokens defined in both dark (:root) and light ([data-theme="light"])

Micro-interactions (Phase 2):
- Spring-overshoot message entry animation (slideUp)
- Spring-scale button press on all interactive buttons
- Tab crossfade animation, tool card smooth accordion (max-height)
- Modal scale(0.95) + blur(8px) entry, toast spring slide
- Sidebar width crossfade, card hover lift

Visual depth (Phase 3):
- Tab bar glass morphism + surface highlight + sliding indicator
- Active tab accent background pill
- Assistant message accent left border, user message bubble tail
- Floating input area (rounded + shadow + margin)

Chat polish (Phase 4):
- Smooth streaming cursor (cursorPulse), message hover timestamps
- Time separators (Today/Yesterday/date)
- Textarea smooth auto-expand, send button glow

Settings & forms (Phase 5):
- iOS-style toggle switches for boolean settings
- Input focus glow, save feedback spring animation
- Welcome card with gradient background + proper spacing
- Sticky settings group headers with glass backdrop

Accessibility & mobile (Phase 6):
- Animated focus ring, prefers-reduced-motion global kill-switch
- Touch target audit (44px min), mobile bottom-sheet modals
- Mobile bottom tab bar, toast redesign (icon + border + countdown)
- Thread hover translateX, badge in_progress pulse

Bug fixes:
- Gateway/TEE popover z-index (tab-bar z-index: 200, popovers 500)
- Connection lost banner as fixed top bar instead of flex child
- Sidebar collapse keeps toggle + new thread buttons visible
- Downgrade noisy startup logs (db, webhook, vector) to debug
- Remove green dot pulse animation on connected status
- Deduplicate confirm-modal in HTML, add tab-indicator div

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(web): mobile layout improvements — sidebar toggle, settings drill-down, tab bar polish

- Fix mobile sidebar toggle: use expanded-mobile class instead of collapsed,
  add backdrop overlay, auto-close on thread select, outside-click dismiss
- Settings: replace cramped horizontal tabs with drill-down navigation
  (category list → detail view → back button)
- Bottom tab bar: add glass morphism, hide theme toggle, flip tab indicator
  to top edge
- Keep thread toggle button visible in collapsed 36px sidebar strip

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(repl): interactive approval selector and transient status lines

- Replace ASCII-art approval box with clean horizontal rule card
- Add inquire-based interactive selector for tool approvals (↑↓ + Enter)
- Selector runs directly from send_status via spawn_blocking, with
  stdin_locked flag to prevent readline from competing for stdin
- Transient thinking/tool-started lines: each replaces the previous,
  all erased before final output (no clutter left in scrollback)
- Esc in selector sends denial so agent never gets stuck

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: widen TurnCost token fields to u64 and remove unused variable

- Change input_tokens/output_tokens from u32 to u64 in StatusUpdate::TurnCost,
  SseEvent::TurnCost, and the thread_ops emit site to avoid truncation on
  large conversations
- Remove unused _routine_engine_for_loop binding in agent_loop.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: reduce startup log noise — demote info to debug

Demote routine startup messages (builder, WASM tools, tunnel, WASM
channels) from info to debug so the default log output stays clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(web): allow CDN scripts in CSP connect-src directive

Add cdn.jsdelivr.net and cdnjs.cloudflare.com to connect-src so the
browser can fetch marked.js and DOMPurify without CSP violations.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: fix cargo fmt in repl.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(web): gate turn_cost SSE handler on current thread

Prevents cost badge from attaching to the wrong message when
switching threads or receiving events from background threads.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* ci: retrigger CI

* fix: add missing extension_manager to webhook EngineContext

The webhook trigger path added in #736 was missing the
extension_manager field introduced by #1453.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: ignore RUSTSEC-2026-0049 rustls-webpki CRL advisory

Low impact — requires compromised CA to exploit. Tracked for
upstream rustls-webpki upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(routines): use fields.join for cron normalization

Use split_whitespace fields instead of re-trimming the original string
to avoid preserving extra internal whitespace in cron expressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(repl): Apple-style approval card — clean vertical flow

- Drop verbose tool description (the command IS the decision surface)
- Unified vertical pipe layout: ◆ header → │ params → │ selector
- Selector options show keyboard shortcuts inline: Approve (y)
- Compact help message, answered state uses └ to close the flow
- No horizontal rules, no blank-line padding — just breathing room

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(repl): replace inquire with crossterm for approval selector

Drop the inquire dependency (which pulled in crossterm 0.25, duplicating
the existing 0.28). The 3-option approval selector is now built directly
with crossterm raw mode — same UX, zero new dependencies.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore(deps): upgrade crossterm 0.28 → 0.29, eliminate duplication

termimad (via crokey) uses crossterm 0.29. Upgrading our direct
dependency from 0.28 to 0.29 collapses to a single crossterm version
in the dependency tree. Also migrated termimad::crossterm:: references
to the direct crossterm import.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review comments — box_top off-by-one, smart_truncate overflow, mobile theme toggle

- Fix box_top() fill calculation: was off-by-one, producing boxes 1 char
  too wide (fmt.rs)
- Fix smart_truncate(): account for "..." in the budget so output never
  exceeds max_chars (repl.rs)
- Move theme toggle to settings sidebar on mobile instead of display:none,
  so mobile users can still switch themes (style.css, index.html, app.js)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: cargo fmt repl.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review — retry duplication, CSP connect-src, deny color

- Remove failed message before retry to prevent duplicate user messages
- Revert connect-src to 'self' — CDN hosts only need script-src
- Use red for Deny confirmation in REPL approval selector

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-21 23:50:49 -07:00

600 lines
20 KiB
Rust

//! Channel trait and message types.
use std::collections::HashMap;
use std::pin::Pin;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::Stream;
use uuid::Uuid;
use crate::error::ChannelError;
/// Kind of attachment carried on an incoming message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttachmentKind {
/// Audio content (voice notes, audio files).
Audio,
/// Image content (photos, screenshots).
Image,
/// Document content (PDFs, files).
Document,
}
impl AttachmentKind {
/// Infer attachment kind from MIME type.
pub fn from_mime_type(mime: &str) -> Self {
let base = mime.split(';').next().unwrap_or(mime).trim();
if base.starts_with("audio/") {
Self::Audio
} else if base.starts_with("image/") {
Self::Image
} else {
Self::Document
}
}
}
/// A file or media attachment on an incoming message.
#[derive(Debug, Clone)]
pub struct IncomingAttachment {
/// Unique identifier within the channel (e.g., Telegram file_id).
pub id: String,
/// What kind of content this is.
pub kind: AttachmentKind,
/// MIME type (e.g., "image/jpeg", "audio/ogg", "application/pdf").
pub mime_type: String,
/// Original filename, if known.
pub filename: Option<String>,
/// File size in bytes, if known.
pub size_bytes: Option<u64>,
/// URL to download the file from the channel's API.
pub source_url: Option<String>,
/// Opaque key for host-side storage (e.g., after download/caching).
pub storage_key: Option<String>,
/// Extracted text content (e.g., OCR result, PDF text, audio transcript).
pub extracted_text: Option<String>,
/// Raw file bytes (for small files downloaded by the channel).
pub data: Vec<u8>,
/// Duration in seconds (for audio/video).
pub duration_secs: Option<u32>,
}
/// A message received from an external channel.
#[derive(Debug, Clone)]
pub struct IncomingMessage {
/// Unique message ID.
pub id: Uuid,
/// Channel this message came from.
pub channel: String,
/// Storage/persistence scope for this interaction.
///
/// For owner-capable channels this is the stable instance owner ID when the
/// configured owner is speaking; otherwise it can be a guest/sender-scoped
/// identifier to preserve isolation.
pub user_id: String,
/// Stable instance owner scope for this IronClaw deployment.
pub owner_id: String,
/// Channel-specific sender/actor identifier.
pub sender_id: String,
/// Optional display name.
pub user_name: Option<String>,
/// Message content.
pub content: String,
/// Thread/conversation ID for threaded conversations.
pub thread_id: Option<String>,
/// Stable channel/chat/thread scope for this conversation.
pub conversation_scope_id: Option<String>,
/// When the message was received.
pub received_at: DateTime<Utc>,
/// Channel-specific metadata.
pub metadata: serde_json::Value,
/// IANA timezone string from the client (e.g. "America/New_York").
pub timezone: Option<String>,
/// File or media attachments on this message.
pub attachments: Vec<IncomingAttachment>,
/// Internal-only flag: message was generated inside the process (e.g. job
/// monitor) and must bypass the normal user-input pipeline. This field is
/// not settable via metadata, so external channels cannot spoof it.
pub(crate) is_internal: bool,
}
impl IncomingMessage {
/// Create a new incoming message.
pub fn new(
channel: impl Into<String>,
user_id: impl Into<String>,
content: impl Into<String>,
) -> Self {
let user_id = user_id.into();
Self {
id: Uuid::new_v4(),
channel: channel.into(),
owner_id: user_id.clone(),
sender_id: user_id.clone(),
user_id,
user_name: None,
content: content.into(),
thread_id: None,
conversation_scope_id: None,
received_at: Utc::now(),
metadata: serde_json::Value::Null,
timezone: None,
attachments: Vec::new(),
is_internal: false,
}
}
/// Set the thread ID.
pub fn with_thread(mut self, thread_id: impl Into<String>) -> Self {
let thread_id = thread_id.into();
self.conversation_scope_id = Some(thread_id.clone());
self.thread_id = Some(thread_id);
self
}
/// Set the stable owner scope for this message.
pub fn with_owner_id(mut self, owner_id: impl Into<String>) -> Self {
self.owner_id = owner_id.into();
self
}
/// Set the channel-specific sender/actor identifier.
pub fn with_sender_id(mut self, sender_id: impl Into<String>) -> Self {
self.sender_id = sender_id.into();
self
}
/// Set the conversation scope for this message.
pub fn with_conversation_scope(mut self, scope_id: impl Into<String>) -> Self {
self.conversation_scope_id = Some(scope_id.into());
self
}
/// Set metadata.
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = metadata;
self
}
/// Set user name.
pub fn with_user_name(mut self, name: impl Into<String>) -> Self {
self.user_name = Some(name.into());
self
}
/// Set the client timezone.
pub fn with_timezone(mut self, tz: impl Into<String>) -> Self {
self.timezone = Some(tz.into());
self
}
/// Set attachments.
pub fn with_attachments(mut self, attachments: Vec<IncomingAttachment>) -> Self {
self.attachments = attachments;
self
}
/// Mark this message as internal (bypasses user-input pipeline).
pub(crate) fn into_internal(mut self) -> Self {
self.is_internal = true;
self
}
/// Effective conversation scope, falling back to thread_id for legacy callers.
pub fn conversation_scope(&self) -> Option<&str> {
self.conversation_scope_id
.as_deref()
.or(self.thread_id.as_deref())
}
/// Best-effort routing target for proactive replies on the current channel.
pub fn routing_target(&self) -> Option<String> {
routing_target_from_metadata(&self.metadata).or_else(|| {
if self.sender_id.is_empty() {
None
} else {
Some(self.sender_id.clone())
}
})
}
}
/// Extract a channel-specific proactive routing target from message metadata.
pub fn routing_target_from_metadata(metadata: &serde_json::Value) -> Option<String> {
metadata
.get("signal_target")
.and_then(|value| match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
})
.or_else(|| {
metadata.get("chat_id").and_then(|value| match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
})
})
.or_else(|| {
metadata.get("target").and_then(|value| match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
})
})
}
/// Stream of incoming messages.
pub type MessageStream = Pin<Box<dyn Stream<Item = IncomingMessage> + Send>>;
/// Response to send back to a channel.
#[derive(Debug, Clone)]
pub struct OutgoingResponse {
/// The content to send.
pub content: String,
/// Optional thread ID to reply in.
pub thread_id: Option<String>,
/// Optional file paths to attach.
pub attachments: Vec<String>,
/// Channel-specific metadata for the response.
pub metadata: serde_json::Value,
}
impl OutgoingResponse {
/// Create a simple text response.
pub fn text(content: impl Into<String>) -> Self {
Self {
content: content.into(),
thread_id: None,
attachments: Vec::new(),
metadata: serde_json::Value::Null,
}
}
/// Set the thread ID for the response.
pub fn in_thread(mut self, thread_id: impl Into<String>) -> Self {
self.thread_id = Some(thread_id.into());
self
}
/// Add attachments to the response.
pub fn with_attachments(mut self, paths: Vec<String>) -> Self {
self.attachments = paths;
self
}
}
/// Status update types for showing agent activity.
#[derive(Debug, Clone)]
pub enum StatusUpdate {
/// Agent is thinking/processing.
Thinking(String),
/// Tool execution started.
ToolStarted { name: String },
/// Tool execution completed.
///
/// Use [`StatusUpdate::tool_completed`] to construct this variant — it
/// handles redaction of sensitive parameters and keeps the 9-line pattern
/// in one place.
ToolCompleted {
name: String,
success: bool,
/// Error message when success is false.
error: Option<String>,
/// Tool input parameters (JSON string) for display on failure.
/// Only populated when `success` is `false`. Values listed in the
/// tool's `sensitive_params()` are replaced with `"[REDACTED]"`.
parameters: Option<String>,
},
/// Brief preview of tool execution output.
ToolResult { name: String, preview: String },
/// Streaming text chunk.
StreamChunk(String),
/// General status message.
Status(String),
/// A sandbox job has started (shown as a clickable card in the UI).
JobStarted {
job_id: String,
title: String,
browse_url: String,
},
/// Tool requires user approval before execution.
ApprovalNeeded {
request_id: String,
tool_name: String,
description: String,
parameters: serde_json::Value,
/// When `true`, the UI should offer an "always" option that auto-approves
/// future calls to this tool for the rest of the session. When `false`
/// (i.e. `ApprovalRequirement::Always`), the tool must be approved every
/// time and the "always" button should be hidden.
allow_always: bool,
},
/// Extension needs user authentication (token or OAuth).
AuthRequired {
extension_name: String,
instructions: Option<String>,
auth_url: Option<String>,
setup_url: Option<String>,
},
/// Extension authentication completed.
AuthCompleted {
extension_name: String,
success: bool,
message: String,
},
/// An image was generated by a tool.
ImageGenerated {
/// Base64 data URL of the generated image.
data_url: String,
/// Optional workspace path where the image was saved.
path: Option<String>,
},
/// Suggested follow-up messages for the user.
Suggestions { suggestions: Vec<String> },
/// Per-turn token usage and cost summary (shown as subtle metadata).
TurnCost {
input_tokens: u64,
output_tokens: u64,
cost_usd: String,
},
}
impl StatusUpdate {
/// Build a `ToolCompleted` status with redacted parameters.
///
/// On failure, serializes the tool's input parameters as pretty JSON after
/// replacing any keys listed in the tool's `sensitive_params()` with
/// `"[REDACTED]"`. On success, no parameters or error are included.
///
/// Pass the resolved `Tool` reference (if available) so this method can
/// query `sensitive_params()` directly — callers don't need to manage the
/// borrow lifetime of the sensitive slice.
pub fn tool_completed(
name: String,
result: &Result<String, crate::error::Error>,
params: &serde_json::Value,
tool: Option<&dyn crate::tools::Tool>,
) -> Self {
let success = result.is_ok();
let sensitive = tool.map(|t| t.sensitive_params()).unwrap_or(&[]);
Self::ToolCompleted {
name,
success,
error: result.as_ref().err().map(|e| e.to_string()),
parameters: if !success {
let safe = crate::tools::redact_params(params, sensitive);
Some(serde_json::to_string_pretty(&safe).unwrap_or_else(|_| safe.to_string()))
} else {
None
},
}
}
}
/// Trait for message channels.
///
/// Channels receive messages from external sources and convert them to
/// a unified format. They also handle sending responses back.
#[async_trait]
pub trait Channel: Send + Sync {
/// Get the channel name (e.g., "cli", "slack", "telegram", "http").
fn name(&self) -> &str;
/// Start listening for messages.
///
/// Returns a stream of incoming messages. The channel should handle
/// reconnection and error recovery internally.
async fn start(&self) -> Result<MessageStream, ChannelError>;
/// Send a response back to the user.
///
/// The response is sent in the context of the original message
/// (same channel, same thread if applicable).
async fn respond(
&self,
msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError>;
/// Send a status update (thinking, tool execution, etc.).
///
/// The metadata contains channel-specific routing info (e.g., Telegram chat_id)
/// needed to deliver the status to the correct destination.
///
/// Default implementation does nothing (for channels that don't support status).
async fn send_status(
&self,
_status: StatusUpdate,
_metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
Ok(())
}
/// Send a proactive message without a prior incoming message.
///
/// Used for alerts, heartbeat notifications, and other agent-initiated communication.
/// The user_id helps target a specific user within the channel.
///
/// Default implementation does nothing (for channels that don't support broadcast).
async fn broadcast(
&self,
_user_id: &str,
_response: OutgoingResponse,
) -> Result<(), ChannelError> {
Ok(())
}
/// Check if the channel is healthy.
async fn health_check(&self) -> Result<(), ChannelError>;
/// Get conversation context from message metadata for system prompt.
///
/// Returns key-value pairs like "sender", "sender_uuid", "group" that
/// help the LLM understand who it's talking to.
///
/// Default implementation returns empty map.
fn conversation_context(&self, _metadata: &serde_json::Value) -> HashMap<String, String> {
HashMap::new()
}
/// Gracefully shut down the channel.
async fn shutdown(&self) -> Result<(), ChannelError> {
Ok(())
}
}
/// Trait for channels that support hot-secret-swapping during SIGHUP reload.
///
/// This allows channels to update authentication credentials without restarting,
/// enabling zero-downtime configuration reloads. Channels that don't support
/// secret updates can simply not implement this trait.
#[async_trait]
pub trait ChannelSecretUpdater: Send + Sync {
/// Update the secret for this channel.
///
/// Called during SIGHUP configuration reload. Implementation should:
/// - Apply the new secret atomically
/// - Not fail the entire reload if secret update fails
/// - Log appropriate errors/info messages
///
/// The secret is optional (may be None if secret is no longer configured).
async fn update_secret(&self, new_secret: Option<secrecy::SecretString>);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::credentials::TEST_REDACT_SECRET_123;
/// Stub tool that marks `"value"` as sensitive.
struct SecretTool;
#[async_trait]
impl crate::tools::Tool for SecretTool {
fn name(&self) -> &str {
"secret_save"
}
fn description(&self) -> &str {
"stub"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {}})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &crate::context::JobContext,
) -> Result<crate::tools::ToolOutput, crate::tools::ToolError> {
unreachable!()
}
fn sensitive_params(&self) -> &[&str] {
&["value"]
}
}
#[test]
fn tool_completed_redacts_sensitive_params_on_failure() {
let params = serde_json::json!({"name": "api_key", "value": TEST_REDACT_SECRET_123});
let err: Result<String, crate::error::Error> =
Err(crate::error::ToolError::ExecutionFailed {
name: "secret_save".into(),
reason: "db error".into(),
}
.into());
let tool = SecretTool;
let status = StatusUpdate::tool_completed(
"secret_save".into(),
&err,
&params,
Some(&tool as &dyn crate::tools::Tool),
);
if let StatusUpdate::ToolCompleted {
success,
error,
parameters,
..
} = &status
{
assert!(!success);
let err_msg = error.as_deref().expect("should have error");
assert!(err_msg.contains("db error"), "error: {}", err_msg);
let param_str = parameters
.as_ref()
.expect("should have parameters on failure");
assert!(
param_str.contains("[REDACTED]"),
"sensitive value should be redacted: {}",
param_str
);
assert!(
!param_str.contains(TEST_REDACT_SECRET_123),
"raw secret should not appear: {}",
param_str
);
assert!(
param_str.contains("api_key"),
"non-sensitive params should be preserved: {}",
param_str
);
} else {
panic!("expected ToolCompleted variant");
}
}
#[test]
fn tool_completed_no_params_on_success() {
let params = serde_json::json!({"name": "key", "value": "secret"});
let ok: Result<String, crate::error::Error> = Ok("done".into());
let status = StatusUpdate::tool_completed("secret_save".into(), &ok, &params, None);
if let StatusUpdate::ToolCompleted {
success,
error,
parameters,
..
} = &status
{
assert!(success);
assert!(error.is_none());
assert!(parameters.is_none(), "no params should be sent on success");
} else {
panic!("expected ToolCompleted variant");
}
}
#[test]
fn tool_completed_no_tool_passes_params_unredacted() {
let params = serde_json::json!({"cmd": "ls -la"});
let err: Result<String, crate::error::Error> =
Err(crate::error::ToolError::ExecutionFailed {
name: "shell".into(),
reason: "timeout".into(),
}
.into());
let status = StatusUpdate::tool_completed("shell".into(), &err, &params, None);
if let StatusUpdate::ToolCompleted { parameters, .. } = &status {
let param_str = parameters.as_ref().expect("should have parameters");
assert!(
param_str.contains("ls -la"),
"non-sensitive params should pass through: {}",
param_str
);
} else {
panic!("expected ToolCompleted variant");
}
}
#[test]
fn test_incoming_message_with_timezone() {
let msg = IncomingMessage::new("test", "user1", "hello").with_timezone("America/New_York");
assert_eq!(msg.timezone.as_deref(), Some("America/New_York"));
}
}