chore: remove expired v0.3.1 compatibility shims (#5106)

This commit is contained in:
chengyongru
2026-07-27 13:53:04 +08:00
committed by GitHub
parent 39348dfafe
commit 281b4b7f0b
16 changed files with 87 additions and 249 deletions
+1 -6
View File
@@ -186,9 +186,7 @@ Dream is configured under `agents.defaults.dream`:
"defaults": { "defaults": {
"dream": { "dream": {
"intervalH": 2, "intervalH": 2,
"modelOverride": null, "modelOverride": null
"maxBatchSize": 20,
"maxIterations": 10
} }
} }
} }
@@ -200,15 +198,12 @@ Dream is configured under `agents.defaults.dream`:
| `intervalH` | How often Dream runs, in hours | | `intervalH` | How often Dream runs, in hours |
| `cron` | Cron expression override (takes precedence over `intervalH`) | | `cron` | Cron expression override (takes precedence over `intervalH`) |
| `modelOverride` | Optional Dream-specific model override *(pending implementation)* | | `modelOverride` | Optional Dream-specific model override *(pending implementation)* |
| `maxBatchSize` | *(Deprecated — not used)* |
| `maxIterations` | *(Deprecated — not used)* |
In practical terms: In practical terms:
- `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule. - `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule.
- `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`). - `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`).
- `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent. - `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent.
- `maxBatchSize` and `maxIterations` are preserved for config compatibility but no longer affect behavior.
## In Practice ## In Practice
-19
View File
@@ -3,8 +3,6 @@
from __future__ import annotations from __future__ import annotations
import pkgutil import pkgutil
from functools import cache
from importlib.metadata import entry_points
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from loguru import logger from loguru import logger
@@ -19,22 +17,6 @@ if TYPE_CHECKING:
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
@cache
def _warn_legacy_channel_entry_points() -> None:
# TODO(v0.3.1): Remove this detection and warning. v0.3.0 is the final
# migration window for installed legacy channel entry points.
names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")})
if not names:
return
logger.warning(
"Legacy channel entry points were detected but will not be loaded: {}. "
"The '{}' entry-point group is no longer supported; use a built-in channel or "
"migrate it into nanobot/channels/<channel>/.",
", ".join(names),
"nanobot.channels",
)
def _channel_package_names() -> list[str]: def _channel_package_names() -> list[str]:
import nanobot.channels as package import nanobot.channels as package
@@ -49,7 +31,6 @@ def discover_plugins(
enabled_names: set[str] | None = None, enabled_names: set[str] | None = None,
) -> dict[str, ChannelPlugin]: ) -> dict[str, ChannelPlugin]:
"""Load dependency-free descriptors from self-contained channel packages.""" """Load dependency-free descriptors from self-contained channel packages."""
_warn_legacy_channel_entry_points()
plugins: dict[str, ChannelPlugin] = {} plugins: dict[str, ChannelPlugin] = {}
for name in _channel_package_names(): for name in _channel_package_names():
if enabled_names is not None and name not in enabled_names: if enabled_names is not None and name not in enabled_names:
-20
View File
@@ -815,7 +815,6 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
except ValueError as e: except ValueError as e:
console.print(f"[red]Error: {e}[/red]") console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1) raise typer.Exit(1)
_warn_deprecated_config_keys(config_path)
if workspace: if workspace:
loaded.agents.defaults.workspace = workspace loaded.agents.defaults.workspace = workspace
return loaded return loaded
@@ -836,24 +835,6 @@ def _read_trigger_cli_message(message: str | None) -> str:
raise typer.Exit(1) raise typer.Exit(1)
def _warn_deprecated_config_keys(config_path: Path | None) -> None:
"""Hint users to remove obsolete keys from their config file."""
import json
from nanobot.config.loader import get_config_path
path = config_path or get_config_path()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return
if "memoryWindow" in raw.get("agents", {}).get("defaults", {}):
console.print(
"[dim]Hint: `memoryWindow` in your config is no longer used "
"and can be safely removed.[/dim]"
)
def _load_inspection_config( def _load_inspection_config(
config: str | None = None, config: str | None = None,
workspace: str | None = None, workspace: str | None = None,
@@ -873,7 +854,6 @@ def _load_inspection_config(
except ValueError as exc: except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]") console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc raise typer.Exit(1) from exc
_warn_deprecated_config_keys(display_path)
if workspace: if workspace:
loaded.agents.defaults.workspace = workspace loaded.agents.defaults.workspace = workspace
return display_path, loaded return display_path, loaded
-18
View File
@@ -7,7 +7,6 @@ from pathlib import Path
from typing import Any from typing import Any
import pydantic import pydantic
from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from nanobot.config.schema import Config, _resolve_tool_config_refs from nanobot.config.schema import Config, _resolve_tool_config_refs
@@ -200,23 +199,6 @@ def _env_replace(match: re.Match[str]) -> str:
def _migrate_config(data: dict) -> dict: def _migrate_config(data: dict) -> dict:
"""Migrate old config formats to current.""" """Migrate old config formats to current."""
agents = data.get("agents", {})
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
if isinstance(defaults, dict):
had_legacy_max_messages = (
"maxMessages" in defaults or "max_messages" in defaults
)
defaults.pop("maxMessages", None)
defaults.pop("max_messages", None)
if had_legacy_max_messages:
# TODO(v0.3.1): Remove this legacy cleanup branch. v0.3.0 is the
# final release that warns before the schema silently ignores the field.
logger.warning(
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
"replay max messages is now an internal safety cap. Remove it from "
"config. This compatibility warning will be removed in the next version."
)
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace # Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
tools = data.get("tools", {}) tools = data.get("tools", {})
exec_cfg = tools.get("exec", {}) exec_cfg = tools.get("exec", {})
-3
View File
@@ -64,9 +64,6 @@ class DreamConfig(Base):
default=None, default=None,
validation_alias=AliasChoices("modelOverride", "model", "model_override"), validation_alias=AliasChoices("modelOverride", "model", "model_override"),
) # Override model for Dream sessions (pending implementation) ) # Override model for Dream sessions (pending implementation)
max_batch_size: int = Field(default=20, ge=1) # Deprecated: no longer used
max_iterations: int = Field(default=15, ge=1) # Deprecated: no longer used
annotate_line_ages: bool = True # Deprecated: no longer used
def build_schedule(self, timezone: str) -> CronSchedule: def build_schedule(self, timezone: str) -> CronSchedule:
"""Build the runtime schedule, preferring the legacy cron override if present.""" """Build the runtime schedule, preferring the legacy cron override if present."""
+19 -66
View File
@@ -5,7 +5,6 @@ import errno
import json import json
import os import os
import re import re
import shutil
from collections import OrderedDict from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from copy import deepcopy from copy import deepcopy
@@ -477,6 +476,14 @@ class SessionManager:
except _SESSION_DATA_ERRORS: except _SESSION_DATA_ERRORS:
return None return None
@classmethod
def _session_key_from_path(cls, path: Path) -> str | None:
"""Decode a session key only from a canonical collision-resistant filename."""
key = cls._decode_storage_key(path.stem)
if key is None or cls._storage_key(key) != path.stem:
return None
return key
def _get_session_path(self, key: str) -> Path: def _get_session_path(self, key: str) -> Path:
"""Get the collision-resistant workspace path for a session.""" """Get the collision-resistant workspace path for a session."""
return self.sessions_dir / f"{self._storage_key(key)}.jsonl" return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
@@ -489,61 +496,6 @@ class SessionManager:
"""Legacy global session path (~/.nanobot/sessions/).""" """Legacy global session path (~/.nanobot/sessions/)."""
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl" return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
@staticmethod
def _stored_key_for_path(path: Path) -> str | None:
"""Read the stored session key from a JSONL metadata row, if present."""
try:
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
data = json.loads(line)
if not isinstance(data, dict):
raise ValueError("session records must be JSON objects")
if data.get("_type") == "metadata":
stored_key = data.get("key")
return stored_key if isinstance(stored_key, str) else None
return None
except _SESSION_DATA_ERRORS:
return None
return None
def _resolve_session_path(self, key: str, *, migrate: bool = False) -> Path | None:
"""Resolve a session path, falling back to legacy storage locations."""
path = self._get_session_path(key)
if path.exists():
return path
# TODO(v0.3.1): Remove both legacy fallbacks. v0.3.0 is the final
# compatibility window for reading and lazily migrating legacy session files.
fallback_paths = [
(self._get_legacy_lossy_path(key), "legacy lossy path"),
(self._get_legacy_session_path(key), "legacy path"),
]
for fallback_path, description in fallback_paths:
if not fallback_path.exists():
continue
stored_key = self._stored_key_for_path(fallback_path)
if stored_key and stored_key != key:
logger.info(
"Skipping session {} from {} because it belongs to {}",
key,
description,
stored_key,
)
continue
if not migrate:
return fallback_path
try:
shutil.move(str(fallback_path), str(path))
logger.info("Migrated session {} from {}", key, description)
except Exception:
logger.exception("Failed to migrate session {}", key)
return None
return path
return None
def get_or_create(self, key: str) -> Session: def get_or_create(self, key: str) -> Session:
""" """
Get an existing session or create a new one. Get an existing session or create a new one.
@@ -567,8 +519,8 @@ class SessionManager:
def _load(self, key: str) -> Session | None: def _load(self, key: str) -> Session | None:
"""Load a session from disk.""" """Load a session from disk."""
path = self._resolve_session_path(key, migrate=True) path = self._get_session_path(key)
if path is None: if not path.exists():
return None return None
try: try:
@@ -847,8 +799,8 @@ class SessionManager:
Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or
``None`` when the session file does not exist or fails to parse. ``None`` when the session file does not exist or fails to parse.
""" """
path = self._resolve_session_path(key) path = self._get_session_path(key)
if path is None: if not path.exists():
return None return None
try: try:
messages: list[dict[str, Any]] = [] messages: list[dict[str, Any]] = []
@@ -890,8 +842,8 @@ class SessionManager:
This is used by WebUI routes that need session-level metadata but not the This is used by WebUI routes that need session-level metadata but not the
full conversation transcript. full conversation transcript.
""" """
path = self._resolve_session_path(key) path = self._get_session_path(key)
if path is None: if not path.exists():
return None return None
try: try:
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
@@ -935,8 +887,9 @@ class SessionManager:
sessions = [] sessions = []
for path in self.sessions_dir.glob("*.jsonl"): for path in self.sessions_dir.glob("*.jsonl"):
decoded = self._decode_storage_key(path.stem) storage_key = self._session_key_from_path(path)
fallback_key = decoded or path.stem.replace("_", ":", 1) if storage_key is None:
continue
try: try:
# Read the metadata line and a small preview for session lists. # Read the metadata line and a small preview for session lists.
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
@@ -946,7 +899,7 @@ class SessionManager:
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("session records must be JSON objects") raise ValueError("session records must be JSON objects")
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
key = data.get("key") or fallback_key key = data.get("key") or storage_key
metadata = data.get("metadata", {}) metadata = data.get("metadata", {})
title = _metadata_title(metadata) title = _metadata_title(metadata)
preview = "" preview = ""
@@ -991,7 +944,7 @@ class SessionManager:
except FileNotFoundError: except FileNotFoundError:
continue continue
except _SESSION_DATA_ERRORS: except _SESSION_DATA_ERRORS:
repaired = self._repair(fallback_key, path=path) repaired = self._repair(storage_key, path=path)
if repaired is not None: if repaired is not None:
sessions.append( sessions.append(
{ {
+10 -5
View File
@@ -54,7 +54,11 @@ def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, An
for row in existing_rows or [] for row in existing_rows or []
if isinstance(row.get("file"), str) if isinstance(row.get("file"), str)
} }
paths = sorted(session_manager.sessions_dir.glob("*.jsonl")) paths = sorted(
path
for path in session_manager.sessions_dir.glob("*.jsonl")
if SessionManager._session_key_from_path(path) is not None
)
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
changed = existing_rows is None changed = existing_rows is None
@@ -268,8 +272,9 @@ def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None: def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
storage_key = SessionManager._decode_storage_key(path.stem) storage_key = SessionManager._session_key_from_path(path)
fallback_key = storage_key or path.stem.replace("_", ":", 1) if storage_key is None:
return None
try: try:
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
first_line = f.readline().strip() first_line = f.readline().strip()
@@ -320,7 +325,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
fallback_time = datetime.fromtimestamp(signature["mtime_ns"] / 1e9).isoformat() fallback_time = datetime.fromtimestamp(signature["mtime_ns"] / 1e9).isoformat()
created_at_s = created_at_s or fallback_time created_at_s = created_at_s or fallback_time
updated_at_s = updated_at_s or fallback_time updated_at_s = updated_at_s or fallback_time
key = data.get("key") or fallback_key key = data.get("key") or storage_key
activity_signature = _webui_activity_signature(key) activity_signature = _webui_activity_signature(key)
activity_updated_at = _webui_activity_updated_at(activity_signature) activity_updated_at = _webui_activity_updated_at(activity_signature)
return { return {
@@ -340,7 +345,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
**activity_signature, **activity_signature,
} }
except Exception: except Exception:
repaired = session_manager._repair(fallback_key) repaired = session_manager._repair(storage_key)
if repaired is None: if repaired is None:
return None return None
return _indexed_row_for_session(repaired, path) return _indexed_row_for_session(repaired, path)
+11 -37
View File
@@ -65,7 +65,7 @@ def test_save_uses_new_path_not_lossy(tmp_path: Path, monkeypatch) -> None:
assert lossy_path.read_text(encoding="utf-8") == stale_lossy assert lossy_path.read_text(encoding="utf-8") == stale_lossy
def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None: def test_load_ignores_legacy_lossy_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch) sm = _manager(tmp_path, monkeypatch)
key = "telegram:legacy:lossy" key = "telegram:legacy:lossy"
lossy_path = sm._get_legacy_lossy_path(key) lossy_path = sm._get_legacy_lossy_path(key)
@@ -73,49 +73,23 @@ def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None:
session = sm._load(key) session = sm._load(key)
assert session is not None assert session is None
assert session.metadata == {"source": "test"} assert lossy_path.exists()
assert session.messages[0]["content"] == "loaded from lossy" assert not sm._get_session_path(key).exists()
def test_load_migrates_lossy_to_new_path(tmp_path: Path, monkeypatch) -> None: def test_load_ignores_legacy_global_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch) sm = _manager(tmp_path, monkeypatch)
key = "telegram:migrate:lossy" key = "telegram:legacy:global"
new_path = sm._get_session_path(key) new_path = sm._get_session_path(key)
lossy_path = sm._get_legacy_lossy_path(key) legacy_path = sm._get_legacy_session_path(key)
_write_session_file(lossy_path, key, "migrate me") _write_session_file(legacy_path, key, "loaded from global")
session = sm._load(key) session = sm._load(key)
assert session is not None assert session is None
assert session.messages[0]["content"] == "migrate me" assert legacy_path.exists()
assert new_path.exists() assert not new_path.exists()
assert not lossy_path.exists()
def test_load_does_not_migrate_lossy_path_for_different_stored_key(
tmp_path: Path,
monkeypatch,
) -> None:
sm = _manager(tmp_path, monkeypatch)
first_key = "telegram:a_b"
second_key = "telegram:a:b"
lossy_path = sm._get_legacy_lossy_path(first_key)
assert lossy_path == sm._get_legacy_lossy_path(second_key)
_write_session_file(lossy_path, first_key, "belongs to first")
loaded_second = sm._load(second_key)
assert loaded_second is None
assert lossy_path.exists()
assert not sm._get_session_path(second_key).exists()
loaded_first = sm._load(first_key)
assert loaded_first is not None
assert loaded_first.messages[0]["content"] == "belongs to first"
assert sm._get_session_path(first_key).exists()
assert not lossy_path.exists()
def test_safe_key_is_lossy() -> None: def test_safe_key_is_lossy() -> None:
-29
View File
@@ -788,35 +788,6 @@ def test_discover_plugins_skips_names_outside_enabled_set():
assert loaded == [] assert loaded == []
def test_discover_plugins_warns_once_for_legacy_entry_points():
from nanobot.channels.registry import _warn_legacy_channel_entry_points, discover_plugins
legacy_entry_points = [SimpleNamespace(name="z-old"), SimpleNamespace(name="a-old")]
_warn_legacy_channel_entry_points.cache_clear()
try:
with (
patch(
"nanobot.channels.registry.entry_points",
return_value=legacy_entry_points,
) as metadata_entry_points,
patch("nanobot.channels.registry._channel_package_names", return_value=[]),
patch("nanobot.channels.registry.logger.warning") as warning,
):
discover_plugins()
discover_plugins()
finally:
_warn_legacy_channel_entry_points.cache_clear()
metadata_entry_points.assert_called_once_with(group="nanobot.channels")
warning.assert_called_once_with(
"Legacy channel entry points were detected but will not be loaded: {}. "
"The '{}' entry-point group is no longer supported; use a built-in channel or "
"migrate it into nanobot/channels/<channel>/.",
"a-old, z-old",
"nanobot.channels",
)
def test_channel_manifest_rejects_invalid_dependency_metadata(): def test_channel_manifest_rejects_invalid_dependency_metadata():
with pytest.raises(TypeError, match="tuple of requirements"): with pytest.raises(TypeError, match="tuple of requirements"):
ChannelPlugin( ChannelPlugin(
-11
View File
@@ -1728,17 +1728,6 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
assert passed_config.workspace_path == workspace_path assert passed_config.workspace_path == workspace_path
def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"agents": {"defaults": {"memoryWindow": 42}}}))
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
assert result.exit_code == 0
assert "memoryWindow" in result.stdout
assert "no longer used" in result.stdout
def test_heartbeat_retains_recent_messages_by_default(): def test_heartbeat_retains_recent_messages_by_default():
config = Config() config = Config()
+3 -9
View File
@@ -96,22 +96,17 @@ def test_onboard_does_not_crash_with_legacy_memory_window(tmp_path, monkeypatch)
@pytest.mark.parametrize("field_name", ["maxMessages", "max_messages"]) @pytest.mark.parametrize("field_name", ["maxMessages", "max_messages"])
def test_load_config_warns_and_ignores_legacy_max_messages(tmp_path, field_name) -> None: def test_load_config_ignores_legacy_max_messages(tmp_path, field_name) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config_path.write_text( config_path.write_text(
json.dumps({"agents": {"defaults": {field_name: 25, "maxTokens": 1234}}}), json.dumps({"agents": {"defaults": {field_name: 25, "maxTokens": 1234}}}),
encoding="utf-8", encoding="utf-8",
) )
with patch("nanobot.config.loader.logger.warning") as warning: config = load_config(config_path)
config = load_config(config_path)
assert config.agents.defaults.max_tokens == 1234 assert config.agents.defaults.max_tokens == 1234
assert not hasattr(config.agents.defaults, "max_messages") assert not hasattr(config.agents.defaults, "max_messages")
warning.assert_called_once()
message = warning.call_args.args[0]
assert "legacy and ignored" in message
assert "next version" in message
def test_save_config_drops_legacy_max_messages(tmp_path) -> None: def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
@@ -121,8 +116,7 @@ def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
encoding="utf-8", encoding="utf-8",
) )
with patch("nanobot.config.loader.logger.warning"): config = load_config(config_path)
config = load_config(config_path)
save_config(config, config_path) save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8")) saved = json.loads(config_path.read_text(encoding="utf-8"))
+19
View File
@@ -52,3 +52,22 @@ def test_dream_config_uses_model_override_name_and_accepts_legacy_model() -> Non
assert cfg.model_override == "openrouter/sonnet" assert cfg.model_override == "openrouter/sonnet"
assert dumped["modelOverride"] == "openrouter/sonnet" assert dumped["modelOverride"] == "openrouter/sonnet"
assert "model" not in dumped assert "model" not in dumped
def test_dream_config_ignores_retired_noop_fields() -> None:
cfg = DreamConfig.model_validate(
{
"maxBatchSize": 99,
"maxIterations": 99,
"annotateLineAges": False,
}
)
dumped = cfg.model_dump(by_alias=True)
assert not hasattr(cfg, "max_batch_size")
assert not hasattr(cfg, "max_iterations")
assert not hasattr(cfg, "annotate_line_ages")
assert "maxBatchSize" not in dumped
assert "maxIterations" not in dumped
assert "annotateLineAges" not in dumped
@@ -1,4 +1,5 @@
"""Regression tests for legacy-stem session handling.""" """Tests for retired legacy session storage paths."""
import json import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -6,15 +7,11 @@ from pathlib import Path
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch) -> None: def test_list_sessions_ignores_legacy_stem(tmp_path: Path) -> None:
monkeypatch.setattr(
"nanobot.session.manager.get_legacy_sessions_dir",
lambda: tmp_path / "legacy_sessions",
)
manager = SessionManager(tmp_path / "workspace") manager = SessionManager(tmp_path / "workspace")
# Simulate a legacy lossy-path filename (telegram_12345.jsonl) with a corrupt # A legacy lossy-path filename must not be treated as current session storage,
# first line that triggers the repair branch in list_sessions. # even when the file contains otherwise recoverable records.
legacy_stem = "telegram_12345" legacy_stem = "telegram_12345"
corrupt_path = manager.sessions_dir / f"{legacy_stem}.jsonl" corrupt_path = manager.sessions_dir / f"{legacy_stem}.jsonl"
corrupt_path.parent.mkdir(parents=True, exist_ok=True) corrupt_path.parent.mkdir(parents=True, exist_ok=True)
@@ -24,7 +21,6 @@ def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch)
"created_at": datetime(2025, 1, 1).isoformat(), "created_at": datetime(2025, 1, 1).isoformat(),
"updated_at": datetime(2025, 1, 1).isoformat(), "updated_at": datetime(2025, 1, 1).isoformat(),
}) })
# Corrupt line followed by valid message
corrupt_path.write_text( corrupt_path.write_text(
metadata + "\n{INVALID JSON LINE\n" metadata + "\n{INVALID JSON LINE\n"
+ json.dumps({"role": "user", "content": "recoverable message"}) + "\n", + json.dumps({"role": "user", "content": "recoverable message"}) + "\n",
@@ -33,14 +29,11 @@ def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch)
sessions = manager.list_sessions() sessions = manager.list_sessions()
# BUG: repair fails because _repair re-encodes the fallback_key via assert sessions == []
# _get_session_path, producing a base64 stem that doesn't match the assert corrupt_path.exists()
# actual legacy filename. The session is silently dropped.
assert len(sessions) == 1, f"Expected 1 session, got {len(sessions)}"
assert sessions[0]["key"] == "telegram:12345"
def test_read_session_methods_fall_back_to_legacy_lossy_stem( def test_read_session_methods_ignore_legacy_lossy_stem(
tmp_path: Path, tmp_path: Path,
monkeypatch, monkeypatch,
) -> None: ) -> None:
@@ -69,8 +62,5 @@ def test_read_session_methods_fall_back_to_legacy_lossy_stem(
metadata_result = manager.read_session_metadata(key) metadata_result = manager.read_session_metadata(key)
file_result = manager.read_session_file(key) file_result = manager.read_session_file(key)
assert metadata_result is not None assert metadata_result is None
assert metadata_result["metadata"] == metadata["metadata"] assert file_result is None
assert file_result is not None
assert file_result["metadata"] == metadata["metadata"]
assert file_result["messages"] == []
+14
View File
@@ -98,6 +98,20 @@ def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
assert list_webui_sessions(manager) == [] assert list_webui_sessions(manager) == []
def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
legacy_path = manager.sessions_dir / "websocket_legacy.jsonl"
legacy_path.write_text(
'{"_type":"metadata","key":"websocket:legacy",'
'"created_at":"2025-01-01T00:00:00",'
'"updated_at":"2025-01-01T00:00:00","metadata":{}}\n',
encoding="utf-8",
)
assert list_webui_sessions(manager) == []
assert legacy_path.exists()
def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None: def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None:
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:cron-preview") session = manager.get_or_create("websocket:cron-preview")
-3
View File
@@ -115,9 +115,6 @@ function baseSettingsPayload() {
}, },
dream: { dream: {
schedule: "every 2h", schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
}, },
unified_session: false, unified_session: false,
}, },
-3
View File
@@ -259,9 +259,6 @@ function modelSettings(model: string, provider: string): SettingsPayload {
}, },
dream: { dream: {
schedule: "every 2h", schedule: "every 2h",
max_batch_size: 20,
max_iterations: 15,
annotate_line_ages: true,
}, },
unified_session: false, unified_session: false,
}, },