Batch: mic STT capability probe+fallback (#3618, live-drive verified), journal cleanup on delete (#3802), minimal-schema SQL guard (#3762), Help hover readability (#3810). Full suite 8265, Codex SAFE, Opus SHIP, CI 11/11. Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: dso2ng <dso2ng@users.noreply.github.com>
This commit is contained in:
1 parent
cadbe1e5d3
commit
396d0d0abd
13 files changed
+755
-37
No files matched your search
@@ -3,6 +3,14 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [v0.51.326] — 2026-06-08 — Release KP (mic STT probe + journal cleanup + schema guard + help hover)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Mic input prefers configured server-side STT, with a safe fallback.** A page-load capability probe defaults to the Hermes STT provider when one is configured; installs without server STT keep browser dictation, and a failed transcribe reverts to browser SpeechRecognition. (#3618, @dso2ng)
|
||||||
|
- **Deleting a conversation now also removes its turn/run journals** (previously the user's messages and full request/response payloads stayed on disk in plaintext after delete). (#3802)
|
||||||
|
- **Imported/CLI/agent sessions no longer vanish from the sidebar on minimal `state.db` schemas.** The session-list query now degrades gracefully when the `messages` table is absent or missing columns instead of raising and hiding every row. (#3762)
|
||||||
|
- **Help tab buttons stay readable on hover in every theme** (the hover fill no longer renders same-color text). (#3810)
|
||||||
|
|
||||||
## [v0.51.325] — 2026-06-08 — Release KO (in-app Help tab)
|
## [v0.51.325] — 2026-06-08 — Release KO (in-app Help tab)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+54
-18
@@ -447,11 +447,51 @@ def read_importable_agent_session_rows(
|
|||||||
origin_chat_id_expr = _optional_col('origin_chat_id', session_cols)
|
origin_chat_id_expr = _optional_col('origin_chat_id', session_cols)
|
||||||
origin_user_id_expr = _optional_col('origin_user_id', session_cols)
|
origin_user_id_expr = _optional_col('origin_user_id', session_cols)
|
||||||
platform_expr = _optional_col('platform', session_cols)
|
platform_expr = _optional_col('platform', session_cols)
|
||||||
user_message_count_expr = (
|
# Older/minimal state.db schemas can have NO ``messages`` table at all,
|
||||||
"COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END)"
|
# or a ``messages`` table without a ``session_id`` / ``timestamp`` column.
|
||||||
if 'role' in message_cols
|
# The projection SQL below joins ``messages`` and aggregates
|
||||||
else "COUNT(m.id)"
|
# ``MAX(m.timestamp)`` unconditionally, so on those schemas the query
|
||||||
)
|
# raised ``sqlite3.OperationalError`` — which the caller
|
||||||
|
# (``get_cli_sessions``) swallows into an empty list, silently hiding
|
||||||
|
# ALL imported/CLI/agent sessions from the sidebar. Detect the columns
|
||||||
|
# and degrade gracefully (mirrors ``read_session_lineage_metadata``):
|
||||||
|
# only join/aggregate ``messages`` when it's actually usable, otherwise
|
||||||
|
# fall back to the per-session ``s.message_count`` / ``s.started_at``. (#3762)
|
||||||
|
messages_has_session_id = 'session_id' in message_cols
|
||||||
|
messages_has_timestamp = 'timestamp' in message_cols
|
||||||
|
use_messages_join = messages_has_session_id
|
||||||
|
count_col = 'id' if 'id' in message_cols else 'session_id'
|
||||||
|
|
||||||
|
if use_messages_join:
|
||||||
|
actual_count_expr = f"COUNT(m.{count_col})"
|
||||||
|
if 'role' in message_cols:
|
||||||
|
user_message_count_expr = "COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END)"
|
||||||
|
else:
|
||||||
|
user_message_count_expr = f"COUNT(m.{count_col})"
|
||||||
|
last_activity_expr = "MAX(m.timestamp)" if messages_has_timestamp else "NULL"
|
||||||
|
join_clause = "LEFT JOIN messages m ON m.session_id = s.id"
|
||||||
|
group_by_clause = "GROUP BY s.id"
|
||||||
|
else:
|
||||||
|
# No usable messages table: use the denormalized per-session counts
|
||||||
|
# and ``started_at`` so the rows still surface in the sidebar.
|
||||||
|
actual_count_expr = "s.message_count"
|
||||||
|
user_message_count_expr = "s.message_count"
|
||||||
|
last_activity_expr = "NULL"
|
||||||
|
join_clause = ""
|
||||||
|
group_by_clause = ""
|
||||||
|
|
||||||
|
if use_messages_join and messages_has_timestamp:
|
||||||
|
order_by_clause = "ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC"
|
||||||
|
candidate_order_clause = (
|
||||||
|
"ORDER BY COALESCE(\n"
|
||||||
|
" (SELECT MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id),\n"
|
||||||
|
" s.started_at\n"
|
||||||
|
" ) DESC,\n"
|
||||||
|
" s.started_at DESC"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
order_by_clause = "ORDER BY s.started_at DESC"
|
||||||
|
candidate_order_clause = "ORDER BY s.started_at DESC"
|
||||||
|
|
||||||
where_clauses = ["s.source IS NOT NULL"]
|
where_clauses = ["s.source IS NOT NULL"]
|
||||||
params: list[object] = []
|
params: list[object] = []
|
||||||
@@ -477,9 +517,9 @@ def read_importable_agent_session_rows(
|
|||||||
{parent_expr},
|
{parent_expr},
|
||||||
{ended_expr},
|
{ended_expr},
|
||||||
{end_reason_expr},
|
{end_reason_expr},
|
||||||
COUNT(m.id) AS actual_message_count,
|
{actual_count_expr} AS actual_message_count,
|
||||||
{user_message_count_expr} AS actual_user_message_count,
|
{user_message_count_expr} AS actual_user_message_count,
|
||||||
MAX(m.timestamp) AS last_activity
|
{last_activity_expr} AS last_activity
|
||||||
"""
|
"""
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
result_limit = max(0, int(limit))
|
result_limit = max(0, int(limit))
|
||||||
@@ -500,19 +540,15 @@ def read_importable_agent_session_rows(
|
|||||||
SELECT s.id
|
SELECT s.id
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
WHERE {' AND '.join(where_clauses)}
|
WHERE {' AND '.join(where_clauses)}
|
||||||
ORDER BY COALESCE(
|
{candidate_order_clause}
|
||||||
(SELECT MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id),
|
|
||||||
s.started_at
|
|
||||||
) DESC,
|
|
||||||
s.started_at DESC
|
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
)
|
)
|
||||||
{select_sql}
|
{select_sql}
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN candidates c ON c.id = s.id
|
JOIN candidates c ON c.id = s.id
|
||||||
LEFT JOIN messages m ON m.session_id = s.id
|
{join_clause}
|
||||||
GROUP BY s.id
|
{group_by_clause}
|
||||||
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
{order_by_clause}
|
||||||
""",
|
""",
|
||||||
[*params, candidate_limit],
|
[*params, candidate_limit],
|
||||||
)
|
)
|
||||||
@@ -521,10 +557,10 @@ def read_importable_agent_session_rows(
|
|||||||
f"""
|
f"""
|
||||||
{select_sql}
|
{select_sql}
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
LEFT JOIN messages m ON m.session_id = s.id
|
{join_clause}
|
||||||
WHERE {' AND '.join(where_clauses)}
|
WHERE {' AND '.join(where_clauses)}
|
||||||
GROUP BY s.id
|
{group_by_clause}
|
||||||
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
{order_by_clause}
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
|
|||||||
+27
-1
@@ -3375,7 +3375,13 @@ from api.workspace import (
|
|||||||
_is_remote_terminal_backend,
|
_is_remote_terminal_backend,
|
||||||
_workspace_blocked_roots,
|
_workspace_blocked_roots,
|
||||||
)
|
)
|
||||||
from api.upload import handle_upload, handle_upload_extract, handle_transcribe, handle_workspace_upload
|
from api.upload import (
|
||||||
|
handle_upload,
|
||||||
|
handle_upload_extract,
|
||||||
|
handle_transcribe,
|
||||||
|
handle_transcribe_capability,
|
||||||
|
handle_workspace_upload,
|
||||||
|
)
|
||||||
from api.streaming import (
|
from api.streaming import (
|
||||||
_sse,
|
_sse,
|
||||||
_run_agent_streaming,
|
_run_agent_streaming,
|
||||||
@@ -5304,6 +5310,9 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
pass
|
pass
|
||||||
return j(handler, settings)
|
return j(handler, settings)
|
||||||
|
|
||||||
|
if parsed.path == "/api/transcribe/capability":
|
||||||
|
return handle_transcribe_capability(handler)
|
||||||
|
|
||||||
if parsed.path == "/api/reasoning":
|
if parsed.path == "/api/reasoning":
|
||||||
# Current reasoning config (shared source of truth with the CLI —
|
# Current reasoning config (shared source of truth with the CLI —
|
||||||
# reads display.show_reasoning and agent.reasoning_effort from
|
# reads display.show_reasoning and agent.reasoning_effort from
|
||||||
@@ -7252,6 +7261,23 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
shutil.rmtree(_session_attachment_dir(sid), ignore_errors=True)
|
shutil.rmtree(_session_attachment_dir(sid), ignore_errors=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to clean attachment dir for deleted session %s", sid)
|
logger.debug("Failed to clean attachment dir for deleted session %s", sid)
|
||||||
|
# Remove the turn-journal shards and the run-journal directory so a
|
||||||
|
# deleted conversation is not recoverable from disk. The session JSON +
|
||||||
|
# state.db rows are cleared above, but these journals retain the user's
|
||||||
|
# messages (turn journal) and the full request/response payloads (run
|
||||||
|
# journal) in plaintext. (#3802)
|
||||||
|
try:
|
||||||
|
from api.turn_journal import delete_turn_journal
|
||||||
|
|
||||||
|
delete_turn_journal(sid)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to delete turn journal for deleted session %s", sid)
|
||||||
|
try:
|
||||||
|
from api.run_journal import delete_run_journal
|
||||||
|
|
||||||
|
delete_run_journal(sid)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to delete run journal for deleted session %s", sid)
|
||||||
# Prune the per-session agent lock so deleted sessions don't leak
|
# Prune the per-session agent lock so deleted sessions don't leak
|
||||||
# Lock entries in SESSION_AGENT_LOCKS forever.
|
# Lock entries in SESSION_AGENT_LOCKS forever.
|
||||||
with SESSION_AGENT_LOCKS_LOCK:
|
with SESSION_AGENT_LOCKS_LOCK:
|
||||||
|
|||||||
@@ -256,6 +256,32 @@ def find_run_summary(run_id: str, *, session_dir: Path | None = None) -> dict |
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_run_journal(session_id: str, *, session_dir: Path | None = None) -> bool:
|
||||||
|
"""Remove the entire per-session run-journal directory (``_run_journal/{sid}/``).
|
||||||
|
|
||||||
|
The run journal stores one directory per session containing a ``{rid}.jsonl``
|
||||||
|
file per run, so removing the session's directory clears every run's full
|
||||||
|
request/response payloads. Invalid/empty ids and a missing directory are a
|
||||||
|
no-op so callers can invoke this unconditionally on delete. Returns ``True``
|
||||||
|
if a directory was removed, ``False`` otherwise.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
sid = str(session_id or "").strip()
|
||||||
|
# Reject path-traversal ids: the regex below permits dots, so a bare "." or
|
||||||
|
# ".." would resolve `root / RUN_JOURNAL_DIR_NAME / sid` to the journal ROOT
|
||||||
|
# (or its parent) and rmtree the wrong directory. The route call site only
|
||||||
|
# passes real sids, but this is a public helper — guard it directly.
|
||||||
|
if sid in (".", "..") or not sid or "/" in sid or "\\" in sid or not _SAFE_ID_RE.fullmatch(sid):
|
||||||
|
return False
|
||||||
|
root = Path(session_dir) if session_dir is not None else _default_session_dir()
|
||||||
|
session_journal_dir = root / RUN_JOURNAL_DIR_NAME / sid
|
||||||
|
if not session_journal_dir.exists():
|
||||||
|
return False
|
||||||
|
shutil.rmtree(session_journal_dir, ignore_errors=True)
|
||||||
|
return not session_journal_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
def stale_interrupted_event(session_id: str, run_id: str, *, after_seq: int | None = None) -> dict | None:
|
def stale_interrupted_event(session_id: str, run_id: str, *, after_seq: int | None = None) -> dict | None:
|
||||||
summary = latest_run_summary(session_id, run_id)
|
summary = latest_run_summary(session_id, run_id)
|
||||||
if summary.get("terminal") or not summary.get("event_count"):
|
if summary.get("terminal") or not summary.get("event_count"):
|
||||||
|
|||||||
@@ -242,5 +242,40 @@ def iter_turn_journal_session_ids(session_dir: Path) -> list[str]:
|
|||||||
return sorted(session_ids)
|
return sorted(session_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_turn_journal(session_id: str, *, session_dir: Path | None = None) -> int:
|
||||||
|
"""Remove every turn-journal shard for ``session_id``.
|
||||||
|
|
||||||
|
Deletes both the pid-scoped shards (``{sid}~{pid}.jsonl``) written by
|
||||||
|
:func:`append_turn_journal_event` and the legacy single-file form
|
||||||
|
(``{sid}.jsonl``) that :func:`read_turn_journal` still merges. Returns the
|
||||||
|
number of files removed. Invalid/empty ids and a missing journal directory
|
||||||
|
are treated as a no-op so callers can invoke this unconditionally on delete.
|
||||||
|
"""
|
||||||
|
sid = str(session_id or "").strip()
|
||||||
|
# Reject "."/".." for parity with delete_run_journal — the regex permits
|
||||||
|
# dots, and a traversal id has no legitimate use here.
|
||||||
|
if sid in (".", "..") or not sid or "/" in sid or "\\" in sid or not _SESSION_ID_RE.fullmatch(sid):
|
||||||
|
return 0
|
||||||
|
root = Path(session_dir) if session_dir is not None else _default_session_dir()
|
||||||
|
journal_dir = root / TURN_JOURNAL_DIR_NAME
|
||||||
|
if not journal_dir.exists():
|
||||||
|
return 0
|
||||||
|
removed = 0
|
||||||
|
shards = list(journal_dir.glob(f"{sid}~*.jsonl"))
|
||||||
|
legacy = journal_dir / f"{sid}.jsonl"
|
||||||
|
if legacy.exists():
|
||||||
|
shards.append(legacy)
|
||||||
|
for shard in shards:
|
||||||
|
try:
|
||||||
|
shard.unlink()
|
||||||
|
removed += 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
# Best-effort cleanup; the caller logs the overall delete outcome.
|
||||||
|
pass
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def is_terminal_turn_event(event: dict) -> bool:
|
def is_terminal_turn_event(event: dict) -> bool:
|
||||||
return str((event or {}).get("event") or "") in _TERMINAL_EVENTS
|
return str((event or {}).get("event") or "") in _TERMINAL_EVENTS
|
||||||
+119
@@ -398,6 +398,125 @@ def handle_transcribe(handler):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _stt_provider_capability_from_module(stt):
|
||||||
|
"""Return (available, provider) for a loaded transcription_tools module."""
|
||||||
|
try:
|
||||||
|
load_cfg = getattr(stt, "_load_stt_config", None)
|
||||||
|
stt_config = load_cfg() if callable(load_cfg) else {}
|
||||||
|
cfg_dict = stt_config if isinstance(stt_config, dict) else {}
|
||||||
|
is_enabled = getattr(stt, "is_stt_enabled", None)
|
||||||
|
if callable(is_enabled) and not is_enabled(stt_config):
|
||||||
|
return False, "none"
|
||||||
|
|
||||||
|
# Some tests and future agent releases expose the provider decision as a
|
||||||
|
# single helper. Use it when the lower-level capability flags are not
|
||||||
|
# available. The current agent module exposes the flags below, so the
|
||||||
|
# normal path mirrors _get_provider() without triggering its lazy local
|
||||||
|
# STT install side effect during a passive web page probe.
|
||||||
|
has_internal_flags = any(
|
||||||
|
hasattr(stt, name)
|
||||||
|
for name in ("_HAS_FASTER_WHISPER", "_HAS_OPENAI", "_HAS_MISTRAL")
|
||||||
|
)
|
||||||
|
get_provider = getattr(stt, "_get_provider", None)
|
||||||
|
if callable(get_provider) and not has_internal_flags:
|
||||||
|
provider = str(get_provider(stt_config) or "none")
|
||||||
|
return provider not in ("", "none"), provider or "none"
|
||||||
|
|
||||||
|
def env(name):
|
||||||
|
getter = getattr(stt, "get_env_value", None)
|
||||||
|
try:
|
||||||
|
if callable(getter):
|
||||||
|
return str(getter(name) or "").strip()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return os.getenv(name, "").strip()
|
||||||
|
|
||||||
|
def has_local_command():
|
||||||
|
helper = getattr(stt, "_has_local_command", None)
|
||||||
|
try:
|
||||||
|
return bool(helper()) if callable(helper) else False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_browser_audio_converter():
|
||||||
|
helper = getattr(stt, "_find_ffmpeg_binary", None)
|
||||||
|
try:
|
||||||
|
return bool(helper()) if callable(helper) else False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_openai_audio():
|
||||||
|
helper = getattr(stt, "_has_openai_audio_backend", None)
|
||||||
|
try:
|
||||||
|
return bool(helper()) if callable(helper) else False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def local_command_available():
|
||||||
|
# The browser sends WebM/Ogg blobs; the local-command path converts
|
||||||
|
# non-WAV input through ffmpeg before invoking the command.
|
||||||
|
return has_local_command() and has_browser_audio_converter()
|
||||||
|
|
||||||
|
def resolve_provider(provider):
|
||||||
|
if provider == "local":
|
||||||
|
if bool(getattr(stt, "_HAS_FASTER_WHISPER", False)):
|
||||||
|
return "local"
|
||||||
|
if local_command_available():
|
||||||
|
return "local_command"
|
||||||
|
return "none"
|
||||||
|
if provider == "local_command":
|
||||||
|
if local_command_available():
|
||||||
|
return "local_command"
|
||||||
|
if bool(getattr(stt, "_HAS_FASTER_WHISPER", False)):
|
||||||
|
return "local"
|
||||||
|
return "none"
|
||||||
|
if provider == "groq":
|
||||||
|
return "groq" if bool(getattr(stt, "_HAS_OPENAI", False)) and bool(env("GROQ_API_KEY")) else "none"
|
||||||
|
if provider == "openai":
|
||||||
|
return "openai" if bool(getattr(stt, "_HAS_OPENAI", False)) and has_openai_audio() else "none"
|
||||||
|
if provider == "mistral":
|
||||||
|
return "mistral" if bool(getattr(stt, "_HAS_MISTRAL", False)) and bool(env("MISTRAL_API_KEY")) else "none"
|
||||||
|
if provider == "xai":
|
||||||
|
try:
|
||||||
|
from tools.xai_http import resolve_xai_http_credentials
|
||||||
|
|
||||||
|
return "xai" if resolve_xai_http_credentials().get("api_key") else "none"
|
||||||
|
except Exception:
|
||||||
|
return "none"
|
||||||
|
if provider == "elevenlabs":
|
||||||
|
return "elevenlabs" if bool(env("ELEVENLABS_API_KEY")) else "none"
|
||||||
|
return "none"
|
||||||
|
|
||||||
|
explicit = "provider" in cfg_dict
|
||||||
|
if explicit:
|
||||||
|
configured = str(cfg_dict.get("provider") or "local")
|
||||||
|
provider = resolve_provider(configured)
|
||||||
|
return provider != "none", provider if provider != "none" else configured
|
||||||
|
|
||||||
|
for candidate in ("local", "local_command", "groq", "openai", "mistral", "xai", "elevenlabs"):
|
||||||
|
provider = resolve_provider(candidate)
|
||||||
|
if provider != "none":
|
||||||
|
return True, provider
|
||||||
|
return False, "none"
|
||||||
|
except Exception:
|
||||||
|
return False, "none"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _stt_provider_capability():
|
||||||
|
"""Return (available, provider) for a cheap server-side STT capability probe."""
|
||||||
|
try:
|
||||||
|
import tools.transcription_tools as stt
|
||||||
|
except ImportError:
|
||||||
|
return False, "none"
|
||||||
|
return _stt_provider_capability_from_module(stt)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_transcribe_capability(handler):
|
||||||
|
available, provider = _stt_provider_capability()
|
||||||
|
return j(handler, {"ok": True, "available": bool(available), "provider": provider})
|
||||||
|
|
||||||
|
|
||||||
def handle_workspace_upload(handler):
|
def handle_workspace_upload(handler):
|
||||||
"""Upload a file into a session's workspace directory.
|
"""Upload a file into a session's workspace directory.
|
||||||
|
|
||||||
|
|||||||
+84
-11
@@ -470,7 +470,13 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
|
|||||||
|
|
||||||
// Persist SR failure across reloads (e.g. Tailscale/network error)
|
// Persist SR failure across reloads (e.g. Tailscale/network error)
|
||||||
const _micForceMediaRecorderKey='mic_force_mediarecorder';
|
const _micForceMediaRecorderKey='mic_force_mediarecorder';
|
||||||
let _forceMediaRecorder=!SpeechRecognition||localStorage.getItem(_micForceMediaRecorderKey)==='1';
|
const _micForceMediaRecorderStored=localStorage.getItem(_micForceMediaRecorderKey);
|
||||||
|
// Prefer Hermes server-side STT (MediaRecorder -> /api/transcribe) only
|
||||||
|
// after the server confirms an STT provider is available. No stored key must
|
||||||
|
// keep browser SpeechRecognition as the first-click default until then; that
|
||||||
|
// avoids dropping the first dictation on installs without server STT.
|
||||||
|
let _serverSttAvailable=false;
|
||||||
|
let _forceMediaRecorder=!SpeechRecognition||(_micForceMediaRecorderStored===null?(_serverSttAvailable&&_canRecordAudio):_micForceMediaRecorderStored==='1');
|
||||||
|
|
||||||
// Raw audio mode preference: send audio file instead of transcribing
|
// Raw audio mode preference: send audio file instead of transcribing
|
||||||
let _rawAudioMode = localStorage.getItem('hermes-raw-audio-mode') === 'true';
|
let _rawAudioMode = localStorage.getItem('hermes-raw-audio-mode') === 'true';
|
||||||
@@ -485,7 +491,7 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
|
|||||||
const statusText=status?status.querySelector('.status-text'):null;
|
const statusText=status?status.querySelector('.status-text'):null;
|
||||||
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
|
btn.style.display=''; // Show button — browser supports speech recognition or recording fallback
|
||||||
|
|
||||||
let recognition=(!_forceMediaRecorder&&SpeechRecognition)?new SpeechRecognition():null;
|
let recognition=null;
|
||||||
let mediaRecorder=null;
|
let mediaRecorder=null;
|
||||||
let mediaStream=null;
|
let mediaStream=null;
|
||||||
let audioChunks=[];
|
let audioChunks=[];
|
||||||
@@ -556,6 +562,18 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _isServerSttUnavailable(err){
|
||||||
|
const status=err&&err.status;
|
||||||
|
if(status===404||status===503||status>=500) return true;
|
||||||
|
if(!status) return true;
|
||||||
|
const msg=String((err&&err.message)||'').toLowerCase();
|
||||||
|
return msg.includes('unavailable')||msg.includes('not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _allowBrowserSttFallback(){
|
||||||
|
return !!(SpeechRecognition&&localStorage.getItem(_micForceMediaRecorderKey)!=='1');
|
||||||
|
}
|
||||||
|
|
||||||
async function _transcribeBlob(blob){
|
async function _transcribeBlob(blob){
|
||||||
const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
|
const ext=(blob.type&&blob.type.includes('ogg'))?'ogg':'webm';
|
||||||
const form=new FormData();
|
const form=new FormData();
|
||||||
@@ -564,9 +582,21 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
|
|||||||
try{
|
try{
|
||||||
const res=await fetch('api/transcribe',{method:'POST',body:form});
|
const res=await fetch('api/transcribe',{method:'POST',body:form});
|
||||||
const data=await res.json().catch(()=>({}));
|
const data=await res.json().catch(()=>({}));
|
||||||
if(!res.ok) throw new Error(data.error||'Transcription failed');
|
if(!res.ok){
|
||||||
|
const err=new Error(data.error||'Transcription failed');
|
||||||
|
err.status=res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
_commitTranscript(data.transcript||'');
|
_commitTranscript(data.transcript||'');
|
||||||
}catch(err){
|
}catch(err){
|
||||||
|
if(_isServerSttUnavailable(err)&&_allowBrowserSttFallback()){
|
||||||
|
window._micPendingSend=false;
|
||||||
|
localStorage.setItem(_micForceMediaRecorderKey,'0');
|
||||||
|
_forceMediaRecorder=false;
|
||||||
|
recognition=_ensureSpeechRecognition();
|
||||||
|
showToast(err.message||t('mic_network'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
window._micPendingSend=false;
|
window._micPendingSend=false;
|
||||||
showToast(err.message||t('mic_network'));
|
showToast(err.message||t('mic_network'));
|
||||||
}finally{
|
}finally{
|
||||||
@@ -600,14 +630,16 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
|
|||||||
}
|
}
|
||||||
window._stopMic=_stopMic; // expose for send-guard above
|
window._stopMic=_stopMic; // expose for send-guard above
|
||||||
|
|
||||||
if(recognition && !_forceMediaRecorder){
|
function _ensureSpeechRecognition(){
|
||||||
recognition.continuous=false;
|
if(!SpeechRecognition) return null;
|
||||||
recognition.interimResults=true;
|
const sr=recognition||new SpeechRecognition();
|
||||||
recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
|
sr.continuous=false;
|
||||||
|
sr.interimResults=true;
|
||||||
|
sr.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
|
||||||
|
|
||||||
recognition.onstart=()=>{ _finalText=''; };
|
sr.onstart=()=>{ _finalText=''; };
|
||||||
|
|
||||||
recognition.onresult=(event)=>{
|
sr.onresult=(event)=>{
|
||||||
let interim='';
|
let interim='';
|
||||||
let final=_finalText;
|
let final=_finalText;
|
||||||
for(let i=event.resultIndex;i<event.results.length;i++){
|
for(let i=event.resultIndex;i<event.results.length;i++){
|
||||||
@@ -619,7 +651,7 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
|
|||||||
autoResize();
|
autoResize();
|
||||||
};
|
};
|
||||||
|
|
||||||
recognition.onend=()=>{
|
sr.onend=()=>{
|
||||||
const committed=_finalText
|
const committed=_finalText
|
||||||
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
? (_prefix&&!_prefix.endsWith(' ')&&!_prefix.endsWith('\n')
|
||||||
? _prefix+' '+_finalText.trimStart()
|
? _prefix+' '+_finalText.trimStart()
|
||||||
@@ -632,9 +664,10 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
|
|||||||
window._micPendingSend=false;
|
window._micPendingSend=false;
|
||||||
send();
|
send();
|
||||||
}
|
}
|
||||||
|
_applyDeferredServerSttFlip();
|
||||||
};
|
};
|
||||||
|
|
||||||
recognition.onerror=(event)=>{
|
sr.onerror=(event)=>{
|
||||||
_setRecording(false);
|
_setRecording(false);
|
||||||
window._micPendingSend=false;
|
window._micPendingSend=false;
|
||||||
_isRecording=false;
|
_isRecording=false;
|
||||||
@@ -651,8 +684,47 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
|
|||||||
};
|
};
|
||||||
showToast(msgs[event.error]||t('mic_error')+event.error);
|
showToast(msgs[event.error]||t('mic_error')+event.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return sr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(!_forceMediaRecorder){
|
||||||
|
recognition=_ensureSpeechRecognition();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _probeServerSttCapability(){
|
||||||
|
if(!_canRecordAudio||_micForceMediaRecorderStored!==null) return;
|
||||||
|
try{
|
||||||
|
const res=await fetch('api/transcribe/capability',{cache:'no-store'});
|
||||||
|
const data=await res.json().catch(()=>({}));
|
||||||
|
if(res.ok&&data&&data.available){
|
||||||
|
_serverSttAvailable=true;
|
||||||
|
if(!window._micActive){
|
||||||
|
_forceMediaRecorder=true;
|
||||||
|
recognition=null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(_err){
|
||||||
|
// Keep browser SpeechRecognition as the safe first-click default when the
|
||||||
|
// passive capability probe fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the capability probe resolved WHILE a session was active, the flip to
|
||||||
|
// server STT was deferred to protect that in-flight session. Apply it once the
|
||||||
|
// session ends so subsequent clicks use the configured server STT as intended.
|
||||||
|
// Reads LIVE localStorage (not the init-time const) so a fallback that just
|
||||||
|
// persisted '0' is respected and not re-flipped.
|
||||||
|
function _applyDeferredServerSttFlip(){
|
||||||
|
if(_serverSttAvailable&&!_forceMediaRecorder&&!window._micActive
|
||||||
|
&&localStorage.getItem(_micForceMediaRecorderKey)===null){
|
||||||
|
_forceMediaRecorder=true;
|
||||||
|
recognition=null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_probeServerSttCapability();
|
||||||
|
|
||||||
btn.onclick=async()=>{
|
btn.onclick=async()=>{
|
||||||
// Race-condition guard: ignore rapid double-clicks
|
// Race-condition guard: ignore rapid double-clicks
|
||||||
if(_isRecording){
|
if(_isRecording){
|
||||||
@@ -707,6 +779,7 @@ $('btnAttach').onclick=e=>{if(e&&e.preventDefault)e.preventDefault();$('fileInpu
|
|||||||
else if(window._micPendingSend){
|
else if(window._micPendingSend){
|
||||||
window._micPendingSend=false;
|
window._micPendingSend=false;
|
||||||
}
|
}
|
||||||
|
_applyDeferredServerSttFlip();
|
||||||
};
|
};
|
||||||
_activeCaptureMode=_rawAudioMode?'media-raw':'media-transcribe';
|
_activeCaptureMode=_rawAudioMode?'media-raw':'media-transcribe';
|
||||||
mediaRecorder.start();
|
mediaRecorder.start();
|
||||||
|
|||||||
+7
-1
@@ -1566,7 +1566,13 @@
|
|||||||
.help-card-title{font-size:13.5px;font-weight:650;color:var(--text);margin-bottom:3px;}
|
.help-card-title{font-size:13.5px;font-weight:650;color:var(--text);margin-bottom:3px;}
|
||||||
.help-card-desc{font-size:12px;color:var(--muted);line-height:1.45;margin-bottom:11px;}
|
.help-card-desc{font-size:12px;color:var(--muted);line-height:1.45;margin-bottom:11px;}
|
||||||
.help-card-link{display:inline-flex;align-items:center;gap:6px;text-decoration:none;padding:7px 13px;border-radius:7px;font-size:12px;font-weight:600;background:var(--input-bg);border:1px solid var(--border);color:var(--text);transition:all .15s;}
|
.help-card-link{display:inline-flex;align-items:center;gap:6px;text-decoration:none;padding:7px 13px;border-radius:7px;font-size:12px;font-weight:600;background:var(--input-bg);border:1px solid var(--border);color:var(--text);transition:all .15s;}
|
||||||
.help-card-link:hover{background:var(--accent);border-color:var(--accent);color:var(--accent-text,#1a1a1a);}
|
/* On hover the button fills with --accent; text must contrast against that fill.
|
||||||
|
--accent-text is an accent-on-background link color (often == --accent), so using
|
||||||
|
it here produces same-color text on the accent fill (invisible — e.g. slate/gold/
|
||||||
|
ares/mono dark). --bg is the page background, which --accent is designed to contrast
|
||||||
|
against, so background-colored text on an accent fill stays readable in every theme. */
|
||||||
|
.help-card-link:hover{background:var(--accent);border-color:var(--accent);color:var(--bg);}
|
||||||
|
.help-card-link:hover svg{opacity:1;}
|
||||||
.help-card-link svg{flex:0 0 auto;opacity:.8;}
|
.help-card-link svg{flex:0 0 auto;opacity:.8;}
|
||||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;min-height:0;background:var(--main-bg);}
|
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;min-height:0;background:var(--main-bg);}
|
||||||
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:var(--topbar-bg);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
|
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:var(--topbar-bg);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||||
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||||
|
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
LOCALE_COUNT = 13 # en, it, ja, ru, es, de, zh, zh-TW, pt, ko, fr, tr, pl
|
LOCALE_COUNT = 13 # en, it, ja, ru, es, de, zh, zh-TW, pt, ko, fr, tr, pl
|
||||||
|
|
||||||
@@ -46,3 +47,27 @@ def test_i18n_help_keys_present_in_all_locales():
|
|||||||
assert I18N_JS.count("settings_help_issue_label") == LOCALE_COUNT
|
assert I18N_JS.count("settings_help_issue_label") == LOCALE_COUNT
|
||||||
assert I18N_JS.count("settings_help_docs_link") == LOCALE_COUNT
|
assert I18N_JS.count("settings_help_docs_link") == LOCALE_COUNT
|
||||||
assert I18N_JS.count("settings_help_issue_link") == LOCALE_COUNT
|
assert I18N_JS.count("settings_help_issue_link") == LOCALE_COUNT
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_card_link_hover_is_contrast_safe():
|
||||||
|
"""The hover fill is var(--accent); the text color must NOT be var(--accent-text).
|
||||||
|
|
||||||
|
In most themes --accent-text equals (or nearly equals) --accent — it is an
|
||||||
|
accent-on-background link color, not a contrasting color for text sitting ON
|
||||||
|
an accent fill. Using it on the accent-filled hover state produced same-color
|
||||||
|
text on the fill (invisible) across nearly every theme (slate/gold/ares/mono/
|
||||||
|
sisyphus/catppuccin dark all had accent == accent-text). The fix uses
|
||||||
|
var(--bg) — the page background — which --accent is designed to contrast
|
||||||
|
against, so the text stays readable in every theme.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
m = re.search(r"\.help-card-link:hover\s*\{([^}]*)\}", STYLE_CSS)
|
||||||
|
assert m, "expected a .help-card-link:hover rule in style.css"
|
||||||
|
rule = m.group(1)
|
||||||
|
assert "background:var(--accent)" in rule.replace(" ", "")
|
||||||
|
# The bug: accent-text on accent fill. Must not reappear.
|
||||||
|
assert "--accent-text" not in rule, (
|
||||||
|
"hover text uses var(--accent-text) on an accent fill — invisible in most "
|
||||||
|
"themes; use var(--bg) for theme-agnostic contrast"
|
||||||
|
)
|
||||||
|
assert "color:var(--bg)" in rule.replace(" ", "")
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Regression tests for #3762 — minimal state.db schemas must not hide all sessions.
|
||||||
|
|
||||||
|
`read_importable_agent_session_rows()` built its projection SQL with
|
||||||
|
unconditional references to the `messages` table and `messages.timestamp`
|
||||||
|
(`MAX(m.timestamp)`, `COUNT(m.id)`, the `LEFT JOIN messages`, and the
|
||||||
|
`ORDER BY MAX(m.timestamp)`). On a `state.db` that has no `messages` table — or
|
||||||
|
a `messages` table without a `timestamp` column — that query raised
|
||||||
|
`sqlite3.OperationalError`, which `get_cli_sessions()` catches and turns into an
|
||||||
|
empty list, so EVERY imported/CLI/agent session silently vanished from the
|
||||||
|
sidebar. These tests build minimal schemas directly and assert the rows still
|
||||||
|
come back instead of the query exploding.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.agent_sessions import read_importable_agent_session_rows
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db(path, *, with_messages_table, with_timestamp, with_session_id=True,
|
||||||
|
sessions=None, messages=None):
|
||||||
|
"""Build a minimal state.db.
|
||||||
|
|
||||||
|
sessions: list of (id, title, model, message_count, started_at, source, session_source)
|
||||||
|
messages: list of (session_id, role[, timestamp]) — inserted only when the
|
||||||
|
messages table is created with the matching columns.
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(str(path))
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER,
|
||||||
|
started_at REAL, source TEXT, session_source TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for r in (sessions or []):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (id, title, model, message_count, started_at, source, session_source) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?)",
|
||||||
|
r,
|
||||||
|
)
|
||||||
|
if with_messages_table:
|
||||||
|
cols = ["id INTEGER PRIMARY KEY"]
|
||||||
|
if with_session_id:
|
||||||
|
cols.append("session_id TEXT")
|
||||||
|
cols.append("role TEXT")
|
||||||
|
if with_timestamp:
|
||||||
|
cols.append("timestamp REAL")
|
||||||
|
conn.execute(f"CREATE TABLE messages ({', '.join(cols)})")
|
||||||
|
for m in (messages or []):
|
||||||
|
if with_session_id and with_timestamp:
|
||||||
|
conn.execute("INSERT INTO messages (session_id, role, timestamp) VALUES (?,?,?)", m)
|
||||||
|
elif with_session_id:
|
||||||
|
conn.execute("INSERT INTO messages (session_id, role) VALUES (?,?)", m[:2])
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_schema_returns_rows_baseline(tmp_path):
|
||||||
|
db = tmp_path / "state.db"
|
||||||
|
_make_db(
|
||||||
|
db, with_messages_table=True, with_timestamp=True,
|
||||||
|
sessions=[("cli-1", "Hello", "gpt", 3, 1000.0, "cli", "cli")],
|
||||||
|
messages=[("cli-1", "user", 1001.0), ("cli-1", "assistant", 1002.0)],
|
||||||
|
)
|
||||||
|
out = read_importable_agent_session_rows(db, exclude_sources=None)
|
||||||
|
assert "cli-1" in {r["id"] for r in out}
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_table_without_timestamp_does_not_hide_sessions(tmp_path):
|
||||||
|
"""The exact #3762 repro: messages table exists but has no timestamp column.
|
||||||
|
|
||||||
|
On master this raised sqlite3.OperationalError on MAX(m.timestamp) and the
|
||||||
|
whole list collapsed to empty. The COUNT join still works, so the session
|
||||||
|
must surface with its real message count and a NULL last_activity.
|
||||||
|
"""
|
||||||
|
db = tmp_path / "state.db"
|
||||||
|
_make_db(
|
||||||
|
db, with_messages_table=True, with_timestamp=False,
|
||||||
|
sessions=[("cli-1", "Hello", "gpt", 2, 1000.0, "cli", "cli")],
|
||||||
|
messages=[("cli-1", "user"), ("cli-1", "assistant")],
|
||||||
|
)
|
||||||
|
out = read_importable_agent_session_rows(db, exclude_sources=None)
|
||||||
|
assert {r["id"] for r in out} == {"cli-1"}
|
||||||
|
assert out[0].get("last_activity") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_messages_table_does_not_hide_sessions(tmp_path):
|
||||||
|
"""state.db with a sessions table but NO messages table at all.
|
||||||
|
|
||||||
|
With no join possible we fall back to the denormalized s.message_count, so
|
||||||
|
non-empty sessions still surface.
|
||||||
|
"""
|
||||||
|
db = tmp_path / "state.db"
|
||||||
|
_make_db(
|
||||||
|
db, with_messages_table=False, with_timestamp=False,
|
||||||
|
sessions=[
|
||||||
|
("cli-1", "First", "gpt", 2, 1000.0, "cli", "cli"),
|
||||||
|
("cli-2", "Second", "gpt", 7, 2000.0, "cli", "cli"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
out = read_importable_agent_session_rows(db, exclude_sources=None)
|
||||||
|
assert {r["id"] for r in out} == {"cli-1", "cli-2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_table_without_session_id_does_not_hide_sessions(tmp_path):
|
||||||
|
"""A messages table that can't be joined (no session_id) degrades to s.message_count."""
|
||||||
|
db = tmp_path / "state.db"
|
||||||
|
_make_db(
|
||||||
|
db, with_messages_table=True, with_timestamp=True, with_session_id=False,
|
||||||
|
sessions=[("cli-1", "Hello", "gpt", 4, 1000.0, "cli", "cli")],
|
||||||
|
)
|
||||||
|
out = read_importable_agent_session_rows(db, exclude_sources=None)
|
||||||
|
assert {r["id"] for r in out} == {"cli-1"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("with_timestamp", [True, False])
|
||||||
|
def test_limit_path_also_guarded(tmp_path, with_timestamp):
|
||||||
|
"""The bounded (limit set) candidate-CTE branch must be column-aware too."""
|
||||||
|
db = tmp_path / "state.db"
|
||||||
|
sessions = [(f"cli-{i}", f"T{i}", "gpt", 2, 1000.0 + i, "cli", "cli") for i in range(5)]
|
||||||
|
messages = []
|
||||||
|
for i in range(5):
|
||||||
|
if with_timestamp:
|
||||||
|
messages += [(f"cli-{i}", "user", 1000.0 + i), (f"cli-{i}", "assistant", 1000.5 + i)]
|
||||||
|
else:
|
||||||
|
messages += [(f"cli-{i}", "user"), (f"cli-{i}", "assistant")]
|
||||||
|
_make_db(db, with_messages_table=True, with_timestamp=with_timestamp,
|
||||||
|
sessions=sessions, messages=messages)
|
||||||
|
out = read_importable_agent_session_rows(db, limit=3, exclude_sources=None)
|
||||||
|
assert len(out) == 3
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Regression tests for #3802 — deleting a session must remove its journal files.
|
||||||
|
|
||||||
|
Deleting a conversation from the WebUI removed the session JSON + state.db rows
|
||||||
|
but left the turn journal (`_turn_journal/{sid}*.jsonl`, user messages in
|
||||||
|
plaintext) and the run journal (`_run_journal/{sid}/`, full request/response
|
||||||
|
payloads) on disk, so the conversation stayed recoverable. These tests pin the
|
||||||
|
two cleanup helpers: every shard/dir for the deleted session is removed, and an
|
||||||
|
unrelated session's journals are left untouched.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from api.run_journal import RunJournalWriter, delete_run_journal, read_run_events
|
||||||
|
from api.turn_journal import (
|
||||||
|
append_turn_journal_event,
|
||||||
|
delete_turn_journal,
|
||||||
|
read_turn_journal,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _submit(sid, content, session_dir):
|
||||||
|
return append_turn_journal_event(
|
||||||
|
sid,
|
||||||
|
{"event": "submitted", "turn_id": "t1", "stream_id": "s1", "role": "user", "content": content},
|
||||||
|
session_dir=session_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_turn_journal_removes_pid_shard_and_legacy(tmp_path):
|
||||||
|
# pid-scoped shard (written by append) + a legacy single-file shard.
|
||||||
|
_submit("sid-del", "secret message", session_dir=tmp_path)
|
||||||
|
journal_dir = tmp_path / "_turn_journal"
|
||||||
|
legacy = journal_dir / "sid-del.jsonl"
|
||||||
|
legacy.write_text('{"event":"submitted","session_id":"sid-del","content":"old"}\n', encoding="utf-8")
|
||||||
|
|
||||||
|
pid_shard = journal_dir / f"sid-del~{os.getpid()}.jsonl"
|
||||||
|
assert pid_shard.exists()
|
||||||
|
assert legacy.exists()
|
||||||
|
|
||||||
|
removed = delete_turn_journal("sid-del", session_dir=tmp_path)
|
||||||
|
|
||||||
|
assert removed == 2
|
||||||
|
assert not pid_shard.exists()
|
||||||
|
assert not legacy.exists()
|
||||||
|
# read_turn_journal now finds nothing for the deleted session.
|
||||||
|
assert read_turn_journal("sid-del", session_dir=tmp_path)["events"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_turn_journal_leaves_other_sessions_intact(tmp_path):
|
||||||
|
_submit("sid-keep", "keep me", session_dir=tmp_path)
|
||||||
|
_submit("sid-del", "delete me", session_dir=tmp_path)
|
||||||
|
|
||||||
|
delete_turn_journal("sid-del", session_dir=tmp_path)
|
||||||
|
|
||||||
|
keep_shard = tmp_path / "_turn_journal" / f"sid-keep~{os.getpid()}.jsonl"
|
||||||
|
del_shard = tmp_path / "_turn_journal" / f"sid-del~{os.getpid()}.jsonl"
|
||||||
|
assert keep_shard.exists(), "unrelated session's journal must survive"
|
||||||
|
assert not del_shard.exists()
|
||||||
|
assert read_turn_journal("sid-keep", session_dir=tmp_path)["events"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_turn_journal_noop_on_missing_or_invalid(tmp_path):
|
||||||
|
# Missing directory: no error, zero removed.
|
||||||
|
assert delete_turn_journal("nope", session_dir=tmp_path) == 0
|
||||||
|
# Invalid id: no error, zero removed (and never touches the filesystem).
|
||||||
|
assert delete_turn_journal("../etc/passwd", session_dir=tmp_path) == 0
|
||||||
|
assert delete_turn_journal("", session_dir=tmp_path) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_run_journal_removes_session_directory(tmp_path):
|
||||||
|
writer = RunJournalWriter("sid-del", "run-1", session_dir=tmp_path)
|
||||||
|
writer.append_sse_event("token", {"text": "hello"})
|
||||||
|
writer.append_sse_event("done", {"session": {"session_id": "sid-del"}})
|
||||||
|
run_dir = tmp_path / "_run_journal" / "sid-del"
|
||||||
|
assert run_dir.exists()
|
||||||
|
|
||||||
|
assert delete_run_journal("sid-del", session_dir=tmp_path) is True
|
||||||
|
assert not run_dir.exists()
|
||||||
|
# No events recoverable after delete.
|
||||||
|
assert read_run_events("sid-del", "run-1", session_dir=tmp_path)["events"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_run_journal_leaves_other_sessions_intact(tmp_path):
|
||||||
|
RunJournalWriter("sid-keep", "run-k", session_dir=tmp_path).append_sse_event("token", {"text": "k"})
|
||||||
|
RunJournalWriter("sid-del", "run-d", session_dir=tmp_path).append_sse_event("token", {"text": "d"})
|
||||||
|
|
||||||
|
delete_run_journal("sid-del", session_dir=tmp_path)
|
||||||
|
|
||||||
|
assert (tmp_path / "_run_journal" / "sid-keep").exists()
|
||||||
|
assert not (tmp_path / "_run_journal" / "sid-del").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_run_journal_noop_on_missing_or_invalid(tmp_path):
|
||||||
|
assert delete_run_journal("nope", session_dir=tmp_path) is False
|
||||||
|
assert delete_run_journal("../escape", session_dir=tmp_path) is False
|
||||||
|
assert delete_run_journal("", session_dir=tmp_path) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_journals_reject_dot_traversal_ids(tmp_path):
|
||||||
|
"""A bare '.'/'..' passes the dot-permitting id regex but must NOT resolve to
|
||||||
|
the journal root/parent and delete the wrong directory (no '/' to catch it).
|
||||||
|
"""
|
||||||
|
# Seed a real run + turn journal so we'd notice an over-broad delete.
|
||||||
|
writer = RunJournalWriter("keep", "run-1", session_dir=tmp_path)
|
||||||
|
writer.append_sse_event("token", {"text": "hello"})
|
||||||
|
_submit("keep", "hi", tmp_path)
|
||||||
|
run_dir = tmp_path / "_run_journal" / "keep"
|
||||||
|
assert run_dir.exists()
|
||||||
|
for bad in (".", ".."):
|
||||||
|
assert delete_run_journal(bad, session_dir=tmp_path) is False
|
||||||
|
assert delete_turn_journal(bad, session_dir=tmp_path) == 0
|
||||||
|
# The legitimate journals must still be present.
|
||||||
|
assert run_dir.exists()
|
||||||
|
assert read_turn_journal("keep", session_dir=tmp_path)
|
||||||
+51
-3
@@ -338,12 +338,54 @@ def test_boot_js_media_recorder_fallback_posts_to_transcribe_api():
|
|||||||
assert 'fetch(' in js
|
assert 'fetch(' in js
|
||||||
|
|
||||||
|
|
||||||
|
def test_boot_js_prefers_server_side_stt_by_default():
|
||||||
|
"""Default mic capture should prefer server STT only after the server confirms support."""
|
||||||
|
js, _ = get_text("/static/boot.js")
|
||||||
|
assert "const _micForceMediaRecorderStored=localStorage.getItem(_micForceMediaRecorderKey);" in js
|
||||||
|
assert "let _serverSttAvailable=false" in js
|
||||||
|
assert "_micForceMediaRecorderStored===null?(_serverSttAvailable&&_canRecordAudio):" in js
|
||||||
|
assert "fetch('api/transcribe/capability'" in js
|
||||||
|
|
||||||
|
|
||||||
|
def test_boot_js_no_server_stt_first_click_uses_browser_speech_recognition():
|
||||||
|
"""No-server-STT installs must not lose the first mic click before fallback runs."""
|
||||||
|
js, _ = get_text("/static/boot.js")
|
||||||
|
default_idx = js.find("_micForceMediaRecorderStored===null?")
|
||||||
|
assert default_idx != -1
|
||||||
|
default_expr = js[default_idx:default_idx + 140]
|
||||||
|
assert "_serverSttAvailable" in default_expr
|
||||||
|
assert "_canRecordAudio" in default_expr
|
||||||
|
assert "_canRecordAudio:" not in default_expr
|
||||||
|
|
||||||
|
|
||||||
|
def test_boot_js_falls_back_to_browser_stt_when_server_transcribe_unavailable():
|
||||||
|
"""If server-side STT is unavailable, browser SpeechRecognition should be re-enabled."""
|
||||||
|
js, _ = get_text("/static/boot.js")
|
||||||
|
assert "function _isServerSttUnavailable(err)" in js
|
||||||
|
assert "function _allowBrowserSttFallback()" in js
|
||||||
|
assert "localStorage.setItem(_micForceMediaRecorderKey,'0')" in js
|
||||||
|
assert "_forceMediaRecorder=false" in js
|
||||||
|
assert "recognition=_ensureSpeechRecognition()" in js
|
||||||
|
|
||||||
|
|
||||||
|
def test_boot_js_keeps_explicit_server_stt_preference_on_transcribe_failure():
|
||||||
|
"""An explicit mic_force_mediarecorder='1' preference must not be silently overwritten."""
|
||||||
|
js, _ = get_text("/static/boot.js")
|
||||||
|
assert "localStorage.getItem(_micForceMediaRecorderKey)!=='1'" in js
|
||||||
|
|
||||||
|
|
||||||
def test_routes_define_transcribe_endpoint():
|
def test_routes_define_transcribe_endpoint():
|
||||||
"""Server routes must expose /api/transcribe for MediaRecorder fallback uploads."""
|
"""Server routes must expose /api/transcribe for MediaRecorder fallback uploads."""
|
||||||
routes = pathlib.Path(__file__).parent.parent.joinpath("api/routes.py").read_text(encoding="utf-8")
|
routes = pathlib.Path(__file__).parent.parent.joinpath("api/routes.py").read_text(encoding="utf-8")
|
||||||
assert '"/api/transcribe"' in routes
|
assert '"/api/transcribe"' in routes
|
||||||
|
|
||||||
|
|
||||||
|
def test_routes_define_transcribe_capability_endpoint():
|
||||||
|
"""Server routes must expose a cheap STT capability probe before defaulting to MediaRecorder."""
|
||||||
|
routes = pathlib.Path(__file__).parent.parent.joinpath("api/routes.py").read_text(encoding="utf-8")
|
||||||
|
assert '"/api/transcribe/capability"' in routes
|
||||||
|
|
||||||
|
|
||||||
def test_boot_js_shows_mic_button_when_any_voice_path_is_supported():
|
def test_boot_js_shows_mic_button_when_any_voice_path_is_supported():
|
||||||
"""boot.js must reveal btnMic when speech recognition or recorder fallback is available."""
|
"""boot.js must reveal btnMic when speech recognition or recorder fallback is available."""
|
||||||
js, _ = get_text("/static/boot.js")
|
js, _ = get_text("/static/boot.js")
|
||||||
@@ -384,7 +426,9 @@ def test_boot_js_prefix_captured_on_start():
|
|||||||
def test_boot_js_onresult_prepends_prefix():
|
def test_boot_js_onresult_prepends_prefix():
|
||||||
"""onresult must include _prefix when writing to textarea (append, not replace)."""
|
"""onresult must include _prefix when writing to textarea (append, not replace)."""
|
||||||
js, _ = get_text("/static/boot.js")
|
js, _ = get_text("/static/boot.js")
|
||||||
onresult_idx = js.find("recognition.onresult")
|
onresult_idx = js.find("sr.onresult")
|
||||||
|
if onresult_idx == -1:
|
||||||
|
onresult_idx = js.find("recognition.onresult")
|
||||||
onresult_end = js.find("};", onresult_idx)
|
onresult_end = js.find("};", onresult_idx)
|
||||||
onresult_body = js[onresult_idx:onresult_end]
|
onresult_body = js[onresult_idx:onresult_end]
|
||||||
# ta.value must be set to _prefix + something, not just the transcript alone
|
# ta.value must be set to _prefix + something, not just the transcript alone
|
||||||
@@ -394,7 +438,9 @@ def test_boot_js_onresult_prepends_prefix():
|
|||||||
def test_boot_js_onend_commits_with_prefix():
|
def test_boot_js_onend_commits_with_prefix():
|
||||||
"""onend must commit _prefix + _finalText so appended text survives after recognition ends."""
|
"""onend must commit _prefix + _finalText so appended text survives after recognition ends."""
|
||||||
js, _ = get_text("/static/boot.js")
|
js, _ = get_text("/static/boot.js")
|
||||||
onend_idx = js.find("recognition.onend")
|
onend_idx = js.find("sr.onend")
|
||||||
|
if onend_idx == -1:
|
||||||
|
onend_idx = js.find("recognition.onend")
|
||||||
onend_end = js.find("};", onend_idx)
|
onend_end = js.find("};", onend_idx)
|
||||||
onend_body = js[onend_idx:onend_end]
|
onend_body = js[onend_idx:onend_end]
|
||||||
assert "_prefix" in onend_body
|
assert "_prefix" in onend_body
|
||||||
@@ -413,7 +459,9 @@ def test_boot_js_prefix_reset_on_stop():
|
|||||||
def test_boot_js_auto_space_between_prefix_and_transcript():
|
def test_boot_js_auto_space_between_prefix_and_transcript():
|
||||||
"""onend must insert a space between existing text and new transcript when needed."""
|
"""onend must insert a space between existing text and new transcript when needed."""
|
||||||
js, _ = get_text("/static/boot.js")
|
js, _ = get_text("/static/boot.js")
|
||||||
onend_idx = js.find("recognition.onend")
|
onend_idx = js.find("sr.onend")
|
||||||
|
if onend_idx == -1:
|
||||||
|
onend_idx = js.find("recognition.onend")
|
||||||
onend_end = js.find("};", onend_idx)
|
onend_end = js.find("};", onend_idx)
|
||||||
onend_body = js[onend_idx:onend_end]
|
onend_body = js[onend_idx:onend_end]
|
||||||
# Should handle spacing — look for trimStart or endsWith(' ') check
|
# Should handle spacing — look for trimStart or endsWith(' ') check
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
|
||||||
from api.upload import handle_transcribe
|
import api.upload as upload
|
||||||
|
from api.upload import (
|
||||||
|
_stt_provider_capability_from_module,
|
||||||
|
handle_transcribe,
|
||||||
|
handle_transcribe_capability,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _multipart_body(fields=None, files=None, boundary=b"voiceboundary"):
|
def _multipart_body(fields=None, files=None, boundary=b"voiceboundary"):
|
||||||
@@ -49,6 +54,13 @@ class _FakeHandler:
|
|||||||
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_transcription_tools(monkeypatch, fake_mod):
|
||||||
|
monkeypatch.setitem(sys.modules, "tools.transcription_tools", fake_mod)
|
||||||
|
tools_pkg = sys.modules.get("tools")
|
||||||
|
if tools_pkg is not None:
|
||||||
|
monkeypatch.setattr(tools_pkg, "transcription_tools", fake_mod, raising=False)
|
||||||
|
|
||||||
|
|
||||||
def test_handle_transcribe_requires_file_field():
|
def test_handle_transcribe_requires_file_field():
|
||||||
body, content_type = _multipart_body(fields={"note": "missing file"})
|
body, content_type = _multipart_body(fields={"note": "missing file"})
|
||||||
handler = _FakeHandler(body, content_type)
|
handler = _FakeHandler(body, content_type)
|
||||||
@@ -60,7 +72,7 @@ def test_handle_transcribe_requires_file_field():
|
|||||||
def test_handle_transcribe_returns_transcript(monkeypatch):
|
def test_handle_transcribe_returns_transcript(monkeypatch):
|
||||||
fake_mod = types.ModuleType("tools.transcription_tools")
|
fake_mod = types.ModuleType("tools.transcription_tools")
|
||||||
fake_mod.transcribe_audio = lambda path: {"success": True, "transcript": "hello from audio"}
|
fake_mod.transcribe_audio = lambda path: {"success": True, "transcript": "hello from audio"}
|
||||||
monkeypatch.setitem(sys.modules, "tools.transcription_tools", fake_mod)
|
_install_fake_transcription_tools(monkeypatch, fake_mod)
|
||||||
|
|
||||||
body, content_type = _multipart_body(
|
body, content_type = _multipart_body(
|
||||||
files={"file": ("voice.webm", b"RIFFfakeaudio", "audio/webm")}
|
files={"file": ("voice.webm", b"RIFFfakeaudio", "audio/webm")}
|
||||||
@@ -75,7 +87,7 @@ def test_handle_transcribe_returns_transcript(monkeypatch):
|
|||||||
def test_handle_transcribe_surfaces_provider_error(monkeypatch):
|
def test_handle_transcribe_surfaces_provider_error(monkeypatch):
|
||||||
fake_mod = types.ModuleType("tools.transcription_tools")
|
fake_mod = types.ModuleType("tools.transcription_tools")
|
||||||
fake_mod.transcribe_audio = lambda path: {"success": False, "error": "STT not configured"}
|
fake_mod.transcribe_audio = lambda path: {"success": False, "error": "STT not configured"}
|
||||||
monkeypatch.setitem(sys.modules, "tools.transcription_tools", fake_mod)
|
_install_fake_transcription_tools(monkeypatch, fake_mod)
|
||||||
|
|
||||||
body, content_type = _multipart_body(
|
body, content_type = _multipart_body(
|
||||||
files={"file": ("voice.webm", b"RIFFfakeaudio", "audio/webm")}
|
files={"file": ("voice.webm", b"RIFFfakeaudio", "audio/webm")}
|
||||||
@@ -85,3 +97,62 @@ def test_handle_transcribe_surfaces_provider_error(monkeypatch):
|
|||||||
|
|
||||||
assert handler.status == 503
|
assert handler.status == 503
|
||||||
assert handler.payload()["error"] == "STT not configured"
|
assert handler.payload()["error"] == "STT not configured"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_transcribe_capability_reports_unavailable_without_provider(monkeypatch):
|
||||||
|
monkeypatch.setattr(upload, "_stt_provider_capability", lambda: (False, "none"))
|
||||||
|
|
||||||
|
handler = _FakeHandler(b"", "")
|
||||||
|
handle_transcribe_capability(handler)
|
||||||
|
|
||||||
|
assert handler.status == 200
|
||||||
|
assert handler.payload()["ok"] is True
|
||||||
|
assert handler.payload()["available"] is False
|
||||||
|
|
||||||
|
def test_handle_transcribe_capability_reports_available_provider(monkeypatch):
|
||||||
|
monkeypatch.setattr(upload, "_stt_provider_capability", lambda: (True, "openai"))
|
||||||
|
|
||||||
|
handler = _FakeHandler(b"", "")
|
||||||
|
handle_transcribe_capability(handler)
|
||||||
|
|
||||||
|
assert handler.status == 200
|
||||||
|
assert handler.payload()["ok"] is True
|
||||||
|
assert handler.payload()["available"] is True
|
||||||
|
assert handler.payload()["provider"] == "openai"
|
||||||
|
|
||||||
|
def test_handle_transcribe_capability_requires_ffmpeg_for_local_command_webm(monkeypatch):
|
||||||
|
fake_mod = types.ModuleType("tools.transcription_tools")
|
||||||
|
fake_mod.__dict__.update(
|
||||||
|
{
|
||||||
|
"_load_stt_config": lambda: {"provider": "local_command"},
|
||||||
|
"is_stt_enabled": lambda cfg=None: True,
|
||||||
|
"_HAS_FASTER_WHISPER": False,
|
||||||
|
"_HAS_OPENAI": False,
|
||||||
|
"_HAS_MISTRAL": False,
|
||||||
|
"_has_local_command": lambda: True,
|
||||||
|
"_find_ffmpeg_binary": lambda: None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
available, provider = _stt_provider_capability_from_module(fake_mod)
|
||||||
|
|
||||||
|
assert available is False
|
||||||
|
assert provider == "local_command"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_transcribe_capability_reports_actual_local_command_fallback(monkeypatch):
|
||||||
|
fake_mod = types.ModuleType("tools.transcription_tools")
|
||||||
|
fake_mod.__dict__.update(
|
||||||
|
{
|
||||||
|
"_load_stt_config": lambda: {"provider": "local"},
|
||||||
|
"is_stt_enabled": lambda cfg=None: True,
|
||||||
|
"_HAS_FASTER_WHISPER": False,
|
||||||
|
"_HAS_OPENAI": False,
|
||||||
|
"_HAS_MISTRAL": False,
|
||||||
|
"_has_local_command": lambda: True,
|
||||||
|
"_find_ffmpeg_binary": lambda: "/usr/bin/ffmpeg",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
available, provider = _stt_provider_capability_from_module(fake_mod)
|
||||||
|
|
||||||
|
assert available is True
|
||||||
|
assert provider == "local_command"
|
||||||
Reference in new issue
Block a user