diff --git a/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx b/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx
index 145beca..7e15e3d 100644
--- a/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx
+++ b/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx
@@ -2585,7 +2585,7 @@ export function renderTurnActivitySummary(
const latestTerminalLine = terminalTail[terminalTail.length - 1] ?? null;
const runningEdit = editSummaries.find((item) => item.status === 'running' || item.status === 'pending');
const latestEdit = runningEdit ?? (turnIsActive ? editSummaries[editSummaries.length - 1] : undefined);
- const runningCommand = displayToolCalls.find((tool) => isCommandTool(tool) && (tool.status === 'running' || tool.status === 'pending'));
+ const runningTool = displayToolCalls.find((tool) => tool.status === 'running' || tool.status === 'pending');
const hasActivity = Boolean(commandLine || displayToolCalls.length > 0 || combinedTerminal.length > 0);
const runtimePhase = options.runtimePhase;
const phaseStatusText = runtimePhaseStatusText(runtimePhase, turnIsActive);
@@ -2594,31 +2594,38 @@ export function renderTurnActivitySummary(
runtimePhase === 'output_completed' ||
runtimePhase === 'finalizing' ||
runtimePhase === 'failed';
+ // Non-command, non-edit tool calls (read / search / …) — counted separately
+ // from shell commands and file edits so a provider that mostly calls tools
+ // (e.g. kohaku) still shows its activity in the summary.
+ const toolCallCount = displayToolCalls.filter(
+ (tool) => !isCommandTool(tool) && !isEditTool(tool),
+ ).length;
const summaryParts = [
createdCount > 0 ? `已创建 ${createdCount} 个文件` : null,
editedCompletedCount > 0 ? `已编辑 ${editedCompletedCount} 个文件` : null,
editingCount > 0 ? `正在编辑 ${editingCount} 个文件` : null,
failedEditCount > 0 ? `${failedEditCount} 个文件编辑失败` : null,
commandCount > 0 ? `已运行 ${commandCount} 条命令` : null,
+ toolCallCount > 0 ? `已调用 ${toolCallCount} 个工具` : null,
showPhaseStatus ? phaseStatusText : null,
].filter(Boolean);
const phaseIsActive = runtimePhase === 'running' || runtimePhase === 'output_completed' || runtimePhase === 'finalizing';
const showSpinner = phaseIsActive || turnIsActive || hasRunningTool || hasLiveTerminal;
const detailToolCalls = displayToolCalls.filter((tool) => !actionableTools.includes(tool));
- // Prefer a currently-running command; otherwise fall back to the most recent
- // command that already ran. Without this, a provider whose tools have all
- // completed (e.g. kohaku between tool batches, with no launcher commandLine)
- // would fall through to the "暂无工具事件" status even though `commandCount`
- // shows commands ran — a confusing contradiction.
- const latestCommandTool = runningCommand
- ?? [...displayToolCalls].reverse().find((tool) => isCommandTool(tool));
- const commandActivity = latestCommandTool
- ? activeToolInputLabel(latestCommandTool)
+ // Headline: show the latest tool activity — a currently-running tool (command
+ // or otherwise), else the most recent tool that ran. The non-command fallback
+ // matters for providers whose tools are mostly non-command and complete fast
+ // (e.g. kohaku running read/write/edit): without it the card would fall
+ // through to the "暂无工具事件" status even though tools clearly ran.
+ const latestActivityTool = runningTool
+ ?? (displayToolCalls.length > 0 ? displayToolCalls[displayToolCalls.length - 1] : undefined);
+ const commandActivity = latestActivityTool
+ ? activeToolInputLabel(latestActivityTool)
: commandLine
? truncateActivityText(`${commandLine}${commandArgsSuffix(state.commandArgs)}`, 180)
: null;
- const commandVerb = runningCommand || hasRunningTool || hasLiveTerminal || (!latestCommandTool && runtimePhase === 'running')
+ const commandVerb = runningTool || hasLiveTerminal || (!latestActivityTool && runtimePhase === 'running')
? '正在运行'
: '已运行';
const editActivity = latestEdit
@@ -2763,12 +2770,12 @@ export function renderTurnActivitySummary(
{commandLine}{commandArgsSuffix(state.commandArgs)}
)}
- {detailToolCalls.slice(0, 5).map((tool, idx) => (
-
{combinedTerminal.slice(-80).join('\n')}
diff --git a/crates/switchyard-provider-kohaku/src/turn.rs b/crates/switchyard-provider-kohaku/src/turn.rs
index 717325e..94c2d11 100644
--- a/crates/switchyard-provider-kohaku/src/turn.rs
+++ b/crates/switchyard-provider-kohaku/src/turn.rs
@@ -61,7 +61,7 @@ pub async fn run_kohaku_turn(
return Err(err);
}
- // argv: kt run --headless --json
+ // argv: kt run --headless --json --log-level error
// [--llm ] --sandbox --cwd --no-subagents
// -p
// The creature ref must lead so it binds to the required `agent_path`
@@ -70,6 +70,14 @@ pub async fn run_kohaku_turn(
args.extend_from_slice(configured_args);
args.push("--headless".to_string());
args.push("--json".to_string());
+ // Quiet kt's stderr to errors only. Plugins (e.g. kt-biome's PEV verifier
+ // and OpenTelemetry exporter) log benign WARNING-level "plugin disabled /
+ // no-op" noise on every build; Switchyard captures kt stderr and surfaces it
+ // as a failed turn's reason + a diagnostics artifact, so that noise would
+ // otherwise dominate the view. Real turn outcomes arrive on the stdout JSONL
+ // (`turn_end`), independent of this log level, so nothing actionable is lost.
+ args.push("--log-level".to_string());
+ args.push("ERROR".to_string());
args.extend(kohaku_runtime_args(model, thinking_level));
args.extend(kohaku_policy_args(policy));
// Leaf execution: a routed/peer run must not spawn sub-agents.
@@ -184,11 +192,11 @@ pub async fn run_kohaku_turn(
value,
} => {
// Translate genuine tool/subagent calls into normalized
- // command_execution items so they're counted and surfaced in
- // the live-execution card (matching codex/claude). Pure KT
- // runtime telemetry (processing_*, token_usage, session_info,
- // compact_*, …) is backend noise and is dropped so the chat
- // shows only the model's message.
+ // tool_call items (real name + args + result) so the
+ // live-execution card shows what the model actually did.
+ // Pure KT runtime telemetry (processing_*, token_usage,
+ // session_info, compact_*, …) is backend noise and is
+ // dropped so the chat shows only the model's message.
if let Some(item) = normalize_tool_activity(&activity_type, &value) {
event_tx_clone
.send(ProviderEvent::new(
@@ -324,15 +332,16 @@ fn partial_begin_suffix_len(tail: &str) -> usize {
/// Map a KohakuTerrarium `activity` event onto Switchyard's normalized item
/// vocabulary, or `None` if it is backend runtime *telemetry* that should not
-/// reach the chat. Genuine tool/subagent calls become `command_execution`
-/// items so the live-execution card counts them ("已运行 N 条命令") and shows
-/// the running one in its headline ("正在运行 ") — the same surfaces
-/// codex/claude drive via their command items. A `*_start` becomes a running
-/// item that the frontend merges (by command) with its later `*_done`/`*_error`
-/// into one transitioning card. Pure telemetry (processing_*, token_usage,
-/// *_token_update, session_info, compact_*, tool_promoted, job_cancelled,
-/// interrupt, …) and unknown types are dropped so a vocabulary drift in the KT
-/// fork never reintroduces flooding.
+/// reach the chat. Genuine tool/subagent calls become `tool_call` items so the
+/// live-execution card shows the real tool name (not a generic "Execute
+/// Command"), the call arguments (file path / content, collapsible via the
+/// card's Show-Input toggle), and the result. The KT `job_id`
+/// (`_`, stable across start/done) is used as the item id so a
+/// `*_start` (running, with args) and its later `*_done`/`*_error` (with the
+/// result) merge into ONE transitioning card. Pure telemetry (processing_*,
+/// token_usage, *_token_update, session_info, compact_*, tool_promoted,
+/// job_cancelled, interrupt, …) and unknown types are dropped so a vocabulary
+/// drift in the KT fork never reintroduces flooding.
fn normalize_tool_activity(
activity_type: &str,
value: &serde_json::Value,
@@ -343,23 +352,53 @@ fn normalize_tool_activity(
"tool_error" | "subagent_tool_error" | "subagent_error" => "failed",
_ => return None,
};
- let name = value
- .get("detail")
+ let metadata = value.get("metadata");
+ let job_id = metadata
+ .and_then(|m| m.get("job_id"))
.and_then(|v| v.as_str())
+ .filter(|s| !s.trim().is_empty());
+ // KT job_id is "_"; strip the suffix for a clean title.
+ let name = job_id
+ .map(|id| id.rsplit_once('_').map(|(head, _)| head).unwrap_or(id))
.filter(|s| !s.trim().is_empty())
.or_else(|| {
- value
- .get("metadata")
+ metadata
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
})
.filter(|s| !s.trim().is_empty())
.unwrap_or("tool");
- Some(serde_json::json!({
- "item_type": "command_execution",
- "command": name,
- "status": status,
- }))
+
+ let mut item = serde_json::Map::new();
+ item.insert("item_type".into(), serde_json::json!("tool_call"));
+ item.insert("name".into(), serde_json::json!(name));
+ item.insert("status".into(), serde_json::json!(status));
+ // Stable id so start/done/error for the same call merge into one card.
+ if let Some(id) = job_id {
+ item.insert("id".into(), serde_json::json!(id));
+ }
+ // Call arguments (path / content / …) — shown via the card's Show-Input
+ // toggle, truncated there when long.
+ if let Some(args) = metadata.and_then(|m| m.get("args"))
+ && !args.is_null()
+ {
+ item.insert("arguments".into(), args.clone());
+ }
+ // Tool result (done/error) — shown as the card output (diff-aware,
+ // collapsed when large).
+ if let Some(result) = metadata.and_then(|m| m.get("result"))
+ && !result.is_null()
+ && result.as_str() != Some("")
+ {
+ item.insert("output".into(), result.clone());
+ }
+ if status == "failed"
+ && let Some(error) = metadata.and_then(|m| m.get("error"))
+ && !error.is_null()
+ {
+ item.insert("error".into(), error.clone());
+ }
+ Some(serde_json::Value::Object(item))
}
/// Map Switchyard's sandbox mode + workspace onto `kt` headless flags.
@@ -454,41 +493,69 @@ mod tests {
assert!(normalize_tool_activity("session_info", &serde_json::json!({})).is_none());
assert!(normalize_tool_activity("brand_new_event", &serde_json::json!({})).is_none());
- // A tool start becomes a running command_execution (the frontend merges
- // it with the later done/error by command into one transitioning card),
- // so the live card counts it and shows it in the headline.
- let start =
- normalize_tool_activity("tool_start", &serde_json::json!({"detail": "read"})).unwrap();
+ // A tool start becomes a running tool_call carrying the real name (from
+ // the KT job_id), the call args (shown via Show-Input), and the stable
+ // id (so done/error merge into one card).
+ let start = normalize_tool_activity(
+ "tool_start",
+ &serde_json::json!({
+ "detail": "[read[ab12]] path=x",
+ "metadata": {"job_id": "read_ab12cd", "args": {"path": "x"}}
+ }),
+ )
+ .unwrap();
assert_eq!(
start.get("item_type").and_then(|v| v.as_str()),
- Some("command_execution")
+ Some("tool_call")
+ );
+ assert_eq!(start.get("name").and_then(|v| v.as_str()), Some("read"));
+ assert_eq!(
+ start.get("id").and_then(|v| v.as_str()),
+ Some("read_ab12cd")
);
- assert_eq!(start.get("command").and_then(|v| v.as_str()), Some("read"));
assert_eq!(
start.get("status").and_then(|v| v.as_str()),
Some("running")
);
+ assert_eq!(
+ start
+ .get("arguments")
+ .and_then(|a| a.get("path"))
+ .and_then(|v| v.as_str()),
+ Some("x")
+ );
- let done =
- normalize_tool_activity("tool_done", &serde_json::json!({"detail": "read"})).unwrap();
+ // The matching done carries the same id (→ merge) and the result.
+ let done = normalize_tool_activity(
+ "tool_done",
+ &serde_json::json!({"metadata": {"job_id": "read_ab12cd", "result": "file body"}}),
+ )
+ .unwrap();
+ assert_eq!(done.get("id").and_then(|v| v.as_str()), Some("read_ab12cd"));
assert_eq!(
done.get("status").and_then(|v| v.as_str()),
Some("completed")
);
+ assert_eq!(
+ done.get("output").and_then(|v| v.as_str()),
+ Some("file body")
+ );
+ // tool_error surfaces the error and a failed status.
let errored = normalize_tool_activity(
"tool_error",
- &serde_json::json!({"metadata": {"name": "write"}}),
+ &serde_json::json!({"metadata": {"job_id": "write_99", "error": "denied"}}),
)
.unwrap();
- assert_eq!(
- errored.get("command").and_then(|v| v.as_str()),
- Some("write")
- );
+ assert_eq!(errored.get("name").and_then(|v| v.as_str()), Some("write"));
assert_eq!(
errored.get("status").and_then(|v| v.as_str()),
Some("failed")
);
+ assert_eq!(
+ errored.get("error").and_then(|v| v.as_str()),
+ Some("denied")
+ );
}
#[test]
diff --git a/tests/bin/fake_kt.rs b/tests/bin/fake_kt.rs
index b716ec6..3045e34 100644
--- a/tests/bin/fake_kt.rs
+++ b/tests/bin/fake_kt.rs
@@ -21,11 +21,16 @@ fn main() {
let args: Vec = std::env::args().collect();
eprintln!("[fake_kt] argv = {args:?}");
- // The adapter must always request the headless JSONL surface.
+ // The adapter must always request the headless JSONL surface, and quiet
+ // kt's stderr to errors only so benign plugin warnings don't pollute the UI.
if !args.iter().any(|a| a == "--headless") || !args.iter().any(|a| a == "--json") {
eprintln!("[fake_kt] missing --headless/--json");
std::process::exit(3);
}
+ if !args.iter().any(|a| a == "--log-level") {
+ eprintln!("[fake_kt] missing --log-level (stderr noise must be quieted)");
+ std::process::exit(4);
+ }
let prompt = args
.iter()
@@ -73,13 +78,16 @@ fn main() {
);
}
+ // Real kt tool activity: start carries job_id ("_") + args;
+ // done carries the same job_id + the result. The adapter derives the tool
+ // name from job_id and merges start/done into one card by that id.
emit(
&mut out,
- serde_json::json!({"type":"activity","activity_type":"tool_start","detail":"read","metadata":{"name":"read","args":{"path":"x"}}}),
+ serde_json::json!({"type":"activity","activity_type":"tool_start","detail":"[read[ab12]] path=x","metadata":{"job_id":"read_ab12cd","args":{"path":"x"},"background":false}}),
);
emit(
&mut out,
- serde_json::json!({"type":"activity","activity_type":"tool_done","detail":"read","metadata":{}}),
+ serde_json::json!({"type":"activity","activity_type":"tool_done","detail":"[read[ab12]]","metadata":{"job_id":"read_ab12cd","result":"file body","tools_used":[]}}),
);
emit(
&mut out,
diff --git a/tests/tests/kohaku_integration.rs b/tests/tests/kohaku_integration.rs
index 5d1eb52..1397837 100644
--- a/tests/tests/kohaku_integration.rs
+++ b/tests/tests/kohaku_integration.rs
@@ -125,7 +125,7 @@ async fn kohaku_keeps_chat_clean_of_protocol_and_sentinel() {
let mut display = String::new();
let mut terminal_outputs = 0;
let mut execution_telemetry = 0;
- let mut command_executions: Vec = vec![];
+ let mut tool_calls: Vec<(String, Option)> = vec![];
while let Some(e) = rx.recv().await {
match e.payload.get("item_type").and_then(|v| v.as_str()) {
Some("agent_message") => {
@@ -135,9 +135,14 @@ async fn kohaku_keeps_chat_clean_of_protocol_and_sentinel() {
}
Some("terminal_output") => terminal_outputs += 1,
Some("execution_telemetry") => execution_telemetry += 1,
- Some("command_execution") => {
- if let Some(c) = e.payload.get("command").and_then(|v| v.as_str()) {
- command_executions.push(c.to_string());
+ Some("tool_call") => {
+ if let Some(name) = e.payload.get("name").and_then(|v| v.as_str()) {
+ let id = e
+ .payload
+ .get("id")
+ .and_then(|v| v.as_str())
+ .map(str::to_string);
+ tool_calls.push((name.to_string(), id));
}
}
_ => {}
@@ -153,11 +158,19 @@ async fn kohaku_keeps_chat_clean_of_protocol_and_sentinel() {
"the kt.exe driver must not be surfaced as execution_telemetry (it would \
headline the live card as '正在运行 kt.exe' and inflate the command count)"
);
- // The fake runs a `read` tool — it must reach the live card as a counted
- // command_execution (start->done), not vanish.
+ // The fake runs a `read` tool (start+done) — it must surface as tool_call
+ // items named "read" (not a generic "Execute Command"), carrying the stable
+ // job_id so the two merge into one card.
assert!(
- command_executions.iter().any(|c| c == "read"),
- "kt tool activity should surface as command_execution items, got: {command_executions:?}"
+ tool_calls.iter().any(|(name, _)| name == "read"),
+ "kt tool activity should surface as named tool_call items, got: {tool_calls:?}"
+ );
+ assert!(
+ tool_calls
+ .iter()
+ .filter(|(name, _)| name == "read")
+ .all(|(_, id)| id.as_deref() == Some("read_ab12cd")),
+ "tool_call start/done must share the kt job_id so they merge, got: {tool_calls:?}"
);
assert!(
!display.contains("SWITCHYARD_JSON"),