diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index fef44b40e7..1a682b7972 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -258,6 +258,68 @@ 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 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 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_config_set_yolo_toggles_session_scope(): from tools.approval import clear_session, is_session_yolo_enabled diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ae1c0d90fb..ebfb9c88b3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1530,6 +1530,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 +1568,13 @@ 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: + if db.set_session_title(key, pending_title): + session["pending_title"] = None + except Exception: + pass session["agent"] = agent try: @@ -1736,12 +1744,24 @@ 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: + return _ok( + rid, + { + "title": db.get_session_title(key) or session.get("pending_title") or "", + "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, 4007, "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}) + session["pending_title"] = title + return _ok(rid, {"pending": True, "title": title}) except Exception as e: return _err(rid, 5007, str(e)) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index b47efb3d52..dba3548712 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -397,6 +397,34 @@ describe('createSlashHandler', () => { expect(rpc).not.toHaveBeenCalled() expect(ctx.transcript.sys).toHaveBeenCalledWith('no active session — nothing to save') }) + + it('/title 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 => ({ diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 4c14fde4f1..91f06bb570 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -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('session.title', { session_id: ctx.sid }) + .then( + ctx.guarded(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 ') + } + + ctx.gateway + .rpc('session.title', { session_id: ctx.sid, title }) + .then( + ctx.guarded(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', diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index c645393268..dbaecd4d3d 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -119,6 +119,12 @@ export interface SessionListResponse { sessions?: SessionListItem[] } +export interface SessionTitleResponse { + pending?: boolean + session_key?: string + title?: string +} + export interface SessionSaveResponse { file?: string }