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.
This commit is contained in:
Brooklyn Nicholson
2026-04-27 11:49:02 -05:00
parent 27936ee02d
commit 633f74504f
6 changed files with 181 additions and 47 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"},
@@ -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": [],

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

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