mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
feat(ui): show tool arguments in CLI and gateway
Add params_summary to ActionExecuted/ActionFailed events so the CLI and gateway can display what tools are doing: ● http(https://api.github.com/repos/nearai/ironclaw/issues) ● web_search(latest AI news) ● memory_read(HEARTBEAT.md) The summarize_params() helper extracts the most relevant argument per tool type (URL for http, query for search, path for memory, etc.) and truncates to 80 chars. Sensitive params are not included. Router forwards the summary in both StatusUpdate (CLI/REPL) and AppEvent (web gateway SSE) display names. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
@@ -1211,7 +1211,10 @@ mod tests {
|
||||
|
||||
assert!(!exec_events.is_empty(), "should have ActionExecuted events");
|
||||
for call_id in &exec_events {
|
||||
assert!(!call_id.is_empty(), "ActionExecuted event must have non-empty call_id");
|
||||
assert!(
|
||||
!call_id.is_empty(),
|
||||
"ActionExecuted event must have non-empty call_id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1257,20 +1260,11 @@ mod tests {
|
||||
let policy = Arc::new(PolicyEngine::new());
|
||||
|
||||
// Grant a lease that does NOT cover "restricted_tool"
|
||||
leases
|
||||
.grant(tid, "basic_cap", vec![], None, None)
|
||||
.await;
|
||||
leases.grant(tid, "basic_cap", vec![], None, None).await;
|
||||
|
||||
let (_tx, rx) = crate::runtime::messaging::signal_channel(16);
|
||||
let mut exec = ExecutionLoop::new(
|
||||
thread,
|
||||
llm,
|
||||
effects,
|
||||
leases,
|
||||
policy,
|
||||
rx,
|
||||
"test-user".into(),
|
||||
);
|
||||
let mut exec =
|
||||
ExecutionLoop::new(thread, llm, effects, leases, policy, rx, "test-user".into());
|
||||
|
||||
exec.run().await.unwrap();
|
||||
|
||||
@@ -1306,10 +1300,7 @@ mod tests {
|
||||
.collect();
|
||||
|
||||
for (call_id, _name) in &fail_events {
|
||||
assert!(
|
||||
!call_id.is_empty(),
|
||||
"ActionFailed event must have call_id"
|
||||
);
|
||||
assert!(!call_id.is_empty(), "ActionFailed event must have call_id");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ use crate::traits::effect::{EffectExecutor, ThreadExecutionContext};
|
||||
use crate::traits::llm::{LlmBackend, LlmCallConfig};
|
||||
use crate::traits::store::Store;
|
||||
use crate::types::error::EngineError;
|
||||
use crate::types::event::{EventKind, ThreadEvent};
|
||||
use crate::types::event::{EventKind, ThreadEvent, summarize_params};
|
||||
use crate::types::message::ThreadMessage;
|
||||
use crate::types::project::ProjectId;
|
||||
use crate::types::step::{StepId, TokenUsage};
|
||||
@@ -719,6 +719,7 @@ async fn handle_execute_action(
|
||||
action_name: name.clone(),
|
||||
call_id: call_id.clone(),
|
||||
error,
|
||||
params_summary: None,
|
||||
},
|
||||
&call_id,
|
||||
&name,
|
||||
@@ -751,6 +752,7 @@ async fn handle_execute_action(
|
||||
action_name: name.clone(),
|
||||
call_id: call_id.clone(),
|
||||
error: reason,
|
||||
params_summary: None,
|
||||
},
|
||||
&call_id,
|
||||
&name,
|
||||
@@ -779,6 +781,7 @@ async fn handle_execute_action(
|
||||
}
|
||||
|
||||
// 4. Execute
|
||||
let ps = summarize_params(&name, ¶ms);
|
||||
match effects
|
||||
.execute_action(&name, params, &lease, &exec_ctx)
|
||||
.await
|
||||
@@ -792,6 +795,7 @@ async fn handle_execute_action(
|
||||
action_name: name.clone(),
|
||||
call_id: call_id.clone(),
|
||||
duration_ms: r.duration.as_millis() as u64,
|
||||
params_summary: ps.clone(),
|
||||
},
|
||||
&call_id,
|
||||
&name,
|
||||
@@ -815,6 +819,7 @@ async fn handle_execute_action(
|
||||
action_name: name.clone(),
|
||||
call_id: call_id.clone(),
|
||||
error: e.to_string(),
|
||||
params_summary: ps,
|
||||
},
|
||||
&call_id,
|
||||
&name,
|
||||
@@ -886,6 +891,7 @@ fn handle_emit_event(
|
||||
action_name,
|
||||
call_id,
|
||||
duration_ms: 0,
|
||||
params_summary: None,
|
||||
}
|
||||
}
|
||||
"action_failed" => {
|
||||
@@ -897,6 +903,7 @@ fn handle_emit_event(
|
||||
action_name,
|
||||
call_id,
|
||||
error,
|
||||
params_summary: None,
|
||||
}
|
||||
}
|
||||
"skill_activated" => {
|
||||
|
||||
@@ -443,10 +443,7 @@ pub async fn execute_code_with_skills(
|
||||
let entries: Vec<(MontyObject, MontyObject)> = known_actions
|
||||
.iter()
|
||||
.map(|name| {
|
||||
(
|
||||
MontyObject::String(name.clone()),
|
||||
MontyObject::Bool(true),
|
||||
)
|
||||
(MontyObject::String(name.clone()), MontyObject::Bool(true))
|
||||
})
|
||||
.collect();
|
||||
ExtFunctionResult::Return(MontyObject::Dict(entries.into()))
|
||||
@@ -993,6 +990,7 @@ async fn dispatch_action(
|
||||
action_name: action_name.into(),
|
||||
call_id: call_id.into(),
|
||||
error: format!("no lease for action '{action_name}'"),
|
||||
params_summary: None,
|
||||
});
|
||||
return DispatchResult::Ok(ExtFunctionResult::NotFound(action_name.into()));
|
||||
}
|
||||
@@ -1012,6 +1010,7 @@ async fn dispatch_action(
|
||||
action_name: action_name.into(),
|
||||
call_id: call_id.into(),
|
||||
error: reason.clone(),
|
||||
params_summary: None,
|
||||
});
|
||||
return DispatchResult::Ok(ExtFunctionResult::Error(MontyException::new(
|
||||
ExcType::RuntimeError,
|
||||
@@ -1046,6 +1045,7 @@ async fn dispatch_action(
|
||||
action_name: action_name.into(),
|
||||
call_id: call_id.into(),
|
||||
duration_ms: result.duration.as_millis() as u64,
|
||||
params_summary: None,
|
||||
});
|
||||
let monty_obj = json_to_monty(&result.output);
|
||||
action_results.push(result);
|
||||
@@ -1064,6 +1064,7 @@ async fn dispatch_action(
|
||||
action_name: action_name.into(),
|
||||
call_id: call_id.into(),
|
||||
error: e.to_string(),
|
||||
params_summary: None,
|
||||
});
|
||||
DispatchResult::Ok(ExtFunctionResult::Error(MontyException::new(
|
||||
ExcType::RuntimeError,
|
||||
|
||||
@@ -68,6 +68,7 @@ pub async fn execute_action_calls(
|
||||
action_name: call.action_name.clone(),
|
||||
call_id: call.id.clone(),
|
||||
error: format!("no lease for action '{}'", call.action_name),
|
||||
params_summary: None,
|
||||
});
|
||||
results.push(error_result);
|
||||
continue;
|
||||
@@ -97,6 +98,7 @@ pub async fn execute_action_calls(
|
||||
action_name: call.action_name.clone(),
|
||||
call_id: call.id.clone(),
|
||||
error: reason,
|
||||
params_summary: None,
|
||||
});
|
||||
results.push(error_result);
|
||||
continue;
|
||||
@@ -138,6 +140,7 @@ pub async fn execute_action_calls(
|
||||
action_name: call.action_name.clone(),
|
||||
call_id: call.id.clone(),
|
||||
duration_ms: action_result.duration.as_millis() as u64,
|
||||
params_summary: None,
|
||||
});
|
||||
results.push(action_result);
|
||||
}
|
||||
@@ -153,6 +156,7 @@ pub async fn execute_action_calls(
|
||||
action_name: action_name.clone(),
|
||||
call_id: call_id.clone(),
|
||||
error: format!("authentication required for credential '{credential_name}'"),
|
||||
params_summary: None,
|
||||
});
|
||||
return Ok(ActionBatchResult {
|
||||
results,
|
||||
@@ -178,6 +182,7 @@ pub async fn execute_action_calls(
|
||||
action_name: call.action_name.clone(),
|
||||
call_id: call.id.clone(),
|
||||
error: e.to_string(),
|
||||
params_summary: None,
|
||||
});
|
||||
results.push(error_result);
|
||||
}
|
||||
@@ -272,7 +277,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_id_preserved_on_successful_execution() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let thread = Thread::new(
|
||||
"test",
|
||||
ThreadType::Foreground,
|
||||
ProjectId::new(),
|
||||
ThreadConfig::default(),
|
||||
);
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
|
||||
vec![test_action("web_search")],
|
||||
vec![Ok(ActionResult {
|
||||
@@ -306,9 +316,17 @@ mod tests {
|
||||
assert!(!result.results[0].is_error);
|
||||
|
||||
// Event should carry the same call_id
|
||||
let exec_event = result.events.iter().find(|e| matches!(e, EventKind::ActionExecuted { .. }));
|
||||
let exec_event = result
|
||||
.events
|
||||
.iter()
|
||||
.find(|e| matches!(e, EventKind::ActionExecuted { .. }));
|
||||
assert!(exec_event.is_some());
|
||||
if let Some(EventKind::ActionExecuted { call_id, action_name, .. }) = exec_event {
|
||||
if let Some(EventKind::ActionExecuted {
|
||||
call_id,
|
||||
action_name,
|
||||
..
|
||||
}) = exec_event
|
||||
{
|
||||
assert_eq!(call_id, "call_r2o5mqBgdNUlH8KzskncUGaX");
|
||||
assert_eq!(action_name, "web_search");
|
||||
}
|
||||
@@ -316,7 +334,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_id_preserved_on_execution_error() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let thread = Thread::new(
|
||||
"test",
|
||||
ThreadType::Foreground,
|
||||
ProjectId::new(),
|
||||
ThreadConfig::default(),
|
||||
);
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
|
||||
vec![test_action("shell")],
|
||||
vec![Err(EngineError::Effect {
|
||||
@@ -343,7 +366,10 @@ mod tests {
|
||||
assert_eq!(result.results[0].call_id, "call_abc123def");
|
||||
assert!(result.results[0].is_error);
|
||||
|
||||
let fail_event = result.events.iter().find(|e| matches!(e, EventKind::ActionFailed { .. }));
|
||||
let fail_event = result
|
||||
.events
|
||||
.iter()
|
||||
.find(|e| matches!(e, EventKind::ActionFailed { .. }));
|
||||
assert!(fail_event.is_some());
|
||||
if let Some(EventKind::ActionFailed { call_id, .. }) = fail_event {
|
||||
assert_eq!(call_id, "call_abc123def");
|
||||
@@ -352,7 +378,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_id_preserved_when_no_lease() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let thread = Thread::new(
|
||||
"test",
|
||||
ThreadType::Foreground,
|
||||
ProjectId::new(),
|
||||
ThreadConfig::default(),
|
||||
);
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(vec![], vec![]));
|
||||
let leases = Arc::new(LeaseManager::new());
|
||||
let policy = Arc::new(PolicyEngine::new());
|
||||
@@ -383,7 +414,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_calls_each_get_correct_call_id() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let thread = Thread::new(
|
||||
"test",
|
||||
ThreadType::Foreground,
|
||||
ProjectId::new(),
|
||||
ThreadConfig::default(),
|
||||
);
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
|
||||
vec![test_action("tool_a"), test_action("tool_b")],
|
||||
vec![
|
||||
@@ -616,7 +652,12 @@ mod tests {
|
||||
/// ever has an empty call_id when the ActionCall provided one.
|
||||
#[tokio::test]
|
||||
async fn openai_empty_call_id_never_produced() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let thread = Thread::new(
|
||||
"test",
|
||||
ThreadType::Foreground,
|
||||
ProjectId::new(),
|
||||
ThreadConfig::default(),
|
||||
);
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
|
||||
vec![test_action("echo")],
|
||||
vec![Ok(ActionResult {
|
||||
@@ -653,7 +694,12 @@ mod tests {
|
||||
/// but engine must never lose it).
|
||||
#[tokio::test]
|
||||
async fn mistral_format_call_id_preserved() {
|
||||
let thread = Thread::new("test", ThreadType::Foreground, ProjectId::new(), ThreadConfig::default());
|
||||
let thread = Thread::new(
|
||||
"test",
|
||||
ThreadType::Foreground,
|
||||
ProjectId::new(),
|
||||
ThreadConfig::default(),
|
||||
);
|
||||
let effects: Arc<dyn EffectExecutor> = Arc::new(MockEffects::new(
|
||||
vec![test_action("web_search")],
|
||||
vec![Ok(ActionResult {
|
||||
|
||||
@@ -481,6 +481,7 @@ mod tests {
|
||||
action_name: "web_search".into(),
|
||||
call_id: "call_123".into(),
|
||||
error: "No lease for action 'web_search'".into(),
|
||||
params_summary: None,
|
||||
},
|
||||
));
|
||||
|
||||
@@ -546,13 +547,19 @@ mod tests {
|
||||
thread.add_message(ThreadMessage::assistant("parallel calls"));
|
||||
thread.add_message(ThreadMessage::action_result("", "tool_a", "result_a"));
|
||||
thread.add_message(ThreadMessage::action_result("", "tool_b", "result_b"));
|
||||
thread.add_message(ThreadMessage::action_result("call_ok", "tool_c", "result_c"));
|
||||
thread.add_message(ThreadMessage::action_result(
|
||||
"call_ok", "tool_c", "result_c",
|
||||
));
|
||||
|
||||
let issues = analyze_trace(&thread);
|
||||
let empty_issues: Vec<_> = issues
|
||||
.iter()
|
||||
.filter(|i| i.category == "empty_call_id")
|
||||
.collect();
|
||||
assert_eq!(empty_issues.len(), 2, "should flag exactly the 2 empty call_ids");
|
||||
assert_eq!(
|
||||
empty_issues.len(),
|
||||
2,
|
||||
"should flag exactly the 2 empty call_ids"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,71 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::capability::LeaseId;
|
||||
|
||||
/// Generate a short human-readable summary of tool parameters for display.
|
||||
///
|
||||
/// For `http`: shows the URL. For `web_search`: shows the query.
|
||||
/// For other tools: shows the first string argument, truncated.
|
||||
/// Returns `None` for empty or unrecognizable params.
|
||||
pub fn summarize_params(action_name: &str, params: &serde_json::Value) -> Option<String> {
|
||||
let summary = match action_name {
|
||||
"http" | "web_fetch" => params.get("url").and_then(|v| v.as_str()).map(|u| {
|
||||
if u.len() > 80 {
|
||||
format!("{}...", &u[..77])
|
||||
} else {
|
||||
u.to_string()
|
||||
}
|
||||
}),
|
||||
"web_search" | "llm_context" => params
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|q| truncate(q, 60)),
|
||||
"memory_search" => params
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|q| truncate(q, 60)),
|
||||
"memory_write" => params
|
||||
.get("target")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|t| t.to_string()),
|
||||
"memory_read" => params
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|p| p.to_string()),
|
||||
"shell" => params
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|c| truncate(c, 60)),
|
||||
"message" => params
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|c| truncate(c, 40)),
|
||||
_ => {
|
||||
// Generic: show first string value
|
||||
if let Some(obj) = params.as_object() {
|
||||
obj.values()
|
||||
.find_map(|v| v.as_str())
|
||||
.map(|s| truncate(s, 50))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
summary.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
// Find a safe UTF-8 boundary
|
||||
let mut end = max.min(s.len());
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}...", &s[..end])
|
||||
}
|
||||
}
|
||||
use crate::types::step::{StepId, TokenUsage};
|
||||
use crate::types::thread::{ThreadId, ThreadState};
|
||||
|
||||
@@ -76,12 +141,18 @@ pub enum EventKind {
|
||||
action_name: String,
|
||||
call_id: String,
|
||||
duration_ms: u64,
|
||||
/// Short human-readable summary of parameters (e.g., URL for http tool).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
params_summary: Option<String>,
|
||||
},
|
||||
ActionFailed {
|
||||
step_id: StepId,
|
||||
action_name: String,
|
||||
call_id: String,
|
||||
error: String,
|
||||
/// Short human-readable summary of parameters.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
params_summary: Option<String>,
|
||||
},
|
||||
|
||||
// ── Capability leases ───────────────────────────────────
|
||||
|
||||
+63
-33
@@ -1174,14 +1174,20 @@ async fn forward_event_to_channel(
|
||||
EventKind::ActionExecuted {
|
||||
action_name,
|
||||
duration_ms,
|
||||
params_summary,
|
||||
..
|
||||
} => {
|
||||
// Emit ToolStarted then ToolCompleted so the frontend shows the card
|
||||
// Format tool name with params summary: "http(https://api.github.com/...)"
|
||||
let display_name = match params_summary {
|
||||
Some(summary) => format!("{}({})", action_name, summary),
|
||||
None => action_name.clone(),
|
||||
};
|
||||
|
||||
let _ = channels
|
||||
.send_status(
|
||||
channel_name,
|
||||
StatusUpdate::ToolStarted {
|
||||
name: action_name.clone(),
|
||||
name: display_name.clone(),
|
||||
},
|
||||
metadata,
|
||||
)
|
||||
@@ -1190,7 +1196,7 @@ async fn forward_event_to_channel(
|
||||
.send_status(
|
||||
channel_name,
|
||||
StatusUpdate::ToolCompleted {
|
||||
name: action_name.clone(),
|
||||
name: display_name,
|
||||
success: true,
|
||||
error: None,
|
||||
parameters: Some(format!("{duration_ms}ms")),
|
||||
@@ -1200,13 +1206,21 @@ async fn forward_event_to_channel(
|
||||
.await;
|
||||
}
|
||||
EventKind::ActionFailed {
|
||||
action_name, error, ..
|
||||
action_name,
|
||||
error,
|
||||
params_summary,
|
||||
..
|
||||
} => {
|
||||
let display_name = match params_summary {
|
||||
Some(summary) => format!("{}({})", action_name, summary),
|
||||
None => action_name.clone(),
|
||||
};
|
||||
|
||||
let _ = channels
|
||||
.send_status(
|
||||
channel_name,
|
||||
StatusUpdate::ToolStarted {
|
||||
name: action_name.clone(),
|
||||
name: display_name.clone(),
|
||||
},
|
||||
metadata,
|
||||
)
|
||||
@@ -1215,7 +1229,7 @@ async fn forward_event_to_channel(
|
||||
.send_status(
|
||||
channel_name,
|
||||
StatusUpdate::ToolCompleted {
|
||||
name: action_name.clone(),
|
||||
name: display_name,
|
||||
success: false,
|
||||
error: Some(error.clone()),
|
||||
parameters: None,
|
||||
@@ -1316,35 +1330,51 @@ fn thread_event_to_app_events(
|
||||
EventKind::ActionExecuted {
|
||||
action_name,
|
||||
duration_ms,
|
||||
params_summary,
|
||||
..
|
||||
} => vec![
|
||||
AppEvent::ToolStarted {
|
||||
name: action_name.clone(),
|
||||
thread_id: Some(thread_id.into()),
|
||||
},
|
||||
AppEvent::ToolCompleted {
|
||||
name: action_name.clone(),
|
||||
success: true,
|
||||
error: None,
|
||||
parameters: Some(format!("{duration_ms}ms")),
|
||||
thread_id: Some(thread_id.into()),
|
||||
},
|
||||
],
|
||||
} => {
|
||||
let display_name = match params_summary {
|
||||
Some(s) => format!("{}({})", action_name, s),
|
||||
None => action_name.clone(),
|
||||
};
|
||||
vec![
|
||||
AppEvent::ToolStarted {
|
||||
name: display_name.clone(),
|
||||
thread_id: Some(thread_id.into()),
|
||||
},
|
||||
AppEvent::ToolCompleted {
|
||||
name: display_name,
|
||||
success: true,
|
||||
error: None,
|
||||
parameters: Some(format!("{duration_ms}ms")),
|
||||
thread_id: Some(thread_id.into()),
|
||||
},
|
||||
]
|
||||
}
|
||||
EventKind::ActionFailed {
|
||||
action_name, error, ..
|
||||
} => vec![
|
||||
AppEvent::ToolStarted {
|
||||
name: action_name.clone(),
|
||||
thread_id: Some(thread_id.into()),
|
||||
},
|
||||
AppEvent::ToolCompleted {
|
||||
name: action_name.clone(),
|
||||
success: false,
|
||||
error: Some(error.clone()),
|
||||
parameters: None,
|
||||
thread_id: Some(thread_id.into()),
|
||||
},
|
||||
],
|
||||
action_name,
|
||||
error,
|
||||
params_summary,
|
||||
..
|
||||
} => {
|
||||
let display_name = match params_summary {
|
||||
Some(s) => format!("{}({})", action_name, s),
|
||||
None => action_name.clone(),
|
||||
};
|
||||
vec![
|
||||
AppEvent::ToolStarted {
|
||||
name: display_name.clone(),
|
||||
thread_id: Some(thread_id.into()),
|
||||
},
|
||||
AppEvent::ToolCompleted {
|
||||
name: display_name,
|
||||
success: false,
|
||||
error: Some(error.clone()),
|
||||
parameters: None,
|
||||
thread_id: Some(thread_id.into()),
|
||||
},
|
||||
]
|
||||
}
|
||||
EventKind::StepCompleted { tokens, .. } => vec![AppEvent::Status {
|
||||
message: format!(
|
||||
"Step complete — {} in / {} out tokens",
|
||||
|
||||
Reference in New Issue
Block a user