story-kit: merge 167_bug_thinking_trace_height_constraint_not_working_in_web_ui
This commit is contained in:
@@ -3171,6 +3171,60 @@ async fn run_agent_pty_streaming(
|
|||||||
.map_err(|e| format!("Agent task panicked: {e}"))?
|
.map_err(|e| format!("Agent task panicked: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dispatch a `stream_event` from Claude Code's `--include-partial-messages` output.
|
||||||
|
///
|
||||||
|
/// Extracts `thinking_delta` and `text_delta` from `content_block_delta` events
|
||||||
|
/// and routes them as `AgentEvent::Thinking` and `AgentEvent::Output` respectively.
|
||||||
|
/// This ensures thinking traces flow through the dedicated `ThinkingBlock` UI
|
||||||
|
/// component rather than appearing as unbounded regular output.
|
||||||
|
fn handle_agent_stream_event(
|
||||||
|
event: &serde_json::Value,
|
||||||
|
story_id: &str,
|
||||||
|
agent_name: &str,
|
||||||
|
tx: &broadcast::Sender<AgentEvent>,
|
||||||
|
event_log: &Mutex<Vec<AgentEvent>>,
|
||||||
|
log_writer: Option<&Mutex<AgentLogWriter>>,
|
||||||
|
) {
|
||||||
|
let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
if event_type == "content_block_delta"
|
||||||
|
&& let Some(delta) = event.get("delta")
|
||||||
|
{
|
||||||
|
let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
match delta_type {
|
||||||
|
"thinking_delta" => {
|
||||||
|
if let Some(thinking) = delta.get("thinking").and_then(|t| t.as_str()) {
|
||||||
|
emit_event(
|
||||||
|
AgentEvent::Thinking {
|
||||||
|
story_id: story_id.to_string(),
|
||||||
|
agent_name: agent_name.to_string(),
|
||||||
|
text: thinking.to_string(),
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
event_log,
|
||||||
|
log_writer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"text_delta" => {
|
||||||
|
if let Some(text) = delta.get("text").and_then(|t| t.as_str()) {
|
||||||
|
emit_event(
|
||||||
|
AgentEvent::Output {
|
||||||
|
story_id: story_id.to_string(),
|
||||||
|
agent_name: agent_name.to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
event_log,
|
||||||
|
log_writer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper to send an event to broadcast, event log, and optional persistent log file.
|
/// Helper to send an event to broadcast, event log, and optional persistent log file.
|
||||||
fn emit_event(
|
fn emit_event(
|
||||||
event: AgentEvent,
|
event: AgentEvent,
|
||||||
@@ -3229,6 +3283,11 @@ fn run_agent_pty_blocking(
|
|||||||
cmd.arg("--output-format");
|
cmd.arg("--output-format");
|
||||||
cmd.arg("stream-json");
|
cmd.arg("stream-json");
|
||||||
cmd.arg("--verbose");
|
cmd.arg("--verbose");
|
||||||
|
// Enable partial streaming so we receive thinking_delta and text_delta
|
||||||
|
// events in real-time, rather than only complete assistant events.
|
||||||
|
// Without this, thinking traces may not appear in the structured output
|
||||||
|
// and instead leak as unstructured PTY text.
|
||||||
|
cmd.arg("--include-partial-messages");
|
||||||
|
|
||||||
// Supervised agents don't need interactive permission prompts
|
// Supervised agents don't need interactive permission prompts
|
||||||
cmd.arg("--permission-mode");
|
cmd.arg("--permission-mode");
|
||||||
@@ -3358,42 +3417,25 @@ fn run_agent_pty_blocking(
|
|||||||
.and_then(|s| s.as_str())
|
.and_then(|s| s.as_str())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
}
|
}
|
||||||
"assistant" => {
|
// With --include-partial-messages, thinking and text arrive
|
||||||
if let Some(message) = json.get("message")
|
// incrementally via stream_event → content_block_delta. Handle
|
||||||
&& let Some(content) = message.get("content").and_then(|c| c.as_array())
|
// them here for real-time streaming to the frontend.
|
||||||
{
|
"stream_event" => {
|
||||||
for block in content {
|
if let Some(event) = json.get("event") {
|
||||||
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
handle_agent_stream_event(
|
||||||
if block_type == "thinking" {
|
event,
|
||||||
if let Some(thinking) =
|
story_id,
|
||||||
block.get("thinking").and_then(|t| t.as_str())
|
agent_name,
|
||||||
{
|
tx,
|
||||||
emit_event(
|
event_log,
|
||||||
AgentEvent::Thinking {
|
log_writer,
|
||||||
story_id: story_id.to_string(),
|
);
|
||||||
agent_name: agent_name.to_string(),
|
|
||||||
text: thinking.to_string(),
|
|
||||||
},
|
|
||||||
tx,
|
|
||||||
event_log,
|
|
||||||
log_writer,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
|
||||||
emit_event(
|
|
||||||
AgentEvent::Output {
|
|
||||||
story_id: story_id.to_string(),
|
|
||||||
agent_name: agent_name.to_string(),
|
|
||||||
text: text.to_string(),
|
|
||||||
},
|
|
||||||
tx,
|
|
||||||
event_log,
|
|
||||||
log_writer,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Complete assistant events are skipped for content extraction
|
||||||
|
// because thinking and text already arrived via stream_event.
|
||||||
|
// The raw JSON is still forwarded as AgentJson below.
|
||||||
|
"assistant" | "user" | "result" => {}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4994,6 +5036,93 @@ stage = "qa"
|
|||||||
assert_eq!(entries[0].event["status"], "running");
|
assert_eq!(entries[0].event["status"], "running");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── bug 167: handle_agent_stream_event routes thinking/text correctly ───
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_event_thinking_delta_emits_thinking_event() {
|
||||||
|
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
|
||||||
|
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
let event = serde_json::json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": {"type": "thinking_delta", "thinking": "Let me analyze this..."}
|
||||||
|
});
|
||||||
|
|
||||||
|
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
|
||||||
|
|
||||||
|
let received = rx.try_recv().unwrap();
|
||||||
|
match received {
|
||||||
|
AgentEvent::Thinking {
|
||||||
|
story_id,
|
||||||
|
agent_name,
|
||||||
|
text,
|
||||||
|
} => {
|
||||||
|
assert_eq!(story_id, "s1");
|
||||||
|
assert_eq!(agent_name, "coder-1");
|
||||||
|
assert_eq!(text, "Let me analyze this...");
|
||||||
|
}
|
||||||
|
other => panic!("Expected Thinking event, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_event_text_delta_emits_output_event() {
|
||||||
|
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
|
||||||
|
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
let event = serde_json::json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": {"type": "text_delta", "text": "Here is the result."}
|
||||||
|
});
|
||||||
|
|
||||||
|
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
|
||||||
|
|
||||||
|
let received = rx.try_recv().unwrap();
|
||||||
|
match received {
|
||||||
|
AgentEvent::Output {
|
||||||
|
story_id,
|
||||||
|
agent_name,
|
||||||
|
text,
|
||||||
|
} => {
|
||||||
|
assert_eq!(story_id, "s1");
|
||||||
|
assert_eq!(agent_name, "coder-1");
|
||||||
|
assert_eq!(text, "Here is the result.");
|
||||||
|
}
|
||||||
|
other => panic!("Expected Output event, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_event_input_json_delta_ignored() {
|
||||||
|
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
|
||||||
|
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
let event = serde_json::json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"delta": {"type": "input_json_delta", "partial_json": "{\"file\":"}
|
||||||
|
});
|
||||||
|
|
||||||
|
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
|
||||||
|
|
||||||
|
// No event should be emitted for tool argument deltas
|
||||||
|
assert!(rx.try_recv().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_event_non_delta_type_ignored() {
|
||||||
|
let (tx, mut rx) = broadcast::channel::<AgentEvent>(64);
|
||||||
|
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
let event = serde_json::json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {"role": "assistant"}
|
||||||
|
});
|
||||||
|
|
||||||
|
handle_agent_stream_event(&event, "s1", "coder-1", &tx, &event_log, None);
|
||||||
|
|
||||||
|
assert!(rx.try_recv().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
// ── bug 118: pending entry cleanup on start_agent failure ────────────────
|
// ── bug 118: pending entry cleanup on start_agent failure ────────────────
|
||||||
|
|
||||||
/// Regression test for bug 118: when worktree creation fails (e.g. because
|
/// Regression test for bug 118: when worktree creation fails (e.g. because
|
||||||
|
|||||||
Reference in New Issue
Block a user