Merge pull request #16625 from NousResearch/bb/fix-tui-title-session-sync
fix(tui): keep /title session names in sync
This commit is contained in:
commit
d5a89283b7
@ -3294,6 +3294,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
chat_topic = self._get_effective_topic(message.channel, is_thread=is_thread)
|
||||
|
||||
# Build source
|
||||
guild = getattr(message, "guild", None)
|
||||
source = self.build_source(
|
||||
chat_id=str(effective_channel.id),
|
||||
chat_name=chat_name,
|
||||
@ -3303,7 +3304,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
thread_id=thread_id,
|
||||
chat_topic=chat_topic,
|
||||
is_bot=getattr(message.author, "bot", False),
|
||||
guild_id=str(message.guild.id) if message.guild else None,
|
||||
guild_id=str(guild.id) if guild else None,
|
||||
parent_chat_id=parent_channel_id,
|
||||
message_id=str(message.id),
|
||||
)
|
||||
|
||||
@ -17,6 +17,7 @@ pkgs.buildNpmPackage (npm // {
|
||||
inherit src npmDeps version;
|
||||
|
||||
doCheck = false;
|
||||
npmFlags = [ "--legacy-peer-deps" ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
@ -100,20 +100,36 @@ def test_session_resume_uses_parent_lineage_for_display(monkeypatch):
|
||||
|
||||
def get_messages_as_conversation(self, target, include_ancestors=False):
|
||||
captured.setdefault("history_calls", []).append((target, include_ancestors))
|
||||
return [
|
||||
{"role": "user", "content": "root prompt"},
|
||||
{"role": "assistant", "content": "root answer"},
|
||||
] if include_ancestors else [{"role": "user", "content": "tip prompt"}]
|
||||
return (
|
||||
[
|
||||
{"role": "user", "content": "root prompt"},
|
||||
{"role": "assistant", "content": "root answer"},
|
||||
]
|
||||
if include_ancestors
|
||||
else [{"role": "user", "content": "tip prompt"}]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: FakeDB())
|
||||
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
|
||||
monkeypatch.setattr(server, "_set_session_context", lambda target: [])
|
||||
monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
|
||||
monkeypatch.setattr(server, "_make_agent", lambda *args, **kwargs: types.SimpleNamespace(model="test"))
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent: {"model": "test", "tools": {}, "skills": {}})
|
||||
monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_make_agent",
|
||||
lambda *args, **kwargs: types.SimpleNamespace(model="test"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_session_info",
|
||||
lambda agent: {"model": "test", "tools": {}, "skills": {}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
server, "_init_session", lambda sid, key, agent, history, cols=80: None
|
||||
)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "session.resume", "params": {"session_id": "tip"}})
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.resume", "params": {"session_id": "tip"}}
|
||||
)
|
||||
|
||||
assert resp["result"]["messages"] == [
|
||||
{"role": "user", "text": "root prompt"},
|
||||
@ -258,6 +274,307 @@ def _session(agent=None, **extra):
|
||||
}
|
||||
|
||||
|
||||
def test_session_title_queues_when_db_row_not_ready(monkeypatch):
|
||||
class _FakeDB:
|
||||
def get_session_title(self, _key):
|
||||
return None
|
||||
|
||||
def get_session(self, _key):
|
||||
return None
|
||||
|
||||
def set_session_title(self, _key, _title):
|
||||
return False
|
||||
|
||||
server._sessions["sid"] = _session(pending_title=None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
try:
|
||||
set_resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.title",
|
||||
"params": {"session_id": "sid", "title": "queued title"},
|
||||
}
|
||||
)
|
||||
|
||||
assert set_resp["result"]["pending"] is True
|
||||
assert set_resp["result"]["title"] == "queued title"
|
||||
assert server._sessions["sid"]["pending_title"] == "queued title"
|
||||
|
||||
get_resp = server.handle_request(
|
||||
{"id": "2", "method": "session.title", "params": {"session_id": "sid"}}
|
||||
)
|
||||
assert get_resp["result"]["title"] == "queued title"
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_session_title_clears_pending_after_persist(monkeypatch):
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
self.title = "old"
|
||||
|
||||
def get_session_title(self, _key):
|
||||
return self.title
|
||||
|
||||
def get_session(self, _key):
|
||||
return {"id": _key, "title": self.title}
|
||||
|
||||
def set_session_title(self, _key, title):
|
||||
self.title = title
|
||||
return True
|
||||
|
||||
db = _FakeDB()
|
||||
server._sessions["sid"] = _session(pending_title="stale")
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.title",
|
||||
"params": {"session_id": "sid", "title": "fresh"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["pending"] is False
|
||||
assert resp["result"]["title"] == "fresh"
|
||||
assert server._sessions["sid"]["pending_title"] is None
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_session_title_does_not_queue_noop_when_row_exists(monkeypatch):
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
self.title = "same title"
|
||||
|
||||
def get_session_title(self, _key):
|
||||
return self.title
|
||||
|
||||
def get_session(self, _key):
|
||||
return {"id": _key, "title": self.title}
|
||||
|
||||
def set_session_title(self, _key, _title):
|
||||
# Simulate sqlite UPDATE rowcount==0 for no-op update.
|
||||
return False
|
||||
|
||||
server._sessions["sid"] = _session(pending_title="stale")
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.title",
|
||||
"params": {"session_id": "sid", "title": "same title"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["pending"] is False
|
||||
assert resp["result"]["title"] == "same title"
|
||||
assert server._sessions["sid"]["pending_title"] is None
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_session_title_get_falls_back_to_pending_when_db_read_throws(monkeypatch):
|
||||
class _FakeDB:
|
||||
def get_session_title(self, _key):
|
||||
raise RuntimeError("db temporarily locked")
|
||||
|
||||
server._sessions["sid"] = _session(pending_title="queued title")
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.title", "params": {"session_id": "sid"}}
|
||||
)
|
||||
assert resp["result"]["title"] == "queued title"
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_session_title_get_retries_persist_for_pending_title(monkeypatch):
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
self.title = ""
|
||||
|
||||
def get_session_title(self, _key):
|
||||
return self.title
|
||||
|
||||
def set_session_title(self, _key, title):
|
||||
self.title = title
|
||||
return True
|
||||
|
||||
def get_session(self, _key):
|
||||
return {"id": _key, "title": self.title}
|
||||
|
||||
db = _FakeDB()
|
||||
server._sessions["sid"] = _session(pending_title="queued title")
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.title", "params": {"session_id": "sid"}}
|
||||
)
|
||||
assert resp["result"]["title"] == "queued title"
|
||||
assert server._sessions["sid"]["pending_title"] is None
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_session_title_get_retries_pending_even_when_db_has_title(monkeypatch):
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
self.title = "auto title"
|
||||
|
||||
def get_session_title(self, _key):
|
||||
return self.title
|
||||
|
||||
def set_session_title(self, _key, title):
|
||||
self.title = title
|
||||
return True
|
||||
|
||||
def get_session(self, _key):
|
||||
return {"id": _key, "title": self.title}
|
||||
|
||||
db = _FakeDB()
|
||||
server._sessions["sid"] = _session(pending_title="queued title")
|
||||
monkeypatch.setattr(server, "_get_db", lambda: db)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.title", "params": {"session_id": "sid"}}
|
||||
)
|
||||
assert resp["result"]["title"] == "queued title"
|
||||
assert server._sessions["sid"]["pending_title"] is None
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_session_title_rejects_empty_title_with_specific_error_code(monkeypatch):
|
||||
class _FakeDB:
|
||||
def get_session_title(self, _key):
|
||||
return ""
|
||||
|
||||
server._sessions["sid"] = _session()
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.title",
|
||||
"params": {"session_id": "sid", "title": " "},
|
||||
}
|
||||
)
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 4021
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_session_title_set_maps_valueerror_to_user_error(monkeypatch):
|
||||
class _FakeDB:
|
||||
def get_session_title(self, _key):
|
||||
return ""
|
||||
|
||||
def get_session(self, _key):
|
||||
return {"id": _key}
|
||||
|
||||
def set_session_title(self, _key, _title):
|
||||
raise ValueError("Title already in use")
|
||||
|
||||
server._sessions["sid"] = _session()
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.title",
|
||||
"params": {"session_id": "sid", "title": "dup"},
|
||||
}
|
||||
)
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 4022
|
||||
assert "already in use" in resp["error"]["message"]
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_session_title_set_errors_when_row_lookup_fails_after_noop(monkeypatch):
|
||||
class _FakeDB:
|
||||
def get_session_title(self, _key):
|
||||
return ""
|
||||
|
||||
def get_session(self, _key):
|
||||
raise RuntimeError("row lookup failed")
|
||||
|
||||
def set_session_title(self, _key, _title):
|
||||
return False
|
||||
|
||||
server._sessions["sid"] = _session()
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "session.title",
|
||||
"params": {"session_id": "sid", "title": "fresh"},
|
||||
}
|
||||
)
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 5007
|
||||
assert "row lookup failed" in resp["error"]["message"]
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_session_create_drops_pending_title_on_valueerror(monkeypatch):
|
||||
unblock_agent = threading.Event()
|
||||
|
||||
class _FakeWorker:
|
||||
def __init__(self, key, model):
|
||||
self.key = key
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
class _FakeAgent:
|
||||
model = "x"
|
||||
provider = "openrouter"
|
||||
base_url = ""
|
||||
api_key = ""
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, _key, source="tui", model=None):
|
||||
return None
|
||||
|
||||
def set_session_title(self, _key, _title):
|
||||
raise ValueError("Title already in use")
|
||||
|
||||
def _make_agent(_sid, _key):
|
||||
unblock_agent.wait(timeout=2.0)
|
||||
return _FakeAgent()
|
||||
|
||||
monkeypatch.setattr(server, "_make_agent", _make_agent)
|
||||
monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"})
|
||||
monkeypatch.setattr(server, "_probe_credentials", lambda _a: None)
|
||||
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
|
||||
|
||||
import tools.approval as _approval
|
||||
|
||||
monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
|
||||
monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "session.create", "params": {"cols": 80}})
|
||||
sid = resp["result"]["session_id"]
|
||||
session = server._sessions[sid]
|
||||
session["pending_title"] = "duplicate title"
|
||||
unblock_agent.set()
|
||||
session["agent_ready"].wait(timeout=2.0)
|
||||
|
||||
assert session["pending_title"] is None
|
||||
server._sessions.pop(sid, None)
|
||||
|
||||
|
||||
def test_config_set_yolo_toggles_session_scope():
|
||||
from tools.approval import clear_session, is_session_yolo_enabled
|
||||
|
||||
@ -1798,6 +2115,7 @@ def test_session_create_continues_when_state_db_is_unavailable(monkeypatch):
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emits.append(a))
|
||||
|
||||
import tools.approval as _approval
|
||||
|
||||
monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
|
||||
monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)
|
||||
|
||||
@ -1905,6 +2223,7 @@ def test_model_options_propagates_list_exception(monkeypatch):
|
||||
# prompt.submit — auto-title
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ImmediateThread:
|
||||
"""Runs the target callable synchronously so assertions can follow."""
|
||||
|
||||
@ -1919,7 +2238,9 @@ def test_prompt_submit_auto_titles_session_on_complete(monkeypatch):
|
||||
"""maybe_auto_title is called after a successful (complete) prompt."""
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
return {
|
||||
"final_response": "Rome was founded in 753 BC.",
|
||||
"messages": [
|
||||
@ -1955,7 +2276,9 @@ def test_prompt_submit_skips_auto_title_when_interrupted(monkeypatch):
|
||||
"""maybe_auto_title must NOT be called when the agent was interrupted."""
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
return {
|
||||
"final_response": "partial answer",
|
||||
"interrupted": True,
|
||||
@ -1985,7 +2308,9 @@ def test_prompt_submit_skips_auto_title_when_response_empty(monkeypatch):
|
||||
"""maybe_auto_title must NOT be called when the agent returns an empty reply."""
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
return {
|
||||
"final_response": "",
|
||||
"messages": [],
|
||||
|
||||
@ -39,7 +39,8 @@ def _dockerfile_instructions(dockerfile_text: str) -> list[str]:
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
current = f"{current} {line.removesuffix('\\').strip()}".strip()
|
||||
continued = line.removesuffix("\\").strip()
|
||||
current = f"{current} {continued}".strip()
|
||||
if not line.endswith("\\"):
|
||||
instructions.append(current)
|
||||
current = ""
|
||||
|
||||
@ -759,8 +759,11 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
|
||||
custom_provs = None
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers, load_config
|
||||
|
||||
cfg = load_config()
|
||||
user_provs = [{"provider": k, **v} for k, v in (cfg.get("providers") or {}).items()]
|
||||
user_provs = [
|
||||
{"provider": k, **v} for k, v in (cfg.get("providers") or {}).items()
|
||||
]
|
||||
custom_provs = get_compatible_custom_providers(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
@ -918,7 +921,10 @@ def _probe_config_health(cfg: dict) -> str:
|
||||
def _session_info(agent) -> dict:
|
||||
reasoning_config = getattr(agent, "reasoning_config", None)
|
||||
reasoning_effort = ""
|
||||
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is not False:
|
||||
if (
|
||||
isinstance(reasoning_config, dict)
|
||||
and reasoning_config.get("enabled") is not False
|
||||
):
|
||||
reasoning_effort = str(reasoning_config.get("effort", "") or "")
|
||||
service_tier = getattr(agent, "service_tier", None) or ""
|
||||
info: dict = {
|
||||
@ -1042,7 +1048,11 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict):
|
||||
if _tool_progress_enabled(sid):
|
||||
# tool.complete is the source of truth for todos (full list from the
|
||||
# tool result). args.todos here may be a partial merge update.
|
||||
_emit("tool.start", sid, {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)})
|
||||
_emit(
|
||||
"tool.start",
|
||||
sid,
|
||||
{"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)},
|
||||
)
|
||||
|
||||
|
||||
def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str):
|
||||
@ -1530,6 +1540,7 @@ def _(rid, params: dict) -> dict:
|
||||
"history_lock": threading.Lock(),
|
||||
"history_version": 0,
|
||||
"image_counter": 0,
|
||||
"pending_title": None,
|
||||
"running": False,
|
||||
"session_key": key,
|
||||
"show_reasoning": _load_show_reasoning(),
|
||||
@ -1567,6 +1578,42 @@ def _(rid, params: dict) -> dict:
|
||||
db = _get_db()
|
||||
if db is not None:
|
||||
db.create_session(key, source="tui", model=_resolve_model())
|
||||
pending_title = (session.get("pending_title") or "").strip()
|
||||
if pending_title:
|
||||
try:
|
||||
title_applied = db.set_session_title(key, pending_title)
|
||||
if title_applied:
|
||||
session["pending_title"] = None
|
||||
else:
|
||||
existing_row = db.get_session(key)
|
||||
existing_title = (
|
||||
(existing_row or {}).get("title") or ""
|
||||
).strip()
|
||||
if existing_title == pending_title:
|
||||
session["pending_title"] = None
|
||||
else:
|
||||
logger.info(
|
||||
"Pending title still queued for session %s (wanted=%r, current=%r)",
|
||||
sid,
|
||||
pending_title,
|
||||
existing_title,
|
||||
)
|
||||
except ValueError as e:
|
||||
# Queued title can become invalid/duplicate between queue time
|
||||
# and DB row creation. Drop the queue and log the reason so
|
||||
# future /title reads don't surface a stuck pending value.
|
||||
session["pending_title"] = None
|
||||
logger.info(
|
||||
"Dropping pending title for session %s: %s",
|
||||
sid,
|
||||
e,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to apply pending title for session %s",
|
||||
sid,
|
||||
exc_info=True,
|
||||
)
|
||||
session["agent"] = agent
|
||||
|
||||
try:
|
||||
@ -1706,7 +1753,9 @@ def _(rid, params: dict) -> dict:
|
||||
try:
|
||||
db.reopen_session(target)
|
||||
history = db.get_messages_as_conversation(target)
|
||||
display_history = db.get_messages_as_conversation(target, include_ancestors=True)
|
||||
display_history = db.get_messages_as_conversation(
|
||||
target, include_ancestors=True
|
||||
)
|
||||
messages = _history_to_messages(display_history)
|
||||
tokens = _set_session_context(target)
|
||||
try:
|
||||
@ -1736,12 +1785,57 @@ def _(rid, params: dict) -> dict:
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5007)
|
||||
title, key = params.get("title", ""), session["session_key"]
|
||||
key = session["session_key"]
|
||||
if "title" not in params:
|
||||
fallback = session.get("pending_title") or ""
|
||||
try:
|
||||
resolved_title = db.get_session_title(key) or ""
|
||||
if fallback:
|
||||
if db.set_session_title(key, fallback):
|
||||
session["pending_title"] = None
|
||||
resolved_title = fallback
|
||||
else:
|
||||
existing_row = db.get_session(key)
|
||||
existing_title = ((existing_row or {}).get("title") or "").strip()
|
||||
if existing_title == fallback:
|
||||
session["pending_title"] = None
|
||||
resolved_title = fallback
|
||||
elif not resolved_title:
|
||||
resolved_title = fallback
|
||||
elif resolved_title:
|
||||
session["pending_title"] = None
|
||||
except Exception:
|
||||
resolved_title = fallback
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"title": resolved_title,
|
||||
"session_key": key,
|
||||
},
|
||||
)
|
||||
title = (params.get("title", "") or "").strip()
|
||||
if not title:
|
||||
return _ok(rid, {"title": db.get_session_title(key) or "", "session_key": key})
|
||||
return _err(rid, 4021, "title required")
|
||||
try:
|
||||
db.set_session_title(key, title)
|
||||
return _ok(rid, {"title": title})
|
||||
if db.set_session_title(key, title):
|
||||
session["pending_title"] = None
|
||||
return _ok(rid, {"pending": False, "title": title})
|
||||
# rowcount == 0 can mean "same value" as well as "missing row".
|
||||
# Queue only when the session row truly does not exist yet.
|
||||
existing_row = db.get_session(key)
|
||||
if existing_row:
|
||||
session["pending_title"] = None
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"pending": False,
|
||||
"title": (existing_row.get("title") or title),
|
||||
},
|
||||
)
|
||||
session["pending_title"] = title
|
||||
return _ok(rid, {"pending": True, "title": title})
|
||||
except ValueError as e:
|
||||
return _err(rid, 4022, str(e))
|
||||
except Exception as e:
|
||||
return _err(rid, 5007, str(e))
|
||||
|
||||
@ -1761,7 +1855,9 @@ def _(rid, params: dict) -> dict:
|
||||
db = _get_db()
|
||||
if db is not None and session.get("session_key"):
|
||||
try:
|
||||
history = db.get_messages_as_conversation(session["session_key"], include_ancestors=True)
|
||||
history = db.get_messages_as_conversation(
|
||||
session["session_key"], include_ancestors=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return _ok(
|
||||
@ -2899,7 +2995,11 @@ def _(rid, params: dict) -> dict:
|
||||
|
||||
if key == "mouse":
|
||||
raw = str(value or "").strip().lower()
|
||||
display = _load_cfg().get("display") if isinstance(_load_cfg().get("display"), dict) else {}
|
||||
display = (
|
||||
_load_cfg().get("display")
|
||||
if isinstance(_load_cfg().get("display"), dict)
|
||||
else {}
|
||||
)
|
||||
current = bool(display.get("tui_mouse", True))
|
||||
|
||||
if raw in ("", "toggle"):
|
||||
@ -3763,7 +3863,9 @@ def _details_completion_item(value: str, meta: str = "") -> dict:
|
||||
return {"text": value, "display": value, "meta": meta}
|
||||
|
||||
|
||||
def _details_root_completion_item(value: str, meta: str, needs_leading_space: bool) -> dict:
|
||||
def _details_root_completion_item(
|
||||
value: str, meta: str, needs_leading_space: bool
|
||||
) -> dict:
|
||||
return _details_completion_item(
|
||||
f" {value}" if needs_leading_space else value,
|
||||
meta,
|
||||
@ -3778,7 +3880,7 @@ def _details_completions(text: str) -> list[dict] | None:
|
||||
if stripped and not "/details".startswith(stripped.lower().split()[0]):
|
||||
return None
|
||||
|
||||
body = text[len("/details"):]
|
||||
body = text[len("/details") :]
|
||||
if body.startswith(" "):
|
||||
body = body[1:]
|
||||
parts = body.split()
|
||||
@ -3789,12 +3891,18 @@ def _details_completions(text: str) -> list[dict] | None:
|
||||
if not body or (len(parts) == 0 and has_trailing_space):
|
||||
return [
|
||||
*[
|
||||
_details_root_completion_item(mode, "global mode", not has_trailing_space)
|
||||
_details_root_completion_item(
|
||||
mode, "global mode", not has_trailing_space
|
||||
)
|
||||
for mode in modes
|
||||
],
|
||||
_details_root_completion_item("cycle", "cycle global mode", not has_trailing_space),
|
||||
_details_root_completion_item(
|
||||
"cycle", "cycle global mode", not has_trailing_space
|
||||
),
|
||||
*[
|
||||
_details_root_completion_item(section, "section override", not has_trailing_space)
|
||||
_details_root_completion_item(
|
||||
section, "section override", not has_trailing_space
|
||||
)
|
||||
for section in sections
|
||||
],
|
||||
]
|
||||
@ -3808,9 +3916,7 @@ def _details_completions(text: str) -> list[dict] | None:
|
||||
(
|
||||
"section override"
|
||||
if candidate in sections
|
||||
else "cycle global mode"
|
||||
if candidate == "cycle"
|
||||
else "global mode"
|
||||
else "cycle global mode" if candidate == "cycle" else "global mode"
|
||||
),
|
||||
)
|
||||
for candidate in candidates
|
||||
@ -3819,7 +3925,10 @@ def _details_completions(text: str) -> list[dict] | None:
|
||||
|
||||
if len(parts) == 1 and has_trailing_space and parts[0].lower() in sections:
|
||||
return [
|
||||
*[_details_completion_item(mode, f"set {parts[0].lower()}") for mode in modes],
|
||||
*[
|
||||
_details_completion_item(mode, f"set {parts[0].lower()}")
|
||||
for mode in modes
|
||||
],
|
||||
_details_completion_item("reset", f"clear {parts[0].lower()} override"),
|
||||
]
|
||||
|
||||
@ -3828,7 +3937,11 @@ def _details_completions(text: str) -> list[dict] | None:
|
||||
return [
|
||||
_details_completion_item(
|
||||
candidate,
|
||||
f"clear {parts[0].lower()} override" if candidate == "reset" else f"set {parts[0].lower()}",
|
||||
(
|
||||
f"clear {parts[0].lower()} override"
|
||||
if candidate == "reset"
|
||||
else f"set {parts[0].lower()}"
|
||||
),
|
||||
)
|
||||
for candidate in (*modes, "reset")
|
||||
if candidate.startswith(prefix) and candidate != prefix
|
||||
@ -4712,7 +4825,11 @@ def _(rid, params: dict) -> dict:
|
||||
|
||||
return _ok(rid, {"skills": get_available_skills()})
|
||||
if action == "search":
|
||||
from tools.skills_hub import GitHubAuth, create_source_router, unified_search
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth,
|
||||
create_source_router,
|
||||
unified_search,
|
||||
)
|
||||
|
||||
raw = (
|
||||
unified_search(
|
||||
|
||||
41
ui-tui/package-lock.json
generated
41
ui-tui/package-lock.json
generated
@ -124,7 +124,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@ -502,6 +501,31 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
@ -1676,7 +1700,6 @@
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
@ -1687,7 +1710,6 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@ -1698,7 +1720,6 @@
|
||||
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
@ -1728,7 +1749,6 @@
|
||||
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
@ -2046,7 +2066,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -2449,7 +2468,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@ -3185,7 +3203,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@ -3317,7 +3334,6 @@
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@ -4226,7 +4242,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
|
||||
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"type-fest": "^4.18.2"
|
||||
@ -5663,7 +5678,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -5773,7 +5787,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -6598,7 +6611,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@ -6725,7 +6737,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -6835,7 +6846,6 @@
|
||||
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
@ -7251,7 +7261,6 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@ -397,6 +397,34 @@ describe('createSlashHandler', () => {
|
||||
expect(rpc).not.toHaveBeenCalled()
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith('no active session — nothing to save')
|
||||
})
|
||||
|
||||
it('/title <name> uses session.title RPC and bypasses slash.exec', async () => {
|
||||
patchUiState({ sid: 'sid-abc' })
|
||||
const rpc = vi.fn(() => Promise.resolve({ pending: false, title: 'my title' }))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
createSlashHandler(ctx)('/title my title')
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('session.title', { session_id: 'sid-abc', title: 'my title' })
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith('session title set: my title')
|
||||
})
|
||||
})
|
||||
|
||||
it('/title with no args fetches and displays the current title', async () => {
|
||||
patchUiState({ sid: 'sid-abc' })
|
||||
const rpc = vi.fn(() => Promise.resolve({ title: 'demo title' }))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
createSlashHandler(ctx)('/title')
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('session.title', { session_id: 'sid-abc' })
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith('title: demo title')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
ConfigGetValueResponse,
|
||||
ConfigSetResponse,
|
||||
SessionSaveResponse,
|
||||
SessionTitleResponse,
|
||||
SessionSteerResponse,
|
||||
SessionUndoResponse
|
||||
} from '../../../gatewayTypes.js'
|
||||
@ -151,6 +152,47 @@ export const coreCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'set or show current session title',
|
||||
name: 'title',
|
||||
run: (arg, ctx) => {
|
||||
if (!ctx.sid) {
|
||||
return ctx.transcript.sys('no active session')
|
||||
}
|
||||
|
||||
const title = arg.trim()
|
||||
|
||||
if (!arg) {
|
||||
ctx.gateway
|
||||
.rpc<SessionTitleResponse>('session.title', { session_id: ctx.sid })
|
||||
.then(
|
||||
ctx.guarded<SessionTitleResponse>(r => {
|
||||
const current = (r?.title ?? '').trim()
|
||||
ctx.transcript.sys(current ? `title: ${current}` : 'no title set')
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return ctx.transcript.sys('usage: /title <your session title>')
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
.rpc<SessionTitleResponse>('session.title', { session_id: ctx.sid, title })
|
||||
.then(
|
||||
ctx.guarded<SessionTitleResponse>(r => {
|
||||
const next = (r?.title ?? title).trim()
|
||||
const suffix = r?.pending ? ' (queued while session initializes)' : ''
|
||||
ctx.transcript.sys(`session title set: ${next}${suffix}`)
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'toggle compact transcript',
|
||||
name: 'compact',
|
||||
|
||||
@ -119,6 +119,12 @@ export interface SessionListResponse {
|
||||
sessions?: SessionListItem[]
|
||||
}
|
||||
|
||||
export interface SessionTitleResponse {
|
||||
pending?: boolean
|
||||
session_key?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface SessionSaveResponse {
|
||||
file?: string
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user