mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
fix: improve Telegram status delivery and reliability (#304)
* fix: make Telegram status prompts reliable Approval and auth prompts could be missed when polling or reply-context sends failed, leaving users stuck in waiting states. This adds explicit status mapping and retries, keeps typing active through intermediate work while suppressing noisy tool telemetry, and adds regression tests plus CI coverage for the Telegram channel crate. * fix: normalize terminal status handling Terminal status strings from the agent loop can vary in casing and formatting, which could leak internal status lines to Telegram. This normalizes Done/Interrupted mapping and filters terminal status text consistently to keep chat UX clean while preserving actionable prompts. --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c68dc2ff2a
commit
4003300a8c
+607
-24
@@ -1375,13 +1375,25 @@ impl WasmChannel {
|
||||
/// that repeats the call every 4 seconds (Telegram's typing indicator
|
||||
/// expires after ~5s).
|
||||
///
|
||||
/// On Done/Interrupted/Status: cancels the repeat task, fires on_status once.
|
||||
/// On terminal or user-action-required states: cancels the repeat task,
|
||||
/// then fires on_status once.
|
||||
///
|
||||
/// On intermediate progress states (tool/auth/job/status updates), keeps
|
||||
/// the typing repeater running and fires on_status once.
|
||||
/// On StreamChunk: no-op (too noisy).
|
||||
async fn handle_status_update(
|
||||
&self,
|
||||
status: StatusUpdate,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Result<(), ChannelError> {
|
||||
fn is_terminal_text_status(msg: &str) -> bool {
|
||||
let trimmed = msg.trim();
|
||||
trimmed.eq_ignore_ascii_case("done")
|
||||
|| trimmed.eq_ignore_ascii_case("interrupted")
|
||||
|| trimmed.eq_ignore_ascii_case("awaiting approval")
|
||||
|| trimmed.eq_ignore_ascii_case("rejected")
|
||||
}
|
||||
|
||||
match &status {
|
||||
StatusUpdate::Thinking(_) => {
|
||||
// Cancel any existing typing task
|
||||
@@ -1508,8 +1520,8 @@ impl WasmChannel {
|
||||
let _ = self.call_on_status(&status, metadata).await;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Done, Interrupted, Status, ToolStarted, ToolCompleted: cancel and fire once
|
||||
StatusUpdate::AuthRequired { .. } => {
|
||||
// Waiting on user action: stop typing and fire once.
|
||||
self.cancel_typing_task().await;
|
||||
|
||||
if let Err(e) = self.call_on_status(&status, metadata).await {
|
||||
@@ -1520,6 +1532,28 @@ impl WasmChannel {
|
||||
);
|
||||
}
|
||||
}
|
||||
StatusUpdate::Status(msg) if is_terminal_text_status(msg) => {
|
||||
// Waiting on user or terminal states: stop typing and fire once.
|
||||
self.cancel_typing_task().await;
|
||||
|
||||
if let Err(e) = self.call_on_status(&status, metadata).await {
|
||||
tracing::debug!(
|
||||
channel = %self.name,
|
||||
error = %e,
|
||||
"on_status failed (best-effort)"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Intermediate progress status: keep any existing typing task alive.
|
||||
if let Err(e) = self.call_on_status(&status, metadata).await {
|
||||
tracing::debug!(
|
||||
channel = %self.name,
|
||||
error = %e,
|
||||
"on_status failed (best-effort)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2126,6 +2160,16 @@ fn convert_http_response(wit: wit_channel::OutgoingHttpResponse) -> HttpResponse
|
||||
}
|
||||
|
||||
/// Convert a StatusUpdate + metadata into the WIT StatusUpdate type.
|
||||
fn truncate_status_text(input: &str, max_chars: usize) -> String {
|
||||
let mut iter = input.chars();
|
||||
let truncated: String = iter.by_ref().take(max_chars).collect();
|
||||
if iter.next().is_some() {
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_channel::StatusUpdate {
|
||||
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
|
||||
|
||||
@@ -2137,17 +2181,25 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
||||
},
|
||||
StatusUpdate::ToolStarted { name } => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::ToolStarted,
|
||||
message: name.clone(),
|
||||
message: format!("Tool started: {}", name),
|
||||
metadata_json,
|
||||
},
|
||||
StatusUpdate::ToolCompleted { name, success } => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::ToolCompleted,
|
||||
message: format!("{}: {}", name, if *success { "ok" } else { "failed" }),
|
||||
message: format!(
|
||||
"Tool completed: {} ({})",
|
||||
name,
|
||||
if *success { "ok" } else { "failed" }
|
||||
),
|
||||
metadata_json,
|
||||
},
|
||||
StatusUpdate::ToolResult { name, preview } => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::ToolCompleted,
|
||||
message: format!("{}: {}", name, preview),
|
||||
status: wit_channel::StatusType::ToolResult,
|
||||
message: format!(
|
||||
"Tool result: {}\n{}",
|
||||
name,
|
||||
truncate_status_text(preview, 280)
|
||||
),
|
||||
metadata_json,
|
||||
},
|
||||
StatusUpdate::StreamChunk(chunk) => wit_channel::StatusUpdate {
|
||||
@@ -2156,11 +2208,16 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
||||
metadata_json,
|
||||
},
|
||||
StatusUpdate::Status(msg) => {
|
||||
// Map well-known status strings to WIT types
|
||||
let status_type = match msg.as_str() {
|
||||
"Done" => wit_channel::StatusType::Done,
|
||||
"Interrupted" => wit_channel::StatusType::Interrupted,
|
||||
_ => wit_channel::StatusType::Thinking,
|
||||
// Map well-known status strings to WIT types (case-insensitive
|
||||
// to stay consistent with is_terminal_text_status and the
|
||||
// Telegram-side classify_status_update).
|
||||
let trimmed = msg.trim();
|
||||
let status_type = if trimmed.eq_ignore_ascii_case("done") {
|
||||
wit_channel::StatusType::Done
|
||||
} else if trimmed.eq_ignore_ascii_case("interrupted") {
|
||||
wit_channel::StatusType::Interrupted
|
||||
} else {
|
||||
wit_channel::StatusType::Status
|
||||
};
|
||||
wit_channel::StatusUpdate {
|
||||
status: status_type,
|
||||
@@ -2169,34 +2226,62 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
||||
}
|
||||
}
|
||||
StatusUpdate::ApprovalNeeded {
|
||||
request_id,
|
||||
tool_name,
|
||||
description,
|
||||
..
|
||||
} => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::Thinking,
|
||||
message: format!("Approval needed: {} - {}", tool_name, description),
|
||||
status: wit_channel::StatusType::ApprovalNeeded,
|
||||
message: format!(
|
||||
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: yes (or /approve), no (or /deny), or always (or /always).",
|
||||
tool_name, description, request_id
|
||||
),
|
||||
metadata_json,
|
||||
},
|
||||
StatusUpdate::JobStarted { job_id, title, .. } => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::Thinking,
|
||||
message: format!("Job started: {} ({})", title, job_id),
|
||||
StatusUpdate::JobStarted {
|
||||
job_id,
|
||||
title,
|
||||
browse_url,
|
||||
} => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::JobStarted,
|
||||
message: format!("Job started: {} ({})\n{}", title, job_id, browse_url),
|
||||
metadata_json,
|
||||
},
|
||||
StatusUpdate::AuthRequired { extension_name, .. } => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::Thinking,
|
||||
message: format!("Auth required: {}", extension_name),
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name,
|
||||
instructions,
|
||||
auth_url,
|
||||
setup_url,
|
||||
} => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::AuthRequired,
|
||||
message: {
|
||||
let mut lines = vec![format!("Authentication required for {}.", extension_name)];
|
||||
if let Some(text) = instructions
|
||||
&& !text.trim().is_empty()
|
||||
{
|
||||
lines.push(text.trim().to_string());
|
||||
}
|
||||
if let Some(url) = auth_url {
|
||||
lines.push(format!("Auth URL: {}", url));
|
||||
}
|
||||
if let Some(url) = setup_url {
|
||||
lines.push(format!("Setup URL: {}", url));
|
||||
}
|
||||
lines.join("\n")
|
||||
},
|
||||
metadata_json,
|
||||
},
|
||||
StatusUpdate::AuthCompleted {
|
||||
extension_name,
|
||||
success,
|
||||
..
|
||||
message,
|
||||
} => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::Thinking,
|
||||
status: wit_channel::StatusType::AuthCompleted,
|
||||
message: format!(
|
||||
"Auth {}: {}",
|
||||
"Authentication {} for {}. {}",
|
||||
if *success { "completed" } else { "failed" },
|
||||
extension_name
|
||||
extension_name,
|
||||
message
|
||||
),
|
||||
metadata_json,
|
||||
},
|
||||
@@ -2212,6 +2297,12 @@ fn clone_wit_status_update(update: &wit_channel::StatusUpdate) -> wit_channel::S
|
||||
wit_channel::StatusType::Interrupted => wit_channel::StatusType::Interrupted,
|
||||
wit_channel::StatusType::ToolStarted => wit_channel::StatusType::ToolStarted,
|
||||
wit_channel::StatusType::ToolCompleted => wit_channel::StatusType::ToolCompleted,
|
||||
wit_channel::StatusType::ToolResult => wit_channel::StatusType::ToolResult,
|
||||
wit_channel::StatusType::ApprovalNeeded => wit_channel::StatusType::ApprovalNeeded,
|
||||
wit_channel::StatusType::Status => wit_channel::StatusType::Status,
|
||||
wit_channel::StatusType::JobStarted => wit_channel::StatusType::JobStarted,
|
||||
wit_channel::StatusType::AuthRequired => wit_channel::StatusType::AuthRequired,
|
||||
wit_channel::StatusType::AuthCompleted => wit_channel::StatusType::AuthCompleted,
|
||||
},
|
||||
message: update.message.clone(),
|
||||
metadata_json: update.metadata_json.clone(),
|
||||
@@ -2555,6 +2646,100 @@ mod tests {
|
||||
channel.shutdown().await.expect("Shutdown should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typing_task_persists_on_tool_started() {
|
||||
let channel = create_test_channel();
|
||||
let _stream = channel.start().await.expect("Channel should start");
|
||||
|
||||
let metadata = serde_json::json!({"chat_id": 123});
|
||||
|
||||
// Start typing
|
||||
let _ = channel
|
||||
.send_status(
|
||||
crate::channels::StatusUpdate::Thinking("Processing...".into()),
|
||||
&metadata,
|
||||
)
|
||||
.await;
|
||||
assert!(channel.typing_task.read().await.is_some());
|
||||
|
||||
// Intermediate tool status should not cancel typing
|
||||
let _ = channel
|
||||
.send_status(
|
||||
crate::channels::StatusUpdate::ToolStarted {
|
||||
name: "http_request".into(),
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(channel.typing_task.read().await.is_some());
|
||||
|
||||
channel.shutdown().await.expect("Shutdown should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typing_task_cancelled_on_approval_needed() {
|
||||
let channel = create_test_channel();
|
||||
let _stream = channel.start().await.expect("Channel should start");
|
||||
|
||||
let metadata = serde_json::json!({"chat_id": 123});
|
||||
|
||||
// Start typing
|
||||
let _ = channel
|
||||
.send_status(
|
||||
crate::channels::StatusUpdate::Thinking("Processing...".into()),
|
||||
&metadata,
|
||||
)
|
||||
.await;
|
||||
assert!(channel.typing_task.read().await.is_some());
|
||||
|
||||
// Approval-needed should stop typing while waiting for user action
|
||||
let _ = channel
|
||||
.send_status(
|
||||
crate::channels::StatusUpdate::ApprovalNeeded {
|
||||
request_id: "req-1".into(),
|
||||
tool_name: "http_request".into(),
|
||||
description: "Fetch weather".into(),
|
||||
parameters: serde_json::json!({"url": "https://wttr.in"}),
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(channel.typing_task.read().await.is_none());
|
||||
|
||||
channel.shutdown().await.expect("Shutdown should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typing_task_cancelled_on_awaiting_approval_status() {
|
||||
let channel = create_test_channel();
|
||||
let _stream = channel.start().await.expect("Channel should start");
|
||||
|
||||
let metadata = serde_json::json!({"chat_id": 123});
|
||||
|
||||
// Start typing
|
||||
let _ = channel
|
||||
.send_status(
|
||||
crate::channels::StatusUpdate::Thinking("Processing...".into()),
|
||||
&metadata,
|
||||
)
|
||||
.await;
|
||||
assert!(channel.typing_task.read().await.is_some());
|
||||
|
||||
// Legacy terminal status string should also cancel typing
|
||||
let _ = channel
|
||||
.send_status(
|
||||
crate::channels::StatusUpdate::Status("Awaiting approval".into()),
|
||||
&metadata,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(channel.typing_task.read().await.is_none());
|
||||
|
||||
channel.shutdown().await.expect("Shutdown should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typing_task_replaced_on_new_thinking() {
|
||||
let channel = create_test_channel();
|
||||
@@ -2678,6 +2863,27 @@ mod tests {
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_done_case_insensitive() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!(null);
|
||||
|
||||
// lowercase
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("done".into()),
|
||||
&metadata,
|
||||
);
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
|
||||
|
||||
// with whitespace
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status(" Done ".into()),
|
||||
&metadata,
|
||||
);
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_interrupted() {
|
||||
use super::status_to_wit;
|
||||
@@ -2694,6 +2900,311 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_interrupted_case_insensitive() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!(null);
|
||||
|
||||
// lowercase
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("interrupted".into()),
|
||||
&metadata,
|
||||
);
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::Interrupted
|
||||
));
|
||||
|
||||
// with whitespace
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status(" Interrupted ".into()),
|
||||
&metadata,
|
||||
);
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::Interrupted
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_generic_status() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!(null);
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("Awaiting approval".into()),
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Status));
|
||||
assert_eq!(wit.message, "Awaiting approval");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_auth_required() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!({"chat_id": 42});
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::AuthRequired {
|
||||
extension_name: "weather".to_string(),
|
||||
instructions: Some("Paste your token".to_string()),
|
||||
auth_url: Some("https://example.com/auth".to_string()),
|
||||
setup_url: None,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::AuthRequired
|
||||
));
|
||||
assert!(wit.message.contains("Authentication required for weather"));
|
||||
assert!(wit.message.contains("Paste your token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_tool_started() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!({"chat_id": 7});
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::ToolStarted {
|
||||
name: "http_request".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::ToolStarted
|
||||
));
|
||||
assert_eq!(wit.message, "Tool started: http_request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_tool_completed_success() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!(null);
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::ToolCompleted {
|
||||
name: "http_request".to_string(),
|
||||
success: true,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::ToolCompleted
|
||||
));
|
||||
assert_eq!(wit.message, "Tool completed: http_request (ok)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_tool_completed_failure() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!(null);
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::ToolCompleted {
|
||||
name: "http_request".to_string(),
|
||||
success: false,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::ToolCompleted
|
||||
));
|
||||
assert_eq!(wit.message, "Tool completed: http_request (failed)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_tool_result() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!(null);
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::ToolResult {
|
||||
name: "http_request".to_string(),
|
||||
preview: "{".to_string() + "\"temperature\": 22}",
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::ToolResult
|
||||
));
|
||||
assert!(wit.message.starts_with("Tool result: http_request\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_tool_result_truncates_preview() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!(null);
|
||||
let long_preview = "x".repeat(400);
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::ToolResult {
|
||||
name: "big_tool".to_string(),
|
||||
preview: long_preview,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::ToolResult
|
||||
));
|
||||
assert!(wit.message.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_job_started() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!({"chat_id": 1});
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::JobStarted {
|
||||
job_id: "job-1".to_string(),
|
||||
title: "Daily sync".to_string(),
|
||||
browse_url: "https://example.com/jobs/job-1".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::JobStarted
|
||||
));
|
||||
assert!(wit.message.contains("Daily sync"));
|
||||
assert!(wit.message.contains("https://example.com/jobs/job-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_auth_completed_success() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!(null);
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::AuthCompleted {
|
||||
extension_name: "weather".to_string(),
|
||||
success: true,
|
||||
message: "Token saved".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::AuthCompleted
|
||||
));
|
||||
assert!(wit.message.contains("Authentication completed"));
|
||||
assert!(wit.message.contains("Token saved"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_auth_completed_failure() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!(null);
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::AuthCompleted {
|
||||
extension_name: "weather".to_string(),
|
||||
success: false,
|
||||
message: "Invalid token".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::AuthCompleted
|
||||
));
|
||||
assert!(wit.message.contains("Authentication failed"));
|
||||
assert!(wit.message.contains("Invalid token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_to_wit_approval_needed() {
|
||||
use super::status_to_wit;
|
||||
|
||||
let metadata = serde_json::json!({"chat_id": 42});
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::ApprovalNeeded {
|
||||
request_id: "req-123".to_string(),
|
||||
tool_name: "http_request".to_string(),
|
||||
description: "Fetch weather data".to_string(),
|
||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::ApprovalNeeded
|
||||
));
|
||||
assert!(wit.message.contains("http_request"));
|
||||
assert!(wit.message.contains("/approve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_prompt_roundtrip_submission_aliases() {
|
||||
use super::status_to_wit;
|
||||
use crate::agent::submission::{Submission, SubmissionParser};
|
||||
|
||||
let metadata = serde_json::json!({"chat_id": 42});
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::ApprovalNeeded {
|
||||
request_id: "req-321".to_string(),
|
||||
tool_name: "http_request".to_string(),
|
||||
description: "Fetch weather data".to_string(),
|
||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::ApprovalNeeded
|
||||
));
|
||||
assert!(wit.message.contains("/approve"));
|
||||
assert!(wit.message.contains("/deny"));
|
||||
assert!(wit.message.contains("/always"));
|
||||
|
||||
let approve = SubmissionParser::parse("/approve");
|
||||
assert!(matches!(
|
||||
approve,
|
||||
Submission::ApprovalResponse {
|
||||
approved: true,
|
||||
always: false
|
||||
}
|
||||
));
|
||||
|
||||
let deny = SubmissionParser::parse("/deny");
|
||||
assert!(matches!(
|
||||
deny,
|
||||
Submission::ApprovalResponse {
|
||||
approved: false,
|
||||
always: false
|
||||
}
|
||||
));
|
||||
|
||||
let always = SubmissionParser::parse("/always");
|
||||
assert!(matches!(
|
||||
always,
|
||||
Submission::ApprovalResponse {
|
||||
approved: true,
|
||||
always: true
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_wit_status_update() {
|
||||
use super::{clone_wit_status_update, wit_channel};
|
||||
@@ -2710,6 +3221,78 @@ mod tests {
|
||||
assert_eq!(cloned.metadata_json, "{\"a\":1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_wit_status_update_approval_needed() {
|
||||
use super::{clone_wit_status_update, wit_channel};
|
||||
|
||||
let original = wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::ApprovalNeeded,
|
||||
message: "approval needed".to_string(),
|
||||
metadata_json: "{\"chat_id\":42}".to_string(),
|
||||
};
|
||||
|
||||
let cloned = clone_wit_status_update(&original);
|
||||
assert!(matches!(
|
||||
cloned.status,
|
||||
wit_channel::StatusType::ApprovalNeeded
|
||||
));
|
||||
assert_eq!(cloned.message, "approval needed");
|
||||
assert_eq!(cloned.metadata_json, "{\"chat_id\":42}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_wit_status_update_auth_completed() {
|
||||
use super::{clone_wit_status_update, wit_channel};
|
||||
|
||||
let original = wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::AuthCompleted,
|
||||
message: "auth complete".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
};
|
||||
|
||||
let cloned = clone_wit_status_update(&original);
|
||||
assert!(matches!(
|
||||
cloned.status,
|
||||
wit_channel::StatusType::AuthCompleted
|
||||
));
|
||||
assert_eq!(cloned.message, "auth complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_wit_status_update_all_variants() {
|
||||
use super::{clone_wit_status_update, wit_channel};
|
||||
|
||||
let variants = vec![
|
||||
wit_channel::StatusType::Thinking,
|
||||
wit_channel::StatusType::Done,
|
||||
wit_channel::StatusType::Interrupted,
|
||||
wit_channel::StatusType::ToolStarted,
|
||||
wit_channel::StatusType::ToolCompleted,
|
||||
wit_channel::StatusType::ToolResult,
|
||||
wit_channel::StatusType::ApprovalNeeded,
|
||||
wit_channel::StatusType::Status,
|
||||
wit_channel::StatusType::JobStarted,
|
||||
wit_channel::StatusType::AuthRequired,
|
||||
wit_channel::StatusType::AuthCompleted,
|
||||
];
|
||||
|
||||
for status in variants {
|
||||
let original = wit_channel::StatusUpdate {
|
||||
status,
|
||||
message: "sample".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
};
|
||||
let cloned = clone_wit_status_update(&original);
|
||||
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&cloned.status),
|
||||
std::mem::discriminant(&original.status)
|
||||
);
|
||||
assert_eq!(cloned.message, "sample");
|
||||
assert_eq!(cloned.metadata_json, "{}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redact_credentials_replaces_values() {
|
||||
use super::ChannelStoreData;
|
||||
|
||||
Reference in New Issue
Block a user