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:
brooklyn! 2026-04-27 12:05:54 -05:00 committed by GitHub
commit d5a89283b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 580 additions and 50 deletions

View File

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

View File

@ -17,6 +17,7 @@ pkgs.buildNpmPackage (npm // {
inherit src npmDeps version;
doCheck = false;
npmFlags = [ "--legacy-peer-deps" ];
installPhase = ''
runHook preInstall

View File

@ -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": [],

View File

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

View File

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

View File

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

View File

@ -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 => ({

View File

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

View File

@ -119,6 +119,12 @@ export interface SessionListResponse {
sessions?: SessionListItem[]
}
export interface SessionTitleResponse {
pending?: boolean
session_key?: string
title?: string
}
export interface SessionSaveResponse {
file?: string
}