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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions crates/switchyard-gui/frontend/src/components/ui/RenderHelpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,15 @@ export function renderMessageBody(
}
}

// Defense-in-depth: a sentinel is a routing/control artifact and must never
// appear in the bubble, even when it isn't a recognized delegate or arrives
// in fragments mid-stream. Strip every complete block, then drop an unclosed
// trailing block (a dangling BEGIN with no END yet). Mirrors the Rust
// strip_sentinel_blocks semantics.
remainingText = remainingText
.replace(/<<<SWITCHYARD_JSON_BEGIN>>>[\s\S]*?<<<SWITCHYARD_JSON_END>>>/g, '')
.replace(/<<<SWITCHYARD_JSON_BEGIN>>>[\s\S]*$/, '');

if (!remainingText.trim() && !delegateCard) return null;

// Split by code blocks first
Expand Down Expand Up @@ -2597,12 +2606,19 @@ export function renderTurnActivitySummary(
const phaseIsActive = runtimePhase === 'running' || runtimePhase === 'output_completed' || runtimePhase === 'finalizing';
const showSpinner = phaseIsActive || turnIsActive || hasRunningTool || hasLiveTerminal;
const detailToolCalls = displayToolCalls.filter((tool) => !actionableTools.includes(tool));
const commandActivity = runningCommand
? activeToolInputLabel(runningCommand)
// Prefer a currently-running command; otherwise fall back to the most recent
// command that already ran. Without this, a provider whose tools have all
// completed (e.g. kohaku between tool batches, with no launcher commandLine)
// would fall through to the "暂无工具事件" status even though `commandCount`
// shows commands ran — a confusing contradiction.
const latestCommandTool = runningCommand
?? [...displayToolCalls].reverse().find((tool) => isCommandTool(tool));
const commandActivity = latestCommandTool
? activeToolInputLabel(latestCommandTool)
: commandLine
? truncateActivityText(`${commandLine}${commandArgsSuffix(state.commandArgs)}`, 180)
: null;
const commandVerb = runningCommand || hasRunningTool || hasLiveTerminal || runtimePhase === 'running'
const commandVerb = runningCommand || hasRunningTool || hasLiveTerminal || (!latestCommandTool && runtimePhase === 'running')
? '正在运行'
: '已运行';
const editActivity = latestEdit
Expand Down
12 changes: 9 additions & 3 deletions crates/switchyard-provider-api/src/sentinel.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use crate::ProviderError;

const SENTINEL_BEGIN: &str = "<<<SWITCHYARD_JSON_BEGIN>>>";
const SENTINEL_END: &str = "<<<SWITCHYARD_JSON_END>>>";
/// The opening marker of a Switchyard control-channel (e.g. delegate) block.
/// Public so streaming adapters can gate it out of the live display channel.
pub const SENTINEL_BEGIN: &str = "<<<SWITCHYARD_JSON_BEGIN>>>";
/// The closing marker of a Switchyard control-channel block.
pub const SENTINEL_END: &str = "<<<SWITCHYARD_JSON_END>>>";

/// Extract all JSON blocks delimited by sentinel markers from provider text output.
pub fn extract_sentinel_blocks(text: &str) -> Vec<&str> {
Expand Down Expand Up @@ -37,7 +40,10 @@ pub fn strip_sentinel_blocks(text: &str) -> String {
if let Some(end) = text[json_start..].find(SENTINEL_END) {
search_from = json_start + end + SENTINEL_END.len();
} else {
// Unclosed block — keep the rest as-is
// Unclosed block — keep the rest as-is. This shared strip stays
// conservative (it runs on every provider's final response); a
// streaming adapter that needs to withhold a pending block does so
// in its own display channel (see the kohaku SentinelDisplayFilter).
search_from = abs_begin + SENTINEL_BEGIN.len();
}
}
Expand Down
70 changes: 60 additions & 10 deletions crates/switchyard-provider-kohaku/src/jsonl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@
//!
//! The `switchyard-headless` fork emits one JSON object per line on stdout:
//! `turn_start`, `text`, `activity` (with `activity_type` + nested
//! `metadata`), `turn_end` (`status`/`text`/`error`/`usage`), and `error`.
//! Every line is also passed through verbatim as an `ItemUpdated` event for
//! the diagnostics drawer; this module only classifies the lines that affect
//! the assistant text and the turn outcome.
//! `metadata`), `turn_end` (`status`/`text`/`error`/`usage`), and a top-level
//! `error`. This module classifies each line into a [`KohakuEvent`]; the
//! adapter (`turn.rs`) then maps only the user-facing signal onto events:
//! assistant `text` deltas (sentinel-gated), genuine tool/subagent `activity`
//! (normalized to collapsed tool cards), and the turn outcome/error. Pure
//! runtime telemetry and `turn_start` are dropped so the chat shows only the
//! model's message; the raw protocol is preserved solely in the archived
//! stdout artifact, not as live `ItemUpdated` events.

use serde_json::Value;

#[derive(Debug, PartialEq, Eq)]
#[derive(Debug, PartialEq)]
pub enum KohakuEvent {
/// An assistant text delta (`{"type":"text","content":...}`).
Text(String),
Expand All @@ -19,7 +23,16 @@ pub enum KohakuEvent {
text: String,
error: Option<String>,
},
/// activity / turn_start / error / anything else — passed through as-is.
/// Runtime activity (`{"type":"activity","activity_type":...,"metadata":...}`).
/// `value` is the whole activity object so the adapter can read
/// `activity_type` / `detail` / `metadata` when deciding what (if anything)
/// to surface.
Activity { activity_type: String, value: Value },
/// The turn-start marker (`{"type":"turn_start",...}`).
TurnStart,
/// A top-level fatal error (`{"type":"error","content":...}`).
Error(String),
/// Anything else — carries no user-facing signal.
Other,
}

Expand Down Expand Up @@ -47,6 +60,23 @@ pub fn classify(json: &Value) -> KohakuEvent {
.and_then(Value::as_str)
.map(str::to_string),
},
Some("activity") => KohakuEvent::Activity {
activity_type: json
.get("activity_type")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
value: json.clone(),
},
Some("turn_start") => KohakuEvent::TurnStart,
Some("error") => KohakuEvent::Error(
json.get("content")
.and_then(Value::as_str)
.or_else(|| json.get("error").and_then(Value::as_str))
.or_else(|| json.get("message").and_then(Value::as_str))
.unwrap_or("")
.to_string(),
),
_ => KohakuEvent::Other,
}
}
Expand Down Expand Up @@ -100,14 +130,34 @@ mod tests {
}

#[test]
fn activity_and_turn_start_are_other() {
fn classifies_activity_with_type() {
assert_eq!(
classify(&json!({"type": "activity", "activity_type": "tool_start"})),
KohakuEvent::Other
classify(&json!({"type": "activity", "activity_type": "tool_start", "detail": "read"})),
KohakuEvent::Activity {
activity_type: "tool_start".to_string(),
value: json!({"type": "activity", "activity_type": "tool_start", "detail": "read"}),
}
);
}

#[test]
fn classifies_turn_start() {
assert_eq!(
classify(&json!({"type": "turn_start", "agent": "x"})),
KohakuEvent::Other
KohakuEvent::TurnStart
);
}

#[test]
fn classifies_top_level_error() {
assert_eq!(
classify(&json!({"type": "error", "content": "fatal boom"})),
KohakuEvent::Error("fatal boom".to_string())
);
}

#[test]
fn unknown_type_is_other() {
assert_eq!(classify(&json!({"type": "mystery"})), KohakuEvent::Other);
}
}
Loading
Loading