Skip to content
Closed
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
89 changes: 89 additions & 0 deletions gateway/run_agent_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,8 +618,88 @@ def _voice_channel_sidecar_note(self, event, source: SessionSource, session_key:
return None
return f"[Voice channel now: {vc_now or 'not connected to a voice channel'}]"

def _rehydrate_prompt_pins(
self, session_key: Optional[str], expected_session_id: Optional[str] = None,
) -> None:
"""Restore durable prompt pins lazily for the first internal turn after a gateway restart."""
if not session_key:
return
state = self._session_state(session_key)
if state.conversation.ephemeral_pin is not None and state.conversation.channel_pin is not None:
return
store = getattr(self, "session_store", None)
getter = getattr(store, "get_prompt_pin", None)
if not callable(getter):
return
try:
persisted = getter(session_key, expected_session_id=expected_session_id)
except Exception:
logger.debug("Failed to read persisted prompt pin for %s", session_key, exc_info=True)
return
if not isinstance(persisted, dict):
return
context_key, context_prompt = persisted.get("context_key"), persisted.get("context_prompt")
channel_prompt, parent_chat_id = persisted.get("channel_prompt"), persisted.get("parent_chat_id")
if isinstance(context_key, str) and context_key and isinstance(context_prompt, str):
if state.conversation.ephemeral_pin is None:
state.conversation.ephemeral_pin = (context_key, context_prompt)
if (
(channel_prompt is None or isinstance(channel_prompt, str))
and (parent_chat_id is None or isinstance(parent_chat_id, str))
and state.conversation.channel_pin is None
):
state.conversation.channel_pin = (channel_prompt, parent_chat_id)

def _prompt_pin_snapshot(self, session_key: Optional[str]) -> Optional[Dict[str, Any]]:
"""Current coherent pair of context/channel pins, ready for durable storage."""
state = self._peek_session_state(session_key) if session_key else None
if state is None:
return None
ephemeral_pin, channel_pin = state.conversation.ephemeral_pin, state.conversation.channel_pin
if not (
isinstance(ephemeral_pin, tuple) and len(ephemeral_pin) == 2
and isinstance(ephemeral_pin[0], str) and isinstance(ephemeral_pin[1], str)
and isinstance(channel_pin, tuple) and len(channel_pin) == 2
):
return None
channel_prompt, parent_chat_id = channel_pin
if channel_prompt is not None and not isinstance(channel_prompt, str):
return None
if parent_chat_id is not None and not isinstance(parent_chat_id, str):
return None
return {
"version": 1,
"context_key": ephemeral_pin[0],
"context_prompt": ephemeral_pin[1],
"channel_prompt": channel_prompt,
"parent_chat_id": parent_chat_id,
}

async def _persist_prompt_pins(
self, session_key: Optional[str], expected_session_id: Optional[str],
) -> None:
"""Persist changed human-turn pins before agent execution; failures stay cache-only."""
snapshot = self._prompt_pin_snapshot(session_key)
if snapshot is None or not session_key:
return
store = getattr(self, "session_store", None)
getter = getattr(store, "get_prompt_pin", None)
try:
if callable(getter) and getter(session_key) == snapshot:
return
except Exception:
logger.debug("Failed to compare persisted prompt pin for %s", session_key, exc_info=True)
try:
await self.async_session_store.set_prompt_pin(
session_key, snapshot, expected_session_id=expected_session_id,
)
except Exception:
# Durability protects cache continuity; a store outage must not block the user turn.
logger.debug("Failed to persist prompt pin for %s", session_key, exc_info=True)

def _pinned_session_context_prompt(
self, context, redact_pii: bool, session_key: Optional[str], *, internal: bool = False,
expected_session_id: Optional[str] = None,
) -> str:
"""Session-context prompt pinned per session: key hit → pinned bytes reused VERBATIM (immune
to renderer nondeterminism); key miss → re-render and re-pin (rename, topic edit, /sethome).
Expand All @@ -631,6 +711,10 @@ def _pinned_session_context_prompt(
it reuses an existing pin verbatim; with no pin yet it renders and pins as usual."""
_pin_state = self._peek_session_state(session_key) if session_key else None
_eph_pin = _pin_state.conversation.ephemeral_pin if _pin_state else None
if internal and _eph_pin is None:
self._rehydrate_prompt_pins(session_key, expected_session_id)
_pin_state = self._peek_session_state(session_key) if session_key else None
_eph_pin = _pin_state.conversation.ephemeral_pin if _pin_state else None
Comment on lines +714 to +717

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — validate current privacy policy before accepting a durable pin. On Telegram/WhatsApp/Signal, a pin created with privacy.redact_pii=False can be restored after restart when the current setting is True. The read fence succeeds for the same session; lines 718–719 then return the old unredacted text before the privacy-aware key/renderer is consulted. A cold runner on the base instead renders with True. This is separate from the compression-recovery finding.

Persist/validate a separate effective privacy-policy discriminator (including legacy-pin handling), not the whole current metadata key: internal sources intentionally lack human metadata. Unchanged-policy restarts should still reuse exact bytes.

Repository-native regression suggestion, to append to tests/gateway/test_internal_event_pin_wiring.py using its existing helpers/imports. This integration test is suggested, not executed here; the review body reports the separately executed bounded probe accurately.

@pytest.mark.asyncio
@pytest.mark.parametrize("first_internal", [True, False])
async def test_restart_pin_respects_new_redaction_setting(monkeypatch, first_internal):
    import gateway.run as gr

    config = {"privacy": {"redact_pii": False}}
    monkeypatch.setattr(gr, "_load_gateway_config", lambda: config)
    durable = {}
    raw_id = "123456789"  # synthetic fixture

    def source():
        return SessionSource(
            platform=Platform.TELEGRAM, chat_id=raw_id, user_id=raw_id,
            chat_type="dm", message_id="synthetic-message",
        )

    before = _make_runner(monkeypatch, durable_prompt_pin=durable)
    before.session_store.get_or_create_session.return_value.platform = Platform.TELEGRAM
    old_calls = []
    _capture(before, old_calls)
    await _drive(before, ((False, source()),))
    assert len(old_calls) == 1
    assert raw_id in old_calls[0]["context_prompt"]
    assert isinstance(durable.get("value"), dict)

    config["privacy"]["redact_pii"] = True
    after = _make_runner(monkeypatch, durable_prompt_pin=durable)
    after.session_store.get_or_create_session.return_value.platform = Platform.TELEGRAM
    new_calls = []
    _capture(after, new_calls)
    await _drive(after, ((first_internal, source()),))
    assert len(new_calls) == 1
    assert raw_id not in new_calls[0]["context_prompt"]

Also preserve the existing unchanged-policy byte-equality tests and add an old-format snapshot case. Current policy must win before the bytes reach _run_agent.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional execution evidence for this existing P1, at the unchanged head 8928355d002697108443ac7b0edfcd28b02a2738; this is a follow-up to the finding above, not another finding or another formal review.

I reran the cold-runner differential with the production build_session_context_prompt, ID-hashing helpers, source-description methods, and privacy-aware _ephemeral_change_key extracted from the pinned source, rather than a reduced renderer. A human turn renders under redact_pii=False; its real _prompt_pin_snapshot() output is JSON-round-tripped into a fresh host; an internal-first call then runs with redact_pii=True and the same expected session ID. The key itself changes when privacy changes, but the restored-pin return bypasses it.

Linux / CPython 3.13.5, separate processes for base and head, TZ=UTC LANG=C.UTF-8 PYTHONHASHSEED=0. Observed returned text for the synthetic identifier 5550123456:

base bac0c45d8593ed9d53a8e3fcacecdc920b71a2c4, internal-first, redact_pii=True:
**Source:** Telegram ("DM with user_54f87d384a95")
**User ID:** "user_54f87d384a95"
- `"origin"` → Back to this chat ("54f87d384a95")
renderer calls: 1; privacy assertion: PASS; process exit: 0

head 8928355d002697108443ac7b0edfcd28b02a2738, internal-first, redact_pii=True:
**Source:** Telegram ("DM with 5550123456")
**User ID:** "5550123456"
- `"origin"` → Back to this chat ("5550123456")
renderer calls: 0; privacy assertion: FAIL; process exit: 1
AssertionError: redact_pii=True returned raw pre-restart identifier bytes

At the head, all four discriminating controls behaved as expected: human-first after the policy change redacted; internal-first with no durable pin redacted; a different stored session ID rejected the snapshot and redacted; unchanged False policy reused the old bytes without rendering. The last case is the intended cache behavior, not a failure. The source-description, user-ID and origin-delivery fields all expose the same bypass; these are not three different bugs.

Evidence boundary: this remains source-isolated execution, not a complete gateway/SQLite/provider run. The cache methods and renderer execute; the session-state host and session-ID-fenced getter are explicit fixtures, and the routing record is JSON-round-tripped. The supported Telegram renderer branch is exercised; unrelated platform-note hooks and the full config loader are not. Source tracing verifies that _hmwa_prepare_turn reads the live flag and passes it into this helper, and _handle_message_with_agent passes the returned prepared.context_prompt to _run_agent (gateway/run_turn.py:2068–2081,2207–2211). I did not rerun the earlier review's 123-test suite or compression/SQLite probe in this environment, and I am not claiming live external disclosure or retroactive redaction of transcript history.

This strengthens the existing policy finding without changing its scope or severity. Keep same-policy exact-byte reuse and the session-ID fence; separately validate effective privacy/render-policy identity before promoting durable prompt bytes into the current turn. An opaque metadata hash alone cannot establish that an old snapshot is safe under the current policy.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #126137 by 62eb2b3. The pin now records the privacy.redact_pii it was rendered under, as a required field in both the in-memory pin (ephemeral_pin is (key, text, redact_pii)) and the durable snapshot. Pinned bytes are never reused under a different policy. That applies to internal turns after a restart and also within a running process, which had the same bypass until the next human turn. If the policy hasn't changed, restarts still reuse the exact bytes.

Your suggested test passes on the final head (f8954fe), both internal-first and human-first. The branch has test_internal_event_never_reuses_prompt_pin_from_another_privacy_policy, and removing the policy check makes it fail. On legacy snapshots: sanitize_prompt_pin refuses a pin without redact_pii, so it falls back to a fresh render. v1 was never released, so there are no older on-disk pins to migrate.

if internal and _eph_pin is not None:
return _eph_pin[1]
_eph_key = self._ephemeral_change_key(context, redact_pii)
Expand All @@ -643,6 +727,7 @@ def _pinned_session_context_prompt(

def _pinned_channel_inputs(
self, session_key: Optional[str], channel_prompt: Optional[str], source: SessionSource, *, internal: bool,
expected_session_id: Optional[str] = None,
):
"""``(channel_prompt, source)`` for this turn's agent run.

Expand All @@ -658,6 +743,10 @@ def _pinned_channel_inputs(
return channel_prompt, source
state = self._peek_session_state(session_key)
pin = state.conversation.channel_pin if state else None
if pin is None:
self._rehydrate_prompt_pins(session_key, expected_session_id)
state = self._peek_session_state(session_key)
pin = state.conversation.channel_pin if state else None
if pin is None:
return channel_prompt, source
pinned_prompt, pinned_parent = pin
Expand Down
4 changes: 3 additions & 1 deletion gateway/run_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1767,7 +1767,9 @@ async def _process_handoff(self, row: Dict[str, Any], profile_name: Optional[str
# Ensure a session_store entry exists for this key; switch_session then re-points it.
await self.async_session_store.get_or_create_session(dest.source)
# switch_session ends the prior session and reopens the CLI session under the new key.
switched = await self.async_session_store.switch_session(session_key, cli_session_id)
switched = await self.async_session_store.switch_session(
session_key, cli_session_id, preserve_prompt_pin=False,
)
if switched is None:
raise RuntimeError(f"could not switch session key {session_key} → {cli_session_id}")
# Evict the cached AIAgent (rebuild against the CLI session_id, like /resume) and clear stale
Expand Down
6 changes: 6 additions & 0 deletions gateway/run_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -2077,6 +2077,7 @@ async def _hmwa_prepare_turn(self, event, source, session_entry, session_key, _q
# the system prompt cannot drift turn-over-turn; a miss (thread rename, /sethome) re-renders.
context_prompt = self._pinned_session_context_prompt(
context, _redact_pii, session_key, internal=event.internal,
expected_session_id=session_entry.session_id,
)

# Per-turn notes ride the user message via the api_content sidecar, NOT context_prompt
Expand Down Expand Up @@ -2198,7 +2199,12 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
# Internal events reuse the last human turn's channel inputs (see _pinned_channel_inputs).
_turn_channel_prompt, _turn_source = self._pinned_channel_inputs(
session_key, event.channel_prompt, source, internal=event.internal,
expected_session_id=_run_start_session_id,
)
if not event.internal:
# Persist the coherent context+channel pair before execution: a crash during the
# human turn may be followed by an internal startup-resume on the next process.
await self._persist_prompt_pins(session_key, _run_start_session_id)
agent_result = await self._run_agent(
message=message_text, context_prompt=prepared.context_prompt, history=history, source=_turn_source,
session_id=_run_start_session_id, session_key=session_key,
Expand Down
83 changes: 83 additions & 0 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,32 @@ def sanitize_model_override(override: Optional[Dict[str, Any]]) -> Optional[Dict
return cleaned or None


_PROMPT_PIN_VERSION = 1


def sanitize_prompt_pin(pin: Any) -> Optional[Dict[str, Any]]:
"""Validated durable snapshot of the exact ephemeral inputs reused by internal turns."""
if not isinstance(pin, dict) or pin.get("version") != _PROMPT_PIN_VERSION:
return None
context_key = pin.get("context_key")
context_prompt = pin.get("context_prompt")
channel_prompt = pin.get("channel_prompt")
parent_chat_id = pin.get("parent_chat_id")
if not isinstance(context_key, str) or not context_key or not isinstance(context_prompt, str):
return None
if channel_prompt is not None and not isinstance(channel_prompt, str):
return None
if parent_chat_id is not None and not isinstance(parent_chat_id, str):
return None
return {
"version": _PROMPT_PIN_VERSION,
"context_key": context_key,
"context_prompt": context_prompt,
"channel_prompt": channel_prompt,
"parent_chat_id": parent_chat_id,
}


@dataclass
class SessionEntry:
"""Routing-index entry: maps a session key to its current session ID and metadata."""
Expand Down Expand Up @@ -533,6 +559,9 @@ class SessionEntry:
# "default" spelled out). The key namespace only says where the turn RUNS; after a restart this is
# what says which bot may deliver to it. None = unknown (row predates the field, or standalone).
transport_profile: Optional[str] = None
# Exact session-context/channel inputs from the last human turn. Append-only dataclass field so
# older positional construction of transport_profile keeps its meaning.
prompt_pin: Optional[Dict[str, Any]] = None

# Fields (de)serialized verbatim, in wire order (``from_dict`` reads them with
# ``data.get(name, <dataclass default>)``), split around the three ISO-datetime/token keys.
Expand Down Expand Up @@ -562,6 +591,10 @@ def to_dict(self) -> Dict[str, Any]:
if self.model_override:
# Defence-in-depth against an unsanitized dict stored directly.
result["model_override"] = sanitize_model_override(self.model_override)
if self.prompt_pin:
# Same defence-in-depth: routing JSON must never preserve malformed pin state.
if pin := sanitize_prompt_pin(self.prompt_pin):
result["prompt_pin"] = pin
if self.transport_profile:
result["transport_profile"] = self.transport_profile
if self.origin:
Expand Down Expand Up @@ -604,6 +637,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry":
last_resume_marked_at=_parse_iso(data.get("last_resume_marked_at")),
active_turn_token=token, active_turn_started_at=started_at,
model_override=sanitize_model_override(data.get("model_override")),
prompt_pin=sanitize_prompt_pin(data.get("prompt_pin")),
transport_profile=transport_profile if isinstance(transport_profile, str) and transport_profile else None,
**plain,
)
Expand Down Expand Up @@ -1110,6 +1144,48 @@ def get_model_override(self, session_key: str) -> Optional[Dict[str, str]]:
entry = self._entry_locked(session_key)
return dict(entry.model_override) if entry and entry.model_override else None

def set_prompt_pin(
self, session_key: str, pin: Optional[Dict[str, Any]], *,
expected_session_id: Optional[str] = None,
) -> bool:
"""Persist effective prompt inputs without letting a stale turn cross a boundary.

The candidate is durable before the in-memory entry is published. The expected session id
is the turn's launch identity: a concurrent reset or resume makes this a no-op instead of
writing the old conversation's system bytes onto the new one.
"""
cleaned = sanitize_prompt_pin(pin)
if pin is not None and cleaned is None:
return False
with self._lock:
entry = self._entry_locked(session_key)
if entry is None:
return False
if expected_session_id is not None and entry.session_id != expected_session_id:
return False
if entry.prompt_pin == cleaned:
return True
candidate = entry.to_dict()
if cleaned is None:
candidate.pop("prompt_pin", None)
else:
candidate["prompt_pin"] = cleaned
self._save_entry(session_key, entry_data=candidate, lock_held=True)
entry.prompt_pin = dict(cleaned) if cleaned is not None else None
return True

def get_prompt_pin(
self, session_key: str, *, expected_session_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Return the prompt pin only while the route still owns the caller's session."""
with self._lock:
entry = self._entry_locked(session_key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — this read loses the snapshot while loading a compression-recovered route.

_entry_locked() can run startup recovery before this getter examines the pin. When compression has committed but the routing entry still names its parent, _stale_entry_verdict() returns the child entry built by _create_entry_from_recovered_row(). That entry has prompt_pin=None; the loader replaces the original and saves it. Preserving pins in switch_session() does not cover this path.

At this head I drove a human turn through the real handler, committed compression through publish_compression_child() with a real lease, deleted the JSON mirror and opened a fresh store/runner. With route advancement before restart, the internal turn retained its channel hint and context. Without that advancement, recovery still selected the correct child, but the internal turn received channel_prompt=None and different context. No provider was contacted.

Preserve the snapshot when recovery proves the replacement is a compression descendant of its owner, and save the child route with it. Keep unrelated/new conversations and explicit boundaries from inheriting it. This regression needs both sides of the crash window, not only a fresh store whose route already names the live child.

Runnable regression — current head: 1 passed, 1 failed

Save as tests/gateway/test_prompt_pin_review_probe.py and run:

HERMES_PYTHON=/path/to/test-environment/bin/python bash scripts/run_tests.sh \
  tests/gateway/test_prompt_pin_review_probe.py -q -s --tb=short -j 1 --file-retries 0
"""Independent review probes for durable prompt-pin lifecycle boundaries."""
import pytest

from gateway.config import GatewayConfig, Platform
from gateway.session import SessionSource, SessionStore


@pytest.mark.parametrize("publish_route", [True, False], ids=["normal-advance", "crash-before-route-advance"])
@pytest.mark.asyncio
async def test_compression_restart_preserves_prompt_pin(tmp_path, monkeypatch, publish_route):
    import gateway.session as subject
    from tests.gateway.test_internal_event_pin_wiring import _make_runner, _capture, _drive, _human_source, _wake_source
    print("SUBJECT", subject.__file__)
    config = GatewayConfig()
    sessions = tmp_path / "sessions"
    source = _human_source()
    store = SessionStore(sessions_dir=sessions, config=config)
    before = _make_runner(monkeypatch, config)
    before.session_store = store
    calls_before = []
    _capture(before, calls_before)
    await _drive(before, ((False, source),), channel_prompt="human channel hint")
    entry = store.get_or_create_session(source)
    key, parent = entry.session_key, entry.session_id
    pin = store.get_prompt_pin(key, expected_session_id=parent)
    assert pin and pin["channel_prompt"] == "human channel hint"
    db = store._db
    child = "compressed-child"
    assert db.try_acquire_compression_lock(parent, "review-compressor")
    db.publish_compression_child(
        parent_session_id=parent, child_session_id=child, source="discord",
        messages=[{"role": "user", "content": "compressed continuation"}],
        compression_lock_holder="review-compressor",
    )
    if publish_route:
        assert store.advance_compression_session(key, parent, child)
    # Compression committed, but a process crash can precede routing publication.
    (sessions / "sessions.json").unlink(missing_ok=True)
    db.close()
    restarted = SessionStore(sessions_dir=sessions, config=config)
    try:
        actual = restarted.get_prompt_pin(key, expected_session_id=child)
        recovered = restarted._entries[key]
        print("RECOVERED", recovered.session_id, "PIN", actual)
        assert recovered.session_id == child
        after = _make_runner(monkeypatch, config)
        after.session_store = restarted
        calls_after = []
        _capture(after, calls_after)
        await _drive(after, ((True, _wake_source()),))
        assert len(calls_before) == len(calls_after) == 1
        print("TURN_CHANNEL", calls_before[0]["channel_prompt"], "->", calls_after[0]["channel_prompt"])
        print("CONTEXT_EQUAL", calls_before[0]["context_prompt"] == calls_after[0]["context_prompt"])
        assert calls_after[0]["channel_prompt"] == calls_before[0]["channel_prompt"]
        assert calls_after[0]["context_prompt"] == calls_before[0]["context_prompt"]
        assert actual == pin
    finally:
        restarted._db.close()

Observed for crash-before-route-advance:

RECOVERED compressed-child PIN None
TURN_CHANNEL human channel hint -> None
CONTEXT_EQUAL False
AssertionError: assert None == 'human channel hint'

The normal-advance control passes, with the channel hint retained and CONTEXT_EQUAL True. Linux, Python 3.14.7, isolated test homes, real SQLite and compression transaction; the existing harness captures provider execution at _run_agent.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #126137 by 87f8ef5. When restart recovery repoints a stale route, _stale_entry_verdict now carries prompt_pin over only if the recovered session is the ended parent's compression tip (_compression_tip_for_session_id), so unrelated or new conversations never inherit it. The repointed child is saved with the pin.

I ran your probe verbatim on the final head (f8954fe): both normal-advance and crash-before-route-advance pass. The branch also has a permanent store-level regression test, test_prompt_pin_follows_compression_child_recovered_after_crash. Removing the carry-over makes that test fail.

if entry is None:
return None
if expected_session_id is not None and entry.session_id != expected_session_id:
return None
return dict(entry.prompt_pin) if entry.prompt_pin else None

def reset_session(self, session_key: str, display_name: Optional[str] = None) -> Optional[SessionEntry]:
"""Force reset a session, creating a new session ID."""
with self._lock:
Expand Down Expand Up @@ -1224,13 +1300,16 @@ def remove_by_session_id(self, session_id: str) -> int:
# restart-resume freshness gate (#85709).
def switch_session(
self, session_key: str, target_session_id: str, *, expected_session_id: Optional[str] = None,
preserve_prompt_pin: bool = True,
) -> Optional[SessionEntry]:
"""Point a session key at an existing session ID (``/resume``): ends the current row and
reopens the target so resume matches the CLI.

``expected_session_id`` makes the repoint a compare-and-swap: ``None`` is returned when
the key no longer points at that session, so a caller that resolved against a snapshot
across an await (async-delegation re-pin) cannot overwrite a concurrent /new or /resume.
Prompt pins follow non-boundary repoints by default; /resume opts out explicitly because it
starts a different conversation on the same routing key.
"""
with self._lock:
old_entry = self._entry_locked(session_key)
Expand All @@ -1247,6 +1326,10 @@ def switch_session(
new_entry = self._replace_route_locked(
session_key, old_entry, target_session_id, _now(),
display_name=old_entry.display_name, model_override=old_entry.model_override,
prompt_pin=(
dict(old_entry.prompt_pin)
if preserve_prompt_pin and old_entry.prompt_pin is not None else None
),
)

if self._db_for_key(session_key) and old_entry.session_id:
Expand Down
4 changes: 3 additions & 1 deletion gateway/slash_commands_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,9 @@ async def _handle_resume_command(self, event: MessageEvent) -> str:
if current_entry.session_id == target_id:
return t("gateway.resume.already_on", name=name)
self._release_running_agent_state(session_key)
new_entry = await self.async_session_store.switch_session(session_key, target_id)
new_entry = await self.async_session_store.switch_session(
session_key, target_id, preserve_prompt_pin=False,
)
if not new_entry:
return t("gateway.resume.switch_failed")
# Conversation boundary: all conversation-scoped state + security state in one funnel call.
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway/test_handoff_secondary_profile_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def _make_multiplex_runner():
platform=Platform.TELEGRAM, chat_type="dm",
))

async def _switch(key, sid):
async def _switch(key, sid, *, preserve_prompt_pin=True):
captured["session_key"] = key
return SessionEntry(
session_key=key, session_id=sid,
Expand Down
49 changes: 48 additions & 1 deletion tests/gateway/test_handoff_thread_session_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig
from gateway.run import GatewayRunner
from gateway.session import SessionSource, build_session_key
from gateway.session import SessionEntry, SessionSource, build_session_key


def _organic_discord_thread_key(thread_id: str, parent_id: str, user_id: str) -> str:
Expand Down Expand Up @@ -212,3 +212,50 @@ def test_matrix_handoff_key_matches_the_thread_reply_key(is_dm, monkeypatch):
_dest, handoff = _handoff_destination(Platform.MATRIX, room_id, "$seed", None, adapter)
assert handoff == _organic_matrix_reply_key(adapter, room_id, "$seed")
assert handoff.split(":")[3] == ("dm" if is_dm else "group")

@pytest.mark.asyncio
async def test_cli_handoff_drops_previous_route_prompt_pin():
"""CLI handoff is a conversation boundary: the synthetic internal turn must not inherit
prompt bytes from whatever conversation previously occupied the destination routing key."""
runner = object.__new__(GatewayRunner)
source = SessionSource(
platform=Platform.DISCORD,
chat_id="handoff-thread",
chat_type="thread",
user_id="system:handoff",
thread_id="handoff-thread",
)
dest = SimpleNamespace(
source=source,
platform_name="discord",
home=SimpleNamespace(chat_id="parent-channel"),
effective_thread_id="handoff-thread",
transport=SimpleNamespace(send=AsyncMock()),
)
runner._handoff_resolve_destination = AsyncMock(return_value=dest)
runner._handoff_session_key = MagicMock(return_value="agent:main:discord:thread:handoff-thread:handoff-thread")
runner._evict_cached_agent = MagicMock()
runner._release_running_agent_state = MagicMock()
runner._handle_message = AsyncMock(return_value=None)

store = MagicMock()
store.get_or_create_session.return_value = SessionEntry(
session_key="agent:main:discord:thread:handoff-thread:handoff-thread",
session_id="previous-gateway-session",
created_at=None,
updated_at=None,
)
store.switch_session.return_value = SessionEntry(
session_key="agent:main:discord:thread:handoff-thread:handoff-thread",
session_id="cli-session",
created_at=None,
updated_at=None,
)
runner.session_store = store

await runner._process_handoff(
{"id": "cli-session", "title": "work", "handoff_platform": "discord"}
)

assert store.switch_session.call_args.kwargs["preserve_prompt_pin"] is False

Loading