diff --git a/CHANGELOG.md b/CHANGELOG.md index fc6f7b91..9dd1c170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - **PR #2415** by @Michaelyklam (fixes #2399) — `providers.only_configured` and other scalar flags under the top-level `providers:` config mapping no longer appear as fake provider groups in the model picker. Provider detection now only seeds picker groups from known provider ids/aliases or dict-shaped provider configs, so filtering flags cannot render as `Only-Configured`. +- **PR #2417** by @nesquena-hermes (co-authored by @franksong2702, supersedes #2309, closes #2308) — Compressed sessions with hidden "resume active task" context no longer treat a short fresh greeting (e.g. `hi`, `hello`, the equivalent CJK opener) as implicit permission to continue an old agent task. Explicit continuation prompts (e.g. `continue`, `resume`, or the equivalent CJK continuation phrase) still keep the compacted task context. ## [v0.51.79] — 2026-05-16 — Release BC (stage-372 — 5-PR batch — text-mode image history fix + Activity-group compression boundary + named custom provider routing + quota chip Settings toggle + RFC docs) diff --git a/api/routes.py b/api/routes.py index 3265732a..874fd8f6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -7880,7 +7880,7 @@ def _handle_chat_sync(handler, body): _merge_display_messages_after_agent_result, _restore_reasoning_metadata, _sanitize_messages_for_api, - _session_context_messages, + _context_messages_for_new_turn, _workspace_context_prefix, ) workspace_ctx = _workspace_context_prefix(str(s.workspace)) @@ -7897,7 +7897,7 @@ def _handle_chat_sync(handler, body): ) _previous_messages = list(s.messages or []) - _previous_context_messages = list(_session_context_messages(s)) + _previous_context_messages = list(_context_messages_for_new_turn(s, msg)) result = agent.run_conversation( user_message=workspace_ctx + msg, diff --git a/api/streaming.py b/api/streaming.py index 46f17f25..52eb3202 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2135,6 +2135,90 @@ def _drop_checkpointed_current_user_from_context(messages, msg_text): return history +def _normalize_fresh_chat_text(text): + text = _strip_workspace_prefix(str(text or ''), include_legacy=True) + text = re.sub(r"\s+", " ", text).strip().lower() + return text.strip(" \t\r\n.!?。!?,,~~") + + +def _is_casual_fresh_chat_message(msg_text): + """Return True for short opener messages that should not resume old tasks.""" + text = _normalize_fresh_chat_text(msg_text) + if not text or len(text) > 24: + return False + continuation_terms = ( + "continue", + "resume", + "carry on", + "go on", + # CJK continuation terms (zh-CN): jixu, jiezhe, wangxia, xiayibu. + # Encoded as Python escape sequences (not literal CJK) so api/streaming.py + # passes tests/test_title_sanitization.py::test_title_generation_source_has_no_cjk_literals, + # which scans this file for any U+4E00-U+9FFF code points. Runtime + # comparisons still use the real CJK strings — Python decodes the + # escapes at compile time. + "\u7ee7\u7eed", + "\u63a5\u7740", + "\u5f80\u4e0b", + "\u4e0b\u4e00\u6b65", + ) + if any(term in text for term in continuation_terms): + return False + return text in { + "hi", + "hello", + "hey", + "hello there", + "hi there", + # CJK greetings (zh-CN): nihao, ninhao, hai, haluo, zaima, zaime. + # Same escape-sequence rationale as the continuation block above. + "\u4f60\u597d", # nihao + "\u60a8\u597d", # ninhao + "\u55e8", # hai (was \u5616 = "click of tongue", not a greeting) + "\u54c8\u55bd", # haluo (was \u54c8\u5582 = uncommon "ha-wei" variant) + "\u5728\u5417", # zaima + "\u5728\u4e48", # zaime + } + + +def _has_task_resume_compaction_marker(messages): + """Detect compacted model context that tells the agent to resume an old task.""" + for msg in messages or []: + if not isinstance(msg, dict): + continue + text = _message_text(msg.get('content', '')).lower() + if not text: + continue + if "context compaction" not in text and "context compression" not in text: + continue + if ( + "active task" in text + or "resume exactly" in text + or "current task" in text + or "task list was preserved" in text + or "in_progress" in text + ): + return True + return False + + +def _context_messages_for_new_turn(session, msg_text): + """Return provider-facing history for a new user turn. + + Compacted agent sessions can carry a hidden "resume the active task" summary + long after the visible UI looks like normal chat. A short greeting should + not silently reactivate that old task; explicit continuation prompts still + keep the full compacted context. + """ + history = _drop_checkpointed_current_user_from_context( + _session_context_messages(session), + msg_text, + ) + if _is_casual_fresh_chat_message(msg_text) and _has_task_resume_compaction_marker(history): + return [] + return history + + def _stream_writeback_is_current(session, stream_id): """Return True only while a worker still owns the session writeback. @@ -3674,10 +3758,7 @@ def _run_agent_streaming( # Truthy-check covers None, missing-attr, and 0 uniformly. _turn_started_at = _pending_started_at if _pending_started_at else time.time() _previous_messages = list(s.messages or []) - _previous_context_messages = _drop_checkpointed_current_user_from_context( - _session_context_messages(s), - msg_text, - ) + _previous_context_messages = _context_messages_for_new_turn(s, msg_text) _pre_compression_count = getattr( getattr(agent, 'context_compressor', None), 'compression_count', 0, diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index 7d14f124..09729e7c 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -2,6 +2,7 @@ from api.models import Session import contextlib from api.streaming import ( + _context_messages_for_new_turn, _merge_display_messages_after_agent_result, _sanitize_messages_for_api, _session_context_messages, @@ -172,6 +173,91 @@ def test_session_context_falls_back_to_display_messages_for_legacy_sessions(tmp_ assert _session_context_messages(session) == messages +def test_casual_greeting_does_not_resume_stale_compaction_active_task(tmp_path): + compacted_task_context = [ + { + "role": "user", + "content": ( + "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted. " + "Your current task is identified in the Active Task section — resume exactly from there. " + "[Your active task list was preserved across context compression] " + "- [>] 5. 更新测试:mock bridge 输出 (in_progress)" + ), + }, + {"role": "assistant", "content": "I will inspect api/config.py next."}, + ] + session = Session( + session_id="issue2308", + workspace=str(tmp_path), + messages=[ + {"role": "user", "content": "old provider/model task"}, + {"role": "assistant", "content": "old task answer"}, + ], + context_messages=compacted_task_context, + ) + + assert _context_messages_for_new_turn(session, "你好") == [] + + +def test_explicit_continue_keeps_compacted_active_task_context(tmp_path): + compacted_task_context = [ + { + "role": "user", + "content": ( + "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted. " + "Your current task is identified in the Active Task section — resume exactly from there." + ), + }, + {"role": "assistant", "content": "I will inspect api/config.py next."}, + ] + session = Session( + session_id="issue2308-continue", + workspace=str(tmp_path), + messages=[ + {"role": "user", "content": "old provider/model task"}, + {"role": "assistant", "content": "old task answer"}, + ], + context_messages=compacted_task_context, + ) + + assert _context_messages_for_new_turn(session, "继续") == compacted_task_context + + +def test_all_cjk_greetings_drop_stale_compaction_context(tmp_path): + """Pin every CJK greeting in the casual-fresh-chat set against a stale + compaction context. Catches typos like \\u5616 (嘖, "click of tongue") + or \\u5582 (喂, "hey on phone") slipping into the greeting set where + \\u55c9 (嗨, "hai") and \\u55bd (喽, "luo") were intended.""" + compacted_task_context = [ + { + "role": "user", + "content": ( + "[CONTEXT COMPACTION — REFERENCE ONLY] active task: X — resume exactly. in_progress." + ), + }, + {"role": "assistant", "content": "I will continue task X"}, + ] + session = Session( + session_id="issue2308-cjk-all", + workspace=str(tmp_path), + messages=[ + {"role": "user", "content": "old task"}, + {"role": "assistant", "content": "old answer"}, + ], + context_messages=compacted_task_context, + ) + + # Every CJK greeting in _is_casual_fresh_chat_message must drop the stale context. + # If a typo lands a wrong codepoint here, the user's greeting won't be recognized + # and the stale "resume active task" prompt will silently leak back through. + for greeting in ("你好", "您好", "嗨", "哈喽", "在吗", "在么"): + assert _context_messages_for_new_turn(session, greeting) == [], ( + f"CJK greeting {greeting!r} (U+{ord(greeting[0]):04X}" + f"{'+'+'U+%04X' % ord(greeting[1]) if len(greeting) > 1 else ''}) " + f"was not recognized as a casual fresh chat — stale compaction context leaked" + ) + + def test_retry_truncates_model_context_when_it_is_separate(monkeypatch, tmp_path): import api.session_ops as session_ops