fix(chat-naming): preserve manual names under always mode (#1941)

This commit is contained in:
Li-john1021 2026-10-13 13:30:28 +08:00
parent e3051fb584
commit 3e87172178
7 changed files with 208 additions and 5 deletions

View file

@ -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 <name>`) 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

View file

@ -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.

View file

@ -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)

View file

@ -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}".')

View file

@ -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)

View file

@ -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

View file

@ -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"