From cdfbd89ea53970e9406b3dfa9f302b032d9a6705 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 10:51:14 -0500 Subject: [PATCH 1/6] fix(tui): keep /title session names in sync Route TUI /title through session.title RPC and queue titles when the session DB row is still initializing, so renamed sessions reliably appear in /resume and browse flows. --- tests/test_tui_gateway_server.py | 62 +++++++++++++++++++ tui_gateway/server.py | 28 +++++++-- .../src/__tests__/createSlashHandler.test.ts | 28 +++++++++ ui-tui/src/app/slash/commands/core.ts | 42 +++++++++++++ ui-tui/src/gatewayTypes.ts | 6 ++ 5 files changed, 162 insertions(+), 4 deletions(-) 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 } From 3824b0323793053d484176777bce8456b658fc4e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 11:05:10 -0500 Subject: [PATCH 2/6] fix(tui-gateway): harden session title RPC edge cases Handle session.title read failures without crashing, distinguish no-op title writes from missing session rows, and use a distinct empty-title error code with regression coverage. --- tests/test_tui_gateway_server.py | 76 ++++++++++++++++++++++++++++++++ tui_gateway/server.py | 25 ++++++++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 1a682b7972..d0e96922fa 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -263,6 +263,9 @@ def test_session_title_queues_when_db_row_not_ready(monkeypatch): def get_session_title(self, _key): return None + def get_session(self, _key): + return None + def set_session_title(self, _key, _title): return False @@ -297,6 +300,9 @@ def test_session_title_clears_pending_after_persist(monkeypatch): 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 @@ -320,6 +326,76 @@ def test_session_title_clears_pending_after_persist(monkeypatch): 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_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_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 ebfb9c88b3..4e1e99eb05 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1746,20 +1746,41 @@ def _(rid, params: dict) -> dict: return _db_unavailable_error(rid, code=5007) key = session["session_key"] if "title" not in params: + fallback = session.get("pending_title") or "" + try: + resolved_title = db.get_session_title(key) or fallback + except Exception: + resolved_title = fallback return _ok( rid, { - "title": db.get_session_title(key) or session.get("pending_title") or "", + "title": resolved_title, "session_key": key, }, ) title = (params.get("title", "") or "").strip() if not title: - return _err(rid, 4007, "title required") + return _err(rid, 4021, "title required") try: 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 = None + try: + existing_row = db.get_session(key) + except Exception: + existing_row = None + 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 Exception as e: From 492c4c6573b43c9d887d0161ca43809526661834 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 11:15:37 -0500 Subject: [PATCH 3/6] fix(tui-gateway): address follow-up Copilot title threads Tighten pending-title flush during session init and treat row lookup failures during title-set no-op detection as RPC errors instead of silently queueing. --- tests/test_tui_gateway_server.py | 28 ++++++++++++++++++++++++++++ tui_gateway/server.py | 27 ++++++++++++++++++++------- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index d0e96922fa..ff6a022b52 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -396,6 +396,34 @@ def test_session_title_rejects_empty_title_with_specific_error_code(monkeypatch) 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_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 4e1e99eb05..f47b21f0f0 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1571,10 +1571,27 @@ def _(rid, params: dict) -> dict: pending_title = (session.get("pending_title") or "").strip() if pending_title: try: - if db.set_session_title(key, pending_title): + 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 Exception: - pass + logger.warning( + "Failed to apply pending title for session %s", + sid, + exc_info=True, + ) session["agent"] = agent try: @@ -1767,11 +1784,7 @@ def _(rid, params: dict) -> dict: 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 = None - try: - existing_row = db.get_session(key) - except Exception: - existing_row = None + existing_row = db.get_session(key) if existing_row: session["pending_title"] = None return _ok( From 3aa86717b60c82065fc1cb8623dc2f7a3762d48c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 11:27:51 -0500 Subject: [PATCH 4/6] fix(tui-gateway): harden pending-title retry and user errors Retry persisting queued titles on session.title reads and map title validation failures to a user-facing 4022 code instead of generic 5007. --- tests/test_tui_gateway_server.py | 56 ++++++++++++++++++++++++++++++++ tui_gateway/server.py | 18 +++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ff6a022b52..76e0bb4f57 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -375,6 +375,34 @@ def test_session_title_get_falls_back_to_pending_when_db_read_throws(monkeypatch 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_rejects_empty_title_with_specific_error_code(monkeypatch): class _FakeDB: def get_session_title(self, _key): @@ -396,6 +424,34 @@ def test_session_title_rejects_empty_title_with_specific_error_code(monkeypatch) 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): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f47b21f0f0..acc91c3baa 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1765,7 +1765,21 @@ def _(rid, params: dict) -> dict: if "title" not in params: fallback = session.get("pending_title") or "" try: - resolved_title = db.get_session_title(key) or fallback + resolved_title = db.get_session_title(key) or "" + if not resolved_title and 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 + else: + resolved_title = fallback + elif resolved_title: + session["pending_title"] = None except Exception: resolved_title = fallback return _ok( @@ -1796,6 +1810,8 @@ def _(rid, params: dict) -> dict: ) 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)) From 27936ee02dfcfb91006ea839cf7dabd913055e1e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 11:31:49 -0500 Subject: [PATCH 5/6] fix(tui-gateway): keep queued user titles from being dropped Retry queued pending titles even when the DB already has a non-empty title so explicit user title intents are not silently lost (for example after auto-title). Includes regression coverage. --- tests/test_tui_gateway_server.py | 28 ++++++++++++++++++++++++++++ tui_gateway/server.py | 4 ++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 76e0bb4f57..6d448a34f4 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -403,6 +403,34 @@ def test_session_title_get_retries_persist_for_pending_title(monkeypatch): 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): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index acc91c3baa..601c90b41e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1766,7 +1766,7 @@ def _(rid, params: dict) -> dict: fallback = session.get("pending_title") or "" try: resolved_title = db.get_session_title(key) or "" - if not resolved_title and fallback: + if fallback: if db.set_session_title(key, fallback): session["pending_title"] = None resolved_title = fallback @@ -1776,7 +1776,7 @@ def _(rid, params: dict) -> dict: if existing_title == fallback: session["pending_title"] = None resolved_title = fallback - else: + elif not resolved_title: resolved_title = fallback elif resolved_title: session["pending_title"] = None From 633f74504f852d3aafba096fb9e7c698b8fe1c42 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 11:49:02 -0500 Subject: [PATCH 6/6] fix(ci): resolve follow-up title edge case and flaky checks Handle queued-title ValueError cleanup during session init, harden Discord message source building for test stubs, and fix the Dockerfile contract test syntax error. Also refresh the TUI lockfile and Nix build flags so nix ubuntu-latest no longer fails on npm lock/peer resolution drift. --- gateway/platforms/discord.py | 3 +- nix/tui.nix | 1 + tests/test_tui_gateway_server.py | 97 ++++++++++++++++++--- tests/tools/test_dockerfile_pid1_reaping.py | 3 +- tui_gateway/server.py | 83 ++++++++++++++---- ui-tui/package-lock.json | 41 +++++---- 6 files changed, 181 insertions(+), 47 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 0816fb93a0..e0b2a64c67 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -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), ) diff --git a/nix/tui.nix b/nix/tui.nix index 4fddebfecb..7453fa2673 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -17,6 +17,7 @@ pkgs.buildNpmPackage (npm // { inherit src npmDeps version; doCheck = false; + npmFlags = [ "--legacy-peer-deps" ]; installPhase = '' runHook preInstall diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6d448a34f4..99f42b0af4 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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"}, @@ -508,6 +524,57 @@ def test_session_title_set_errors_when_row_lookup_fails_after_noop(monkeypatch): 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 @@ -2048,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) @@ -2155,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.""" @@ -2169,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": [ @@ -2205,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, @@ -2235,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": [], diff --git a/tests/tools/test_dockerfile_pid1_reaping.py b/tests/tools/test_dockerfile_pid1_reaping.py index 1e47b64f6e..7538162798 100644 --- a/tests/tools/test_dockerfile_pid1_reaping.py +++ b/tests/tools/test_dockerfile_pid1_reaping.py @@ -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 = "" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 601c90b41e..b7cda00ff4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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): @@ -1576,7 +1586,9 @@ def _(rid, params: dict) -> dict: session["pending_title"] = None else: existing_row = db.get_session(key) - existing_title = ((existing_row or {}).get("title") or "").strip() + existing_title = ( + (existing_row or {}).get("title") or "" + ).strip() if existing_title == pending_title: session["pending_title"] = None else: @@ -1586,6 +1598,16 @@ def _(rid, params: dict) -> dict: 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", @@ -1731,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: @@ -1831,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( @@ -2969,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"): @@ -3833,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, @@ -3848,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() @@ -3859,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 ], ] @@ -3878,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 @@ -3889,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"), ] @@ -3898,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 @@ -4782,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( diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 2efd64fe40..017e9913bd 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -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" }