Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 22 additions & 15 deletions crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -2763,12 +2770,12 @@ export function renderTurnActivitySummary(
{commandLine}{commandArgsSuffix(state.commandArgs)}
</code>
)}
{detailToolCalls.slice(0, 5).map((tool, idx) => (
<ToolCard key={tool.id || idx} tool={tool} />
))}
{detailToolCalls.length > 5 && (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>还有 {detailToolCalls.length - 5} 项执行详情会在回合结束后折叠显示。</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>更早的 {detailToolCalls.length - 5} 项执行详情已折叠,回合结束后可查看完整列表。</div>
)}
{detailToolCalls.slice(-5).map((tool) => (
<ToolCard key={tool.id} tool={tool} />
))}
{combinedTerminal.length > 0 && (
<pre style={{ margin: 0, padding: '8px 10px', background: '#0c0f1d', borderRadius: 6, color: '#38bdf8', fontFamily: 'monospace', fontSize: 11, maxHeight: 140, overflowY: 'auto', border: '1px solid #1e293b', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{combinedTerminal.slice(-80).join('\n')}
Expand Down
143 changes: 105 additions & 38 deletions crates/switchyard-provider-kohaku/src/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ pub async fn run_kohaku_turn(
return Err(err);
}

// argv: kt run <creature [+user extras]> --headless --json
// argv: kt run <creature [+user extras]> --headless --json --log-level error
// [--llm <sel>] --sandbox <preset> --cwd <dir> --no-subagents
// -p <prompt>
// The creature ref must lead so it binds to the required `agent_path`
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 ("正在运行 <tool>") — 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`
/// (`<tool>_<shortid>`, 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,
Expand All @@ -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 "<tool_name>_<shortid>"; 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.
Expand Down Expand Up @@ -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]
Expand Down
14 changes: 11 additions & 3 deletions tests/bin/fake_kt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ fn main() {
let args: Vec<String> = 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()
Expand Down Expand Up @@ -73,13 +78,16 @@ fn main() {
);
}

// Real kt tool activity: start carries job_id ("<tool>_<shortid>") + 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,
Expand Down
29 changes: 21 additions & 8 deletions tests/tests/kohaku_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = vec![];
let mut tool_calls: Vec<(String, Option<String>)> = vec![];
while let Some(e) = rx.recv().await {
match e.payload.get("item_type").and_then(|v| v.as_str()) {
Some("agent_message") => {
Expand All @@ -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));
}
}
_ => {}
Expand All @@ -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"),
Expand Down
Loading