v1.5.0: Inline subagent panels, Bedrock compaction fixes, context window debug view

New features:
- Inline subagent panels with expand/collapse and click-to-toggle
- /context slash command to inspect bedrock_message_history
- Child-to-parent question routing with auto-answer for subagents
- Subagent token usage and cost merging into parent conversation
- Randomized session-colored user avatar silhouettes

Bug fixes:
- Bedrock: remove orphaned tool_results after compaction
- Bedrock: self-healing exchange lookup for out-of-order streaming
- Bedrock: append continuation prompt when conversation ends with assistant
- Duration sanity check rejects epoch-time artifacts from session restore
- Cache hit rate calculation uses actual total_input_tokens
- Hide "Time to first token" when value is zero

Improvements:
- Demote verbose bedrock-debug logs to debug/trace levels
- Bedrock tool usage counting falls back to action counting
- Remove logout menu item from workspace menu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ward
2026-05-21 14:30:04 -05:00
co-authored by Claude Opus 4.6
parent 6f54e2cb30
commit f278e53b7e
22 changed files with 914 additions and 113 deletions
+147
View File
@@ -430,8 +430,155 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
/// Call this on the combined (history + new input) messages before
/// sending to `build_converse_request`.
pub fn sanitize_messages_for_bedrock(messages: &mut Vec<ConversationMessage>) {
remove_orphaned_tool_results(messages);
ensure_starts_with_user_message(messages);
ensure_tool_results_paired(messages);
ensure_ends_with_user_message(messages);
}
/// Bedrock requires the conversation to end with a user message.
/// If the last message is an assistant message (e.g. after compaction),
/// append a continuation prompt.
fn ensure_ends_with_user_message(messages: &mut Vec<ConversationMessage>) {
if messages.last().is_some_and(|m| m.role == MessageRole::Assistant) {
log::info!("[bedrock] Appending continuation prompt (conversation ended with assistant message)");
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Continue.".to_string()),
});
}
}
/// Removes tool_result content that references tool_use IDs not present in any
/// preceding assistant message. This happens after compaction when the history
/// is replaced with a summary but the next request still carries tool_results
/// from the old (now-discarded) exchanges.
fn remove_orphaned_tool_results(messages: &mut Vec<ConversationMessage>) {
use std::collections::HashSet;
// Collect all tool_use IDs from assistant messages.
let mut valid_tool_use_ids = HashSet::new();
for msg in messages.iter() {
if msg.role == MessageRole::Assistant {
collect_tool_use_ids_into(&msg.content, &mut valid_tool_use_ids);
}
}
if valid_tool_use_ids.is_empty() {
// No tool_use in history — remove ALL tool_results from user messages.
let before_count = messages.len();
messages.retain(|msg| {
if msg.role != MessageRole::User {
return true;
}
!is_pure_tool_result(&msg.content)
});
// Also strip tool_result parts from MultiPart user messages.
for msg in messages.iter_mut() {
if msg.role != MessageRole::User {
continue;
}
strip_tool_result_parts(&mut msg.content);
}
if messages.len() != before_count {
log::info!(
"[bedrock] Removed {} orphaned tool_result message(s) (no tool_use in history)",
before_count - messages.len()
);
}
return;
}
// Remove tool_results whose IDs aren't in valid_tool_use_ids.
for msg in messages.iter_mut() {
if msg.role != MessageRole::User {
continue;
}
strip_orphaned_tool_result_parts(&mut msg.content, &valid_tool_use_ids);
}
// Remove messages that became empty after stripping.
messages.retain(|msg| !is_empty_content(&msg.content));
}
fn is_pure_tool_result(content: &MessageContent) -> bool {
matches!(content, MessageContent::ToolResult { .. })
}
fn strip_tool_result_parts(content: &mut MessageContent) {
if let MessageContent::MultiPart(parts) = content {
parts.retain(|p| !matches!(p, ContentPart::ToolResult { .. }));
if parts.len() == 1 {
let part = parts.remove(0);
*content = match part {
ContentPart::Text(t) => MessageContent::Text(t),
ContentPart::ToolUse { tool_use_id, name, input } => {
MessageContent::ToolUse { tool_use_id, name, input }
}
ContentPart::ToolResult { tool_use_id, content: c, is_error } => {
MessageContent::ToolResult { tool_use_id, content: c, is_error }
}
};
}
}
}
fn strip_orphaned_tool_result_parts(
content: &mut MessageContent,
valid_ids: &std::collections::HashSet<String>,
) {
match content {
MessageContent::ToolResult { tool_use_id, .. } => {
if !valid_ids.contains(tool_use_id) {
*content = MessageContent::Text(String::new());
}
}
MessageContent::MultiPart(parts) => {
parts.retain(|p| match p {
ContentPart::ToolResult { tool_use_id, .. } => valid_ids.contains(tool_use_id),
_ => true,
});
if parts.len() == 1 {
let part = parts.remove(0);
*content = match part {
ContentPart::Text(t) => MessageContent::Text(t),
ContentPart::ToolUse { tool_use_id, name, input } => {
MessageContent::ToolUse { tool_use_id, name, input }
}
ContentPart::ToolResult { tool_use_id, content: c, is_error } => {
MessageContent::ToolResult { tool_use_id, content: c, is_error }
}
};
}
}
_ => {}
}
}
fn is_empty_content(content: &MessageContent) -> bool {
match content {
MessageContent::Text(t) => t.is_empty(),
MessageContent::MultiPart(parts) => parts.is_empty(),
_ => false,
}
}
fn collect_tool_use_ids_into(content: &MessageContent, ids: &mut std::collections::HashSet<String>) {
match content {
MessageContent::ToolUse { tool_use_id, .. } => {
ids.insert(tool_use_id.clone());
}
MessageContent::MultiPart(parts) => {
for p in parts {
if let ContentPart::ToolUse { tool_use_id, .. } = p {
ids.insert(tool_use_id.clone());
}
}
}
_ => {}
}
}
fn ensure_starts_with_user_message(messages: &mut Vec<ConversationMessage>) {
+10 -6
View File
@@ -124,10 +124,10 @@ pub fn bedrock_stream_to_response_events(
event_count += 1;
match event {
StreamEvent::MessageStart(_) => {
log::info!("[bedrock-debug] Event #{event_count}: MessageStart");
log::debug!("[bedrock] Event #{event_count}: MessageStart");
}
StreamEvent::ContentBlockStart(block_start) => {
log::info!("[bedrock-debug] Event #{event_count}: ContentBlockStart");
log::debug!("[bedrock] Event #{event_count}: ContentBlockStart");
if let Some(start) = block_start.start() {
match start {
ContentBlockStart::ToolUse(tool_start) => {
@@ -166,11 +166,11 @@ pub fn bedrock_stream_to_response_events(
}
}
StreamEvent::ContentBlockDelta(delta) => {
log::info!("[bedrock-debug] Event #{event_count}: ContentBlockDelta");
log::trace!("[bedrock] Event #{event_count}: ContentBlockDelta");
if let Some(d) = delta.delta() {
match d {
ContentBlockDelta::Text(text) => {
log::info!("[bedrock-debug] Event #{event_count}: TextDelta ({} chars): {:?}", text.len(), &text[..text.len().min(80)]);
log::debug!("[bedrock] TextDelta ({} chars)", text.len());
history_text.push_str(text);
if text_flushed {
let msg_id = current_text_message_id.as_ref().unwrap();
@@ -182,6 +182,10 @@ pub fn bedrock_stream_to_response_events(
yield Ok(append);
} else {
buffered_text.push_str(text);
// Buffer a few initial deltas so the first
// AddMessagesToTask carries enough content for
// the exchange to be fully registered before
// subsequent AppendToMessageContent events arrive.
if buffered_text.len() >= 1 {
let msg_id = Uuid::new_v4().to_string();
current_text_message_id = Some(msg_id.clone());
@@ -209,7 +213,7 @@ pub fn bedrock_stream_to_response_events(
}
}
StreamEvent::ContentBlockStop(_) => {
log::info!("[bedrock-debug] Event #{event_count}: ContentBlockStop (tool_use_id={:?})", if current_tool_use_id.is_empty() { "none" } else { &current_tool_use_id });
log::debug!("[bedrock] Event #{event_count}: ContentBlockStop (tool_use_id={:?})", if current_tool_use_id.is_empty() { "none" } else { &current_tool_use_id });
if !current_tool_use_id.is_empty() {
// Skip suggest_next_prompt — its executor hangs forever
// waiting for UI interaction that doesn't exist in the
@@ -382,7 +386,7 @@ pub fn bedrock_stream_to_response_events(
&model_id,
);
log::info!(
"[bedrock] Stream finished: model={model_id}, input_tokens={input_tokens}, output_tokens={output_tokens}, cache_read={cache_read_input_tokens}, cache_write={cache_write_input_tokens}, cost_cents={cost:.4}"
"[bedrock] Stream finished: {event_count} events, model={model_id}, input_tokens={input_tokens}, output_tokens={output_tokens}, cache_read={cache_read_input_tokens}, cache_write={cache_write_input_tokens}, cost_cents={cost:.4}"
);
// Build and store the assistant message into bedrock_messages_sent