Skip to content

Commit 5538997

Browse files
fix(ui): fix rendering of auto-approved tool calls and add dynamic tool icons
- Added mapping for LIST_DIR tool call in local and WASM SDK layers. - Added dynamic icon rendering in Leptos frontend for different tool names (directory, search, commands, file edit/creation). - Fixed agent server SSE emission to emit synthetic tool_start for tool calls that skip the Active state (e.g. auto-approved LIST_DIR). - Suppressed duplicate text token events for tool call steps in agent server.
1 parent 7696d5e commit 5538997

4 files changed

Lines changed: 151 additions & 19 deletions

File tree

examples/agent_server/src/main.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ async fn build_session(
304304
NEVER use file:// or file:/// URI prefixes — they are not valid filesystem paths and \
305305
will cause tool failures. Always write paths as: /absolute/path/to/file, never as \
306306
file:///absolute/path/to/file. \
307-
Use tools proactively: list directory contents, read files, search code, run commands. \
307+
Only use filesystem tools (like listing directory contents, reading/searching files, and running commands) when relevant to the user request. Do not access the workspace filesystem or run directory listings for queries that only need web search or general information. \
308308
Think step-by-step and show your reasoning. \
309309
Use markdown formatting for all responses including code blocks."
310310
);
@@ -533,7 +533,12 @@ async fn chat_stream_handler(
533533
}
534534

535535
// ── text token delta ───────────────────────────────────
536-
if step.source == StepSource::Model && !step.content_delta.is_empty() {
536+
// Skip token emission for ToolCall steps — their content_delta
537+
// is the tool label (e.g. "Workspace directory listing"), not
538+
// assistant prose. The label is passed via tool_start instead.
539+
if step.source == StepSource::Model && !step.content_delta.is_empty()
540+
&& step.r#type != StepType::ToolCall
541+
{
537542
let _ = tx.send(Ok(sse_event("token", serde_json::json!({
538543
"step_index": step_index,
539544
"text": step.content_delta,
@@ -565,6 +570,24 @@ async fn chat_stream_handler(
565570
} else if step.status == StepStatus::Done
566571
|| step.status == StepStatus::Error
567572
{
573+
// If this tool was never seen as Active (e.g. auto-approved
574+
// non-WRITE_TOOLS like LIST_DIR that go straight from
575+
// WaitingForUser → Done), emit a synthetic tool_start first
576+
// so the frontend creates a proper ToolCallView card.
577+
if seen_tool_ids.insert(call.id.clone()) {
578+
tracing::info!(
579+
tool = %call.name, id = %call.id,
580+
"stream-handler: synthetic tool_start (skipped Active)"
581+
);
582+
let _ = tx.send(Ok(sse_event("tool_start", serde_json::json!({
583+
"id": call.id,
584+
"name": call.name,
585+
"args": call.args,
586+
"canonical_path": call.canonical_path,
587+
"label": step.content,
588+
"trajectory_id": &trajectory_id,
589+
})))).await;
590+
}
568591
tracing::info!(
569592
tool = %call.name,
570593
status = ?step.status,

examples/leptos_ssr_axum/src/app.rs

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,20 @@ fn ThinkingView(content: String, is_streaming: bool) -> impl IntoView {
130130
>
131131
<summary class="flex items-center justify-between px-4 py-2.5 cursor-pointer font-medium text-xs text-amber-600 dark:text-amber-400 bg-amber-50/40 dark:bg-amber-950/10 hover:bg-amber-100/50 dark:hover:bg-amber-950/20 transition-colors select-none">
132132
<div class="flex items-center gap-2">
133-
<span class=format!("thinking-icon text-sm {}", if is_streaming { "animate-spin" } else { "" })>
134-
"\u{1F9E0}"
135-
</span>
133+
{if is_streaming {
134+
view! {
135+
<svg class="w-3.5 h-3.5 flex-shrink-0 animate-spin text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
136+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"></circle>
137+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
138+
</svg>
139+
}.into_any()
140+
} else {
141+
view! {
142+
<span class="thinking-icon text-sm select-none">
143+
"\u{1F9E0}"
144+
</span>
145+
}.into_any()
146+
}}
136147
<span class="tracking-wide uppercase font-semibold text-[10px]">
137148
{if is_streaming { "Thinking process..." } else { "Thought process" }}
138149
</span>
@@ -201,6 +212,49 @@ fn ToolCallView(
201212
.unwrap_or(&name)
202213
.to_string();
203214

215+
let tool_icon = match name.as_str() {
216+
"LIST_DIR" => view! {
217+
<svg class="w-3.5 h-3.5 flex-shrink-0 opacity-60" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
218+
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
219+
</svg>
220+
}.into_any(),
221+
"FIND_FILE" | "SEARCH_DIR" => view! {
222+
<svg class="w-3.5 h-3.5 flex-shrink-0 opacity-60" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
223+
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
224+
</svg>
225+
}.into_any(),
226+
"RUN_COMMAND" => view! {
227+
<svg class="w-3.5 h-3.5 flex-shrink-0 opacity-60" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
228+
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
229+
</svg>
230+
}.into_any(),
231+
"VIEW_FILE" => view! {
232+
<svg class="w-3.5 h-3.5 flex-shrink-0 opacity-60" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
233+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
234+
</svg>
235+
}.into_any(),
236+
"EDIT_FILE" | "CREATE_FILE" => view! {
237+
<svg class="w-3.5 h-3.5 flex-shrink-0 opacity-60" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
238+
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />
239+
</svg>
240+
}.into_any(),
241+
"START_SUBAGENT" => view! {
242+
<svg class="w-3.5 h-3.5 flex-shrink-0 opacity-60" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
243+
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M12 3v1.5m3.75-1.5v1.5M19.5 8.25h-1.5M19.5 12h-1.5m1.5 3.75h-1.5m-14.25-3.75h-1.5m1.5-3.75h-1.5m1.5 7.5h-1.5m3 3V21M12 19.5V21m3.75-1.5V21m-9-15h10.5a1.5 1.5 0 011.5 1.5v10.5a1.5 1.5 0 01-1.5 1.5H6.75a1.5 1.5 0 01-1.5-1.5V6.75a1.5 1.5 0 011.5-1.5zm2.25 4.5h4.5v4.5h-4.5v-4.5z" />
244+
</svg>
245+
}.into_any(),
246+
"GENERATE_IMAGE" => view! {
247+
<svg class="w-3.5 h-3.5 flex-shrink-0 opacity-60" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
248+
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
249+
</svg>
250+
}.into_any(),
251+
_ => view! {
252+
<svg class="w-3.5 h-3.5 flex-shrink-0 opacity-60" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
253+
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75a4.5 4.5 0 01-4.884 4.484c-1.076-.091-2.264.071-2.95.904l-7.152 8.684a2.548 2.548 0 11-3.586-3.586l8.684-7.152c.833-.686.995-1.874.904-2.95a4.5 4.5 0 016.336-4.486l-3.276 3.276a3.004 3.004 0 002.25 2.25l3.276-3.276c.256.565.398 1.192.398 1.852z" />
254+
</svg>
255+
}.into_any(),
256+
};
257+
204258
// `<details>` is open while the tool is actively running so the user can
205259
// watch the arguments in real time; it collapses automatically when done.
206260
let is_open = matches!(status, ToolCallStatus::Running); // Theme tokens per status
@@ -286,8 +340,10 @@ fn ToolCallView(
286340
"flex items-center justify-between px-3 py-2 cursor-pointer text-xs select-none transition-colors {}",
287341
summary_cls
288342
)>
289-
// Left: icon + human-readable title + raw tool chip + subtitle
343+
// Left: wrench icon + status icon + human-readable title + raw tool chip + subtitle
290344
<div class="flex items-center gap-2 flex-1 min-w-0">
345+
// Dynamic tool call icon
346+
{tool_icon}
291347
{status_icon}
292348
// Primary: intent label ("Change Directory") not raw name
293349
<span class="font-semibold truncate">{display_title}</span>
@@ -2563,18 +2619,15 @@ fn ChatPage() -> impl IntoView {
25632619
let stream_completed_nat = stream_completed.clone();
25642620
let do_finalize_nat = do_finalize.clone();
25652621
let native_err_closure = Closure::<dyn FnMut(_)>::new(move |_event: web_sys::Event| {
2566-
// Only show an error if the stream was NOT completed intentionally.
2622+
// Finalize if the stream wasn't already completed.
2623+
// We intentionally do NOT push an Error block here — the native
2624+
// onerror fires on transport-level disconnects AND when we call
2625+
// es.close() ourselves after a normal idle/done sequence. Any
2626+
// server-sent errors already arrive via the named "error" SSE
2627+
// event handler above. Pushing "Connection to agent lost" from
2628+
// here causes false positives after every normal completion.
25672629
if !stream_completed_nat.get() {
2568-
// Finalise first: collapses thinking block, saves accumulated
2569-
// blocks to KV so we don't lose partial responses on error.
25702630
do_finalize_nat();
2571-
2572-
let err_id = next_id();
2573-
set_blocks.update(|bs| bs.push(MessageBlock::Error {
2574-
id: err_id,
2575-
message: "Connection to agent lost".to_string(),
2576-
http_code: None,
2577-
}));
25782631
}
25792632
active_es_native_err.update_value(|opt_es| {
25802633
if let Some(es) = opt_es.take() {
@@ -3165,7 +3218,22 @@ fn ChatPage() -> impl IntoView {
31653218
// and as the step.content for the ToolCall — we use it as the
31663219
// card title and suppress the redundant standalone bubble).
31673220
{move || {
3168-
let all_blocks = blocks.get();
3221+
let mut all_blocks = blocks.get();
3222+
// Reorder: ensure Thinking blocks appear before adjacent
3223+
// AssistantMessage blocks. The server may emit text tokens
3224+
// before thinking deltas for the same step, causing
3225+
// AssistantMessage to be pushed before Thinking in the vec.
3226+
{
3227+
let mut i = 1;
3228+
while i < all_blocks.len() {
3229+
if matches!(all_blocks[i], MessageBlock::Thinking { .. })
3230+
&& matches!(all_blocks[i - 1], MessageBlock::AssistantMessage { .. })
3231+
{
3232+
all_blocks.swap(i - 1, i);
3233+
}
3234+
i += 1;
3235+
}
3236+
}
31693237
// Collect all non-empty short labels from ToolCall blocks
31703238
let tool_labels: std::collections::HashSet<String> = all_blocks.iter()
31713239
.filter_map(|b| match b {

src/local.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,6 +1345,16 @@ fn extract_builtin_tool_call(
13451345
canonical_path: search.directory_path.clone(),
13461346
});
13471347
}
1348+
if let Some(ref list) = step_update.list_directory {
1349+
return Some(ToolCall {
1350+
id,
1351+
name: "LIST_DIR".to_string(),
1352+
args: serde_json::json!({
1353+
"directory_path": list.directory_path,
1354+
}),
1355+
canonical_path: list.directory_path.clone(),
1356+
});
1357+
}
13481358
None
13491359
}
13501360

src/wasm.rs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,16 @@ fn extract_builtin_tool_call(step_update: &StepUpdate) -> Option<ToolCall> {
12351235
canonical_path: search.directory_path.clone(),
12361236
});
12371237
}
1238+
if let Some(ref list) = step_update.list_directory {
1239+
return Some(ToolCall {
1240+
id,
1241+
name: "LIST_DIR".to_string(),
1242+
args: serde_json::json!({
1243+
"directory_path": list.directory_path,
1244+
}),
1245+
canonical_path: list.directory_path.clone(),
1246+
});
1247+
}
12381248
None
12391249
}
12401250

@@ -1260,8 +1270,8 @@ fn extract_tool_result(step_update: &StepUpdate) -> Option<ToolResult> {
12601270
mod tests {
12611271
use super::*;
12621272
use crate::proto::localharness::{
1263-
ActionCreateFile, ActionEditFile, ActionFindFile, ActionRunCommand, ActionViewFile,
1264-
StepUpdate,
1273+
ActionCreateFile, ActionEditFile, ActionFindFile, ActionListDirectory, ActionRunCommand,
1274+
ActionViewFile, StepUpdate,
12651275
};
12661276
use crate::types::{CapabilitiesConfig, GeminiConfig};
12671277
use futures_util::{SinkExt, StreamExt};
@@ -1445,6 +1455,27 @@ mod tests {
14451455
})
14461456
);
14471457

1458+
// 5.6 ListDirectory
1459+
let step_update_list = StepUpdate {
1460+
trajectory_id: Some("traj_1".to_string()),
1461+
step_index: Some(8),
1462+
list_directory: Some(ActionListDirectory {
1463+
directory_path: Some("list_path".to_string()),
1464+
results: vec![],
1465+
}),
1466+
..Default::default()
1467+
};
1468+
let tc = extract_builtin_tool_call(&step_update_list).unwrap();
1469+
assert_eq!(tc.id, "traj_1_8");
1470+
assert_eq!(tc.name, "LIST_DIR");
1471+
assert_eq!(tc.canonical_path, Some("list_path".to_string()));
1472+
assert_eq!(
1473+
tc.args,
1474+
serde_json::json!({
1475+
"directory_path": "list_path"
1476+
})
1477+
);
1478+
14481479
// 6. None
14491480
let step_update_none = StepUpdate {
14501481
trajectory_id: Some("traj_1".to_string()),

0 commit comments

Comments
 (0)