fix: resolve false-positive rate limit and one-message-behind in chat sessions

The Claude Code CLI v2.1.45+ emits a `rate_limit_event` message type that
the Python SDK v0.1.19 cannot parse, raising MessageParseError. Two bugs
resulted:

1. **False-positive rate limit**: check_rate_limit_error() matched
   "rate_limit" in the exception string "Unknown message type:
   rate_limit_event" via both an explicit type check and a regex fallback,
   triggering 15-19s backoff + query re-send on every session.

2. **One-message-behind**: The MessageParseError killed the
   receive_response() async generator, but the CLI subprocess was still
   alive with buffered response data. Catching and returning meant the
   response was never consumed. The next send_message() would read the
   previous response first, creating a one-behind offset.

Changes:

- chat_constants.py: check_rate_limit_error() now returns (False, None)
  for any MessageParseError, blocking both false-positive paths. Added
  safe_receive_response() helper that retries receive_response() on
  MessageParseError — the SDK's decoupled producer/consumer architecture
  (anyio memory channel) allows the new generator to continue reading
  remaining messages without data loss. Removed calculate_rate_limit_backoff
  re-export and MAX_CHAT_RATE_LIMIT_RETRIES constant.

- spec_chat_session.py, assistant_chat_session.py, expand_chat_session.py:
  Replaced retry-with-backoff loops with safe_receive_response() wrapper.
  Removed asyncio.sleep backoff, query re-send, and rate_limited yield.
  Cleaned up unused imports (asyncio, calculate_rate_limit_backoff,
  MAX_CHAT_RATE_LIMIT_RETRIES).

- agent.py: Added inner retry loop around receive_response() with same
  MessageParseError skip-and-restart pattern. Removed early-return that
  truncated responses.

- types.ts: Removed SpecChatRateLimitedMessage,
  AssistantChatRateLimitedMessage, and their union entries.

- useSpecChat.ts, useAssistantChat.ts, useExpandChat.ts: Removed dead
  'rate_limited' case handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-02-23 13:00:16 +02:00
parent 9af0f309b7
commit 4f102e7bc2
9 changed files with 258 additions and 363 deletions

View File

@@ -74,46 +74,65 @@ async def run_agent_session(
await client.query(message) await client.query(message)
# Collect response text and show tool use # Collect response text and show tool use
# Retry receive_response() on MessageParseError — the SDK raises this for
# unknown CLI message types (e.g. "rate_limit_event") which kills the async
# generator. The subprocess is still alive so we restart to read remaining
# messages from the buffered channel.
response_text = "" response_text = ""
async for msg in client.receive_response(): max_parse_retries = 50
msg_type = type(msg).__name__ parse_retries = 0
while True:
try:
async for msg in client.receive_response():
msg_type = type(msg).__name__
# Handle AssistantMessage (text and tool use) # Handle AssistantMessage (text and tool use)
if msg_type == "AssistantMessage" and hasattr(msg, "content"): if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content: for block in msg.content:
block_type = type(block).__name__ block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"): if block_type == "TextBlock" and hasattr(block, "text"):
response_text += block.text response_text += block.text
print(block.text, end="", flush=True) print(block.text, end="", flush=True)
elif block_type == "ToolUseBlock" and hasattr(block, "name"): elif block_type == "ToolUseBlock" and hasattr(block, "name"):
print(f"\n[Tool: {block.name}]", flush=True) print(f"\n[Tool: {block.name}]", flush=True)
if hasattr(block, "input"): if hasattr(block, "input"):
input_str = str(block.input) input_str = str(block.input)
if len(input_str) > 200: if len(input_str) > 200:
print(f" Input: {input_str[:200]}...", flush=True) print(f" Input: {input_str[:200]}...", flush=True)
else: else:
print(f" Input: {input_str}", flush=True) print(f" Input: {input_str}", flush=True)
# Handle UserMessage (tool results) # Handle UserMessage (tool results)
elif msg_type == "UserMessage" and hasattr(msg, "content"): elif msg_type == "UserMessage" and hasattr(msg, "content"):
for block in msg.content: for block in msg.content:
block_type = type(block).__name__ block_type = type(block).__name__
if block_type == "ToolResultBlock": if block_type == "ToolResultBlock":
result_content = getattr(block, "content", "") result_content = getattr(block, "content", "")
is_error = getattr(block, "is_error", False) is_error = getattr(block, "is_error", False)
# Check if command was blocked by security hook # Check if command was blocked by security hook
if "blocked" in str(result_content).lower(): if "blocked" in str(result_content).lower():
print(f" [BLOCKED] {result_content}", flush=True) print(f" [BLOCKED] {result_content}", flush=True)
elif is_error: elif is_error:
# Show errors (truncated) # Show errors (truncated)
error_str = str(result_content)[:500] error_str = str(result_content)[:500]
print(f" [Error] {error_str}", flush=True) print(f" [Error] {error_str}", flush=True)
else: else:
# Tool succeeded - just show brief confirmation # Tool succeeded - just show brief confirmation
print(" [Done]", flush=True) print(" [Done]", flush=True)
break # Normal completion
except Exception as inner_exc:
if type(inner_exc).__name__ == "MessageParseError":
parse_retries += 1
if parse_retries > max_parse_retries:
print(f"Too many unrecognized CLI messages ({parse_retries}), stopping")
break
print(f"Ignoring unrecognized message from Claude CLI: {inner_exc}")
continue
raise # Re-raise to outer except
print("\n" + "-" * 70 + "\n") print("\n" + "-" * 70 + "\n")
return "continue", response_text return "continue", response_text

View File

@@ -7,7 +7,6 @@ The assistant can answer questions about the codebase and features
but cannot modify any files. but cannot modify any files.
""" """
import asyncio
import json import json
import logging import logging
import os import os
@@ -27,10 +26,9 @@ from .assistant_database import (
get_messages, get_messages,
) )
from .chat_constants import ( from .chat_constants import (
MAX_CHAT_RATE_LIMIT_RETRIES,
ROOT_DIR, ROOT_DIR,
calculate_rate_limit_backoff,
check_rate_limit_error, check_rate_limit_error,
safe_receive_response,
) )
# Load environment variables from .env file if present # Load environment variables from .env file if present
@@ -399,66 +397,47 @@ class AssistantChatSession:
full_response = "" full_response = ""
# Stream the response (with rate-limit retry) # Stream the response
for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1): try:
try: async for msg in safe_receive_response(self.client, logger):
async for msg in self.client.receive_response(): msg_type = type(msg).__name__
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"): if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content: for block in msg.content:
block_type = type(block).__name__ block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"): if block_type == "TextBlock" and hasattr(block, "text"):
text = block.text text = block.text
if text: if text:
full_response += text full_response += text
yield {"type": "text", "content": text} yield {"type": "text", "content": text}
elif block_type == "ToolUseBlock" and hasattr(block, "name"): elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name tool_name = block.name
tool_input = getattr(block, "input", {}) tool_input = getattr(block, "input", {})
# Intercept ask_user tool calls -> yield as question message # Intercept ask_user tool calls -> yield as question message
if tool_name == "mcp__features__ask_user": if tool_name == "mcp__features__ask_user":
questions = tool_input.get("questions", []) questions = tool_input.get("questions", [])
if questions: if questions:
yield { yield {
"type": "question", "type": "question",
"questions": questions, "questions": questions,
} }
continue continue
yield { yield {
"type": "tool_call", "type": "tool_call",
"tool": tool_name, "tool": tool_name,
"input": tool_input, "input": tool_input,
} }
# Completed successfully — break out of retry loop except Exception as exc:
break is_rate_limit, _ = check_rate_limit_error(exc)
except Exception as exc: if is_rate_limit:
is_rate_limit, retry_secs = check_rate_limit_error(exc) logger.warning(f"Rate limited: {exc}")
if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES: yield {"type": "error", "content": "Rate limited. Please try again later."}
delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt) return
logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s") raise
yield {
"type": "rate_limited",
"retry_in": delay,
"attempt": _attempt + 1,
"max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES,
}
await asyncio.sleep(delay)
await self.client.query(message)
continue
if is_rate_limit:
logger.error("Rate limit retries exhausted for assistant chat")
yield {"type": "error", "content": "Rate limited. Please try again later."}
return
# Non-rate-limit MessageParseError: log and break (don't crash)
if type(exc).__name__ == "MessageParseError":
logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
break
raise
# Store the complete response in the database # Store the complete response in the database
if full_response and self.conversation_id: if full_response and self.conversation_id:

View File

@@ -12,7 +12,7 @@ imports (``from .chat_constants import API_ENV_VARS``) continue to work.
import logging import logging
import sys import sys
from pathlib import Path from pathlib import Path
from typing import AsyncGenerator from typing import Any, AsyncGenerator
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Root directory of the autoforge project (repository root). # Root directory of the autoforge project (repository root).
@@ -33,15 +33,10 @@ if _root_str not in sys.path:
# imports continue to work unchanged. # imports continue to work unchanged.
# ------------------------------------------------------------------- # -------------------------------------------------------------------
from env_constants import API_ENV_VARS # noqa: E402, F401 from env_constants import API_ENV_VARS # noqa: E402, F401
from rate_limit_utils import calculate_rate_limit_backoff, is_rate_limit_error, parse_retry_after # noqa: E402, F401 from rate_limit_utils import is_rate_limit_error, parse_retry_after # noqa: E402, F401
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# -------------------------------------------------------------------
# Rate-limit handling for chat sessions
# -------------------------------------------------------------------
MAX_CHAT_RATE_LIMIT_RETRIES = 3
def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]: def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
"""Inspect an exception and determine if it represents a rate-limit. """Inspect an exception and determine if it represents a rate-limit.
@@ -49,24 +44,15 @@ def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the
parsed Retry-After value when available, otherwise ``None`` (caller parsed Retry-After value when available, otherwise ``None`` (caller
should use exponential backoff). should use exponential backoff).
Handles:
- ``MessageParseError`` whose raw *data* dict has
``type == "rate_limit_event"`` (Claude CLI sends this).
- Any exception whose string representation matches known rate-limit
patterns (via ``rate_limit_utils.is_rate_limit_error``).
""" """
# MessageParseError = unknown CLI message type (e.g. "rate_limit_event").
# These are informational events, NOT actual rate limit errors.
# The word "rate_limit" in the type name would false-positive the regex.
if type(exc).__name__ == "MessageParseError":
return False, None
# For all other exceptions: match error text against known rate-limit patterns
exc_str = str(exc) exc_str = str(exc)
# Check for MessageParseError with a rate_limit_event payload
cls_name = type(exc).__name__
if cls_name == "MessageParseError":
raw_data = getattr(exc, "data", None)
if isinstance(raw_data, dict) and raw_data.get("type") == "rate_limit_event":
retry = parse_retry_after(str(raw_data)) if raw_data else None
return True, retry
# Fallback: match error text against known rate-limit patterns
if is_rate_limit_error(exc_str): if is_rate_limit_error(exc_str):
retry = parse_retry_after(exc_str) retry = parse_retry_after(exc_str)
return True, retry return True, retry
@@ -74,6 +60,34 @@ def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
return False, None return False, None
async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator:
"""Wrap ``client.receive_response()`` to skip ``MessageParseError``.
The Claude Code CLI may emit message types (e.g. ``rate_limit_event``)
that the installed Python SDK does not recognise, causing
``MessageParseError`` which kills the async generator. The CLI
subprocess is still alive and the SDK uses a buffered memory channel,
so we restart ``receive_response()`` to continue reading remaining
messages without losing data.
"""
max_retries = 50
retries = 0
while True:
try:
async for msg in client.receive_response():
yield msg
return # Normal completion
except Exception as exc:
if type(exc).__name__ == "MessageParseError":
retries += 1
if retries > max_retries:
log.error(f"Too many unrecognized CLI messages ({retries}), stopping")
return
log.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
continue
raise
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]: async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
"""Yield a single multimodal user message in Claude Agent SDK format. """Yield a single multimodal user message in Claude Agent SDK format.

View File

@@ -23,11 +23,10 @@ from dotenv import load_dotenv
from ..schemas import ImageAttachment from ..schemas import ImageAttachment
from .chat_constants import ( from .chat_constants import (
MAX_CHAT_RATE_LIMIT_RETRIES,
ROOT_DIR, ROOT_DIR,
calculate_rate_limit_backoff,
check_rate_limit_error, check_rate_limit_error,
make_multimodal_message, make_multimodal_message,
safe_receive_response,
) )
# Load environment variables from .env file if present # Load environment variables from .env file if present
@@ -304,67 +303,32 @@ class ExpandChatSession:
else: else:
await self.client.query(message) await self.client.query(message)
# Stream the response (with rate-limit retry) # Stream the response
for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1): try:
try: async for msg in safe_receive_response(self.client, logger):
async for msg in self.client.receive_response(): msg_type = type(msg).__name__
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"): if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content: for block in msg.content:
block_type = type(block).__name__ block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"): if block_type == "TextBlock" and hasattr(block, "text"):
text = block.text text = block.text
if text: if text:
yield {"type": "text", "content": text} yield {"type": "text", "content": text}
self.messages.append({ self.messages.append({
"role": "assistant", "role": "assistant",
"content": text, "content": text,
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
}) })
# Completed successfully — break out of retry loop except Exception as exc:
break is_rate_limit, _ = check_rate_limit_error(exc)
except Exception as exc: if is_rate_limit:
is_rate_limit, retry_secs = check_rate_limit_error(exc) logger.warning(f"Rate limited: {exc}")
if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES: yield {"type": "error", "content": "Rate limited. Please try again later."}
delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt) return
logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s") raise
yield {
"type": "rate_limited",
"retry_in": delay,
"attempt": _attempt + 1,
"max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES,
}
await asyncio.sleep(delay)
# Re-send the query before retrying receive_response
if attachments and len(attachments) > 0:
content_blocks_retry: list[dict[str, Any]] = []
if message:
content_blocks_retry.append({"type": "text", "text": message})
for att in attachments:
content_blocks_retry.append({
"type": "image",
"source": {
"type": "base64",
"media_type": att.mimeType,
"data": att.base64Data,
}
})
await self.client.query(make_multimodal_message(content_blocks_retry))
else:
await self.client.query(message)
continue
if is_rate_limit:
logger.error("Rate limit retries exhausted for expand chat")
yield {"type": "error", "content": "Rate limited. Please try again later."}
return
# Non-rate-limit MessageParseError: log and break (don't crash)
if type(exc).__name__ == "MessageParseError":
logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
break
raise
def get_features_created(self) -> int: def get_features_created(self) -> int:
"""Get the total number of features created in this session.""" """Get the total number of features created in this session."""

View File

@@ -6,7 +6,6 @@ Manages interactive spec creation conversation with Claude.
Uses the create-spec.md skill to guide users through app spec creation. Uses the create-spec.md skill to guide users through app spec creation.
""" """
import asyncio
import json import json
import logging import logging
import os import os
@@ -21,11 +20,10 @@ from dotenv import load_dotenv
from ..schemas import ImageAttachment from ..schemas import ImageAttachment
from .chat_constants import ( from .chat_constants import (
MAX_CHAT_RATE_LIMIT_RETRIES,
ROOT_DIR, ROOT_DIR,
calculate_rate_limit_backoff,
check_rate_limit_error, check_rate_limit_error,
make_multimodal_message, make_multimodal_message,
safe_receive_response,
) )
# Load environment variables from .env file if present # Load environment variables from .env file if present
@@ -311,145 +309,125 @@ class SpecChatSession:
# Store paths for the completion message # Store paths for the completion message
spec_path = None spec_path = None
# Stream the response using receive_response (with rate-limit retry) # Stream the response
for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1): try:
try: async for msg in safe_receive_response(self.client, logger):
async for msg in self.client.receive_response(): msg_type = type(msg).__name__
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"): if msg_type == "AssistantMessage" and hasattr(msg, "content"):
# Process content blocks in the assistant message # Process content blocks in the assistant message
for block in msg.content: for block in msg.content:
block_type = type(block).__name__ block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"): if block_type == "TextBlock" and hasattr(block, "text"):
# Accumulate text and yield it # Accumulate text and yield it
text = block.text text = block.text
if text: if text:
current_text += text current_text += text
yield {"type": "text", "content": text} yield {"type": "text", "content": text}
# Store in message history # Store in message history
self.messages.append({ self.messages.append({
"role": "assistant", "role": "assistant",
"content": text, "content": text,
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
}) })
elif block_type == "ToolUseBlock" and hasattr(block, "name"): elif block_type == "ToolUseBlock" and hasattr(block, "name"):
tool_name = block.name tool_name = block.name
tool_input = getattr(block, "input", {}) tool_input = getattr(block, "input", {})
tool_id = getattr(block, "id", "") tool_id = getattr(block, "id", "")
if tool_name in ("Write", "Edit"): if tool_name in ("Write", "Edit"):
# File being written or edited - track for verification # File being written or edited - track for verification
file_path = tool_input.get("file_path", "") file_path = tool_input.get("file_path", "")
# Track app_spec.txt # Track app_spec.txt
if "app_spec.txt" in str(file_path): if "app_spec.txt" in str(file_path):
pending_writes["app_spec"] = { pending_writes["app_spec"] = {
"tool_id": tool_id, "tool_id": tool_id,
"path": file_path "path": file_path
} }
logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}") logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}")
# Track initializer_prompt.md # Track initializer_prompt.md
elif "initializer_prompt.md" in str(file_path): elif "initializer_prompt.md" in str(file_path):
pending_writes["initializer"] = { pending_writes["initializer"] = {
"tool_id": tool_id, "tool_id": tool_id,
"path": file_path "path": file_path
} }
logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}") logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
elif msg_type == "UserMessage" and hasattr(msg, "content"): elif msg_type == "UserMessage" and hasattr(msg, "content"):
# Tool results - check for write confirmations and errors # Tool results - check for write confirmations and errors
for block in msg.content: for block in msg.content:
block_type = type(block).__name__ block_type = type(block).__name__
if block_type == "ToolResultBlock": if block_type == "ToolResultBlock":
is_error = getattr(block, "is_error", False) is_error = getattr(block, "is_error", False)
tool_use_id = getattr(block, "tool_use_id", "") tool_use_id = getattr(block, "tool_use_id", "")
if is_error: if is_error:
content = getattr(block, "content", "Unknown error") content = getattr(block, "content", "Unknown error")
logger.warning(f"Tool error: {content}") logger.warning(f"Tool error: {content}")
# Clear any pending writes that failed # Clear any pending writes that failed
for key in pending_writes: for key in pending_writes:
pending_write = pending_writes[key] pending_write = pending_writes[key]
if pending_write is not None and tool_use_id == pending_write.get("tool_id"): if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
logger.error(f"{key} write failed: {content}") logger.error(f"{key} write failed: {content}")
pending_writes[key] = None pending_writes[key] = None
else: else:
# Tool succeeded - check which file was written # Tool succeeded - check which file was written
# Check app_spec.txt # Check app_spec.txt
if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"): if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
file_path = pending_writes["app_spec"]["path"] file_path = pending_writes["app_spec"]["path"]
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
if full_path.exists(): if full_path.exists():
logger.info(f"app_spec.txt verified at: {full_path}") logger.info(f"app_spec.txt verified at: {full_path}")
files_written["app_spec"] = True files_written["app_spec"] = True
spec_path = file_path spec_path = file_path
# Notify about file write (but NOT completion yet) # Notify about file write (but NOT completion yet)
yield {
"type": "file_written",
"path": str(file_path)
}
else:
logger.error(f"app_spec.txt not found after write: {full_path}")
pending_writes["app_spec"] = None
# Check initializer_prompt.md
if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
file_path = pending_writes["initializer"]["path"]
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
if full_path.exists():
logger.info(f"initializer_prompt.md verified at: {full_path}")
files_written["initializer"] = True
# Notify about file write
yield {
"type": "file_written",
"path": str(file_path)
}
else:
logger.error(f"initializer_prompt.md not found after write: {full_path}")
pending_writes["initializer"] = None
# Check if BOTH files are now written - only then signal completion
if files_written["app_spec"] and files_written["initializer"]:
logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
self.complete = True
yield { yield {
"type": "spec_complete", "type": "file_written",
"path": str(spec_path) "path": str(file_path)
} }
# Completed successfully — break out of retry loop else:
break logger.error(f"app_spec.txt not found after write: {full_path}")
except Exception as exc: pending_writes["app_spec"] = None
is_rate_limit, retry_secs = check_rate_limit_error(exc)
if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES: # Check initializer_prompt.md
delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt) if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s") file_path = pending_writes["initializer"]["path"]
yield { full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
"type": "rate_limited", if full_path.exists():
"retry_in": delay, logger.info(f"initializer_prompt.md verified at: {full_path}")
"attempt": _attempt + 1, files_written["initializer"] = True
"max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES,
} # Notify about file write
await asyncio.sleep(delay) yield {
# Re-send the query before retrying receive_response "type": "file_written",
await self.client.query(message) "path": str(file_path)
continue }
if is_rate_limit: else:
logger.error("Rate limit retries exhausted for spec chat") logger.error(f"initializer_prompt.md not found after write: {full_path}")
yield {"type": "error", "content": "Rate limited. Please try again later."} pending_writes["initializer"] = None
return
# Non-rate-limit MessageParseError: log and break (don't crash) # Check if BOTH files are now written - only then signal completion
if type(exc).__name__ == "MessageParseError": if files_written["app_spec"] and files_written["initializer"]:
logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}") logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
break self.complete = True
raise yield {
"type": "spec_complete",
"path": str(spec_path)
}
except Exception as exc:
is_rate_limit, _ = check_rate_limit_error(exc)
if is_rate_limit:
logger.warning(f"Rate limited: {exc}")
yield {"type": "error", "content": "Rate limited. Please try again later."}
return
raise
def is_complete(self) -> bool: def is_complete(self) -> bool:
"""Check if spec creation is complete.""" """Check if spec creation is complete."""

View File

@@ -269,20 +269,6 @@ export function useAssistantChat({
break; break;
} }
case "rate_limited": {
// Show rate limit info as system message
setMessages((prev) => [
...prev,
{
id: generateId(),
role: "system",
content: `Rate limited. Retrying in ${data.retry_in}s... (attempt ${data.attempt}/${data.max_attempts})`,
timestamp: new Date(),
},
]);
break;
}
case "pong": { case "pong": {
// Keep-alive response, nothing to do // Keep-alive response, nothing to do
break; break;

View File

@@ -226,20 +226,6 @@ export function useExpandChat({
break break
} }
case 'rate_limited': {
// Show rate limit info as system message
setMessages((prev) => [
...prev,
{
id: generateId(),
role: 'system',
content: `Rate limited. Retrying in ${data.retry_in}s... (attempt ${data.attempt}/${data.max_attempts})`,
timestamp: new Date(),
},
])
break
}
case 'pong': { case 'pong': {
// Keep-alive response, nothing to do // Keep-alive response, nothing to do
break break

View File

@@ -322,20 +322,6 @@ export function useSpecChat({
break break
} }
case 'rate_limited': {
// Show rate limit info as system message
setMessages((prev) => [
...prev,
{
id: generateId(),
role: 'system',
content: `Rate limited. Retrying in ${data.retry_in}s... (attempt ${data.attempt}/${data.max_attempts})`,
timestamp: new Date(),
},
])
break
}
case 'pong': { case 'pong': {
// Keep-alive response, nothing to do // Keep-alive response, nothing to do
break break

View File

@@ -407,13 +407,6 @@ export interface SpecChatResponseDoneMessage {
type: 'response_done' type: 'response_done'
} }
export interface SpecChatRateLimitedMessage {
type: 'rate_limited'
retry_in: number
attempt: number
max_attempts: number
}
export type SpecChatServerMessage = export type SpecChatServerMessage =
| SpecChatTextMessage | SpecChatTextMessage
| SpecChatQuestionMessage | SpecChatQuestionMessage
@@ -423,7 +416,6 @@ export type SpecChatServerMessage =
| SpecChatErrorMessage | SpecChatErrorMessage
| SpecChatPongMessage | SpecChatPongMessage
| SpecChatResponseDoneMessage | SpecChatResponseDoneMessage
| SpecChatRateLimitedMessage
// Image attachment for chat messages // Image attachment for chat messages
export interface ImageAttachment { export interface ImageAttachment {
@@ -509,13 +501,6 @@ export interface AssistantChatPongMessage {
type: 'pong' type: 'pong'
} }
export interface AssistantChatRateLimitedMessage {
type: 'rate_limited'
retry_in: number
attempt: number
max_attempts: number
}
export type AssistantChatServerMessage = export type AssistantChatServerMessage =
| AssistantChatTextMessage | AssistantChatTextMessage
| AssistantChatToolCallMessage | AssistantChatToolCallMessage
@@ -524,7 +509,6 @@ export type AssistantChatServerMessage =
| AssistantChatErrorMessage | AssistantChatErrorMessage
| AssistantChatConversationCreatedMessage | AssistantChatConversationCreatedMessage
| AssistantChatPongMessage | AssistantChatPongMessage
| AssistantChatRateLimitedMessage
// ============================================================================ // ============================================================================
// Expand Chat Types // Expand Chat Types
@@ -548,7 +532,6 @@ export type ExpandChatServerMessage =
| SpecChatErrorMessage // Reuse error message type | SpecChatErrorMessage // Reuse error message type
| SpecChatPongMessage // Reuse pong message type | SpecChatPongMessage // Reuse pong message type
| SpecChatResponseDoneMessage // Reuse response_done type | SpecChatResponseDoneMessage // Reuse response_done type
| SpecChatRateLimitedMessage // Reuse rate_limited message type
// Bulk feature creation // Bulk feature creation
export interface FeatureBulkCreate { export interface FeatureBulkCreate {