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."""
|
"""Check if user is authorized."""
|
||||||
return user_id in get_authorized_users()
|
return user_id in get_authorized_users()
|
||||||
|
|
||||||
def make_callbacks(bot, chat_id, stop_typing_event: asyncio.Event):
|
def make_callbacks(bot, chat_id, session_name: str):
|
||||||
"""Create callbacks for ClaudeSubprocess bound to specific chat with typing control."""
|
"""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):
|
async def on_output(text):
|
||||||
t0 = time.monotonic()
|
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}")
|
logger.warning(f"MarkdownV2 parse failed, falling back to plain text: {e}")
|
||||||
await bot.send_message(chat_id=chat_id, text=chunk)
|
await bot.send_message(chat_id=chat_id, text=chunk)
|
||||||
|
|
||||||
# Stop typing indicator
|
# Stop typing indicator (dynamic lookup)
|
||||||
stop_typing_event.set()
|
_stop_typing()
|
||||||
|
|
||||||
elapsed = time.monotonic() - t0
|
elapsed = time.monotonic() - t0
|
||||||
logger.info(f"[TIMING] Telegram send: {elapsed:.3f}s ({len(text)} chars, {len(chunks)} chunks)")
|
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):
|
async def on_error(error):
|
||||||
await bot.send_message(chat_id=chat_id, text=f"Error: {error}")
|
await bot.send_message(chat_id=chat_id, text=f"Error: {error}")
|
||||||
# Stop typing indicator on error
|
# Stop typing indicator on error
|
||||||
stop_typing_event.set()
|
_stop_typing()
|
||||||
|
|
||||||
async def on_complete():
|
async def on_complete():
|
||||||
# Stop typing indicator on completion
|
# Stop typing indicator on completion
|
||||||
stop_typing_event.set()
|
_stop_typing()
|
||||||
|
|
||||||
async def on_status(status):
|
async def on_status(status):
|
||||||
await bot.send_message(chat_id=chat_id, text=f"[{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_config = session_manager.get_session(name)
|
||||||
persona_data = session_manager.load_persona(persona or 'default')
|
persona_data = session_manager.load_persona(persona or 'default')
|
||||||
|
|
||||||
# Create stop typing event for this session
|
# Create callbacks bound to this chat (typing looked up dynamically)
|
||||||
stop_typing = asyncio.Event()
|
callbacks = make_callbacks(context.bot, update.effective_chat.id, name)
|
||||||
|
|
||||||
# Create callbacks bound to this chat
|
|
||||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
|
|
||||||
|
|
||||||
# Create subprocess
|
# Create subprocess
|
||||||
subprocesses[name] = ClaudeSubprocess(
|
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_name = session_data.get('persona', 'default')
|
||||||
persona_data = session_manager.load_persona(persona_name)
|
persona_data = session_manager.load_persona(persona_name)
|
||||||
|
|
||||||
# Create stop typing event for this session
|
# Create callbacks bound to this chat (typing looked up dynamically)
|
||||||
stop_typing = asyncio.Event()
|
callbacks = make_callbacks(context.bot, update.effective_chat.id, name)
|
||||||
|
|
||||||
# Create callbacks bound to this chat
|
|
||||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
|
|
||||||
|
|
||||||
# Create subprocess
|
# Create subprocess
|
||||||
subprocesses[name] = ClaudeSubprocess(
|
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]}...")
|
logger.info(f"[TIMING] Message received: session='{active_session}', age={msg_age:.1f}s, text={message[:50]}...")
|
||||||
|
|
||||||
try:
|
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:
|
if active_session not in typing_tasks:
|
||||||
stop_typing = asyncio.Event()
|
stop_typing = asyncio.Event()
|
||||||
typing_task = asyncio.create_task(
|
typing_task = asyncio.create_task(
|
||||||
typing_indicator_loop(context.bot, update.effective_chat.id, stop_typing)
|
typing_indicator_loop(context.bot, update.effective_chat.id, stop_typing)
|
||||||
)
|
)
|
||||||
typing_tasks[active_session] = (typing_task, 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)
|
# Get or create subprocess for active session (avoid double-start)
|
||||||
already_alive = active_session in subprocesses and subprocesses[active_session].is_alive
|
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_name = session_data.get('persona', 'default')
|
||||||
persona_data = session_manager.load_persona(persona_name)
|
persona_data = session_manager.load_persona(persona_name)
|
||||||
|
|
||||||
# Create callbacks bound to this chat with stop_typing event
|
# Create callbacks bound to this chat (typing looked up dynamically)
|
||||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
|
callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
|
||||||
|
|
||||||
# Create subprocess
|
# Create subprocess
|
||||||
subprocess_inst = ClaudeSubprocess(
|
subprocess_inst = ClaudeSubprocess(
|
||||||
|
|
@ -652,6 +659,12 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
# Get caption if any
|
# Get caption if any
|
||||||
caption = update.message.caption or ""
|
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
|
# Start typing indicator
|
||||||
if active_session not in typing_tasks:
|
if active_session not in typing_tasks:
|
||||||
stop_typing = asyncio.Event()
|
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_name = session_data.get('persona', 'default')
|
||||||
persona_data = session_manager.load_persona(persona_name)
|
persona_data = session_manager.load_persona(persona_name)
|
||||||
|
|
||||||
stop_typing = typing_tasks[active_session][1]
|
callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
|
||||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
|
|
||||||
|
|
||||||
subprocess_inst = ClaudeSubprocess(
|
subprocess_inst = ClaudeSubprocess(
|
||||||
session_dir=session_dir,
|
session_dir=session_dir,
|
||||||
|
|
@ -724,6 +736,12 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
# Get caption if any
|
# Get caption if any
|
||||||
caption = update.message.caption or ""
|
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
|
# Start typing indicator
|
||||||
if active_session not in typing_tasks:
|
if active_session not in typing_tasks:
|
||||||
stop_typing = asyncio.Event()
|
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_name = session_data.get('persona', 'default')
|
||||||
persona_data = session_manager.load_persona(persona_name)
|
persona_data = session_manager.load_persona(persona_name)
|
||||||
|
|
||||||
stop_typing = typing_tasks[active_session][1]
|
callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session)
|
||||||
callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing)
|
|
||||||
|
|
||||||
subprocess_inst = ClaudeSubprocess(
|
subprocess_inst = ClaudeSubprocess(
|
||||||
session_dir=session_dir,
|
session_dir=session_dir,
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ class ClaudeSubprocess:
|
||||||
"--input-format", "stream-json",
|
"--input-format", "stream-json",
|
||||||
"--output-format", "stream-json",
|
"--output-format", "stream-json",
|
||||||
"--verbose",
|
"--verbose",
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add --continue if prior session exists
|
# Add --continue if prior session exists
|
||||||
|
|
@ -141,15 +142,15 @@ class ClaudeSubprocess:
|
||||||
cmd.append("--continue")
|
cmd.append("--continue")
|
||||||
logger.debug("Using --continue flag (found existing .claude/ directory)")
|
logger.debug("Using --continue flag (found existing .claude/ directory)")
|
||||||
|
|
||||||
# Add persona settings
|
# Add persona settings (model FIRST, then system prompt)
|
||||||
if self._persona:
|
if self._persona:
|
||||||
if "system_prompt" in self._persona:
|
|
||||||
cmd.extend(["--system-prompt", self._persona["system_prompt"]])
|
|
||||||
settings = self._persona.get("settings", {})
|
settings = self._persona.get("settings", {})
|
||||||
if "max_turns" in settings:
|
|
||||||
cmd.extend(["--max-turns", str(settings["max_turns"])])
|
|
||||||
if "model" in settings:
|
if "model" in settings:
|
||||||
cmd.extend(["--model", settings["model"]])
|
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
|
# Prepare environment
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
|
@ -166,11 +167,10 @@ class ClaudeSubprocess:
|
||||||
# Ensure session directory exists
|
# Ensure session directory exists
|
||||||
self._session_dir.mkdir(parents=True, exist_ok=True)
|
self._session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Log command
|
# Log full command
|
||||||
cmd_flags = [c for c in cmd if c.startswith("--")]
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[TIMING] Starting persistent subprocess: cwd={self._session_dir.name}, "
|
f"[TIMING] Starting persistent subprocess: cwd={self._session_dir.name}, "
|
||||||
f"flags={' '.join(cmd_flags)}"
|
f"cmd={' '.join(cmd)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -226,7 +226,7 @@ class ClaudeSubprocess:
|
||||||
|
|
||||||
# Write NDJSON to stdin
|
# Write NDJSON to stdin
|
||||||
try:
|
try:
|
||||||
msg_dict = {"type": "user", "content": message}
|
msg_dict = {"type": "user", "message": {"role": "user", "content": message}}
|
||||||
ndjson_line = json.dumps(msg_dict) + '\n'
|
ndjson_line = json.dumps(msg_dict) + '\n'
|
||||||
self._process.stdin.write(ndjson_line.encode())
|
self._process.stdin.write(ndjson_line.encode())
|
||||||
await self._process.stdin.drain() # CRITICAL: flush buffer
|
await self._process.stdin.drain() # CRITICAL: flush buffer
|
||||||
|
|
@ -317,6 +317,8 @@ class ClaudeSubprocess:
|
||||||
if event_type == "assistant":
|
if event_type == "assistant":
|
||||||
# Extract text from assistant message
|
# Extract text from assistant message
|
||||||
message = event.get("message", {})
|
message = event.get("message", {})
|
||||||
|
model = message.get("model", "unknown")
|
||||||
|
logger.info(f"Assistant response model: {model}")
|
||||||
content = message.get("content", [])
|
content = message.get("content", [])
|
||||||
for block in content:
|
for block in content:
|
||||||
if block.get("type") == "text":
|
if block.get("type") == "text":
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"description": "All-around helpful assistant",
|
"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.",
|
"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": {
|
"settings": {
|
||||||
"model": "sonnet",
|
"model": "claude-sonnet-4-5-20250929",
|
||||||
"max_turns": 25
|
"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():
|
while not stop_event.is_set():
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to send typing indicator: {e}")
|
logger.warning(f"Failed to send typing indicator: {e}")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue