diff --git a/core/models.py b/core/models.py index 6914b20..1adae65 100644 --- a/core/models.py +++ b/core/models.py @@ -76,8 +76,20 @@ class Session: _session_manager._persist_message(self.id, message) def get_context_messages(self) -> List[Dict[str, Any]]: - """Get messages in format for LLM API.""" - return [msg.to_dict() for msg in self.history] + """Get messages in format for LLM API. + + Slash-command / setup replies are persisted to history so they render + in the transcript, but they are UI chatter (e.g. ``/setup ...`` and its + status lines) the user never meant as conversation. They carry + ``metadata.source == "slash"``; exclude them here so they never reach + the model. Display/history-load paths use the raw ``history`` and are + unaffected. + """ + return [ + msg.to_dict() + for msg in self.history + if (msg.metadata or {}).get("source") != "slash" + ] def get(self, key: str, default=None): """Dict-like access for compatibility.""" diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js index 97b3fb3..19bcd7a 100644 --- a/static/js/slashCommands.js +++ b/static/js/slashCommands.js @@ -5880,7 +5880,9 @@ async function handleSlashCommand(input) { let args = parts.slice(1); const ctx = _makeCtx(); let _userShown = false; - function _showUser() { if (!_userShown) { _userShown = true; _addMessage('user', input); _persistMsg('user', input); } } + // Tag the echoed command with source:'slash' so it renders in the transcript + // but is excluded from LLM context (get_context_messages), like the replies. + function _showUser() { if (!_userShown) { _userShown = true; _addMessage('user', input); _persistMsg('user', input, { source: 'slash' }); } } try { // --- Check for --help / -h on any command --- diff --git a/tests/test_session_context_excludes_slash.py b/tests/test_session_context_excludes_slash.py new file mode 100644 index 0000000..e9ff152 --- /dev/null +++ b/tests/test_session_context_excludes_slash.py @@ -0,0 +1,44 @@ +"""Regression: slash-command / setup messages must not reach LLM context. + +Slash replies (and the echoed `/setup ...` command) are persisted to history so +they render in the transcript, tagged ``metadata.source == "slash"``. They are +UI chatter the user never meant as conversation, so ``get_context_messages`` +(the LLM-API view) must exclude them while the raw history keeps them for +display. See issue #2634. +""" + +from core.models import Session, ChatMessage + + +def _session_with_slash(): + s = Session(id="s1", name="t", endpoint_url="http://x/v1", model="m") + s.add_message(ChatMessage("user", "hi, give me a recipe")) + s.add_message(ChatMessage("user", "/setup copilot", metadata={"source": "slash"})) + s.add_message(ChatMessage("assistant", "Starting GitHub Copilot sign-in...", metadata={"source": "slash"})) + s.add_message(ChatMessage("assistant", "Here is a recipe", metadata={"model": "m"})) + return s + + +def test_context_excludes_slash_messages(): + ctx = _session_with_slash().get_context_messages() + contents = [m["content"] for m in ctx] + assert "hi, give me a recipe" in contents + assert "Here is a recipe" in contents + # Slash command + its status reply are filtered out of LLM context. + assert "/setup copilot" not in contents + assert all("sign-in" not in c for c in contents) + assert len(ctx) == 2 + + +def test_history_still_keeps_slash_messages_for_display(): + s = _session_with_slash() + # Raw history (what the UI renders) is untouched. + assert len(s.history) == 4 + assert any(m.content == "/setup copilot" for m in s.history) + + +def test_no_metadata_messages_are_kept(): + s = Session(id="s2", name="t", endpoint_url="http://x/v1", model="m") + s.add_message(ChatMessage("user", "plain")) + s.add_message(ChatMessage("assistant", "reply")) + assert [m["content"] for m in s.get_context_messages()] == ["plain", "reply"]