story-kit: start 147_bug_activity_indicator_still_only_shows_thinking_despite_bug_140_fix

This commit is contained in:
Dave
2026-02-24 15:26:39 +00:00
parent 5556296ddf
commit 03ca8624cd
4 changed files with 313 additions and 33 deletions

View File

@@ -96,6 +96,13 @@ impl ClaudeCodeProvider {
}
}
// Drain any remaining activity messages that were buffered when the
// token channel closed. The select! loop breaks on token_rx → None,
// but activity_rx may still hold signals sent in the same instant.
while let Ok(name) = activity_rx.try_recv() {
on_activity(&name);
}
pty_handle
.await
.map_err(|e| format!("PTY task panicked: {e}"))??;
@@ -154,6 +161,11 @@ fn run_pty_session(
cmd.arg("--output-format");
cmd.arg("stream-json");
cmd.arg("--verbose");
// Enable partial streaming events so we receive stream_event messages
// containing raw API events (content_block_start, content_block_delta,
// etc.). Without this flag, only complete assistant/user/result events
// are emitted and tool-start activity signals never fire.
cmd.arg("--include-partial-messages");
// Delegate permission decisions to the MCP prompt_permission tool.
// Claude Code will call this tool via the story-kit MCP server when
// a tool requires user approval, instead of using PTY stdin/stdout.
@@ -166,7 +178,7 @@ fn run_pty_session(
cmd.env("CLAUDECODE", "");
slog!(
"[pty-debug] Spawning: claude -p \"{}\" {} --output-format stream-json --verbose --permission-prompt-tool mcp__story-kit__prompt_permission",
"[pty-debug] Spawning: claude -p \"{}\" {} --output-format stream-json --verbose --include-partial-messages --permission-prompt-tool mcp__story-kit__prompt_permission",
user_message,
resume_session_id
.map(|s| format!("--resume {s}"))
@@ -255,39 +267,18 @@ fn run_pty_session(
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
// Check if child has exited
if let Ok(Some(_status)) = child.try_wait() {
// Drain remaining lines
// Drain remaining lines through the same dispatch path
// (process_json_event) so activity signals fire correctly.
while let Ok(Some(line)) = line_rx.try_recv() {
let trimmed = line.trim();
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
&& let Some(event_type) =
json.get("type").and_then(|t| t.as_str())
{
match event_type {
"stream_event" => {
if let Some(event) = json.get("event") {
handle_stream_event(event, &token_tx, &activity_tx);
}
}
"assistant" => {
if let Some(message) = json.get("message")
&& let Some(content) = message
.get("content")
.and_then(|c| c.as_array())
{
parse_assistant_message(content, &msg_tx);
}
}
"user" => {
if let Some(message) = json.get("message")
&& let Some(content) = message
.get("content")
.and_then(|c| c.as_array())
{
parse_tool_results(content, &msg_tx);
}
}
_ => {}
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
process_json_event(
&json,
&token_tx,
&activity_tx,
&msg_tx,
&mut sid_tx,
);
}
}
break;
@@ -356,6 +347,17 @@ fn process_json_event(
if let Some(message) = json.get("message")
&& let Some(content) = message.get("content").and_then(|c| c.as_array())
{
// Fire activity signals for tool_use blocks as a fallback path.
// The primary path is via stream_event → content_block_start (real-time),
// but assistant events also carry tool_use blocks and serve as a reliable
// backup if stream_event delivery is delayed or missed.
for block in content {
if block.get("type").and_then(|t| t.as_str()) == Some("tool_use")
&& let Some(name) = block.get("name").and_then(|n| n.as_str())
{
let _ = activity_tx.send(name.to_string());
}
}
parse_assistant_message(content, msg_tx);
}
false
@@ -943,6 +945,105 @@ mod tests {
assert_eq!(tokens, vec!["word"]);
}
#[test]
fn process_json_event_stream_event_tool_use_fires_activity() {
// This is the primary activity path: stream_event wrapping content_block_start
// with a tool_use block. Requires --include-partial-messages to be enabled.
let (tok_tx, _tok_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = make_channels();
let mut sid_tx = None::<tokio::sync::oneshot::Sender<String>>;
let json = json!({
"type": "stream_event",
"session_id": "s1",
"event": {
"type": "content_block_start",
"index": 1,
"content_block": {"type": "tool_use", "id": "toolu_abc", "name": "Bash", "input": {}}
}
});
assert!(!process_json_event(&json, &tok_tx, &act_tx, &msg_tx, &mut sid_tx));
drop(act_tx);
let activities: Vec<String> = {
let mut v = vec![];
while let Ok(a) = act_rx.try_recv() {
v.push(a);
}
v
};
assert_eq!(activities, vec!["Bash"]);
}
#[test]
fn process_json_event_assistant_with_tool_use_fires_activity() {
let (tok_tx, _tok_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = make_channels();
let mut sid_tx = None::<tokio::sync::oneshot::Sender<String>>;
let json = json!({
"type": "assistant",
"message": {
"content": [
{"type": "text", "text": "Let me read that file."},
{"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {"file_path": "/foo.rs"}}
]
}
});
assert!(!process_json_event(&json, &tok_tx, &act_tx, &msg_tx, &mut sid_tx));
drop(act_tx);
let activities: Vec<String> = {
let mut v = vec![];
while let Ok(a) = act_rx.try_recv() {
v.push(a);
}
v
};
assert_eq!(activities, vec!["Read"]);
}
#[test]
fn process_json_event_assistant_with_multiple_tool_uses_fires_all_activities() {
let (tok_tx, _tok_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = make_channels();
let mut sid_tx = None::<tokio::sync::oneshot::Sender<String>>;
let json = json!({
"type": "assistant",
"message": {
"content": [
{"type": "tool_use", "id": "id1", "name": "Glob", "input": {}},
{"type": "tool_use", "id": "id2", "name": "Bash", "input": {}}
]
}
});
assert!(!process_json_event(&json, &tok_tx, &act_tx, &msg_tx, &mut sid_tx));
drop(act_tx);
let activities: Vec<String> = {
let mut v = vec![];
while let Ok(a) = act_rx.try_recv() {
v.push(a);
}
v
};
assert_eq!(activities, vec!["Glob", "Bash"]);
}
#[test]
fn process_json_event_assistant_text_only_no_activity() {
let (tok_tx, _tok_rx, act_tx, mut act_rx, msg_tx, _msg_rx) = make_channels();
let mut sid_tx = None::<tokio::sync::oneshot::Sender<String>>;
let json = json!({
"type": "assistant",
"message": {
"content": [{"type": "text", "text": "Just text, no tools."}]
}
});
assert!(!process_json_event(&json, &tok_tx, &act_tx, &msg_tx, &mut sid_tx));
drop(act_tx);
let activities: Vec<String> = {
let mut v = vec![];
while let Ok(a) = act_rx.try_recv() {
v.push(a);
}
v
};
assert!(activities.is_empty());
}
#[test]
fn process_json_event_assistant_event_parses_message() {
let (tok_tx, _tok_rx, act_tx, _act_rx, msg_tx, msg_rx) = make_channels();