Merge remote-tracking branch 'origin/master' into stage-332

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
nesquena-hermes
2026-05-10 18:07:50 +00:00
23 changed files with 1778 additions and 487 deletions
+2
View File
@@ -50,3 +50,5 @@ docs/*
graphify-out/
.graphify_cached.json
.graphify_uncached.txt
.venv/
+60
View File
@@ -16,6 +16,66 @@
5049 → **5057 collected, 5057 passing, 0 regressions** (+8 net new). Full suite 154s on Python 3.11 with `HERMES_HOME` isolation.
## [v0.51.37] — 2026-05-10 — Release M (compression / lineage backend)
### Fixed
- **PR #2004** by @franksong2702 — Persisted compression boundary summary for reload UI. Both manual `/session/compress` and auto-compression paths now persist `compression_anchor_summary`, `compression_anchor_visible_idx`, and `compression_anchor_message_key` so the compression card renders correctly after a page reload. Closes #1833.
- **PR #2006** by @qxxaa — Stamp profile on continuation session after context compression. In multi-profile deployments, memory writes after auto-compression silently targeted the **default profile's** `MEMORY.md`, regardless of which profile the browser session was using. Root cause: the compression migration block in `_periodic_checkpoint` did not carry `s.profile` across to the continuation session, so subsequent requests fell back to the default profile's `HERMES_HOME`. Fix resolves the profile name from `s.profile` (or `get_active_profile_name()` while TLS still holds) at streaming-thread start, then stamps `s.profile = _resolved_profile_name` on the continuation session. Verified evidence: session `0dfefb` had read the wrong profile's `MEMORY.md` (16% / 4 entries) instead of the troubleshooting profile's bank (72-77% / 5000+ chars).
- **PR #2011** by @ai-ag2026 — Sidebar lineage collapse: prefer the latest compressed segment when a parent row is touched. Previously the sidebar collapse helper picked representatives by timestamp only, which could surface a touched-parent row instead of the newer compressed tip. Now keys on `_compression_segment_count` so the highest-count segment wins. Regression test added.
- **PR #2014** by @ai-ag2026 — Keep explicit `/api/session/branch` forks out of compression-lineage collapse. Forked sessions now mark `session_source="fork"` on creation, and the sidebar lineage helper guards against folding fork rows into the compression-collapse path even when the parent isn't currently in the rendered window. Backend marker test + sidebar guard test added.
- **PR #2015** by @Jellypowered — Stitch continuation-lineage transcripts in WebUI. Sessions split by continuation events (compression boundary, CLI-close) could show only the latest segment in the WebUI message history. `get_cli_session_messages()` now walks the valid continuation lineage and stitches messages across sessions so the full conversation is visible.
### Added
- **PR #2012** by @dso2ng — New read-only `/api/session/lineage-report/<sid>` endpoint exposing a bounded JSON diagnostic of a session's compression/branching lineage. Pure backend probe — no client UI changes. The sidebar lineage UI (#1906/#1943) already covers user-facing affordances; this fills the bounded backend probe gap for CLI/scripting use.
### Tests
5049 → **5058 collected, 5058 passing, 0 regressions** (+9 net new across `test_session_lineage_collapse.py`, `test_session_lineage_full_transcript.py`, `test_session_lineage_report.py`, `test_465_session_branching.py`, `test_auto_compression_card.py`, `test_sprint46.py`). Full suite 157s on Python 3.11 with `HERMES_HOME` isolation.
### Notes
- `api/routes.py` (4 PRs touched it) and `api/streaming.py` (2 PRs) were the multi-PR files. All hunks at distinct anchors; stage merge clean with no conflicts.
- Theme coherence: every PR in this batch addresses session compression, lineage, or continuation-stitching — the same conceptual surface from different angles.
## [v0.51.36] — 2026-05-10 — Release L (locale + provider + cross-cutting)
### Fixed
- **PR #1992** by @29n`ctl.sh` line 42 used `[[ -v ${key} ]]`, which requires bash 4.2+. macOS ships with bash 3.2 → `conditional binary operator expected` error. Replaced with `[[ -n "${!key+x}" ]]` — a portable variable-set check that works on bash 3.2+, zsh, and POSIX-compatible shells. No behavior change.
- **PR #1998** by @franksong2702 — Localized `/goal` runtime status strings. Added 13 i18n keys (`goal_evaluating_progress`, `goal_working_toward`, `goal_continuing_toast`, `goal_status_*`, `goal_set/paused/resumed/cleared/no_goal`, `goal_achieved`, `goal_paused_budget_exhausted`, `goal_continuing`) across all locales; new keys reach `static/messages.js` and `static/commands.js` so the goal UI no longer hardcodes English. Closes #1933.
- **PR #2000** by @qxxaa — Skill tools resolve from the wrong profile after per-request profile switch. `tools/skills_tool.py` and `tools/skill_manager_tool.py` cache `HERMES_HOME` as a module-level constant at import time. The process-wide `switch_profile()` path patches both modules via `_set_hermes_home()`, but the per-request path (`switch_profile(process_wide=False)`, introduced in #1700) only updated `os.environ['HERMES_HOME']` and skipped the module patching. Result: agents on non-default profiles always saw the root profile's skills. Fix adds the same monkeypatching to the per-request branch in `api/streaming.py`. Closes the parity gap with #1700.
- **PR #2001** by @franksong2702`clarify.timeout` config was ignored by WebUI clarify prompts. The callback used a hardcoded `timeout = 120`. Now reads `clarify.timeout` from `api.config.get_config()` with bounded fallback (defaults to 120 on missing/invalid config), and threads `timeout_seconds` into the `api.clarify.submit_pending` payload so the frontend countdown matches the backend timeout. Regression test in `tests/test_sprint42.py`. Closes #1999.
- **PR #2005** by @vikarag — Added Xiaomi as a first-class provider in the WebUI's model catalog. `hermes-agent` already registered Xiaomi (verified at `hermes_cli/models.py:782` + auth entries) but `api/config.py` was missing the corresponding `_PROVIDER_DISPLAY` / `_PROVIDER_ALIASES` / `_PROVIDER_MODELS` entries, so the provider list showed Xiaomi as `Unsupported` and the model dropdown fell back to OpenRouter. Adds `xiaomi` display name, `mimo`/`xiaomi-mimo` aliases, and 5 MiMo models (V2.5 Pro/V2.5/V2 Pro/V2 Omni/V2 Flash).
### i18n
- **PR #2002** by @eov128 — Refreshed Simplified Chinese (zh) translation. Two kinds of changes:
- Decoded `\uXXXX` escape sequences to literal CJK characters in already-translated strings (semantically identical at runtime; improves source readability and grep-ability)
- Translated 30+ previously-untranslated strings tagged `// TODO: translate` — covering MCP server status (`mcp_status_active`, `mcp_status_configured`, ...), MCP tools panel, session toolsets, workspace hidden files, terminal pane, and personality switch hint
**Stage 330 conflict resolution:** #1998 added new `goal_*` English keys interleaved with the `cmd_interrupt` block that #2002 was rewriting; resolved by preserving #1998's new English keys (TODO: translate) above the section while taking #2002's CJK literals for `cmd_*` / `settings_*` keys.
**Stage 330 test fix:** `tests/test_chinese_locale.py::test_chinese_locale_includes_representative_translations` was pinned to the source-encoded `\uXXXX` form for `settings_title` and `login_title`. Broadened to accept either `\uXXXX` or literal CJK (same runtime behavior). Other source-form assertions in this test were already on literal CJK.
### Tests
5049 → **5049 collected, 5049 passing, 0 regressions** (one PR added new tests in `test_kanban_ui_static.py` already counted in stage 329; stage 330 net is flat). Full suite 158s on Python 3.11 with `HERMES_HOME` isolation.
### Notes
- `api/streaming.py` was the high-collision file (4 PRs touched it: #1998 #2000 #2001 #2006-not-in-this-stage). Stage merge clean; #2000 and #2001 each added separate ~17-LOC blocks at distinct anchor points, no overlap.
- All 6 PRs from 6 different authors except for #1998+#2001 (both @franksong2702). Disjoint themes.
## [v0.51.35] — 2026-05-10 — Release K (kanban polish + i18n DE pluralization)
### Fixed
+157
View File
@@ -439,6 +439,163 @@ def read_importable_agent_session_rows(
def _lineage_report_row(row: dict, role: str) -> dict:
updated_at = row.get('ended_at') if row.get('ended_at') is not None else row.get('started_at')
return {
'session_id': row.get('id'),
'role': role,
'title': row.get('title'),
'source': row.get('source'),
'started_at': row.get('started_at'),
'updated_at': updated_at,
'end_reason': row.get('end_reason'),
'active': row.get('ended_at') is None,
'archived': False,
}
def _empty_lineage_report(session_id: str, *, found: bool = False) -> dict:
return {
'mutation': False,
'found': found,
'session_id': session_id,
'lineage_key': session_id,
'tip_session_id': session_id,
'total_segments': 0,
'materialized_segments': 0,
'segments': [],
'children': [],
'manual_review': False,
}
def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops: int = 20) -> dict:
"""Return a bounded, read-only lifecycle report for a session lineage.
This helper intentionally reports only facts that can be derived from
``state.db.sessions`` without mutating WebUI JSON, archiving rows, or
deleting historical segments. It mirrors the sidebar continuation rules so
a future UI/PR can explain which rows are hidden compression/cli-close
segments and which child-session branches remain distinct.
"""
sid = str(session_id or '').strip()
if not sid:
return _empty_lineage_report('')
db_path = Path(db_path)
if not db_path.exists():
return _empty_lineage_report(sid)
try:
with closing(sqlite3.connect(str(db_path))) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("PRAGMA table_info(sessions)")
session_cols = {row[1] for row in cur.fetchall()}
required = {'id', 'parent_session_id', 'end_reason'}
if not required.issubset(session_cols):
return _empty_lineage_report(sid)
source_expr = _optional_col('source', session_cols)
title_expr = _optional_col('title', session_cols)
started_expr = _optional_col('started_at', session_cols, '0')
ended_expr = _optional_col('ended_at', session_cols)
end_reason_expr = _optional_col('end_reason', session_cols)
parent_expr = _optional_col('parent_session_id', session_cols)
def fetch_one(row_id: str | None) -> dict | None:
if not row_id:
return None
cur.execute(
f"""
SELECT s.id,
{source_expr},
{title_expr},
{started_expr},
{parent_expr},
{ended_expr},
{end_reason_expr}
FROM sessions s
WHERE s.id = ?
""",
(row_id,),
)
row = cur.fetchone()
return dict(row) if row else None
target = fetch_one(sid)
if not target:
return _empty_lineage_report(sid)
segments = [target]
current = target
seen = {sid}
manual_review = False
for _hop in range(max(0, int(max_hops))):
parent_id = current.get('parent_session_id')
parent = fetch_one(parent_id)
if not parent or parent_id in seen:
manual_review = bool(parent_id and parent_id in seen)
break
if not _is_continuation_session(parent, current):
break
segments.append(parent)
seen.add(parent_id)
current = parent
else:
manual_review = True
segment_ids = {row['id'] for row in segments}
child_rows: list[dict] = []
for parent in segments:
cur.execute(
f"""
SELECT s.id,
{source_expr},
{title_expr},
{started_expr},
{parent_expr},
{ended_expr},
{end_reason_expr}
FROM sessions s
WHERE s.parent_session_id = ?
ORDER BY s.started_at DESC
""",
(parent['id'],),
)
for child_row in cur.fetchall():
child = dict(child_row)
if child['id'] in segment_ids:
continue
if _is_continuation_session(parent, child):
# A continuation outside the selected path means the
# lineage is branched or the caller selected an older
# segment. Report manual review rather than proposing
# destructive cleanup candidates.
manual_review = True
continue
child_rows.append(child)
except Exception:
return _empty_lineage_report(sid)
root_id = segments[-1]['id'] if segments else sid
tip_id = segments[0]['id'] if segments else sid
return {
'mutation': False,
'found': True,
'session_id': sid,
'lineage_key': root_id,
'tip_session_id': tip_id,
'total_segments': len(segments),
'materialized_segments': len(segments),
'segments': [
_lineage_report_row(row, 'tip' if idx == 0 else 'hidden_segment')
for idx, row in enumerate(segments)
],
'children': [_lineage_report_row(row, 'child_session') for row in child_rows],
'manual_review': manual_review,
}
def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[str]) -> dict[str, dict]:
"""Return compression-lineage metadata for known WebUI sidebar sessions.
+11
View File
@@ -657,6 +657,7 @@ _PROVIDER_DISPLAY = {
"qwen": "Qwen",
"x-ai": "xAI",
"nvidia": "NVIDIA NIM",
"xiaomi": "Xiaomi",
}
# Provider alias → canonical slug. Users configure providers using the
@@ -707,6 +708,8 @@ _PROVIDER_ALIASES = {
"nvidia-nim": "nvidia",
"build-nvidia": "nvidia",
"nemotron": "nvidia",
"mimo": "xiaomi",
"xiaomi-mimo": "xiaomi",
# Legacy alias — earlier WebUI builds wrote ``provider: local`` for unknown
# loopback endpoints, but ``local`` is not registered in
# ``hermes_cli.auth.PROVIDER_REGISTRY``. Routing it through ``custom``
@@ -1066,6 +1069,14 @@ _PROVIDER_MODELS = {
{"id": "nvidia/llama-3.3-nemotron-super-49b-v1.5", "label": "Llama 3.3 Nemotron Super 49B"},
{"id": "qwen/qwen3-next-80b-a3b-instruct", "label": "Qwen3 Next 80B"},
],
# Xiaomi MiMo — direct API via api.xiaomimimo.com
"xiaomi": [
{"id": "mimo-v2.5-pro", "label": "MiMo V2.5 Pro"},
{"id": "mimo-v2.5", "label": "MiMo V2.5"},
{"id": "mimo-v2-pro", "label": "MiMo V2 Pro"},
{"id": "mimo-v2-omni", "label": "MiMo V2 Omni"},
{"id": "mimo-v2-flash", "label": "MiMo V2 Flash"},
],
# xAI — prefix used in OpenRouter model IDs (x-ai/grok-4-20)
"x-ai": [
{"id": "grok-4.20", "label": "Grok 4.20"},
+123 -4
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import copy
import logging
import re
import time
from pathlib import Path
from typing import Any, Dict, Optional
@@ -279,6 +280,8 @@ def _payload(
error: str | None = None,
kickoff_prompt: str | None = None,
decision: Dict[str, Any] | None = None,
message_key: str | None = None,
message_args: list[Any] | None = None,
) -> Dict[str, Any]:
body: Dict[str, Any] = {
"ok": bool(ok),
@@ -292,9 +295,98 @@ def _payload(
body["kickoff_prompt"] = kickoff_prompt
if decision is not None:
body["decision"] = decision
if message_key:
body["message_key"] = message_key
if message_args is not None:
body["message_args"] = [a for a in message_args if a is not None]
return body
def _goal_status_payload(state: Any, *, default_message: str | None = None) -> Dict[str, Any]:
"""Build localized-status style payload fields from a goal state."""
if default_message is None:
default_message = "No active goal. Set one with /goal <text>."
if state is None:
return {"message": default_message, "message_key": "goal_status_none"}
status = str(getattr(state, "status", "") or "").strip()
if status in ("cleared",):
return {"message": default_message, "message_key": "goal_status_none"}
turns_used = int(getattr(state, "turns_used", 0) or 0)
max_turns = int(getattr(state, "max_turns", 0) or 0)
goal = str(getattr(state, "goal", "") or "")
if status == "active":
return {
"message": f"⊙ Goal (active, {turns_used}/{max_turns} turns): {goal}",
"message_key": "goal_status_active",
"message_args": [turns_used, max_turns, goal],
}
if status == "paused":
reason = str(getattr(state, "paused_reason", "") or "")
return {
"message": f"⏸ Goal (paused, {turns_used}/{max_turns}{'' + reason if reason else ''}): {goal}",
"message_key": "goal_status_paused",
"message_args": [turns_used, max_turns, reason, goal],
}
if status == "done":
return {
"message": f"✓ Goal done ({turns_used}/{max_turns}): {goal}",
"message_key": "goal_status_done",
"message_args": [turns_used, max_turns, goal],
}
return {
"message": f"Goal ({status}, {turns_used}/{max_turns}): {goal}",
"message_args": [status, turns_used, max_turns, goal],
}
def _extract_goal_turns_from_message(message: str) -> tuple[int, int]:
"""Best-effort extraction for continuation messages like '(1/20)'."""
if not message:
return 0, 0
match = re.search(r"\((\d+)\s*/\s*(\d+)\)", message)
if not match:
return 0, 0
try:
return int(match.group(1)), int(match.group(2))
except Exception:
return 0, 0
def _goal_decision_payload(
decision: Dict[str, Any],
state: Any,
) -> Dict[str, Any]:
"""Attach goal message i18n key/args to an evaluation decision."""
if not isinstance(decision, dict):
return decision
status = str(decision.get("status") or "").strip()
reason = str(decision.get("reason") or "").strip()
turns_used = int(getattr(state, "turns_used", 0) or 0)
max_turns = int(getattr(state, "max_turns", 0) or 0)
if (turns_used, max_turns) == (0, 0):
turns_used, max_turns = _extract_goal_turns_from_message(str(decision.get("message") or ""))
if status == "done":
return {
**decision,
"message_key": "goal_achieved",
"message_args": [reason],
}
if status == "paused":
return {
**decision,
"message_key": "goal_paused_budget_exhausted",
"message_args": [turns_used, max_turns],
}
if decision.get("should_continue"):
return {
**decision,
"message_key": "goal_continuing",
"message_args": [turns_used, max_turns, reason],
}
return decision
def goal_state_snapshot(session_id: str, *, profile_home: str | Path | None = None) -> Any:
"""Return a deep copy of current goal state for rollback before kickoff."""
mgr = _manager(str(session_id or ""), profile_home=profile_home)
@@ -355,24 +447,46 @@ def goal_command_payload(
lower = text.lower()
if not text or lower == "status":
return _payload(action="status", message=mgr.status_line(), state=getattr(mgr, "state", None))
state = getattr(mgr, "state", None)
status_payload = _goal_status_payload(state)
return _payload(action="status", state=state, **status_payload)
if lower == "pause":
state = mgr.pause(reason="user-paused")
if state is None:
return _payload(ok=False, action="pause", error="no_goal", message="No goal set.")
return _payload(action="pause", message=f"⏸ Goal paused: {state.goal}", state=state)
return _payload(
ok=False,
action="pause",
error="no_goal",
message="No goal set.",
message_key="goal_no_goal",
)
return _payload(
action="pause",
message=f"⏸ Goal paused: {state.goal}",
message_key="goal_paused",
message_args=[str(state.goal)],
state=state,
)
if lower == "resume":
state = mgr.resume()
if state is None:
return _payload(ok=False, action="resume", error="no_goal", message="No goal to resume.")
return _payload(
ok=False,
action="resume",
error="no_goal",
message="No goal to resume.",
message_key="goal_no_goal",
)
return _payload(
action="resume",
message=(
f"▶ Goal resumed: {state.goal}\n"
"Send a new message, or type continue, to kick it off."
),
message_key="goal_resumed",
message_args=[str(state.goal)],
state=state,
)
@@ -382,6 +496,7 @@ def goal_command_payload(
return _payload(
action="clear",
message="Goal cleared." if had else "No active goal.",
message_key="goal_cleared" if had else "goal_no_goal",
state=getattr(mgr, "state", None),
)
@@ -408,6 +523,8 @@ def goal_command_payload(
"I'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\n"
"Controls: /goal status · /goal pause · /goal resume · /goal clear"
),
message_key="goal_set",
message_args=[state.max_turns, state.goal],
state=state,
kickoff_prompt=state.goal,
)
@@ -486,4 +603,6 @@ def evaluate_goal_after_turn(
decision.setdefault("should_continue", False)
decision.setdefault("continuation_prompt", None)
decision.setdefault("message", "")
decision = dict(decision)
decision = _goal_decision_payload(decision, getattr(mgr, "state", None))
return decision
+55 -5
View File
@@ -329,6 +329,7 @@ class Session:
context_messages=None,
compression_anchor_visible_idx=None,
compression_anchor_message_key=None,
compression_anchor_summary=None,
context_length=None, threshold_tokens=None,
last_prompt_tokens=None,
gateway_routing=None, gateway_routing_history=None,
@@ -361,6 +362,7 @@ class Session:
self.context_messages = context_messages if isinstance(context_messages, list) else []
self.compression_anchor_visible_idx = compression_anchor_visible_idx
self.compression_anchor_message_key = compression_anchor_message_key
self.compression_anchor_summary = compression_anchor_summary
self.context_length = context_length
self.threshold_tokens = threshold_tokens
self.last_prompt_tokens = last_prompt_tokens
@@ -411,6 +413,7 @@ class Session:
'personality', 'active_stream_id',
'pending_user_message', 'pending_attachments', 'pending_started_at',
'compression_anchor_visible_idx', 'compression_anchor_message_key',
'compression_anchor_summary',
'context_length', 'threshold_tokens', 'last_prompt_tokens',
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
'parent_session_id',
@@ -572,6 +575,7 @@ class Session:
'personality': self.personality,
'compression_anchor_visible_idx': self.compression_anchor_visible_idx,
'compression_anchor_message_key': self.compression_anchor_message_key,
'compression_anchor_summary': self.compression_anchor_summary,
'context_length': self.context_length,
'threshold_tokens': self.threshold_tokens,
'last_prompt_tokens': self.last_prompt_tokens,
@@ -1662,7 +1666,9 @@ def get_cli_session_messages(sid) -> list:
Preserve tool-call/result and reasoning metadata from the agent state.db so
CLI-origin transcripts render with the same tool cards as WebUI-native
sessions. Returns empty list on any error.
sessions. When the requested session is the tip of a compression/CLI-close
continuation chain, return the stitched full transcript across all segments
in chronological order. Returns empty list on any error.
"""
import os
if str(sid or '').startswith(f'{CLAUDE_CODE_SOURCE}_'):
@@ -1701,12 +1707,56 @@ def get_cli_session_messages(sid) -> list:
'codex_message_items',
]
selected = ['role', 'content', 'timestamp'] + [c for c in optional if c in available]
cur.execute("PRAGMA table_info(sessions)")
session_cols = {str(row['name']) for row in cur.fetchall()}
session_chain = [str(sid)]
if {'parent_session_id', 'end_reason', 'started_at', 'source'}.issubset(session_cols):
cur.execute(
"""
SELECT id, source, started_at, parent_session_id, ended_at, end_reason
FROM sessions
WHERE id = ?
""",
(sid,),
)
rows_by_id = {}
row = cur.fetchone()
if row:
rows_by_id[str(row['id'])] = dict(row)
current_id = str(row['id'])
seen = {current_id}
for _ in range(20):
current = rows_by_id.get(current_id)
parent_id = current.get('parent_session_id') if current else None
if not parent_id or parent_id in seen:
break
cur.execute(
"""
SELECT id, source, started_at, parent_session_id, ended_at, end_reason
FROM sessions
WHERE id = ?
""",
(parent_id,),
)
parent_row = cur.fetchone()
if not parent_row:
break
parent_dict = dict(parent_row)
rows_by_id[str(parent_row['id'])] = parent_dict
if not _is_continuation_session(parent_dict, current):
break
session_chain.insert(0, str(parent_row['id']))
current_id = str(parent_row['id'])
seen.add(current_id)
placeholders = ', '.join('?' for _ in session_chain)
cur.execute(f"""
SELECT {', '.join(selected)}
SELECT {', '.join(selected)}, session_id
FROM messages
WHERE session_id = ?
ORDER BY timestamp ASC
""", (sid,))
WHERE session_id IN ({placeholders})
ORDER BY timestamp ASC, id ASC
""", session_chain)
msgs = []
for row in cur.fetchall():
msg = {
+74 -2
View File
@@ -26,6 +26,7 @@ from api.agent_sessions import (
MESSAGING_SOURCES,
is_cli_session_row,
is_cli_session_row_visible,
read_session_lineage_report,
)
logger = logging.getLogger(__name__)
@@ -3028,8 +3029,31 @@ def handle_get(handler, parsed) -> bool:
# longer visible conversation than the single state.db
# segment for this messaging session id. Prefer the longer
# sidecar so repaired WebUI history is not hidden behind the
# canonical per-segment transcript.
_all_msgs = sidecar_messages if len(sidecar_messages) > len(cli_messages) else cli_messages
# canonical per-segment transcript. When both sources carry
# different slices of the same stitched conversation, merge
# them chronologically and dedupe exact repeats.
if sidecar_messages and sidecar_messages != cli_messages:
merged_messages = []
seen_message_keys = set()
for msg in sorted(list(cli_messages) + list(sidecar_messages), key=lambda m: (
float(m.get("timestamp") or 0),
str(m.get("role") or ""),
str(m.get("content") or ""),
)):
key = (
str(msg.get("role") or ""),
str(msg.get("content") or ""),
str(msg.get("timestamp") or ""),
str(msg.get("tool_call_id") or ""),
str(msg.get("tool_name") or msg.get("name") or ""),
)
if key in seen_message_keys:
continue
seen_message_keys.add(key)
merged_messages.append(msg)
_all_msgs = merged_messages
else:
_all_msgs = sidecar_messages if len(sidecar_messages) > len(cli_messages) else cli_messages
else:
_all_msgs = s.messages
else:
@@ -3184,6 +3208,15 @@ def handle_get(handler, parsed) -> bool:
return j(handler, {"session": redact_session_data(sess)})
return bad(handler, "Session not found", 404)
if parsed.path == "/api/session/lineage/report":
sid = parse_qs(parsed.query).get("session_id", [""])[0]
if not sid:
return bad(handler, "session_id required", 400)
report = read_session_lineage_report(_active_state_db_path(), sid)
if not report.get("found"):
return bad(handler, "Session not found", 404)
return j(handler, report)
if parsed.path == "/api/session/status":
sid = parse_qs(parsed.query).get("session_id", [""])[0]
if not sid:
@@ -4232,6 +4265,7 @@ def handle_post(handler, parsed) -> bool:
title=branch_title,
messages=forked_messages,
parent_session_id=source.session_id,
session_source="fork",
)
with LOCK:
SESSIONS[branch.session_id] = branch
@@ -7505,6 +7539,38 @@ def _handle_session_compress(handler, body):
return None
return {"role": role, "ts": ts, "text": norm, "attachments": attach_count}
def _compression_summary_from_messages(messages):
text = None
for m in reversed(messages or []):
if not isinstance(m, dict):
continue
role = str(m.get("role") or "").lower()
if role != "assistant":
continue
if not isinstance(m.get("content"), str):
continue
content = str(m.get("content") or "").strip()
if not content:
continue
norm = re.sub(r"\s+", " ", content).strip()
if (
"context compaction" in norm.lower()
or "context compression" in norm.lower()
):
return norm
return None
def _compact_summary_text(raw_text):
if not isinstance(raw_text, str):
return None
txt = raw_text.strip()
if not txt:
return None
txt = re.sub(r"\s+", " ", txt)
if len(txt) > 320:
txt = f"{txt[:314]}"
return txt
try:
require(body, "session_id")
except ValueError as e:
@@ -7691,6 +7757,12 @@ def _handle_session_compress(handler, body):
visible_after = _visible_messages_for_anchor(compressed)
s.compression_anchor_visible_idx = max(0, len(visible_after) - 1) if visible_after else None
s.compression_anchor_message_key = _anchor_message_key(visible_after[-1]) if visible_after else None
summary_text = None
if isinstance(summary, dict):
summary_text = summary.get("reference_message") or summary.get("token_line") or summary.get("headline")
s.compression_anchor_summary = _compact_summary_text(
summary_text or _compression_summary_from_messages(compressed) or ""
)
s.save()
session_payload = redact_session_data(
+164 -2
View File
@@ -20,6 +20,7 @@ from typing import Optional
logger = logging.getLogger(__name__)
from api.config import (
get_config,
STREAMS, STREAMS_LOCK, CANCEL_FLAGS, AGENT_INSTANCES, STREAM_PARTIAL_TEXT,
STREAM_REASONING_TEXT, STREAM_LIVE_TOOL_CALLS,
STREAM_GOAL_RELATED, PENDING_GOAL_CONTINUATION,
@@ -86,6 +87,19 @@ def _is_quota_error_text(err_text: str) -> bool:
)
def _clarify_timeout_seconds(default: int = 120) -> int:
"""Resolve clarify timeout from config, with bounded fallback."""
try:
cfg = get_config()
raw = cfg.get("clarify", {}).get("timeout", default)
timeout_seconds = int(raw)
if timeout_seconds <= 0:
return default
return timeout_seconds
except Exception:
return default
def _classify_provider_error(err_str: str, exc=None, *, silent_failure: bool = False) -> dict:
"""Classify provider/agent failure text for WebUI apperror UX.
@@ -1536,6 +1550,87 @@ def _is_context_compression_marker(msg):
)
def _compact_summary_text(raw_text: str | None, limit: int = 320) -> str | None:
"""Normalize a text blob used in compression summary cards."""
if not isinstance(raw_text, str):
return None
txt = raw_text.strip()
if not txt:
return None
txt = re.sub(r"\s+", " ", txt).strip()
if len(txt) > limit:
txt = f"{txt[: limit - 6]}"
return txt
def _compression_anchor_message_key(message):
if not isinstance(message, dict):
return None
role = str(message.get('role') or '')
if not role or role == 'tool':
return None
content = message.get('content', '')
text = _message_text(content)
if len(text) > 160:
text = text[:160]
ts = message.get('_ts') or message.get('timestamp')
attachments = message.get('attachments')
attach_count = len(attachments) if isinstance(attachments, list) else 0
if not text and not attach_count and not ts:
return None
return {'role': role, 'ts': ts, 'text': text, 'attachments': attach_count}
def _visible_messages_for_compression_anchor(messages):
out = []
for m in messages or []:
if not isinstance(m, dict):
continue
role = m.get('role')
if not role or role == 'tool':
continue
content = m.get('content', '')
has_attachments = bool(m.get('attachments'))
has_tool_calls = bool(isinstance(m.get('tool_calls'), list) and m.get('tool_calls'))
has_tool_use = False
has_reasoning = bool(m.get('reasoning'))
if isinstance(content, list):
text = '\n'.join(
str(p.get('text') or p.get('content') or '')
for p in content
if isinstance(p, dict)
and p.get('type') in {'text', 'input_text', 'output_text'}
).strip()
for part in content:
if not isinstance(part, dict):
continue
if part.get('type') == 'tool_use':
has_tool_use = True
if not text:
has_reasoning = has_reasoning or any(
isinstance(part, dict)
and part.get('type') in {'thinking', 'reasoning'}
for part in content
)
else:
text = str(content or '').strip()
if text or has_attachments or has_tool_calls or has_tool_use or has_reasoning:
out.append(m)
return out
def _compression_summary_from_messages(messages):
for m in reversed(messages or []):
if not isinstance(m, dict):
continue
if not _is_context_compression_marker(m):
continue
text = _message_text(m.get('content'))
if text:
return text
return None
def _find_current_user_turn(messages, msg_text):
needle = " ".join(str(msg_text or '').split())
fallback = None
@@ -2022,7 +2117,24 @@ def _run_agent_streaming(
except ImportError:
_profile_home = os.environ.get('HERMES_HOME', '')
_profile_runtime_env = {}
# Capture the resolved profile name now, while profile context is
# reliable. Used in the compression migration block to stamp s.profile
# on the continuation session. We resolve it here rather than calling
# get_active_profile_name() at compression time because that function
# reads thread-local storage (_tls.profile) set by set_request_profile()
# on the HTTP handler thread. The streaming thread is a separate
# threading.Thread and does not inherit TLS. At compression time,
# get_active_profile_name() would fall back to the process-global
# _active_profile, which may belong to a different concurrent tab.
_resolved_profile_name = getattr(s, 'profile', None)
if not _resolved_profile_name:
try:
from api.profiles import get_active_profile_name
_resolved_profile_name = get_active_profile_name()
except Exception:
_resolved_profile_name = None
_thread_env = _build_agent_thread_env(
_profile_runtime_env,
str(s.workspace),
@@ -2046,6 +2158,23 @@ def _run_agent_streaming(
os.environ['HERMES_SESSION_KEY'] = session_id
if _profile_home:
os.environ['HERMES_HOME'] = _profile_home
# Patch module-level caches to match the active profile.
# _set_hermes_home() does this for process-wide switches
# but per-request switches skip it (#1700).
from pathlib import Path as _P
_ph = _P(_profile_home)
try:
import tools.skills_tool as _sk
_sk.HERMES_HOME = _ph
_sk.SKILLS_DIR = _ph / 'skills'
except (ImportError, AttributeError):
pass
try:
import tools.skill_manager_tool as _sm
_sm.HERMES_HOME = _ph
_sm.SKILLS_DIR = _ph / 'skills'
except (ImportError, AttributeError):
pass
# Lock released — agent runs without holding it
# ── MCP Server Discovery (lazy import, idempotent) ──
# MUST run AFTER the HERMES_HOME mutation above — `discover_mcp_tools()`
@@ -2106,7 +2235,7 @@ def _run_agent_streaming(
def _clarify_callback_impl(question, choices, sid, cancel_evt, put_event):
"""Bridge Hermes clarify prompts to the WebUI."""
timeout = 120
timeout = _clarify_timeout_seconds()
choices_list = [str(choice) for choice in (choices or [])]
data = {
'question': str(question or ''),
@@ -2114,6 +2243,7 @@ def _run_agent_streaming(
'session_id': sid,
'kind': 'clarify',
'requested_at': time.time(),
'timeout_seconds': timeout,
}
try:
from api.clarify import submit_pending as _submit_clarify_pending, clear_pending as _clear_clarify_pending
@@ -2972,6 +3102,22 @@ def _run_agent_streaming(
old_path = SESSION_DIR / f'{old_sid}.json'
new_path = SESSION_DIR / f'{new_sid}.json'
s.session_id = new_sid
# Carry profile identity across the compression boundary.
# Without this, s.profile stays None on the continuation
# session. On the next request, _run_agent_streaming calls
# get_hermes_home_for_profile(getattr(s, 'profile', None))
# which falls back to the default profile's HERMES_HOME.
# Memory writes then land in the wrong profile's MEMORY.md.
# Stamping here also ensures s.save() persists a non-null
# profile field to the continuation session's JSON file,
# covering the case where the session is later evicted from
# SESSIONS and reconstructed from disk via Session.load().
if not s.profile and _resolved_profile_name:
s.profile = _resolved_profile_name
logger.info(
"Stamped profile=%r on continuation session %s after compression",
_resolved_profile_name, new_sid,
)
with LOCK:
if old_sid in SESSIONS:
SESSIONS[new_sid] = SESSIONS.pop(old_sid)
@@ -3001,6 +3147,17 @@ def _run_agent_streaming(
_compressed = True
# Notify the frontend that compression happened
if _compressed:
visible_after = _visible_messages_for_compression_anchor(s.messages)
s.compression_anchor_visible_idx = (
max(0, len(visible_after) - 1) if visible_after else None
)
s.compression_anchor_message_key = (
_compression_anchor_message_key(visible_after[-1]) if visible_after else None
)
s.compression_anchor_summary = _compact_summary_text(
_compression_summary_from_messages(s.messages)
or _compression_summary_from_messages(s.context_messages)
)
put('compressed', {
'message': 'Context auto-compressed to continue the conversation',
})
@@ -3320,6 +3477,7 @@ def _run_agent_streaming(
'session_id': session_id,
'state': 'evaluating',
'message': 'Evaluating goal progress…',
'message_key': 'goal_evaluating_progress',
})
_goal_decision = evaluate_goal_after_turn(
session_id,
@@ -3334,6 +3492,8 @@ def _run_agent_streaming(
'session_id': session_id,
'state': 'continuing' if decision.get('should_continue') else 'idle',
'message': _goal_message,
'message_key': decision.get('message_key') or ('goal_continuing' if _goal_message else ''),
'message_args': decision.get('message_args') or [],
'decision': decision,
})
if decision.get('should_continue'):
@@ -3347,6 +3507,8 @@ def _run_agent_streaming(
'continuation_prompt': continuation_prompt,
'text': continuation_prompt,
'message': _goal_message,
'message_key': decision.get('message_key') or 'goal_continuing',
'message_args': decision.get('message_args') or [],
'decision': decision,
})
except Exception as _goal_exc:
+2 -2
View File
@@ -39,7 +39,7 @@ _load_repo_dotenv_preserving_env() {
key="${key#export }"
key="${key//[[:space:]]/}"
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
if [[ -v ${key} ]]; then
if [[ -n "${!key+x}" ]]; then
value="${!key}"
preserved+=("${key}=${value}")
fi
@@ -215,7 +215,7 @@ start_cmd() {
: >> "${LOG_FILE}"
(
cd "${REPO_ROOT}"
exec "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" "${CTL_BOOTSTRAP_ARGS[@]}"
exec "${python_exe}" "${REPO_ROOT}/bootstrap.py" --no-browser --foreground --host "${CTL_HOST}" "${CTL_PORT}" ${CTL_BOOTSTRAP_ARGS[@]+"${CTL_BOOTSTRAP_ARGS[@]}"}
) >> "${LOG_FILE}" 2>&1 &
pid=$!
+12 -2
View File
@@ -639,7 +639,17 @@ async function cmdGoal(args){
model_provider:S.session.model_provider||null,
profile:S.activeProfile||S.session.profile||'default',
})});
const msg=String((r&&r.message)||'').trim();
const msg = (() => {
const raw = String((r && r.message) || '').trim();
const key = String((r && r.message_key) || '').trim();
const args = Array.isArray(r && r.message_args) ? r.message_args : [];
if (raw.includes('\n')) return raw;
if (key && typeof t === 'function') {
const translated = String(t(key, ...args));
if (translated && translated !== key) return translated;
}
return raw;
})();
if(msg){
S.messages.push({role:'assistant',content:msg,_ts:Date.now()/1000,_goalStatus:true,_transient:true});
renderMessages({preserveScroll:true});
@@ -649,7 +659,7 @@ async function cmdGoal(args){
S.toolCalls=[];
if(typeof clearLiveToolCards==='function')clearLiveToolCards();
appendThinking();setBusy(true);
setComposerStatus('Working toward goal…');
setComposerStatus(t('goal_working_toward'));
S.activeStreamId=r.stream_id;
if(S.session&&S.session.session_id===activeSid){
S.session.active_stream_id=r.stream_id;
+586 -451
View File
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -896,17 +896,30 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}catch(_){}
});
function _resolveGoalMessage(d){
const key=String(d && d.message_key ? d.message_key : '').trim();
const args=Array.isArray(d && d.message_args) ? d.message_args : [];
const raw=String(d&&d.message||'').trim();
if(key && typeof t==='function'){
try{
const translated=String(t(key,...args));
if(translated && translated!==key)return translated;
}catch(_){}
}
return raw;
}
source.addEventListener('goal',e=>{
try{
const d=JSON.parse(e.data||'{}');
if((d.session_id||activeSid)!==activeSid) return;
const goalState=String(d.state||'').trim();
const goalEvaluatingMessage='Evaluating goal progress';
const goalEvaluatingMessage=t('goal_evaluating_progress');
if(goalState==='evaluating'){
setComposerStatus(goalEvaluatingMessage);
return;
}
const msg=String(d.message||'').trim();
const msg=_resolveGoalMessage(d);
if(!msg)return;
_latestGoalStatus={message:msg,decision:d.decision||null,state:goalState||null};
setComposerStatus(msg);
@@ -927,7 +940,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
model_provider:S.session&&S.session.model_provider||null,
profile:S.activeProfile||'default',
};
showToast('Continuing toward goal…',2200);
const toast=t('goal_continuing_toast');
const cmsg=_resolveGoalMessage(d);
showToast((toast&&cmsg&&cmsg!==toast)?cmsg.split('\n')[0]:toast,2200);
}catch(_){}
});
+9 -1
View File
@@ -1978,6 +1978,7 @@ function _isChildSession(s){
function _sessionLineageKey(s, sessionIdsInList){
if(!s||!s.session_id) return null;
if(_isChildSession(s)) return null;
if(s.session_source==='fork') return null;
const lineageKey=s._lineage_root_id||s.lineage_root_id||null;
if(lineageKey) return lineageKey;
// If parent_session_id points to another session in the current list,
@@ -2102,7 +2103,14 @@ function _collapseSessionLineageForSidebar(sessions){
}
for(const [key,items] of groups.entries()){
if(items.length<=1){result.push(items[0]);continue;}
const sorted=[...items].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
const sorted=[...items].sort((a,b)=>{
const bSeg=Number(b&&b._compression_segment_count||0);
const aSeg=Number(a&&a._compression_segment_count||0);
if(bSeg||aSeg){
if(bSeg!==aSeg) return bSeg-aSeg;
}
return _sessionTimestampMs(b)-_sessionTimestampMs(a);
});
const chosen=sorted[0];
result.push({...chosen,_lineage_key:key,_lineage_collapsed_count:items.length,_lineage_segments:sorted});
}
+7 -2
View File
@@ -4866,6 +4866,9 @@ function renderMessages(options){
const sessionCompressionAnchorKey=(
S.session && S.session.compression_anchor_message_key && typeof S.session.compression_anchor_message_key==='object'
) ? S.session.compression_anchor_message_key : null;
const sessionCompressionSummary=(
S.session && typeof S.session.compression_anchor_summary==='string'
) ? S.session.compression_anchor_summary.trim() : '';
const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages);
const vis=S.messages.filter(m=>{
if(!m||!m.role||m.role==='tool')return false;
@@ -4882,8 +4885,10 @@ function renderMessages(options){
inner.innerHTML='';
const compressionNode=compressionState?_compressionCardsNode(compressionState):null;
const referenceMessage=S.messages.find(m=>_isContextCompactionMessage(m));
const referenceText=referenceMessage?msgContent(referenceMessage)||String(referenceMessage.content||''):'';
const referenceNode=(!compressionState && referenceMessage && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey))
const referenceText=referenceMessage
? msgContent(referenceMessage)||String(referenceMessage.content||'')
: sessionCompressionSummary;
const referenceNode=(!compressionState && !!referenceText && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary))
? (()=>{const row=document.createElement('div');row.innerHTML=`<div class="compression-turn"><div class="compression-turn-blocks">${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}</div></div>`;return row.firstElementChild;})()
: null;
let preservedCompressionTaskCardsAttached=!!referenceNode;
+26
View File
@@ -68,6 +68,32 @@ def test_branch_creates_session_with_parent():
"Branch handler should set parent_session_id to source session"
def test_branch_marks_explicit_forks_as_fork_sessions():
"""Explicit branches must not be mistaken for compression lineage rows."""
with open('api/routes.py') as f:
src = f.read()
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
src, re.DOTALL
)
assert branch_match
block = branch_match.group(1)
assert 'session_source="fork"' in block, \
"Branch handler should mark explicit forks with session_source='fork'"
def test_branch_fork_sessions_do_not_collapse_into_parent_lineage():
"""Forks remain selectable rows even if their parent is not in the current list."""
with open('static/sessions.js') as f:
src = f.read()
fn = re.search(r'function _sessionLineageKey\(.*?\n\}', src, re.DOTALL)
assert fn, "Could not find _sessionLineageKey"
block = fn.group(0)
assert "if(s.session_source==='fork') return null;" in block, \
"Explicit fork sessions should not collapse via parent_session_id"
assert block.index("if(s.session_source==='fork') return null;") < block.index('return s.parent_session_id || null')
def test_branch_keep_count_support():
"""Verify the branch endpoint supports keep_count parameter."""
with open('api/routes.py') as f:
+10
View File
@@ -207,6 +207,16 @@ def test_preserved_task_list_renders_through_compression_card_path():
assert "_contextCompactionMessageHtml(m, tsTitle, preservedForThisCard)" in src
def test_context_anchor_reference_uses_session_summary_fallback():
src = _read("static/ui.js")
assert "sessionCompressionSummary" in src
assert "const sessionCompressionSummary" in src
assert "referenceText=referenceMessage" in src
assert ": sessionCompressionSummary" in src
assert "!!referenceText && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)" in src
def test_preserved_task_list_attaches_once_per_render():
src = _read("static/ui.js")
+16 -11
View File
@@ -79,18 +79,23 @@ def test_chinese_locale_block_exists():
def test_chinese_locale_includes_representative_translations():
src = read(REPO / "static" / "i18n.js")
expected = [
"settings_title: '\\u8bbe\\u7f6e'",
"login_title: '\\u767b\\u5f55'",
"approval_heading: '需要审批'",
"tab_tasks: '任务'",
"tab_profiles: '配置'",
"session_time_bucket_today: '今天'",
"onboarding_title: '欢迎使用 Hermes Web UI'",
"onboarding_complete: '引导完成'",
# Each tuple is a list of acceptable source forms for the same translation —
# either escape-encoded `\uXXXX` form or literal CJK characters. They produce
# the same runtime string; do not pin source encoding.
expected_alternatives = [
[r"settings_title: '\u8bbe\u7f6e'", "settings_title: '设置'"],
[r"login_title: '\u767b\u5f55'", "login_title: '登录'"],
["approval_heading: '需要审批'"],
["tab_tasks: '任务'"],
["tab_profiles: '配置'"],
["session_time_bucket_today: '今天'"],
["onboarding_title: '欢迎使用 Hermes Web UI'"],
["onboarding_complete: '引导完成'"],
]
for entry in expected:
assert entry in src
for alts in expected_alternatives:
assert any(alt in src for alt in alts), (
f"None of the expected forms found in i18n.js: {alts!r}"
)
def test_chinese_locale_covers_english_keys():
+11 -1
View File
@@ -68,10 +68,18 @@ def test_goal_command_payload_matches_gateway_controls(monkeypatch):
set_goal = webui_goals.goal_command_payload("sid-123", "ship the feature")
assert status["message"] == "No active goal. Set one with /goal <text>."
assert status["message_key"] == "goal_status_none"
assert pause["message"] == "⏸ Goal paused: ship the feature"
assert pause["message_key"] == "goal_paused"
assert pause["message_args"] == ["ship the feature"]
assert resume["message"].startswith("▶ Goal resumed: ship the feature")
assert resume["message_key"] == "goal_resumed"
assert resume["message_args"] == ["ship the feature"]
assert clear["message"] == "Goal cleared."
assert clear["message_key"] == "goal_cleared"
assert set_goal["action"] == "set"
assert set_goal["message_key"] == "goal_set"
assert set_goal["message_args"] == [20, "ship the feature"]
assert set_goal["kickoff_prompt"] == "ship the feature"
assert "⊙ Goal set (20-turn budget): ship the feature" in set_goal["message"]
assert ("set", "ship the feature") in calls
@@ -145,6 +153,8 @@ def test_goal_continuation_decision_emits_status_and_normal_user_prompt(monkeypa
decision = webui_goals.evaluate_goal_after_turn("sid-123", "not done yet", user_initiated=False)
assert decision["message_key"] == "goal_continuing"
assert decision["message_args"] == [1, 20, "one step remains"]
assert decision["message"].startswith("↻ Continuing toward goal")
assert decision["should_continue"] is True
assert decision["continuation_prompt"].startswith("[Continuing toward your standing goal]")
@@ -266,7 +276,7 @@ def test_frontend_has_goal_slash_command_and_status_event_handler():
def test_frontend_goal_evaluating_state_uses_calm_composer_indicator():
assert "const goalState=String(d.state||'').trim();" in MESSAGES_JS
assert "const goalEvaluatingMessage='Evaluating goal progress…';" in MESSAGES_JS
assert "t('goal_evaluating_progress')" in MESSAGES_JS
assert "if(goalState==='evaluating')" in MESSAGES_JS
assert "setComposerStatus(goalEvaluatingMessage);" in MESSAGES_JS
assert "return;" in MESSAGES_JS
+41
View File
@@ -170,6 +170,47 @@ console.log(JSON.stringify(collapsed));
assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["seg10", "seg9", "seg8", "seg7"]
def test_sidebar_lineage_collapse_prefers_highest_compression_segment_over_touched_parent():
"""A touched parent segment must not hide the newer compressed tip.
Opening or polling an older segment can refresh its updated_at without adding
messages. The collapsed sidebar row must still pick the highest compression
segment, otherwise the visible chat jumps back to a parent that lacks the
completed assistant answer.
"""
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
function extractFunc(name) {{
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
const sessions = [
{{session_id:'seg13', title:'Schaue dir die Release (fork)', message_count:2490, updated_at:200, last_message_at:200, _lineage_root_id:'root', _compression_segment_count:13}},
{{session_id:'seg14', title:'Schaue dir die Release (fork)', message_count:2532, updated_at:150, last_message_at:150, _lineage_root_id:'root', _compression_segment_count:14}},
];
const collapsed = _collapseSessionLineageForSidebar(sessions);
console.log(JSON.stringify(collapsed));
"""
collapsed = json.loads(_run_node(source))
assert [row["session_id"] for row in collapsed] == ["seg14"]
assert collapsed[0]["_lineage_collapsed_count"] == 2
assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["seg14", "seg13"]
def test_sidebar_attaches_child_sessions_to_collapsed_hidden_parent_lineage():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
@@ -0,0 +1,61 @@
"""Regression coverage for stitched full-transcript loading across session segments."""
from __future__ import annotations
import api.routes as routes
def test_session_endpoint_merges_sidecar_and_lineage_messages_for_cli_sessions(monkeypatch):
class DummySession:
def __init__(self):
self.messages = [{"role": "assistant", "content": "sidecar tail", "timestamp": 10.0}]
self.tool_calls = []
self.active_stream_id = None
self.pending_user_message = None
self.pending_attachments = []
self.pending_started_at = None
self.context_length = 0
self.threshold_tokens = 0
self.last_prompt_tokens = 0
self.model = "openai/gpt-5"
self.session_id = "tip"
def compact(self):
return {"session_id": "tip", "title": "Tip", "model": "openai/gpt-5"}
captured = {}
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: DummySession())
monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda s: None)
monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda sid: {"session_source": "messaging"})
monkeypatch.setattr(routes, "_is_messaging_session_record", lambda s: True)
monkeypatch.setattr(
routes,
"get_cli_session_messages",
lambda sid: [
{"role": "user", "content": "root user", "timestamp": 1.0},
{"role": "assistant", "content": "tip assistant", "timestamp": 2.0},
],
)
monkeypatch.setattr(routes, "_resolve_effective_session_model_for_display", lambda s: getattr(s, "model", None))
monkeypatch.setattr(routes, "_resolve_effective_session_model_provider_for_display", lambda s: None)
monkeypatch.setattr(routes, "_merge_cli_sidebar_metadata", lambda raw, meta: raw)
monkeypatch.setattr(routes, "redact_session_data", lambda raw: raw)
monkeypatch.setattr(routes, "j", lambda handler, payload, status=200: captured.setdefault("payload", payload))
class Handler:
pass
class Parsed:
path = "/api/session"
query = "session_id=tip"
routes.handle_get(Handler(), Parsed())
session = captured["payload"]["session"]
assert [m["content"] for m in session["messages"]] == [
"root user",
"tip assistant",
"sidecar tail",
]
+196
View File
@@ -0,0 +1,196 @@
"""Read-only session lineage report endpoint tests."""
import json
import sqlite3
import time
from types import SimpleNamespace
from urllib.parse import urlparse
from unittest.mock import patch
import api.agent_sessions as agent_sessions
import api.routes as routes
def _ensure_state_db(path):
conn = sqlite3.connect(str(path))
conn.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
title TEXT,
model TEXT,
started_at REAL NOT NULL,
message_count INTEGER DEFAULT 0,
parent_session_id TEXT,
ended_at REAL,
end_reason TEXT
);
"""
)
return conn
def _insert_state_row(conn, sid, *, parent=None, ended_at=None, end_reason=None, started_at=None, source="webui"):
conn.execute(
"""
INSERT INTO sessions
(id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES (?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?)
""",
(sid, source, sid.replace("_", " "), started_at or time.time(), parent, ended_at, end_reason),
)
conn.commit()
def test_lineage_report_returns_bounded_read_only_tip_and_hidden_segments(tmp_path):
conn = _ensure_state_db(tmp_path / "state.db")
t0 = time.time() - 100
try:
_insert_state_row(conn, "lineage_report_root", started_at=t0, ended_at=t0 + 5, end_reason="compression")
_insert_state_row(conn, "lineage_report_mid", parent="lineage_report_root", started_at=t0 + 6, ended_at=t0 + 12, end_reason="cli_close")
_insert_state_row(conn, "lineage_report_tip", parent="lineage_report_mid", started_at=t0 + 13)
report = agent_sessions.read_session_lineage_report(tmp_path / "state.db", "lineage_report_tip")
assert report["mutation"] is False
assert report["session_id"] == "lineage_report_tip"
assert report["lineage_key"] == "lineage_report_root"
assert report["tip_session_id"] == "lineage_report_tip"
assert report["total_segments"] == 3
assert report["materialized_segments"] == 3
assert [s["session_id"] for s in report["segments"]] == [
"lineage_report_tip",
"lineage_report_mid",
"lineage_report_root",
]
assert [s["role"] for s in report["segments"]] == ["tip", "hidden_segment", "hidden_segment"]
assert report["children"] == []
assert report["manual_review"] is False
assert "archive_candidates" not in report
assert "delete_candidates" not in report
finally:
conn.close()
def test_lineage_report_keeps_cross_surface_parent_out_of_hidden_segments(tmp_path):
conn = _ensure_state_db(tmp_path / "state.db")
t0 = time.time() - 100
try:
_insert_state_row(
conn,
"lineage_report_telegram_parent",
source="telegram",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_report_webui_tip",
source="webui",
parent="lineage_report_telegram_parent",
started_at=t0 + 6,
)
report = agent_sessions.read_session_lineage_report(tmp_path / "state.db", "lineage_report_webui_tip")
assert report["lineage_key"] == "lineage_report_webui_tip"
assert report["total_segments"] == 1
assert [s["session_id"] for s in report["segments"]] == ["lineage_report_webui_tip"]
assert report["segments"][0]["role"] == "tip"
assert report["children"] == []
finally:
conn.close()
def test_lineage_report_surfaces_non_continuation_children_without_mutation(tmp_path):
conn = _ensure_state_db(tmp_path / "state.db")
t0 = time.time() - 100
try:
_insert_state_row(conn, "lineage_report_root", started_at=t0, ended_at=t0 + 5, end_reason="compression")
_insert_state_row(conn, "lineage_report_tip", parent="lineage_report_root", started_at=t0 + 6, ended_at=t0 + 15, end_reason="user_stop")
_insert_state_row(conn, "lineage_report_child", parent="lineage_report_tip", started_at=t0 + 8)
report = agent_sessions.read_session_lineage_report(tmp_path / "state.db", "lineage_report_tip")
assert report["lineage_key"] == "lineage_report_root"
assert [s["session_id"] for s in report["segments"]] == ["lineage_report_tip", "lineage_report_root"]
assert report["children"] == [
{
"session_id": "lineage_report_child",
"role": "child_session",
"title": "lineage report child",
"source": "webui",
"started_at": t0 + 8,
"updated_at": t0 + 8,
"end_reason": None,
"active": True,
"archived": False,
}
]
assert report["mutation"] is False
finally:
conn.close()
def test_lineage_report_marks_bounded_parent_walk_for_manual_review(tmp_path):
conn = _ensure_state_db(tmp_path / "state.db")
t0 = time.time() - 100
try:
_insert_state_row(conn, "lineage_report_root", started_at=t0, ended_at=t0 + 5, end_reason="compression")
_insert_state_row(conn, "lineage_report_mid", parent="lineage_report_root", started_at=t0 + 6, ended_at=t0 + 12, end_reason="compression")
_insert_state_row(conn, "lineage_report_tip", parent="lineage_report_mid", started_at=t0 + 13)
report = agent_sessions.read_session_lineage_report(tmp_path / "state.db", "lineage_report_tip", max_hops=1)
assert report["mutation"] is False
assert report["manual_review"] is True
assert [s["session_id"] for s in report["segments"]] == ["lineage_report_tip", "lineage_report_mid"]
assert report["total_segments"] == 2
finally:
conn.close()
def test_lineage_report_endpoint_is_read_only_and_uses_active_state_db(tmp_path):
conn = _ensure_state_db(tmp_path / "state.db")
t0 = time.time() - 100
try:
_insert_state_row(conn, "lineage_report_root", started_at=t0, ended_at=t0 + 5, end_reason="compression")
_insert_state_row(conn, "lineage_report_tip", parent="lineage_report_root", started_at=t0 + 6)
captured = {}
def fake_j(handler, data, status=200, **_kwargs):
captured["status"] = status
captured["data"] = data
return data
handler = SimpleNamespace()
parsed = urlparse("/api/session/lineage/report?session_id=lineage_report_tip")
with patch.object(routes, "_active_state_db_path", return_value=tmp_path / "state.db"), patch.object(routes, "j", side_effect=fake_j):
routes.handle_get(handler, parsed)
assert captured["status"] == 200
assert captured["data"]["mutation"] is False
assert captured["data"]["lineage_key"] == "lineage_report_root"
assert captured["data"]["total_segments"] == 2
finally:
conn.close()
def test_lineage_report_endpoint_returns_404_for_unknown_session(tmp_path):
conn = _ensure_state_db(tmp_path / "state.db")
conn.close()
captured = {}
def fake_bad(handler, message, status=400):
captured["status"] = status
captured["message"] = message
return {"error": message}
handler = SimpleNamespace()
parsed = urlparse("/api/session/lineage/report?session_id=missing_lineage_report_session")
with patch.object(routes, "_active_state_db_path", return_value=tmp_path / "state.db"), patch.object(routes, "bad", side_effect=fake_bad):
routes.handle_get(handler, parsed)
assert captured == {"status": 404, "message": "Session not found"}
+128
View File
@@ -9,6 +9,7 @@ Covers:
- streaming.py: SessionDB init is placed before AIAgent construction
"""
import ast
import threading
import pathlib
import re
import queue
@@ -402,6 +403,133 @@ class TestRuntimeRouteInjection(unittest.TestCase):
"interim_assistant event should carry the assistant commentary text"
)
def test_clarify_callback_passes_configured_timeout_seconds(self):
"""clarify prompt data should use clarify.timeout from config when present."""
import api.streaming as streaming
captured = {}
submit_payloads = []
class FakeEntry:
def __init__(self, value):
self.result = value
self.event = threading.Event()
self.event.set()
def fake_submit_pending(_sid, payload):
submit_payloads.append(payload)
return FakeEntry("selected")
class CapturingAgent:
def __init__(self, model=None, provider=None, base_url=None, api_key=None,
platform=None, quiet_mode=False, enabled_toolsets=None,
fallback_model=None, session_id=None, session_db=None,
stream_delta_callback=None, reasoning_callback=None,
tool_progress_callback=None, clarify_callback=None, **kwargs):
self.clarify_callback = clarify_callback
self.session_id = session_id
captured["init_kwargs"] = {
"clarify_callback": clarify_callback,
}
def run_conversation(self, **kwargs):
if self.clarify_callback:
captured["clarify_result"] = self.clarify_callback(
"Need user confirmation",
["first", "second"],
)
return {
"messages": [
{"role": "user", "content": kwargs.get("persist_user_message", "")},
{"role": "assistant", "content": "ok"},
]
}
def interrupt(self, _message):
captured["interrupted"] = True
class FakeSession:
session_id = "sess-clarify-timeout"
title = "clarify-timeout test"
workspace = "/tmp"
model = "gpt-5.4"
messages = []
personality = None
input_tokens = 0
output_tokens = 0
estimated_cost = None
tool_calls = []
active_stream_id = None
pending_user_message = None
pending_attachments = []
pending_started_at = None
def save(self, touch_updated_at=True, **_kwargs):
pass
def compact(self):
return {
"session_id": self.session_id,
"title": self.title,
"workspace": self.workspace,
"model": self.model,
"created_at": 0,
"updated_at": 0,
"pinned": False,
"archived": False,
"project_id": None,
"profile": None,
"input_tokens": 0,
"output_tokens": 0,
"estimated_cost": None,
"personality": None,
}
@property
def path(self):
return "/tmp/fake.json"
fake_stream_id = "stream-clarify-timeout"
fake_queue = queue.Queue()
fake_rt_module = types.ModuleType("hermes_cli.runtime_provider")
fake_rt_module.resolve_runtime_provider = mock.Mock(return_value={
"provider": "openai-codex",
"base_url": "https://api.openai.com/v1",
"api_key": "rt-key",
"api_mode": "codex_responses",
"command": "codex",
"args": ["exec", "--json"],
"credential_pool": object(),
})
fake_hermes_cli = types.ModuleType("hermes_cli")
fake_hermes_cli.runtime_provider = fake_rt_module
fake_hermes_state = types.ModuleType("hermes_state")
fake_hermes_state.SessionDB = mock.Mock(return_value=object())
with mock.patch.object(streaming, "get_session", return_value=FakeSession()), \
mock.patch.object(streaming, "_get_ai_agent", return_value=CapturingAgent), \
mock.patch.object(streaming, "resolve_model_provider", return_value=("gpt-5.4", "openai-codex", None)), \
mock.patch.object(streaming, "get_config", return_value={"clarify": {"timeout": 300}}), \
mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \
mock.patch("api.clarify.submit_pending", side_effect=fake_submit_pending), \
mock.patch.dict(sys.modules, {
"hermes_cli": fake_hermes_cli,
"hermes_cli.runtime_provider": fake_rt_module,
"hermes_state": fake_hermes_state,
}):
streaming.STREAMS[fake_stream_id] = fake_queue
streaming._run_agent_streaming(
session_id="sess-clarify-timeout",
msg_text="please run task",
model="gpt-5.4",
workspace="/tmp",
stream_id=fake_stream_id,
)
self.assertEqual(captured["clarify_result"], "selected")
self.assertEqual(len(submit_payloads), 1)
self.assertEqual(submit_payloads[0]["timeout_seconds"], 300)
class TestSessionDBAST(unittest.TestCase):
"""AST-level checks: verify the try/except is not inside _ENV_LOCK (deadlock guard)."""
+9 -1
View File
@@ -10,7 +10,7 @@ import types
from api.models import Session
from api.config import SESSION_DIR
from api.routes import _handle_session_compress
from api.routes import _handle_session_compress, get_session
from tests._pytest_port import BASE
@@ -141,6 +141,14 @@ def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions):
{"role": "user", "content": "one"},
{"role": "assistant", "content": "four"},
]
assert payload["session"]["compression_anchor_summary"] is not None
assert payload["session"]["compression_anchor_visible_idx"] == 1
assert isinstance(payload["session"]["compression_anchor_message_key"], dict)
assert payload["session"]["compression_anchor_message_key"].get("role") == "assistant"
loaded = get_session(sid)
assert loaded.compression_anchor_summary == payload["session"]["compression_anchor_summary"]
assert loaded.compression_anchor_visible_idx == payload["session"]["compression_anchor_visible_idx"]
assert loaded.compression_anchor_message_key == payload["session"]["compression_anchor_message_key"]
assert _FakeAgent.last_instance is not None
assert _FakeAgent.last_instance.context_compressor.calls[0]["focus_topic"] == "database schema"