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