From 2b5766aaf33ec9002e81ae2e11e2b1aff1cd373a Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 24 Feb 2026 19:35:06 +0000 Subject: [PATCH] story-kit: merge 167_bug_thinking_trace_height_constraint_not_working_in_web_ui --- server/src/agents.rs | 197 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 163 insertions(+), 34 deletions(-) diff --git a/server/src/agents.rs b/server/src/agents.rs index f59b0ad..f96b90b 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -3171,6 +3171,60 @@ async fn run_agent_pty_streaming( .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, + event_log: &Mutex>, + log_writer: Option<&Mutex>, +) { + 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. fn emit_event( event: AgentEvent, @@ -3229,6 +3283,11 @@ fn run_agent_pty_blocking( cmd.arg("--output-format"); cmd.arg("stream-json"); 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 cmd.arg("--permission-mode"); @@ -3358,42 +3417,25 @@ fn run_agent_pty_blocking( .and_then(|s| s.as_str()) .map(|s| s.to_string()); } - "assistant" => { - if let Some(message) = json.get("message") - && let Some(content) = message.get("content").and_then(|c| c.as_array()) - { - for block in content { - let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or(""); - if block_type == "thinking" { - if let Some(thinking) = - block.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, - ); - } - } 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, - ); - } - } + // With --include-partial-messages, thinking and text arrive + // incrementally via stream_event → content_block_delta. Handle + // them here for real-time streaming to the frontend. + "stream_event" => { + if let Some(event) = json.get("event") { + handle_agent_stream_event( + event, + story_id, + agent_name, + 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"); } + // ── 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::(64); + let event_log: Mutex> = 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::(64); + let event_log: Mutex> = 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::(64); + let event_log: Mutex> = 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::(64); + let event_log: Mutex> = 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 ──────────────── /// Regression test for bug 118: when worktree creation fails (e.g. because