Compare commits

..

1 Commits

Author SHA1 Message Date
Claude
20c5837683 docs: Add bug report for duplicate tool_result blocks issue
Documents API error where multiple tool_result blocks are sent with
the same tool_use_id when a tool fails. The "Sibling tool call errored"
message is incorrectly being sent for the same tool that failed,
rather than only for other sibling tools in a parallel batch.

Includes:
- Root cause analysis
- Example JSON from reported incidents
- Suggested fix locations and pseudocode
- Related request IDs for debugging
2025-12-08 01:19:25 +00:00
7 changed files with 130 additions and 333 deletions

View File

@@ -0,0 +1,123 @@
# Bug Report: Duplicate tool_result blocks with same tool_use_id
## Summary
When a tool call fails (particularly observed with the Tmux tool), two `tool_result` blocks are incorrectly sent to the API with the same `tool_use_id`, causing an API error:
```
API Error: 400
{"type":"error","error":{"type":"invalid_request_error","message":"messages.X.content.1: each tool_use must have a single result. Found multiple `tool_result` blocks with id: toolu_XXX"}}
```
## Root Cause
The parallel tool execution error handling code sends a "Sibling tool call errored" message even for the tool that actually failed, when it should only send this message for OTHER sibling tools in the parallel batch.
## Reproduction
1. Execute a tool call that will fail (e.g., Tmux select-pane on a non-existent window)
2. Observe two tool_result messages being generated:
- First: The actual error result ("Window not found...")
- Second: A "<tool_use_error>Sibling tool call errored</tool_use_error>" message with the SAME tool_use_id
## Example JSON (from reported incident)
```json
// Assistant requests tool use
{
"type": "tool_use",
"id": "toolu_01K2K2KFvUrwA9PaHGp652zW",
"name": "Tmux",
"input": {"args": ["select-pane", "-t", "test-perms3:0.0"]}
}
// First tool_result (correct - actual error)
{
"type": "user",
"message": {
"role": "user",
"content": [{
"tool_use_id": "toolu_01K2K2KFvUrwA9PaHGp652zW",
"type": "tool_result",
"content": "Window not found. The specified window may not exist.",
"is_error": true
}]
}
}
// Second tool_result (BUG - same tool_use_id!)
{
"type": "user",
"message": {
"role": "user",
"content": [{
"type": "tool_result",
"content": "<tool_use_error>Sibling tool call errored</tool_use_error>",
"is_error": true,
"tool_use_id": "toolu_01K2K2KFvUrwA9PaHGp652zW"
}]
}
}
```
## Expected Behavior
- When a tool fails, only ONE tool_result should be sent for that tool_use_id
- The "Sibling tool call errored" message should ONLY be sent for OTHER tool_use_ids in a parallel batch, not for the tool that actually errored
## Suggested Fix Location
The fix should be in the code that handles parallel tool execution errors:
```typescript
// Pseudocode for the fix
function handleParallelToolResults(toolUseIds: string[], failedToolId: string, error: Error) {
const results = [];
for (const id of toolUseIds) {
if (id === failedToolId) {
// Send actual error for the failed tool
results.push({
tool_use_id: id,
type: "tool_result",
content: error.message,
is_error: true
});
} else {
// Only send sibling error for OTHER tools
results.push({
tool_use_id: id,
type: "tool_result",
content: "<tool_use_error>Sibling tool call errored</tool_use_error>",
is_error: true
});
}
}
return results;
}
```
Additionally, a deduplication check before sending to the API would prevent this:
```typescript
function deduplicateToolResults(messages: Message[]): Message[] {
const seenToolUseIds = new Set<string>();
return messages.filter(msg => {
if (msg.type === "tool_result") {
if (seenToolUseIds.has(msg.tool_use_id)) {
return false; // Skip duplicate
}
seenToolUseIds.add(msg.tool_use_id);
}
return true;
});
}
```
## Related CHANGELOG Fixes
Similar issues have been fixed before:
- v1.0.84: "Fix tool_use/tool_result id mismatch error when network is unstable"
- v2.0.0: "Hooks: Reduced PostToolUse 'tool_use' ids were found without 'tool_result' blocks errors"
- v2.0.22: "Fix bug causing duplicate permission prompts with parallel tool calls"
## Reported By
Issue reported via Slack with multiple reproductions showing the Tmux tool triggering this bug.
## Request IDs (for debugging)
- req_011CVt2KXhCGmrL2YN9sSZQP
- req_011CVtFYJLPxn9oB9FEkCnRr
- req_011CVtG1PREveTLikakXGEdn

View File

@@ -1,68 +1,5 @@
# Changelog
## 2.0.69
- Minor bugfixes
## 2.0.68
- Fixed IME (Input Method Editor) support for languages like Chinese, Japanese, and Korean by correctly positioning the composition window at the cursor
- Fixed a bug where disallowed MCP tools were visible to the model
- Fixed an issue where steering messages could be lost while a subagent is working
- Fixed Option+Arrow word navigation treating entire CJK (Chinese, Japanese, Korean) text sequences as a single word instead of navigating by word boundaries
- Improved plan mode exit UX: show simplified yes/no dialog when exiting with empty or missing plan instead of throwing an error
- Add support for enterprise managed settings. Contact your Anthropic account team to enable this feature.
## 2.0.67
- Thinking mode is now enabled by default for Opus 4.5
- Thinking mode configuration has moved to /config
- Added search functionality to `/permissions` command with `/` keyboard shortcut for filtering rules by tool name
- Show reason why autoupdater is disabled in `/doctor`
- Fixed false "Another process is currently updating Claude" error when running `claude update` while another instance is already on the latest version
- Fixed MCP servers from `.mcp.json` being stuck in pending state when running in non-interactive mode (`-p` flag or piped input)
- Fixed scroll position resetting after deleting a permission rule in `/permissions`
- Fixed word deletion (opt+delete) and word navigation (opt+arrow) not working correctly with non-Latin text such as Cyrillic, Greek, Arabic, Hebrew, Thai, and Chinese
- Fixed `claude install --force` not bypassing stale lock files
- Fixed consecutive @~/ file references in CLAUDE.md being incorrectly parsed due to markdown strikethrough interference
- Windows: Fixed plugin MCP servers failing due to colons in log directory paths
## 2.0.65
- Added ability to switch models while writing a prompt using alt+p (linux, windows), option+p (macos).
- Added context window information to status line input
- Added `fileSuggestion` setting for custom `@` file search commands
- Added `CLAUDE_CODE_SHELL` environment variable to override automatic shell detection (useful when login shell differs from actual working shell)
- Fixed prompt not being saved to history when aborting a query with Escape
- Fixed Read tool image handling to identify format from bytes instead of file extension
## 2.0.64
- Made auto-compacting instant
- Agents and bash commands can run asynchronously and send messages to wake up the main agent
- /stats now provides users with interesting CC stats, such as favorite model, usage graph, usage streak
- Added named session support: use `/rename` to name sessions, `/resume <name>` in REPL or `claude --resume <name>` from the terminal to resume them
- Added support for .claude/rules/`. See https://code.claude.com/docs/en/memory for details.
- Added image dimension metadata when images are resized, enabling accurate coordinate mappings for large images
- Fixed auto-loading .env when using native installer
- Fixed `--system-prompt` being ignored when using `--continue` or `--resume` flags
- Improved `/resume` screen with grouped forked sessions and keyboard shortcuts for preview (P) and rename (R)
- VSCode: Added copy-to-clipboard button on code blocks and bash tool inputs
- VSCode: Fixed extension not working on Windows ARM64 by falling back to x64 binary via emulation
- Bedrock: Improve efficiency of token counting
- Bedrock: Add support for `aws login` AWS Management Console credentials
- Unshipped AgentOutputTool and BashOutputTool, in favor of a new unified TaskOutputTool
## 2.0.62
- Added "(Recommended)" indicator for multiple-choice questions, with the recommended option moved to the top of the list
- Added `attribution` setting to customize commit and PR bylines (deprecates `includeCoAuthoredBy`)
- Fixed duplicate slash commands appearing when ~/.claude is symlinked to a project directory
- Fixed slash command selection not working when multiple commands share the same name
- Fixed an issue where skill files inside symlinked skill directories could become circular symlinks
- Fixed running versions getting removed because lock file incorrectly going stale
- Fixed IDE diff tab not closing when rejecting file changes
## 2.0.61
- Reverted VSCode support for multiple terminal clients due to responsiveness issues.

View File

@@ -93,6 +93,11 @@ Found 3 issues:
<link to file and line with full sha1 + line range for context>
🤖 Generated with [Claude Code](https://claude.ai/code)
<sub>- If this code review was useful, please react with 👍. Otherwise, react with 👎.</sub>
---
- Or, if you found no issues:
@@ -103,6 +108,8 @@ Found 3 issues:
No issues found. Checked for bugs and CLAUDE.md compliance.
🤖 Generated with [Claude Code](https://claude.ai/code)
---
- When linking to code, follow the following format precisely, otherwise the Markdown preview won't render correctly: https://github.com/anthropics/claude-code/blob/c21d3c10bc8e898b7ac1a2d745bdc9bc4e423afe/package.json#L10-L15

View File

@@ -44,17 +44,6 @@
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/notification.py",
"timeout": 10
}
]
}
]
}
}

View File

@@ -1,143 +0,0 @@
#!/usr/bin/env python3
"""Notification hook executor for hookify plugin.
This script is called by Claude Code when notifications are sent.
It formats teammate idle notifications and other IPC messages for display.
"""
import os
import sys
import json
from datetime import datetime
# CRITICAL: Add plugin root to Python path for imports
PLUGIN_ROOT = os.environ.get('CLAUDE_PLUGIN_ROOT')
if PLUGIN_ROOT:
parent_dir = os.path.dirname(PLUGIN_ROOT)
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
if PLUGIN_ROOT not in sys.path:
sys.path.insert(0, PLUGIN_ROOT)
def format_idle_notification(data: dict) -> str:
"""Format an idle notification for display.
Args:
data: The notification data containing type, from, timestamp, etc.
Returns:
Formatted string for display
"""
worker_name = data.get('from', 'worker')
timestamp = data.get('timestamp', '')
# Format timestamp if present
time_str = ''
if timestamp:
try:
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
time_str = dt.strftime('%H:%M:%S')
except (ValueError, AttributeError):
time_str = ''
# Build the formatted output using the suggested format
lines = [f"{worker_name}"]
if time_str:
lines.append(f" ⎿ Status is idle ({time_str})")
else:
lines.append(" ⎿ Status is idle")
return '\n'.join(lines)
def format_notification(notification_content: str) -> dict:
"""Parse and format a notification message.
Args:
notification_content: Raw notification content (may be JSON or plain text)
Returns:
Dict with formatted systemMessage
"""
# Try to parse as JSON first
try:
data = json.loads(notification_content)
# Check if this is an idle notification
if isinstance(data, dict) and data.get('type') == 'idle_notification':
formatted = format_idle_notification(data)
return {"systemMessage": formatted}
# Handle other notification types
notification_type = data.get('type', '') if isinstance(data, dict) else ''
if notification_type == 'status_update':
worker = data.get('from', 'worker')
status = data.get('status', 'unknown')
return {"systemMessage": f"{worker}\n ⎿ Status: {status}"}
if notification_type == 'progress_update':
worker = data.get('from', 'worker')
progress = data.get('progress', '')
return {"systemMessage": f"{worker}\n{progress}"}
# For unknown JSON types, still try to format nicely
if isinstance(data, dict) and 'from' in data:
worker = data.get('from', 'worker')
msg = data.get('message', data.get('status', 'update'))
return {"systemMessage": f"{worker}\n{msg}"}
except (json.JSONDecodeError, TypeError):
# Not JSON, return as-is
pass
return {}
def main():
"""Main entry point for Notification hook."""
try:
# Read input from stdin
input_data = json.load(sys.stdin)
# Get notification content
notification = input_data.get('notification', '')
# Also check for raw notification data in the input
if not notification and input_data.get('type') == 'idle_notification':
# The input itself is an idle notification
formatted = format_idle_notification(input_data)
result = {"systemMessage": formatted}
elif notification:
# Format the notification content
result = format_notification(notification)
else:
# Check if the input looks like an IPC message
if input_data.get('type') in ['idle_notification', 'status_update', 'progress_update']:
if input_data.get('type') == 'idle_notification':
formatted = format_idle_notification(input_data)
result = {"systemMessage": formatted}
else:
worker = input_data.get('from', 'worker')
status = input_data.get('status', input_data.get('type', 'update'))
result = {"systemMessage": f"{worker}\n{status}"}
else:
result = {}
# Always output JSON
print(json.dumps(result), file=sys.stdout)
except Exception as e:
error_output = {
"systemMessage": f"Notification format error: {str(e)}"
}
print(json.dumps(error_output), file=sys.stdout)
finally:
# ALWAYS exit 0
sys.exit(0)
if __name__ == '__main__':
main()

View File

@@ -1,50 +0,0 @@
#!/bin/bash
# Example: Format teammate idle notification
#
# This script demonstrates how to format raw JSON idle notifications
# into user-friendly display format.
#
# Usage: echo '{"type":"idle_notification","from":"worker-1","timestamp":"..."}' | ./format-idle-notification.sh
set -euo pipefail
# Read JSON from stdin
input=$(cat)
# Parse notification type
notification_type=$(echo "$input" | jq -r '.type // empty' 2>/dev/null || echo "")
if [[ "$notification_type" == "idle_notification" ]]; then
# Extract fields
worker_name=$(echo "$input" | jq -r '.from // "worker"')
timestamp=$(echo "$input" | jq -r '.timestamp // empty')
# Format timestamp if present
time_str=""
if [[ -n "$timestamp" ]]; then
# Try to format the timestamp
time_str=$(date -d "$timestamp" '+%H:%M:%S' 2>/dev/null || echo "")
fi
# Output formatted notification using recommended format:
# ⏺ worker-1
# ⎿ Status is idle
echo "$worker_name"
if [[ -n "$time_str" ]]; then
echo " ⎿ Status is idle ($time_str)"
else
echo " ⎿ Status is idle"
fi
# Output JSON for hook system
if [[ -n "$time_str" ]]; then
jq -n --arg msg "$worker_name\n ⎿ Status is idle ($time_str)" \
'{"systemMessage": $msg}'
else
jq -n --arg msg "$worker_name\n ⎿ Status is idle" \
'{"systemMessage": $msg}'
fi
else
# Not an idle notification, pass through
echo "$input"
fi

View File

@@ -344,69 +344,3 @@ fi
- Per-project settings
- Team-specific rules
- Dynamic validation criteria
## Pattern 11: Format Teammate Idle Notifications
Format raw JSON IPC messages from workers/teammates into user-friendly display:
```json
{
"Notification": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/notification.py",
"timeout": 10
}
]
}
]
}
```
**Example script (format-idle-notification.py):**
```python
#!/usr/bin/env python3
import sys
import json
def format_idle_notification(data):
"""Format idle notification for display."""
worker_name = data.get('from', 'worker')
# Output format:
# ⏺ worker-1
# ⎿ Status is idle
return f"{worker_name}\n ⎿ Status is idle"
def main():
input_data = json.load(sys.stdin)
# Check for idle notification
if input_data.get('type') == 'idle_notification':
formatted = format_idle_notification(input_data)
print(json.dumps({"systemMessage": formatted}))
else:
print(json.dumps({}))
if __name__ == '__main__':
main()
```
**Input (raw JSON IPC message):**
```json
{"type": "idle_notification", "from": "worker-1", "timestamp": "2025-12-15T05:22:40.320Z"}
```
**Output (formatted for display):**
```
⏺ worker-1
⎿ Status is idle
```
**Use for:**
- Formatting teammate/worker status messages
- Converting internal IPC messages to user-friendly display
- Multi-agent swarm coordination UI
- Any notification that shouldn't show raw JSON to users