diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 74e9feda2e3..a46789737a3 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -391,6 +391,27 @@ def conversation_history_after_compression(agent: Any, messages: list) -> Option return None +def _ensure_compressed_has_user_turn(original_messages: list, compressed: list) -> None: + """Preserve a real user turn when a compressor returns assistant/tool-only context.""" + if any(isinstance(msg, dict) and msg.get("role") == "user" for msg in compressed): + return + for msg in reversed(original_messages): + if not isinstance(msg, dict) or msg.get("role") != "user": + continue + restored = dict(msg) + restored.pop("_db_persisted", None) + compressed.append(restored) + return + compressed.append({ + "role": "user", + "content": ( + "Continue from the compressed conversation context above. " + "This marker exists because the compacted transcript contained " + "no preserved user turn." + ), + }) + + def compress_context( agent: Any, messages: list, @@ -647,6 +668,7 @@ def compress_context( todo_snapshot = agent._todo_store.format_for_injection() if todo_snapshot: compressed.append({"role": "user", "content": todo_snapshot}) + _ensure_compressed_has_user_turn(messages, compressed) agent._invalidate_system_prompt() new_system_prompt = agent._build_system_prompt(system_message) diff --git a/tests/agent/test_compression_concurrent_fork.py b/tests/agent/test_compression_concurrent_fork.py index 9652af8d1af..7fd5ca3117d 100644 --- a/tests/agent/test_compression_concurrent_fork.py +++ b/tests/agent/test_compression_concurrent_fork.py @@ -202,6 +202,40 @@ def test_skipped_compression_returns_messages_unchanged(tmp_path: Path) -> None: agent.context_compressor.compress.assert_not_called() +def test_compression_restores_user_turn_when_compressor_drops_all_users(tmp_path: Path) -> None: + """Provider chat templates need at least one user message after compaction. + + A plugin or future compressor can legally return a compacted context made + only of assistant/tool summary rows. Before the guard in + ``compress_context``, that transcript went straight into the next API call; + LM Studio / llama.cpp Jinja templates then failed with "No user query found + in messages." Preserve the last real user turn from the pre-compression + transcript instead of inventing a new active request. + """ + db = SessionDB(db_path=tmp_path / "state.db") + parent_sid = "NO_USER_AFTER_COMPRESS" + db.create_session(parent_sid, source="cli") + + agent = _build_agent_with_db(db, parent_sid) + agent.context_compressor.compress.side_effect = lambda *_a, **_kw: [ + { + "role": "assistant", + "content": "[CONTEXT COMPACTION] earlier work was summarized", + } + ] + messages = [ + {"role": "user", "content": "first request"}, + {"role": "assistant", "content": "first answer"}, + {"role": "user", "content": "please continue from here"}, + {"role": "assistant", "content": "working"}, + ] + + compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000) + + user_messages = [msg for msg in compressed if msg.get("role") == "user"] + assert user_messages == [{"role": "user", "content": "please continue from here"}] + + def test_lock_refresh_keeps_owner_live_past_initial_ttl(tmp_path: Path, monkeypatch) -> None: """The owning compression call must keep its lease alive while it runs.""" real_try_acquire = SessionDB.try_acquire_compression_lock