diff --git a/plugins/_chat_naming/AGENTS.md b/plugins/_chat_naming/AGENTS.md index 00778213c..94cc59e54 100644 --- a/plugins/_chat_naming/AGENTS.md +++ b/plugins/_chat_naming/AGENTS.md @@ -20,11 +20,13 @@ - Utility Model input contains the current name and user messages only; assistant work and tool results are excluded. - The complete naming prompt must remain within 70% of the effective Utility Model context window, preserving the newest user context when trimming is required. - `always` is the default and considers the latest user message plus recent user context; `once` names only unnamed user chats from their first user message. Preserve explicitly saved modes. +- Manual renames (API `save` and `/rename `) set persisted context data `chat_naming_manual` before the chat is saved. Automatic naming skips that chat while the flag is set. `/rename auto` clears the flag, then generates and saves a name. +- Automatic naming does not save when `context.name` changed between scheduling and the Utility Model response. - Generated names are concise and normalized before persistence. - Renaming a parallel child updates both its context name and sidebar label. - Automatic naming skips delegated child contexts; their labels belong to `call_subordinate` or an explicit rename action, in both direct and parallel execution. - Manual task renames update both scheduler metadata and the task context name. -- `/rename auto` uses the same generation and persistence helpers as the rename modal; any other non-empty argument is saved as the custom chat name. +- `/rename auto` clears `chat_naming_manual` and uses the same generation and persistence helpers as the rename modal; any other non-empty argument is saved as the custom chat name and sets the flag. ## Work Guidance diff --git a/plugins/_chat_naming/README.md b/plugins/_chat_naming/README.md index bae9a2dff..a567cf53c 100644 --- a/plugins/_chat_naming/README.md +++ b/plugins/_chat_naming/README.md @@ -2,8 +2,8 @@ Chat Naming adds a standard sidebar action for manually renaming chats and tasks. Its modal can ask the chat's configured Utility Model to suggest a concise name from recent user messages. -Automatic naming is configured per project and agent profile. By default, it reviews the name after every user message using recent user-only context, changing it when the topic changes. It runs after the response, so its Utility Model call never delays the Main Model. Select **Only once** to name an unnamed chat from its first user message instead. Previously saved settings are preserved; switch existing **Only once** configurations to **After every user message** to enable topic updates. +Automatic naming is configured per project and agent profile. By default, it reviews the name after every user message using recent user-only context, changing it when the topic changes. A chat renamed by hand stays as named until automatic naming is turned back on for that chat. It runs after the response, so its Utility Model call never delays the Main Model. Select **Only once** to name an unnamed chat from its first user message instead. Previously saved settings are preserved; switch existing **Only once** configurations to **After every user message** to enable topic updates. -Use `/rename New Chat Name` to rename the current chat directly, or `/rename auto` to generate and save a name with its configured Utility Model. +Use `/rename New Chat Name` to rename the current chat directly. That manual name is kept until `/rename auto`, which generates a name with the configured Utility Model and turns automatic naming back on for the chat. Subordinate chats keep their delegation labels. Automatic naming does not overwrite them; an explicit rename action or a new `call_subordinate` name can change them. diff --git a/plugins/_chat_naming/api/chat_name.py b/plugins/_chat_naming/api/chat_name.py index d3772bbd7..e5cef7ac7 100644 --- a/plugins/_chat_naming/api/chat_name.py +++ b/plugins/_chat_naming/api/chat_name.py @@ -38,10 +38,12 @@ class ChatName(ApiHandler): name = naming.normalize_manual_name(input.get("name", "")) if kind == "task": await self._save_task_name(item_id, name) + context.set_data(naming.MANUAL_NAME_KEY, True) context.name = name save_tmp_chat(context) mark_dirty_all(reason="plugins._chat_naming.save_task_name") else: + context.set_data(naming.MANUAL_NAME_KEY, True) naming.save_context_name(context.agent0, name) return {"ok": True, "name": name} return Response(f"Unknown action: {action}", 400) diff --git a/plugins/_chat_naming/commands/rename_command.py b/plugins/_chat_naming/commands/rename_command.py index 7295ed306..2c1d09da5 100644 --- a/plugins/_chat_naming/commands/rename_command.py +++ b/plugins/_chat_naming/commands/rename_command.py @@ -17,6 +17,9 @@ async def run(payload: dict[str, Any]) -> dict[str, Any]: if raw_name.casefold() == "auto": raw_name = await naming.generate_name(agent) + agent.context.set_data(naming.MANUAL_NAME_KEY, False) + else: + agent.context.set_data(naming.MANUAL_NAME_KEY, True) name = naming.save_context_name(agent, raw_name) return _toast(f'Chat renamed to "{name}".') diff --git a/plugins/_chat_naming/extensions/python/monologue_end/_60_rename_chat.py b/plugins/_chat_naming/extensions/python/monologue_end/_60_rename_chat.py index cd3a0dd1c..979bcefee 100644 --- a/plugins/_chat_naming/extensions/python/monologue_end/_60_rename_chat.py +++ b/plugins/_chat_naming/extensions/python/monologue_end/_60_rename_chat.py @@ -5,6 +5,8 @@ from helpers.extension import Extension from helpers.notification import NotificationManager, NotificationPriority, NotificationType from plugins._chat_naming.helpers import naming +_UNSET_SCHEDULED_NAME = object() + class RenameChat(Extension): async def execute(self, loop_data: LoopData = LoopData(), **kwargs): @@ -18,6 +20,8 @@ class RenameChat(Extension): config = naming.get_config(self.agent) if not config["automatic_naming"]: return + if self.agent.context.get_data(naming.MANUAL_NAME_KEY): + return mode = config["automatic_naming_mode"] if mode == naming.MODE_ONCE and str(self.agent.context.name or "").strip(): @@ -37,6 +41,7 @@ class RenameChat(Extension): messages=messages, request_sequence=naming.latest_user_sequence(self.agent), only_if_unnamed=(mode == naming.MODE_ONCE), + scheduled_name=self.agent.context.name, ) ) @@ -46,6 +51,7 @@ class RenameChat(Extension): messages: list[str], request_sequence: int, only_if_unnamed: bool, + scheduled_name: object = _UNSET_SCHEDULED_NAME, ) -> None: if not self.agent: return @@ -60,6 +66,13 @@ class RenameChat(Extension): return if naming.latest_user_sequence(self.agent) != request_sequence: return + if ( + scheduled_name is not _UNSET_SCHEDULED_NAME + and self.agent.context.name != scheduled_name + ): + return + if self.agent.context.get_data(naming.MANUAL_NAME_KEY): + return if AgentContext.get(self.agent.context.id) is not self.agent.context: return naming.save_context_name(self.agent, new_name) diff --git a/plugins/_chat_naming/helpers/naming.py b/plugins/_chat_naming/helpers/naming.py index 2ffba4b33..dd0187408 100644 --- a/plugins/_chat_naming/helpers/naming.py +++ b/plugins/_chat_naming/helpers/naming.py @@ -11,6 +11,7 @@ from helpers.state_monitor_integration import mark_dirty_all PLUGIN_NAME = "_chat_naming" MODE_ONCE = "once" MODE_ALWAYS = "always" +MANUAL_NAME_KEY = "chat_naming_manual" RECENT_USER_MESSAGES = 4 GENERATED_NAME_LIMIT = 40 UTILITY_CONTEXT_INPUT_RATIO = 0.7 diff --git a/plugins/_chat_naming/tests/test_chat_naming.py b/plugins/_chat_naming/tests/test_chat_naming.py index 6f164d952..750ddb038 100644 --- a/plugins/_chat_naming/tests/test_chat_naming.py +++ b/plugins/_chat_naming/tests/test_chat_naming.py @@ -38,7 +38,12 @@ class _Agent: ) self.context.agent0 = self self.context.output_data = {} + self.context.data = {} self.context.get_output_data = self.context.output_data.get + self.context.get_data = lambda key, recursive=True: self.context.data.get(key) + self.context.set_data = lambda key, value, recursive=True: self.context.data.__setitem__( + key, value + ) self.history = _History(messages) self.config = SimpleNamespace(profile="agent0") self._response = response @@ -384,7 +389,13 @@ async def test_manual_task_rename_updates_scheduler_and_context(monkeypatch): dirty = [] monkeypatch.setattr(chat_name.AgentContext, "get", lambda _id: agent.context) monkeypatch.setattr(chat_name.TaskScheduler, "get", lambda: scheduler) - monkeypatch.setattr(chat_name, "save_tmp_chat", lambda context: saved.append(context.name)) + monkeypatch.setattr( + chat_name, + "save_tmp_chat", + lambda context: saved.append( + (context.name, context.get_data(naming.MANUAL_NAME_KEY)) + ), + ) monkeypatch.setattr(chat_name, "mark_dirty_all", lambda *, reason: dirty.append(reason)) response = await object.__new__(chat_name.ChatName).process( @@ -400,7 +411,8 @@ async def test_manual_task_rename_updates_scheduler_and_context(monkeypatch): assert response == {"ok": True, "name": "Daily Report"} assert scheduler.updated == [(agent.context.id, {"name": "Daily Report"})] assert agent.context.name == "Daily Report" - assert saved == ["Daily Report"] + assert saved == [("Daily Report", True)] + assert agent.context.data[naming.MANUAL_NAME_KEY] is True assert dirty == ["plugins._chat_naming.save_task_name"] @@ -409,3 +421,173 @@ async def test_chat_naming_endpoints_keep_default_auth_and_csrf_protection(): assert ChatName.requires_auth() is True assert ChatName.requires_csrf() is True + + +async def test_always_mode_does_not_schedule_or_save_after_manual_name(monkeypatch): + """A name set with /rename must not be replaced by always-mode auto-naming (#1941).""" + agent = _Agent( + [_Message({"user_message": "Explain Docker networking"}, sequence=1)], + response="Docker Networking", + ) + monkeypatch.setattr(naming.persist_chat, "save_tmp_chat", lambda *_args, **_kwargs: None) + monkeypatch.setattr(naming, "mark_dirty_all", lambda **_kwargs: None) + monkeypatch.setattr( + "plugins._model_config.helpers.model_config.get_utility_model_config", + lambda _agent: {"ctx_length": 1000}, + ) + monkeypatch.setattr( + naming.plugins, + "get_plugin_config", + lambda *_args, **_kwargs: { + "automatic_naming": True, + "automatic_naming_mode": "always", + }, + ) + + await rename_command.run( + { + "invocation": {"raw_arguments": "Manual Name"}, + "context": {"agent": agent}, + } + ) + assert agent.context.name == "Manual Name" + assert agent.context.data[naming.MANUAL_NAME_KEY] is True + + scheduled = [] + monkeypatch.setattr( + rename_chat.asyncio, "create_task", lambda coro: scheduled.append(coro) + ) + await rename_chat.RenameChat(agent=agent).execute() + monkeypatch.setattr(rename_chat.AgentContext, "get", lambda _id: agent.context) + if scheduled: + await scheduled[0] + + if scheduled and agent.context.name != "Manual Name": + pytest.fail( + "BUG_REPRODUCED: always-mode scheduled auto-naming and saved " + f"{agent.context.name!r} over the manual name" + ) + assert scheduled == [] + assert agent.context.name == "Manual Name" + + +async def test_api_save_sets_manual_flag_and_blocks_always_mode(monkeypatch): + from plugins._chat_naming.api import chat_name + + agent = _Agent( + [_Message({"user_message": "Name this chat"}, sequence=1)], + name="Greeting", + ) + monkeypatch.setattr(chat_name.AgentContext, "get", lambda _id: agent.context) + monkeypatch.setattr(naming.persist_chat, "save_tmp_chat", lambda *_args, **_kwargs: None) + monkeypatch.setattr(naming, "mark_dirty_all", lambda **_kwargs: None) + + response = await object.__new__(chat_name.ChatName).process( + { + "action": "save", + "kind": "chat", + "item_id": agent.context.id, + "name": " Manual Chat ", + }, + None, + ) + + assert response == {"ok": True, "name": "Manual Chat"} + assert agent.context.name == "Manual Chat" + assert agent.context.data[naming.MANUAL_NAME_KEY] is True + + scheduled = [] + monkeypatch.setattr( + naming.plugins, + "get_plugin_config", + lambda *_args, **_kwargs: { + "automatic_naming": True, + "automatic_naming_mode": "always", + }, + ) + monkeypatch.setattr( + rename_chat.asyncio, "create_task", lambda coro: scheduled.append(coro) + ) + await rename_chat.RenameChat(agent=agent).execute() + assert scheduled == [] + assert agent.context.name == "Manual Chat" + + +async def test_rename_auto_clears_manual_flag_and_allows_always_mode(monkeypatch): + agent = _Agent( + [_Message({"user_message": "Plan a launch"}, sequence=1)], + name="Manual Name", + ) + agent.context.set_data(naming.MANUAL_NAME_KEY, True) + monkeypatch.setattr(naming.persist_chat, "save_tmp_chat", lambda *_args, **_kwargs: None) + monkeypatch.setattr(naming, "mark_dirty_all", lambda **_kwargs: None) + + async def generate(target_agent): + assert target_agent is agent + return "Launch Plan" + + monkeypatch.setattr(rename_command.naming, "generate_name", generate) + result = await rename_command.run( + { + "invocation": {"raw_arguments": "auto"}, + "context": {"agent": agent}, + } + ) + + assert agent.context.get_data(naming.MANUAL_NAME_KEY) is False + assert agent.context.name == "Launch Plan" + assert result["effects"][0]["message"] == 'Chat renamed to "Launch Plan".' + + scheduled = [] + monkeypatch.setattr( + naming.plugins, + "get_plugin_config", + lambda *_args, **_kwargs: { + "automatic_naming": True, + "automatic_naming_mode": "always", + }, + ) + monkeypatch.setattr( + rename_chat.asyncio, "create_task", lambda coro: scheduled.append(coro) + ) + await rename_chat.RenameChat(agent=agent).execute() + assert len(scheduled) == 1 + scheduled[0].close() + + +async def test_name_change_while_pending_does_not_overwrite(monkeypatch): + agent = _Agent( + [_Message({"user_message": "Explain Docker"}, sequence=6)], + name="Greeting", + ) + scheduled = [] + monkeypatch.setattr( + naming.plugins, + "get_plugin_config", + lambda *_args, **_kwargs: { + "automatic_naming": True, + "automatic_naming_mode": "always", + }, + ) + monkeypatch.setattr( + rename_chat.asyncio, "create_task", lambda coro: scheduled.append(coro) + ) + await rename_chat.RenameChat(agent=agent).execute() + assert len(scheduled) == 1 + + async def generate(_agent, **kwargs): + _agent.context.name = "Manual Name" + return "Utility Name" + + saved = [] + monkeypatch.setattr(rename_chat.naming, "generate_name", generate) + monkeypatch.setattr( + rename_chat.naming, + "save_context_name", + lambda _agent, name: saved.append(name), + ) + monkeypatch.setattr(rename_chat.AgentContext, "get", lambda _id: agent.context) + await scheduled.pop() + + assert saved == [] + assert agent.context.name == "Manual Name"