Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 25 additions & 47 deletions src/telegram_agent_bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -5523,40 +5523,38 @@ async def discard_failed_window() -> None:
await discard_failed_window()
return False, startup_message

# Do not synthesize a session_map entry before validating the resumed
# process. A synthetic entry used to make failed resumes look successful.
# Prepare the new window before loading its SessionStart hook result. The
# hook owns the active transcript identity after a resume.
session_manager.prepare_window_launch(
created_wid,
cwd=str(selected_path),
window_name=created_wname,
account_name=account_name or "",
)
hook_ok = await session_manager.wait_for_session_map_entry(
created_wid,
timeout=15.0,
expected_session_id=resume_session_id,
apply=False,
)
healthy, health_message = await _recovered_agent_process_status(created_wid)
if not healthy:
await discard_failed_window()
return False, health_message

session_manager.prepare_window_launch(
created_wid,
cwd=str(selected_path),
window_name=created_wname,
account_name=account_name or "",
)
if not hook_ok:
logger.info(
"Recovered missing window %s as %s without a hook entry; "
"tracking resumed session_id=%s",
"falling back to resumed session_id=%s",
old_window_id,
created_wid,
resume_session_id,
)
session_manager.register_session_to_window(
created_wid,
resume_session_id,
str(selected_path),
window_name=created_wname,
persist_session_map=True,
)
session_manager.register_session_to_window(
created_wid,
resume_session_id,
str(selected_path),
window_name=created_wname,
persist_session_map=True,
)
ws = session_manager.get_window_state(created_wid)
ws.account_name = account_name or ""
session_manager._save_state()
Expand Down Expand Up @@ -6289,18 +6287,6 @@ async def _create_and_bind_window(
reasoning_effort=profile.reasoning_effort,
fast_mode=profile.fast_mode,
)
if resume_session_id:
# A resumed Codex window continues writing to the original JSONL.
# Persist that expected session immediately so the transcript
# monitor cannot auto-bind an older same-cwd transcript while the
# TUI is still restoring.
session_manager.register_session_to_window(
created_wid,
resume_session_id,
str(selected_path),
window_name=created_wname,
persist_session_map=True,
)
logger.info(
"Window created: %s (id=%s) at %s (user=%d, thread=%s, resume=%s, account=%s)",
created_wname,
Expand All @@ -6320,34 +6306,26 @@ async def _create_and_bind_window(
created_wid, timeout=15.0
)

# --resume creates a new session_id in the hook, but messages continue
# writing to the resumed session's JSONL file. Override window_state to
# track the original session_id so the monitor can route messages back.
# Keep the SessionStart identity when Codex reports one. Current Codex
# versions write resumed turns to that transcript, not the selected
# historical session. Only fall back to the selected session when no
# hook entry arrives.
if resume_session_id:
ws = session_manager.get_window_state(created_wid)
if not hook_ok:
# Hook timed out — manually populate window_state so the
# monitor can still route messages back to this topic.
logger.warning(
"Hook timed out for resume window %s, "
"manually setting session_id=%s cwd=%s",
"falling back to session_id=%s cwd=%s",
created_wid,
resume_session_id,
selected_path,
)
ws.session_id = resume_session_id
ws.cwd = str(selected_path)
ws.window_name = created_wname
session_manager._save_state()
elif ws.session_id != resume_session_id:
logger.info(
"Resume override: window %s session_id %s -> %s",
session_manager.register_session_to_window(
created_wid,
ws.session_id,
resume_session_id,
str(selected_path),
window_name=created_wname,
persist_session_map=True,
)
ws.session_id = resume_session_id
session_manager._save_state()

if pending_thread_id is not None:
# Thread bind flow: bind thread to newly created window
Expand Down
14 changes: 4 additions & 10 deletions tests/telegram_agent_bot/test_agent_input_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -1372,14 +1372,14 @@ async def test_recovery_failure_after_original_id_reuse_keeps_saved_state(monkey


@pytest.mark.asyncio
async def test_recovery_commits_binding_only_after_validation(monkeypatch):
async def test_recovery_keeps_hook_session_identity_after_validation(monkeypatch):
old_state = SimpleNamespace(
session_id="sid-1",
cwd="/tmp/repo",
window_name="Repo",
account_name="",
)
new_state = SimpleNamespace(session_id="", account_name="")
new_state = SimpleNamespace(session_id="resumed-session", account_name="")
session_manager = MagicMock()
session_manager.window_states = {"@8": old_state}
session_manager.user_window_offsets = {}
Expand Down Expand Up @@ -1420,18 +1420,12 @@ async def test_recovery_commits_binding_only_after_validation(monkeypatch):
assert ok is True
assert "Recovered window" in message
session_manager.wait_for_session_map_entry.assert_awaited_once_with(
"@9", timeout=15.0, expected_session_id="sid-1", apply=False
"@9", timeout=15.0
)
session_manager.prepare_window_launch.assert_called_once_with(
"@9", cwd="/tmp/repo", window_name="Repo", account_name=""
)
session_manager.register_session_to_window.assert_called_once_with(
"@9",
"sid-1",
"/tmp/repo",
window_name="Repo",
persist_session_map=True,
)
session_manager.register_session_to_window.assert_not_called()
session_manager.bind_thread.assert_called_once_with(
12345, 42, "@9", window_name="Repo"
)
Expand Down
52 changes: 52 additions & 0 deletions tests/telegram_agent_bot/test_bot_topic_isolation.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Regression tests for keeping Telegram topics isolated by Codex session."""

from unittest.mock import AsyncMock, MagicMock, patch
from types import SimpleNamespace

import pytest

Expand Down Expand Up @@ -329,6 +330,57 @@ class DummyUser:
)
safe_edit.assert_awaited_once()

@pytest.mark.asyncio
async def test_create_and_bind_window_keeps_resumed_hook_session_identity(self):
class DummyCallbackQuery:
def __init__(self) -> None:
self.answer = AsyncMock()
self.from_user = MagicMock(id=12345)

class DummyUser:
id = 12345

query = DummyCallbackQuery()
context = _make_context()
user = DummyUser()
hook_state = SimpleNamespace(session_id="resumed-session")

with (
patch("telegram.CallbackQuery", DummyCallbackQuery),
patch("telegram.User", DummyUser),
patch("telegram_agent_bot.bot.session_manager") as mock_sm,
patch("telegram_agent_bot.bot.safe_edit", new_callable=AsyncMock),
patch("telegram_agent_bot.bot.get_default_account_name", return_value=""),
patch(
"telegram_agent_bot.bot.create_agent_session",
new_callable=AsyncMock,
) as create_agent_session,
):
create_agent_session.return_value = CreateSessionResult(
ok=True,
message="Resumed window 'project'",
target=AgentTarget("local", "local", window_id="@1"),
display_name="project",
)
mock_sm.get_window_state.return_value = hook_state
mock_sm.wait_for_session_map_entry = AsyncMock(return_value=True)
mock_sm.resolve_chat_id.return_value = -1001234567890

from telegram_agent_bot.bot import _create_and_bind_window

await _create_and_bind_window(
query,
context,
user,
"/tmp/project",
42,
resume_session_id="selected-session",
)

mock_sm.wait_for_session_map_entry.assert_awaited_once_with("@1", timeout=15.0)
mock_sm.register_session_to_window.assert_not_called()
assert hook_state.session_id == "resumed-session"

@pytest.mark.asyncio
async def test_create_and_bind_window_accepts_remote_backend_target(self):
"""A backend plugin can create a session without a local tmux window."""
Expand Down
Loading