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>) {