From d939a015f3ace2554722a5cd757db993b59f7fcf Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:11:19 +0800 Subject: [PATCH 01/14] feat(claude): capture in-turn tool/subagent activity for the GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switchyard's claude provider passed Claude Code's stream-json through raw, so tool_use blocks (inside `assistant` messages) and tool_result blocks (inside `user` messages) never became tool cards: the per-turn path emitted them as unrecognized raw items and the persistent path dropped the `assistant` event entirely. Both paths now curate the stream into normalized events the GUI renders + monitors live: - tool_use -> a `tool_call` card (id = Claude tool_use.id, name, arguments) - tool_result -> merged into that card by id (output + completed/failed) - thinking / redacted_thinking -> a `reasoning` item - text still streams token-by-token; the raw stdout protocol is no longer mirrored to the terminal channel (it was flooding the live card) — only genuine non-JSON output is. Applies to both run_claude_turn (turn.rs) and the persistent ClaudeLiveInstance (live.rs). Background Bash (run_in_background) and the Task subagent are just tool_use calls, so they now surface as cards too. Tests: new fake_claude fixture + claude_integration.rs (deterministic, CI) asserting text streaming, tool_use->tool_call, tool_result merge-by-id, no protocol terminal mirror, and failure propagation; unit tests for the content-block helpers. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/switchyard-provider-claude/src/live.rs | 65 +++- crates/switchyard-provider-claude/src/turn.rs | 357 ++++++++++++++---- tests/Cargo.toml | 6 + tests/bin/fake_claude.rs | 84 +++++ tests/tests/claude_integration.rs | 177 +++++++++ 5 files changed, 608 insertions(+), 81 deletions(-) create mode 100644 tests/bin/fake_claude.rs create mode 100644 tests/tests/claude_integration.rs diff --git a/crates/switchyard-provider-claude/src/live.rs b/crates/switchyard-provider-claude/src/live.rs index dd16086..9454257 100644 --- a/crates/switchyard-provider-claude/src/live.rs +++ b/crates/switchyard-provider-claude/src/live.rs @@ -31,6 +31,7 @@ use switchyard_provider_subprocess::{ }; use crate::stream_json::extract_delta_text; +use crate::turn::{claude_thinking_item, claude_tool_result_item, claude_tool_use_item}; /// Long-running Claude Code process driven via newline-delimited stream-json IO. pub struct ClaudeLiveInstance { @@ -216,10 +217,68 @@ impl LiveInstance for ClaudeLiveInstance { return; } - // 2. Drop the consolidated `assistant` event. Its content - // already streamed as deltas; emitting it again would - // double-render in text accumulators downstream. + // 2. Consolidated `assistant` event: its text already streamed + // as deltas (don't re-emit that), but it also carries + // `tool_use` / `thinking` blocks — surface those as + // normalized tool_call / reasoning items so the GUI renders + // live tool cards instead of losing the activity. if msg_type == "assistant" { + if let Some(content) = json + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + { + for block in content { + let item = match block.get("type").and_then(|t| t.as_str()) { + Some("tool_use") => claude_tool_use_item(block), + Some("thinking") | Some("redacted_thinking") => { + claude_thinking_item(block) + } + _ => None, + }; + if let Some(item) = item + && event_tx + .send(ProviderEvent::new( + turn_id, + EventType::ItemUpdated, + "claude", + item, + )) + .await + .is_err() + { + return; + } + } + } + continue; + } + + // 2b. `user` event carries tool_result blocks — merge each into + // its tool_call card (by tool_use_id) with output/outcome. + if msg_type == "user" { + if let Some(content) = json + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + { + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") + && let Some(item) = claude_tool_result_item(block) + && event_tx + .send(ProviderEvent::new( + turn_id, + EventType::ItemUpdated, + "claude", + item, + )) + .await + .is_err() + { + return; + } + } + } continue; } diff --git a/crates/switchyard-provider-claude/src/turn.rs b/crates/switchyard-provider-claude/src/turn.rs index 883c515..5a6725b 100644 --- a/crates/switchyard-provider-claude/src/turn.rs +++ b/crates/switchyard-provider-claude/src/turn.rs @@ -89,97 +89,136 @@ pub async fn run_claude_turn( while let Some(output_line) = line_rx.recv().await { let line = output_line.text; let protocol_line = line.trim_end_matches(['\r', '\n']); - event_tx_clone - .send(ProviderEvent::terminal_output( - turn_id, - "claude", - &line, - Some("merged"), - Some(output_line.transport.as_str()), - )) - .await - .ok(); if protocol_line.is_empty() { continue; } - if let Ok(json) = serde_json::from_str::(protocol_line) { - let msg_type = json.get("type").and_then(|t| t.as_str()).unwrap_or(""); - - // 1. Streaming text deltas — emit a text_message so the chat - // ticker can render token-by-token. Handles both the - // wrapped `stream_event` shape (with --include-partial-messages) - // and the unwrapped form (without). - if let Some(delta_text) = extract_delta_text(&json, msg_type) - && !delta_text.is_empty() - { - assistant_message.push_str(delta_text); - streamed_any = true; - event_tx_clone - .send(ProviderEvent::text_message(turn_id, "claude", delta_text)) - .await - .ok(); - } - - match msg_type { - // 2. Consolidated `assistant` block — Claude emits this - // AFTER the deltas finish (with --include-partial-messages - // on). If we already streamed the body, drop it on - // the floor so we don't double-render. If deltas - // didn't arrive (e.g. user disabled the flag via - // extra_args), fall back to using the consolidated - // text as the response body. - "assistant" if !streamed_any => { - if let Some(content) = json - .get("message") - .and_then(|m| m.get("content")) - .and_then(|c| c.as_array()) - { - for block in content { - if block.get("type").and_then(|t| t.as_str()) == Some("text") - && let Some(text) = block.get("text").and_then(|t| t.as_str()) - && assistant_message.is_empty() - { - assistant_message = text.to_string(); - } - } - } - } - // 3. `result` is the turn-boundary marker carrying usage - // / cost / num_turns. Take its `result` field only - // when nothing else gave us a response body (e.g. - // a tool-only turn with no assistant text). - "result" => { - if let Some(text) = json.get("result").and_then(|r| r.as_str()) - && assistant_message.is_empty() - { - assistant_message = text.to_string(); - } - } - _ => {} - } - // 4. Pass the raw JSON through as ItemUpdated for the - // diagnostics drawer and any downstream observability. + // `claude --output-format stream-json` stdout is pure protocol, one + // JSON object per line. Mirroring all of it to the terminal channel + // would flood the live-execution card, so only genuine non-JSON + // stdout is surfaced there; the protocol is parsed-only. + let Ok(json) = serde_json::from_str::(protocol_line) else { event_tx_clone - .send(ProviderEvent::new( + .send(ProviderEvent::terminal_output( turn_id, - EventType::ItemUpdated, "claude", - json, + &line, + Some("merged"), + Some(output_line.transport.as_str()), )) .await .ok(); - } else if !protocol_line.trim().is_empty() { - // Non-JSON line — surface as plain text so debugging is - // possible when claude prints something unexpected. + continue; + }; + let msg_type = json.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + // 1. Streaming text deltas — render token-by-token. Handles both the + // wrapped `stream_event` shape (--include-partial-messages on) and + // the unwrapped form. A text delta carries nothing else. + if let Some(delta_text) = extract_delta_text(&json, msg_type) + && !delta_text.is_empty() + { + assistant_message.push_str(delta_text); + streamed_any = true; event_tx_clone - .send(ProviderEvent::text_message( - turn_id, - "claude", - protocol_line, - )) + .send(ProviderEvent::text_message(turn_id, "claude", delta_text)) .await .ok(); + continue; + } + + match msg_type { + // 2. Consolidated `assistant` message: the full content block + // list (text / tool_use / thinking). Tool calls and thinking + // are translated into normalized events so the GUI renders + // them as live tool cards / a reasoning item; text is the + // response-body fallback only when no deltas streamed. + "assistant" => { + if let Some(content) = json + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + { + for block in content { + match block.get("type").and_then(|t| t.as_str()) { + Some("tool_use") => { + if let Some(item) = claude_tool_use_item(block) { + event_tx_clone + .send(ProviderEvent::new( + turn_id, + EventType::ItemUpdated, + "claude", + item, + )) + .await + .ok(); + } + } + Some("thinking") | Some("redacted_thinking") => { + if let Some(item) = claude_thinking_item(block) { + event_tx_clone + .send(ProviderEvent::new( + turn_id, + EventType::ItemUpdated, + "claude", + item, + )) + .await + .ok(); + } + } + Some("text") => { + if !streamed_any + && assistant_message.is_empty() + && let Some(text) = + block.get("text").and_then(|t| t.as_str()) + { + assistant_message = text.to_string(); + } + } + _ => {} + } + } + } + } + // 3. `user` message carries tool_result blocks — merge each into + // its tool_call card (by tool_use_id) with the output/outcome. + "user" => { + if let Some(content) = json + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + { + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") + && let Some(item) = claude_tool_result_item(block) + { + event_tx_clone + .send(ProviderEvent::new( + turn_id, + EventType::ItemUpdated, + "claude", + item, + )) + .await + .ok(); + } + } + } + } + // 4. `result` — turn-boundary marker (usage/cost/num_turns). Use + // its text only as the response-body fallback (tool-only turn). + "result" => { + if let Some(text) = json.get("result").and_then(|r| r.as_str()) + && assistant_message.is_empty() + { + assistant_message = text.to_string(); + } + } + // system/init, the per-token stream_event partials, and message_* + // markers carry no chat signal; the raw stream is still archived + // as the stdout artifact for diagnostics/replay. + _ => {} } } assistant_message @@ -205,6 +244,96 @@ pub async fn run_claude_turn( Ok(build_turn_result(response_text, &output, "claude")) } +/// Translate a Claude `tool_use` content block into a normalized `tool_call` +/// item (id / name / arguments) so the GUI renders it as a live tool card. The +/// Claude `tool_use.id` is reused as the item id so the later `tool_result` +/// (keyed by `tool_use_id`) merges into the same card. +pub(crate) fn claude_tool_use_item(block: &serde_json::Value) -> Option { + let id = block + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty())?; + let name = block + .get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or("tool"); + let mut item = serde_json::Map::new(); + item.insert("item_type".into(), serde_json::json!("tool_call")); + item.insert("id".into(), serde_json::json!(id)); + item.insert("name".into(), serde_json::json!(name)); + item.insert("status".into(), serde_json::json!("running")); + if let Some(input) = block.get("input") + && !input.is_null() + { + item.insert("arguments".into(), input.clone()); + } + Some(serde_json::Value::Object(item)) +} + +/// Translate a Claude `tool_result` content block into a `tool_call` update that +/// merges (by `tool_use_id`) into the running card with the output + outcome. +pub(crate) fn claude_tool_result_item(block: &serde_json::Value) -> Option { + let id = block + .get("tool_use_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty())?; + let is_error = block + .get("is_error") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let mut item = serde_json::Map::new(); + item.insert("item_type".into(), serde_json::json!("tool_call")); + item.insert("id".into(), serde_json::json!(id)); + item.insert( + "status".into(), + serde_json::json!(if is_error { "failed" } else { "completed" }), + ); + if let Some(output) = claude_tool_result_output(block.get("content")) { + item.insert("output".into(), output); + } + Some(serde_json::Value::Object(item)) +} + +/// A Claude `tool_result.content` is either a plain string or an array of +/// content blocks (commonly `[{"type":"text","text":"…"}]`). Flatten text +/// blocks to a single string for the card output; preserve other shapes as-is. +fn claude_tool_result_output(content: Option<&serde_json::Value>) -> Option { + match content? { + serde_json::Value::String(s) if !s.is_empty() => Some(serde_json::Value::String(s.clone())), + serde_json::Value::String(_) => None, + value @ serde_json::Value::Array(blocks) => { + let text: String = blocks + .iter() + .filter_map(|b| { + b.get("text") + .and_then(|t| t.as_str()) + .or_else(|| b.as_str()) + }) + .collect(); + if !text.is_empty() { + Some(serde_json::Value::String(text)) + } else if blocks.is_empty() { + None + } else { + Some(value.clone()) + } + } + serde_json::Value::Null => None, + other => Some(other.clone()), + } +} + +/// Translate a Claude `thinking` / `redacted_thinking` block into a `reasoning` +/// item the GUI can render distinctly. +pub(crate) fn claude_thinking_item(block: &serde_json::Value) -> Option { + let text = block + .get("thinking") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty())?; + Some(serde_json::json!({ "item_type": "reasoning", "text": text })) +} + fn claude_policy_args(policy: &ExecutionPolicy) -> Vec { let mut args = Vec::new(); match policy.effective_sandbox_mode() { @@ -316,4 +445,76 @@ mod tests { vec!["--dangerously-skip-permissions"] ); } + + #[test] + fn tool_use_block_becomes_normalized_tool_call() { + let block = serde_json::json!({ + "type": "tool_use", + "id": "toolu_01", + "name": "Bash", + "input": {"command": "echo hi", "run_in_background": true} + }); + let item = claude_tool_use_item(&block).unwrap(); + assert_eq!( + item.get("item_type").and_then(|v| v.as_str()), + Some("tool_call") + ); + assert_eq!(item.get("id").and_then(|v| v.as_str()), Some("toolu_01")); + assert_eq!(item.get("name").and_then(|v| v.as_str()), Some("Bash")); + assert_eq!(item.get("status").and_then(|v| v.as_str()), Some("running")); + assert_eq!( + item.get("arguments") + .and_then(|a| a.get("command")) + .and_then(|v| v.as_str()), + Some("echo hi") + ); + } + + #[test] + fn tool_result_block_merges_by_id_with_flattened_output() { + // Array-of-text content is flattened to a string for the card output. + let ok = serde_json::json!({ + "type": "tool_result", + "tool_use_id": "toolu_01", + "content": [{"type": "text", "text": "hi\n"}], + "is_error": false + }); + let item = claude_tool_result_item(&ok).unwrap(); + assert_eq!(item.get("id").and_then(|v| v.as_str()), Some("toolu_01")); + assert_eq!( + item.get("status").and_then(|v| v.as_str()), + Some("completed") + ); + assert_eq!(item.get("output").and_then(|v| v.as_str()), Some("hi\n")); + + // String content + error flag. + let err = serde_json::json!({ + "type": "tool_result", + "tool_use_id": "toolu_02", + "content": "boom", + "is_error": true + }); + let item = claude_tool_result_item(&err).unwrap(); + assert_eq!(item.get("status").and_then(|v| v.as_str()), Some("failed")); + assert_eq!(item.get("output").and_then(|v| v.as_str()), Some("boom")); + } + + #[test] + fn thinking_block_becomes_reasoning_item() { + let block = serde_json::json!({"type": "thinking", "thinking": "let me think"}); + let item = claude_thinking_item(&block).unwrap(); + assert_eq!( + item.get("item_type").and_then(|v| v.as_str()), + Some("reasoning") + ); + assert_eq!( + item.get("text").and_then(|v| v.as_str()), + Some("let me think") + ); + // No id required to render → blocks without an id are dropped only when + // empty; a tool_use without an id is dropped. + assert!( + claude_tool_use_item(&serde_json::json!({"type": "tool_use", "name": "X"})).is_none() + ); + } } diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 4c29207..a94d21c 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -18,6 +18,12 @@ path = "../crates/switchyard-tui/src/main.rs" name = "fake_kt" path = "bin/fake_kt.rs" +# Fake `claude` for the deterministic Claude Code provider integration test +# (claude_integration.rs locates it via CARGO_BIN_EXE_fake_claude). +[[bin]] +name = "fake_claude" +path = "bin/fake_claude.rs" + [dependencies] switchyard-app-providers = { workspace = true } switchyard-core = { workspace = true } diff --git a/tests/bin/fake_claude.rs b/tests/bin/fake_claude.rs new file mode 100644 index 0000000..a6a102a --- /dev/null +++ b/tests/bin/fake_claude.rs @@ -0,0 +1,84 @@ +//! Fake `claude` for the Claude Code provider integration test. +//! +//! Validates that the adapter requested the stream-json surface, then emits a +//! canned `--output-format stream-json` stream that exercises the in-turn +//! activity the adapter must capture: streamed text deltas, an `assistant` +//! message carrying a `tool_use` block (a background Bash), the matching +//! `user` `tool_result`, a final assistant text, and the `result` marker. +//! Logs go to stderr (mirroring real `claude`) so the adapter keeps stdout +//! pure protocol. +//! +//! Behaviour switches: +//! - `FAKE_CLAUDE_FAIL=1` → emit an error `result` and exit 1. + +use std::io::Write; + +fn emit(out: &mut impl Write, v: serde_json::Value) { + writeln!(out, "{v}").expect("write stream-json line"); +} + +fn main() { + let args: Vec = std::env::args().collect(); + eprintln!("[fake_claude] argv = {args:?}"); + + // The adapter must always request the stream-json output surface. + let has_stream_json = args + .windows(2) + .any(|w| w[0] == "--output-format" && w[1] == "stream-json"); + if !has_stream_json { + eprintln!("[fake_claude] missing --output-format stream-json"); + std::process::exit(3); + } + + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + + emit( + &mut out, + serde_json::json!({"type":"system","subtype":"init","cwd":".","tools":["Bash"]}), + ); + + if std::env::var("FAKE_CLAUDE_FAIL").is_ok() { + emit( + &mut out, + serde_json::json!({"type":"result","subtype":"error","result":"boom","is_error":true}), + ); + out.flush().ok(); + std::process::exit(1); + } + + // Streamed text delta (wrapped stream_event shape). + emit( + &mut out, + serde_json::json!({"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me check. "}}}), + ); + // Consolidated assistant message: the streamed text + a tool_use block. + emit( + &mut out, + serde_json::json!({"type":"assistant","message":{"role":"assistant","content":[ + {"type":"text","text":"Let me check. "}, + {"type":"tool_use","id":"toolu_bg1","name":"Bash","input":{"command":"echo hi","run_in_background":true}} + ]}}), + ); + // tool_result for that call (array-of-text content). + emit( + &mut out, + serde_json::json!({"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_bg1","content":[{"type":"text","text":"hi\n"}],"is_error":false} + ]}}), + ); + // Final assistant text (streamed + consolidated). + emit( + &mut out, + serde_json::json!({"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Done."}}}), + ); + emit( + &mut out, + serde_json::json!({"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done."}]}}), + ); + emit( + &mut out, + serde_json::json!({"type":"result","subtype":"success","result":"Done.","is_error":false,"num_turns":2}), + ); + out.flush().ok(); +} diff --git a/tests/tests/claude_integration.rs b/tests/tests/claude_integration.rs new file mode 100644 index 0000000..973e30c --- /dev/null +++ b/tests/tests/claude_integration.rs @@ -0,0 +1,177 @@ +//! Deterministic integration test for the Claude Code provider. +//! +//! Drives the real `ClaudeProvider` subprocess path against a fake `claude` +//! binary (no network / no auth), asserting that Claude's stream-json in-turn +//! activity is captured the way the GUI needs: streamed assistant text, a +//! `tool_use` surfaced as a normalized `tool_call` card, the matching +//! `tool_result` merged in by id, and a clean response — with the raw +//! per-line protocol NOT mirrored to the terminal channel. Runs in CI. + +use std::collections::HashMap; + +use switchyard_provider_api::{ + CancellationToken, ContextBundle, EventType, ExecutionPolicy, Provider, TurnInput, +}; +use switchyard_provider_claude::ClaudeProvider; + +fn fake_claude() -> String { + env!("CARGO_BIN_EXE_fake_claude").to_string() +} + +fn policy() -> ExecutionPolicy { + ExecutionPolicy { + timeout_secs: 30, + write_access: false, + cwd: std::env::current_dir().unwrap(), + allowed_paths: vec![], + } +} + +fn context() -> ContextBundle { + ContextBundle { + summary: None, + recent_turns: vec![], + peer_state: vec![], + artifacts: vec![], + } +} + +fn input(msg: &str) -> TurnInput { + TurnInput { + user_message: msg.to_string(), + system_prompt: None, + attachments: vec![], + } +} + +/// One observed `tool_call` ItemUpdated payload, flattened for assertions. +#[derive(Default)] +struct SeenTool { + name: Option, + id: Option, + status: Option, + output: Option, +} + +#[tokio::test] +async fn claude_captures_text_and_tool_activity() { + let provider = ClaudeProvider::new(fake_claude(), vec![], HashMap::new(), 30); + let turn_id = uuid::Uuid::now_v7(); + let (tx, mut rx) = tokio::sync::mpsc::channel(256); + + provider + .start_turn( + turn_id, + input("ping"), + policy(), + context(), + tx, + CancellationToken::new(), + ) + .await + .expect("start_turn ok"); + + let mut started = false; + let mut completed = false; + let mut display = String::new(); + let mut terminal_outputs = 0; + let mut tool_calls: Vec = vec![]; + while let Some(e) = rx.recv().await { + match e.event_type { + EventType::TurnStarted => started = true, + EventType::TurnCompleted => completed = true, + _ => {} + } + match e.payload.get("item_type").and_then(|v| v.as_str()) { + Some("agent_message") => { + if let Some(t) = e.payload.get("text").and_then(|v| v.as_str()) { + display.push_str(t); + } + } + Some("terminal_output") => terminal_outputs += 1, + Some("tool_call") => { + let get = |k: &str| { + e.payload + .get(k) + .and_then(|v| v.as_str()) + .map(str::to_string) + }; + tool_calls.push(SeenTool { + name: get("name"), + id: get("id"), + status: get("status"), + output: get("output"), + }); + } + _ => {} + } + } + + assert!(started, "should emit TurnStarted"); + assert!(completed, "should emit TurnCompleted on exit 0"); + assert_eq!( + terminal_outputs, 0, + "stream-json protocol lines must not be mirrored as terminal output" + ); + // Assistant text streamed token-by-token. + assert!(display.contains("Let me check."), "got: {display:?}"); + assert!(display.contains("Done."), "got: {display:?}"); + + // The tool_use surfaced as a named, running tool_call carrying its args. + assert!( + tool_calls.iter().any(|t| t.name.as_deref() == Some("Bash") + && t.id.as_deref() == Some("toolu_bg1") + && t.status.as_deref() == Some("running")), + "tool_use should surface as a running Bash tool_call" + ); + // The tool_result merged into the same card (by id) with output + outcome. + assert!( + tool_calls + .iter() + .any(|t| t.id.as_deref() == Some("toolu_bg1") + && t.status.as_deref() == Some("completed") + && t.output.as_deref().is_some_and(|o| o.contains("hi"))), + "tool_result should merge by id with completed status + output" + ); + + let (result, bundle) = provider.finalize_turn(turn_id).await.expect("finalize"); + assert_eq!(result.exit_code, Some(0)); + assert!( + result.response_text.contains("Done."), + "response_text: {:?}", + result.response_text + ); + assert!(!bundle.artifacts.is_empty(), "should archive raw output"); +} + +#[tokio::test] +async fn claude_turn_failure_propagates() { + let mut env = HashMap::new(); + env.insert("FAKE_CLAUDE_FAIL".to_string(), "1".to_string()); + let provider = ClaudeProvider::new(fake_claude(), vec![], env, 30); + let turn_id = uuid::Uuid::now_v7(); + let (tx, mut rx) = tokio::sync::mpsc::channel(256); + + provider + .start_turn( + turn_id, + input("ping"), + policy(), + context(), + tx, + CancellationToken::new(), + ) + .await + .expect("start_turn returns Ok even when the turn itself fails"); + + let mut failed = false; + while let Some(e) = rx.recv().await { + if e.event_type == EventType::TurnFailed { + failed = true; + } + } + assert!(failed, "should emit TurnFailed on non-zero exit"); + + let (result, _) = provider.finalize_turn(turn_id).await.expect("finalize"); + assert_eq!(result.exit_code, Some(1), "exit code should propagate"); +} From 0331f5f2834e996a81e0bcf4260d48901571835b Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:22:08 +0800 Subject: [PATCH 02/14] feat(gui): badge + count Claude background tasks (run_in_background) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightweight cross-turn awareness for Claude Code background tasks (Step 2). A Bash with `run_in_background` survives across turns on the persistent provider instance (the core is warm-started as a live instance), but its launch tool_result "completes" immediately, so the card read as a finished call. - ToolCard badges such tools "后台运行中" (amber) and shows a running status instead of a green completed check, so a background task reads as long-lived; the badged card persists in history, making it visible across turns. - The live-execution card summarizes "N 个后台任务运行中". Detection is frontend-only off the tool_call arguments (run_in_background:true), which Step 1's Claude tool_use normalization already surfaces. Reliable cross-turn completion tracking (auto-clearing the count when a task is killed) needs Bash<->BashOutput/KillShell shell-id correlation — deferred to the full monitor panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/ui/RenderHelpers.tsx | 14 +++++++++ .../frontend/src/components/ui/ToolCard.tsx | 31 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx b/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx index 7e15e3d..2cd41ed 100644 --- a/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx +++ b/crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx @@ -964,6 +964,16 @@ function normalizedProtocolKind(payload: any): string { return normalizeProviderEventType(getProtocolKind(payload)).replace(/_/g, '.'); } +function isBackgroundTool(tool: ToolDisplay): boolean { + const input = tool.input; + return ( + !!input && + typeof input === 'object' && + !Array.isArray(input) && + (input as { run_in_background?: unknown }).run_in_background === true + ); +} + function isCommandTool(tool: ToolDisplay): boolean { const name = String(tool.name || '').toLowerCase(); return ( @@ -2600,6 +2610,9 @@ export function renderTurnActivitySummary( const toolCallCount = displayToolCalls.filter( (tool) => !isCommandTool(tool) && !isEditTool(tool), ).length; + // Background tasks (e.g. Claude's Bash with run_in_background) keep running + // across turns on the persistent provider instance; surface a running count. + const backgroundCount = displayToolCalls.filter((tool) => isBackgroundTool(tool)).length; const summaryParts = [ createdCount > 0 ? `已创建 ${createdCount} 个文件` : null, editedCompletedCount > 0 ? `已编辑 ${editedCompletedCount} 个文件` : null, @@ -2607,6 +2620,7 @@ export function renderTurnActivitySummary( failedEditCount > 0 ? `${failedEditCount} 个文件编辑失败` : null, commandCount > 0 ? `已运行 ${commandCount} 条命令` : null, toolCallCount > 0 ? `已调用 ${toolCallCount} 个工具` : null, + backgroundCount > 0 ? `${backgroundCount} 个后台任务运行中` : null, showPhaseStatus ? phaseStatusText : null, ].filter(Boolean); diff --git a/crates/switchyard-gui/frontend/src/components/ui/ToolCard.tsx b/crates/switchyard-gui/frontend/src/components/ui/ToolCard.tsx index 96c2eab..1579a40 100644 --- a/crates/switchyard-gui/frontend/src/components/ui/ToolCard.tsx +++ b/crates/switchyard-gui/frontend/src/components/ui/ToolCard.tsx @@ -381,6 +381,14 @@ export const ToolCard: React.FC = ({ tool }) => { const inputText = useMemo(() => (showInput ? formatData(tool.input) : ''), [showInput, tool.input]); const hasInput = hasData(tool.input); + // A background task (Claude's Bash with `run_in_background`): the launch + // "completes" immediately but the process keeps running across turns, so the + // card is badged "后台运行中" rather than reading as a finished call. + const isBackground = + tool.input != null && + typeof tool.input === 'object' && + !Array.isArray(tool.input) && + (tool.input as { run_in_background?: unknown }).run_in_background === true; const lowerName = tool.name.toLowerCase(); const commandLike = lowerName.includes('command') || lowerName.includes('execute') || lowerName.includes('run') || lowerName.includes('shell'); const outputMeta = useMemo(() => summarizeOutputData(tool.output, commandLike), [commandLike, tool.output]); @@ -466,15 +474,32 @@ export const ToolCard: React.FC = ({ tool }) => {
{getIcon()} {tool.name} + {isBackground && ( + + 后台运行中 + + )}
- {tool.status === 'completed' ? ( + {isBackground ? null : tool.status === 'completed' ? ( ) : tool.status === 'failed' ? ( ) : null} - - {tool.status} + + {isBackground ? '后台' : tool.status}
From 6afc8b46764389ed7aa11d1a531c9f21bb92d515 Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Tue, 30 Jun 2026 04:14:15 +0800 Subject: [PATCH 03/14] feat(kohaku): map thinking level to --reasoning + forward direct-tool output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two kohaku-adapter fixes, both entangled in turn.rs: - Thinking level was dropped: kohaku_runtime_args ignored thinking_level (`_thinking_level`). It now emits `kt run --reasoning `, normalized to KT's vocabulary (max→xhigh; auto/unknown→omit) and independent of --llm. Doc comments + the GUI capability hint updated. - Direct-tool output was dropped: KT keys a DIRECT (foreground) tool's body under metadata.output (only sub-agents/background use `result`), but the adapter read only `result` — so every kt tool (read, edit, write, multi_edit, …) reached the GUI with empty output, hiding tool bodies and file diffs. Now reads `output` first, then `result`. fake_kt uses the real `output` key + an edit tool whose output carries a unified diff; kohaku_integration asserts the body (and the diff) survive. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../frontend/src/providerCliCapabilities.ts | 12 +-- crates/switchyard-provider-kohaku/src/lib.rs | 6 +- crates/switchyard-provider-kohaku/src/turn.rs | 100 +++++++++++++++--- tests/bin/fake_kt.rs | 23 +++- tests/tests/kohaku_integration.rs | 31 +++++- 5 files changed, 142 insertions(+), 30 deletions(-) diff --git a/crates/switchyard-gui/frontend/src/providerCliCapabilities.ts b/crates/switchyard-gui/frontend/src/providerCliCapabilities.ts index 22ca822..3b10821 100644 --- a/crates/switchyard-gui/frontend/src/providerCliCapabilities.ts +++ b/crates/switchyard-gui/frontend/src/providerCliCapabilities.ts @@ -148,17 +148,17 @@ export function providerCliMapping( return { backend: inferred, modelMapped: true, - thinkingMapped: false, + thinkingMapped: true, summary: 'KohakuTerrarium (kt): Switchyard runs `kt run --headless --json -p `. ' + 'Set "Subprocess CLI Command" to `kt` if it is on PATH, otherwise the full path to kt.exe ' + '(e.g. \\Scripts\\kt.exe). The FIRST CLI Execution Argument must be the creature ref ' + - '(a config-folder path or @pkg/creatures/). Default Model maps to `--llm `; ' + - 'the sandbox mode maps to `--sandbox READ_ONLY|WORKSPACE|off`. Requires a kt with headless ' + - 'support (the switchyard-headless fork).', + '(a config-folder path or @pkg/creatures/). Default Model maps to `--llm ` and ' + + 'thinking level to `--reasoning ` (a controller override applied to whatever profile ' + + 'resolves); the sandbox mode maps to `--sandbox READ_ONLY|WORKSPACE|off`. Requires a kt with ' + + 'headless support (the switchyard-headless fork).', modelHint: '--llm (e.g. enzi/gpt-5.5-custom)', - thinkingHint: - 'Not a separate flag; encode reasoning in the --llm selector, e.g. @reasoning=low.', + thinkingHint: '--reasoning (max → xhigh)', }; default: return { diff --git a/crates/switchyard-provider-kohaku/src/lib.rs b/crates/switchyard-provider-kohaku/src/lib.rs index 38c1725..98385bd 100644 --- a/crates/switchyard-provider-kohaku/src/lib.rs +++ b/crates/switchyard-provider-kohaku/src/lib.rs @@ -33,8 +33,10 @@ pub struct KohakuProvider { pub env: HashMap, /// Maps onto the `kt --llm ` profile (e.g. `enzi/gpt-5.5-custom`). pub model: Option, - /// Reserved: KT has no standalone effort flag; reasoning is encoded in the - /// `--llm` selector's `@reasoning=` variation, so this is not mapped in v1. + /// Maps onto `kt run --reasoning ` (switchyard-headless fork): a + /// controller-level `reasoning_effort` override that applies to whatever + /// profile resolves, independent of the `--llm` selector. Normalized to + /// KT's vocabulary in [`turn::kohaku_runtime_args`]. pub thinking_level: Option, pub timeout_secs: u64, results: Arc>>, diff --git a/crates/switchyard-provider-kohaku/src/turn.rs b/crates/switchyard-provider-kohaku/src/turn.rs index 94c2d11..3ec898f 100644 --- a/crates/switchyard-provider-kohaku/src/turn.rs +++ b/crates/switchyard-provider-kohaku/src/turn.rs @@ -384,13 +384,18 @@ fn normalize_tool_activity( { 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("") + // Tool result (done/error) — shown as the card output (diff-aware, so an + // edit/write/multi_edit diff renders an expandable file-change card). KT + // keys this differently by path: DIRECT (foreground) tools — edit, write, + // multi_edit, read, … — put the text under `output`; sub-agents and + // backgrounded jobs use `result`. Read `output` first, then fall back to + // `result`, so every tool's body actually reaches the card (reading only + // `result` silently dropped all direct-tool output, diffs included). + if let Some(output) = metadata + .and_then(|m| m.get("output").or_else(|| m.get("result"))) + .filter(|v| !v.is_null() && v.as_str() != Some("")) { - item.insert("output".into(), result.clone()); + item.insert("output".into(), output.clone()); } if status == "failed" && let Some(error) = metadata.and_then(|m| m.get("error")) @@ -420,37 +425,102 @@ fn kohaku_policy_args(policy: &ExecutionPolicy) -> Vec { args } -/// Map the configured model onto the `kt --llm ` profile. +/// Map the configured model + thinking level onto `kt run` runtime flags. /// -/// KT has no standalone effort flag — reasoning is encoded in the selector's -/// `@reasoning=` variation — so `thinking_level` is not appended in v1 (a -/// user wanting it can include it directly in `model`, e.g. -/// `enzi/gpt-5.5-custom@reasoning=low`). +/// `model` becomes `--llm ` (KT's profile selector). The thinking +/// level becomes `kt run --reasoning `, which the switchyard-headless +/// fork injects as a controller-level `reasoning_effort` override — it wins +/// over the resolved profile's own setting for **any** backend and, unlike a +/// `@reasoning=` variation, does not require the profile to declare +/// a `reasoning` variation group. The two are independent: `--reasoning` +/// applies even when no `--llm` is set (it overrides the creature's default +/// profile). Levels are normalized to KT's vocabulary +/// (`none/minimal/low/medium/high/xhigh`); the Claude-only `max` maps to +/// `xhigh` (the highest KT/OpenAI honor) and unknown labels are dropped so a +/// typo can't make every turn fail. pub(crate) fn kohaku_runtime_args( model: Option<&str>, - _thinking_level: Option<&str>, + thinking_level: Option<&str>, ) -> Vec { let mut args = Vec::new(); if let Some(model) = model.map(str::trim).filter(|m| !m.is_empty()) { args.push("--llm".to_string()); args.push(model.to_string()); } + if let Some(level) = normalize_reasoning_effort(thinking_level) { + args.push("--reasoning".to_string()); + args.push(level.to_string()); + } args } +/// Normalize a Switchyard thinking level onto a value `kt --reasoning` accepts. +/// +/// Returns `None` (don't pass the flag — let the profile / CLI default stand) +/// for empty / auto / default and for any unrecognized label. +fn normalize_reasoning_effort(value: Option<&str>) -> Option<&'static str> { + match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() { + Some("minimal" | "min") => Some("minimal"), + Some("low" | "light") => Some("low"), + Some("medium" | "normal" | "standard") => Some("medium"), + Some("high" | "deep") => Some("high"), + // KT/OpenAI top out at `xhigh`; treat the Claude-only `max` as "as + // high as this backend goes" rather than forwarding a value the + // endpoint would reject. + Some("xhigh" | "max") => Some("xhigh"), + // Explicit opt-out is a valid KT value (disables reasoning). + Some("none" | "off") => Some("none"), + Some("auto" | "default" | "") | None => None, + Some(_) => None, + } +} + #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; #[test] - fn runtime_args_map_model_only() { + fn runtime_args_map_model_and_reasoning() { + // Model + level → both flags. assert_eq!( kohaku_runtime_args(Some("enzi/gpt-5.5-custom"), Some("high")), - vec!["--llm", "enzi/gpt-5.5-custom"] + vec!["--llm", "enzi/gpt-5.5-custom", "--reasoning", "high"] + ); + // Blank model is dropped; the level still maps. + assert_eq!( + kohaku_runtime_args(Some(" "), Some("medium")), + vec!["--reasoning", "medium"] ); + // Reasoning is independent of model — it overrides the creature's + // default profile even with no `--llm`. + assert_eq!( + kohaku_runtime_args(None, Some("low")), + vec!["--reasoning", "low"] + ); + // Nothing configured → no flags. assert_eq!(kohaku_runtime_args(Some(" "), None), Vec::::new()); - assert_eq!(kohaku_runtime_args(None, Some("low")), Vec::::new()); + // Empty / auto levels don't emit the flag. + assert_eq!(kohaku_runtime_args(Some("m"), Some("")), vec!["--llm", "m"]); + assert_eq!( + kohaku_runtime_args(Some("m"), Some("auto")), + vec!["--llm", "m"] + ); + } + + #[test] + fn reasoning_effort_normalizes_vocab() { + assert_eq!(normalize_reasoning_effort(Some("High")), Some("high")); + assert_eq!(normalize_reasoning_effort(Some(" deep ")), Some("high")); + assert_eq!(normalize_reasoning_effort(Some("min")), Some("minimal")); + // Claude-only `max` clamps to the highest KT/OpenAI value. + assert_eq!(normalize_reasoning_effort(Some("max")), Some("xhigh")); + assert_eq!(normalize_reasoning_effort(Some("xhigh")), Some("xhigh")); + assert_eq!(normalize_reasoning_effort(Some("none")), Some("none")); + // Unknown / auto / empty → don't forward. + assert_eq!(normalize_reasoning_effort(Some("bogus")), None); + assert_eq!(normalize_reasoning_effort(Some("default")), None); + assert_eq!(normalize_reasoning_effort(None), None); } #[test] diff --git a/tests/bin/fake_kt.rs b/tests/bin/fake_kt.rs index 3045e34..5822b19 100644 --- a/tests/bin/fake_kt.rs +++ b/tests/bin/fake_kt.rs @@ -79,15 +79,34 @@ fn main() { } // Real kt tool activity: start carries job_id ("_") + args; - // done carries the same job_id + the result. The adapter derives the tool + // done carries the same job_id + the body. The adapter derives the tool // name from job_id and merges start/done into one card by that id. + // + // IMPORTANT: real KT DIRECT (foreground) tools — read, edit, write, + // multi_edit, … — put the result text under the `output` metadata key + // (only sub-agents/backgrounded jobs use `result`). The adapter must read + // `output`; this fixture uses `output` so the test catches a regression + // where it only read `result` (which silently dropped all tool bodies). emit( &mut out, 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[ab12]]","metadata":{"job_id":"read_ab12cd","result":"file body","tools_used":[]}}), + serde_json::json!({"type":"activity","activity_type":"tool_done","detail":"[read[ab12]]","metadata":{"job_id":"read_ab12cd","output":"file body"}}), + ); + + // An `edit` DIRECT tool: its `output` carries a unified diff (with a/b path + // headers) — exactly what KT's edit/write/multi_edit now emit. The adapter + // must forward this so the GUI renders an expandable file-change card. + let edit_diff = "Edited x.py\n 1 replacement(s) made\n\n--- a/x.py\n+++ b/x.py\n@@ -1,1 +1,1 @@\n-return 1\n+return 42\n"; + emit( + &mut out, + serde_json::json!({"type":"activity","activity_type":"tool_start","detail":"[edit[cd34]] path=x.py","metadata":{"job_id":"edit_cd34ef","args":{"path":"x.py","old":"return 1","new":"return 42"},"background":false}}), + ); + emit( + &mut out, + serde_json::json!({"type":"activity","activity_type":"tool_done","detail":"[edit[cd34]]","metadata":{"job_id":"edit_cd34ef","output":edit_diff}}), ); emit( &mut out, diff --git a/tests/tests/kohaku_integration.rs b/tests/tests/kohaku_integration.rs index 1397837..fd36968 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 tool_calls: Vec<(String, Option)> = vec![]; + let mut tool_calls: Vec<(String, Option, Option)> = vec![]; while let Some(e) = rx.recv().await { match e.payload.get("item_type").and_then(|v| v.as_str()) { Some("agent_message") => { @@ -142,7 +142,12 @@ async fn kohaku_keeps_chat_clean_of_protocol_and_sentinel() { .get("id") .and_then(|v| v.as_str()) .map(str::to_string); - tool_calls.push((name.to_string(), id)); + let output = e + .payload + .get("output") + .and_then(|v| v.as_str()) + .map(str::to_string); + tool_calls.push((name.to_string(), id, output)); } } _ => {} @@ -162,16 +167,32 @@ async fn kohaku_keeps_chat_clean_of_protocol_and_sentinel() { // items named "read" (not a generic "Execute Command"), carrying the stable // job_id so the two merge into one card. assert!( - tool_calls.iter().any(|(name, _)| name == "read"), + 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")), + .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:?}" ); + // DIRECT tools key their body under `output` (not `result`); the adapter + // must forward it. The `read` done carries "file body". + assert!( + tool_calls.iter().any(|(name, _, output)| name == "read" + && output.as_deref().is_some_and(|o| o.contains("file body"))), + "direct tool output (KT `output` key) must reach the card, got: {tool_calls:?}" + ); + // The `edit` tool's output must carry the unified diff so the GUI can render + // an expandable file-change card (this is the whole fix for "no kt diffs"). + assert!( + tool_calls.iter().any(|(name, _, output)| name == "edit" + && output.as_deref().is_some_and(|o| { + o.contains("--- a/x.py") && o.contains("+++ b/x.py") && o.contains("+return 42") + })), + "edit tool output must carry the unified diff, got: {tool_calls:?}" + ); assert!( !display.contains("SWITCHYARD_JSON"), "sentinel must never leak into the chat display, got: {display:?}" From 54c2ad6a2503ce414cc75ffcc911bb05563b937c Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Tue, 30 Jun 2026 04:14:30 +0800 Subject: [PATCH 04/14] feat(gui): lazily backfill a historical turn's events on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session loader caps the initial event pull (full load = oldest 1000 events). In a long session most turns' tool/diff events fall outside that window, so renderTurnEvents finds no activity and their cards vanish — even though the events are persisted. New `get_turn_events(turn_id)` Tauri command (backed by the existing EventLog::list_events + events(turn_id) index). When a historical turn row scrolls into view (the list is virtualized) with activity but no loaded events, the frontend fetches just that turn's events and merges them in — deduped by event_id, at most once per turn, reset on session switch. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/switchyard-gui/frontend/src/App.tsx | 43 +++++++++++++++++++ .../frontend/src/components/ChatArea.tsx | 18 ++++++++ crates/switchyard-gui/src/main.rs | 36 +++++++++++++++- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/crates/switchyard-gui/frontend/src/App.tsx b/crates/switchyard-gui/frontend/src/App.tsx index 7b9250a..360de90 100644 --- a/crates/switchyard-gui/frontend/src/App.tsx +++ b/crates/switchyard-gui/frontend/src/App.tsx @@ -1872,6 +1872,9 @@ function App() { const [telemetryLogs, setTelemetryLogs] = useState([]); const [sessionEvents, setSessionEvents] = useState([]); const sessionEventsCursorRef = useRef>({}); + // Turns whose events we've already lazily backfilled (keyed `sessionId:turnId`), + // so a historical row that scrolls into view fetches its events at most once. + const ensuredTurnEventsRef = useRef>(new Set()); const [realtimeTerminalLines, setRealtimeTerminalLines] = useState>({}); // Keep the unbounded-ish text accumulator out of React state. Terminal // chunks can arrive dozens of times per second; storing the buffer in state @@ -2932,6 +2935,45 @@ function App() { return eventList; }; + // Lazily backfill a single historical turn's events on demand. The session + // loader caps how many events it pulls up front, so in a long session most + // turns' tool/diff events fall outside that window and their cards can't + // render. When such a row scrolls into view, fetch just that turn's events + // (cheap point lookup on the events(turn_id) index) and merge them in. + const ensureTurnEventsLoaded = useCallback((turnId: string) => { + if (!turnId) return; + const sessionId = selectedSessionRef.current?.session_id; + if (!sessionId) return; + const key = `${sessionId}:${turnId}`; + if (ensuredTurnEventsRef.current.has(key)) return; + ensuredTurnEventsRef.current.add(key); + void (async () => { + try { + const events = await invoke('get_turn_events', { turnId }); + if (!events?.length) return; + // Session may have changed while the fetch was in flight. + if (selectedSessionRef.current?.session_id !== sessionId) return; + setSessionEvents((prev) => { + const seen = new Set(prev.map((e) => e?.event_id).filter(Boolean)); + const fresh = events.filter((e) => e?.event_id && !seen.has(e.event_id)); + if (fresh.length === 0) return prev; + return [...prev, ...fresh]; + }); + } catch (err) { + // Allow a retry on a transient failure. + ensuredTurnEventsRef.current.delete(key); + console.warn('failed to lazy-load turn events', turnId, err); + } + })(); + }, []); + + // Reset the lazy-backfill dedup set when the active session changes, so + // re-opening a session re-backfills any turn whose events aren't in the + // restored snapshot (a redundant fetch is deduped server-side cheaply). + useEffect(() => { + ensuredTurnEventsRef.current.clear(); + }, [selectedSession?.session_id]); + const fetchRuntimeSnapshot = async (sessionId: string, mode: 'full' | 'incremental' = 'incremental') => { const afterEventId = mode === 'incremental' ? (runtimeSnapshotCursorRef.current[sessionId] ?? 0) : 0; const snapshot = await invoke('get_runtime_snapshot', { @@ -5867,6 +5909,7 @@ function App() { renderMessageBody={renderMessageBody} renderTurnEvents={renderTurnEventsWithActions} renderTurnActivitySummary={renderTurnActivitySummaryWithActions} + onEnsureTurnEvents={ensureTurnEventsLoaded} queuedMessages={messageQueue} onClearQueue={handleClearQueue} sandboxMode={config?.sandbox?.mode ?? DEFAULT_SANDBOX_MODE} diff --git a/crates/switchyard-gui/frontend/src/components/ChatArea.tsx b/crates/switchyard-gui/frontend/src/components/ChatArea.tsx index 61ad71f..12306d3 100644 --- a/crates/switchyard-gui/frontend/src/components/ChatArea.tsx +++ b/crates/switchyard-gui/frontend/src/components/ChatArea.tsx @@ -56,6 +56,9 @@ interface ChatAreaProps { hyardJobs?: Record, options?: RenderTurnEventsOptions, ) => React.ReactNode; + /// Lazily backfill a historical turn's events when its row scrolls into view + /// (its events may be outside the session loader's initial window). + onEnsureTurnEvents?: (turnId: string) => void; queuedMessages: SendPayload[]; onClearQueue: () => void; sandboxMode: SandboxMode; @@ -1114,6 +1117,7 @@ interface HistoricalTurnContentProps { storedAttachments?: InputAttachment[]; hasAssistantActivity: boolean; turnEvents: any[]; + onEnsureTurnEvents?: (turnId: string) => void; relatedTurns: Turn[]; realtimeLines?: string[]; hyardJob?: any; @@ -1139,6 +1143,7 @@ const HistoricalTurnContentBase: React.FC = ({ storedAttachments, hasAssistantActivity, turnEvents, + onEnsureTurnEvents, relatedTurns, realtimeLines, hyardJob, @@ -1157,6 +1162,17 @@ const HistoricalTurnContentBase: React.FC = ({ const assistantContent = t.provider_response || t.error_message; const scopedHyardJobs = t.turn_id && hyardJob ? { [t.turn_id]: hyardJob } : undefined; + // A completed assistant turn that produced activity but has no loaded events + // is almost certainly one whose events fell outside the session loader's + // initial window — lazily backfill them so its tool/diff cards render. The + // parent dedups per turn, so this fires at most once per row. + useEffect(() => { + if (!t.turn_id || !onEnsureTurnEvents) return; + if (turnEvents.length > 0) return; + if (!hasAssistantActivity && !t.provider_response) return; + onEnsureTurnEvents(t.turn_id); + }, [t.turn_id, turnEvents.length, hasAssistantActivity, t.provider_response, onEnsureTurnEvents]); + if (t.origin === 'user') { if (isSystemFeedback) { const parsedResults = cachedParseSystemFeedbackResults(t.user_message); @@ -1588,6 +1604,7 @@ export const ChatArea: React.FC = ({ renderMessageBody, renderTurnEvents, renderTurnActivitySummary, + onEnsureTurnEvents, queuedMessages, onClearQueue, sandboxMode, @@ -2577,6 +2594,7 @@ export const ChatArea: React.FC = ({ storedAttachments={turnAttachments[t.turn_id]} hasAssistantActivity={hasAssistantActivity} turnEvents={turnEvents} + onEnsureTurnEvents={onEnsureTurnEvents} relatedTurns={relatedTurns} realtimeLines={realtimeTerminalLines[t.turn_id]} hyardJob={t.turn_id ? hyardJobsByTurnId.get(t.turn_id) : undefined} diff --git a/crates/switchyard-gui/src/main.rs b/crates/switchyard-gui/src/main.rs index 936eaf6..bb5a2ea 100644 --- a/crates/switchyard-gui/src/main.rs +++ b/crates/switchyard-gui/src/main.rs @@ -32,8 +32,9 @@ use switchyard_provider_api::{ use switchyard_runtime::{RuntimeDb, RuntimeIpcMessage, RuntimeIpcRequest, RuntimeSnapshot}; use switchyard_session::{Artifact, Event, Session, Turn, Workspace}; use switchyard_store::{ - ArtifactStore, SessionCatalog, SessionEventRepository, SessionRepository, StoreBackend, - StoreHandle, TurnPage, TurnRepository, WorkspaceIndex, default_index_path, workspace_data_dir, + ArtifactStore, EventLog, SessionCatalog, SessionEventRepository, SessionRepository, + StoreBackend, StoreHandle, TurnPage, TurnRepository, WorkspaceIndex, default_index_path, + workspace_data_dir, }; /// Tauri-managed state holding the in-memory copy of `workspaces.json`. @@ -3504,6 +3505,36 @@ async fn get_session_events( .map_err(|e| format!("failed to join session events loader: {e}"))? } +/// Load every stored event for a SINGLE turn, by `turn_id`. +/// +/// The session-wide loader ([`get_session_events`]) caps how many events the +/// GUI pulls up front, so in a long session most turns' tool/diff events fall +/// outside that window and their cards can't render. This lets the GUI +/// lazily backfill a specific historical turn's events on demand — the +/// `events(turn_id, sequence_id)` index makes it a cheap point lookup. +#[tauri::command] +async fn get_turn_events( + workspace_state: tauri::State<'_, WorkspaceState>, + turn_id: String, +) -> Result, String> { + let turn_uuid = + uuid::Uuid::parse_str(&turn_id).map_err(|e| format!("invalid turn ID: {}", e))?; + let ws = workspace_state.current()?; + let config = SwitchyardConfig::resolve(&ws.primary_root).unwrap_or_default(); + tauri::async_runtime::spawn_blocking(move || { + let (store, _data_dir) = open_workspace_store(&ws, &config)?; + let events = store + .list_events(turn_uuid) + .map_err(|e| e.to_string())? + .into_iter() + .filter(|event| !switchyard_provider_api::is_empty_reasoning_payload(&event.payload)) + .collect(); + Ok(events) + }) + .await + .map_err(|e| format!("failed to join turn events loader: {e}"))? +} + #[tauri::command] async fn get_runtime_snapshot( workspace_state: tauri::State<'_, WorkspaceState>, @@ -5172,6 +5203,7 @@ fn main() { get_session_turns, get_session_turns_page, get_session_events, + get_turn_events, get_runtime_snapshot, save_clipboard_attachment, persist_attachment_file, From d242c3b8060d277430e0c468322723f8bbc76fae Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:27:53 +0800 Subject: [PATCH 05/14] feat(session): add goal + goal_plan fields to Session Session-level goal mode state: goal text, goal_status lifecycle, goal_iteration counter, and a persisted goal_plan (Vec) tracked outside the chat. All #[serde(default)] so existing stored sessions deserialize without migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/switchyard-session/src/lib.rs | 2 +- crates/switchyard-session/src/session.rs | 32 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/switchyard-session/src/lib.rs b/crates/switchyard-session/src/lib.rs index 649959d..505fc3c 100644 --- a/crates/switchyard-session/src/lib.rs +++ b/crates/switchyard-session/src/lib.rs @@ -8,6 +8,6 @@ pub mod workspace; pub use artifact::{Artifact, ArtifactType}; pub use event::{Event, EventType, ItemType}; pub use inbox::{InboxDeliveryMode, InboxEntry, InboxItemKind, InboxStatus}; -pub use session::{ACTIVE_TURN_LEASE_SECS, Session, SessionMode}; +pub use session::{ACTIVE_TURN_LEASE_SECS, GoalPlanItem, Session, SessionMode}; pub use turn::{Turn, TurnOrigin, TurnRole, TurnStatus}; pub use workspace::Workspace; diff --git a/crates/switchyard-session/src/session.rs b/crates/switchyard-session/src/session.rs index 8aae0af..c3de5a2 100644 --- a/crates/switchyard-session/src/session.rs +++ b/crates/switchyard-session/src/session.rs @@ -27,6 +27,26 @@ pub struct Session { /// CLIs; unsupported providers keep their CLI default. #[serde(default)] pub thinking_level: Option, + /// Switchyard-native "goal mode" for this session. When set, a goal loop + /// drives the active Core provider turn-after-turn toward this objective + /// until it self-reports done or a cap is hit. `None` = no goal. + #[serde(default)] + pub goal: Option, + /// Lifecycle of the active goal: `running` | `achieved` | `stopped` | + /// `failed`. `None` when no goal has run. + #[serde(default)] + pub goal_status: Option, + /// Number of goal-loop iterations completed so far (for UI progress). + #[serde(default)] + pub goal_iteration: u32, + /// Live plan/todo list the goal maintains, tracked outside the chat and + /// persisted so it survives an app restart. For providers WITHOUT native + /// goal support (the Switchyard self-driven loop: kt/agy/codex-self-driven) + /// the loop asks the agent to emit this each turn and parses it in; + /// native-goal providers (Claude) keep their own todos, so this may stay + /// empty for them. + #[serde(default)] + pub goal_plan: Vec, pub enabled_peers: Vec, pub mode: SessionMode, pub summary: Option, @@ -49,6 +69,14 @@ pub enum SessionMode { Headless, } +/// One item in a goal's plan/todo list (see [`Session::goal_plan`]). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GoalPlanItem { + pub text: String, + #[serde(default)] + pub done: bool, +} + impl Session { pub fn new(active_core: String) -> Self { Self::new_in_workspace(Uuid::nil(), active_core) @@ -67,6 +95,10 @@ impl Session { active_core, model: None, thinking_level: None, + goal: None, + goal_status: None, + goal_iteration: 0, + goal_plan: Vec::new(), enabled_peers: Vec::new(), mode: SessionMode::Interactive, summary: None, From 21abfdcada8266b5f1c68c56434c0cbec37b3c34 Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:28:04 +0800 Subject: [PATCH 06/14] feat(provider-api): goal completion marker + plan-block parsing Add GOAL_DONE_MARKER (self-signal completion) and GOAL_PLAN_BEGIN/END delimiters, plus parse_goal_plan() which reads the last plan block's markdown checkbox lines into (text, done) pairs. Kept decoupled from session types (plain tuples). Unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/switchyard-provider-api/src/lib.rs | 5 +- .../switchyard-provider-api/src/sentinel.rs | 68 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/switchyard-provider-api/src/lib.rs b/crates/switchyard-provider-api/src/lib.rs index 69d6ddc..335b6d4 100644 --- a/crates/switchyard-provider-api/src/lib.rs +++ b/crates/switchyard-provider-api/src/lib.rs @@ -30,7 +30,10 @@ pub use provider::{ PersistentProvider, Provider, }; pub use role::ProviderRole; -pub use sentinel::{extract_sentinel_blocks, parse_sentinel_json, strip_sentinel_blocks}; +pub use sentinel::{ + GOAL_DONE_MARKER, GOAL_PLAN_BEGIN, GOAL_PLAN_END, contains_goal_done, extract_sentinel_blocks, + parse_goal_plan, parse_sentinel_json, strip_sentinel_blocks, +}; pub use tokio_util::sync::CancellationToken; pub use types::{ ARTIFACT_TYPE_RAW_OUTPUT, ArtifactBundle, ArtifactEntry, ContextBundle, EffectiveSandboxMode, diff --git a/crates/switchyard-provider-api/src/sentinel.rs b/crates/switchyard-provider-api/src/sentinel.rs index 531e570..df8c719 100644 --- a/crates/switchyard-provider-api/src/sentinel.rs +++ b/crates/switchyard-provider-api/src/sentinel.rs @@ -6,6 +6,49 @@ pub const SENTINEL_BEGIN: &str = "<<>>"; /// The closing marker of a Switchyard control-channel block. pub const SENTINEL_END: &str = "<<>>"; +/// Completion marker for Switchyard-native goal mode. The goal loop instructs +/// the model to emit this on its own line once the goal is fully achieved; the +/// loop detects it in the turn response and stops. Kept distinct from the +/// JSON control block so goal completion doesn't require a parseable payload. +pub const GOAL_DONE_MARKER: &str = "<<>>"; + +/// True when a turn's text self-reports goal completion via [`GOAL_DONE_MARKER`]. +pub fn contains_goal_done(text: &str) -> bool { + text.contains(GOAL_DONE_MARKER) +} + +/// Delimiters of a goal **plan/todo** block. Providers without native goal +/// support are asked (via prompt injection in the goal loop) to emit their +/// running plan inside this block each turn, as markdown checkbox lines, so +/// Switchyard can track it outside the chat. +pub const GOAL_PLAN_BEGIN: &str = "<<>>"; +pub const GOAL_PLAN_END: &str = "<<>>"; + +/// Parse the last goal-plan block from `text` into `(item_text, done)` pairs, +/// reading markdown checkbox lines (`- [ ] todo` / `- [x] done`). Uses the LAST +/// block so the most recent plan wins. Returns `None` when no block/items are +/// present, letting callers keep the previous plan instead of clearing it. Kept +/// decoupled from session types (returns plain tuples). +pub fn parse_goal_plan(text: &str) -> Option> { + let begin = text.rfind(GOAL_PLAN_BEGIN)? + GOAL_PLAN_BEGIN.len(); + let rest = &text[begin..]; + let end = rest.find(GOAL_PLAN_END).unwrap_or(rest.len()); + let mut items = Vec::new(); + for line in rest[..end].lines() { + let l = line.trim().trim_start_matches(['-', '*', ' ']); + if let Some(after) = l.strip_prefix('[') + && let Some((mark, tail)) = after.split_once(']') + { + let done = mark.trim().eq_ignore_ascii_case("x"); + let t = tail.trim(); + if !t.is_empty() { + items.push((t.to_string(), done)); + } + } + } + if items.is_empty() { None } else { Some(items) } +} + /// Extract all JSON blocks delimited by sentinel markers from provider text output. pub fn extract_sentinel_blocks(text: &str) -> Vec<&str> { let mut blocks = Vec::new(); @@ -169,4 +212,29 @@ Some trailing text."#; let text = "just plain text"; assert_eq!(strip_sentinel_blocks(text), text); } + + #[test] + fn parse_goal_plan_reads_checkbox_items() { + let text = "working on it\n<<>>\n- [x] set up repo\n- [ ] write tests\n\ + * [X] fix build\n- [ ] ship\n<<>>\ntrailing"; + let plan = parse_goal_plan(text).unwrap(); + assert_eq!(plan.len(), 4); + assert_eq!(plan[0], ("set up repo".to_string(), true)); + assert_eq!(plan[1], ("write tests".to_string(), false)); + assert_eq!(plan[2], ("fix build".to_string(), true)); + assert_eq!(plan[3], ("ship".to_string(), false)); + } + + #[test] + fn parse_goal_plan_uses_last_block_and_handles_missing() { + assert!(parse_goal_plan("no plan here").is_none()); + // No end marker: still parses to end of text. + let open = "<<>>\n- [ ] only item"; + assert_eq!(parse_goal_plan(open).unwrap().len(), 1); + // Two blocks: the last one wins. + let two = "<<>>\n- [ ] old\n<<>>\n\ + <<>>\n- [x] new\n<<>>"; + let plan = parse_goal_plan(two).unwrap(); + assert_eq!(plan, vec![("new".to_string(), true)]); + } } From 7f19546dee062f2c690d3bce1895681dc2335060 Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:28:19 +0800 Subject: [PATCH 07/14] feat(core): Switchyard-native goal engine + router display split goal_runner: provider-agnostic goal loop (run_goal_loop) with no hard caps by default (unbounded until achieved/stopped; stuck-guard only), evaluator-gated completion (Claude-Code-style read-only judge turn) + self-signal fast-path + feedback re-injection, and native /goal passthrough (run_native_slash_goal) for Claude. Maintains + persists session.goal_plan each turn. router: run_routed_turn_observable_with_display_and_prompt_injection adds an optional display_message so goal turns store a clean bubble while the verbose autonomous-mode instructions drive the model. runtime_events: GoalIterationStarted / GoalCompleted / GoalPlanUpdated. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/switchyard-core/src/goal_runner.rs | 613 +++++++++++++++++++ crates/switchyard-core/src/lib.rs | 5 + crates/switchyard-core/src/router.rs | 56 +- crates/switchyard-core/src/runtime_events.rs | 24 + 4 files changed, 691 insertions(+), 7 deletions(-) create mode 100644 crates/switchyard-core/src/goal_runner.rs diff --git a/crates/switchyard-core/src/goal_runner.rs b/crates/switchyard-core/src/goal_runner.rs new file mode 100644 index 0000000..06a8338 --- /dev/null +++ b/crates/switchyard-core/src/goal_runner.rs @@ -0,0 +1,613 @@ +//! Switchyard-native **goal mode**. +//! +//! Given a high-level goal, drive the active Core provider turn-after-turn — +//! reusing the full routed-turn machinery (persistent instance via the proxy, +//! delegation, context composition) — until the goal is judged achieved or the +//! user stops. Provider-agnostic, so CLIs whose own `/goal` is only reachable in +//! an interactive TUI (agy) or has no goal at all (kt) still get goal behavior. +//! +//! Design mirrors how the coding CLIs engineer goal mode (all source/binary +//! verified): **no hard iteration/time cap by default** (run until done or the +//! user stops — like Claude Code / Codex / Antigravity), and completion decided +//! two ways, first hit wins: +//! - **self-signal fast-path**: the agent emits [`GOAL_DONE_MARKER`] (cheap; the +//! pattern Codex/Antigravity use — the working agent declares done). +//! - **evaluator** (optional, Claude-Code-style): after each turn a *separate, +//! read-only, tool-less* judge turn decides yes/no + reason; "no" feeds the +//! reason back into the next turn as guidance. +//! +//! The only automatic brake is a stuck-guard (`max_no_progress` consecutive +//! empty/identical turns) so a dumb loop can't spin forever; iteration/time +//! caps are opt-in. + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use switchyard_provider_api::{ + CancellationToken, ExecutionPolicy, GOAL_DONE_MARKER, GOAL_PLAN_BEGIN, GOAL_PLAN_END, + InputAttachment, LiveInstanceRegistry, PeerCatalog, Provider, contains_goal_done, + parse_goal_plan, +}; +use switchyard_session::{GoalPlanItem, Session}; +use switchyard_store::{CanonicalStore, JsonlStore}; + +use crate::error::CoreError; +use crate::router::{ + RouterPromptInjection, run_routed_turn_observable_with_display_and_prompt_injection, + run_routed_turn_observable_with_policy_attachments_and_prompt_injection, +}; +use crate::runtime_events::RuntimeEvent; +use crate::turn_runner::run_turn_with_policy; + +/// Consecutive no-progress iterations (empty or identical response) before the +/// loop gives up. A safety brake against a stuck model, NOT a turn cap — a goal +/// that keeps producing new work runs unbounded. +pub const GOAL_STUCK_GUARD: u32 = 6; + +/// Optional bounds for a goal run. Defaults are **unbounded** (run until the +/// goal is achieved or the user stops), matching Claude Code / Codex / agy. +#[derive(Debug, Clone, Copy)] +pub struct GoalLimits { + /// Hard cap on iterations. `None` = unbounded (default). + pub max_iterations: Option, + /// Hard wall-clock cap in seconds. `None` = unbounded (default). + pub max_wall_secs: Option, + /// Stuck-guard: stop after this many consecutive no-progress turns. `0` + /// disables the guard. + pub max_no_progress: u32, +} + +impl Default for GoalLimits { + fn default() -> Self { + Self { + max_iterations: None, + max_wall_secs: None, + max_no_progress: GOAL_STUCK_GUARD, + } + } +} + +/// Terminal state of a goal run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GoalStatus { + /// Goal judged achieved (self-signal or evaluator). + Achieved, + /// Stopped by user cancel, an opt-in cap, or the stuck-guard. + Stopped, + /// A turn failed. + Failed, +} + +impl GoalStatus { + pub fn as_str(self) -> &'static str { + match self { + GoalStatus::Achieved => "achieved", + GoalStatus::Stopped => "stopped", + GoalStatus::Failed => "failed", + } + } +} + +/// Result of a completed goal run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GoalOutcome { + pub status: GoalStatus, + pub iterations: u32, +} + +/// Build the per-iteration message injected into the routed turn. `feedback` is +/// the evaluator's most-recent "not yet done" reason, threaded in as guidance +/// for the next turn (Claude-Code-style). +pub fn compose_goal_prompt(goal: &str, iteration: u32, feedback: Option<&str>) -> String { + let mut msg = if iteration <= 1 { + format!( + "You are running in autonomous GOAL MODE. Work continuously toward the goal below, \ + taking whatever concrete steps and tool calls are needed each turn.\n\n\ + GOAL: {goal}\n\n\ + When — and ONLY when — the goal is FULLY achieved, output a line containing exactly \ + {GOAL_DONE_MARKER} and nothing else on that line. Never output it prematurely. If the \ + goal is not yet done, make concrete progress this turn." + ) + } else { + format!( + "Continue autonomously toward the GOAL below (iteration {iteration}). Build on the work \ + already done and take the next concrete step. Output the exact line {GOAL_DONE_MARKER} \ + only once the goal is fully achieved.\n\nGOAL: {goal}" + ) + }; + // Ask the agent to maintain a plan/todo list Switchyard can track outside + // the chat (native-goal CLIs keep their own; this is for the self-driven + // loop). Full list re-emitted every turn, most recent block wins. + msg.push_str(&format!( + "\n\nAlso maintain a short PLAN as a markdown checklist inside a block delimited by \ + {GOAL_PLAN_BEGIN} and {GOAL_PLAN_END}. One step per line: \"- [ ] step\" for todo, \ + \"- [x] step\" for done. Re-output the FULL updated plan every turn, marking finished \ + steps done. Keep it to the concrete steps for THIS goal." + )); + if let Some(reason) = feedback.map(str::trim).filter(|r| !r.is_empty()) { + msg.push_str(&format!( + "\n\nThe completion check says the goal is NOT yet met: {reason}\nAddress that now." + )); + } + msg +} + +/// Pure pre-iteration stop check (cancel / opt-in caps). Extracted so the stop +/// policy is unit-testable without running turns. +fn precheck_stop( + iteration: u32, + elapsed_secs: u64, + cancelled: bool, + limits: &GoalLimits, +) -> Option { + if cancelled { + return Some(GoalStatus::Stopped); + } + if limits.max_iterations.is_some_and(|max| iteration >= max) { + return Some(GoalStatus::Stopped); + } + if limits.max_wall_secs.is_some_and(|max| elapsed_secs >= max) { + return Some(GoalStatus::Stopped); + } + None +} + +/// Parse a judge turn's response into (met, reason). `met` is true when the +/// first non-empty line starts with "YES"; the reason is the remaining text. +fn parse_judge_verdict(response: &str) -> (bool, String) { + let mut lines = response.lines().map(str::trim).filter(|l| !l.is_empty()); + let first = lines.next().unwrap_or(""); + let met = first.to_ascii_uppercase().starts_with("YES"); + let reason = lines.collect::>().join(" "); + let reason = if reason.is_empty() { + first.to_string() + } else { + reason + }; + (met, reason) +} + +/// Run a separate, read-only, tool-less evaluator turn against `judge` to decide +/// whether `goal` is achieved given the agent's latest output. Runs on a +/// throwaway store so it never pollutes the real session history (mirrors Claude +/// Code's evaluator, which judges the transcript without adding a turn). Returns +/// `None` if the judge turn errors — caller treats that as "not yet, no reason". +async fn evaluate_goal_met( + judge: &dyn Provider, + judge_store: &mut JsonlStore, + goal: &str, + latest_output: &str, + cwd: &Path, +) -> Option<(bool, String)> { + let prompt = format!( + "You are a strict goal-completion evaluator. Do NOT use any tools or take any action — \ + judge only from the text below.\n\nGOAL:\n{goal}\n\nThe agent's latest output:\n---\n\ + {latest_output}\n---\n\nBased ONLY on the output above, is the GOAL now FULLY achieved? \ + Reply with exactly YES or NO on the first line, then a one-sentence reason on the second." + ); + let mut judge_session = Session::new("goal-judge".to_string()); + let out = run_turn_with_policy( + judge_store, + &mut judge_session, + judge, + prompt, + cwd.to_path_buf(), + ExecutionPolicy::read_only(cwd.to_path_buf()), + ) + .await + .ok()?; + let response = out.response.unwrap_or_default(); + if response.trim().is_empty() { + return None; + } + Some(parse_judge_verdict(&response)) +} + +fn emit(tx: Option<&tokio::sync::mpsc::Sender>, event: RuntimeEvent) { + if let Some(tx) = tx { + let _ = tx.try_send(event); + } +} + +/// Run the goal loop until achieved / stopped / failed. Each iteration is a full +/// routed turn (persisted to the session history), so progress is visible in the +/// chat. Mutates + persists `session.goal_status` / `session.goal_iteration`. +/// +/// `judge`, when `Some`, enables the evaluator: a fresh provider instance used +/// for the read-only completion check (should be distinct from `core_provider` +/// so it doesn't pollute the persistent instance's context). When `None`, only +/// the self-signal marker decides completion. +#[allow(clippy::too_many_arguments)] +pub async fn run_goal_loop( + store: &mut (impl CanonicalStore + ?Sized), + session: &mut Session, + core_provider: &dyn Provider, + peer_catalog: &PeerCatalog, + resolve_peer: &(dyn Fn(&str) -> Option> + Send + Sync), + registry: Option>, + goal: &str, + cwd: PathBuf, + artifact_dir: Option<&Path>, + runtime_tx: Option<&tokio::sync::mpsc::Sender>, + cancel: CancellationToken, + base_policy: ExecutionPolicy, + limits: GoalLimits, + judge: Option<&dyn Provider>, +) -> Result { + let provider_name = session.active_core.clone(); + let started = Instant::now(); + let mut iteration: u32 = 0; + let mut no_progress: u32 = 0; + let mut last_response = String::new(); + let mut feedback: Option = None; + + // Throwaway store for evaluator turns — keeps judge turns out of the real + // session history. Created once and reused (each judge uses a fresh session). + let mut judge_store = judge.map(|_| { + let dir = + std::env::temp_dir().join(format!("switchyard-goal-judge-{}", uuid::Uuid::now_v7())); + let _ = std::fs::create_dir_all(&dir); + (JsonlStore::new(dir.clone()), dir) + }); + + let status = loop { + if let Some(stop) = precheck_stop( + iteration, + started.elapsed().as_secs(), + cancel.is_cancelled(), + &limits, + ) { + break stop; + } + + iteration += 1; + session.goal_iteration = iteration; + session.goal_status = Some("running".to_string()); + let _ = store.save_session(session); + emit( + runtime_tx, + RuntimeEvent::GoalIterationStarted { + session_id: session.session_id, + provider: provider_name.clone(), + iteration, + }, + ); + + let message = compose_goal_prompt(goal, iteration, feedback.as_deref()); + // Show a clean goal line in the chat bubble; the verbose autonomous-mode + // instructions in `message` drive the model but stay out of the UI. No + // iteration number — the user tracks progress via the header timer + plan + // panel, not per-turn counters. + let display = format!("🎯 {goal}"); + let result = run_routed_turn_observable_with_display_and_prompt_injection( + store, + session, + core_provider, + peer_catalog, + resolve_peer, + registry.clone(), + message, + Vec::::new(), + cwd.clone(), + artifact_dir, + runtime_tx, + cancel.clone(), + base_policy.clone(), + Some(display), + RouterPromptInjection::Clean, + ) + .await; + + let response = match result { + Ok(routed) => routed.response.unwrap_or_default(), + Err(_) => break GoalStatus::Failed, + }; + + // Track the agent's plan/todo list outside the chat (persist + emit so + // the UI updates live). Only replaces the plan when a block is present. + if let Some(items) = parse_goal_plan(&response) { + session.goal_plan = items + .into_iter() + .map(|(text, done)| GoalPlanItem { text, done }) + .collect(); + let _ = store.save_session(session); + emit( + runtime_tx, + RuntimeEvent::GoalPlanUpdated { + session_id: session.session_id, + provider: provider_name.clone(), + plan: session.goal_plan.clone(), + }, + ); + } + + // Completion, first hit wins: (1) self-signal marker, (2) evaluator. + if contains_goal_done(&response) { + break GoalStatus::Achieved; + } + if let (Some(judge), Some((judge_store, _))) = (judge, judge_store.as_mut()) + && let Some((met, reason)) = + evaluate_goal_met(judge, judge_store, goal, &response, &cwd).await + { + if met { + break GoalStatus::Achieved; + } + feedback = Some(reason); + } + + // Stuck-guard: give up only after repeated genuinely-empty/identical work. + let progressed = !response.trim().is_empty() && response != last_response; + no_progress = if progressed { 0 } else { no_progress + 1 }; + if limits.max_no_progress > 0 && no_progress >= limits.max_no_progress { + break GoalStatus::Stopped; + } + last_response = response; + }; + + if let Some((_, dir)) = judge_store.take() { + let _ = std::fs::remove_dir_all(&dir); + } + + session.goal_status = Some(status.as_str().to_string()); + let _ = store.save_session(session); + emit( + runtime_tx, + RuntimeEvent::GoalCompleted { + session_id: session.session_id, + provider: provider_name, + status: status.as_str().to_string(), + iterations: iteration, + }, + ); + + Ok(GoalOutcome { + status, + iterations: iteration, + }) +} + +/// Native slash-goal passthrough (Increment 2): send `" "` as a +/// SINGLE turn and let the provider's own goal engine loop inside that turn — +/// used for Claude Code, whose `/goal` runs its evaluator loop to completion +/// within one persistent-instance send. No Switchyard loop, no evaluator: the +/// CLI does everything; we just stream + record the outcome. `base_policy` +/// should be unbounded (timeout 0) since one turn spans the whole native loop. +#[allow(clippy::too_many_arguments)] +pub async fn run_native_slash_goal( + store: &mut (impl CanonicalStore + ?Sized), + session: &mut Session, + core_provider: &dyn Provider, + peer_catalog: &PeerCatalog, + resolve_peer: &(dyn Fn(&str) -> Option> + Send + Sync), + registry: Option>, + slash: &str, + goal: &str, + cwd: PathBuf, + artifact_dir: Option<&Path>, + runtime_tx: Option<&tokio::sync::mpsc::Sender>, + cancel: CancellationToken, + base_policy: ExecutionPolicy, +) -> Result { + let provider_name = session.active_core.clone(); + session.goal_iteration = 1; + session.goal_status = Some("running".to_string()); + let _ = store.save_session(session); + emit( + runtime_tx, + RuntimeEvent::GoalIterationStarted { + session_id: session.session_id, + provider: provider_name.clone(), + iteration: 1, + }, + ); + + let message = format!("{slash} {goal}"); + let result = run_routed_turn_observable_with_policy_attachments_and_prompt_injection( + store, + session, + core_provider, + peer_catalog, + resolve_peer, + registry, + message, + Vec::::new(), + cwd, + artifact_dir, + runtime_tx, + cancel.clone(), + base_policy, + RouterPromptInjection::Clean, + ) + .await; + + // The native /goal loop ends (auto-clears) only when its own evaluator says + // the condition is met, so a clean turn completion = achieved. A cancel = + // stopped; any other error = failed. + let status = match &result { + Ok(_) => GoalStatus::Achieved, + Err(_) if cancel.is_cancelled() => GoalStatus::Stopped, + Err(_) => GoalStatus::Failed, + }; + session.goal_status = Some(status.as_str().to_string()); + let _ = store.save_session(session); + emit( + runtime_tx, + RuntimeEvent::GoalCompleted { + session_id: session.session_id, + provider: provider_name, + status: status.as_str().to_string(), + iterations: 1, + }, + ); + Ok(GoalOutcome { + status, + iterations: 1, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fake_provider::FakeProvider; + + fn temp_store() -> (JsonlStore, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + (JsonlStore::new(dir.path().to_path_buf()), dir) + } + + #[test] + fn compose_prompt_includes_goal_marker_and_feedback() { + let first = compose_goal_prompt("ship it", 1, None); + assert!(first.contains("ship it") && first.contains(GOAL_DONE_MARKER)); + let later = compose_goal_prompt("ship it", 3, Some("tests still failing")); + assert!(later.contains("iteration 3")); + assert!(later.contains("tests still failing")); + } + + #[test] + fn precheck_stop_defaults_unbounded_but_honors_cancel_and_optin_caps() { + let unbounded = GoalLimits::default(); + // Unbounded: even a huge iteration count does not stop by itself. + assert_eq!(precheck_stop(10_000, 10_000, false, &unbounded), None); + assert_eq!( + precheck_stop(0, 0, true, &unbounded), + Some(GoalStatus::Stopped) + ); + let capped = GoalLimits { + max_iterations: Some(3), + max_wall_secs: Some(60), + max_no_progress: 6, + }; + assert_eq!( + precheck_stop(3, 0, false, &capped), + Some(GoalStatus::Stopped) + ); + assert_eq!( + precheck_stop(1, 60, false, &capped), + Some(GoalStatus::Stopped) + ); + assert_eq!(precheck_stop(1, 1, false, &capped), None); + } + + #[test] + fn parse_judge_verdict_reads_yes_no_and_reason() { + let (met, reason) = parse_judge_verdict("YES\nall tests pass"); + assert!(met); + assert_eq!(reason, "all tests pass"); + let (met, reason) = parse_judge_verdict("no\nlint still fails"); + assert!(!met); + assert_eq!(reason, "lint still fails"); + let (met, _) = parse_judge_verdict(" yes, done "); + assert!(met); + } + + #[test] + fn goal_status_strings() { + assert_eq!(GoalStatus::Achieved.as_str(), "achieved"); + assert_eq!(GoalStatus::Stopped.as_str(), "stopped"); + assert_eq!(GoalStatus::Failed.as_str(), "failed"); + } + + #[tokio::test] + async fn self_signal_marker_achieves_without_a_judge() { + let (mut store, dir) = temp_store(); + let mut session = Session::new("fake".to_string()); + let provider = FakeProvider::success(format!("all set {GOAL_DONE_MARKER}")); + let catalog = PeerCatalog::default(); + let resolve_peer = |_: &str| None; + + let outcome = run_goal_loop( + &mut store, + &mut session, + &provider, + &catalog, + &resolve_peer, + None, + "make it green", + dir.path().to_path_buf(), + None, + None, + CancellationToken::new(), + ExecutionPolicy::read_only(dir.path().to_path_buf()), + GoalLimits::default(), + None, + ) + .await + .unwrap(); + + assert_eq!(outcome.status, GoalStatus::Achieved); + assert_eq!(outcome.iterations, 1); + } + + #[tokio::test] + async fn evaluator_yes_achieves_even_without_self_signal() { + let (mut store, dir) = temp_store(); + let mut session = Session::new("fake".to_string()); + // Worker never self-signals; the judge says YES → achieved via evaluator. + let worker = FakeProvider::success("did the work"); + let judge = FakeProvider::success("YES\nlooks complete"); + let catalog = PeerCatalog::default(); + let resolve_peer = |_: &str| None; + + let outcome = run_goal_loop( + &mut store, + &mut session, + &worker, + &catalog, + &resolve_peer, + None, + "do the thing", + dir.path().to_path_buf(), + None, + None, + CancellationToken::new(), + ExecutionPolicy::read_only(dir.path().to_path_buf()), + GoalLimits::default(), + Some(&judge), + ) + .await + .unwrap(); + + assert_eq!(outcome.status, GoalStatus::Achieved); + assert_eq!(outcome.iterations, 1); + } + + #[tokio::test] + async fn stuck_guard_stops_a_never_completing_loop() { + let (mut store, dir) = temp_store(); + let mut session = Session::new("fake".to_string()); + // Worker keeps producing the SAME output and never self-signals; the + // judge always says NO. Only the stuck-guard (identical output) stops it. + let worker = FakeProvider::success("still working"); + let judge = FakeProvider::success("NO\nnot done yet"); + let catalog = PeerCatalog::default(); + let resolve_peer = |_: &str| None; + let limits = GoalLimits { + max_iterations: None, + max_wall_secs: None, + max_no_progress: 2, + }; + + let outcome = run_goal_loop( + &mut store, + &mut session, + &worker, + &catalog, + &resolve_peer, + None, + "impossible", + dir.path().to_path_buf(), + None, + None, + CancellationToken::new(), + ExecutionPolicy::read_only(dir.path().to_path_buf()), + limits, + Some(&judge), + ) + .await + .unwrap(); + + assert_eq!(outcome.status, GoalStatus::Stopped); + assert_eq!(session.goal_status.as_deref(), Some("stopped")); + } +} diff --git a/crates/switchyard-core/src/lib.rs b/crates/switchyard-core/src/lib.rs index 514b972..ac4662e 100644 --- a/crates/switchyard-core/src/lib.rs +++ b/crates/switchyard-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod error; pub mod event_mapper; pub mod fake_provider; +pub mod goal_runner; pub mod instance; pub mod ipc; pub mod policy; @@ -18,10 +19,14 @@ pub use proxy::PersistentProviderProxy; // execute_delegate; core depends on orchestrator for the call site). pub use event_mapper::map_provider_event; pub use fake_provider::FakeProvider; +pub use goal_runner::{ + GoalLimits, GoalOutcome, GoalStatus, compose_goal_prompt, run_goal_loop, run_native_slash_goal, +}; pub use policy::{execution_policy_from_config, execution_policy_from_config_with_overrides}; pub use registry::{ProviderRegistry, build_peer_catalog, build_peer_catalog_probed}; pub use router::{ RoutedTurnOutput, RouterPromptInjection, run_routed_turn, run_routed_turn_observable, + run_routed_turn_observable_with_display_and_prompt_injection, run_routed_turn_observable_with_policy, run_routed_turn_observable_with_policy_and_attachments, run_routed_turn_observable_with_policy_attachments_and_prompt_injection, run_routed_turn_with_archive, run_routed_turn_with_archive_and_policy, diff --git a/crates/switchyard-core/src/router.rs b/crates/switchyard-core/src/router.rs index 3973ec2..e6b02ed 100644 --- a/crates/switchyard-core/src/router.rs +++ b/crates/switchyard-core/src/router.rs @@ -643,6 +643,49 @@ pub async fn run_routed_turn_observable_with_policy_attachments_and_prompt_injec cancel: switchyard_provider_api::CancellationToken, base_policy: ExecutionPolicy, prompt_injection: RouterPromptInjection, +) -> Result { + run_routed_turn_observable_with_display_and_prompt_injection( + store, + session, + core_provider, + peer_catalog, + resolve_peer, + registry, + user_message, + attachments, + cwd, + artifact_dir, + runtime_tx, + cancel, + base_policy, + None, + prompt_injection, + ) + .await +} + +/// Same as the above, but with an optional `display_message`: when `Some`, that +/// string is what's persisted and shown as the turn's user message, while +/// `user_message` is used only to build the actual model prompt. Goal mode uses +/// this so its verbose autonomous-mode instructions drive the model WITHOUT +/// leaking into the user's chat bubble (the bubble shows a clean goal line). +#[allow(clippy::too_many_arguments)] +pub async fn run_routed_turn_observable_with_display_and_prompt_injection( + store: &mut (impl CanonicalStore + ?Sized), + session: &mut Session, + core_provider: &dyn Provider, + peer_catalog: &PeerCatalog, + resolve_peer: &(dyn Fn(&str) -> Option> + Send + Sync), + registry: Option>, + user_message: String, + attachments: Vec, + cwd: PathBuf, + artifact_dir: Option<&std::path::Path>, + runtime_tx: Option<&tokio::sync::mpsc::Sender>, + cancel: switchyard_provider_api::CancellationToken, + base_policy: ExecutionPolicy, + display_message: Option, + prompt_injection: RouterPromptInjection, ) -> Result { let mut last_output: Option = None; let mut delegated = false; @@ -652,7 +695,10 @@ pub async fn run_routed_turn_observable_with_policy_attachments_and_prompt_injec system_prompt: None, attachments: attachments.clone(), }; - let stored_user_message = user_turn_input.user_message_text(); + // `model_base` is the full text used to build the model prompt; the stored/ + // displayed user message can differ (goal mode shows a clean line). + let model_base = user_turn_input.user_message_text(); + let stored_user_message = display_message.unwrap_or_else(|| model_base.clone()); let (continuation_hint, initial_hint, mut callback_receipts) = if prompt_injection .includes_orchestration() { @@ -691,18 +737,14 @@ pub async fn run_routed_turn_observable_with_policy_attachments_and_prompt_injec // First iteration: clean user text, optionally augmented with // peer catalog + HYARD continuity state for orchestration mode. build_first_iteration_router_message( - &stored_user_message, + &model_base, peer_catalog, initial_hint.as_deref(), prompt_injection, ) } else if let Some(ref ctx) = inject_context { // Finalization: original task + delegate result + explicit instruction - build_finalization_router_message( - &stored_user_message, - ctx, - continuation_hint.as_deref(), - ) + build_finalization_router_message(&model_base, ctx, continuation_hint.as_deref()) } else { break; }; diff --git a/crates/switchyard-core/src/runtime_events.rs b/crates/switchyard-core/src/runtime_events.rs index 2b539b4..84de4c5 100644 --- a/crates/switchyard-core/src/runtime_events.rs +++ b/crates/switchyard-core/src/runtime_events.rs @@ -4,6 +4,7 @@ //! so the TUI (or any observer) can render live state without polling the store. use switchyard_provider_api::{ExecutionTelemetry, HyardJobObservation}; +use switchyard_session::GoalPlanItem; use uuid::Uuid; use serde::{Deserialize, Serialize}; @@ -194,4 +195,27 @@ pub enum RuntimeEvent { /// `permanent_death`, `core_reset`, `session_clear`. reason: String, }, + /// A Switchyard-native goal-mode iteration is starting. `iteration` is + /// 1-indexed. Frontends use this to show live goal progress. + GoalIterationStarted { + session_id: Uuid, + provider: String, + iteration: u32, + }, + /// A goal run finished. `status` is `achieved` | `stopped` | `failed`; + /// `iterations` is how many loop passes ran. + GoalCompleted { + session_id: Uuid, + provider: String, + status: String, + iterations: u32, + }, + /// The goal's tracked plan/todo list changed (Switchyard self-driven loop + /// parsed a fresh plan block from the agent). Carries the full current list + /// so the UI can render it outside the chat without re-fetching the session. + GoalPlanUpdated { + session_id: Uuid, + provider: String, + plan: Vec, + }, } From 1d8fc8ec00582ef001ad896b89c8a882c1af4a56 Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:28:31 +0800 Subject: [PATCH 08/14] feat(cli,tui): handle goal RuntimeEvent variants Add match arms for GoalIterationStarted / GoalCompleted / GoalPlanUpdated: CLI host-job mapper treats them as non-job events; TUI surfaces brief goal progress lines in the event log. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/switchyard-cli/src/host.rs | 10 ++++++++-- crates/switchyard-tui/src/state.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/switchyard-cli/src/host.rs b/crates/switchyard-cli/src/host.rs index cb4d605..274d59b 100644 --- a/crates/switchyard-cli/src/host.rs +++ b/crates/switchyard-cli/src/host.rs @@ -458,7 +458,10 @@ fn runtime_host_job_event_type_for_runtime_event(event: &RuntimeEvent) -> Option | RuntimeEvent::WorkerSpawned { .. } | RuntimeEvent::WorkerStateChanged { .. } | RuntimeEvent::WorkerRetrying { .. } - | RuntimeEvent::WorkerTerminated { .. } => None, + | RuntimeEvent::WorkerTerminated { .. } + | RuntimeEvent::GoalIterationStarted { .. } + | RuntimeEvent::GoalCompleted { .. } + | RuntimeEvent::GoalPlanUpdated { .. } => None, } } @@ -3400,7 +3403,10 @@ fn apply_runtime_event(job: &mut HostJobState, event: &RuntimeEvent) { | RuntimeEvent::WorkerSpawned { .. } | RuntimeEvent::WorkerStateChanged { .. } | RuntimeEvent::WorkerRetrying { .. } - | RuntimeEvent::WorkerTerminated { .. } => {} + | RuntimeEvent::WorkerTerminated { .. } + | RuntimeEvent::GoalIterationStarted { .. } + | RuntimeEvent::GoalCompleted { .. } + | RuntimeEvent::GoalPlanUpdated { .. } => {} } } diff --git a/crates/switchyard-tui/src/state.rs b/crates/switchyard-tui/src/state.rs index 713d84f..1fa97c6 100644 --- a/crates/switchyard-tui/src/state.rs +++ b/crates/switchyard-tui/src/state.rs @@ -1184,6 +1184,25 @@ impl RuntimeState { | RuntimeEvent::WorkerStateChanged { .. } | RuntimeEvent::WorkerRetrying { .. } | RuntimeEvent::WorkerTerminated { .. } => {} + RuntimeEvent::GoalIterationStarted { + provider, + iteration, + .. + } => { + self.push_event(format!("[goal/{provider}] 第 {iteration} 轮")); + } + RuntimeEvent::GoalCompleted { + provider, + status, + iterations, + .. + } => { + self.push_event(format!("[goal/{provider}] {status}({iterations} 轮)")); + } + RuntimeEvent::GoalPlanUpdated { provider, plan, .. } => { + let done = plan.iter().filter(|i| i.done).count(); + self.push_event(format!("[goal/{provider}] 计划 {done}/{} 完成", plan.len())); + } } self.dirty = true; } From 37f550d9bd1c50f6a7a7270eab3baa4636bfe139 Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:28:48 +0800 Subject: [PATCH 09/14] feat(gui): start_goal/stop_goal commands + goal event bridge Tauri start_goal (blocking goal run on the persistent Core instance; native passthrough for Claude, self-driven loop otherwise, with a fresh isolated evaluator provider) and stop_goal (cooperative cancel). GoalRunState + ActiveTurnState mutual exclusion; ensure_persistent_core_instance helper shared with run_turn; goal RuntimeEvent arms in the runtime bridge. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/switchyard-gui/src/main.rs | 400 ++++++++++++++++++++++++------ 1 file changed, 325 insertions(+), 75 deletions(-) diff --git a/crates/switchyard-gui/src/main.rs b/crates/switchyard-gui/src/main.rs index bb5a2ea..ca3fbed 100644 --- a/crates/switchyard-gui/src/main.rs +++ b/crates/switchyard-gui/src/main.rs @@ -499,6 +499,29 @@ fn runtime_event_bridge_brief(event: &RuntimeEvent) -> String { "WorkerTerminated session_id={session_id} instance_id={instance_id} provider={provider} label={} reason={reason}", label.as_deref().unwrap_or("-") ), + RuntimeEvent::GoalIterationStarted { + session_id, + provider, + iteration, + } => format!( + "GoalIterationStarted session_id={session_id} provider={provider} iteration={iteration}" + ), + RuntimeEvent::GoalCompleted { + session_id, + provider, + status, + iterations, + } => format!( + "GoalCompleted session_id={session_id} provider={provider} status={status} iterations={iterations}" + ), + RuntimeEvent::GoalPlanUpdated { + session_id, + provider, + plan, + } => format!( + "GoalPlanUpdated session_id={session_id} provider={provider} items={}", + plan.len() + ), } } @@ -4101,81 +4124,19 @@ async fn run_turn( .create(&provider, effective_config.providers.get(&provider)) .ok_or_else(|| format!("unsupported provider: {}", provider))?; - // Pre-spawn/ensure core provider is persistent for this session. Try to - // resume a previously-bound CLI thread via the per-session - // `native_bindings[_resume_token]` slot — `start_persistent_instance_resumed` - // gracefully falls back to a fresh start if the daemon refuses the token. - if let Some(persistent) = provider_impl.as_persistent() - && !pool.has_live_instance(&provider, session.session_id) - { - let mut env = effective_config - .providers - .get(&provider) - .map(|c| c.env.clone()) - .unwrap_or_default(); - // Inject Switchyard identity so any hooks the CLI fires - // (`switchyard host hook fire …`) can find the right session. - env.insert( - "SWITCHYARD_SESSION_ID".to_string(), - session.session_id.to_string(), - ); - env.insert("SWITCHYARD_PROVIDER".to_string(), provider.clone()); - let resume_key = format!("{provider}_resume_token"); - let resume_token = session.native_bindings.get(&resume_key).cloned(); - if let Ok(inst) = persistent - .start_persistent_instance_resumed(cwd.clone(), env, resume_token.clone()) - .await - { - // After spawn, the live instance may have minted a fresh thread - // (when the token was None, stale, or unsupported). Persist the - // current token so the next respawn can resume cleanly. - let new_token = inst.resume_token(); - match (&new_token, &resume_token) { - (Some(t), Some(prior)) if t == prior => { - // Resume took — no save needed. - } - (Some(t), _) => { - session - .native_bindings - .insert(resume_key.clone(), t.clone()); - let _ = store.save_session(&session); - } - (None, Some(_)) => { - // Token now invalid (provider stopped exposing one). Drop - // the binding so we don't keep retrying a stale id. - session.native_bindings.remove(&resume_key); - let _ = store.save_session(&session); - } - (None, None) => {} - } - - let mut metadata = switchyard_provider_api::InstanceMetadata::new( - provider.clone(), - session.session_id, - None, - switchyard_provider_api::InstanceKind::Core, - ); - metadata.state = switchyard_provider_api::InstanceState::Idle; - let spawned_at = metadata.spawned_at; - let provider_name = metadata.provider.clone(); - if let Ok(instance_id) = pool.register(metadata, inst) { - // Emit a WorkerSpawned so the frontend can update the Core - // status card without polling. Worker peers get their own - // events via the supervisor; the Core spawns here outside - // the supervisor path. - tx.send(switchyard_core::RuntimeEvent::WorkerSpawned { - session_id: session.session_id, - instance_id, - provider: provider_name, - label: None, - kind: "core".to_string(), - spawned_at: spawned_at.to_rfc3339(), - }) - .await - .ok(); - } - } - } + // Pre-spawn/ensure the Core provider runs as a persistent instance for this + // session (resume via native_bindings token; graceful fresh-start fallback). + ensure_persistent_core_instance( + &pool, + &*provider_impl, + &provider, + &cwd, + &effective_config, + &mut session, + &mut store, + &tx, + ) + .await; let registry_dyn: Arc = pool.inner().clone(); let core_proxy = switchyard_core::PersistentProviderProxy::new_with_runtime_events( @@ -4267,6 +4228,284 @@ async fn run_turn( } } +/// Ensure the Core provider is running as a persistent live instance for this +/// session, registering it in the pool. Resumes a prior CLI thread via the +/// per-session `native_bindings[_resume_token]` slot; falls back to a +/// fresh start if the daemon refuses the token. No-op for one-shot providers +/// (`as_persistent() == None`) or when a live instance already exists. +/// Shared by `run_turn` (one turn) and `start_goal` (many turns on the same +/// warm instance). +#[allow(clippy::too_many_arguments)] +async fn ensure_persistent_core_instance( + pool: &Arc, + provider_impl: &dyn switchyard_provider_api::Provider, + provider: &str, + cwd: &std::path::Path, + effective_config: &SwitchyardConfig, + session: &mut switchyard_session::Session, + store: &mut StoreHandle, + tx: &tokio::sync::mpsc::Sender, +) { + let Some(persistent) = provider_impl.as_persistent() else { + return; + }; + if pool.has_live_instance(provider, session.session_id) { + return; + } + let mut env = effective_config + .providers + .get(provider) + .map(|c| c.env.clone()) + .unwrap_or_default(); + // Inject Switchyard identity so any hooks the CLI fires can find the session. + env.insert( + "SWITCHYARD_SESSION_ID".to_string(), + session.session_id.to_string(), + ); + env.insert("SWITCHYARD_PROVIDER".to_string(), provider.to_string()); + let resume_key = format!("{provider}_resume_token"); + let resume_token = session.native_bindings.get(&resume_key).cloned(); + let Ok(inst) = persistent + .start_persistent_instance_resumed(cwd.to_path_buf(), env, resume_token.clone()) + .await + else { + return; + }; + // The instance may have minted a fresh thread; persist the current token. + let new_token = inst.resume_token(); + match (&new_token, &resume_token) { + (Some(t), Some(prior)) if t == prior => {} + (Some(t), _) => { + session + .native_bindings + .insert(resume_key.clone(), t.clone()); + let _ = store.save_session(session); + } + (None, Some(_)) => { + session.native_bindings.remove(&resume_key); + let _ = store.save_session(session); + } + (None, None) => {} + } + let mut metadata = switchyard_provider_api::InstanceMetadata::new( + provider.to_string(), + session.session_id, + None, + switchyard_provider_api::InstanceKind::Core, + ); + metadata.state = switchyard_provider_api::InstanceState::Idle; + let spawned_at = metadata.spawned_at; + let provider_name = metadata.provider.clone(); + if let Ok(instance_id) = pool.register(metadata, inst) { + tx.send(switchyard_core::RuntimeEvent::WorkerSpawned { + session_id: session.session_id, + instance_id, + provider: provider_name, + label: None, + kind: "core".to_string(), + spawned_at: spawned_at.to_rfc3339(), + }) + .await + .ok(); + } +} + +/// Switchyard-native goal mode: drive the active Core provider turn-after-turn +/// toward `goal` until the model self-reports done or a cap trips. Blocks for +/// the whole run (like `run_turn` blocks for one turn); progress streams via +/// `runtime_event_batch`, and `stop_goal` cancels it cooperatively. Returns +/// `":"` (status = achieved|stopped|failed). +#[tauri::command] +#[allow(clippy::too_many_arguments)] +async fn start_goal( + app: tauri::AppHandle, + pool: tauri::State<'_, Arc>, + workspace_state: tauri::State<'_, WorkspaceState>, + file_watcher: tauri::State<'_, FileWatcherState>, + session_id: String, + goal: String, + provider: Option, + sandbox_mode: Option, +) -> Result { + let goal = goal.trim().to_string(); + if goal.is_empty() { + return Err("goal is empty".to_string()); + } + let (ws, mut store, data_dir, base_config) = open_current_store(&workspace_state)?; + let cwd = ws.primary_root.clone(); + let session_uuid = + uuid::Uuid::parse_str(&session_id).map_err(|e| format!("invalid session ID: {}", e))?; + let mut session = store + .load_session(session_uuid) + .map_err(|e| format!("load session: {}", e))? + .ok_or_else(|| format!("session {} not found", session_id))?; + if session.workspace_id.is_nil() { + session.workspace_id = ws.workspace_id; + } + + let provider = provider.unwrap_or_else(|| session.active_core.clone()); + let mut effective_config = base_config.clone(); + apply_session_provider_overrides(&mut effective_config, &provider, &session); + let registry = build_provider_registry(&effective_config); + let bridge_debug = env_flag_enabled("SWITCHYARD_DEBUG_RUNTIME_BRIDGE"); + let tx = spawn_runtime_event_bridge(app.clone(), bridge_debug); + let provider_impl = registry + .create(&provider, effective_config.providers.get(&provider)) + .ok_or_else(|| format!("unsupported provider: {}", provider))?; + + ensure_persistent_core_instance( + &pool, + &*provider_impl, + &provider, + &cwd, + &effective_config, + &mut session, + &mut store, + &tx, + ) + .await; + + let registry_dyn: Arc = pool.inner().clone(); + let core_proxy = switchyard_core::PersistentProviderProxy::new_with_runtime_events( + provider.clone(), + session.session_id, + provider_impl, + Some(registry_dyn.clone()), + Some(tx.clone()), + ); + let peer_catalog = + build_peer_catalog_probed(&provider, ®istry, &effective_config.providers).await; + let artifact_dir = data_dir.join("artifacts"); + let _ = std::fs::create_dir_all(&artifact_dir); + + // Mutual exclusion: a goal and a manual turn can't run for the same session + // at once (they'd contend on the persistent instance). Register the cancel + // in BOTH ActiveTurnState (so `run_turn` is rejected while a goal runs) and + // GoalRunState (so `stop_goal` can find + cancel it). + let cancel = CancellationToken::new(); + { + let active = app.state::(); + let mut guard = active.cancel_by_session.lock().unwrap(); + if guard.contains_key(&session.session_id) { + return Err(format!( + "a turn or goal is already running for session {}", + session.session_id + )); + } + guard.insert(session.session_id, cancel.clone()); + } + app.state::() + .cancel_by_session + .lock() + .unwrap() + .insert(session.session_id, cancel.clone()); + + file_watcher.start_turn_for(session.session_id); + let mut policy = + execution_policy_from_config_with_overrides(&effective_config, &cwd, sandbox_mode, &[]); + // A goal run is unbounded (spans many turns / a native /goal's whole loop) — + // don't let a per-turn timeout kill it; the user's stop button + stuck-guard + // are the brakes. + policy.timeout_secs = 0; + let resolve_peer = |name: &str| registry.create(name, effective_config.providers.get(name)); + + // Native passthrough when the CLI has its own goal engine reachable in our + // driving mode: Claude Code parses "/goal " and runs its evaluator loop + // within a single persistent-instance turn — let it drive. Otherwise use + // Switchyard's provider-agnostic loop (evaluator + self-signal). + let backend = effective_config + .providers + .get(&provider) + .and_then(|c| c.backend.clone()) + .unwrap_or_else(|| provider.to_lowercase()); + let outcome = if backend.contains("claude") { + switchyard_core::run_native_slash_goal( + &mut store, + &mut session, + &core_proxy, + &peer_catalog, + &resolve_peer, + Some(registry_dyn.clone()), + "/goal", + &goal, + cwd, + Some(&artifact_dir), + Some(&tx), + cancel.clone(), + policy, + ) + .await + } else { + // Fresh, isolated provider instance for the completion evaluator + // (Claude-Code-style), kept off the persistent Core instance so its + // read-only judge turn can't pollute that conversation. + let judge_impl = registry.create(&provider, effective_config.providers.get(&provider)); + switchyard_core::run_goal_loop( + &mut store, + &mut session, + &core_proxy, + &peer_catalog, + &resolve_peer, + Some(registry_dyn.clone()), + &goal, + cwd, + Some(&artifact_dir), + Some(&tx), + cancel.clone(), + policy, + switchyard_core::GoalLimits::default(), + judge_impl.as_deref(), + ) + .await + }; + + // Promote any file changes captured across the whole goal run. + let captured = file_watcher.end_turn_for(session.session_id); + if !captured.is_empty() + && let Some(turn_id) = latest_turn_id(&store, session.session_id) + { + persist_captured_changes(&mut store, turn_id, &provider, &captured); + } + workspace_state.invalidate_git_status_cache(); + + app.state::() + .cancel_by_session + .lock() + .unwrap() + .remove(&session.session_id); + app.state::() + .cancel_by_session + .lock() + .unwrap() + .remove(&session.session_id); + + match outcome { + Ok(o) => Ok(format!("{}:{}", o.status.as_str(), o.iterations)), + Err(e) => Err(format!("goal run failed: {}", e)), + } +} + +/// Cooperatively stop an active goal run for a session (next iteration boundary). +#[tauri::command] +async fn stop_goal(app: tauri::AppHandle, session_id: String) -> Result<(), String> { + let session_uuid = + uuid::Uuid::parse_str(&session_id).map_err(|e| format!("invalid session ID: {}", e))?; + let token = app + .state::() + .cancel_by_session + .lock() + .unwrap() + .get(&session_uuid) + .cloned(); + match token { + Some(t) => { + t.cancel(); + Ok(()) + } + None => Err(format!("no active goal for session {}", session_id)), + } +} + /// Find the most recent turn for a session — used to anchor file-watcher /// captured changes after `run_routed_turn_observable` has persisted /// its turn record. @@ -4324,6 +4563,14 @@ struct ActiveTurnState { cancel_by_session: std::sync::Mutex>, } +/// Cancel tokens for active Switchyard goal runs, keyed by session. Separate +/// from `ActiveTurnState` so `stop_goal` can target goal runs specifically +/// (a goal also registers in `ActiveTurnState` for turn/goal mutual exclusion). +#[derive(Default)] +struct GoalRunState { + cancel_by_session: std::sync::Mutex>, +} + #[tauri::command] fn cancel_turn( session_id: Option, @@ -5174,6 +5421,7 @@ fn main() { .manage(ActiveTurnState { cancel_by_session: std::sync::Mutex::new(HashMap::new()), }) + .manage(GoalRunState::default()) .manage(Arc::new(switchyard_core::InstancePool::new())) .manage(workspace_state) .manage(file_watcher) @@ -5204,6 +5452,8 @@ fn main() { get_session_turns_page, get_session_events, get_turn_events, + start_goal, + stop_goal, get_runtime_snapshot, save_clipboard_attachment, persist_attachment_file, From 6832828fad58938cef73b300142ba02ed8dca00c Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:29:05 +0800 Subject: [PATCH 10/14] =?UTF-8?q?feat(gui/frontend):=20goal=20UI=20?= =?UTF-8?q?=E2=80=94=20/goal=20command,=20plan=20panel,=20timer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register /goal (activate/stop/status) as a slash command with autocomplete. ControlCenter Goal card: activate/stop + live plan checklist (persisted goal_plan). ChatArea header shows a live goal elapsed timer (no iteration counter). App.tsx wires start/stop + consumes goal runtime events (iteration/status/plan) live. RenderHelpers strips the goal plan block + completion marker from chat bubbles (surfaced only in the plan panel). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/switchyard-gui/frontend/src/App.tsx | 97 ++++++++++++++ .../frontend/src/components/ChatArea.tsx | 48 +++++++ .../frontend/src/components/ControlCenter.tsx | 121 ++++++++++++++++++ .../frontend/src/components/slashCommands.ts | 33 +++++ .../src/components/ui/RenderHelpers.tsx | 8 +- crates/switchyard-gui/frontend/src/types.ts | 14 ++ 6 files changed, 320 insertions(+), 1 deletion(-) diff --git a/crates/switchyard-gui/frontend/src/App.tsx b/crates/switchyard-gui/frontend/src/App.tsx index 360de90..c519d9e 100644 --- a/crates/switchyard-gui/frontend/src/App.tsx +++ b/crates/switchyard-gui/frontend/src/App.tsx @@ -3956,6 +3956,44 @@ function App() { } break; } + + case 'GoalIterationStarted': { + const sessionId = normalizedString(data.session_id); + const iteration = typeof data.iteration === 'number' ? data.iteration : undefined; + if (sessionId) { + applyGoalPatch(sessionId, { + goal_status: 'running', + ...(iteration !== undefined ? { goal_iteration: iteration } : {}), + }); + if (iteration !== undefined) { + enqueueLog(now, 'sys', `Goal iteration ${iteration} started (${data.provider})`); + } + } + break; + } + + case 'GoalCompleted': { + const sessionId = normalizedString(data.session_id); + const status = normalizedString(data.status) ?? 'stopped'; + if (sessionId) { + applyGoalPatch(sessionId, { goal_status: status }); + enqueueLog( + now, + 'sys', + `Goal ${status} after ${data.iterations ?? '?'} iteration(s) (${data.provider})`, + ); + } + break; + } + + case 'GoalPlanUpdated': { + const sessionId = normalizedString(data.session_id); + const plan = Array.isArray(data.plan) + ? data.plan.map((p: any) => ({ text: String(p?.text ?? ''), done: Boolean(p?.done) })) + : []; + if (sessionId) applyGoalPatch(sessionId, { goal_plan: plan }); + break; + } } }; @@ -5219,6 +5257,21 @@ function App() { }, openCanvasFile: openFileInCanvas, appendSystemNote, + startGoal: async (goal: string) => { + const target = selectedSessionRef.current; + if (!target) throw new Error('select a session first'); + await handleStartGoal(target.session_id, goal); + }, + stopGoal: async () => { + const target = selectedSessionRef.current; + if (target) await handleStopGoal(target.session_id); + }, + goalStatus: () => { + const s = selectedSessionRef.current; + if (!s?.goal) return null; + const iter = s.goal_iteration ? ` (iter ${s.goal_iteration})` : ''; + return `${s.goal_status ?? 'idle'}${iter}: ${s.goal}`; + }, }); const handleSend = async ( @@ -5667,6 +5720,48 @@ function App() { } }; + // Patch goal fields on a session in both the list and the live selection. + const applyGoalPatch = (sessionId: string, patch: Partial) => { + setSessions((prev) => + prev.map((s) => (s.session_id === sessionId ? { ...s, ...patch } : s)), + ); + if (selectedSessionRef.current?.session_id === sessionId) { + const updated = { ...selectedSessionRef.current, ...patch } as Session; + selectedSessionRef.current = updated; + setSelectedSession(updated); + } + }; + + // Activate Switchyard goal mode. `start_goal` blocks for the whole run (like + // `run_turn` blocks for one turn); each iteration streams as a normal turn via + // runtime events, so the chat shows live progress. We optimistically flag the + // session "running" and set the terminal status from the ":" + // result when it resolves. + const handleStartGoal = async (sessionId: string, goal: string) => { + applyGoalPatch(sessionId, { goal, goal_status: 'running', goal_iteration: 0 }); + addLog(new Date().toLocaleTimeString(), 'sys', `Goal activated: ${goal}`); + try { + const resp = await invoke('start_goal', { sessionId, goal }); + const status = (resp || '').split(':')[0] || 'stopped'; + applyGoalPatch(sessionId, { goal_status: status }); + addLog(new Date().toLocaleTimeString(), 'sys', `Goal ${resp}`); + } catch (e) { + applyGoalPatch(sessionId, { goal_status: 'failed' }); + addLog(new Date().toLocaleTimeString(), 'sys', `Goal failed: ${e}`); + console.error('goal run failed', e); + } + }; + + // Cooperatively stop the active goal. The blocking `start_goal` call then + // resolves with a "stopped:*" status and patches the session. + const handleStopGoal = async (sessionId: string) => { + try { + await invoke('stop_goal', { sessionId }); + } catch (e) { + console.error('stop goal failed', e); + } + }; + const handleUpdateSessionRuntime = async ( sessionId: string, activeCore: string, @@ -6080,6 +6175,8 @@ function App() { onUpdateSessionRuntime={handleUpdateSessionRuntime} onUpdateSessionSummary={handleUpdateSessionSummary} onUpdateSessionChecklist={handleUpdateSessionChecklist} + onStartGoal={handleStartGoal} + onStopGoal={handleStopGoal} /> )} diff --git a/crates/switchyard-gui/frontend/src/components/ChatArea.tsx b/crates/switchyard-gui/frontend/src/components/ChatArea.tsx index 12306d3..2e0fd49 100644 --- a/crates/switchyard-gui/frontend/src/components/ChatArea.tsx +++ b/crates/switchyard-gui/frontend/src/components/ChatArea.tsx @@ -15,6 +15,16 @@ import { stripAttachmentReferences, } from '../utils/attachments'; +/// Format an elapsed-seconds count as `m:ss` (or `h:mm:ss` past an hour) for the +/// active-goal header timer. +function formatGoalElapsed(secs: number): string { + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = secs % 60; + const pad = (n: number) => String(n).padStart(2, '0'); + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; +} + interface ChatAreaProps { selectedSession: Session | null; isGenerating: boolean; @@ -1668,6 +1678,29 @@ export const ChatArea: React.FC = ({ const currentSessionId = selectedSession?.session_id ?? null; const sessionCacheKey = virtualSessionKey(currentSessionId); + // Live elapsed timer for an active goal run (the user wants a clock, not an + // iteration counter). Baseline is tracked in the frontend and resets on + // session switch / new run — matching Claude Code's "timer resets on resume". + const goalRunning = selectedSession?.goal_status === 'running'; + const [goalElapsedSecs, setGoalElapsedSecs] = useState(0); + const goalStartRef = useRef(null); + useEffect(() => { + if (!goalRunning) { + goalStartRef.current = null; + setGoalElapsedSecs(0); + return; + } + if (goalStartRef.current == null) goalStartRef.current = Date.now(); + const tick = () => { + if (goalStartRef.current != null) { + setGoalElapsedSecs(Math.floor((Date.now() - goalStartRef.current) / 1000)); + } + }; + tick(); + const id = window.setInterval(tick, 1000); + return () => window.clearInterval(id); + }, [goalRunning, currentSessionId]); + const { lastUserTurnId, delegateTurnsByParent, @@ -2454,6 +2487,21 @@ export const ChatArea: React.FC = ({
+ {goalRunning && ( +
+ 🎯 Goal · {formatGoalElapsed(goalElapsedSecs)} +
+ )} {isGenerating && (
diff --git a/crates/switchyard-gui/frontend/src/components/ControlCenter.tsx b/crates/switchyard-gui/frontend/src/components/ControlCenter.tsx index 9444f56..3fe7d9f 100644 --- a/crates/switchyard-gui/frontend/src/components/ControlCenter.tsx +++ b/crates/switchyard-gui/frontend/src/components/ControlCenter.tsx @@ -44,6 +44,10 @@ interface ControlCenterProps { ) => Promise; onUpdateSessionSummary: (sessionId: string, summary: string | null) => Promise; onUpdateSessionChecklist: (sessionId: string, checklistJson: string) => Promise; + /// Activate Switchyard goal mode: drive the Core toward `goal` until done. + onStartGoal: (sessionId: string, goal: string) => Promise; + /// Cooperatively stop the active goal run. + onStopGoal: (sessionId: string) => Promise; } type TabType = 'topology' | 'agents' | 'checklist' | 'summary' | 'telemetry'; @@ -107,6 +111,8 @@ export const ControlCenter: React.FC = ({ onUpdateSessionRuntime, onUpdateSessionSummary, onUpdateSessionChecklist, + onStartGoal, + onStopGoal, }) => { const [activeTab, setActiveTab] = useState('agents'); const [logSearch, setLogSearch] = useState(''); @@ -119,6 +125,8 @@ export const ControlCenter: React.FC = ({ const [sessionModel, setSessionModel] = useState(''); const [sessionThinkingLevel, setSessionThinkingLevel] = useState(''); const [runtimeSaving, setRuntimeSaving] = useState(false); + const [goalText, setGoalText] = useState(''); + const [goalBusy, setGoalBusy] = useState(false); // Sync summaryText when selectedSession changes useEffect(() => { @@ -129,11 +137,13 @@ export const ControlCenter: React.FC = ({ setSessionCore(selectedSession?.active_core || ''); setSessionModel(selectedSession?.model || ''); setSessionThinkingLevel(selectedSession?.thinking_level || ''); + setGoalText(selectedSession?.goal || ''); }, [ selectedSession?.session_id, selectedSession?.active_core, selectedSession?.model, selectedSession?.thinking_level, + selectedSession?.goal, ]); const checklistItems: ChecklistItem[] = React.useMemo(() => { @@ -1202,6 +1212,117 @@ export const ControlCenter: React.FC = ({ {selectedRuntimeMapping.thinkingHint}
+
+
Goal Mode (Switchyard)
+
+ 给一个目标,Switchyard 驱动 Core 连续多轮运行直到达成或到上限。对所有 provider 通用。 +
+