fix(desktop,title): name and mark an unsent session, and the titler behind it - #82390
Merged
Conversation
Contributor
૮ >ﻌ< ა ci reviewran on b824f75
|
OutThisLife
force-pushed
the
bb/draft-status
branch
from
August 9, 2026 08:58
26cacb4 to
aa2fb9a
Compare
This was referenced Aug 9, 2026
A draft got no dot at all, so the one tab that has never done anything looked identical to a settled session. Give it the faintest mark the app has — a hollow outline, weakest claim in the dot's priority order, so the first thing that actually happens speaks over it. The row's own message_count is the tiebreaker for what counts as a draft: a session RESUMING also holds an empty message list for a moment, and calling that a draft flashes the wrong mark on a conversation with years of history in it.
OutThisLife
force-pushed
the
bb/draft-status
branch
from
August 9, 2026 09:20
04a37d1 to
cad4255
Compare
OutThisLife
enabled auto-merge
August 9, 2026 09:26
Every unsent tab was called "New session", so a row of them said nothing about which was which. Name each one from its composer, using the same first-line, word-boundary rule the backend's derive_title applies a moment after the draft is finally sent — so the name the tab already shows is the name it keeps. The title moves with the composer, which is far faster than a pane contribution should be re-registered. Panes can now render a tab label instead of declaring one, so the label subscribes to its own key and a rename repaints one string rather than the panes area.
The turn prologue titles every session, and it is shared by every agent — including the ones no person is reading. A cron job already names its own session after the job in its finally block, so the titler spent a side-LLM call per fire to write the delivery scaffolding over it for the length of the run. A delegated child's session is hidden from every picker, so a batch at max_concurrent_children paid N title calls for N names nobody opens. Both are the same class of run that already sets skip_memory to stay off the auxiliary path, so keep the titler off it too.
Titling is two-stage — a slice of the user's own words lands inline, the model's version replaces it a second later — and the platform rename lanes fired on both. That is two rate-limited calls to reach one name, and Discord allows two channel renames per ten minutes, so the throwaway could be the one that survived. The callback now carries which stage it is, and the lanes take the model's. The relay lane also asked where the reply landed at title time, which is before the model has answered: it polled the send-result cache for ten seconds and read the timeout as "never auto-threaded", so any turn with tool calls in it silently kept its raw thread name. Wait on the send itself instead — the adapter already owns that cache, so it can say when a reply arrives and, just as usefully, that one arrived carrying nothing.
The fast-model picker reads /v1/models to find the small model a provider currently serves, and it asked anonymously. Most of those endpoints need a key, so the fetch 401'd and the empty result read as "this provider has no small model" — the picker fell back to its curated list and never noticed. Worse, a failed fetch cached its empty result forever, so one bad moment during startup disabled live model discovery for the life of the process, and the processes that read this run for weeks. Give the failure an expiry and pass the provider's credentials. The bare family rungs (-mini, -flash, haiku) also picked whichever id sorted first, which is the oldest generation a provider still serves: gpt-3.5-mini over gpt-5.4-mini, claude-3-haiku over claude-haiku-4.5. Compare the digit runs as numbers so the rung meant to keep us current does.
An opener is not always titleable — an image with no caption, a compaction handoff, a bare slash command — and those sessions stayed unnamed for life, because the guard that stops re-titling a named session also stopped the nameless one from ever asking again. Let a later turn name a session that still has no title. The derived title also ran the collision dedupe inline on the turn. It is a slice of the user's own words, so it collides constantly — people open sessions with "hi" — and resolving "hi #47" is a widening scan on the critical path for a name the model replaces a second later. Decline it there and let the background stage, which can afford the scan, pick it up.
…S model Two lookalike gaps found auditing the titler. _MACHINE_PREFIXES missed the compressor's legacy summary opener and the "[System note:" injections, so a compacted or resumed session could be named after the note that carried it. Take the summary prefix from the compressor that emits it rather than keeping a fourth local copy. The fast-model exclude list covered embedders but not the other non-chat siblings a provider names after its chat model — "gpt-4o-mini-tts" satisfies the "-mini" rung and cannot answer a prompt.
Switching models before sending the first real message titled the session "[System: The active model for this chat has…" instead of the user's actual question. `_append_model_switch_marker` persists its notice with `role="user"` because strict OpenAI-compatible providers reject a system message that is not first (#48338). Titling had no way to tell that apart from a genuine opening turn, which caused two distinct failures: 1. `_MACHINE_PREFIXES` did not cover the marker. Its `[System: ` prefix matches none of `[CONTEXT COMPACTION`, `[Runtime note:`, or `[SYSTEM]` (different case, no closing bracket), so `is_titleable_user_message()` returned True and the marker was formatted into the title. 2. `maybe_auto_title()` counted the marker as a user message. With the marker present, the first real question arrived at `user_msg_count == 2` and the `> 1` guard returned early, so the session was never titled at all and its `title` column stayed NULL. Fixing only (1) would therefore have traded a wrong title for a permanently missing one. Add the marker prefix to `_MACHINE_PREFIXES` (kept in sync with `tui_gateway.server._MODEL_SWITCH_MARKER_PREFIX`) and count only titleable user messages when detecting the opening turn. The guard stays narrow: ordinary user text that happens to start with "[System:" still titles normally. Adds 6 regression tests, verified to fail without the fix.
Folds the model-switch fix in with the untitled retry. They answer different halves and each is wrong alone: counting alone left a session that merely opened with machinery nameless forever, because nothing reconsidered it, and the stored title alone would never title at all on a store too old to report one. Skip only when both agree — past the opening turn, and already named. Counting a turn now judges a multimodal one on its text, so "here's a screenshot, fix the login" counts as the question it is rather than reading as machinery and undercounting the conversation. Co-authored-by: yy28 <yy28@vip.sina.com>
OutThisLife
force-pushed
the
bb/draft-status
branch
from
August 9, 2026 09:34
9004a38 to
b824f75
Compare
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
fix(desktop,title): name and mark an unsent session, and the titler behind it
33hodl
pushed a commit
to 33hodl/hermes-agent
that referenced
this pull request
Aug 12, 2026
fix(desktop,title): name and mark an unsent session, and the titler behind it
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An unsent session in the desktop app had no name and no status mark: every draft tab read "New session" and carried no dot, so a row of them said nothing about which was which or that anything was there at all. Fixing that meant looking at the session titler behind it, which turned out to be titling machine-driven runs, renaming Discord threads twice, and picking its own model off a catalog it could never read.
Drafts
A draft now names itself from its composer, using the same first-line, word-boundary rule the backend's
derive_titleapplies once the message is finally sent — so the name the tab already shows is the name it keeps. It also gets the faintest dot the app has, a hollow outline, ranked weakest so the first thing that actually happens speaks over it.The title moves with the composer, which is much faster than a pane contribution should be re-registered. Panes can now render a tab label instead of declaring one, so the label subscribes to its own key and a rename repaints one string rather than the whole panes area.
Deciding what counts as a draft rests on the session row's
message_count: a session resuming also holds an empty message list for a moment, and calling that a draft would flash the wrong mark on a conversation with years of history in it.The titler behind it
/v1/modelsanonymously; most of those endpoints need a key, and the 401 read as "this provider has no small model". A failed fetch also cached its empty result forever, so one blip during startup disabled live model discovery for the life of the process.-mini,-flashandhaikumatched whichever id sorted first, which is the oldest a provider still serves:gpt-3.5-miniovergpt-5.4-mini,claude-3-haikuoverclaude-haiku-4.5.Test plan
scripts/run_tests.sh tests/agent/test_title_generator.py tests/agent/test_turn_context.py tests/agent/test_auxiliary_client.py tests/gateway/relay/test_relay_threads.py tests/gateway/test_session_title_rename_lane.py tests/hermes_cli/test_sale_pricing.py tests/acp/test_server.py— 283 passednpx tsc --noEmitinapps/desktop⌘Ttab, type, watch the tab rename and the hollow dot sit next to it; send, watch the backend title take overIssues
Supersedes #82207 by @yy28, cherry-picked so their authorship survives in the history. Their fix is the right one for the model-switch marker: the narrow prefix rather than a broad
[System:match, which their own test shows would have swallowed a real"[System: my own note] how do I …"question. Their guard keeping the prefix in sync with the gateway constant is kept too.Closes #82206 — switching models before the first message titled the session after the switch marker, and left it
NULL-titled for good.Closes #76842 — sessions permanently untitled once the history holds technical
role="user"rows (compaction placeholders, background-process notices, image descriptions). The count guard treated them as real turns and, past the threshold, returned forever with no log line.Those two are the same bug seen from both ends, and each half-fix is wrong alone. Filtering the count leaves a session that merely opened with machinery nameless forever, because nothing reconsidered it. Trusting the stored title alone would never title at all on a store too old to report one. So the guard now needs both to agree: past the opening turn, and already named. Counting a turn also reads a multimodal one by its text, so "here's a screenshot, fix the login" counts as the question it is.
Supersedes #61077 by @we11as22. Its provisional title and its
compare_and_set_session_title()race fix both landed on main already, but it called the rename-stage bug correctly and nothing had picked it up: renames should spend on the final title only. That is here, arrived at independently before I found the PR. The shape differs — rather than a secondprovisional_title_callbackthreaded through the call chain, the existing callback carries which stage produced the title, so a future stage costs no new parameter.Supersedes #55246 by @DavidMetcalfe, closed as implemented on main —
derive_title()+apply_instant_title()are the heuristic instant title it proposed.Relates to #55201, whose phase 1 is on main and whose "no title at all when the LLM call fails" is finished off here. Phase 2 (learning from past titles and manual renames) is untouched, so the issue stays open.
Relates to #72452 — untitled compaction children render their preview instead; the retry above gives them a real title, though the desktop fallback itself is untouched.
Not fixed here, listed so they aren't assumed: #48359 (skill-invocation scaffolding as the title — needs
extract_user_instruction_from_skill_messageapplied to the preview and the titler input) and #82339 (vision descriptions prepended to the opener).