Merge pull request #82390 from NousResearch/bb/draft-status
fix(desktop,title): name and mark an unsent session, and the titler behind it
This commit is contained in:
commit
774e9d4d59
|
|
@ -1907,7 +1907,7 @@ class HermesACPAgent(acp.Agent):
|
|||
# Auto-titling fires inside the turn prologue now; give the agent
|
||||
# this session's notifier so a new title reaches the client as a
|
||||
# session-info update instead of waiting for the next one.
|
||||
def _notify_title_update(_title: str) -> None:
|
||||
def _notify_title_update(_title: str, _source: str) -> None:
|
||||
if conn:
|
||||
loop.call_soon_threadsafe(
|
||||
asyncio.create_task,
|
||||
|
|
|
|||
|
|
@ -735,39 +735,86 @@ _FAST_MODEL_FAMILIES: tuple = (
|
|||
# opposite of what a titler wants; ":batch" is an async queue, not a live
|
||||
# endpoint; embedding models ("all-minilm") match "-mini" but aren't chat
|
||||
# models at all; ":free" tiers are heavily rate-limited and measured slowest.
|
||||
# The modality suffixes are the same trap as the embedders — a provider names
|
||||
# its speech and image endpoints after the chat model they're paired with, so
|
||||
# "gpt-4o-mini-tts" satisfies the "-mini" rung and cannot answer a prompt.
|
||||
_FAST_MODEL_EXCLUDE: tuple = (
|
||||
"thinking", "reason", "-r1", "minilm", ":batch", ":free",
|
||||
"o1-", "o3-", "o4-", "codex", "audio", "-vl", "embed",
|
||||
"-tts", "-transcribe", "-realtime", "-image", "-search-preview",
|
||||
)
|
||||
|
||||
|
||||
_VERSION_CHUNK_RE = re.compile(r"(\d+(?:\.\d+)?)")
|
||||
|
||||
|
||||
def _model_recency_key(model_id: str) -> tuple:
|
||||
"""Sort key that puts a family's newest release first (descending).
|
||||
|
||||
The rungs at the bottom of ``_FAST_MODEL_FAMILIES`` are bare family names —
|
||||
``-mini``, ``-flash``, ``haiku`` — and a provider serves every generation of
|
||||
those it hasn't retired. Compared as plain strings, the oldest wins:
|
||||
``gpt-3.5-mini`` sorts before ``gpt-5.4-mini``, and ``claude-3-haiku`` before
|
||||
``claude-haiku-4.5``. So the rung meant to keep us current on a provider's
|
||||
small tier was pinning us to its most obsolete member.
|
||||
|
||||
Splitting digit runs out and comparing them as numbers fixes both the
|
||||
generation order and the 9-vs-10 cliff a string sort walks off.
|
||||
"""
|
||||
chunks = []
|
||||
for index, part in enumerate(_VERSION_CHUNK_RE.split(model_id.lower())):
|
||||
if not part:
|
||||
continue
|
||||
# re.split with one capturing group alternates text, number, text, …
|
||||
chunks.append((1, float(part), "") if index % 2 else (0, 0.0, part))
|
||||
return tuple(chunks)
|
||||
|
||||
|
||||
def _fast_model_from_catalog(provider_id: str) -> str:
|
||||
"""Pick the fastest small model the provider ACTUALLY serves right now.
|
||||
|
||||
Reads the provider's live (cached) ``/v1/models`` catalog and returns the
|
||||
first ``_FAST_MODEL_FAMILIES`` match. Returns "" when the catalog is
|
||||
newest ``_FAST_MODEL_FAMILIES`` match. Returns "" when the catalog is
|
||||
unavailable or holds no small model, so the caller falls through to the
|
||||
provider's curated default. Never raises and never blocks on a cold
|
||||
network path — the underlying fetch is memory+disk cached with a
|
||||
last-known-good fallback.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import resolve_api_key_provider_credentials
|
||||
from hermes_cli.models import fetch_models_with_pricing
|
||||
from providers import get_provider_profile
|
||||
|
||||
profile = get_provider_profile(provider_id)
|
||||
base_url = str(getattr(profile, "base_url", "") or "").rstrip("/")
|
||||
# The provider's own credentials, because most ``/v1/models`` endpoints
|
||||
# are authenticated: fetched anonymously they 401, and the caller reads
|
||||
# that as "this provider serves no small model" and quietly falls back
|
||||
# to the curated default forever.
|
||||
api_key, base_url = "", ""
|
||||
try:
|
||||
creds = resolve_api_key_provider_credentials(provider_id) or {}
|
||||
api_key = str(creds.get("api_key", "")).strip()
|
||||
base_url = str(creds.get("base_url", "")).strip()
|
||||
except Exception:
|
||||
# Not an API-key provider, or nothing configured yet. The anonymous
|
||||
# fetch below still works for the catalogs that allow it.
|
||||
logger.debug("No credentials for %s catalog", provider_id, exc_info=True)
|
||||
|
||||
if not base_url:
|
||||
base_url = str(getattr(get_provider_profile(provider_id), "base_url", "") or "")
|
||||
base_url = base_url.rstrip("/")
|
||||
if not base_url:
|
||||
return ""
|
||||
# fetch_models_with_pricing appends its own /v1/models.
|
||||
if base_url.endswith("/v1"):
|
||||
base_url = base_url[:-3]
|
||||
catalog = fetch_models_with_pricing(base_url=base_url, timeout=3.0) or {}
|
||||
catalog = fetch_models_with_pricing(
|
||||
api_key=api_key or None, base_url=base_url, timeout=3.0
|
||||
) or {}
|
||||
except Exception:
|
||||
logger.debug("Fast-model catalog lookup failed for %s", provider_id, exc_info=True)
|
||||
return ""
|
||||
|
||||
ids = sorted(str(m) for m in catalog)
|
||||
ids = sorted((str(m) for m in catalog), key=_model_recency_key, reverse=True)
|
||||
for family in _FAST_MODEL_FAMILIES:
|
||||
for model_id in ids:
|
||||
lowered = model_id.lower()
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ import json
|
|||
import logging
|
||||
import re
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from agent.auxiliary_client import call_llm
|
||||
from agent.context_compressor import LEGACY_SUMMARY_PREFIX
|
||||
from agent.message_content import flatten_message_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -32,7 +34,18 @@ logger = logging.getLogger(__name__)
|
|||
# so silent-drops (e.g. OpenRouter 402 exhausting the fallback chain)
|
||||
# become visible instead of piling up as NULL session titles.
|
||||
FailureCallback = Callable[[str, BaseException], None]
|
||||
TitleCallback = Callable[[str], None]
|
||||
|
||||
# Callback signature: (title, source) -> None, where source is the provenance
|
||||
# the title was persisted under (``derived`` for the instant slice of the user's
|
||||
# own words, ``llm`` for the model's upgrade of it).
|
||||
#
|
||||
# Titling is two-stage, and the stage matters to the consumer. A local surface
|
||||
# wants both, so the sidebar renames instantly and sharpens a second later. A
|
||||
# consumer that spends a rate-limited remote call per title — renaming a Discord
|
||||
# thread, a Telegram topic — wants ``llm`` only: acting on both burns two calls
|
||||
# to end up at the same name, and on Discord (2 renames per 10 minutes per
|
||||
# channel) the throwaway one can be what survives.
|
||||
TitleCallback = Callable[[str, str], None]
|
||||
|
||||
# Validation callback: () -> bool. Called right before the LLM request in
|
||||
# generate_title(). Return False to skip — e.g. the user switched models
|
||||
|
|
@ -111,11 +124,24 @@ _CONTROL_WRAPPERS = (
|
|||
)
|
||||
|
||||
# Hermes' own machine-authored openers. A compaction handoff or a resumed
|
||||
# session must not be titled after the scaffolding that carried it.
|
||||
# session must not be titled after the scaffolding that carried it. The legacy
|
||||
# summary prefix comes from the compressor rather than a fourth local copy —
|
||||
# compaction still emits it, and a session named after it is named after us.
|
||||
_MACHINE_PREFIXES = (
|
||||
"[CONTEXT COMPACTION",
|
||||
LEGACY_SUMMARY_PREFIX,
|
||||
"[Runtime note:",
|
||||
"[System note:",
|
||||
"[SYSTEM]",
|
||||
# Model-switch marker from tui_gateway.server._append_model_switch_marker.
|
||||
# It is persisted with role="user" (strict OpenAI-compatible providers
|
||||
# reject a system message that is not first — #48338), so without this
|
||||
# entry it looks like a real opening turn: switching models before the
|
||||
# first real message titled the session
|
||||
# "[System: The active model for this chat has…" instead of the user's
|
||||
# actual question. Keep in sync with
|
||||
# tui_gateway.server._MODEL_SWITCH_MARKER_PREFIX.
|
||||
"[System: The active model for this chat has changed to ",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -392,7 +418,7 @@ def generate_title(
|
|||
return None
|
||||
|
||||
|
||||
def _persist_session_title(session_db, session_id, title, *, source):
|
||||
def _persist_session_title(session_db, session_id, title, *, source, dedupe=True):
|
||||
"""Persist a title at *source* authority, recovering from name collisions.
|
||||
|
||||
The write goes through ``set_auto_title`` (precedence check + write in one
|
||||
|
|
@ -401,6 +427,14 @@ def _persist_session_title(session_db, session_id, title, *, source):
|
|||
session (the unique-title index); rather than leave the session untitled
|
||||
(#50537), append a ``#N`` suffix via ``get_next_title_in_lineage``.
|
||||
|
||||
``dedupe=False`` re-raises that collision instead. The derived title is the
|
||||
one write on the turn's critical path, and it is also the one that collides
|
||||
constantly — it is a slice of the user's own words, and people open sessions
|
||||
with "hi" and "help me debug this". Scanning the lineage for the next free
|
||||
"hi #N" is a widening scan, run inline, for a name the model replaces a
|
||||
second later. The background stage picks the collision back up, so nothing
|
||||
is lost by declining it here.
|
||||
|
||||
Returns the title actually persisted, or None when a higher-authority
|
||||
title already held the row (nothing was written).
|
||||
"""
|
||||
|
|
@ -429,7 +463,7 @@ def _persist_session_title(session_db, session_id, title, *, source):
|
|||
return _set(title)
|
||||
except ValueError:
|
||||
next_title_fn = getattr(session_db, "get_next_title_in_lineage", None)
|
||||
if next_title_fn is None:
|
||||
if not dedupe or next_title_fn is None:
|
||||
raise
|
||||
deduped = next_title_fn(title)
|
||||
if not deduped or deduped == title:
|
||||
|
|
@ -458,11 +492,11 @@ def apply_instant_title(
|
|||
if not title:
|
||||
return None
|
||||
persisted = _persist_session_title(
|
||||
session_db, session_id, title, source="derived"
|
||||
session_db, session_id, title, source="derived", dedupe=False
|
||||
)
|
||||
if persisted and title_callback is not None:
|
||||
try:
|
||||
title_callback(persisted)
|
||||
title_callback(persisted, "derived")
|
||||
except Exception:
|
||||
logger.debug("Instant-title callback failed", exc_info=True)
|
||||
return persisted
|
||||
|
|
@ -573,25 +607,76 @@ def _auto_title_session(
|
|||
main_runtime=main_runtime,
|
||||
runtime_validator=runtime_validator,
|
||||
)
|
||||
source = "llm"
|
||||
if not title:
|
||||
return
|
||||
# No model title, so the derived one has to hold — and it may never have
|
||||
# been written, since the inline attempt declines a name collision
|
||||
# rather than scan the lineage on the turn's critical path. Off that
|
||||
# path the scan is affordable, so spend it here and leave the session
|
||||
# named rather than nameless.
|
||||
title = derive_title(user_message)
|
||||
source = "derived"
|
||||
if not title:
|
||||
return
|
||||
|
||||
try:
|
||||
persisted = _persist_session_title(
|
||||
session_db, session_id, title, source="llm"
|
||||
)
|
||||
persisted = _persist_session_title(session_db, session_id, title, source=source)
|
||||
if persisted is None:
|
||||
return
|
||||
logger.debug("Auto-generated session title: %s", persisted)
|
||||
if title_callback is not None:
|
||||
try:
|
||||
title_callback(persisted)
|
||||
title_callback(persisted, source)
|
||||
except Exception:
|
||||
logger.debug("Auto-title callback failed", exc_info=True)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to set auto-generated title: %s", e)
|
||||
|
||||
|
||||
def _is_real_user_turn(message: Any) -> bool:
|
||||
"""Whether a history entry is a question a person actually asked.
|
||||
|
||||
Hermes persists a lot of machinery under ``role="user"`` — compaction
|
||||
handoffs, model-switch markers, background-process notices — because strict
|
||||
OpenAI-compatible providers reject a system message that isn't first.
|
||||
Counting those as turns is what made a session that merely *opened* with one
|
||||
look like it was already past the point where titling applies.
|
||||
|
||||
A multimodal turn is judged on its text, so "here's a screenshot, fix the
|
||||
login" counts as the real question it is.
|
||||
"""
|
||||
if not isinstance(message, dict) or message.get("role") != "user":
|
||||
return False
|
||||
content = message.get("content")
|
||||
|
||||
return is_titleable_user_message(
|
||||
content if isinstance(content, str) else flatten_message_text(content)
|
||||
)
|
||||
|
||||
|
||||
def _session_is_untitled(session_db, session_id: str) -> bool:
|
||||
"""Whether the session still carries no title of any provenance.
|
||||
|
||||
Titling normally reads the opening message and nothing else, but an opener
|
||||
isn't always titleable: an image with no caption, a compaction handoff, a
|
||||
bare slash command. Those sessions stayed nameless for life — the same guard
|
||||
that stops us re-titling on every turn also stopped us ever trying again.
|
||||
This reopens the question on later turns, and only while the answer is still
|
||||
missing, so a named session asks nothing and pays nothing.
|
||||
|
||||
Answers False when it can't tell: an unreadable title is not a reason to
|
||||
start spending a model call per turn.
|
||||
"""
|
||||
getter = getattr(session_db, "get_session_title", None)
|
||||
if not callable(getter):
|
||||
return False
|
||||
try:
|
||||
return not str(getter(session_id) or "").strip()
|
||||
except Exception:
|
||||
logger.debug("Untitled check failed for %s", session_id, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def maybe_auto_title(
|
||||
session_db,
|
||||
session_id: str,
|
||||
|
|
@ -614,17 +699,18 @@ def maybe_auto_title(
|
|||
if not session_db or not session_id or not user_message:
|
||||
return
|
||||
|
||||
# Count user messages to detect the opening turn. ``conversation_history``
|
||||
# is the state BEFORE this turn's message is appended when called from the
|
||||
# turn prologue, and after it when called post-response, so accept both.
|
||||
# Entries are dicts; anything else means a caller passed the wrong
|
||||
# positional and titling must degrade quietly rather than raise.
|
||||
user_msg_count = sum(
|
||||
1
|
||||
for m in (conversation_history or [])
|
||||
if isinstance(m, dict) and m.get("role") == "user"
|
||||
)
|
||||
if user_msg_count > 1:
|
||||
# Count the real questions behind us to detect the opening turn.
|
||||
# ``conversation_history`` is the state BEFORE this turn's message is
|
||||
# appended when called from the turn prologue, and after it when called
|
||||
# post-response, so accept both.
|
||||
#
|
||||
# Two things have to be true to skip: we are past the opening turn AND the
|
||||
# session already has a name. Either alone gets it wrong. The count alone
|
||||
# left a session that opened with machinery permanently nameless, because
|
||||
# nothing reconsidered it. The title alone would never title at all on a
|
||||
# store too old to report one.
|
||||
user_msg_count = sum(1 for m in (conversation_history or []) if _is_real_user_turn(m))
|
||||
if user_msg_count > 1 and not _session_is_untitled(session_db, session_id):
|
||||
return
|
||||
|
||||
if not is_titleable_user_message(user_message):
|
||||
|
|
|
|||
|
|
@ -170,18 +170,36 @@ def append_notes_to_multimodal_content(content: Any, notes: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# Surfaces whose sessions must not be auto-titled. The prologue is shared by
|
||||
# EVERY agent, not only the ones a human is watching, so membership here is what
|
||||
# keeps the titler off machine-driven runs:
|
||||
#
|
||||
# - cron — the scheduler names its own session after the job in its `finally`
|
||||
# block, and the opener is the cron delivery hint, not a user's request.
|
||||
# Titling it writes that scaffolding as the visible name for the whole run and
|
||||
# bills a side-LLM call per fire, against the same job that sets
|
||||
# `skip_memory` / `skip_background_review` to avoid exactly that.
|
||||
# - subagent — a delegated child's session is hidden from every picker, so its
|
||||
# title is never read. A batch at `max_concurrent_children` would pay N title
|
||||
# calls for N names nobody sees.
|
||||
_UNTITLED_PLATFORMS = frozenset({"cron", "subagent"})
|
||||
|
||||
|
||||
def _maybe_title_session_at_turn_start(agent: Any, messages: List[Any]) -> None:
|
||||
"""Kick off auto-titling for this session's first user message.
|
||||
|
||||
Called from the turn prologue, so every surface (CLI, gateway, TUI/desktop,
|
||||
ACP) gets identical behavior without each one re-implementing the call.
|
||||
Fully defensive: titling is cosmetic and must never break a turn.
|
||||
Called from the turn prologue, so every surface a human reads (CLI, gateway,
|
||||
TUI/desktop, ACP) gets identical behavior without each one re-implementing
|
||||
the call. Fully defensive: titling is cosmetic and must never break a turn.
|
||||
"""
|
||||
session_db = getattr(agent, "_session_db", None)
|
||||
session_id = getattr(agent, "session_id", None)
|
||||
if not session_db or not session_id:
|
||||
return
|
||||
|
||||
if str(getattr(agent, "platform", "") or "").lower() in _UNTITLED_PLATFORMS:
|
||||
return
|
||||
|
||||
try:
|
||||
from agent.message_content import flatten_message_text
|
||||
from agent.title_generator import maybe_auto_title
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger'
|
|||
import { type HermesGateway } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
|
||||
import { NEW_SESSION_TITLE, quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -116,7 +116,7 @@ function ChatHeader({
|
|||
const activeStoredSession =
|
||||
(selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null
|
||||
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : NEW_SESSION_TITLE
|
||||
|
||||
// Which agent/persona owns this chat — glanceable in the header once a
|
||||
// second profile exists, so the open session's ownership is never ambiguous
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ export interface PaneMirror<T> {
|
|||
* self-subscribing component (e.g. a session's status dot) so the strip needn't
|
||||
* re-sync on status/color change — only `title` drives re-registration. */
|
||||
tabLead?: (key: string) => ReactNode
|
||||
/** Custom label NODE for the tile's tab, self-subscribing for the same reason
|
||||
* as `tabLead` — a name that moves faster than re-registration (see
|
||||
* PaneChrome.tabTitle). Falls back to `title`. */
|
||||
tabTitle?: (key: string) => ReactNode
|
||||
/** Glyph buttons the tile contributes to the strip, after the last tab (where
|
||||
* "+" sits), while it is the ACTIVE pane — e.g. a preview's console /
|
||||
* DevTools toggles. DATA, not markup: the strip's `PaneStripGlyph` owns the
|
||||
|
|
@ -82,6 +86,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
|||
title,
|
||||
data: {
|
||||
tabLead: cfg.tabLead ? () => cfg.tabLead!(key) : undefined,
|
||||
tabTitle: cfg.tabTitle ? () => cfg.tabTitle!(key) : undefined,
|
||||
stripTools: cfg.stripTools ? () => cfg.stripTools!(key) : undefined,
|
||||
dock: {
|
||||
before: cfg.before?.(tile),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { NEW_SESSION_TITLE } from '@/lib/chat-runtime'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { $draftTitles, draftTitleIn } from '@/store/composer'
|
||||
|
||||
export interface SessionDraftTitleProps {
|
||||
/** The draft's composer key — a tile's stored session id, or null for the
|
||||
* new chat that has no session yet. */
|
||||
scope: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
* A DRAFT'S NAME — what an unsent session is called until it has a real one.
|
||||
*
|
||||
* The tab of a session that has never been sent renders this instead of its
|
||||
* registered title, because the name moves with the composer: every debounced
|
||||
* stash republishes it. Re-registering the contribution at that rate would
|
||||
* re-render the whole panes area, so the label subscribes for itself and its
|
||||
* own key only.
|
||||
*
|
||||
* Falls back to the placeholder rather than going blank, so an emptied composer
|
||||
* reads the same as one never typed into.
|
||||
*/
|
||||
export function SessionDraftTitle({ scope }: SessionDraftTitleProps) {
|
||||
return useStoreSelector($draftTitles, titles => draftTitleIn(titles, scope)) || NEW_SESSION_TITLE
|
||||
}
|
||||
|
|
@ -66,6 +66,15 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
|||
role: 'status',
|
||||
title: r => r.finishedUnread
|
||||
},
|
||||
// Hollow grey, the faintest ink the app has — nothing has ever run here. It
|
||||
// shares the outline with `background` because both mean "open, not
|
||||
// producing", and sits a shade dimmer because a draft is the one state that
|
||||
// has yet to do anything at all.
|
||||
draft: {
|
||||
ariaLabel: r => r.draftSession,
|
||||
className: `${DOT_BASE} border border-(--ui-text-quaternary)`,
|
||||
title: r => r.draftSession
|
||||
},
|
||||
// Settled: the project color, or nothing at all. An uncolored session used to
|
||||
// get a grey dot, which put a mark of the same weight as a status next to
|
||||
// every resting row and made "no color" look like a state of its own.
|
||||
|
|
@ -78,8 +87,11 @@ export interface SessionStatusDotProps {
|
|||
/** The STORED session id — the key every live-state atom (working /
|
||||
* attention / stalled / unread / background) is keyed by, on BOTH surfaces:
|
||||
* the sidebar row's `session.id` and a pane tile's `storedSessionId` are the
|
||||
* same stored id (`$workingSessionIds` et al. map `storedSessionId`). */
|
||||
storedSessionId: string
|
||||
* same stored id (`$workingSessionIds` et al. map `storedSessionId`).
|
||||
*
|
||||
* Null on a new chat that has yet to reach the backend — no id to key by,
|
||||
* and no turn behind it, which is the draft state by definition. */
|
||||
storedSessionId: null | string
|
||||
/** The session row for color resolution — recents OR the project tree. Both
|
||||
* call sites already hold it; passing it lets the idle dot inherit the
|
||||
* project color even for a session older than the paginated recents page
|
||||
|
|
@ -112,7 +124,9 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa
|
|||
|
||||
// Selector, not a plain useStore: the map is rebuilt whenever any session's
|
||||
// status changes, but a given dot only repaints when ITS OWN state flips.
|
||||
const dotState = useStoreSelector($sessionDotStateById, states => states[storedSessionId] ?? 'idle')
|
||||
const dotState = useStoreSelector($sessionDotStateById, states =>
|
||||
storedSessionId ? (states[storedSessionId] ?? 'idle') : 'draft'
|
||||
)
|
||||
const variant = DOT_VARIANTS[dotState]
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
|||
import { transcribeAudio } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { createComposerAttachmentScope } from '@/store/composer'
|
||||
import { NEW_SESSION_TITLE, sessionTitle } from '@/lib/chat-runtime'
|
||||
import { createComposerAttachmentScope, draftTitleFor } from '@/store/composer'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $projectTree } from '@/store/projects'
|
||||
|
|
@ -61,6 +61,7 @@ import type { SessionDragPayload } from './composer/inline-refs'
|
|||
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
|
||||
import { useComposerActions } from './hooks/use-composer-actions'
|
||||
import { paneMirror } from './pane-mirror'
|
||||
import { SessionDraftTitle } from './session-draft-title'
|
||||
import { startSessionDrag } from './session-drag'
|
||||
import { SessionStatusDot } from './session-status-dot'
|
||||
import { useSessionTileActions } from './session-tile-actions'
|
||||
|
|
@ -374,19 +375,24 @@ export function tileStoredRow(storedSessionId: string): SessionInfo | undefined
|
|||
)
|
||||
}
|
||||
|
||||
/** The tab's REGISTERED name. Deliberately the bare placeholder for a draft
|
||||
* rather than its live composer title (`tabTitle` renders that): re-registering
|
||||
* per keystroke would re-render the strip, and holding the draft's text here
|
||||
* would let the registered name already match the row that lands on send —
|
||||
* skipping the re-register that hands the tab back to this string. */
|
||||
function tileTitle(storedSessionId: string): string {
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
|
||||
// A tab-strip "+" tab is unlisted until its first turn persists, so it isn't
|
||||
// in $sessions yet — label it "New session" rather than a bare "Session".
|
||||
return stored ? sessionTitle(stored) : 'New session'
|
||||
return stored ? sessionTitle(stored) : NEW_SESSION_TITLE
|
||||
}
|
||||
|
||||
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
|
||||
/** The `@session` link payload for a tile tab drag — id + owning profile + title.
|
||||
* Resolved at drag time, so an unsent tab drags under its draft name. */
|
||||
function tileDragPayload(storedSessionId: string): SessionDragPayload {
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
const title = stored ? sessionTitle(stored) : draftTitleFor(storedSessionId) || NEW_SESSION_TITLE
|
||||
|
||||
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
|
||||
return { id: storedSessionId, profile: stored?.profile ?? '', title }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -588,6 +594,10 @@ export const watchSessionTiles = paneMirror<SessionTile>({
|
|||
tabLead: storedSessionId => (
|
||||
<SessionStatusDot session={tileStoredRow(storedSessionId)} storedSessionId={storedSessionId} />
|
||||
),
|
||||
// Until the first turn lists a row there is no title to register, so the tab
|
||||
// takes its name from the composer instead — live, without re-registering.
|
||||
tabTitle: storedSessionId =>
|
||||
tileStoredRow(storedSessionId) ? null : <SessionDraftTitle scope={storedSessionId} />,
|
||||
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
|
||||
tabWrap: (storedSessionId, tab) => (
|
||||
<SessionTabMenu
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { atom, computed } from 'nanostores'
|
||||
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
|
||||
|
||||
import { SessionDraftTitle } from '@/app/chat/session-draft-title'
|
||||
import { SessionStatusDot } from '@/app/chat/session-status-dot'
|
||||
import { PALETTE_AREA, type PaletteContribution, paletteToggle } from '@/app/command-palette/contrib'
|
||||
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
|
||||
|
|
@ -36,7 +37,7 @@ import { Slot } from '@/contrib/react/slot'
|
|||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||
import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
||||
import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
||||
import { Download, FileText, LayoutDashboard, PanelBottom, Terminal, Upload, Zap } from '@/lib/icons'
|
||||
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||
import { setYoloEnabled } from '@/lib/yolo-session'
|
||||
|
|
@ -159,7 +160,7 @@ registry.registerMany([
|
|||
id: 'workspace',
|
||||
area: 'panes',
|
||||
// Live-retitled to the loaded session by syncWorkspaceTitle below.
|
||||
title: 'New session',
|
||||
title: NEW_SESSION_TITLE,
|
||||
data: {
|
||||
placement: 'main',
|
||||
minWidth: '22vw',
|
||||
|
|
@ -430,12 +431,19 @@ const syncWorkspaceTitle = () => {
|
|||
registry.register({
|
||||
id: 'workspace',
|
||||
area: 'panes',
|
||||
title: stored ? storedSessionTitle(stored) : 'New session',
|
||||
// The placeholder, not the draft's live name — `tabTitle` below renders
|
||||
// that. Keeping it here would re-register the pane on every keystroke.
|
||||
title: stored ? storedSessionTitle(stored) : NEW_SESSION_TITLE,
|
||||
data: {
|
||||
// The tab's status dot — the SAME primitive the sidebar row and session
|
||||
// tiles render, so the main tab never disagrees with its sidebar row. No
|
||||
// dot on a fresh draft (no session yet).
|
||||
tabLead: selected ? () => <SessionStatusDot session={stored} storedSessionId={selected} /> : undefined,
|
||||
// tiles render, so the main tab never disagrees with its sidebar row. A
|
||||
// fresh draft has no session to key by, which IS its status: the dot
|
||||
// resolves to `draft` and marks the tab rather than leaving a hole.
|
||||
tabLead: () => <SessionStatusDot session={stored} storedSessionId={selected} />,
|
||||
// A draft's name lives in its composer, not in any session row, so the
|
||||
// label subscribes to it directly — typing renames the tab without
|
||||
// re-registering the pane.
|
||||
tabTitle: stored ? undefined : () => <SessionDraftTitle scope={selected} />,
|
||||
// Pages aren't tab-able: the main zone's bar stands down while one shows.
|
||||
headerVeto: $workspaceIsPage.get(),
|
||||
placement: 'main',
|
||||
|
|
|
|||
|
|
@ -81,6 +81,12 @@ interface PaneChrome extends PaneSizing {
|
|||
* the tab and the sidebar row render status/color from the ONE primitive
|
||||
* (self-subscribing — it updates without the strip re-registering). */
|
||||
tabLead?: () => React.ReactNode
|
||||
/** This pane's TAB LABEL, when it changes faster than the contribution
|
||||
* should. A session pane whose draft is being typed renames on every
|
||||
* debounce beat; re-registering `title` that often would re-render the
|
||||
* whole panes area, so the label subscribes for itself instead. Absent, or
|
||||
* returning nothing, falls back to `title`. */
|
||||
tabTitle?: () => React.ReactNode
|
||||
/** Glyph buttons this pane contributes to the strip, rendered after the last
|
||||
* tab (where "+" sits) while the pane is ACTIVE — controls that act on the
|
||||
* pane, not on any one tab: a preview's console / DevTools toggles. DATA, not
|
||||
|
|
|
|||
|
|
@ -313,6 +313,10 @@ export function TreeGroup({
|
|||
// leaving the tree.
|
||||
const closeableTab = (paneId: string) => !paneChrome(paneFor(paneId)).uncloseable || panesWithCloser.has(paneId)
|
||||
|
||||
// A pane's own live label when it has one, else its registered string.
|
||||
const tabLabel = (paneId: string) =>
|
||||
paneChrome(paneFor(paneId)).tabTitle?.() ?? paneFor(paneId)?.title ?? paneId
|
||||
|
||||
// Collapse/restore a tool panel (or plain minimize elsewhere) — the header
|
||||
// chevron + tap gesture, routed so ⌃`/the titlebar toggle stay truthful.
|
||||
const toggleCollapse = () => (node.minimized ? restoreTreePane(activeId) : collapseTreePane(activeId))
|
||||
|
|
@ -379,7 +383,6 @@ export function TreeGroup({
|
|||
>
|
||||
{shown.map(paneId => {
|
||||
const closeable = closeableTab(paneId)
|
||||
const title = paneFor(paneId)?.title ?? paneId
|
||||
|
||||
return (
|
||||
<PaneTab
|
||||
|
|
@ -397,7 +400,7 @@ export function TreeGroup({
|
|||
side={railSide}
|
||||
vertical
|
||||
>
|
||||
<PaneTabLabel>{title}</PaneTabLabel>
|
||||
<PaneTabLabel>{tabLabel(paneId)}</PaneTabLabel>
|
||||
</PaneTab>
|
||||
)
|
||||
})}
|
||||
|
|
@ -548,7 +551,7 @@ export function TreeGroup({
|
|||
{chrome.tabLead ? (
|
||||
<span className="ml-2 -mr-1 flex shrink-0 items-center">{chrome.tabLead()}</span>
|
||||
) : null}
|
||||
<PaneTabLabel>{title}</PaneTabLabel>
|
||||
<PaneTabLabel>{tabLabel(paneId)}</PaneTabLabel>
|
||||
</PaneTab>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1644,6 +1644,7 @@ export const ar = defineLocale({
|
|||
needsInput: 'تحتاج إدخالا',
|
||||
waitingForAnswer: 'بانتظار إجابة',
|
||||
backgroundRunning: 'تعمل في الخلفية',
|
||||
draftSession: 'مسودة — لم تُرسل بعد',
|
||||
finishedUnread: 'اكتملت وفيها جديد',
|
||||
hideTabBar: 'إخفاء شريط التبويبات',
|
||||
openInNewTab: 'فتح في تبويب جديد',
|
||||
|
|
|
|||
|
|
@ -1966,6 +1966,7 @@ export const en: Translations = {
|
|||
waitingForAnswer: 'Waiting for your answer',
|
||||
finishedUnread: 'Finished — unread',
|
||||
backgroundRunning: 'Background task running',
|
||||
draftSession: 'Draft — nothing sent yet',
|
||||
handoffOrigin: platform => `Handed off from ${platform}`,
|
||||
ownedByProfile: profile => `Profile: ${profile}`,
|
||||
renamed: 'Renamed',
|
||||
|
|
|
|||
|
|
@ -1784,6 +1784,7 @@ export const ja = defineLocale({
|
|||
waitingForAnswer: '回答を待っています',
|
||||
finishedUnread: '完了 — 未読',
|
||||
backgroundRunning: 'バックグラウンドタスク実行中',
|
||||
draftSession: '下書き — 未送信',
|
||||
handoffOrigin: platform => `${platform} から引き継ぎ`,
|
||||
ownedByProfile: profile => `プロファイル: ${profile}`,
|
||||
renamed: '名前を変更しました',
|
||||
|
|
|
|||
|
|
@ -1658,6 +1658,7 @@ export interface Translations {
|
|||
waitingForAnswer: string
|
||||
finishedUnread: string
|
||||
backgroundRunning: string
|
||||
draftSession: string
|
||||
handoffOrigin: (platform: string) => string
|
||||
ownedByProfile: (profile: string) => string
|
||||
renamed: string
|
||||
|
|
|
|||
|
|
@ -1726,6 +1726,7 @@ export const zhHant = defineLocale({
|
|||
waitingForAnswer: '等待您的回答',
|
||||
finishedUnread: '已完成 — 未讀',
|
||||
backgroundRunning: '背景任務執行中',
|
||||
draftSession: '草稿 — 尚未傳送',
|
||||
handoffOrigin: platform => `從 ${platform} 轉接`,
|
||||
ownedByProfile: profile => `設定檔:${profile}`,
|
||||
renamed: '已重新命名',
|
||||
|
|
|
|||
|
|
@ -2158,6 +2158,7 @@ export const zh: Translations = {
|
|||
waitingForAnswer: '正在等待你的回答',
|
||||
finishedUnread: '已完成 — 未读',
|
||||
backgroundRunning: '后台任务运行中',
|
||||
draftSession: '草稿 — 尚未发送',
|
||||
handoffOrigin: platform => `从 ${platform} 转接`,
|
||||
ownedByProfile: profile => `配置档:${profile}`,
|
||||
renamed: '已重命名',
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ export function sessionTitle(session: SessionInfo): string {
|
|||
return session.title?.trim() || session.preview?.trim() || 'Untitled session'
|
||||
}
|
||||
|
||||
/** What a session is called before it has been sent — and before its composer
|
||||
* has been typed into, which is the only thing that can name it earlier. */
|
||||
export const NEW_SESSION_TITLE = 'New session'
|
||||
|
||||
export function coerceGatewayText(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { SLASH_COMMAND_RE } from './chat-runtime'
|
||||
|
||||
/** Matches `agent/title_generator.py`'s MAX_DERIVED_TITLE_CHARS, so a draft
|
||||
* doesn't visibly reflow the moment the backend's derived title replaces it. */
|
||||
const MAX_DRAFT_TITLE_CHARS = 48
|
||||
|
||||
/**
|
||||
* Name a draft after what the user has typed into it.
|
||||
*
|
||||
* The client-side twin of the backend's `derive_title`: first meaningful line,
|
||||
* whitespace collapsed, cut on a word boundary. It runs before any session
|
||||
* exists, so it can't reach the real titler — a draft has no persisted row and
|
||||
* no opening message yet, which is exactly what `apply_instant_title` needs.
|
||||
*
|
||||
* Empty when there's nothing worth naming, so the caller keeps "New session"
|
||||
* rather than showing a title that says less than the placeholder.
|
||||
*/
|
||||
export function deriveDraftTitle(text: string): string {
|
||||
const line = text.split('\n').find(candidate => candidate.trim())?.trim() ?? ''
|
||||
|
||||
if (!line) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// A bare `/skin` names the draft after the command rather than the work, the
|
||||
// failure the backend titler summarizes away. Title from the argument instead;
|
||||
// with no argument there is no intent yet, so the placeholder stands.
|
||||
const body = (SLASH_COMMAND_RE.test(line) ? line.replace(/^\/\S+\s*/, '') : line).split(/\s+/).join(' ')
|
||||
|
||||
if (body.length <= MAX_DRAFT_TITLE_CHARS) {
|
||||
return body
|
||||
}
|
||||
|
||||
// Cut on a word boundary, unless that would throw away more than half of it.
|
||||
const cut = body.slice(0, MAX_DRAFT_TITLE_CHARS)
|
||||
const space = cut.lastIndexOf(' ')
|
||||
const kept = space > MAX_DRAFT_TITLE_CHARS / 2 ? cut.slice(0, space) : cut
|
||||
|
||||
return `${kept.replace(/[\s,.;:—-]+$/, '')}…`
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { deriveDraftTitle } from '@/lib/draft-title'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
||||
export interface ComposerAttachment {
|
||||
|
|
@ -151,6 +152,50 @@ function loadPersistedDraftTexts(): [string, SessionDraft][] {
|
|||
|
||||
const draftsBySession = new Map<string, SessionDraft>(loadPersistedDraftTexts())
|
||||
|
||||
/**
|
||||
* What each unsent draft would be called, keyed the same way its text is.
|
||||
*
|
||||
* A draft has no session to carry a title, so the tab showing it reads this
|
||||
* instead of the "New session" placeholder. Written from `stashSessionDraft`,
|
||||
* the one funnel every composer's text already flows through — the debounce
|
||||
* that persists a draft is the same beat that renames its tab, so typing costs
|
||||
* nothing extra. Only tabs showing a draft subscribe, and each selects its own
|
||||
* key, so a rename repaints one label rather than the strip.
|
||||
*
|
||||
* Seeded from the persisted texts: a draft left open across a restart comes
|
||||
* back already named.
|
||||
*/
|
||||
export const $draftTitles = atom<Record<string, string>>(
|
||||
Object.fromEntries(
|
||||
[...draftsBySession].map(([key, draft]) => [key, deriveDraftTitle(draft.text)]).filter(([, title]) => title)
|
||||
)
|
||||
)
|
||||
|
||||
/** Read one draft's title out of the map — for a `useStoreSelector`, so a tab
|
||||
* repaints on its OWN rename rather than on every draft's. */
|
||||
export const draftTitleIn = (titles: Record<string, string>, scope: string | null | undefined): string =>
|
||||
titles[draftKey(scope)] ?? ''
|
||||
|
||||
export const draftTitleFor = (scope: string | null | undefined): string => draftTitleIn($draftTitles.get(), scope)
|
||||
|
||||
function publishDraftTitle(key: string, title: string): void {
|
||||
const current = $draftTitles.get()
|
||||
|
||||
if ((current[key] ?? '') === title) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...current }
|
||||
|
||||
if (title) {
|
||||
next[key] = title
|
||||
} else {
|
||||
delete next[key]
|
||||
}
|
||||
|
||||
$draftTitles.set(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the persisted drafts written by ANOTHER window into this one's map.
|
||||
*
|
||||
|
|
@ -169,12 +214,14 @@ export function reloadPersistedDrafts(): void {
|
|||
for (const [key, draft] of incoming) {
|
||||
const local = draftsBySession.get(key)
|
||||
draftsBySession.set(key, local?.attachments.length ? { ...local, text: draft.text } : draft)
|
||||
publishDraftTitle(key, deriveDraftTitle(draft.text))
|
||||
}
|
||||
|
||||
// A key that vanished from storage was cleared (sent) in the other window.
|
||||
for (const key of [...draftsBySession.keys()]) {
|
||||
if (!incoming.has(key)) {
|
||||
draftsBySession.delete(key)
|
||||
publishDraftTitle(key, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -257,6 +304,7 @@ export function stashSessionDraft(scope: string | null | undefined, text: string
|
|||
}
|
||||
|
||||
persistDraftTexts()
|
||||
publishDraftTitle(key, deriveDraftTitle(text))
|
||||
}
|
||||
|
||||
export function takeSessionDraft(scope: string | null | undefined): SessionDraft {
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ import { stableRecord } from '@/lib/stable-array'
|
|||
|
||||
import { $backgroundRunningSessionIds } from './composer-status'
|
||||
import { $sessions, $unreadFinishedSessionIds, lineageAliases } from './session'
|
||||
import { $attentionSessionIds, $stalledSessionIds, $workingSessionIds } from './session-states'
|
||||
import { $attentionSessionIds, $draftSessionIds, $stalledSessionIds, $workingSessionIds } from './session-states'
|
||||
|
||||
export type SessionDotState = 'background' | 'idle' | 'needs-input' | 'stalled' | 'unread' | 'working'
|
||||
export type SessionDotState = 'background' | 'draft' | 'idle' | 'needs-input' | 'stalled' | 'unread' | 'working'
|
||||
|
||||
/** The sidebar row's arc. A quiet turn is still authoritatively running, so
|
||||
* `stalled` keeps it; a blocking prompt drops it, because the amber dot is the
|
||||
|
|
@ -46,9 +46,10 @@ export const $sessionDotStateById = computed(
|
|||
$stalledSessionIds,
|
||||
$backgroundRunningSessionIds,
|
||||
$unreadFinishedSessionIds,
|
||||
$draftSessionIds,
|
||||
$sessions
|
||||
],
|
||||
(attention, working, stalled, background, unread, sessions) => {
|
||||
(attention, working, stalled, background, unread, draft, sessions) => {
|
||||
const next: Record<string, SessionDotState> = {}
|
||||
|
||||
const claim = (ids: readonly string[], state: SessionDotState) => {
|
||||
|
|
@ -62,6 +63,10 @@ export const $sessionDotStateById = computed(
|
|||
// Weakest claim first — each pass overwrites the one above it, so the order
|
||||
// below IS the priority order. A blocking prompt outranks everything: it is
|
||||
// the only state that needs the user.
|
||||
//
|
||||
// Draft is weakest of all: it says only "no turn has happened here yet", so
|
||||
// the first thing that does happen speaks over it.
|
||||
claim(draft, 'draft')
|
||||
claim(unread, 'unread')
|
||||
claim(background, 'background')
|
||||
claim(working, 'working')
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
$sessions,
|
||||
$unreadFinishedSessionIds,
|
||||
lineageAliases,
|
||||
sessionMatchesStoredId,
|
||||
setActiveSessionStoredIdRotation
|
||||
} from './session'
|
||||
import { isSecondaryWindow } from './windows'
|
||||
|
|
@ -308,6 +309,38 @@ export const $attentionSessionIds = computed(
|
|||
))
|
||||
)
|
||||
|
||||
// An open session nothing has ever been sent to — the ⌘T tab whose backend
|
||||
// session exists but is unlisted, or a tile still waiting on its first send.
|
||||
// `blankDraftTile`'s predicate, read as a status rather than as a slot to spend.
|
||||
//
|
||||
// The row's own `message_count` is the tiebreaker, and it is load-bearing: a
|
||||
// session RESUMING also holds an empty message list for the moment between
|
||||
// binding its runtime and loading its transcript, and calling that a draft
|
||||
// would flash the wrong mark on a conversation with years of history in it.
|
||||
let draftIds: readonly string[] = []
|
||||
export const $draftSessionIds = computed([$sessionStates, $sessions], (states, sessions) => {
|
||||
const unsent = (state: ClientSessionState) => {
|
||||
if (state.busy || state.messages.length > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const storedId = state.storedSessionId
|
||||
|
||||
// No stored id is the ⌘T tab that hasn't reached the backend yet: a draft
|
||||
// by definition, and no row to consult. Asking anyway would match a row on
|
||||
// an empty lineage root.
|
||||
if (!storedId) {
|
||||
return true
|
||||
}
|
||||
|
||||
const row = sessions.find(session => sessionMatchesStoredId(session, storedId))
|
||||
|
||||
return !row || row.message_count === 0
|
||||
}
|
||||
|
||||
return (draftIds = stableArray(draftIds, storedIds(states, sessions, unsent)))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session tiles.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
yy28
|
||||
|
|
@ -85,6 +85,10 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
# feedback off SendResult — see send()). Consumed by the gateway's
|
||||
# semantic thread-rename lane; bounded like the sibling caches.
|
||||
self._auto_thread_by_chat: Dict[str, Tuple[str, str]] = {}
|
||||
# chat_id -> event fired when the entry above lands, so a consumer that
|
||||
# arrives before the send can wait for it instead of polling. See
|
||||
# wait_for_auto_thread_info.
|
||||
self._auto_thread_waiters: Dict[str, asyncio.Event] = {}
|
||||
# chat_id -> chat_type (e.g. "dm", "channel", "group") learned from the
|
||||
# inbound event. Used to reproduce native Slack's synthetic-DM-thread
|
||||
# suppression on the relay lane: a DM streaming reply carries
|
||||
|
|
@ -993,6 +997,13 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
)
|
||||
except Exception: # noqa: BLE001 - feedback capture must never break send
|
||||
pass
|
||||
# Wake the rename lane on EVERY send into this chat, not only the ones
|
||||
# that auto-threaded. It is waiting to learn where this turn's reply
|
||||
# landed, and "nowhere new" is an answer — one it should get now rather
|
||||
# than by outlasting a timeout.
|
||||
waiter = self._auto_thread_waiters.get(str(chat_id))
|
||||
if waiter is not None:
|
||||
waiter.set()
|
||||
return SendResult(
|
||||
success=bool(result.get("success")),
|
||||
message_id=result.get("message_id"),
|
||||
|
|
@ -1007,6 +1018,42 @@ class RelayAdapter(BasePlatformAdapter):
|
|||
gateway's semantic thread-rename lane (auto session title)."""
|
||||
return self._auto_thread_by_chat.get(str(chat_id))
|
||||
|
||||
async def wait_for_auto_thread_info(
|
||||
self, chat_id: str, timeout: float
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""``auto_thread_info_for_chat``, but willing to wait for the send.
|
||||
|
||||
The rename lane asks where the reply landed as soon as the session is
|
||||
titled, and the session is titled from the user's opening message —
|
||||
before the model has answered, let alone before we've sent anything. So
|
||||
the question arrives a whole turn early, and a turn is a one-liner or
|
||||
twenty minutes of tool calls.
|
||||
|
||||
Waits for the next send into this chat and then answers, so a reply the
|
||||
connector didn't auto-thread reports its miss as soon as it's sent
|
||||
instead of holding until *timeout* — which is only a backstop for a turn
|
||||
that never sends at all.
|
||||
"""
|
||||
info = self.auto_thread_info_for_chat(chat_id)
|
||||
if info is not None:
|
||||
return info
|
||||
key = str(chat_id)
|
||||
waiter = self._auto_thread_waiters.get(key)
|
||||
if waiter is None:
|
||||
waiter = asyncio.Event()
|
||||
self._auto_thread_waiters[key] = waiter
|
||||
try:
|
||||
await asyncio.wait_for(waiter.wait(), timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
finally:
|
||||
# Only the waiter we may have installed, and only if no later call
|
||||
# replaced it; a fired event must not be left behind to make the
|
||||
# next turn's wait return instantly on stale feedback.
|
||||
if self._auto_thread_waiters.get(key) is waiter:
|
||||
self._auto_thread_waiters.pop(key, None)
|
||||
return self.auto_thread_info_for_chat(chat_id)
|
||||
|
||||
def _resolve_reply_to_for_send(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
|
|
|||
|
|
@ -1016,6 +1016,17 @@ def _startup_restore_drain_timeout_secs() -> float:
|
|||
return float(_STARTUP_RESTORE_DRAIN_TIMEOUT_SECS_DEFAULT)
|
||||
|
||||
|
||||
def _as_thread_info(info: Any) -> Optional[Tuple[str, str]]:
|
||||
"""*info* as a (thread_id, initial_name) pair, or None if it isn't one.
|
||||
|
||||
The pair comes back across the relay connector boundary, so its shape is
|
||||
the connector's word rather than ours.
|
||||
"""
|
||||
if isinstance(info, tuple) and len(info) == 2 and all(isinstance(x, str) for x in info):
|
||||
return cast(Tuple[str, str], info)
|
||||
return None
|
||||
|
||||
|
||||
def _float_env(name: str, default: float) -> float:
|
||||
"""Read an env var as float, falling back to ``default`` on typos/empty.
|
||||
|
||||
|
|
@ -4460,9 +4471,15 @@ class TurnRunner:
|
|||
session_id = getattr(agent, "session_id", None)
|
||||
source = ctx.source
|
||||
|
||||
# Both lanes below spend a rate-limited platform call per title, so
|
||||
# they take the model's title and skip the derived one — see
|
||||
# TitleCallback. Renaming twice lands on the same name at twice the
|
||||
# cost, and Discord's 2-per-10-minutes channel budget can spend
|
||||
# itself on the throwaway and drop the one worth showing.
|
||||
if self._runner._is_telegram_topic_lane(source):
|
||||
agent._on_session_title = lambda title: (
|
||||
self._runner._schedule_telegram_topic_title_rename(
|
||||
agent._on_session_title = lambda title, title_source: (
|
||||
title_source == "llm"
|
||||
and self._runner._schedule_telegram_topic_title_rename(
|
||||
source, session_id, title,
|
||||
)
|
||||
)
|
||||
|
|
@ -4477,8 +4494,9 @@ class TurnRunner:
|
|||
# fire time (staging repro 2026-07-31: gating registration on
|
||||
# the cache read meant it never registered and no
|
||||
# thread_rename op was ever sent).
|
||||
agent._on_session_title = lambda title: (
|
||||
self._runner._schedule_discord_semantic_thread_rename(
|
||||
agent._on_session_title = lambda title, title_source: (
|
||||
title_source == "llm"
|
||||
and self._runner._schedule_discord_semantic_thread_rename(
|
||||
source, session_id, title,
|
||||
)
|
||||
)
|
||||
|
|
@ -20381,14 +20399,34 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if not callable(info_fn):
|
||||
return None
|
||||
try:
|
||||
info = info_fn(str(source.chat_id))
|
||||
if (
|
||||
isinstance(info, tuple)
|
||||
and len(info) == 2
|
||||
and all(isinstance(x, str) for x in info)
|
||||
):
|
||||
return cast(Tuple[str, str], info)
|
||||
return _as_thread_info(info_fn(str(source.chat_id)))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _await_relay_auto_thread_info(
|
||||
self, source: SessionSource
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""``_relay_auto_thread_info``, waited out until this turn delivers.
|
||||
|
||||
The legacy send-result path can only answer once the reply is sent, and
|
||||
the caller asks at title time — one turn early. The adapter answers on
|
||||
the send either way, so the timeout is only a backstop for a turn that
|
||||
never sends at all; the turn's own inactivity limit is exactly how long
|
||||
that turn could still be alive.
|
||||
"""
|
||||
# The connector-stamped prospective id is known at ingest, so most
|
||||
# sessions answer here and never wait at all.
|
||||
known = self._relay_auto_thread_info(source)
|
||||
if known is not None:
|
||||
return known
|
||||
adapter = self._adapter_for_source(source)
|
||||
wait_fn = getattr(adapter, "wait_for_auto_thread_info", None)
|
||||
if not callable(wait_fn) or not source.chat_id:
|
||||
return None
|
||||
# 0 means the operator disabled the turn limit; the backstop still needs one.
|
||||
timeout = _float_env("HERMES_AGENT_TIMEOUT", 1800) or 1800
|
||||
try:
|
||||
return _as_thread_info(await wait_fn(str(source.chat_id), timeout))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -20424,18 +20462,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if relay_info is None and not await asyncio.to_thread(
|
||||
self._is_discord_auto_thread_lane, source
|
||||
):
|
||||
# Relay title turn with no feedback captured at schedule time:
|
||||
# the auto-title thread races the delivery that produces the
|
||||
# connector's send-result feedback (thread_id + initial name).
|
||||
# Poll the adapter cache briefly before giving up — delivery is
|
||||
# typically milliseconds-to-seconds behind the title.
|
||||
# Relay title turn with no feedback captured at schedule time: the
|
||||
# title comes off the user's opening message, so it beats the
|
||||
# delivery that produces the connector's send-result feedback
|
||||
# (thread_id + initial name) by the whole length of the turn. Wait
|
||||
# on the adapter for that send rather than guessing how long the
|
||||
# turn will take.
|
||||
if not self._is_relay_discord_channel_lane(source):
|
||||
return
|
||||
for _ in range(20): # up to ~10s
|
||||
relay_info = self._relay_auto_thread_info(source)
|
||||
if relay_info is not None:
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
relay_info = await self._await_relay_auto_thread_info(source)
|
||||
if relay_info is None:
|
||||
# True miss: the connector did not auto-thread this reply
|
||||
# (policy off, DM, already-threaded, or send failed).
|
||||
|
|
|
|||
|
|
@ -1695,6 +1695,43 @@ def ai_gateway_model_ids(*, force_refresh: bool = False) -> list[str]:
|
|||
# Cache: maps model_id → {"prompt": str, "completion": str} per endpoint
|
||||
_pricing_cache: dict[str, dict[str, dict[str, str]]] = {}
|
||||
|
||||
# A failed fetch caches its empty result too, so an unreachable endpoint isn't
|
||||
# re-dialed on every call — but only until this deadline. Cached forever, one
|
||||
# bad moment (a blip during startup, a key that hadn't been written yet) turns
|
||||
# into no live model discovery for the life of the process, and the processes
|
||||
# that read this most are the ones that run for weeks: the gateway, the desktop
|
||||
# backend. Every caller falls back to a curated list meanwhile, so the cost of
|
||||
# the stale entry is silent and invisible.
|
||||
_FAILED_CATALOG_TTL_SECONDS = 120.0
|
||||
_pricing_cache_retry_after: dict[str, float] = {}
|
||||
|
||||
|
||||
def _cached_catalog(cache_key: str) -> Optional[dict[str, dict[str, Any]]]:
|
||||
"""The cached catalog for *cache_key*, or None to go fetch it."""
|
||||
cached = _pricing_cache.get(cache_key)
|
||||
if cached is None:
|
||||
return None
|
||||
retry_after = _pricing_cache_retry_after.get(cache_key)
|
||||
if retry_after is not None and time.monotonic() >= retry_after:
|
||||
_pricing_cache.pop(cache_key, None)
|
||||
_pricing_cache_retry_after.pop(cache_key, None)
|
||||
return None
|
||||
return cached
|
||||
|
||||
|
||||
def _cache_catalog(
|
||||
cache_key: str, result: dict[str, dict[str, Any]]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Cache a catalog result, giving an empty one an expiry."""
|
||||
_pricing_cache[cache_key] = result
|
||||
if result:
|
||||
_pricing_cache_retry_after.pop(cache_key, None)
|
||||
else:
|
||||
_pricing_cache_retry_after[cache_key] = (
|
||||
time.monotonic() + _FAILED_CATALOG_TTL_SECONDS
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _format_price_per_mtok(per_token_str: str) -> str:
|
||||
"""Convert a per-token price string to a human-friendly $/Mtok string.
|
||||
|
|
@ -1831,8 +1868,10 @@ def fetch_models_with_pricing(
|
|||
``original``.
|
||||
"""
|
||||
cache_key = (base_url or "").rstrip("/")
|
||||
if not force_refresh and cache_key in _pricing_cache:
|
||||
return _pricing_cache[cache_key]
|
||||
if not force_refresh:
|
||||
cached = _cached_catalog(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = cache_key + "/v1/models"
|
||||
headers: dict[str, str] = {
|
||||
|
|
@ -1847,8 +1886,7 @@ def fetch_models_with_pricing(
|
|||
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
_pricing_cache[cache_key] = {}
|
||||
return {}
|
||||
return _cache_catalog(cache_key, {})
|
||||
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for item in payload.get("data", []):
|
||||
|
|
@ -1881,8 +1919,7 @@ def fetch_models_with_pricing(
|
|||
entry["original"] = orig_entry
|
||||
result[mid] = entry
|
||||
|
||||
_pricing_cache[cache_key] = result
|
||||
return result
|
||||
return _cache_catalog(cache_key, result)
|
||||
|
||||
|
||||
def fetch_ai_gateway_pricing(
|
||||
|
|
@ -1899,8 +1936,10 @@ def fetch_ai_gateway_pricing(
|
|||
from hermes_constants import AI_GATEWAY_BASE_URL
|
||||
|
||||
cache_key = AI_GATEWAY_BASE_URL.rstrip("/")
|
||||
if not force_refresh and cache_key in _pricing_cache:
|
||||
return _pricing_cache[cache_key]
|
||||
if not force_refresh:
|
||||
cached = _cached_catalog(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
|
|
@ -1910,8 +1949,7 @@ def fetch_ai_gateway_pricing(
|
|||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
_pricing_cache[cache_key] = {}
|
||||
return {}
|
||||
return _cache_catalog(cache_key, {})
|
||||
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for item in payload.get("data", []):
|
||||
|
|
@ -1931,8 +1969,7 @@ def fetch_ai_gateway_pricing(
|
|||
entry["input_cache_write"] = str(pricing["input_cache_write"])
|
||||
result[mid] = entry
|
||||
|
||||
_pricing_cache[cache_key] = result
|
||||
return result
|
||||
return _cache_catalog(cache_key, result)
|
||||
|
||||
|
||||
def _resolve_openrouter_api_key() -> str:
|
||||
|
|
@ -2039,8 +2076,10 @@ def _fireworks_pricing_from_models_dev(
|
|||
pricing formatter expects per-token strings, so divide by 1M.
|
||||
"""
|
||||
cache_key = "models.dev/fireworks"
|
||||
if not force_refresh and cache_key in _pricing_cache:
|
||||
return _pricing_cache[cache_key]
|
||||
if not force_refresh:
|
||||
cached = _cached_catalog(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
try:
|
||||
|
|
@ -2068,8 +2107,7 @@ def _fireworks_pricing_from_models_dev(
|
|||
except Exception:
|
||||
result = {}
|
||||
|
||||
_pricing_cache[cache_key] = result
|
||||
return result
|
||||
return _cache_catalog(cache_key, result)
|
||||
|
||||
|
||||
def _fetch_novita_pricing(
|
||||
|
|
@ -2093,8 +2131,10 @@ def _fetch_novita_pricing(
|
|||
|
||||
base_url = os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1"
|
||||
cache_key = base_url.rstrip("/")
|
||||
if not force_refresh and cache_key in _pricing_cache:
|
||||
return _pricing_cache[cache_key]
|
||||
if not force_refresh:
|
||||
cached = _cached_catalog(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = cache_key + "/models"
|
||||
headers = {
|
||||
|
|
@ -2108,8 +2148,7 @@ def _fetch_novita_pricing(
|
|||
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
_pricing_cache[cache_key] = {}
|
||||
return {}
|
||||
return _cache_catalog(cache_key, {})
|
||||
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for item in payload.get("data", []):
|
||||
|
|
@ -2127,8 +2166,7 @@ def _fetch_novita_pricing(
|
|||
"completion": str(float(out or 0) / 10_000 / 1_000_000),
|
||||
}
|
||||
|
||||
_pricing_cache[cache_key] = result
|
||||
return result
|
||||
return _cache_catalog(cache_key, result)
|
||||
|
||||
|
||||
# All provider IDs and aliases that are valid for the provider:model syntax.
|
||||
|
|
|
|||
|
|
@ -4519,6 +4519,56 @@ class TestFastModelTier:
|
|||
with patch("hermes_cli.models.fetch_models_with_pricing", return_value=catalog):
|
||||
assert ac._fast_model_from_catalog("nous") == "google/gemini-3.6-flash"
|
||||
|
||||
def test_catalog_match_skips_the_non_chat_siblings_of_a_chat_model(self):
|
||||
"""A provider names its speech and image endpoints after the chat model
|
||||
they're paired with, so they satisfy the family rungs and can't answer."""
|
||||
from agent import auxiliary_client as ac
|
||||
|
||||
catalog = {
|
||||
"openai/gpt-4o-mini-tts": {},
|
||||
"openai/gpt-4o-mini-transcribe": {},
|
||||
"openai/gpt-4o-mini-search-preview": {},
|
||||
"openai/gpt-4o-mini": {},
|
||||
}
|
||||
with patch("hermes_cli.models.fetch_models_with_pricing", return_value=catalog):
|
||||
assert ac._fast_model_from_catalog("nous") == "openai/gpt-4o-mini"
|
||||
|
||||
def test_catalog_match_takes_the_newest_of_a_family(self):
|
||||
"""The bare family rungs must land on the current generation.
|
||||
|
||||
A provider serves every generation of its small tier it hasn't retired,
|
||||
and compared as strings the oldest sorts first — so the rung meant to
|
||||
keep the titler current was pinning it to the most obsolete member.
|
||||
"""
|
||||
from agent import auxiliary_client as ac
|
||||
|
||||
catalog = {
|
||||
"openai/gpt-3.5-mini": {},
|
||||
"openai/gpt-9-mini": {},
|
||||
"openai/gpt-10-mini": {},
|
||||
}
|
||||
with patch("hermes_cli.models.fetch_models_with_pricing", return_value=catalog):
|
||||
assert ac._fast_model_from_catalog("nous") == "openai/gpt-10-mini"
|
||||
|
||||
def test_catalog_fetch_is_authenticated(self):
|
||||
"""Most /v1/models endpoints need a key; anonymously they 401.
|
||||
|
||||
A 401 reads as "this provider serves no small model", so the titler
|
||||
would fall back to the curated default and never notice.
|
||||
"""
|
||||
from agent import auxiliary_client as ac
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={"api_key": "sk-test", "base_url": "https://api.example.com/v1"},
|
||||
), patch(
|
||||
"hermes_cli.models.fetch_models_with_pricing", return_value={}
|
||||
) as fetch:
|
||||
ac._fast_model_from_catalog("openai")
|
||||
|
||||
assert fetch.call_args.kwargs["api_key"] == "sk-test"
|
||||
assert fetch.call_args.kwargs["base_url"] == "https://api.example.com"
|
||||
|
||||
def test_falls_back_to_curated_default_when_catalog_unavailable(self):
|
||||
"""An offline catalog degrades to the provider's pinned default."""
|
||||
from agent import auxiliary_client as ac
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ class TestAutoTitleSession:
|
|||
db,
|
||||
"sess-1",
|
||||
"hi",
|
||||
title_callback=seen.append,
|
||||
title_callback=lambda title, source: seen.append(title),
|
||||
)
|
||||
|
||||
assert db.get_session_title("sess-1") == "Manual Title"
|
||||
|
|
@ -159,12 +159,14 @@ class TestAutoTitleSession:
|
|||
db,
|
||||
"sess-1",
|
||||
"hello",
|
||||
title_callback=seen.append,
|
||||
title_callback=lambda title, source: seen.append((title, source)),
|
||||
)
|
||||
db.set_auto_title.assert_called_once_with(
|
||||
"sess-1", "Readable Session", source="llm"
|
||||
)
|
||||
assert seen == ["Readable Session"]
|
||||
# The stage reaches the consumer, so one that spends a rate-limited
|
||||
# remote call per title can take this and skip the derived one.
|
||||
assert seen == [("Readable Session", "llm")]
|
||||
|
||||
def test_upgrades_a_derived_title_but_not_an_llm_one(self, tmp_path):
|
||||
"""The instant title is provisional; a model title is final.
|
||||
|
|
@ -276,6 +278,97 @@ class TestMaybeAutoTitle:
|
|||
assert db.get_session_title("sess-1") is None
|
||||
mock_auto.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"opener",
|
||||
[
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted",
|
||||
"[CONTEXT SUMMARY]: the user was refactoring the auth module",
|
||||
"[System note: the user switched models]",
|
||||
"[Runtime note: resumed from checkpoint]",
|
||||
],
|
||||
)
|
||||
def test_skips_every_shape_of_machine_authored_opener(self, tmp_path, opener):
|
||||
"""A session named after our own scaffolding is named after us."""
|
||||
db = SessionDB(tmp_path / "state.db")
|
||||
db.create_session(session_id="sess-1", source="cli")
|
||||
with patch("agent.title_generator.auto_title_session") as mock_auto:
|
||||
maybe_auto_title(db, "sess-1", opener, [])
|
||||
assert db.get_session_title("sess-1") is None
|
||||
mock_auto.assert_not_called()
|
||||
|
||||
def test_a_multimodal_turn_counts_as_a_real_question(self, tmp_path):
|
||||
""""Here's a screenshot, fix the login" is a question, parts list or not.
|
||||
|
||||
Judging a turn by `content` alone reads a multimodal one as machinery
|
||||
and undercounts the conversation, so a session deep into its history
|
||||
looks like it is still on its opening turn.
|
||||
"""
|
||||
from agent.title_generator import _is_real_user_turn
|
||||
|
||||
assert _is_real_user_turn(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}},
|
||||
{"type": "text", "text": "fix the login button"},
|
||||
],
|
||||
}
|
||||
)
|
||||
# An image with no words is not a question we can name anything after.
|
||||
assert not _is_real_user_turn(
|
||||
{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "x"}}]}
|
||||
)
|
||||
|
||||
def test_titles_on_a_later_turn_when_the_opener_was_not_titleable(self, tmp_path):
|
||||
"""A session whose opener couldn't be titled gets named by a later turn.
|
||||
|
||||
The opener here is a compaction handoff, so turn one leaves the session
|
||||
nameless. Nothing used to reconsider it: the guard that stops re-titling
|
||||
a named session also stopped the nameless one from ever asking again.
|
||||
"""
|
||||
db = SessionDB(tmp_path / "state.db")
|
||||
db.create_session(session_id="sess-1", source="cli")
|
||||
history = [
|
||||
{"role": "user", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] x"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "thanks"},
|
||||
{"role": "assistant", "content": "sure"},
|
||||
]
|
||||
with patch("agent.title_generator.auto_title_session"):
|
||||
maybe_auto_title(db, "sess-1", "fix the flaky auth test", history)
|
||||
assert db.get_session_title("sess-1") == "fix the flaky auth test"
|
||||
|
||||
def test_leaves_an_already_titled_session_alone_on_later_turns(self, tmp_path):
|
||||
"""The retry is for nameless sessions only; a named one asks nothing."""
|
||||
db = SessionDB(tmp_path / "state.db")
|
||||
db.create_session(session_id="sess-1", source="cli")
|
||||
db.set_session_title("sess-1", "Existing name")
|
||||
history = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
{"role": "user", "content": "thanks"},
|
||||
{"role": "assistant", "content": "sure"},
|
||||
]
|
||||
with patch("agent.title_generator.auto_title_session") as mock_auto:
|
||||
maybe_auto_title(db, "sess-1", "and now something else", history)
|
||||
assert db.get_session_title("sess-1") == "Existing name"
|
||||
mock_auto.assert_not_called()
|
||||
|
||||
def test_instant_title_declines_a_name_collision(self, tmp_path):
|
||||
"""A colliding derived title is skipped, not scanned into 'hi #2'.
|
||||
|
||||
Common openers collide constantly, and the lineage scan that resolves
|
||||
the collision runs inline on the turn. The model's title lands moments
|
||||
later, so the session is named either way.
|
||||
"""
|
||||
db = SessionDB(tmp_path / "state.db")
|
||||
db.create_session(session_id="taken", source="cli")
|
||||
db.set_session_title("taken", "hi")
|
||||
db.create_session(session_id="sess-1", source="cli")
|
||||
with patch("agent.title_generator.auto_title_session"):
|
||||
maybe_auto_title(db, "sess-1", "hi", [])
|
||||
assert db.get_session_title("sess-1") is None
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -284,6 +377,23 @@ class TestMaybeAutoTitle:
|
|||
class TestAutoTitleDuplicateHandling:
|
||||
"""Duplicate auto-title handling and not-found hardening (#50537)."""
|
||||
|
||||
def test_background_stage_names_a_collision_the_instant_stage_declined(
|
||||
self, tmp_path
|
||||
):
|
||||
"""The lineage scan the turn skipped happens here instead.
|
||||
|
||||
The inline stage declines a collision to stay off the critical path, and
|
||||
the model can still come back empty. Between them the session would be
|
||||
left nameless, so the background stage spends the scan the turn wouldn't.
|
||||
"""
|
||||
db = SessionDB(tmp_path / "state.db")
|
||||
db.create_session(session_id="taken", source="cli")
|
||||
db.set_session_title("taken", "hi")
|
||||
db.create_session(session_id="sess-1", source="cli")
|
||||
with patch("agent.title_generator.generate_title", return_value=None):
|
||||
auto_title_session(db, "sess-1", "hi")
|
||||
assert db.get_session_title("sess-1") == "hi #2"
|
||||
|
||||
def test_dedupes_duplicate_title_via_lineage(self):
|
||||
db = MagicMock()
|
||||
db.get_session_title_source.return_value = None
|
||||
|
|
@ -295,7 +405,12 @@ class TestAutoTitleDuplicateHandling:
|
|||
return_value="Debugging Import Error",
|
||||
):
|
||||
seen = []
|
||||
auto_title_session(db, "sess-1", "hi", title_callback=seen.append)
|
||||
auto_title_session(
|
||||
db,
|
||||
"sess-1",
|
||||
"hi",
|
||||
title_callback=lambda title, _source: seen.append(title),
|
||||
)
|
||||
db.get_next_title_in_lineage.assert_called_once_with("Debugging Import Error")
|
||||
assert db.set_auto_title.call_args_list[-1][0] == (
|
||||
"sess-1",
|
||||
|
|
@ -360,3 +475,88 @@ class TestRuntimeValidator:
|
|||
assert called.wait(timeout=10), "auto_title thread never ran"
|
||||
kwargs = mock_auto.call_args.kwargs
|
||||
assert kwargs["runtime_validator"] is _v
|
||||
|
||||
|
||||
class TestModelSwitchMarkerNotTitleable:
|
||||
"""Regression: a model-switch marker must never become the session title.
|
||||
|
||||
``_append_model_switch_marker`` (tui_gateway/server.py) persists its notice
|
||||
with ``role="user"`` because strict OpenAI-compatible providers reject a
|
||||
system message that is not first (#48338). Titling therefore has to
|
||||
recognise it as machine-authored, or switching models before asking the
|
||||
first real question titles the session
|
||||
"[System: The active model for this chat has…".
|
||||
"""
|
||||
|
||||
MARKER = (
|
||||
"[System: The active model for this chat has changed to "
|
||||
"deepseek-v4-flash via provider 94mei. From this point forward, use "
|
||||
"this runtime metadata when answering questions about what "
|
||||
"model/provider is active.]"
|
||||
)
|
||||
|
||||
def test_marker_prefix_matches_gateway_constant(self):
|
||||
"""The guard must stay in sync with the gateway's marker builder."""
|
||||
from tui_gateway.server import _MODEL_SWITCH_MARKER_PREFIX
|
||||
from agent.title_generator import _MACHINE_PREFIXES
|
||||
|
||||
assert _MODEL_SWITCH_MARKER_PREFIX in _MACHINE_PREFIXES
|
||||
assert self.MARKER.startswith(_MODEL_SWITCH_MARKER_PREFIX)
|
||||
|
||||
def test_marker_is_not_titleable(self):
|
||||
from agent.title_generator import is_titleable_user_message
|
||||
|
||||
assert is_titleable_user_message(self.MARKER) is False
|
||||
|
||||
def test_derive_title_is_unguarded_by_design(self):
|
||||
"""``derive_title`` is a dumb formatter; the guard lives in the callers.
|
||||
|
||||
Documents the contract deliberately: every caller checks
|
||||
``is_titleable_user_message`` first, so ``derive_title`` itself is
|
||||
allowed to format a marker. If a future caller forgets that check, the
|
||||
marker leaks into the title — which is exactly the bug this class
|
||||
guards against.
|
||||
"""
|
||||
from agent.title_generator import derive_title
|
||||
|
||||
assert derive_title(self.MARKER) is not None
|
||||
|
||||
def test_unrelated_system_bracket_text_still_titleable(self):
|
||||
"""The guard is narrow: real user text starting "[System:" still titles."""
|
||||
from agent.title_generator import is_titleable_user_message
|
||||
|
||||
assert is_titleable_user_message("[System: my own note] how do I ...") is True
|
||||
|
||||
def test_real_question_after_marker_still_titles(self):
|
||||
"""The marker must not consume the session's one titling opportunity.
|
||||
|
||||
The marker is a role="user" row, so counting it made the first real
|
||||
question look like turn 2 — and titling bailed out entirely, leaving
|
||||
the session permanently untitled.
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.get_session_title.return_value = None
|
||||
db.get_session_title_source.return_value = None
|
||||
history = [
|
||||
{"role": "user", "content": self.MARKER},
|
||||
{"role": "user", "content": "南京市秦淮区 小时级天气预报"},
|
||||
]
|
||||
|
||||
with patch("agent.title_generator.auto_title_session") as mock_auto:
|
||||
import threading
|
||||
|
||||
called = threading.Event()
|
||||
mock_auto.side_effect = lambda *a, **k: called.set()
|
||||
maybe_auto_title(db, "sess-1", "南京市秦淮区 小时级天气预报", history)
|
||||
assert called.wait(timeout=10), "auto_title never ran after marker"
|
||||
|
||||
def test_instant_title_skips_marker_uses_real_message(self):
|
||||
from agent.title_generator import apply_instant_title
|
||||
|
||||
db = MagicMock()
|
||||
db.get_session_title_source.return_value = None
|
||||
|
||||
assert apply_instant_title(db, "sess-1", self.MARKER) is None
|
||||
assert apply_instant_title(db, "sess-1", "南京市秦淮区 小时级天气预报") == (
|
||||
"南京市秦淮区 小时级天气预报"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -365,13 +365,44 @@ def test_between_turns_refresh_adds_late_tool_when_servers_registered():
|
|||
assert any(t["function"]["name"] == "mcp_x_tool" for t in agent.tools)
|
||||
|
||||
|
||||
class _TitlingAgent:
|
||||
"""Only what ``_maybe_title_session_at_turn_start`` reads off an agent."""
|
||||
|
||||
def __init__(self, platform):
|
||||
self.platform = platform
|
||||
self.session_id = "sess-1"
|
||||
self.model = "test/model"
|
||||
self.provider = "openrouter"
|
||||
self.base_url = "https://openrouter.ai/api/v1"
|
||||
self.api_key = "sk-x"
|
||||
self.api_mode = "chat_completions"
|
||||
self._session_db = MagicMock()
|
||||
self._session_db_created = True
|
||||
|
||||
|
||||
def _title_turn(platform, message="Fix the login button"):
|
||||
"""Run the prologue's titling step and return the maybe_auto_title mock."""
|
||||
from agent import turn_context
|
||||
|
||||
with patch("agent.title_generator.maybe_auto_title") as titler:
|
||||
turn_context._maybe_title_session_at_turn_start(
|
||||
_TitlingAgent(platform),
|
||||
[{"role": "user", "content": message}],
|
||||
)
|
||||
return titler
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", ["cli", "telegram", "desktop", "acp", None])
|
||||
def test_prologue_titles_the_surfaces_a_person_reads(platform):
|
||||
assert _title_turn(platform).called
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", ["cron", "CRON", "subagent"])
|
||||
def test_prologue_does_not_title_machine_driven_runs(platform):
|
||||
"""Cron names its own session after the job, and nobody opens a subagent's.
|
||||
|
||||
|
||||
|
||||
Both would otherwise pay a side-LLM call per run for a name that is either
|
||||
overwritten or never read.
|
||||
"""
|
||||
assert not _title_turn(platform).called
|
||||
|
||||
|
|
|
|||
|
|
@ -260,6 +260,65 @@ async def test_send_without_thread_feedback_leaves_no_info():
|
|||
assert adapter.auto_thread_info_for_chat("chan2") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waiting_for_auto_thread_feedback_outlasts_the_turn():
|
||||
"""The rename lane asks where the reply landed before the reply exists.
|
||||
|
||||
Titling reads the user's opening message, so the question arrives one whole
|
||||
turn early — and a turn is however long the agent takes. Waiting on the send
|
||||
rather than on a fixed nap is what makes the answer arrive at all.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
adapter, stub = _adapter()
|
||||
|
||||
async def send_outbound(action, *, platform=None):
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": "m1",
|
||||
"thread_id": "th-auto-1",
|
||||
"auto_thread_name": "What is a duck",
|
||||
}
|
||||
|
||||
stub.send_outbound = send_outbound # type: ignore[method-assign]
|
||||
waiting = asyncio.ensure_future(adapter.wait_for_auto_thread_info("chan1", 10.0))
|
||||
await asyncio.sleep(0)
|
||||
assert not waiting.done()
|
||||
|
||||
await adapter.send("chan1", "quack")
|
||||
assert await waiting == ("th-auto-1", "What is a duck")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_reply_that_was_not_auto_threaded_reports_its_miss_on_send():
|
||||
"""A miss is an answer, and the send is when we have it.
|
||||
|
||||
Only a turn that never sends at all should reach the timeout, so a policy
|
||||
that doesn't auto-thread costs a wait as long as the turn, not as long as
|
||||
the backstop.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
adapter, stub = _adapter()
|
||||
|
||||
async def send_outbound(action, *, platform=None):
|
||||
return {"success": True, "message_id": "m1"}
|
||||
|
||||
stub.send_outbound = send_outbound # type: ignore[method-assign]
|
||||
waiting = asyncio.ensure_future(adapter.wait_for_auto_thread_info("chan1", 600.0))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await adapter.send("chan1", "quack")
|
||||
assert await waiting is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waiting_for_auto_thread_feedback_gives_up():
|
||||
"""A turn that never sends anything still has to end."""
|
||||
adapter, _stub = _adapter()
|
||||
assert await adapter.wait_for_auto_thread_info("chan-quiet", 0.05) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_feedback_is_bounded():
|
||||
adapter, stub = _adapter()
|
||||
|
|
@ -291,6 +350,7 @@ def _mk_runner_stub():
|
|||
class _Stub:
|
||||
_is_relay_discord_channel_lane = GatewayRunner._is_relay_discord_channel_lane
|
||||
_relay_auto_thread_info = GatewayRunner._relay_auto_thread_info
|
||||
_await_relay_auto_thread_info = GatewayRunner._await_relay_auto_thread_info
|
||||
_is_discord_auto_thread_lane = GatewayRunner._is_discord_auto_thread_lane
|
||||
_sanitize_discord_thread_title = GatewayRunner._sanitize_discord_thread_title
|
||||
_rename_discord_auto_thread_for_session_title = (
|
||||
|
|
@ -411,9 +471,12 @@ async def test_sibling_threads_in_one_channel_each_rename_to_own_thread():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_title_rename_polls_feedback_that_arrives_late():
|
||||
"""The auto-title races delivery: feedback lands AFTER the rename lane
|
||||
starts. The lane must poll the adapter cache and still rename."""
|
||||
async def test_title_rename_waits_for_feedback_that_arrives_late():
|
||||
"""The title beats delivery by a whole turn, and the lane still renames.
|
||||
|
||||
Feedback only exists once the reply is sent, so the lane waits on the send
|
||||
rather than on a nap long enough to cover a turn it can't measure.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
adapter, stub_conn = _adapter()
|
||||
|
|
@ -434,11 +497,21 @@ async def test_title_rename_polls_feedback_that_arrives_late():
|
|||
runner = _mk_runner_stub()(adapter)
|
||||
src = _relay_channel_source()
|
||||
|
||||
async def land_feedback_late():
|
||||
await asyncio.sleep(0.7) # past the first poll tick
|
||||
adapter._auto_thread_by_chat["chan-parent"] = ("th-9", "Initial words")
|
||||
async def send_outbound(action, *, platform=None):
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": "m1",
|
||||
"thread_id": "th-9",
|
||||
"auto_thread_name": "Initial words",
|
||||
}
|
||||
|
||||
task = asyncio.create_task(land_feedback_late())
|
||||
stub_conn.send_outbound = send_outbound # type: ignore[method-assign]
|
||||
|
||||
async def deliver_late():
|
||||
await asyncio.sleep(0.05)
|
||||
await adapter.send("chan-parent", "the reply")
|
||||
|
||||
task = asyncio.create_task(deliver_late())
|
||||
await runner._rename_discord_auto_thread_for_session_title(
|
||||
src, "sess1", "Debugging the flux capacitor"
|
||||
)
|
||||
|
|
@ -455,11 +528,11 @@ async def test_title_rename_polls_feedback_that_arrives_late():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_title_rename_true_miss_noops(monkeypatch):
|
||||
"""No feedback ever arrives (connector didn't auto-thread): no rename."""
|
||||
import gateway.run as run_mod
|
||||
async def test_title_rename_true_miss_noops():
|
||||
"""The connector didn't auto-thread this reply, so there is nothing to rename."""
|
||||
import asyncio
|
||||
|
||||
adapter, _ = _adapter()
|
||||
adapter, stub_conn = _adapter()
|
||||
renames: list = []
|
||||
|
||||
async def rename_thread(thread_id, name, **kw):
|
||||
|
|
@ -469,14 +542,19 @@ async def test_title_rename_true_miss_noops(monkeypatch):
|
|||
adapter.rename_thread = rename_thread # type: ignore[method-assign]
|
||||
runner = _mk_runner_stub()(adapter)
|
||||
src = _relay_channel_source()
|
||||
# Shrink the poll loop for test speed: 20 ticks of 0.5s -> patch sleep.
|
||||
orig_sleep = run_mod.asyncio.sleep
|
||||
|
||||
async def fast_sleep(_s):
|
||||
await orig_sleep(0)
|
||||
async def send_outbound(action, *, platform=None):
|
||||
return {"success": True, "message_id": "m1"}
|
||||
|
||||
monkeypatch.setattr(run_mod.asyncio, "sleep", fast_sleep)
|
||||
stub_conn.send_outbound = send_outbound # type: ignore[method-assign]
|
||||
|
||||
async def deliver():
|
||||
await asyncio.sleep(0.05)
|
||||
await adapter.send("chan-parent", "the reply")
|
||||
|
||||
task = asyncio.create_task(deliver())
|
||||
await runner._rename_discord_auto_thread_for_session_title(
|
||||
src, "sess1", "A title"
|
||||
)
|
||||
await task
|
||||
assert renames == []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
"""Which title stage is allowed to spend a platform rename.
|
||||
|
||||
Titling is two-stage: a derived slice of the user's own words lands inline, and
|
||||
the model's version replaces it a moment later. A local sidebar wants both. A
|
||||
Discord thread or a Telegram topic wants only the second — renaming twice lands
|
||||
on the same name at twice the cost, and Discord allows two channel renames per
|
||||
ten minutes, so the throwaway can be the one that survives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.run import TurnRunner
|
||||
|
||||
|
||||
def _attach(lane):
|
||||
"""Attach the title callback for *lane* and return (callback, renames)."""
|
||||
renames: list = []
|
||||
source = types.SimpleNamespace(platform=Platform.DISCORD, chat_id="chan-1")
|
||||
|
||||
runner = types.SimpleNamespace(
|
||||
_is_telegram_topic_lane=lambda src: lane == "telegram",
|
||||
_is_discord_auto_thread_lane=lambda src: lane == "discord",
|
||||
_is_relay_discord_channel_lane=lambda src: False,
|
||||
_schedule_telegram_topic_title_rename=(
|
||||
lambda src, sid, title: renames.append(title)
|
||||
),
|
||||
_schedule_discord_semantic_thread_rename=(
|
||||
lambda src, sid, title: renames.append(title)
|
||||
),
|
||||
)
|
||||
holder = types.SimpleNamespace(
|
||||
_runner=runner,
|
||||
_attach_session_title_callback=TurnRunner._attach_session_title_callback,
|
||||
)
|
||||
agent = types.SimpleNamespace(session_id="sess-1")
|
||||
holder._attach_session_title_callback(
|
||||
holder, agent, types.SimpleNamespace(source=source)
|
||||
)
|
||||
return agent._on_session_title, renames
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lane", ["telegram", "discord"])
|
||||
def test_the_rename_waits_for_the_model_title(lane):
|
||||
callback, renames = _attach(lane)
|
||||
|
||||
callback("fix the flaky auth test in log", "derived")
|
||||
assert renames == []
|
||||
|
||||
callback("Fix flaky auth test", "llm")
|
||||
assert renames == ["Fix flaky auth test"]
|
||||
|
|
@ -91,3 +91,66 @@ def test_resolve_nous_pricing_credentials_honors_inference_env_override(monkeypa
|
|||
assert base_url == "https://stg-inference-api.nousresearch.com/v1"
|
||||
|
||||
|
||||
def test_a_failed_catalog_fetch_is_not_cached_forever(monkeypatch):
|
||||
"""A blip must not disable live model discovery for the whole process.
|
||||
|
||||
The empty result is cached so a dead endpoint isn't re-dialed on every
|
||||
call, but it expires — the processes that read this run for weeks, and
|
||||
every caller silently falls back to a curated list meanwhile.
|
||||
"""
|
||||
models_mod._pricing_cache.clear()
|
||||
models_mod._pricing_cache_retry_after.clear()
|
||||
|
||||
calls = []
|
||||
|
||||
def _fail(req, timeout=8.0):
|
||||
calls.append(req)
|
||||
raise OSError("connection refused")
|
||||
|
||||
monkeypatch.setattr(models_mod, "_urlopen_model_catalog_request", _fail)
|
||||
|
||||
assert fetch_models_with_pricing(base_url="https://example.test") == {}
|
||||
# Inside the window the failure is cached: no second dial.
|
||||
assert fetch_models_with_pricing(base_url="https://example.test") == {}
|
||||
assert len(calls) == 1
|
||||
|
||||
now = models_mod.time.monotonic()
|
||||
monkeypatch.setattr(
|
||||
models_mod.time,
|
||||
"monotonic",
|
||||
lambda: now + models_mod._FAILED_CATALOG_TTL_SECONDS + 1,
|
||||
)
|
||||
assert fetch_models_with_pricing(base_url="https://example.test") == {}
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_a_successful_catalog_fetch_stays_cached(monkeypatch):
|
||||
"""Only the failures expire; a real catalog is still fetched once."""
|
||||
models_mod._pricing_cache.clear()
|
||||
models_mod._pricing_cache_retry_after.clear()
|
||||
|
||||
calls = []
|
||||
body = json.dumps(
|
||||
{"data": [{"id": "a/b", "pricing": {"prompt": "1", "completion": "2"}}]}
|
||||
).encode()
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
resp.__enter__ = lambda self: self
|
||||
resp.__exit__ = lambda *a: False
|
||||
|
||||
def _ok(req, timeout=8.0):
|
||||
calls.append(req)
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(models_mod, "_urlopen_model_catalog_request", _ok)
|
||||
|
||||
assert "a/b" in fetch_models_with_pricing(base_url="https://example.test")
|
||||
now = models_mod.time.monotonic()
|
||||
monkeypatch.setattr(
|
||||
models_mod.time,
|
||||
"monotonic",
|
||||
lambda: now + models_mod._FAILED_CATALOG_TTL_SECONDS + 1,
|
||||
)
|
||||
assert "a/b" in fetch_models_with_pricing(base_url="https://example.test")
|
||||
assert len(calls) == 1
|
||||
|
||||
|
|
|
|||
|
|
@ -12238,7 +12238,15 @@ def test_prompt_submit_wires_live_title_rename_callback(monkeypatch):
|
|||
|
||||
hook = getattr(agent, "_on_session_title", None)
|
||||
assert callable(hook), "gateway did not install a live title-rename hook"
|
||||
hook("Founding of Rome")
|
||||
# Titling is two-stage, and a local surface wants both: the sidebar renames
|
||||
# off the derived slice instantly and sharpens when the model's lands. Only
|
||||
# the lanes that spend a rate-limited remote rename filter by stage.
|
||||
hook("tell me about rome", "derived")
|
||||
hook("Founding of Rome", "llm")
|
||||
assert [payload["title"] for kind, payload in emitted if kind == "session.title"] == [
|
||||
"tell me about rome",
|
||||
"Founding of Rome",
|
||||
]
|
||||
assert (
|
||||
"session.title",
|
||||
{"session_id": "session-key", "title": "Founding of Rome"},
|
||||
|
|
|
|||
|
|
@ -9859,7 +9859,7 @@ def _run_prompt_submit(
|
|||
# sidebar repaints the moment a title lands, rather than waiting
|
||||
# for the next list refresh.
|
||||
_title_key = session.get("session_key") or sid
|
||||
agent._on_session_title = lambda t, _k=_title_key: _emit(
|
||||
agent._on_session_title = lambda t, _src, _k=_title_key: _emit(
|
||||
"session.title", sid, {"session_id": _k, "title": t}
|
||||
)
|
||||
result = agent.run_conversation(run_message, **run_kwargs)
|
||||
|
|
|
|||
Loading…
Reference in New Issue