refactor(agent): surface-switch note lives in its own sibling; skip it where no sidecar exists

Move the six surface-switch helpers out of the conversation_loop facade
into agent/surface_switch.py (AGENTS.md: new behaviour goes in a topical
sibling), and fold the review findings on #104494:

- MoA and codex_app_server turns never stamp the api_content sidecar, so
  the staged note could not be read back from the transcript and was
  re-sent on every turn after a switch. Those modes now skip the note
  (stored prompt still reused).
- The announced surface was parsed with split(".") — a plugin platform
  with a dot in its name would never compare equal and re-stage the note
  every turn. The note now closes the name with a fixed terminator.
- One identity-line parser (identity_line_value) shared by
  _stored_prompt_matches_runtime and the switch detector instead of two
  copies of the runtime-boundary/rpartition logic; tool names via the
  existing tools.mcp_tool_agent._def_name; the transcript scan is bounded
  to the last 200 rows (it ran every turn over the whole history).
- consume_surface_switch_note reduced to a plain pop; developer-guide
  prompt-assembly.md updated (Platform is no longer an identity field);
  17 new tests trimmed to 10 (same-shape pin/retire variants folded).

Restoring Platform as an identity field still turns 5 tests red.
This commit is contained in:
kshitijk4poor
2026-09-08 20:19:57 +05:30
committed by kshitij
parent 4e7a49d182
commit 6cc177a76c
5 changed files with 164 additions and 220 deletions
+11 -150
View File
@@ -29,6 +29,7 @@ from agent.prompt_caching import (
strip_anthropic_tool_cache_control,
)
from agent.runtime_cwd import resolve_agent_cwd
from agent.surface_switch import identity_line_value, note_inert_pinned_tools, stage_surface_switch_note
from agent.turn_context import PreflightCompressionTimedOut, build_turn_context
from agent.turn_retry_state import TurnRetryState
# Phase helpers of the turn loop, bound at import so a source-tree swap cannot load a
@@ -677,7 +678,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
except Exception:
pass
agent._cached_system_prompt = agent._build_system_prompt(system_message)
_stage_surface_switch_note(agent, agent._cached_system_prompt, conversation_history)
stage_surface_switch_note(agent, agent._cached_system_prompt, conversation_history)
# Persist so the NEXT turn restores the new bytes verbatim (cache break is
# once per capability change). on_session_start not re-fired: continuation.
_persist_system_prompt(
@@ -691,7 +692,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
agent._cached_system_prompt = stored_prompt
# The reused bytes may describe the surface this conversation STARTED on; correct that
# at the tail of the request instead of rebuilding the prompt in front of it (#104414).
announced_switch = _stage_surface_switch_note(agent, stored_prompt, conversation_history)
announced_switch = stage_surface_switch_note(agent, stored_prompt, conversation_history)
# Same contract for tools[]: pin the array to the order this session already
# sent (tools freeze) instead of re-probing every check_fn on a fresh AIAgent.
# The pin holds ON the announcing turn too. tools[] is serialized AHEAD of the system
@@ -704,11 +705,11 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
try:
saved_tools = session_row.get("tool_names") if session_row else None
if saved_tools:
from tools.mcp_tool_agent import restore_agent_tool_prefix
built_for_this_surface = _agent_tool_names(agent)
from tools.mcp_tool_agent import _def_name, restore_agent_tool_prefix
built_for_this_surface = [_def_name(t) for t in agent.tools or []]
restore_agent_tool_prefix(agent, json.loads(saved_tools))
if announced_switch:
_note_inert_pinned_tools(agent, built_for_this_surface)
note_inert_pinned_tools(agent, built_for_this_surface)
except Exception:
logger.debug("tool prefix restore skipped", exc_info=True)
# Prompt-section callbacks are new-session-only; recover their frozen bytes
@@ -745,7 +746,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
# transcript by an earlier switch does not — retire it here too, or a rebuild for an
# unrelated reason (a model switch) would leave the newest interface statement in the
# request naming a surface the conversation has left (#104414).
_stage_surface_switch_note(agent, agent._cached_system_prompt, conversation_history)
stage_surface_switch_note(agent, agent._cached_system_prompt, conversation_history)
# Plugin hook: on_session_start — fired once for a brand-new session, not on continuation.
try:
@@ -773,157 +774,17 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
)
# A surface switch (desktop <-> TUI, a session resumed under a different host) changes only
# which interface renders the reply, but the surface guidance and the ``Platform:`` trailer are
# embedded in the persisted prompt. Rebuilding for it produced a system prompt that diverged
# from the cached one within its first blocks, so the ENTIRE request behind it re-prefilled —
# a 220K-token session came back at a 1% cache hit (#104414). The stored bytes are therefore
# kept and the CURRENT surface's guidance is delivered on the per-turn user-message channel
# instead: that lands after the cached prefix, is stamped into the byte-stable ``api_content``
# sidecar so later turns replay it unchanged, and the prompt itself converges at the next
# rebuild boundary (compaction).
_SURFACE_SWITCH_NOTE_PREFIX = "[System: This conversation is now being answered on a different interface: "
def _stored_prompt_platform(prompt: str) -> str:
"""The ``Platform:`` value the stored prompt was built with ("" when absent).
Parses only the authoritative identity portion before `# Hermes runtime environment`
so embedder prose (e.g. HERMES_ENVIRONMENT_HINT decoys) cannot shadow it.
"""
identity, runtime_marker, _ = prompt.rpartition(f"\n\n{RUNTIME_ENVIRONMENT_HEADING}\n\n")
runtime_marker = runtime_marker if prompt.endswith(RUNTIME_ENVIRONMENT_END) else ""
lines = (identity if runtime_marker else prompt).splitlines()
prefix = "Platform:"
matches = [line[len(prefix):].strip() for line in lines if line.startswith(prefix)]
return matches[-1] if matches else ""
def _transcript_row_texts(msg):
"""Every string the model actually saw for one transcript row: the wire sidecar first,
then the stored content (plain string, or the text parts of a multimodal list)."""
if not isinstance(msg, dict):
return
sidecar = msg.get("api_content")
if isinstance(sidecar, str):
yield sidecar
content = msg.get("content")
if isinstance(content, str):
yield content
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
yield part["text"]
def _last_announced_surface(conversation_history) -> str:
"""The surface named by the NEWEST surface note in the transcript ("" when there is none).
The note is stamped into the ``api_content`` sidecar and persisted, so it is the last thing
the model was TOLD it is running on — and it outranks the stored prompt's own ``Platform:``
trailer once a switch has been announced. Only the newest note counts: an older one names
a surface the conversation has since left. Reading it back is also what stops a fresh
AIAgent per turn (the gateway shape) from stacking one copy of the note per turn.
"""
for msg in reversed(conversation_history or []):
for text in _transcript_row_texts(msg):
if _SURFACE_SWITCH_NOTE_PREFIX in text:
return text.rsplit(_SURFACE_SWITCH_NOTE_PREFIX, 1)[1].split(".", 1)[0].strip()
return ""
def _agent_tool_names(agent) -> list:
"""The names in ``agent.tools``, in wire order ([] when the attribute is unreadable)."""
names = []
try:
for entry in getattr(agent, "tools", None) or []:
name = (entry.get("function") or {}).get("name", "") if isinstance(entry, dict) else ""
if name:
names.append(name)
except Exception:
return []
return names
def _note_inert_pinned_tools(agent, built_for_this_surface: list) -> None:
"""Name the pinned tools THIS surface did not build, at the end of the switch note.
The freeze keeps a previous surface's tools on the wire deliberately — removing them is the
one thing that would still re-prefill the request behind the preserved prompt — so the model
has to be TOLD they are inert here, or it plans around a ``focus_pane`` that a terminal turn
can only answer with ``tool_error("desktop only")``. Rides the note, so it is one-shot and
lands behind the cached prefix like the rest of the correction.
"""
surface_names = set(built_for_this_surface)
inert = [name for name in _agent_tool_names(agent) if name not in surface_names]
note = getattr(agent, "_surface_switch_note", "") or ""
if not inert or not note:
return
agent._surface_switch_note = note + (
"\n[System: These tools stay listed for this conversation (dropping them would discard "
"the cached request prefix) but were not loaded for this interface — expect a call to "
f"one of them to fail: {', '.join(inert)}.]"
)
def _stage_surface_switch_note(agent, prompt: str, conversation_history) -> bool:
"""Stage a correction when the request would otherwise misdescribe the current surface.
Compares the runtime surface against what the model was last told — the newest surface note
if one exists, else ``prompt``'s own ``Platform:`` trailer. Consulting the note matters in
both directions: switching BACK to the surface the prompt was built for (desktop -> tui ->
desktop) leaves the trailer agreeing with the runtime while a stale note still tells the
model otherwise, and a rebuild for an unrelated reason (a model switch) refreshes the prompt
but not that note.
The full surface guidance is attached only when the prompt itself is out of date; when the
prompt already describes the current surface the note just retires the stale one. Returns
whether it staged — the caller pairs that with re-persisting the tool names, because this is
the turn the surface's toolset changes under it.
"""
current = str(getattr(agent, "platform", "") or "").strip()
described = _stored_prompt_platform(prompt)
told = _last_announced_surface(conversation_history) or described
if not current or not told or told == current:
return False
from agent.system_prompt import platform_surface_hint
hint = platform_surface_hint(agent) if described != current else ""
where = "the guidance below" if hint else "the interface section in the system prompt above"
note = (
f"{_SURFACE_SWITCH_NOTE_PREFIX}{current}. Any earlier interface guidance in this "
f"conversation is superseded — follow {where} for formatting, file delivery and any "
"interface-specific capability.]"
)
agent._surface_switch_note = f"{note}\n{hint}" if hint else note
logger.info(
"Session %s switched surface %s -> %s; keeping the stored system prompt and delivering "
"the new surface guidance as a turn note (prefix cache preserved).",
agent.session_id, told, current,
)
return True
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
"""Return False when the persisted runtime-identity lines are stale."""
identity, runtime_marker, runtime = prompt.rpartition(f"\n\n{RUNTIME_ENVIRONMENT_HEADING}\n\n")
# Legacy prose may quote the heading, but only the new renderer ends in this boundary.
runtime_marker = runtime_marker if prompt.endswith(RUNTIME_ENVIRONMENT_END) else ""
# The final runtime block can contain embedder prose, not authoritative identity.
lines = (identity if runtime_marker else prompt).splitlines()
def line_value(label: str) -> str:
"""Last matching line wins — safe ONLY for volatile-tier fields at the END of the
prompt (embedded project context could shadow earlier fields; see ``host_info_value``)."""
prefix = f"{label}:"
matches = [line[len(prefix):].strip() for line in lines if line.startswith(prefix)]
return matches[-1] if matches else ""
def host_info_value(label: str) -> str:
"""New prompts delimit runtime hints; legacy prompts put them before context."""
prefix = f"{label}:"
host_lines = runtime.split("\n\n", 1)[0].splitlines() if runtime_marker else lines
host_lines = (runtime.split("\n\n", 1)[0] if runtime_marker else prompt).splitlines()
for idx, line in enumerate(host_lines):
if line.startswith("User home directory:"):
for candidate in host_lines[idx + 1: idx + 4]:
@@ -933,9 +794,9 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
# Model/provider identity, then cwd drift. A cwd change is a real content change (context
# files, the workspace snapshot and the coding posture are all resolved from it), so it
# still rebuilds; the runtime surface does not — see the note above.
# still rebuilds; the runtime surface does not (agent/surface_switch.py).
for label, attr in (("Model", "model"), ("Provider", "provider")):
stored = line_value(label)
stored = identity_line_value(prompt, label)
current = str(getattr(agent, attr, "") or "").strip()
if stored and current and stored != current:
return False
@@ -946,7 +807,7 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
return False
# Platform is deliberately NOT an identity field: a surface switch does not invalidate the
# stored bytes, it only makes their interface section out of date, and that is corrected by
# _stage_surface_switch_note without touching the cached prefix (#104414).
# agent.surface_switch.stage_surface_switch_note without touching the cached prefix (#104414).
return True
+138
View File
@@ -0,0 +1,138 @@
"""Surface switch without a prompt rebuild (#104414).
A surface switch (desktop <-> TUI, a session resumed under a different host) changes only which
interface renders the reply, but the surface guidance and the ``Platform:`` trailer are embedded
in the persisted system prompt. Rebuilding for it diverged the prompt within its first blocks,
so the ENTIRE request behind it re-prefilled — a 220K-token session came back at a 1% cache hit.
The stored bytes are therefore kept and the CURRENT surface's guidance is delivered on the
per-turn user-message channel instead: that lands after the cached prefix and is stamped into
the byte-stable ``api_content`` sidecar so later turns replay it unchanged. The prompt itself
converges at the next rebuild boundary (compaction).
"""
from __future__ import annotations
import logging
from typing import Any, Iterator, List
from agent.prompt_builder import RUNTIME_ENVIRONMENT_END, RUNTIME_ENVIRONMENT_HEADING
logger = logging.getLogger("run_agent")
_SURFACE_SWITCH_NOTE_PREFIX = "[System: This conversation is now being answered on a different interface: "
# Closes the surface name in the note; platform names are free-form for plugin platforms, so the
# terminator (not ".") delimits the parse.
_SURFACE_NAME_END = " — any earlier interface guidance"
# Only the newest note matters, and the note is re-stamped on the switch turn, so a bounded tail
# scan is enough; without a bound every turn of a never-switched session walks the whole transcript.
_NOTE_SCAN_TAIL = 200
def identity_line_value(prompt: str, label: str) -> str:
"""Last ``Label: value`` line in the authoritative identity portion of a persisted prompt.
Legacy prose may quote the runtime heading, but only the new renderer ENDS in the boundary;
the final runtime block is embedder prose, never identity, so it is excluded when present.
Last match wins — safe only for volatile-tier trailer fields at the end of the prompt."""
identity, runtime_marker, _ = prompt.rpartition(f"\n\n{RUNTIME_ENVIRONMENT_HEADING}\n\n")
runtime_marker = runtime_marker if prompt.endswith(RUNTIME_ENVIRONMENT_END) else ""
prefix = f"{label}:"
matches = [
line[len(prefix):].strip()
for line in (identity if runtime_marker else prompt).splitlines()
if line.startswith(prefix)
]
return matches[-1] if matches else ""
def _transcript_row_texts(msg: Any) -> Iterator[str]:
"""Every string the model actually saw for one transcript row: the wire sidecar first, then
the stored content (plain string, or the text parts of a multimodal list — where the note
lands as a durable text part because a list cannot take the string sidecar)."""
if not isinstance(msg, dict):
return
sidecar = msg.get("api_content")
if isinstance(sidecar, str):
yield sidecar
content = msg.get("content")
if isinstance(content, str):
yield content
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
yield part["text"]
def _last_announced_surface(conversation_history: Any) -> str:
"""The surface named by the NEWEST switch note in the transcript ("" when none).
Once a switch has been announced, that note — not the stored prompt's ``Platform:`` trailer —
is the last thing the model was told it runs on. Reading it back is also what keeps a fresh
AIAgent per turn (the gateway shape) from stacking one copy of the note per turn."""
for msg in reversed((conversation_history or [])[-_NOTE_SCAN_TAIL:]):
for text in _transcript_row_texts(msg):
if _SURFACE_SWITCH_NOTE_PREFIX in text:
tail = text.rsplit(_SURFACE_SWITCH_NOTE_PREFIX, 1)[1]
return tail.split(_SURFACE_NAME_END, 1)[0].strip()
return ""
def _agent_tool_names(agent: Any) -> List[str]:
from tools.mcp_tool_agent import _def_name
return [name for name in map(_def_name, getattr(agent, "tools", None) or []) if name]
def note_inert_pinned_tools(agent: Any, built_for_this_surface: List[str]) -> None:
"""Name, at the end of the staged note, the pinned tools THIS surface did not build.
The freeze keeps a previous surface's tools on the wire deliberately — removing them is the one
thing that would still re-prefill the request behind the preserved prompt — so the model has
to be TOLD they are inert here, or it plans around a ``focus_pane`` a terminal turn can only
answer with ``tool_error("desktop only")``."""
surface_names = set(built_for_this_surface)
inert = [name for name in _agent_tool_names(agent) if name not in surface_names]
note = getattr(agent, "_surface_switch_note", "") or ""
if not inert or not note:
return
agent._surface_switch_note = note + (
"\n[System: These tools stay listed for this conversation (dropping them would discard "
"the cached request prefix) but were not loaded for this interface — expect a call to "
f"one of them to fail: {', '.join(inert)}.]"
)
def stage_surface_switch_note(agent: Any, prompt: str, conversation_history: Any) -> bool:
"""Stage a one-shot correction when the request would otherwise misdescribe the surface.
Compares the runtime surface against what the model was last told — the newest switch note
if one exists, else ``prompt``'s own ``Platform:`` trailer. Consulting the note matters in
both directions: switching BACK to the prompt's surface (desktop -> tui -> desktop) leaves the
trailer agreeing with the runtime while a stale note still says otherwise, and a rebuild for
an unrelated reason (a model switch) refreshes the prompt but not that note. The full surface
guidance is attached only when the prompt itself is out of date; otherwise the note just
retires the stale one. Returns whether it staged.
MoA and codex_app_server turns never stamp the ``api_content`` sidecar, so the note could not
be read back and would be re-sent every turn; those modes keep the stored prompt and skip the
note entirely."""
if getattr(agent, "provider", None) == "moa" or getattr(agent, "api_mode", None) == "codex_app_server":
return False
current = str(getattr(agent, "platform", "") or "").strip()
described = identity_line_value(prompt, "Platform")
told = _last_announced_surface(conversation_history) or described
if not current or not told or told == current:
return False
from agent.system_prompt import platform_surface_hint
hint = platform_surface_hint(agent) if described != current else ""
where = "the guidance below" if hint else "the interface section in the system prompt above"
note = (
f"{_SURFACE_SWITCH_NOTE_PREFIX}{current}{_SURFACE_NAME_END} in this conversation is "
f"superseded — follow {where} for formatting, file delivery and any interface-specific "
"capability.]"
)
agent._surface_switch_note = f"{note}\n{hint}" if hint else note
logger.info(
"Session %s switched surface %s -> %s; keeping the stored system prompt and delivering "
"the new surface guidance as a turn note (prefix cache preserved).",
agent.session_id, told, current,
)
return True
+3 -9
View File
@@ -125,16 +125,10 @@ def consume_gateway_turn_context_notes(agent: Any) -> str:
def consume_surface_switch_note(agent: Any) -> str:
"""Pop the one-shot surface-change note staged by the system-prompt restore.
Rides the same user-message channel as the gateway's must-deliver notes: a session that
moved between surfaces keeps its stored system prompt (rebuilding it re-prefills the whole
request) and gets the new surface's guidance here, behind the cached prefix (#104414).
"""
"""Pop the one-shot surface-switch note staged by the system-prompt restore (#104414); it rides
the same user-message channel as the gateway's must-deliver notes, behind the cached prefix."""
note = getattr(agent, "_surface_switch_note", "") or ""
if hasattr(agent, "_surface_switch_note"):
with suppress(Exception):
agent._surface_switch_note = ""
agent._surface_switch_note = ""
return note if isinstance(note, str) else ""
+8 -59
View File
@@ -20,7 +20,8 @@ from unittest.mock import MagicMock
import pytest
from agent.conversation_loop import _SURFACE_SWITCH_NOTE_PREFIX, _restore_or_build_system_prompt
from agent.conversation_loop import _restore_or_build_system_prompt
from agent.surface_switch import _SURFACE_NAME_END, _SURFACE_SWITCH_NOTE_PREFIX, identity_line_value
def _make_agent(session_db=None, prebuilt_prompt: str = "BUILT_PROMPT"):
@@ -66,7 +67,7 @@ class TestSurfaceSwitch:
"""A transcript whose newest surface note says the model is on ``platform``."""
return [
{"role": "user", "content": "hi",
"api_content": f"hi\n\n{_SURFACE_SWITCH_NOTE_PREFIX}{platform}. superseded]"},
"api_content": f"hi\n\n{_SURFACE_SWITCH_NOTE_PREFIX}{platform}{_SURFACE_NAME_END} superseded]"},
{"role": "assistant", "content": "hello"},
]
@@ -100,7 +101,7 @@ class TestSurfaceSwitch:
def test_switch_stages_the_new_surface_guidance(self):
agent = self._restore(stored="desktop", current="tui")
note = agent._surface_switch_note
assert note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}tui.")
assert note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}tui{_SURFACE_NAME_END}")
# The correction carries the CURRENT surface's hint, so the model is not left
# following the desktop guidance still sitting in the reused prompt.
assert "terminal UI (TUI)" in note
@@ -108,12 +109,8 @@ class TestSurfaceSwitch:
def test_same_surface_stages_nothing(self):
assert self._restore(stored="cli", current="cli")._surface_switch_note == ""
def test_unknown_surface_stages_nothing(self):
assert self._restore(stored="", current="tui")._surface_switch_note == ""
def test_stored_prompt_platform_ignores_runtime_hint_decoys(self):
from agent.prompt_builder import RUNTIME_ENVIRONMENT_END, RUNTIME_ENVIRONMENT_HEADING
from agent.conversation_loop import _stored_prompt_platform
decoy = "Host: Example\nPlatform: tui\n"
stored = (
@@ -121,7 +118,7 @@ class TestSurfaceSwitch:
"Model: test-model\nProvider: openrouter\nPlatform: desktop\n\n"
f"{RUNTIME_ENVIRONMENT_HEADING}\n\n{decoy}\n\n{RUNTIME_ENVIRONMENT_END}"
)
assert _stored_prompt_platform(stored) == "desktop"
assert identity_line_value(stored, "Platform") == "desktop"
db = MagicMock()
db.get_session.return_value = {"system_prompt": stored}
@@ -131,7 +128,7 @@ class TestSurfaceSwitch:
agent._surface_switch_note = ""
agent._gateway_turn_context_notes = ""
_restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}])
assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}tui.")
assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}tui{_SURFACE_NAME_END}")
def test_not_restaged_once_the_transcript_carries_it(self):
# The note is stamped into the byte-stable api_content sidecar, and the gateway
@@ -139,11 +136,6 @@ class TestSurfaceSwitch:
agent = self._restore(stored="desktop", current="tui", history=self._announced("tui"))
assert agent._surface_switch_note == ""
def test_a_further_switch_is_announced_again(self):
# Only the NEWEST note counts: it names the surface the conversation has left.
agent = self._restore(stored="desktop", current="cli", history=self._announced("tui"))
assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}cli.")
def test_returning_to_the_prompts_own_surface_is_announced(self):
"""desktop -> tui -> desktop.
@@ -151,7 +143,7 @@ class TestSurfaceSwitch:
stage nothing and leave the model acting on the stale "you are on tui" note.
"""
agent = self._restore(stored="desktop", current="desktop", history=self._announced("tui"))
assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}desktop.")
assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}desktop{_SURFACE_NAME_END}")
# The prompt already describes this surface, so the note retires the stale one and
# points at the prompt instead of duplicating the whole hint.
assert "the interface section in the system prompt above" in agent._surface_switch_note
@@ -170,25 +162,7 @@ class TestSurfaceSwitch:
_restore_or_build_system_prompt(agent, None, self._announced("tui"))
agent._build_system_prompt.assert_called_once()
assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}desktop.")
def test_bot_chat_epoch_rebuild_also_retires_a_stale_note(self):
# A Bot Chat capability epoch refresh refreshes the prompt but not the
# note already sitting in the transcript.
from unittest.mock import patch
db = MagicMock()
db.get_session.return_value = {"system_prompt": self._stored("desktop")}
agent = _make_agent(session_db=db, prebuilt_prompt=self._stored("desktop"))
agent.platform = "desktop"
agent._platform_hint_overrides = None
agent._surface_switch_note = ""
with patch("agent.conversation_loop._bot_chat_prompt_stale", return_value=True):
_restore_or_build_system_prompt(agent, None, self._announced("tui"))
agent._build_system_prompt.assert_called_once()
assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}desktop.")
assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}desktop{_SURFACE_NAME_END}")
def test_tool_prefix_stays_pinned_on_the_turn_that_announces_a_switch(self):
"""tools[] is serialized ahead of the prompt this branch went out of its way to keep.
@@ -226,31 +200,6 @@ class TestSurfaceSwitch:
# Only the carried-over name: a tool this surface built is not inert.
assert "read_file" not in agent._surface_switch_note.split("were not loaded for this interface")[1]
def test_the_note_says_nothing_about_tools_when_the_pin_carries_none(self):
from unittest.mock import patch
with patch("tools.mcp_tool_agent.restore_agent_tool_prefix", lambda agent, names: False):
agent = self._restore(stored="desktop", current="tui", tool_names='["read_file"]',
tools=[self._tool("read_file")])
assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}tui.")
assert "were not loaded for this interface" not in agent._surface_switch_note
def test_tool_prefix_is_pinned_again_on_the_next_turn(self):
# The pin is never skipped, on the announcing turn or after it.
from unittest.mock import patch
with patch("tools.mcp_tool_agent.restore_agent_tool_prefix") as pin:
self._restore(stored="desktop", current="tui", history=self._announced("tui"),
tool_names='["read_file"]')
pin.assert_called_once()
def test_tool_prefix_is_still_pinned_on_the_same_surface(self):
from unittest.mock import patch
with patch("tools.mcp_tool_agent.restore_agent_tool_prefix") as pin:
self._restore(stored="cli", current="cli", tool_names='["read_file"]')
pin.assert_called_once()
def test_note_rides_the_user_message_channel_once(self):
from agent.turn_context import _merge_gateway_notes, consume_surface_switch_note
@@ -49,8 +49,10 @@ Consequence for stored prompts: `_stored_prompt_matches_runtime()` (`agent/conve
the first host-info paragraph after the rendered `# Hermes runtime environment` boundary, with a
closing marker at the absolute end distinguishing this layout from legacy prose quoting the heading.
The runtime boundary follows all project, operator, memory and plugin text, so examples in those
blocks do not masquerade as the runtime cwd. Model/provider/platform are read before the runtime
boundary, excluding embedder descriptions. Legacy prompts retain their original
blocks do not masquerade as the runtime cwd. Model/provider are read before the runtime boundary,
excluding embedder descriptions. `Platform:` is deliberately not an identity field: a surface switch
(desktop ↔ TUI) keeps the stored bytes and delivers the current surface's guidance as a one-shot note on
the per-turn user-message channel (`agent/surface_switch.py`), so the cached prefix survives (#104414). Legacy prompts retain their original
host-before-context anchor, so prompts persisted before the reorder still validate.
When `skip_context_files` is set (e.g., subagent delegation), SOUL.md is not loaded and the hardcoded `DEFAULT_AGENT_IDENTITY` is used instead.