fix(02-02): typing indicator lifecycle, model identity, tool permissions
- Fix typing indicator not showing: clean up stale typing tasks between messages and use dynamic event lookup in callbacks instead of capturing a specific event at creation time - Fix stream-json input format: use nested message object for stdin NDJSON - Switch --system-prompt to --append-system-prompt so Claude Code's default system prompt (with model identity) is preserved - Add --dangerously-skip-permissions for full tool access in subprocess - Use full model ID (claude-sonnet-4-5-20250929) in default persona Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f246d18fa0
commit
2d0d4da992
4 changed files with 57 additions and 37 deletions
|
|
@ -68,8 +68,18 @@ def is_authorized(user_id: int) -> bool:
|
|||
"""Check if user is authorized."""
|
||||
return user_id in get_authorized_users()
|
||||
|
||||
def make_callbacks(bot, chat_id, stop_typing_event: asyncio.Event):
|
||||
"""Create callbacks for ClaudeSubprocess bound to specific chat with typing control."""
|
||||
def make_callbacks(bot, chat_id, session_name: str):
|
||||
"""Create callbacks for ClaudeSubprocess bound to specific chat with dynamic typing control.
|
||||
|
||||
Typing events are looked up dynamically from typing_tasks dict so callbacks
|
||||
always reference the CURRENT typing indicator, not a stale one from creation time.
|
||||
"""
|
||||
def _stop_typing():
|
||||
"""Stop the current typing indicator for this session (if any)."""
|
||||
if session_name in typing_tasks:
|
||||
_, stop_event = typing_tasks[session_name]
|
||||
stop_event.set()
|
||||
|
||||
async def on_output(text):
|
||||
t0 = time.monotonic()
|
||||
|
||||
|
|
@ -86,8 +96,8 @@ def make_callbacks(bot, chat_id, stop_typing_event: asyncio.Event):
|
|||
logger.warning(f"MarkdownV2 parse failed, falling back to plain text: {e}")
|
||||
await bot.send_message(chat_id=chat_id, text=chunk)
|
||||
|
||||
# Stop typing indicator
|
||||
stop_typing_event.set()
|
||||
# Stop typing indicator (dynamic lookup)
|
||||
_stop_typing()
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
logger.info(f"[TIMING] Telegram send: {elapsed:.3f}s ({len(text)} chars, {len(chunks)} chunks)")
|
||||
|
|
@ -95,11 +105,11 @@ def make_callbacks(bot, chat_id, stop_typing_event: asyncio.Event):
|
|||
async def on_error(error):
|
||||
await bot.send_message(chat_id=chat_id, text=f"Error: {error}")
|
||||
# Stop typing indicator on error
|
||||
stop_typing_event.set()
|
||||
_stop_typing()
|
||||
|
||||
async def on_complete():
|
||||
# Stop typing indicator on completion
|
||||
stop_typing_event.set()
|
||||
_stop_typing()
|
||||
|
||||
async def on_status(status):
|
||||
await bot.send_message(chat_id=chat_id, text=f"[{status}]")
|
||||
|
|
@ -372,11 +382,8 @@ async def new_session(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
persona_config = session_manager.get_session(name)
|
||||
persona_data = session_manager.load_persona(persona or 'default')
|
||||
|
||||
# Create stop typing event for this session
|
||||
stop_typing = asyncio.Event()
|
||||
|
||||
# Create callbacks bound to this chat
|
||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
|
||||
# Create callbacks bound to this chat (typing looked up dynamically)
|
||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, name)
|
||||
|
||||
# Create subprocess
|
||||
subprocesses[name] = ClaudeSubprocess(
|
||||
|
|
@ -459,11 +466,8 @@ async def switch_session_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE)
|
|||
persona_name = session_data.get('persona', 'default')
|
||||
persona_data = session_manager.load_persona(persona_name)
|
||||
|
||||
# Create stop typing event for this session
|
||||
stop_typing = asyncio.Event()
|
||||
|
||||
# Create callbacks bound to this chat
|
||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
|
||||
# Create callbacks bound to this chat (typing looked up dynamically)
|
||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, name)
|
||||
|
||||
# Create subprocess
|
||||
subprocesses[name] = ClaudeSubprocess(
|
||||
|
|
@ -560,16 +564,19 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
logger.info(f"[TIMING] Message received: session='{active_session}', age={msg_age:.1f}s, text={message[:50]}...")
|
||||
|
||||
try:
|
||||
# Start typing indicator immediately (or reuse existing)
|
||||
# Clean up stale typing task (from previous message completion)
|
||||
if active_session in typing_tasks:
|
||||
old_task, old_event = typing_tasks[active_session]
|
||||
if old_task.done():
|
||||
del typing_tasks[active_session]
|
||||
|
||||
# Start fresh typing indicator for this message
|
||||
if active_session not in typing_tasks:
|
||||
stop_typing = asyncio.Event()
|
||||
typing_task = asyncio.create_task(
|
||||
typing_indicator_loop(context.bot, update.effective_chat.id, stop_typing)
|
||||
)
|
||||
typing_tasks[active_session] = (typing_task, stop_typing)
|
||||
else:
|
||||
# Reuse existing typing indicator
|
||||
typing_task, stop_typing = typing_tasks[active_session]
|
||||
|
||||
# Get or create subprocess for active session (avoid double-start)
|
||||
already_alive = active_session in subprocesses and subprocesses[active_session].is_alive
|
||||
|
|
@ -579,8 +586,8 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
persona_name = session_data.get('persona', 'default')
|
||||
persona_data = session_manager.load_persona(persona_name)
|
||||
|
||||
# Create callbacks bound to this chat with stop_typing event
|
||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
|
||||
# Create callbacks bound to this chat (typing looked up dynamically)
|
||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
|
||||
|
||||
# Create subprocess
|
||||
subprocess_inst = ClaudeSubprocess(
|
||||
|
|
@ -652,6 +659,12 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
# Get caption if any
|
||||
caption = update.message.caption or ""
|
||||
|
||||
# Clean up stale typing task
|
||||
if active_session in typing_tasks:
|
||||
old_task, old_event = typing_tasks[active_session]
|
||||
if old_task.done():
|
||||
del typing_tasks[active_session]
|
||||
|
||||
# Start typing indicator
|
||||
if active_session not in typing_tasks:
|
||||
stop_typing = asyncio.Event()
|
||||
|
|
@ -672,8 +685,7 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
persona_name = session_data.get('persona', 'default')
|
||||
persona_data = session_manager.load_persona(persona_name)
|
||||
|
||||
stop_typing = typing_tasks[active_session][1]
|
||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
|
||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
|
||||
|
||||
subprocess_inst = ClaudeSubprocess(
|
||||
session_dir=session_dir,
|
||||
|
|
@ -724,6 +736,12 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
# Get caption if any
|
||||
caption = update.message.caption or ""
|
||||
|
||||
# Clean up stale typing task
|
||||
if active_session in typing_tasks:
|
||||
old_task, old_event = typing_tasks[active_session]
|
||||
if old_task.done():
|
||||
del typing_tasks[active_session]
|
||||
|
||||
# Start typing indicator
|
||||
if active_session not in typing_tasks:
|
||||
stop_typing = asyncio.Event()
|
||||
|
|
@ -744,8 +762,7 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
persona_name = session_data.get('persona', 'default')
|
||||
persona_data = session_manager.load_persona(persona_name)
|
||||
|
||||
stop_typing = typing_tasks[active_session][1]
|
||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
|
||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
|
||||
|
||||
subprocess_inst = ClaudeSubprocess(
|
||||
session_dir=session_dir,
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ class ClaudeSubprocess:
|
|||
"--input-format", "stream-json",
|
||||
"--output-format", "stream-json",
|
||||
"--verbose",
|
||||
"--dangerously-skip-permissions",
|
||||
]
|
||||
|
||||
# Add --continue if prior session exists
|
||||
|
|
@ -141,15 +142,15 @@ class ClaudeSubprocess:
|
|||
cmd.append("--continue")
|
||||
logger.debug("Using --continue flag (found existing .claude/ directory)")
|
||||
|
||||
# Add persona settings
|
||||
# Add persona settings (model FIRST, then system prompt)
|
||||
if self._persona:
|
||||
if "system_prompt" in self._persona:
|
||||
cmd.extend(["--system-prompt", self._persona["system_prompt"]])
|
||||
settings = self._persona.get("settings", {})
|
||||
if "max_turns" in settings:
|
||||
cmd.extend(["--max-turns", str(settings["max_turns"])])
|
||||
if "model" in settings:
|
||||
cmd.extend(["--model", settings["model"]])
|
||||
if "max_turns" in settings:
|
||||
cmd.extend(["--max-turns", str(settings["max_turns"])])
|
||||
if "system_prompt" in self._persona:
|
||||
cmd.extend(["--append-system-prompt", self._persona["system_prompt"]])
|
||||
|
||||
# Prepare environment
|
||||
env = os.environ.copy()
|
||||
|
|
@ -166,11 +167,10 @@ class ClaudeSubprocess:
|
|||
# Ensure session directory exists
|
||||
self._session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Log command
|
||||
cmd_flags = [c for c in cmd if c.startswith("--")]
|
||||
# Log full command
|
||||
logger.info(
|
||||
f"[TIMING] Starting persistent subprocess: cwd={self._session_dir.name}, "
|
||||
f"flags={' '.join(cmd_flags)}"
|
||||
f"cmd={' '.join(cmd)}"
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -226,7 +226,7 @@ class ClaudeSubprocess:
|
|||
|
||||
# Write NDJSON to stdin
|
||||
try:
|
||||
msg_dict = {"type": "user", "content": message}
|
||||
msg_dict = {"type": "user", "message": {"role": "user", "content": message}}
|
||||
ndjson_line = json.dumps(msg_dict) + '\n'
|
||||
self._process.stdin.write(ndjson_line.encode())
|
||||
await self._process.stdin.drain() # CRITICAL: flush buffer
|
||||
|
|
@ -317,6 +317,8 @@ class ClaudeSubprocess:
|
|||
if event_type == "assistant":
|
||||
# Extract text from assistant message
|
||||
message = event.get("message", {})
|
||||
model = message.get("model", "unknown")
|
||||
logger.info(f"Assistant response model: {model}")
|
||||
content = message.get("content", [])
|
||||
for block in content:
|
||||
if block.get("type") == "text":
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"description": "All-around helpful assistant",
|
||||
"system_prompt": "You are Claude, a helpful AI assistant. Be concise, practical, and direct. Help with whatever is asked — coding, writing, analysis, problem-solving, or just conversation. Adapt your tone and depth to the question.",
|
||||
"settings": {
|
||||
"model": "sonnet",
|
||||
"model": "claude-sonnet-4-5-20250929",
|
||||
"max_turns": 25
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,7 +153,8 @@ async def typing_indicator_loop(bot, chat_id: int, stop_event: asyncio.Event):
|
|||
"""
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
|
||||
result = await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
|
||||
logger.info(f"Typing indicator sent to chat_id={chat_id}, result={result}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send typing indicator: {e}")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue