mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 03:30:36 +00:00
Merge remote-tracking branch 'origin/master' into stage-332
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -50,3 +50,5 @@ docs/*
|
||||
graphify-out/
|
||||
.graphify_cached.json
|
||||
.graphify_uncached.txt
|
||||
|
||||
.venv/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+18
-3
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"}
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user