mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
* fix(desktop): refresh repo status on session switch with unchanged cwd (#68208) fix(desktop): refresh repo status on session switch with unchanged cwd * fix(checkpoints): honor gateway config and task cwd (#68195) * fix(gateway): wire checkpoint config into agents * fix(checkpoints): resolve gateway file paths by task cwd * ci: live-updating PR review comment with structured job statuses Replace the static comment-pending + comment-results two-job pattern with a live-updating comment system that polls the GitHub Actions API every 15s, re-assembles the review comment from whatever results are available, and upserts it via the <!-- hermes-ci-review-bot --> marker. The comment updates in real time as each job finishes — no waiting for the full pipeline. Every CI job that wants to appear in the review comment emits a review_status output — a JSON array of objects, each with a source and a results array: [ { "source": "review-label-gate", "results": [ {"kind": "action_required", "title": "...", "summary": "...", "how_to_fix": "..."}, {"kind": "info", "title": "...", "summary": "..."} ] }, { "source": "ci timing", "results": [ {"kind": "warning", "title": "CI timings", "summary": "...", "detail": "...", "link": "..."} ] } ] One job can emit multiple results of different kinds. The source field is used to exclude the corresponding job from the synthesized error list (case-insensitive, hyphen-normalized matching against GitHub Actions job display names). | job | source | kind (on failure) | section | |----------------------------|--------------------------|---------------------------|----------------------| | review-labels | review label gate | action_required / info | Action required | | lockfile-diff | lockfile-diff | action_required | Action required | | ci-timings | ci timing | warning / info | Warnings | | supply-chain scan | supply chain | error / (none) | Job failures | | supply-chain dep-bounds | supply chain | action_required / (none) | Action required | | osv-scanner | osv scan | warning / (none) | Warnings | | uv-lockfile-check | uv.lock check | action_required / (none) | Action required | | history-check | unrelated histories | action_required | Action required | | contributor-check | contributor attribution | action_required | Action required | Jobs that find nothing emit [] (empty array) — no noise info items. A single comment-live job polls the GitHub Actions API every 15s, classifies jobs into (completed, pending), assembles the comment, and upserts it. Merges review_status outputs from all needs jobs via toJSON(needs.*.outputs.review_status), and downloads the ci-timings artifact when it becomes available. Shows commit SHA + message below the header. The assembler has ZERO job-specific knowledge. It just: 1. collect_from_statuses() — flattens all nested status objects into ReviewItems 2. collect_failed_jobs() — synthesizes errors for failed jobs with no declared status 3. _attach_job_urls() — fills in per-job log links for ALL items 4. render_comment() — groups by severity, renders with group headers Each item shows links inline next to the title: View report (job-emitted URL) and View job (auto-attached logs link). Each info item is its own collapsible <details> block. # ૮ >ﻌ< ა ci review running on abc1234 — commit message first line ## ❌ Job failures ### {title} · [View job](url) {summary} ## ⚠️ Action required ### {title} · [View job](url) {summary} **How to fix:** {how_to_fix} ## ⚠️ Warnings ### {title} · [View report](url) · [View job](url) {summary} {detail} <details><summary>{title}</summary> {content} </details> Still running 3 jobs: ci-timings, docker - test_assemble_review_comment.py (48 tests): collect_from_statuses, collect_failed_jobs with exclude_sources, _attach_job_urls, render_comment (group headers, inline links, commit info, per-item details, pending footer), assemble integration - test_live_comment.py (16 tests): classify_jobs pure function - test_timings_report.py (10 tests): generate_review_status nested format - test_lockfile_diff.py (6 tests) - test_classify_changes.py (32 tests, pre-existing) * ci: migrate AUTOFIX_BOT_PAT to GitHub App token Replace the long-lived fine-grained PAT (AUTOFIX_BOT_PAT) with short-lived (1-hour) installation access tokens minted via a new get-app-token composite action wrapping actions/create-github-app-token@v3.2.0. The PAT was used in 13 spots across 8 workflow files for gh CLI / GitHub API calls. The per-repo GITHUB_TOKEN (1,000 req/hr) was getting rate-limited when multiple workflows fire concurrently (deploy-site, skills-index, ci-timings, supply-chain-audit, js-autofix). App installation tokens get 5,000 req/hr per installation and are scoped to the App's permissions, not a user account. New composite action: .github/actions/get-app-token/ - Wraps actions/create-github-app-token@bcd2ba49 (v3.2.0, SHA-pinned) - Reads APP_ID + APP_PRIVATE_KEY repo secrets - Outputs a 1hr installation token via steps.app-token.outputs.token Requires two new repo secrets (set after creating the GitHub App): - APP_ID: the App's numeric ID - APP_PRIVATE_KEY: the PEM private key App installation permissions needed: contents: write (js-autofix push, pypi release upload) pull-requests: write (js-autofix PR create/merge, supply-chain comment) issues: write (skills-index-freshness issue creation) actions: write (skills-index workflow trigger) workflows: write (skills-index triggers deploy-site.yml) The AUTOFIX_BOT_PAT secret can be deleted once CI passes on this PR. The comment in js-autofix.yml noting that PAT pushes trigger downstream workflows is updated — App tokens have the same property (they are not GITHUB_TOKEN), so the concurrency-cancel loop logic is unchanged. * style(desktop): satisfy merged eslint/prettier config The SSH modules predate the stricter lint config that landed on main (curly, no-empty, perfectionist sorting, prettier). Mechanical lint:fix + fmt pass, empty catch blocks filled with the codebase's void-0 convention, and inline no-control-regex disables on the three deliberate control-char patterns (same pattern as lib/ansi.ts). * fix(ci): pass App secrets as inputs to composite action Composite actions cannot access the secrets context — the runner's template engine rejects secrets.* references at load time with 'Unrecognized named-value: secrets'. Move APP_ID and APP_PRIVATE_KEY from direct secrets.* references inside the composite action to inputs passed by each calling workflow. The fallback logic (GITHUB_TOKEN when APP_ID is empty, for fork PRs) stays in the composite action's check step. * fix(ci): add detect to all-checks-pass needs so its failure blocks merge If detect fails, all downstream sub-workflows get SKIPPED (they have needs: detect). all-checks-pass used if: always() and only checked the sub-workflows — which all showed as 'skipped' (= success) — so it passed even though the root cause (detect) failed. This made the PR mergeable despite a broken CI pipeline. Add detect to all-checks-pass needs so its failure propagates to the gate job and blocks the merge. * fix(desktop): bump skills test timeout to fix cold-start flake (#68235) Test 1 in skills/index.test.tsx pays the full cold-start cost (jsdom env init + module transform + the @/hermes/@/store/profile import graph), which pushed past vitest's 5000ms default under load — caught at 8871ms on one run, 6.6s pure test time on another. Tests 2-4 are ~30-130ms each because all that setup is already cached, so only test 1 was at risk of timing out. Bump the describe-level timeout to 15s. Verified with 10 consecutive runs, 4 of which took 5.5-6.6s of test time and would have hard-failed under the old 5s default. * feat(desktop): open multiple full app windows (electron) Add createInstanceWindow() — a full-chrome peer of the primary that renders the complete app (sidebar, routing, its own draft) against the shared backend, so several GUI windows can run at once. Mirrors the primary's window options + chatWindowWebPreferences (backgroundThrottling stays off so a streamed answer never stalls when blurred) but never overwrites the mainWindow global and doesn't respawn the backend — the renderer's getConnection() joins the running one. New windows cascade off their source via the pure, tested instanceWindowBounds(). Exposed via the hermes🪟openInstance IPC and a "New Window" File menu item. Per-window fullscreen state now targets the window itself, and titlebar/native-theme repaints reach every open chat window instead of only the primary. Retires the now-orphaned compact new-session pop-out (its only caller was ⌘⇧N, repointed in the follow-up commit): drops createNewSessionWindow, the hermes🪟openNewSession handler, and the newSession/new=1 URL flag. * feat(desktop): wire New Window to ⌘⇧N + command palette Repoint session.newWindow (⌘⇧N) from the compact new-session pop-out to openNewWindow(), which opens a full peer instance via the new openWindow bridge, and add a "New Window" entry to the ⌘K palette (shown with its hotkey hint, gated on canOpenNewWindow()). Relabel the action "New window". Drops the retired openNewSessionWindow bridge and the vestigial isNewSessionWindow()/new=1 flag; renames the shared opener helper. * fix(desktop): de-dupe cross-window cues so peers don't spam With multiple full windows, each renderer independently reacts to the same backend event, so one-shot cues fired N times: OS notifications (the per-renderer throttle can't see other windows), the turn-end sound (playCompletionSound runs on every message.complete, ungated by focus), and auto-spoken replies (double voice when a chat is open in two windows). Add a single race-free owner in the main process (electron/event-dedupe.ts): main handles IPC serially, so the first window to claim a key within a short window wins and peers stay quiet. Notifications collapse at the hermes:notify choke point; the sound and spoken replies claim via a new hermes:ambient:claim IPC (keyed by session / reply id). Off Electron the claim falls back to "emit", preserving single-window behavior. The sound's mute check runs before the claim so a muted window can't win the cue and silence an audible peer. * refactor(desktop): tidy the cross-window deduper Drop the unused DEDUPE_WINDOW_MS export and rename its interval so "window" isn't overloaded against BrowserWindow in a multi-window feature (windowMs → intervalMs). DRY the completion-sound play path. No behavior change. * nix: add cage to devDeps * fix(desktop): avoid false remote gateway reauthentication (#68250) * fix(desktop): avoid false remote gateway reauthentication Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com> Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai> * fix(desktop): harden remote revalidation state --------- Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com> Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai> * fix(desktop): keep composer draft across compression tip rotation (#68079) * fix(desktop): keep composer draft across compression tip rotation Auto-compression swaps the live stored session id while the user may still be typing. Scope the composer/queue key on the lineage root and migrate any tip-keyed draft/queue entries onto that durable key when the tip rotates so the in-progress prompt does not vanish when the response lands. * test(desktop): cover draft survival across compression tip rotation Add regression coverage for migrateSessionDraft, lineage-scoped composer keys, and the rotation path that previously wiped an in-progress draft. * fmt(js): `npm run fix` on merge (#68305) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(desktop): Stop parks the queue instead of firing the next queued prompt Interrupting a busy turn with the Stop button (or Esc) settles the session to idle, and the edge-independent auto-drain immediately submits the head of the composer queue. The user pressed Stop to halt the agent, but it looks like Stop skipped the current turn and kept going — and the queued text is hard to find, since its only surface is the collapsed 'N queued' pill above the composer. The old userInterruptedRef latch (a23728dcc) fixed this but was removed in #40221 because it also suppressed the drain that send-now-while-busy depends on. This reintroduces the halt with source awareness instead of a blanket latch: - Explicit halts (Stop button, composer Esc, chat-focus Esc, the streaming message's hover Stop, runtime cancel) park the session's queue before interrupting. Parked queues are skipped by both auto-drain paths (mounted ChatBar + background drainer). - Interrupts that exist to advance the queue (send-now-while-busy) unpark first, so the settle drain they rely on still flows. - The park lifts on any renewed intent: resume, a manual drain (Enter on empty composer or the per-row send arrow), queueing a new prompt, or emptying the queue. It migrates with entries on a runtime re-key and is deliberately not persisted (a fresh process starts unparked). - The queue panel expands on park, switches to 'N Queued — paused' with a pause icon, and grows a Resume action, so the held prompts are visible instead of reading as vanished. Store contract, hook wiring, and background-drain coverage included; docs updated. * fix(cli,tui): recall real paste content on up-arrow Large pastes collapse to a placeholder in the composer, but input history stored the placeholder — so up-arrow recall showed a truncated reference (CLI) or lost the content entirely (TUI, where the `[[…]]` label has no backing snip after submit). Store the expanded content in history instead: - CLI: `_inline_pastes()` expands `[Pasted text #N -> file]` into the buffer before `reset(append_to_history=True)`; also reused by the external editor (dedup). History nav suppresses re-collapse of recalled content. - TUI: `dispatchSubmission` pushes `expandSnips(pasteSnips)(full)`; idempotent on label-free text so re-submitting a recalled entry stays stable. * fix(cli): suppress CPR on POSIX local TTYs under load Delayed ESC[6n replies leak as ^[[row;colR into the classic CLI on SSH/slow PTYs (#13870) and on local POSIX TTYs under heavy subagent load. Suppress CPR on non-Windows platforms (layout hint only); keep native Windows on prompt_toolkit's default pending native coverage. Wire selection through _select_classic_cli_pt_output. * test(cli): prove local CPR leak and Application CPR-disabled wiring Add a delayed-CPR PTY harness (no SSH) plus selection/Application assertions for POSIX local and Windows preserve-default. Update the gating unit test to the new contract. * refactor: drop platform kwarg, fix PTY test cleanup - Remove redundant platform= test seam from _terminal_may_leak_cpr(); use monkeypatch.setattr(sys, 'platform', ...) consistently in both test files. - Wrap PTY tests in try/finally for fd cleanup on assertion failure. - Guard select.select() in terminal thread against OSError after fd close (fixes PytestUnhandledThreadExceptionWarning). - Trim PR-number reference from test module docstring. * docs(portal): remove retired Nous Chat references * fix(web/ddgs): isolate DuckDuckGo search in a disposable process ThreadPoolExecutor timeouts cannot fire when primp holds the GIL in native code (#68096). Run each search in a child process the parent can terminate/kill, and honor tools.interrupt between polls. * test(web/ddgs): cover GIL-hold timeout, interrupt, and worker reap Regression tests for #68096: native GIL-hold and sleep hooks must time out or interrupt promptly with no orphaned search workers. * fix: sanitize subprocess env for DDGS worker os.environ.copy() passes all Hermes secrets (gateway tokens, API keys, dashboard session tokens) into the DDGS child process. Use _sanitize_subprocess_env() to strip Hermes-managed secrets before spawning the worker. * fix(agent): pass persisted-prefix boundary when rotation flushes on cold resume (#68196) The legacy rotation branch in agent/conversation_compression.py flushes the current turn to the OLD session before ending it (#47202) via _flush_messages_to_session_db(messages) with no conversation_history boundary. On the first turn after a cold Desktop resume, the restored transcript rows live in the message list as plain dicts that have not yet been stamped with _DB_PERSISTED_MARKER — the normal turn flush that stamps them runs after preflight compression. With no boundary, _flush_messages_to_session_db builds an empty history_ids set and treats every restored row as new, durably re-appending the whole transcript to the parent session. Repeated restart/resume + threshold compression keeps growing the parent transcript. Pass messages[:_persist_user_message_idx] (the already-durable prefix that turn_context anchors before preflight runs, guarded for int/bounds) as conversation_history so the flush skips the persisted rows by identity and writes only the current turn's new messages. Adds a regression test that pre-populates SQLite, cold-loads the transcript, appends one current user row, and forces rotating compression: it fails before this change (parent grows to 5 rows) and passes after (parent holds the two originals plus the single new turn). * fix(desktop): prevent contentEditable composer input from visually collapsing to near-zero height Fix #68095 The composer input box (contentEditable div) randomly shrank to a tiny/pixelated size when typing character-by-character (paste worked fine). Root cause: during per-keystroke input, the normalizeComposerEditorDom cleanup could briefly leave the contentEditable with zero child nodes, and without intrinsic content the browser collapsed it visually despite the CSS min-height. Two-pronged fix: 1. Add min-h-[1.625rem] bracket syntax alongside the CSS variable min-height to ensure the minimum height is enforced even if the CSS variable resolution is delayed or overridden by browser defaults. 2. In normalizeComposerEditorDom, ensure the contentEditable always has at least one <br> child when empty, giving it intrinsic height that the browser cannot collapse. This is a belt-and-suspenders approach with the CSS min-height. Closes #68095 * fix(agent): circuit-break AttributeError from commit-splice and detect code skew Fix #68178 The git-install auto-updater rewrites source while the desktop backend is live. Because agent/conversation_loop.py is imported lazily on the first API call, a process can end up running two different commits spliced together — one commit's AIAgent against another commit's conversation_loop. When the interface differs, every turn fails permanently with an AttributeError, and the loop retries indefinitely, burning provider API calls (576 failures, 149 wasted API calls observed). Three-prong fix: 1. Circuit-break AttributeError on agent objects: the outer-loop error classifier now detects AttributeError targeting agent/run_agent modules and breaks immediately instead of continuing the retry loop. 2. Code skew detection for desktop/serve backend: run_agent.py now snapshots the checkout revision at import time and exposes a cheap per-iteration check that the conversation loop uses to refuse new work with a clear 'restart required' message before the lazy import can crash. 3. Informative error message: when code skew is detected, the user gets a clear explanation of the mismatch (boot revision vs current revision) and actionable guidance to restart the application. * fix(telegram): preserve fatal recovery handoff Release the current polling-recovery task's ownership before invoking the fatal-error handler. The runner bounds adapter cleanup in a child task; disconnect() cancels the tracked polling-recovery task, so retaining the current notifier in _polling_error_task would cancel the fatal callback before the runner can finish its reconnect-queue or shutdown decision. The new _handoff_polling_fatal_error() helper clears _polling_error_task only when it is the current notifier. Other recovery tasks remain tracked and are still cancelled and awaited during teardown. Covers both network retry exhaustion and polling-conflict exhaustion. Replaces the misleading "Restarting gateway" message with "Escalating to gateway recovery". Fixes #68406. * fix(telegram): widen fatal handoff to heartbeat watchdog path The wedged-recovery heartbeat watchdog (line 2526) calls _notify_fatal_error() directly from the heartbeat task. disconnect() cancels _polling_heartbeat_task unconditionally (no current_task guard, unlike _polling_error_task). Same bug class as #68406: the child disconnect cancels the heartbeat parent before the runner can queue reconnect. Widen _handoff_polling_fatal_error() to also clear _polling_heartbeat_task when it is the current task, and route the heartbeat watchdog call site through the handoff helper. Co-authored-by: Imgaojp <6065749+Imgaojp@users.noreply.github.com> * fix(tests): make the live-system-guard canary fail closed tests/test_live_system_guard_self_test.py executes real kill primitives (os.kill(-1, SIGTERM), os.killpg, pkill -f python) and depends entirely on the autouse _live_system_guard fixture in tests/conftest.py to intercept them. That makes the canary fail-OPEN: in any collection context where the file is present but its home conftest is not — a published sdist that ships tests/ but not tests/conftest.py, a tree assembled by copying test*.py (that glob does not match conftest.py), pytest --noconftest, or a foreign rootdir — the primitives fire for real, and os.kill(-1, SIGTERM) SIGTERMs every process the invoking user owns (a full desktop-session kill was reported in the field). Add an autouse fixture that refuses to run any canary test unless the guard is provably active. The one thing the canary can detect about its own safety is that the guard monkeypatches os.kill with a plain Python function, whereas the unguarded primitive is a C builtin — so the probe keys off that. Tests marked @pytest.mark.live_system_guard_bypass still opt out, matching the guard's own bypass contract (e.g. test_bypass_marker_disables_guard). With the guard loaded every canary test behaves exactly as before; without it each test refuses at setup with zero side effects. Fixes #68311 * fix(billing): rename user-facing "terminal billing" copy to Remote Spending (#68355) * fix(billing): rename user-facing "terminal billing" copy to Remote Spending The capability was renamed Remote Spending on the portal (consent CTA: "Allow Remote Spending"; per-terminal states Granted/Stopped), but the terminal, desktop, and docs still said "terminal billing" everywhere. - Feature name: Remote Spending in titles/labels, lowercase mid-sentence. - Step-up action verb is now "allow", matching the portal consent CTA. - Kill-switch-off recovery copy points at the actual control ("a billing admin can turn it on from the portal's Hermes Agent page") instead of the dead-end "manage it on the portal". - Per-terminal revoke copy uses the portal vocabulary ("stopped"). - Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are unchanged; copy, comments, docs, and test expectations only. * fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename Adversarial review findings: (1) a repeated insufficient_scope after a successful step-up is a per-terminal authorization failure, but the copy blamed the org kill-switch and pointed at the wrong recovery control — now: "Remote Spending still isn't active for this terminal — the authorization didn't take. Retry, or make this change on the portal." (2) the desktop step-up flow started in Remote Spending vocabulary but finished in "billing management access" — renamed both end states. (3) prettier formatting on the touched files (matches the post-merge fmt bot). * feat(tui): show the plan catalog in /subscription on Free (#68357) * feat(tui): show the plan catalog in /subscription on Free The server returns the tier list even with no subscription, but the overlay hid the picker behind can_change_plan && !isFree, so a Free account got only "Start a subscription" with no idea what the plans cost. Now: - Overview on Free offers "Choose a plan" whenever the catalog has enabled paid tiers. - The picker on Free lists each plan as name · price · monthly credits (no upgrade/downgrade hints — there is nothing to move from), and picking one opens the portal, where starting a subscription actually happens (card capture + checkout live there; the upgrade RPC requires an existing subscription). - Paid-plan behavior (preview → confirm → apply) is unchanged. * refactor(tui): compute the picker row suffix once Review feedback: the isFree fork duplicated the label template and run handler; only the suffix differs. * fix(tui): arm the busy guard before the Free portal handoff Adversarial review: the Free branch returned before setting busyRef, so a double-Enter could open the portal twice; and the picker narrated a handoff that openManageLink already narrates (duplicate on success, contradictory on failure). Guard first, let the helper do the talking. * fix(tui): monthly credits are dollars — label them as such The Free picker showed "1000 credits/mo" for what is $1,000 of monthly credit — render "$1,000 credits/mo" (grouped, dollar-signed). * feat(tui): render the Free-plan catalog inline in the /subscription overview Sid ruling: the upsell belongs where the user already is — no intermediate "Choose a plan" hop. On Free the overview lists each paid plan (name · $/mo · $credits/mo) as a pickable row; picking opens the portal (openManageLink narrates). The generic "Start a subscription" row survives only when the catalog is empty. The picker reverts to its original change-only form (Free never reaches it). * feat(desktop): tier catalog chips on the Subscription row Desktop parity with the TUI inline catalog (Sid ruling): accounts that can act see the plans where they already are — Free gets the upsell list (every chip opens the portal), a subscriber sees all tiers with the current one marked inert. Members and team contexts see no chips. Chips learn an optional url (portal handoff) in the shared row model. * chore(tui): fixture harness mirrors the live tier catalog The dev screenshot fixtures showed invented plans ($50 Super / $99 Ultra, "1,000 credits"); align with the real catalog ($20/$100/$200 with $22/$110/$220 monthly credits) so fixture renders cannot be mistaken for product truth. The overlay itself always reads tiers from the subscription API. * chore: trim narration comments * fmt(js): `npm run fix` on merge (#68462) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(relay): attach metadata.user_id on guild replies for egress fallback (#68320) The relay adapter re-attaches an egress discriminator on outbound replies so the connector can resolve the owning tenant. It captured scope_id for scoped (guild) messages and user_id for DMs, but as MUTUALLY EXCLUSIVE: a scoped inbound hit an early return, so the author's user_id was never recorded, and _with_scope only attached user_id when there was no scope_id. Guild replies therefore went out with scope_id only. That's fine while the guild has a provision-time route row. But a MANAGED Discord agent joins guilds dynamically (the shared bot is added to / removed from servers at runtime), and GATEWAY_RELAY_ROUTE_KEYS — the only thing that writes guild route rows — is a self-hosted, static field never stamped for managed agents. So their guild has no route row, the connector's guild-route lookup misses, and with no user_id on the frame there's nothing to fall back to → every guild reply is declined "discord egress declined: target not routed to an onboarded tenant" even though INBOUND resolved the same guild fine (via the author-first SharedSocketRouter.targets() fallback). Fix: capture the authentic author user_id for EVERY inbound (DM and scoped alike) and re-attach it on the outbound reply alongside scope_id. The connector consults it only on a route/scope miss, so carrying both never overrides routing-table resolution. This is the gateway half of the paired gateway-gateway change (makeDiscordTenantOf guild-route-miss author-binding fallback); together they make guild replies resolve the same observed-author way inbound already does. Tests (tests/gateway/relay/test_relay_adapter.py): a guild reply now carries both scope_id AND user_id; a scoped inbound with no author still yields scope_id only (never invents one). Verified fail-without / pass-with. * build: declare pywin32 as a direct win32 dependency hermes_cli/windows_ssh_runtime.py imports win32security/win32file/etc. directly but pywin32 only arrived transitively via concurrent-log-handler -> portalocker. Declare it with a sys_platform gate so the Windows SSH runtime doesn't depend on the logging dep chain. Review follow-up on PR #68130. * fix(desktop): preserve dragging with empty titlebar slots * Revert "fix(agent): circuit-break AttributeError from commit-splice and detect code skew" This reverts commit3a9b9d65d5. * fix(context): revalidate Codex OAuth context windows * test(context): document Codex cache persistence coverage * fix(context): scope Codex catalogue cache by credential * test(context): cover Codex context rollback * fix(compression): report live-resolved Codex window in the autoraise notice The autoraise banner hardcoded '272K' for the gpt-5.4/5.5/5.6 family, but the Codex /models catalog is authoritative and shifts server-side (gpt-5.6 served 372K during July 9-18, 2026 before OpenAI rolled it back). Pass the compressor's live-resolved context_length through so the notice reports the window the session actually got; the static 272K/128K text remains as the fallback when no resolved value is available. * fix(codex): send ChatGPT-Account-Id on /models probes The Codex backend returns the per-account model catalog only when the ChatGPT-Account-Id header is present. Without it, GET /backend-api/codex/models responds 200 OK with {"models":[]} and the picker silently degrades to the hardcoded fallback list — which is stale or wrong for the active plan (no GPT-5.6 family, wrong context windows). This was the upstream bug behind slow first responses and HTTP 520/120s SSE hangs: Hermes was sending invalid slugs because the probe never saw them in the catalog, and Codex's request builder also depends on the same JWT claim that's now being threaded through both probe paths. Fixes the probe-side paths in hermes_cli/codex_models.py and agent/model_metadata.py by extracting chatgpt_account_id from the OAuth JWT (mirroring the request-side logic already in auxiliary_client.py) and sending it as a header. Verified live: - _fetch_models_from_api now returns the 10-model catalog (gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex-spark, 3x -pro variants) instead of []. - _fetch_codex_oauth_context_lengths resolves all 8 account models to 272K context (matches direct API probes of the same account). - end-to-end: hermes chat -m gpt-5.6-sol -q 'Reply with one word: pong' returns 'pong' cleanly via the openai-codex route. Same class of bug as PR #64760. * test(codex): cover ChatGPT-Account-Id header on /models probe Add regression tests locking in the new behavior: a JWT carrying a chatgpt_account_id claim causes the probe to send ChatGPT-Account-Id, while a malformed token omits the header instead of crashing. * fix(tools): make the tool-search context gate provider-aware (#68589) _resolve_active_context_length() called get_model_context_length() with the model id alone, so provider-enforced windows (e.g. Codex OAuth's 272K for gpt-5.x vs the direct API's 1.05M) never reached the tool-search activation gate — it sized against generic metadata for the same slug. Resolve the runtime provider for the configured model and pass provider, base_url, and api_key through. If credential resolution fails (offline, no keys), degrade to a provider+base_url-only lookup so the static provider-aware fallbacks still apply; explicit model.context_length keeps short-circuiting as before (#46620). Gap flagged during review of #16735. * feat(skills): bundle docx, xlsx, and pdf office skills; refresh powerpoint (#68595) Non-technical users asking for Word docs, spreadsheets, or PDF work had no bundled skill coverage — docx/xlsx creation required discovering and installing hub skills, and PDF manipulation had no skill at all beyond OCR extraction and nano-pdf edits. - skills/productivity/docx: create (docx-js), edit (unzip -> XML -> zip), tracked changes, comments, validation. Adapted from anthropics/skills. - skills/productivity/xlsx: openpyxl creation/editing, mandatory LibreOffice recalc gate, formula-compatibility rules, financial-model conventions. Points at optional excel-author for finance-grade work. - skills/productivity/pdf: merge/split/rotate/watermark/encrypt, form filling (AcroForm + flat overlay scripts), text/table extraction, reportlab creation, forms.md + reference.md companions. - skills/productivity/powerpoint: synced to current upstream pptx skill — richer pptxgenjs corruption footguns, template workflow, validate.py + validators + thumbnail.py, font-substitution QA guidance; drops the stale pack.py/editing.md/pptxgenjs.md workflow files. - Cross-linked ocr-and-documents, nano-pdf, excel-author via related_skills so each office skill routes to its siblings. - deliverable-mode docs mention the new skills; regenerated per-skill docs pages, catalogs, and sidebar. - tests/skills/test_office_document_skills.py: frontmatter contracts, referenced-script existence, schema-map integrity, cross-link resolution, script compilation. E2E validated: docx create->render->edit->validate, xlsx recalc (SUM + _xlfn.TEXTJOIN evaluate correctly), pdf create->merge->extract, pptx generate->validate->thumbnail. * fix(approval): raise gateway approval timeout to 300s, honest stale-tap UX, offer Always on mixed prompts (#68597) Three related messaging-approval fixes: 1. approvals.timeout default 60 -> 300. PR #63501 collapsed the gateway wait onto the canonical approvals.timeout (previously gateway_timeout=300), silently shrinking messaging approval windows to 60s. Push-notification approvals routinely arrive later than a minute; taps landed after the wait had already failed closed. 2. Stale-tap honesty: adapters resolved the approval AFTER rendering '<checkmark> Approved by <user>' (Telegram/Discord/Slack), or ignored a zero resolve count (WhatsApp Cloud/Feishu). A tap on an expired prompt claimed approval while the command had already been denied. All button paths now resolve first and render 'Approval expired - command was not run' when nothing was waiting. 3. Mixed-warning prompts (dangerous pattern + tirith finding) now offer Always: the persistence layer already permanently allowlists the pattern key and downgrades the tirith key to session scope, but the UI hid Always whenever ANY tirith warning was present. Pure-tirith prompts still withhold Always (content findings are session-max by design), and Smart-DENY overrides remain once-only. * feat(secrets): one-command token rotation + actionable startup errors for all secret sources (#68605) * feat(secrets): one-command token rotation + actionable startup errors for all secret sources When a Bitwarden machine-account token expired, users saw a raw Rust error dump (invalid_client + Location: + backtrace hints) and the only fix was manually editing .env or re-running the whole setup wizard. - New `hermes secrets bitwarden token` / `hermes secrets onepassword token`: paste a new token (masked prompt or flag), the command probes the backend BEFORE persisting — a rejected token changes nothing; a good one is written to .env and the fetch caches are cleared. - New optional SecretSource.remediation(kind, cfg) hook: startup warnings now print a '→ Run `hermes secrets <name> token`…' fix-it line after any fetch error, for bundled AND plugin sources (generic per-ErrorKind defaults in the ABC). - bws stderr is summarized to its cause line (Location:/backtrace noise dropped) and invalid_client/invalid_grant/400 identity rejects are now classified AUTH_FAILED (was INTERNAL) with a plain-English explanation naming the token env var. - op whoami probe accepts a candidate token so rotation validates the NEW credential, not the ambient one. Additive hook with defaults — no SECRET_SOURCE_API_VERSION bump. * docs: fix MDX parse error in secret-source-plugin hook table Escaped backticks around a <name> placeholder made MDX parse it as an unclosed JSX tag, breaking the docs-site build. Use a plain code span instead. * feat(desktop): configure repository discovery (supersedes #67630) (#68642) * feat(desktop): configure repository discovery * fix(config): preserve additive default migration * fix(desktop): stabilize session-actions-menu gateway mock for repo-scan subscribe projects.ts now runs $gateway.subscribe(syncReposScanning) at module load, and nanostores fires the subscriber synchronously. session-actions-menu.test.ts reaches projects.ts transitively via the session store but mocked @/store/gateway without $gateway, crashing the whole desktop vitest suite ("No \ export is defined"). Simply adding $gateway: atom(null) exposed a second issue: the synchronous subscriber calls the mock's activeGateway() during the transitive import, before the module-level const initializes (TDZ). Hoist the mock fns via vi.hoisted() so activeGateway is defined before the hoisted vi.mock factory runs, and add $gateway: atom(null) to the mock. Mirrors the self-contained mock pattern already used in projects.test.ts. Also maps the PR author's commit email for attribution. Supersedes #67630; incorporates review feedback from that PR. Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com> --------- Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com> Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com> * fix(desktop): ⌘W closes visible file tab when preview selection is stale (#68639) * fix(desktop): make ⌘W close visible file tab on stale preview selection When the live preview target is gone but $rightRailActiveTabId still points at preview, file tabs remain on screen while ⌘W fell through to a workspace no-op. Close the visible file tab instead. * test(desktop): cover ⌘W close for file tabs and ghost preview selection Lock the happy path and the stale-preview regression so ⌘W keeps closing the file tab the rail is actually showing. * fmt(js): `npm run fix` on merge (#68681) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(billing): plan chips and rows deep-link their tier (#68666) * fix(desktop): drop the decorative top-up credits bar (#68649) The bar rendered full-or-empty (value 1|0) because top-ups have no denominator — the wire carries only the current balance and the pool is open-ended, so a fill fraction is fiction. Show the amount alone; subscription credits and the monthly cap keep their bars (real denominators). * fix(ci): route critical supply-chain findings through review gate (#68833) Let the scanner report critical findings without failing. The review-label gate owns the action-required status and blocking result, allowing the ci-reviewed label rerun to clear both CI and the PR comment. * fix: `tool_calls` double-encoding on import (#68856) * nix: add `cage` to devShell * test(desktop): add pre-filled sessions support Exports createSandbox, writeMockProviderConfig, writeEnvFile, buildAppEnv, findElectron, and launchDesktop from fixtures.ts so specs can compose their own seeded-backend fixtures without duplicating the sandbox/config/launch logic. * test(desktop): auto-fail e2e tests on error banner Adds a shared test fixture (e2e/test.ts) that wraps @playwright/test's page with an error-banner guard. When any [role="alert"] element (error notification toast) appears in the DOM during a test, the test fails with the error message text. The guard uses: - A MutationObserver (injected via addInitScript) that watches for [role="alert"] elements appearing at any point during the test - A final DOM scan in afterEach for alerts still visible at teardown - Deduplication so the same error text only fires once All existing e2e specs updated to import { test, expect } from './test' instead of '@playwright/test'. No per-spec setup needed — the guard is auto-installed on every page via the extended fixture. This catches issues like the "resume failed" error banner that can appear during session loading — previously the test would pass while an error toast was silently visible on screen. * fix(state): parse tool_calls JSON string before re-serializing _insert_message_rows and append_message both do json.dumps(tool_calls) to serialize the field for SQLite storage. But when tool_calls arrives as a JSON string (from import_sessions / export_session, which store it as TEXT), json.dumps double-encodes it — wrapping the already-serialized string in quotes and escaping the inner quotes. When _rows_to_conversation later does json.loads(row['tool_calls']), the double-encoded string parses back to a plain string (not a list). _history_to_messages then iterates this string character-by-character, calling tc.get('function', {}) on each char — 'str' object has no attribute 'get'. This was a pre-existing bug (on main), but only triggered by the import_sessions path (the live agent always passes tool_calls as a Python list). The e2e error-banner guard caught it via the 'Resume failed' notification toast. Fix: in both append_message and _insert_message_rows, parse tool_calls with json.loads first if it's a string, then re-serialize. * fix(desktop): exempt boot-failure from error guard - boot-failure: add allowErrorBanners() beforeEach — these tests deliberately trigger boot errors, so error toasts are expected - test.ts: export allowErrorBanners() opt-out + reset flag in afterEach * feat(status-bar): add /battery toggle for a color-coded battery read-out Add an opt-in battery indicator to the CLI and TUI status bars, shown as the first element and colour-coded by charge (green/yellow/orange/red, or green while charging). Off by default and a no-op on machines without a battery. - agent/battery.py: shared psutil-backed reader with a short TTL cache, category bucketing, and a compact 🔋/⚡ label. Fails open to "unavailable" everywhere. - CLI: /battery [on|off|status] toggle persisted to display.battery, rendered first in every status-bar width tier. - TUI: /battery slash command, config sync, a system.battery RPC polled while enabled, and a pinned first segment in StatusRule. * fix(approval): restore session approval for Tirith-flagged commands Adds an allow_session flag to the gateway approval payload so adapters can render the session tier independently of the permanent tier. Matrix gains a session reaction (🌀) and a reaction legend; pure-tirith prompts now offer once/session/deny instead of collapsing to once/deny. Salvaged from PR #67312, adapted to the allow_permanent semantics that landed in #68597 (Always offered when any dangerous-pattern warning is persistable; pure-tirith prompts stay session-max). * fix(approval): honor allow_session across all button adapters Widen the allow_session tier from Matrix to every adapter the gateway notifies: Telegram, Discord, Slack, Feishu, and Teams gate their Session button on it; WhatsApp Cloud and qqbot accept the kwarg (no session tier in their button sets). Also thread allow_session through the plugin- escalation gate, the execute_code guard payload, and the plain-text fallback so every notify path carries the same capability flags. * test(approval): cover allow_session tiers in Matrix reaction seeding and gateway payload Update the Matrix reaction-seeding contract to the four-reaction default (once/session/always/deny), add tirith-tier (session without always) and no-session-tier cases, and assert allow_session=True in the tirith gateway payload. * fix(desktop): wrap missing sidebar icon-button tooltips (#67500) * fix(desktop): wrap sidebar icon buttons in Tip tooltips Several icon-only buttons in the sidebar (header actions, workspace menu, project menu, session actions, load-more) had aria-label but no visual tooltip on hover. Wrap them in the existing <Tip> component, matching the pattern already used elsewhere (e.g. ProfilePill). No behavioral changes -- purely wraps existing buttons. Adds vitest coverage asserting the Tip wrapper (data-slot=tooltip-trigger) for 6 of 7 files; index.tsx is a 1500+ line top-level page component and was verified manually via screenshots instead. * fix(desktop): satisfy consistent-type-imports lint rule in project-dialog test * test(desktop): update session-row mocks for restored sessionColorById * fix(desktop): compose Tip around the real trigger instead of inside it Tip was being placed as SessionActionsMenu's/PlatformAvatar's DIRECT child, which asChild then cloned instead of the actual button/span. Neither Tip nor PlatformAvatar forwarded the injected onClick/ref, so both silently dropped the wiring: - session-actions-menu.tsx: Tip now wraps DropdownMenuTrigger internally (new ooltip prop) instead of the caller wrapping its children in Tip. - platform-icon.tsx: PlatformAvatar now forwards ref and spreads rest props onto its span so a wrapping Tip's trigger actually attaches. - session-row.tsx: updated call site to use the new tooltip prop. - Added session-actions-menu.test.tsx exercising the real DropdownMenu open behavior end-to-end (no Tip/Dropdown mocks). - session-row.test.tsx no longer mocks PlatformAvatar's behavior; it now exercises the real (fixed) component for the handoff-avatar tooltip. * fix(desktop): compose Tip outside PopoverAnchor in ProjectMenu (#67500) * test(desktop): update session-row test for the tooltip-prop composition (cbbbeb2fd) * fix(desktop): satisfy consistent-type-imports in session-row.test.tsx mocks * chore: retrigger CI * test(desktop): stop mocking PlatformAvatar's behavior (#67500, third pass) The mock was re-introduced by a prior edit that fixed an unrelated lint error, silently undoing the earlier fix where this test started exercising the real (forwardRef) PlatformAvatar. Removed the mock; updated the two handoff-avatar tests to query the real component's rendered span instead of text content, since it renders a brand SVG icon for known platforms rather than the platform name as text. * fmt(js): `npm run fix` on merge (#68867) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(gateway): detect stale lock when macOS psutil returns valid start_time for recycled PID On macOS, the lock record's start_time is None (no /proc at creation), but psutil.Process(recycled_pid).create_time() returns a valid float for the unrelated process that now owns the PID. The old condition required both sides to be None before falling back to cmdline checking, so the recycled PID was never detected as stale. Change the fallback condition from AND to OR: when either side's start_time is missing, fall back to cmdline-based gateway detection. Fixes #53763 * fix(gateway): handle PermissionError on stale root-owned lock file When the macOS launchd service runs in a Background session, the gateway process spawns as root and creates a root-owned gateway.lock. On restart as the normal user, open() on that file raises PermissionError, crashing the gateway immediately and entering a launchd crash loop. Catch PermissionError in is_gateway_runtime_lock_active(), remove the stale lock file, and return False so the new process can start cleanly. Fixes #42685 * fix(gateway): guard acquire_gateway_runtime_lock against root-owned lock PermissionError Widen the PermissionError handling from is_gateway_runtime_lock_active (#42689) to the sibling open() in acquire_gateway_runtime_lock: a stale root-owned gateway.lock left by a launchd Background session previously crashed the acquiring process. Unlink the stale file and retry once; if the unlink or retry fails, return False cleanly instead of raising. * fix(gateway): make stale scoped-lock removal atomic via tombstone rename Replace the unlink()+O_EXCL sequence in acquire_scoped_lock with an atomic os.replace() of the stale lock to a <lock>.stale tombstone followed by the existing O_EXCL create. With plain unlink(), two racing starters could both judge the lock stale and the second unlink() would silently delete the first racer's freshly-created lock — both would then 'win'. os.replace() guarantees exactly one racer claims the stale file; the loser gets FileNotFoundError and falls through to O_EXCL, which admits at most one winner. Tombstones are cleaned up immediately; behavior is otherwise identical. * fix(gateway): detect stale gateway_state.json in `gateway status` (TTL + PID liveness) Verified: applies cleanly and the patched module compiles. Tests are described in the PR body (not bundled in this commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(gateway): cover stale gateway_state.json detection (TTL + PID liveness) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(gateway): take over live platform-lock token holders once When --replace misses a cross-HERMES_HOME Telegram token holder, platform connect used to retry forever. Terminate a verified gateway holder once (with the takeover marker) and re-acquire the scoped lock (#65176). Co-authored-by: Cursor <cursoragent@cursor.com> * chore(contributors): map jaretbottoms@gmail.com -> jbbottoms (PR #65178 salvage) * fix(gateway): reap the replaced gateway's orphaned children on POSIX Builds on jbbottoms's #65178 takeover fix (cherry-picked as the previous commit). Windows --replace already tree-kills via taskkill /T, but the POSIX paths signalled only the recorded gateway PID — adapter subprocesses that outlived their parent kept holding scoped token locks and blocked the replacement gateway. - gateway/status.py: _snapshot_gateway_children() captures the old gateway's descendants (psutil, recursive) while it is still alive; reap_gateway_children() SIGTERMs verified orphans after the main PID is confirmed dead, waits bounded, SIGKILLs survivors. Identity-aware (psutil is_running is PID+create-time), skips zombies and children whose ppid still equals the old gateway (parent actually alive), and never raises — best-effort with debug/info logging only. - take_over_scoped_lock_holder() snapshots before terminating and reaps only on a confirmed successful handoff. - gateway/run.py: start_gateway --replace snapshots before SIGTERM and reaps after the old PID is confirmed gone, mirroring taskkill /T. - tests/gateway/test_replace_child_reap.py: reap/skip/never-raise unit coverage plus end-to-end --replace ordering (snapshot → terminate → reap) and the no---replace path never touching the old process. * chore(contributors): map emails for PRs #66906, #66420, #63398 salvage * fix(state): probe FTS5 read path in _db_opens_cleanly so partial index corruption is detected (#66724) `hermes sessions repair --check-only` opens cleanly on state.db files with partial FTS5 index corruption — base tables read fine, the rolled-back write probe from #50502 succeeds, and `PRAGMA integrity_check` returns "ok". But every session_search / /resume title resolution / feature backed by MATCH / snippet / rank queries errors out with `database disk image is malformed` because internal shadow-table segments are bad. The official repair tool then gives false confidence. Add a representative FTS5 read probe against both `messages_fts` and `messages_fts_trigram` (the latter backs title resolution). Empty MATCH strings are accepted by every FTS5 index without requiring populated content, so the probe is safe on a freshly-init'd DB; missing-table / missing-column errors fall through to the existing "not yet a populated DB" branch, matching the write-probe's behaviour. Any other OperationalError is surfaced as the check reason, which sends `hermes sessions repair` to its existing FTS 'rebuild' path (repair_state_db_schema, line 616). Single-file change in hermes_state.py::_db_opens_cleanly. No public API change. No new imports. Fixes #66724. * fix(state): also catch sqlite3.DatabaseError in FTS5 read probe (#66724) The FTS5 read probe in _db_opens_cleanly() only caught sqlite3.OperationalError. But the corruption class #66724 actually wants caught — partial shadow-table damage where MATCH / snippet / rank queries raise DatabaseError("database disk image is malformed") — is a DatabaseError, not OperationalError. Without this catch the probe crashes the caller instead of returning a reason, which is exactly the silent-fail mode the issue describes. Move the try/except inside the for-loop so each FTS table is probed independently (one table corrupted should still surface as a reason), add a separate except clause for DatabaseError that surfaces the same reason format, and use continue instead of pass so the loop still walks both tables when only one is missing on a brand-new DB. Tested by hand: with a corrupted messages_fts_trigram shadow table the function now returns 'fts5 read probe failed on messages_fts_trigram: database disk image is malformed' instead of crashing out. Without this fix it would still crash. * fix(state): preserve degraded-runtime read probe + use canonical FTS5 classifier Two follow-ups on top off842733(the FTS5 read probe added in #66906): 1. The original probe query used MATCH '', which FTS5 rejects with 'fts5: syntax error near '. Empty MATCH syntax is not valid FTS5. Switch to MATCH '""' — a quoted empty phrase that parses, scans zero rows, and exercises the same shadow-table read path the search tools use. The probe previously never reached the shadow segments at all on a healthy DB; the read-corruption class was only being detected because the existing write probe happens to fail first on a DatabaseError. 2. The probe's degraded-runtime branch only checked the substrings 'no such table' / 'no such column'. On a SQLite build without the fts5 module, MATCH against a legacy messages_fts table raises 'no such module: fts5' (a different OperationalError class). The substring check would misclassify that as corruption and trigger repair, whose final fallback deletes the messages_fts% schema (#66906 review). Use SessionDB._is_fts5_unavailable_error() — the canonical classifier already used by the degraded-runtime init path — to recognize both 'no such module: fts5' and 'no such tokenizer: trigram' as capability errors. Add tests covering: - Partial shadow-table damage (read-corruption class) - Repair brings reads back online - Healthy degraded DB without fts5 module stays healthy (regression for the misclassification risk) - Healthy degraded DB without trigram tokenizer stays healthy Closes #66906 review feedback Refs #66724 * fix(state): self-heal FTS corruption on the SessionDB search path too Complements #66296 (self-heal on the write path): search_messages()'s main FTS5 MATCH query caught only sqlite3.OperationalError (a query-syntax error → return empty). A corrupt FTS index raises the malformed / "fts5: corrupt structure record" class, which is a sqlite3.DatabaseError — the parent of OperationalError, so it was NOT caught and propagated straight out of search_messages, crashing session/history search. The write path now rebuilds and retries on that class, but a read-only session (cron/CLI history search, or a search issued before any write) never triggers a write, so its search stayed broken until the next process restart ran the offline repair. Catch the DatabaseError corruption class on the search MATCH read too and route it through the existing one-shot _try_runtime_fts_rebuild(), then retry the query. The catch is moved outside `with self._lock` so rebuild_fts() can re-acquire the lock (mirrors _execute_write). The one-shot guard is shared with the write path, so a single instance never loops on a genuinely unrecoverable index. OperationalError syntax handling is unchanged (caught first). Adds a regression test: with a corrupted messages_fts and no post-corruption write, search_messages() rebuilds in place and returns the match; without the fix it raises DatabaseError. * fix(state): extend search-path FTS self-heal to the CJK/trigram branch The trigram MATCH branch in search_messages() had the same OperationalError-only catch that #66420 fixed on the main FTS5 branch: a corrupt messages_fts_trigram shadow table raises the malformed / 'fts5: corrupt structure record' class (sqlite3.DatabaseError, parent of OperationalError), which propagated straight out of search_messages and crashed CJK session/history search for read-only sessions. Route that class through the shared one-shot _try_runtime_fts_rebuild() and retry the trigram query (catch moved outside self._lock so rebuild_fts() can re-acquire it, mirroring the main branch). If the rebuild is refused (guard consumed / FTS disabled / different error) or the retry fails, fall through to the existing LIKE substring fallback — which reads only the canonical messages table — instead of raising, so CJK search degrades gracefully rather than crashing. Adds two regression tests: trigram search self-heals in place after shadow-table corruption (answers from the rebuilt trigram index, not the LIKE fallback), and degrades to LIKE without raising when the one-shot rebuild was already consumed. Follow-up to #66420; refs #66296 #66724 * fix(state): add REINDEX strategy to repair stale B-tree indexes (#63386) When PRAGMA integrity_check reports 'wrong # of entries in index' for B-tree indexes (e.g. idx_sessions_handoff_state), the existing repair strategies (FTS rebuild, sqlite_master dedup, drop-FTS+VACUUM) don't address the mismatch. Add Strategy 0.5: run REINDEX to rewrite the index b-tree from canonical table rows before escalating to more destructive strategies. * test(state): exercise REINDEX repair against a REAL stale B-tree index Replace the mocked test for #63398's REINDEX strategy: the original monkeypatched _db_opens_cleanly to return the corruption string, so the REINDEX pass itself was never exercised against actual index corruption — the test would pass even if REINDEX didn't fix anything. New fixture _corrupt_btree_index() builds genuine on-disk staleness with a writable_schema hack: rewrite the index definition to a partial index (WHERE 0), REINDEX so the b-tree is rebuilt empty, then restore the full definition. integrity_check then reports the real 'wrong # of entries in index idx_messages_session' / 'row N missing from index' class from #63386 — no mocks anywhere. The rewritten test asserts end-to-end with real function calls: - the real _db_opens_cleanly detects the stale index, - repair_state_db_schema repairs it with strategy 'reindex_btree', - post-repair the detector and raw PRAGMA integrity_check both report healthy, and a query forced through the rebuilt index (INDEXED BY) sees every row. Adds a second test asserting the REINDEX strategy is non-destructive (all sessions/messages survive, readable via SessionDB). Follow-up to #63398; refs #63386 * fix(kanban): auto-repair index-only kanban.db corruption via REINDEX _guard_existing_db_is_healthy previously failed closed on ANY integrity_check failure, including the index-scoped class ('wrong # of entries in index <name>' / 'row N missing from index <name>') where the table b-trees are intact and REINDEX rebuilds the damaged indexes losslessly. Boards hit by that class were bricked until manual surgery even though SQLite can fix them in-place. Now, when integrity_check output consists ONLY of index-scoped errors (index name parsed generically from the message — no hardcoded list): 1. quarantine the corrupt bytes FIRST via the existing content- addressed _backup_corrupt_db, 2. under the caller-held cross-process init flock, REINDEX each named index (falling back to bare REINDEX if a parsed name doesn't resolve), 3. re-run integrity_check and proceed only if it comes back clean. Any non-index error class (page corruption, malformed image, freelist damage) — or a REINDEX whose re-check is still dirty — fails closed exactly as before: backup + KanbanDbCorruptError, no silent recreation. Transient OperationalError (locked/busy) still propagates raw with no quarantine. Tests build a real board DB and corrupt a live index via the writable_schema/partial-index REINDEX trick to produce the genuine 'wrong # of entries in index' shape, then assert auto-repair recovers with data intact, page corruption still raises, and a dirty re-check fails closed. * fix(kanban): cap corrupt-backup retention at 10 files per board DB Content-addressed quarantine backups dedupe identical corrupt bytes, but corruption that keeps mutating between failures (partial repairs, further damage across dispatcher retries, multi-profile fleets) mints a new sha-named backup every round — a user accumulated 124 .corrupt.*.bak files with no bound. After each NEW backup is created, prune oldest-by-mtime backups beyond _CORRUPT_BACKUP_RETENTION (module constant, default 10), including the copied -wal/-shm sidecars. The just-created backup is always exempt (copy2 preserves the source mtime, which can be older than existing backups). Pruning is best-effort and never masks the corruption error about to be raised; dedupe of identical corrupt bytes is unchanged. * feat(kanban): periodic WAL checkpoint (TRUNCATE) on the dispatcher tick Kanban connections set wal_autocheckpoint=100, but SQLite's passive autocheckpoint backs off whenever any reader holds an open snapshot — on a busy multi-process board the -wal file can grow without bound between gateway restarts. After each successful dispatch tick, while still holding the board's single-writer dispatch flock, run PRAGMA wal_checkpoint(TRUNCATE) best-effort at a coarse interval (>=5 min since this process last checkpointed that board; module-level per-path monotonic timestamp, so multi-board dispatchers checkpoint each board on its own clock). Success and busy/locked skips are both logged at DEBUG; a failing checkpoint can never fail the tick. * feat(kanban): add `hermes kanban repair` CLI verb Adds kanban_db.repair_db() — a structured, non-raising wrapper around the same narrow repair policy as the connect-time guard: probe with PRAGMA integrity_check under the board's cross-process init flock; quarantine the corrupt bytes FIRST via the content-addressed backup; REINDEX only when every integrity message is index-scoped; re-check; report ok / repaired / corrupt / missing. Locked/busy OperationalError still propagates raw (a locked healthy DB is not corruption and gets no quarantine), and a repair invalidates the per-process healthy-path cache so the next connect() re-probes. The CLI verb reports status human-readably (or --json), exits 0 for ok/repaired/missing and 1 when the DB is still corrupt (non-index corruption stays fail-closed with manual-recovery guidance). It dispatches BEFORE kanban_command's auto-init: init_db() raises KanbanDbCorruptError on a corrupt board, which previously would have made a repair verb unreachable on exactly the boards that need it. CLI tests drive the real argparse surface (build_parser + kanban_command) against real corrupted SQLite fixtures. * fix(packaging): graft web_dist in MANIFEST.in and add sdist regression test Wheels ship hermes_cli/web_dist via pyproject package-data, but the sdist did not: MANIFEST.in had no graft and .gitignore excludes web_dist, so source tarballs installed a dashboard-less package. Graft the directory and add an sdist regression test that builds the tarball and asserts index.html is inside. Salvaged from #29661; the PR's [web]-extra 404-message change was dropped per maintainer review (misleading guidance for source installs). * fix(dashboard): attempt one recovery build when --skip-build finds no dist --skip-build with a missing web_dist/index.html previously hard-failed with sys.exit(1) (issue #59288). The desktop launcher passes --build-mode skip on every boot, so a wiped or never-populated dist bricked the dashboard until the user manually rebuilt. Now the default-dist path logs a clear warning and attempts exactly ONE recovery build through the existing _build_web_ui path. If the recovery build also fails to produce index.html, the original fatal behavior is preserved with a clear message. A custom HERMES_WEB_DIST stays fail-fast: the build writes to the default dist location and cannot populate a caller-managed directory. Closes #59288 * fix(web): guard _serve_index against a missing or unreadable index.html mount_spa degrades to a JSON 404 catch-all when the dist directory is fully missing, but _serve_index read index.html unguarded — so a dist dir that exists while index.html is missing (partial build, wiped dist, permissions) raised FileNotFoundError on EVERY request instead of returning a useful error. Catch OSError around the read and return the same JSON 404 payload the fully-missing-dist path uses, so clients get a consistent signal. The route recovers automatically once a rebuild restores the file. * fix(dashboard): content-hash web UI freshness check Replace mtime-based web UI dist staleness with a content-hash stamp under HERMES_HOME, matching the desktop build freshness model. This avoids false stale/fresh decisions when git operations rewrite source mtimes without changing bytes, while preserving the existing stale-dist fallback. Also treats malformed or missing stamp data as needing a rebuild. * fix(cli): serialize concurrent web-UI builds with an exclusive flock Concurrent dashboard boots (desktop retry loop) each spawned their own npm install + vite build over the same tree; parallel builds starved each other, the dist sentinel never advanced, and every boot re-triggered the build — cascading into orphan backends, port collisions, and CPU storms that also knocked out the Telegram gateway's heartbeat (2026-07-12). One process now builds under flock; the rest serve the existing dist (stale is acceptable) or wait for the first-ever build to finish. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(cli): run the web-UI staleness walk once, under the build lock The flock wrapper checked _web_ui_build_needed twice (pre-lock fast path and post-lock re-check) and _do_build_web_ui checks it again internally, so a boot that actually built walked the whole web/ source tree three times. The callee's own check already runs under the lock on every path through the wrapper, so it alone is sufficient: drop both wrapper checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(git): ignore the web UI build lock file The cross-process build flock (.web_ui_build.lock at the repo root) is an empty coordination file created on first dashboard boot; it must never be tracked or show up as untracked noise. Also keeps it out of the content-hash staleness digest, which skips gitignored paths. * test(cli): cover the web UI build flock contention paths PR #63455 shipped the flock without tests. Cover the three contention paths: contended+dist serves stale without a second build, uncontended builds under the lock (creating the lock file), and contended+no-dist blocks then skips the rebuild because the staleness re-check runs under the lock and sees the winner's stamp. Also pin the lock filename into .gitignore via a regression assertion. * style(cli): open the build lock file with explicit encoding (ruff PLW1514) * chore(contributors): map eazye19@users.noreply.github.com -> eazye19 (PR #42387 salvage) * fix(mcp): reconnect immediately on transport TaskGroup drop instead of backoff/park Streamable-HTTP / SSE MCP transports run their stream pump inside an anyio TaskGroup. A transient stream drop (idle timeout, brief backend blip, server-side TCP close) surfaces as a BaseExceptionGroup escaping the transport context manager. It reached run()'s error path, which applied exponential backoff (1s..16s) and eventually parked the server for 300s and deregistered its tools — turning a sub-second glitch into a multi-minute tool outage even though the POST path was still healthy. _run_http now wraps all three transport branches (SSE, new + deprecated Streamable-HTTP) and routes a BaseExceptionGroup through a new _reconnect_or_reraise_group() helper that returns 'reconnect' (immediate rebuild, no backoff/park/deregister). It re-raises instead of masking when shutdown is in progress, when the group carries a real CancelledError (cancellation must propagate, cf. #9930), or when no live session was established this attempt (a connect/handshake failure that should fall through to run()'s backoff rather than hot-loop). Fixes #66092 Co-authored-by: 雨哥 <hanyu1212@users.noreply.github.com> * fix(mcp): charge immediate reconnects against a rapid-drop budget (#62212) Harden the immediate-reconnect path from #66271: - _reconnect_or_reraise_group re-raises when the transport TaskGroup carries a KeyboardInterrupt / SystemExit leaf — fatal signals must propagate to the interpreter, never be converted into a reconnect. - Rapid-drop budget: a completed handshake alone no longer clears _reconnect_retries/backoff on clean transport return. A session is UNPROVEN until it demonstrates real health — survived >=1 full keepalive interval (keepalive success path) or served >=1 successful tool call (_mark_session_proven). Unproven clean returns are charged against _MAX_RECONNECT_RETRIES, so a flapping transport that handshakes fine and drops moments later still reaches the park instead of respawning forever (#62212: 6212 spawns in 63h). Proven sessions keep the #57604 behaviour: budget clears, transient blips over a long-lived session never accumulate toward parking. * fix(mcp): unwrap exception groups and park permanent failures immediately (#65673) - Add module-level _unwrap_exception_group (ported from hermes_cli/mcp_config.py, adapted): handles nested groups, prefers non-cancellation leaves over the CancelledErrors anyio sprays across sibling tasks, and re-raises KeyboardInterrupt/SystemExit leaves. - Add _classify_mcp_failure(exc) -> 'permanent'|'transient'. Permanent: auth 401/403, NonMcpEndpointError, InvalidMcpUrlError, FileNotFoundError/ENOENT on the stdio command. Transient: everything else (network/EOF/ClosedResource/TaskGroup drops). Permanent failures park immediately without burning the retry ladder — every retry against a missing binary or a revoked credential hits the same wall. - Every log site in run() and the keepalive failure log now logs the unwrapped root cause as 'TypeName: message', so a dead stdio pipe says 'BrokenPipeError' instead of the opaque 'unhandled errors in a TaskGroup (1 sub-exception)' (and empty str(exc) dead-pipe errors are no longer blank). * fix(mcp): one WARNING per state transition, DEBUG retry chatter, jittered backoff Log-storm hygiene for the reconnect machinery (#65673, #66092): - State transitions carry exactly one WARNING each: connected → degraded (keepalive failure), degraded → parked (budget exhausted / rapid-drop park / permanent error), parked → connected (revival proven healthy, via _mark_session_proven). - Per-attempt retry logs (initial-connect attempts, connection-lost reconnect attempts, per-cycle rebuild notices, park-wake probes) demoted to DEBUG. - Backoff sleeps get +/-20% uniform jitter (_jittered) so a herd of servers that lost the same backend doesn't retry — and log — in lockstep. * fix(mcp): re-register tools during parked revival * fix(mcp): isolate a single failing stdio server from the bridge (#50394) * fix(mcp): clear connect-cooldown state on every shutdown path Builds on trevorgordon981's #50589 (cherry-picked as the previous commit). The #50394 cooldown reset only ran inside the async _shutdown coroutine, which is skipped on the empty-_servers fast path — the most common state when a server failed to connect (failed servers are never recorded in _servers). It was also skipped when the MCP loop wasn't running. Clear _server_connect_retry_after/_server_connect_failures on the fast path and in a final unconditional sweep so a full shutdown/restart always re-attempts every configured server immediately. Adds regression tests for both paths. * chore(contributors): map diffen77 + fazerluga-creator emails For the #66547 and #66981 salvage cherry-picks in this branch. * fix(mcp): reconnect message-less closed transports * test(mcp): isolate resource error breaker state * fix(mcp): harden nested interruption detection * fix(mcp): make transport classification cycle safe * fix(mcp): cycle-guard cause/context traversal in transport classifier Builds on diffen77's #66547 (cherry-picked as the previous commits). Extend _is_session_expired_error's iterative traversal to follow __cause__/__context__ in addition to ExceptionGroup .exceptions — SDK wrappers often raise a generic RuntimeError *from* the message-less ClosedResourceError, leaving the transport signal reachable only via the chain. The identity-visited set guards chain cycles (handlers re-raising previously seen exceptions), and a bounded node budget (_EXC_TRAVERSAL_MAX_NODES) caps pathological acyclic graphs. Adds regression tests: cause/context chain detection, interruption precedence through chains, cyclic cause/context termination, and budget-bounded termination. * fix(mcp): allow background discovery retry after a run that connected nothing start_background_mcp_discovery() sets _mcp_discovery_started once and never resets it. If the first background run exits without connecting any MCP server (startup cancellation, OOM restart, transient network failure), every later call returns immediately and the process is permanently stuck with zero MCP tools until a full restart. Fix: when discovery is marked started but the thread is dead and no server is connected, reset the flag and spawn a fresh discovery thread. Also log a WARNING when a discovery run completes with zero connected servers, so the condition is visible instead of silent. Caught in production on a long-running gateway fleet where a gateway restarted under memory pressure and came back with all MCP tools missing. * fix(tui): centralize stdio TUI MCP discovery on the shared owner Review follow-up: the stdio hermes --tui path spawned its own one-shot discovery thread, so the retry-after-zero-connected semantics added to start_background_mcp_discovery() did not cover it. Spawn TUI discovery through the shared owner and make the entry-side wait_for_mcp_discovery() fall through to the shared owner when no local thread exists (mcp_discovery_in_flight/join_mcp_discovery already consult both owners). Keeps the cheap no-mcp-servers config guard on the TUI path. Adds regression tests for the entry-side wait delegation. * fix(tui): give the stdio TUI discovery a retry-after-zero-connected path Builds on fazerluga-creator's #66981 (cherry-picked as the previous two commits). start_background_mcp_discovery()'s retry allowance only fires when the function is CALLED again, but tui_gateway/entry.py main() calls it exactly once at startup — so a first discovery run that connected nothing still latched the stdio TUI MCP-less for the whole session. Re-invoke the idempotent spawn from wait_for_mcp_discovery() (the per-agent-build wait) when the process is MCP-enabled, gated on a flag set in main() so non-MCP sessions never pay the MCP import on the wait path. Adds regression tests for both the retry re-invocation and the non-MCP skip. * fix(tui): gate the shared-owner MCP discovery wait on the stdio TUI flag The TUI retry-allowance follow-up made tui_gateway.entry.wait_for_mcp_discovery delegate to hermes_cli.mcp_startup unconditionally when no entry-local thread exists. But server._make_agent already calls the startup wait directly for dashboard /api/ws sessions, so every non-stdio agent build paid the bounded wait twice (caught by test_make_agent_waits_for_shared_mcp_discovery). Gate the retry-spawn AND the delegated wait on _mcp_discovery_enabled, which only the stdio TUI arms in main(). * fix(status-bar): address Copilot review on /battery - TUI /battery matches the CLI surface: adds `status` (live reading via system.battery), and the help/usage strings now consistently read [on|off|status]. - batteryLabel() renders `--` for an unknown percent so a null can never surface as "null%" even without the showBattery guard. - Move the system.battery RPC out of the config section into "Methods: tools & system" where system.* RPCs belong. * fix(dashboard): cap gateway health probe timeout * fix(gateway): report runtime source version in /health, not stale dist-info metadata /health and /health/detailed resolve the version via importlib.metadata first, falling back to hermes_cli.__version__. On editable/source checkouts — including the standard git-based install that hermes-setup performs — hermes_agent-*.dist-info can survive a source update unchanged, so the health endpoints keep reporting the previous release even though the running code (CLI, dashboard, release tags) is newer. Stale metadata does not raise, so the source fallback never fires. Flip the preference: use hermes_cli.__version__ (the runtime source of truth shared by the CLI and dashboard) first, and fall back to distribution metadata only when the source import fails. The never-raise contract of the version probe is unchanged. Observed live: CLI, dashboard, and pyproject all reported 0.18.2 while /health returned 0.18.0 from a stale hermes_agent-0.18.0.dist-info left behind by a source update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api-server): count "stopping" runs as active in readiness work counts _readiness_work_counts()'s active_api_runs set is {"queued", "running", "waiting_for_approval"} — it excludes "stopping", the status _handle_stop_run() sets while a run is being interrupted. Since the stop is fully cooperative (the run stays "stopping" — doing real executor-thread work — until the agent actually notices the interrupt and the task settles to "cancelled", an unbounded window, not a fixed timeout), /health/detailed's background_queues.active_api_runs undercounts real active work for that whole duration. Fix: add "stopping" to the active-status set. background_queues.status itself is hardcoded "ok" (gateway/readiness.py), so this doesn't change overall readiness — it only corrects the count value external monitoring tooling reads from this endpoint. * chore(contributors): map yingwaizhiying@gmail.com -> tianma-if (supersedes stale msh01 entry) * feat(dashboard): component-level health rollup on /api/status (#68662) The dashboard's own liveness surface could report healthy while every authenticated request 500'd (e.g. wedged state DB) — /api/status carried gateway-only fields, no storage/dashboard signal, and no middleware counted unhandled exceptions. - DashboardHealth state holder: rolling 5-min deque of unhandled-error timestamps + last self-test result. last_error_type/last_error_path are internal-only diagnostics — snapshot() exports counts/enums/ timestamps exclusively (PUBLIC_API_PATHS no-secrets contract). - Outermost @app.middleware('http') (registered last) wraps call_next in try/except: records + re-raises unhandled exceptions, and records responses with status >= 500. - /api/status gains 'components' {gateway, storage, dashboard, platforms} + top-level 'overall' ok|degraded. storage reuses the gateway readiness state_db probe (read-only, 1s-bounded) in an executor; platforms derive ok/degraded from existing gateway_platforms states. - Authenticated self-test task started in the lifespan: every 60s an in-process httpx ASGITransport GET of /api/sessions?limit=1 with the real _SESSION_TOKEN, feeding the dashboard component. Skips cleanly when httpx is unavailable and while the OAuth gate is engaged (the legacy token is not honoured there). Tests: middleware increments on raising route and on 5xx, window expiry, components shape + overall, storage degraded when the state_db probe fails, dashboard degraded after an error, no secret-bearing fields in the public payload, self-test pass/fail recording (mocked client) + a real ASGI round trip. * fix(status): make gateway_updated_at a stable RFC3339-or-null contract (#68657) The /api/status gateway_updated_at field and the gateway /health/detailed updated_at field passed through whatever gateway_state.json contained, untyped. All current writers emit RFC3339 via _utc_now_iso(), but legacy gateways wrote unix epoch floats, and a corrupt or hand-edited state file can inject numbers or arbitrary garbage — while the frontend types (web/src/lib/api.ts) declare string | null. Add normalize_updated_at() in gateway/status.py as the single funnel: - str: accepted iff datetime.fromisoformat parses (trailing Z tolerated); naive timestamps coerced to UTC; canonical isoformat returned - int/float: treated as unix epoch seconds -> UTC ISO string, with a plausibility guard (reject < 2000-01-01, > now+1day, non-finite) - bool: rejected explicitly (int subclass, but never a timestamp) - anything else: None Apply it at both emit sites: the dashboard /api/status handler (covers both the local read_runtime_status() branch and the remote /health/detailed cross-container fallback branch) and the gateway API server's /health/detailed response. Contract tests: parametrized /api/status normalization (epoch float/int, garbage string, None, bool, dict, absent key), remote-health numeric and garbage bodies, dashboard shape test round-trip assertion, direct normalize_updated_at units (range guards, Z suffix, naive coercion, non-finite floats), and a write_runtime_status -> read_runtime_status round-trip proving the writer side stays tz-aware parseable. * feat(desktop): type-to-focus the composer from empty chat chrome Printable keys and soft `/`/Enter pull focus back to the composer when nothing else owns the key (dialogs, terminal, buttons on Enter, …). * feat(ui-tui): widget-grid 2-axis layout engine + overlay primitives (#20379 rebase) Rebase of the widget-grid PR onto current main, then grow it into a real 2-axis engine: resolveGridTracks (grid-template tracks — fixed cell counts and weighted fr shares with mins), layoutWidgetGrid (1D auto-packing flow), and layoutGridAreas (2D absolute placement with row/col spans and implicit row growth). Overlay/Dialog primitives give zoned viewport-level modals with an optional scrim. /grid-test (interactive: areas, nesting, zoom, gap/padding) and /grid-test streams (4x3 mission-control GridAreas board with promote-to-main) exercise everything end to end. * refactor(ui-tui): route production surfaces through the widget-grid engine Banner (responsive tiers: full logo -> compact rule -> text -> hidden), SessionPanel (fixed hero track + flexible info track, the desktop pane shell's fixed-vs-flex contract), floating overlays, prompt zone, and the pickers all render through WidgetGrid instead of hand-rolled flex math. Pickers gain maxWidth so grid cells can cap them. * feat(ui-tui): background-aware theme adaptation + paired palettes + /theme pin OSC-11 asks the terminal for its actual background at startup (env heuristics are blind on xterm.js hosts) and the theme re-derives against the answer: desktop-contract adaptation (contrast floors + fill polarity), a shared list-row selection primitive instead of per-picker panel fills, paired light_colors/dark_colors skin blocks with a machine audit, and a /theme auto|light|dark pin (display.tui_theme) for hosts whose probe lies. E2E coverage for the OSC reply chain + /theme-info diagnostics. * feat(ui-tui): theme engine — seeds to derived-tone ladder + flash-free boot The desktop color-mix system, ported: a theme is a handful of identity SEEDS (text, primary, accent, border, status hues); every secondary tone (muted, label, surfaces, chips, selection) is a color-mix derivative against the real terminal background. lib/color.ts consolidates the primitives (parse/mix/luminance/contrast/retone + xterm.js's multiplicative liftForContrast). Knobs are grid-search fitted so the math reproduces the classic hand-tuned literals (contract-tested). The display shim renders authored palettes RAW and only rescues near-invisible colors. Boot reads the last resolved theme from $HERMES_HOME/tui-theme-boot.json so the first frame paints in the right palette (no default-dark flash), and the placeholder cursor follows the bubbles textinput pattern. * perf(tui): skin switches stay hot — MCP gating, pooled reload, swap repaint Four responsiveness/hardening fixes surfaced by rapid /skin switching: config.get mtime now carries an mcp_rev hash so the TUI reloads MCP only when MCP-relevant config changed (cosmetic /skin writes cost seconds of reconnects before); reload.mcp runs on the RPC pool serialized by a lock (inline it froze the stdio reader for the duration of a flapping server's retry loop — config.set/complete.slash sat unread and the TUI appeared dead); theme swaps schedule one full repaint (incremental diffs after a recolor tear — stale cells keep the old palette, read as "shadows"); and OSC-11 pure-black answers are distrusted universally (unset-default fingerprint on xterm.js hosts and tmux). Parent EIO zombie fixed: a dead PTY made every render write throw once a second forever; exit after 5 consecutive dead-stream errors. * fix(ui-tui): light mode renders the vivid palette RAW, not WCAG-darkened Pixel-sampled against the reference screenshots: the beloved classic light-mode look is the vivid authored golds rendered essentially raw (#FFD700 shows as bright #F5C242) — transparent terminal profiles apply no contrast lift of their own, and pre-darkening every foreground to WCAG 4.5 produced the reported mustard mud. Light-mode display floors become near-invisible rescues only (1.18 display / 1.6 semantic; dark keeps 1.45/2.2); the default skin ships a fills-only light_colors OVERLAY (polarity-flip the navy menu/status fills, foregrounds inherit the vivid colors), and themeForSkin merges polarity overlays instead of replacing. Palette audit reworked: base colors audited fully, overlays for valid keys and fill polarity. * fix(ui-tui): eradicate every transparent-terminal black-slab trigger On terminal.background #00000000 xterm paints "drawn blank" and attribute-styled cells against an opaque black RGB the user never sees elsewhere. Verified by PTY byte capture + stateful SGR replay that we emit no black backgrounds — then removed every trigger: the banner's opaque space-fills, the scrollbar's non-scrollable space column and SGR-dim track, the placeholder's SGR inverse cursor and dim fallback, and the bold full-width banner rule. Chrome styling is now explicit truecolor only: scrollbar thumb rides primary (accent on hover/drag) over a blended track, the placeholder cursor is a theme-colored chip (48;2 bg + luminance-picked ink), hints always carry an explicit 38;2 foreground. * fix(ui-tui): session-panel hierarchy + polarity-proof placeholder tone Tool/skill rows rendered labels in muted and member lists in text — which inverts per skin (muted is the strong family tone on gold skins but the weak gray on blue ones; slate/poseidon read backwards). Labels now lead in the theme's label tone, values recede via an explicit fade toward the surface; audited across all 9 built-ins x both poles. The composer placeholder lands on muted — the "(and N more toolsets…)" tone — a mid-luminance family color that reads receded on both poles even when polarity detection is wrong. * feat(ui-tui): OSC-10 foreground polarity tiebreaker for transparent terminals Transparent profiles make OSC-11 useless (xterm reports the unset default, pure black, regardless of the composited surface) so polarity detection lagged editor theme flips. OSC-10 reports the theme's REAL foreground on those hosts — its luminance reveals the pole. hermes-ink grows a foreground slot (shared reportedColorSlot factory), App.tsx queries both in the startup batch (background first so a trusted answer wins without churn), and the app commits an inferred pole only when the background was distrusted AND the foreground is decisive (bright=dark theme, dark=light; mid-grays and #000/#fff defaults commit nothing). User pins still outrank. * fix(ui-tui): session-panel fade anchors on muted, not the surface — readable on every pole mix(text, surface) was invisible whenever text was already pale (the light-rendered default: cream blended toward white = nothing) and inherited wrong-polarity detection through the surface. mix(muted, text, .5) is pole-proof: muted is mid-luminance by construction, so the midpoint stays readable everywhere. Audited 9 skins x both poles: 2.0-2.9:1 on white, 5.6-9.3:1 on dark (worst case 1.92 for a light-authored skin on dark). * style(skins): default light overlay = goldenrod ladder, not neon or mustard On white, the vivid #FFD700/#FFBF00 read as glare and the WCAG-darkened mustard reads as mud. The sweet spot is the statusbar's goldenrod family (hue kept, saturation tamed, mid luminance): title #C8961E, headers #D89B04, labels #A97E10, muted #B8860B unchanged, warm bronze ink body #5C4718, deepened semantics + shell blue. Hierarchy on white: ink 8.9:1 > fade 5.2 > label 3.7 > muted 3.3 > title 2.7 > headers 2.4. Dark mode renders the vivid block untouched (explicitly approved as-is). * refactor(ui-tui): DRY the chrome primitives — shared scrollbarColors + hintRgb scrollbarColors(t, hover, grabbed) in overlayPrimitives is now THE scheme for both scrollbars (was duplicated formulas); textInput's hex parsing collapses into one hintRgb helper feeding colorizeHint and hintCursorCell. Comment bloat trimmed. No behavior change — 1243 tests byte-green. * fix(ui-tui): completions popover — aligned name track + neutral descriptions Two-column grid: the name track auto-sizes to the widest visible command so descriptions align in their own column instead of running under the names. Descriptions render in the neutral statusFg gray — label and muted are near-twins on the gold skins, which made command and description read as one unparseable run. * refactor(ui-tui): every selectable list rides the shared selection chip — zero inverse left /agents, /journey, model picker, skills hub, plugins hub, pet picker, and the approval/confirm prompts all used SGR inverse for the active row — terminal-interpreted against unknowable defaults (black slab on transparent profiles) and visually divergent from completions/session-switcher. New spreadable chipRowProps(t, active) in overlayPrimitives (chip bg + lifted ink + bold; spread after `color` so chip ink wins) converts each site to the one selection treatment. rg confirms zero inverse={} remaining in components/. * fix(ui-tui): overlay width caps are absolute — clampOverlayWidth (Copilot review) Every picker/hub forced width >= 24 AFTER applying the caller's maxWidth, so a grid cell narrower than 24 (or a FloatBox cell under 28 with its 4 cols of chrome) overflowed and clipped at the terminal edge. One shared clampOverlayWidth(preferred, maxWidth, min=24): the caller's cap is ABSOLUTE (a cell knows its budget), the usability floor applies only when the cap allows it, uncapped keeps the old floor semantics. Five call sites (model/pet pickers, skills/plugins hubs, session switcher) route through it; the grid-test FloatBox drops its own 24 floor. Contract-tested including the sub-floor cap case from the review. * fix(tui): reload.mcp lock scope + coalesced refresh + macOS polarity flip (Bugbot) Three real findings from the review of the reload.mcp pooling + OSC-10 work: - Lock released too early: the leader now holds _mcp_reload_lock across shutdown+discover AND its own agent refresh — releasing after discover let a second reload tear the registry down while the first was still reading it to rebuild the session's tool snapshot. - Coalesced reload skipped the agent refresh: a follower returned "reloaded" without rebuilding ITS OWN session's snapshot, so a coalesced session kept stale tools. Followers now wait, then refresh their agent against the freshly-built registry (under the lock, skipping the redundant shutdown/discover). Shared _finish_reload tail for the `always` opt-out. - macOS AppleInterfaceStyle fallback never set `resolved`, so a late OSC-10 foreground reply could re-flip the committed inference (visible churn). It now marks resolved; a real OSC-11 background measurement still corrects it (that listener intentionally doesn't gate on resolved — measurement beats inference). Bugbot's other four findings were diff-truncation false positives (color.ts, themeBoot.ts, and the mcp_rev/light_colors/dark_colors types all exist; typecheck + build green). 384 tui_gateway + TS suites pass. * fix(tui): mcp_rev includes mcp_servers; reload survives leader failure; /theme persists first (Bugbot r2) - mcp_rev hash now covers `mcp_servers` (the server DEFINITIONS the classic CLI watches) alongside `mcp`/`tools` — editing a server previously bumped mtime but not mcp_rev, so the TUI skipped reload.mcp and new servers never connected until a manual /reload-mcp. - reload.mcp coalescing survives a failed leader: a completed-generation counter (bumped only after a successful shutdown+discover) gates the follower. If the leader threw (flapping server) the follower re-runs the full reload itself instead of returning a bogus success over an empty registry. - /theme applies AFTER config.set confirms (mirrors /indicator) with a guardedErr catch — a failed persist no longer leaves the session showing a theme that reverts on restart. 384 tui_gateway + 1246 TS tests, lint, build green. * fix(ui-tui): first-swap repaint + config-auto respects shell theme pin (Bugbot r3) - commitTheme compares the first commit against the SEED theme uiStore mounted with (boot cache/default), not null — a cold start that resolves to a skin differing from the default previously skipped the anti-tearing forceRedraw on that first swap. - applyConfiguredTuiTheme('auto') now clears only a pin CONFIG set (tracked via configPinnedTheme), never a HERMES_TUI_THEME the user exported in their shell — that env override outranks auto-detection per detectLightMode's documented priority, and a config hydrate was wiping it. (Bugbot's recurring "GatewaySkin missing paired palette fields" is a diff-truncation false positive — gatewayTypes.ts declares light_colors/ dark_colors, typecheck green.) 81 handler + build/lint green. * fix: restore package-lock.json @esbuild platform entries (Bugbot r4) The branch had stripped every node_modules/@esbuild/* optional-platform entry from the lockfile (442 deletions, 0 additions) — collateral from an earlier local @esbuild/darwin-arm64 reinstall that rewrote the lockfile to this machine's platform only. esbuild still declares them as optionalDependencies, so `npm ci` on Linux CI would skip the platform binary and break Vitest / ui-tui builds. No dependency was added on this branch (the only package.json delta is a dev `visual` script), so the lockfile is restored to match main exactly. * fix(ui-tui): seed mcp_rev baseline at startup so first cosmetic write doesn't reload MCP (Bugbot r5) The startup config.get seeded mtimeRef but not mcpRevRef; after a normal boot mtime is already non-zero so the poller's baseline branch never runs, leaving mcpRevRef empty. The first /skin (or other cosmetic) write bumped mtime with an unchanged mcp_rev, and empty !== hash tripped a full reload.mcp — exactly the reconnect this optimization removes. Seed the revision alongside mtime at boot. * fix(ui-tui): record config theme pin even when env already matches (Bugbot r6) Regression from the r3 shell-pin fix: applyConfiguredTuiTheme('light'|'dark') returned early when HERMES_TUI_THEME already matched, leaving configPinnedTheme false — so a later '/theme auto' refused to clear the pin and the session stayed forced to the old mode while config said auto. Set configPinnedTheme before the match short-circuit. Bugbot's other three r6 findings (GatewaySkin paired-palette fields, ConfigMtimeResponse.mcp_rev, picker maxWidth props) are diff-truncation false positives — all declared in gatewayTypes.ts / the picker prop interfaces; typecheck is green. * feat(ui-tui): shimmer skeleton for the lazy tools section The lazy-loaded Available Tools section rendered a BLANK gap that popped when data landed. It now shows animated shimmer rows shaped like the real content (label block + value run, diagonal band sweep), and the summary line prints "… tools · … skills" instead of "0 tools · 0 skills" while counts load. components/loaders.tsx ships the primitives (shimmerSegments band math, Shimmer, useShimmerPhase, ShimmerRows) — colors are caller-owned theme tones, one interval per composition. Cherry-picked from the SDK-shape branch so the skeleton lands with this PR; the widget-SDK consumers stay in the follow-up. * fmt(js): `npm run fix` on merge (#68938) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fmt(js): `npm run fix` on merge (#68976) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fmt(js): `npm run fix` on merge (#68977) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * test(mcp): stamp breaker-open time on the monotonic clock, not a literal (#69003) test_session_expired_retry_waits_for_new_session hardcoded _server_breaker_opened_at["hindsight"] = 123.0 to simulate a circuit breaker whose cooldown has already elapsed. But the breaker in tools/mcp_tool.py compares that stamp against time.monotonic() (age = monotonic() - opened_at, elapsed when age >= _CIRCUIT_BREAKER_COOLDOWN_SEC). time.monotonic()'s origin is arbitrary and small on a freshly-booted CI container, so age worked out to only a few seconds there (< the 60s cooldown) — the breaker stayed open, the half-open probe never fired, and the retry returned the "unreachable" error instead of "bank ok". It passed on long-uptime dev boxes (large monotonic) and failed under CI, with the reported "Auto-retry available in ~Ns" drifting run to run as the container's monotonic clock varied. Stamp opened_at relative to the same clock the code reads (time.monotonic() - _CIRCUIT_BREAKER_COOLDOWN_SEC - 1.0) so the cooldown is provably elapsed regardless of the monotonic origin, exercising the intended half-open transition deterministically. * fix(windows): share one bounded, tree-killing git probe across both call sites (#68997) subprocess.run(["git", ...], timeout=...) deadlocks on Windows: run()'s post-timeout cleanup calls an unbounded communicate() after killing git. Killing the PATH-resolved launcher can leave a suspended descendant git.exe holding duplicates of the captured stdout/stderr handles, so the pipes never reach EOF and the reader-thread join blocks forever — leaking a process + two reader threads per fired timeout (the accumulating git.exe load behind Windows Defender CPU spikes). Two fail-open probe call sites had this identical flaw: - tui_gateway/git_probe.py::run_git — on the Desktop agent-build path (_start_agent_build -> _session_info -> branch() -> run_git), where the hang turned an optional branch label into "agent initialization timed out" (#68609). - agent/coding_context.py::_git — hangs the agent turn inside build_coding_workspace_block under an ACP host (#66037). Consolidate both onto one shared bounded_git_probe() in hermes_cli/_subprocess_compat.py (both files already import from there, so no new import surface): - explicit communicate(timeout), then on ANY failure a tree-kill — proc.kill() AND, on Windows, best-effort taskkill /T /F so the suspended descendant that holds the pipe writers dies too — plus a bounded 1s post-kill drain; if the pipes are still held they're abandoned (the orphaned reader threads are daemonic and cost nothing). - fail open to "" on every path: spawn error, timeout, kill() raising (access denied / already reaped — a raise inside the except handler previously escaped the contract), and non-timeout communicate() failures now also terminate the child instead of leaving it running. - the taskkill spawn can't re-enter the deadlock class: it captures no pipes (DEVNULL), so its own timeout cleanup has no reader threads to join. Normal-path spawn contract is preserved byte-for-byte: PIPE/PIPE/DEVNULL, text + utf-8 errors="replace", hidden-window creationflags on Windows only, nonzero returncode -> "". Each call site keeps its own timeout (1.5s / 2.5s). Supersedes #68622 (Sora-bluesky — git_probe fix + tree-kill) and #66038 (iamwongeeeee — coding_context fix), folding both into one shared helper so the two sites can't drift and every timeout tree-kills the descendant. Tests consolidated onto the helper, incl. the previously-missing assertion that a Windows timeout escalates to taskkill /T /F. Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com> Co-authored-by: iamwongeeeee <wykim777@naver.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(tui): revision-aware reload.mcp — an ack now means the revision was LOADED Review on #20379, finding 1 (High). Two ways an MCP config revision could be silently acknowledged without ever being applied: Client: the poll advanced its accepted mcp_rev BEFORE calling reload.mcp, and quietRpc collapses failures to null — a reload that failed against a temporarily broken server left the revision recorded as applied, and no subsequent poll retried until an unrelated MCP edit. The handshake is now syncMcpReload(): send the observed rev with the request, advance `accepted` only when the server answers status=reloaded (to the server's loaded_rev, falling back to the requested rev on older gateways), and re-compare on EVERY poll tick — decoupled from mtime — so a transient failure heals on the next tick. An in-flight guard stops the 5s poll from stacking requests behind a slow reload. Server: generation-only coalescing let a follower triggered by revision B ack against revision A's registry when the config changed under a slow leader. The leader now re-hashes the MCP-relevant config after discovery and repeats until stable (bounded), records _mcp_reload_loaded_rev, and a follower coalesces only when the revision it was asked to load matches — otherwise it re-runs the full reload itself. Responses carry loaded_rev. Deterministic tests for the exact failure sequences: failed reload → no ack, no generation advance; A-then-B overlap → follower re-runs; matching rev → coalesces; failed leader → follower re-runs; legacy no-rev callers keep generation-only coalescing (thread ordering via an instrumented lock, no sleeps). Client: 6 vitest cases on the ack/retry/in-flight contract. * fix(ui-tui): boot theme cache gets provenance — a stale hint can't pin the terminal Review on #20379, finding 2 (High). The boot cache seeded the previous session's background into HERMES_TUI_BACKGROUND, the same slot a CURRENT OSC-11 answer occupies — so a cache written on a light terminal pinned a now-pure-black terminal to light forever: the new OSC-11 #000000 answer is distrusted by design, the pure-white OSC-10 foreground is distrusted too, and the macOS appearance fallback refuses to run while the slot is set. Seeding now records provenance (themeBoot.seedBootEnvironment, extracted and testable against a passed env). When the current terminal answers the background probe with the untrusted fingerprint, the gateway handler calls invalidateBootBackground(): the slot clears ONLY while it still holds the seeded value (a trusted answer that overwrote it is authoritative), OSC-10 gets first claim in the same startup batch, and a short settle pass re- derives from the live fallback chain if nothing answered. The cache is also pin-coherent now: commitTheme persists the config mode pin (display.tui_theme) alongside the resolved theme + physical background. Previously "/theme light" on a dark terminal cached a light theme next to a dark background — the next launch painted light, flipped dark when the skin resolved against the seeded background, then flipped light again on config hydration: the exact multi-stage flash the cache exists to eliminate. The seeded pin counts as config-owned (bootSeededPin), so a later 'auto' can still clear it instead of mistaking it for a user shell export. Tests cover the review's sequences: stale-light cache vs current dark terminal (invalidate → fallback chain), stale-dark vs ambiguous light, pinned light on a dark physical background across restart (and the inverse), trusted-overwrite protection, and the explicit-signal guards. * fix(ui-tui): stacked dialog gets input before the grid under it Review on #20379, finding 3 (Medium). /grid-test's `d` opens a dialog on top without clearing the grid, but the grid's input branch ran FIRST — so Esc/q/Enter mutated the hidden grid (close/unzoom/promote) instead of closing the visible dialog, contradicting its "Esc/q/Enter close" hint. Input routing now follows visual stacking: the dialog/grid dispatch is extracted into handleStackedModalInput() with the dialog branch first, the hook consumes through it, and tests drive the real dispatch against the overlay store — each advertised close key closes only the dialog (grid byte-identical), grid keys don't leak through while the dialog is up, and the same keys route to the grid again after it closes. * fix(ui-tui): make `npm run visual` portable off POSIX — zero new deps Review on #20379, finding 4 (Medium). Three portability defects in the visual verification harness: - `FORCE_COLOR=3 COLORTERM=truecolor tsx ...` POSIX env assignment does not work under the Windows npm command shell. The script is now a plain Node launcher (scripts/visual/run.mjs) that sets the env itself and spawns tsx via require.resolve('tsx/cli') — no cross-env, no shell syntax. - Hardcoded /tmp/tui-visual.{html,png} resolve to a drive-root path like C:\tmp on native Windows (and fail when that directory doesn't exist). Both scripts now derive the output directory from a shared paths.mjs helper: os.tmpdir()/hermes-tui-visual (created recursively; HERMES_TUI_VISUAL_DIR overrides for CI or side-by-side runs). - electron was undeclared by ui-tui and only worked via hoisting luck. The launcher now resolves it EXPLICITLY from the install tree the desktop workspace already provides (require('electron') in plain Node returns the binary path), with an ELECTRON_BIN override and a clear error pointing at the repo-root install when it's absent — instead of declaring a second ~100MB dependency on a TUI workspace for a dev-only harness. Also fixes the trailing-whitespace line in render.tsx that `git diff --check` flags. Verified end-to-end: render writes the HTML scene sheet and the electron shot step produces the screenshot from the tmpdir path; the missing-electron error path prints the guidance message. * perf(ui-tui): shimmer loaders share one clock and stop after a bounded period Review on #20379, finding 5 (Perf). Every ShimmerRows mounted its own 90 ms setInterval — the session panel can show lazy skills AND lazy tools at once, and a lazy watch session stays lazy indefinitely, so an otherwise- idle TUI ran ~22 React state updates per second forever. All shimmer compositions now subscribe to a single module-level clock: one interval regardless of how many skeletons are on screen, updates delivered in one timer callback so React batches them into a single render pass, and the interval is torn down with the last subscriber. Each mount's animation is also bounded (SHIMMER_ANIMATE_MS, 30 s): after the budget the skeleton freezes in place — it still reads as "loading" — and stops costing renders entirely. Tests: fake-timer coverage that N subscribers share one timer in lockstep, the interval stops with the last unsubscribe, and a late subscriber restarts the clock cleanly. * fmt(js): prettier pass on the new review tests * chore: gitignore node_modules symlinks, not just directories Worktrees symlink node_modules to the main checkout; the dir-only node_modules/ pattern doesn't match symlinks, so one slipped into a commit and broke npm ci on CI (ENOTDIR). Dropping the trailing slash matches both. * fix(desktop): stop long-session transcript from drifting to old turns (#69019) content-visibility:auto on turn groups (perf: off-screen turns skip style/layout/paint) pairs with contain-intrinsic-size:auto, which only remembers a turn's size after it renders. A turn that finished streaming near the bottom had its smaller mid-stream size remembered; once it scrolled off the top edge and got skipped, it collapsed to that stale height. With overflow-anchor:none the viewport can't self-correct, so the stick-to-bottom lock drifts and the view creeps up over older turns — the 'long session eventually shows old responses' visual glitch. Exempt the newest turns (live tail) from virtualization so a turn is only ever skipped after its layout has settled at its final size (remembered == real -> skipping changes no height). Off-screen older turns still skip, so the dialog/popover whole-document recalc win on long transcripts is kept (it scales with the hundreds of old turns, not the small tail). * ci: retrigger (transient setup-uv manifest fetch flake) * feat(ui-tui): widget-app SDK — registry, host, dispatch; demos become apps The SDK the desktop app already has, ported to the TUI: a WidgetApp contract (id/help/mode/init/reduce/render/usage), a registry, and a host that owns the active widget, routes input to its reducer, and renders it. The grid-test and dialog-test debug surfaces are reimplemented as widget apps instead of bespoke overlay state, and slash commands are generated from the registry. Input for an open widget is owned by the active app (supersedes the demo-only stacked-modal routing) — the single active widget enforces topmost-owns-input structurally. * feat(ui-tui): weather reference app — the async-data contract, themed ASCII art /weather [location]: wttr.in current conditions behind a Dialog, art bucket table-driven off WWO weather codes, every tint a theme family tone (sun = primary, rain = shell blue, thunder = warn). Proves the async story the demos don't: init returns a loading phase and fires the fetch; results land through the new host.updateWidget, which patches state ONLY while the app is still active — a late resolution can never resurrect a closed app or clobber a different one. `r` refetches; Esc/q/Enter close. Four async-contract tests (loading→ready via updateWidget, late-resolution guard, error phase, keymap). 1253 TS tests green. * feat(ui-tui): ambient widget mode — registry-driven slash catalog + in-flow dock Widgets can render as ambient (glanceable, non-blocking) instead of modal, docked in the normal layout flow above/below the status bar rather than taking over the screen. The slash catalog is generated from the widget registry so new apps surface automatically, and /ticker lands as the first live-animation ambient demo. * feat(ui-tui): self-authored widgets — user-widget loader, hot-load, skill Hermes can write its own widgets: a loader discovers $HERMES_HOME/tui-widgets/*.mjs, fs.watch hot-loads them the moment they land (no restart), and a tui-widgets skill teaches the agent the contract and the openWidget-at-register auto-open recipe. Load/error/remove events announce themselves in the transcript; a lazy intro skeleton covers the first paint. * feat(ui-tui): widget primitives — charts, accordion, shimmer, stable streams Reusable render primitives the SDK exposes to widget authors: sparkline/gauge/ hbars chart helpers (dimension-stable so live updates never resize the card), an Accordion for expand/collapse sections, animated shimmer loaders, and a streams demo that no longer reserves a phantom icon column on unfocused titles. * docs(skill): tui-widgets — auto-open recipe (openWidget at end of register) * feat(ui-tui): ambient zone system + widget crash boundary A full placement grid so the agent can put a widget where it asks — dock-top/ bottom and corner zones, with corners as reserved rails that take real space instead of floating over content. A per-widget error boundary plus lenient ShimmerRows means generated widget code can't crash the TUI. * refactor(ui-tui): host placement router + grid-test width-floor fix host.tsx collapses to one placement router over a shared render context, and the grid-test app drops its width floor too (carrying the #20379 review rule). Final formatting pass folded in. * feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop Make the Python skin engine the single source of truth for a canonical theme shape consumed by every surface, so a skin authored in $HERMES_HOME/skins/*.yaml (by a user or by Hermes from a prompt) themes the CLI, TUI, and desktop GUI at once — the theme analogue of the plugin SDK. - @hermes/shared: canonical `HermesSkin` token shape + `SKIN_COLOR_TOKENS` enum, consumed by both TS surfaces (TUI `GatewaySkin` and desktop dedup onto it). - Desktop: `skinToDesktopTheme` resolver (skin → CSS-var palette, VS Code-style derive-from-seed) + `backend-sync` that registers backend skins into the theme registry (Appearance/Cmd-K/`/skin`) and applies on a real change. Seeds on gateway.ready (never stomps a persisted pick), applies on skin.changed and the post-turn `config.get skin` poll (catch-all for agent-edited config.yaml). - TUI: `fromSkin` now maps the status bar + `background` keys it was dropping. - Gateway: `config.get skin` also returns the full resolved palette (additive). - Skill: `hermes-themes` teaches the agent to author + activate a skin. Each surface keeps its own normalizing resolver (ansi for the TUI, CSS vars for the desktop, prompt_toolkit/Rich for the CLI). * fix(themes): activate skins via `hermes config set`, never a config.yaml hand-edit The skill told the agent to `patch` display.skin into config.yaml; a stray indent corrupts the file and breaks the live gateway (the reported "/ menu broke"), and a raw file edit never live-applies in a running CLI/TUI ("nothing happened"). Route activation through the safe writer (`hermes config set display.skin`), and state plainly that a tool call can't hot-switch a running CLI/TUI — the user runs `/skin <name>` (desktop still auto-repaints on the next turn). * feat(themes): agent-authored skins switch live via a gateway skin watcher A skin Hermes activates (`hermes config set display.skin X`) or recolors in place now goes live on every surface (CLI, TUI, desktop) within ~half a second, on its own — no `/skin`, no tool-hook timing, no user action. A gateway daemon polls the resolved skin signature `(name, active-file mtime)` every 0.5s and broadcasts `skin.changed` on any real move — a name switch OR a live color edit to the active skin. It routes through the SAME path `/skin` uses, so all surfaces repaint identically. The watcher seeds its baseline at gateway.ready (stdio + ws) so it only fires on a real change; the `/skin` RPC seeds the baseline too so it never double-broadcasts. Subsumes the desktop's post-turn `config.get skin` poll (its skin.changed handler already applies). * feat(themes): TUI paints its own background from the skin (OSC 11) The TUI inherited the terminal's background; now a skin's `background` paints the whole surface via OSC 11 when a skin is applied, and clears back to the terminal default (OSC 111) on revert and on exit (ridden in through resetTerminalModes). Opt-in: a skin with no `background` leaves the terminal untouched, and the restore only fires if we actually painted. Desktop already themed its own bg; this closes the loop so Hermes owns its background on every surface. * feat(themes): element tokens (ui_tool, ui_thinking) + skinnable diffs Theming was semantic-only: the gold tool `●` was `accent`, shared with headings/links/chevrons, so "recolor tool calls" was impossible and the agent had no key to point at. Add `ui_tool` (● + tool spinner) and `ui_thinking` (reasoning body) tokens that fall back to accent/muted — defaults unchanged, but now independently settable. Make diffs skinnable too (`diff_*`), which fromSkin previously hardcoded. Document the full element→key map in the skill so Hermes knows which knob turns what. * fix(themes): tweak the ACTIVE skin in place, never fork default Changing one color ("make the tool ● cyan") forked `default` — which has no `background` — so applying it reset the terminal to its own (black) default and dropped the active skin's palette. Teach the skill to edit the active skin's file in place for a tweak (watcher repaints on the mtime bump), and to fork a built-in only by carrying its full palette. Hard pitfall: never fork `default` for a tweak. * feat(themes): `hermes skin set` — deterministic one-color tweak, bg untouched Changing a single color kept wrecking the rest because the agent hand-authored a new skin (often from `default`, which has no `background`, resetting the terminal to black). Add `hermes skin set <key> <hex>`: edits the ACTIVE skin's one key in place (a built-in is forked into an editable copy carrying its full palette), so everything else — background included — is preserved. Plus `skin use` / `skin list`. The skill now points tweaks at this command instead of hand-authoring. * feat(themes): dedicated code-syntax palette keys Code highlighting reused brand tokens (accent/text/border/muted), so it couldn't be themed independently. Add syntax_string/number/keyword/comment skin keys → syntax* theme tokens (defaulting to those brand tokens, so defaults are unchanged) and point the highlighter at them. Documented in the element→key map. * test(themes): E2E live skin switch — config write → skin.changed broadcast * fix(themes): reconcile element/syntax tokens with main's derive+adapt pipeline Element tokens (ui_tool/ui_thinking), skinnable diffs, and code-syntax keys flow through buildPalette → adaptColorsToBackground instead of a hand-mapped color block, so they inherit #20379's contrast/polarity machinery. thinking and syntaxComment track the EFFECTIVE muted (banner_dim override included); the skin's `background` feeds the surface (it also paints the terminal via OSC 11); statusFg falls back through ui_text/banner_text. Tests assert the routing/independence contracts rather than pre-adaptation hexes. * fix(themes): apply a runtime switch back to default on the desktop ingestBackendSkin returned early for name === 'default' even when apply=true, so a real runtime switch to the default skin (/skin default on CLI/TUI, or config.set display.skin=default) emitted skin.changed but never repainted the desktop. 'default' is no-opinion on the PALETTE (the desktop keeps its own nous default, so we still never register a converted theme under it), but it IS a valid apply TARGET: setTheme normalizes 'default' -> nous, so switching back repaints to the desktop default. Skip only the registry step for 'default' and let it flow through the apply guard. Addresses Copilot review. * fix(tui_gateway): serve candidate-inclusive display on warm/live resume #65919 persists verification candidates (finish_reason=verification_required / verify_hook_continue) to state.db but collapses them out of the in-memory model history via repair_message_sequence. The eager session.resume + REST paths read the verbatim display lineage (candidate present), but the warm/live-reuse payload (_live_session_payload) built its user-visible messages from the collapsed in-memory model history — so switching to a still-live session dropped the substantive verification answer that a cold resume of the SAME session showed. That divergence is the cross-session "substantive text vanishes on switch" class, and the direct sibling of the resume-duplication regression fixed in #68149. Reconcile the persisted display lineage (candidate-inclusive, the same get_messages_as_conversation(..., include_ancestors=True) read the eager resume + REST paths use) with the fresh in-memory tail in _live_visible_history, so all three surfaces agree by construction while a not-yet-flushed live turn is still shown. Extracted _reconcile_display_with_live as a pure, DI-testable function (anchors on the last persisted row's (role, text); appends only the uncovered in-memory tail; trusts the DB display when the tail can't be anchored). Tests: unit coverage for candidate-inclusion, freshness, empty/raising-DB fallback, and the combined candidate+fresh-tail case. The existing freshness guard (test_session_resume_live_payload_uses_current_history_with_ancestors) stays green. * fix(tui_gateway): candidate-inclusive display on child-watch resume + E2E Complete the #65919 warm/live-payload fix across its sibling path and add real-SessionDB cross-builder coverage. - Child-watch (lazy) resume: the delegated-subagent watch window served _history_to_messages(repaired_history) for its user-visible messages, which collapses out persisted verification candidates just like the warm-payload path did. Build the visible messages from the verbatim child-only display projection (repair_alternation=False) while the repaired history still feeds live replay; fall back to the repaired history if the display read fails. - E2E cross-builder consistency (real SessionDB, not mocks): a persisted verification candidate is collapsed out of the model projection but kept in the display projection, and _live_visible_history now equals the eager session.resume display projection (candidate present). Adds the combined candidate + fully-flushed-second-turn case and a lazy child-watch handler test that asserts the candidate survives in resp["result"]["messages"]. * fix(cli): add skin to _BUILTIN_SUBCOMMANDS for plugin gating The new hermes skin subcommand must be declared so startup plugin discovery can skip when the user targets it. * fmt(js): `npm run fix` on merge (#69048) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(desktop): Billing page revamp — current-plan card, in-app plans view, tier art (#68722) * feat(desktop): revamp Billing page — plan card, in-app plans view, tier art Reshape the desktop Billing settings per wayfinder ticket 09. New page order: Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance summary strip unchanged at the top. - CurrentPlanCard replaces the old Subscription row: tier name + price + renewal and at most one button — "View plans" (free/no-sub + can_change_plan), "Change plan" (subscriber + can_change_plan), or none for teams / non-changers. Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button navigates in-app to the plans sub-view. - bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam, default overview). BillingPlansView renders a grid of PlanCard from live tiers[] (is_enabled, sorted by tier_order, free tier included). - PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo"). Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗" (opens portal with plan=<tierId>); lower tiers are a DISABLED "Downgrade" with a caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so this PR intentionally links them out/disabled rather than wiring the money path. - buildManageSubscriptionUrl gains an optional third arg (tierId) → appends plan=<tierId>. Signature kept identical to draft PR #68666 for a trivial rebase; NAS #748 validates the param server-side. - Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox); unknown name → text-only card. Imported via vite static imports for packaged file:// + webSecurity. - Top-up vs auto-refill disambiguated by section label + first sentence: "One-time top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured copy reads "Charges $X automatically when your balance falls below $Y."). - Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two $ fields with a pre-allocated error line) and the action column (Manage → Save/ Cancel) in place, with the row height reserved for the tallest state so the Usage section never shifts. Fixes the spurious on-open validation error (errors now show only after an edit or a save attempt). Save/disable API calls + confirm-disable flow unchanged. - Remove subscriptionTierChips and the subscription-row chips; reshape (not delete) deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam. - Dev fixtures: add free-personal and subscriber-personal (personal orgs, full 4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable. Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts; delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean. * fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription Visual verification caught a spec-fidelity bug: in the plans grid, an account with no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗" upgrade — clicking would deep-link the portal to "subscribe to Free". Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order tier as the stand-in current plan when there is no subscription, so the free card renders exactly like is_current (inert, "Current plan") and — being the lowest order — no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade. CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is unchanged (Free stays a disabled Downgrade below the current Plus tier). Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean. * chore(desktop): shrink bundled tier art to 128px thumbnails The plan-card wells render the art at ~40px; shipping the full landing images added 2.7 MB to the repo for no visible difference. 128px covers 2x displays; total is now 26 KB. * fix(desktop): address 6 adversarial-review findings on the Billing revamp 1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier is_enabled:false; the enabled-only filter dropped it, leaving currentOrder undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers now resolves current identity/ordering against the UNFILTERED tiers and keeps the grandfathered current tier in the grid as the inert "Current plan" card; downgrades classify against its tier_order. (Non-current disabled tiers are still dropped.) 2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on can_change_plan, but the grid could be empty / current-only and showPlans refused, so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1 actionable (non-current) tier; otherwise it falls back to the portal link. 3. Deep-link bypass. showPlans now gates on the same capability that renders the button (view.plan?.action), so a team / non-changer deep-linking bview=plans always falls back to overview instead of a grid of live Choose buttons. 4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers, refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal link built from subscription?.portal_url ?? billing.portal_url — the refusal caption no longer promises a portal the UI didn't render. 5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan) instead of a bare return, so a null portal_url never strips the routing params. 6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two inputs stack below @2xl) with exact reservation: the edit form is always rendered and both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden when not editing — the row equals the tallest state at every width, no breakpoint math. The refusal stays inside the reserved layer. Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team & personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4. Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean. * refactor(desktop): reuse the shared openExternalLink helper in the plans view * fix(desktop): honor the auto_reload wire contract — null card + disable amounts A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two real desktop bugs in the auto-refill row: A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None for a missing/unknown-kind card and _serialize_billing_state emits `card: null`, but the shared BillingAutoReload.card union had no null arm and use-billing-state dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null` to the shared union (contract honesty) and guard the read (`card?.kind`); null now falls through to the default enabled path, same as a canonical card. B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.) disable() now sends the current threshold_usd/reload_to_usd from the autoReload prop alongside enabled: false, matching the TUI. Tests: enabled auto_reload with card:null renders the normal enabled row (derivation + render, no crash); disable call carries both current amounts. Billing suite 80/80 green; typecheck (app/electron/e2e) + lint clean. * fix(tui): guard the nullable auto_reload card in the auto-reload screen The shared BillingAutoReload.card union gained its honest null arm (the gateway emits card: null for a missing/unknown card); the TUI's only bare dereference follows the same default path as a canonical card. * fix(desktop): align billing inputs to the sm control height The three billing inputs used an ad-hoc h-8 (32px) next to size=sm buttons (24px). They now use the control system's size=sm with a py-[3px] compensation for the input's real 1px border — buttons draw theirs as an inset shadow, so sm alone still sits 2px taller. All five controls in the buy row now measure 24px. * fix(desktop): plan-card actionability + billing view-model hardening Code-quality review of the Billing revamp (PR #68722). BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a plans grid with zero enabled actions AND no portal link. The plan card gated its in-app button on `tiers.some(state !== 'current')`, which counts the (disabled) downgrade tiles. It now gates on an actual UPGRADE being present (`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same plan.action) falls back to overview. Reviewer structural items: - One "plans capability" verdict (personal + can_change_plan + subscription ok) is derived once in deriveBillingView and threaded to BOTH derivePlanCard and derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant lives in one place. - BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/ disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url` defensive branches in the consumers. - `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at its three sites (plan card price, grid ordering, summary plan line). - BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an `accountRows[]` + three `.find(id)` lookups. - The auto-refill row that edits in place carries an explicit `manageInApp: true`; AutoReloadRow keys off it instead of sniffing the action label/url. - tier-art header comment no longer cites an internal repo path; dead `?.` removed from RowValue (via a destructured const) and the plan-card link handler. Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck (app/electron/e2e) + lint clean. * refactor(desktop): adopt inline-review nits on the billing plan card Resolves the inline suggestion threads: - plan-card gate reads a named `hasActionableTier` = "a tile carries an action" (union-safe `'action' in tier`, equivalent to the old upgrade-only check). - re-narrow link/action inside the click callbacks (`plan.link && …`, `tier.action && …`) rather than relying on outer narrowing. Behavior unchanged; billing suite 82/82 green, typecheck + lint clean. * fmt(js): `npm run fix` on merge (#69050) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split (#68689) * feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split Bring the plain (non-TUI) CLI billing surface to parity with the desktop/TUI billing changes: - /subscription on Free (admin/owner, interactive) prints the plan catalog (name · $/mo · $credits/mo, from the same tiers[] data the TUI uses; monthly credits render as dollars). A numbered pick opens the manage-subscription deep-link directly with plan=<tier_id> appended. - subscription_manage_url(state, tier_id=...) appends plan=<tier_id> (the stable tiers[] id) when a tier was picked, org_id first — mirrors the TUI's ?plan=. The paid change flow's blocked/unknown-preview portal fallback carries plan= for upgrades only; downgrades stay generic/native. - /topup overview splits one-time top-up from automatic refill, the distinction stated in each first sentence ("Add funds now — a single charge…" vs "Refill when low — charges … automatically …"), keeping "credits" out of the dollars-only surface. - Downgrades remain native (chargeless scheduled change), unchanged. Updates the CLI-parity section of docs/billing-lifecycle.md and tests under tests/hermes_cli + tests/agent. * refactor(billing): share plan-catalog helpers + harden manage-url builder - subscription_manage_url now preserves unrelated portal query params (parse_qsl, popping only the contract-owned org_id/plan) and restricts to http/https schemes, matching the desktop URL builder — the function owns the contract. - Lift the plan-catalog derivation into agent/subscription_view.py so the CLI Free catalog and the paid picker/blocked-preview branch share one implementation: selectable_tiers (enabled paid, not current, sorted), format_tier_row (name · $/mo · $credits/mo — thousands-grouped like the TUI's toLocaleString, credits suffix hidden when absent/zero), and is_upgrade(state, tier_id). * fix(cli): numbered pick, canonical guarded browser opener, partial auto-refill copy - Free catalog: accept a bare digit as a pick (the shared normalizer only knows the confirm-dialog digit aliases, so `1` used to resolve to None → "Cancelled"). The Nth digit maps to the Nth printed row. - Extract one _open_url_in_browser used by every "open the portal" path, applying the device-code flows' console-browser / remote-session guard (webbrowser.open returns True even for lynx/w3m over SSH) and returning whether a real browser opened. - Consume the shared selectable_tiers / format_tier_row / is_upgrade helpers from the Free catalog, the paid picker, and the blocked-preview branch. - /topup auto-refill copy: the concrete "charges $X … below $Y." sentence only when both amounts are present and finite; otherwise the generic sentence. * docs(billing): correct CLI-parity rows (drop cross-repo ref, downgrade invariant) Remove the other-repo PR reference from the manage-URL row, and state the real downgrade invariant: a blocked downgrade may print the generic manage URL but never carries plan=<tier_id> — selected-tier deep-links are reserved for new subscriptions and upgrades. * feat(desktop): native in-app downgrade — chargeless preview → schedule → undo (#68761) * feat(desktop): native in-app downgrade (chargeless preview → schedule → undo) Ticket 11, stacked on the Billing revamp (ticket 09). Downgrades no longer bounce to the portal — picking a lower tier runs the gateway pending-change flow in-app; the scheduled state renders on the plan card with an undo. Upgrades keep the portal deep link. - api.ts: add previewSubscriptionChange / scheduleSubscriptionChange / resumeSubscription wrappers over subscription.preview|change|resume ({subscription_type_id} / {}), typed via SubscriptionPreviewResponse + BillingMutationResponse (now re-exported from types.ts). - use-subscription-change.ts (new): useDowngradeFlow (preview → confirm → schedule, refetch + onScheduled on success; typed refusals surface via the shared BillingRefusalInline, so insufficient_scope drives the existing step-up exactly like the auto-reload save, retried in place) and useResumeFlow (confirm-less undo). Both accept a `simulate` switch so DEV fixtures click through with canned success. - plans-view.tsx: downgrade tiles are now an actionable "Downgrade" that opens an in-card preview → confirm panel (mirrors the TUI confirm copy: "…takes effect <date>. No charge now; you keep your current plan until then."). The scheduled downgrade target renders an inert "Scheduled" marker; other lower tiers stay actionable (picking one reschedules). - CurrentPlanCard: when a downgrade is pending, the caption reads "Changes to <tier> on <when>." with an inline Undo → resume → refetch. One line, no jumps. - use-billing-state.ts: BillingPlanTierView gains a `scheduled` state (and drops the ticket-09 disabled-downgrade caption); derivePlanTiers matches the pending target by name (NAS sends no id for it) before the downgrade branch; BillingPlanCardView gains `pending`, derived from current.pending_downgrade_* . - inline-feedback.tsx (new): extracted openExternal / BillingRefusalInline / StepUpInlineAction / InlineMessage so the plans view reuses the step-up-aware refusal renderer without a circular import; openExternal now delegates to the canonical @/lib/external-link opener. - dev-fixtures.ts: add `pending-downgrade` (subscriber-personal on Plus with a Free downgrade scheduled for Aug 15) for the plan-card pending state + grid marker. Tests (+16 → 94 green in the billing suite): api wrappers (preview/change/resume + insufficient_scope refusal); view derivation (pending plan-card state, scheduled grid marker); confirm flow (preview shown, change called with the right tier_id, refetch on success, schedule refusal → step-up affordance); undo flow; the use-subscription-change hooks (preview-refusal retry, cancel, simulate path). Updated the ticket-09 downgrade tests for the now-actionable tile. typecheck (app/electron/e2e) + lint clean. PR (later): base sid/desktop-billing-revamp; retarget to main after #68722 (09) merges. * fix(desktop): format downgrade credits delta as signed dollars The downgrade preview rendered the raw wire string ("Monthly credits change: -88."), violating the "monthly credits are DOLLARS" ruling. NAS sends monthly_credits_delta as a bare decimal; format it as signed dollars through the same money formatter ("−$88/mo", sign preserved, abs value formatted). Zero / absent still hides the line. Adds formatMonthlyCreditsDelta (exported) + unit tests (negative/positive/zero/ absent) and asserts the rendered "Monthly credits change: −$88/mo." in the confirm flow. Billing suite 99/99 green; typecheck + lint clean. * fix(desktop): downgrade flow hardening — concurrency guard, a11y, DEV-gated sim Addresses the adversarial review of the native-downgrade diff. - Concurrency: useDowngradeFlow exposes `mutating` (true only while the schedule RPC is in flight). While a change commits, every other Downgrade tile and the Back button are disabled; the active panel's Confirm/Cancel already lock. The plan-card Undo blocks on its own resume via `busy`. (The server also 409s overlapping per-org mutations — this is UI honesty, not the only defense.) - Accessibility: the confirm panel is role="status" aria-live="polite" and takes focus on open (tabIndex=-1 container); closing it returns focus to the tile card, so keyboard focus is never stranded and the async preview text is announced. - DEV-gated simulation: the canned preview/change/resume seam is ignored unless import.meta.env.DEV, so a production build never takes the simulated branch even if a `simulate` prop leaks through. - Comments: documented the deliberate manual-retry-after-step-up (no auto-replay, matching auto-reload) and that name-matching the scheduled target is safe because SubscriptionTypes.name is @unique in NAS. Tests (+5 → 104 green in the billing suite): mutating exposed only during schedule; simulate ignored outside DEV; other downgrade tiles + Back disabled mid-schedule; Undo disabled mid-resume; confirm panel role + focus on open. typecheck (app/electron/e2e) + lint clean. * fix(desktop): scheduled cancellations, downgrade-flow concurrency, inline nits Addresses the native-downgrade review threads. Scheduled cancellations were invisible (NEW review item). subscription.current carries cancel_at_period_end + cancellation_effective_* and subscription.resume clears cancellations exactly like downgrades, but the pending-transition helper only read pending_downgrade_*, so a portal/TUI-scheduled cancellation rendered as a plain renewal with no Undo. The pending state is now a union — { kind:'downgrade', tierName, when } | { kind:'cancellation', when } — computed once in deriveBillingView and threaded to BOTH the plan card and the grid. The card reads "Cancels on <date>." with the same Undo (resume); the grid shows a Scheduled marker only for downgrades (a cancellation has no target tier). Precedence: a downgrade wins if both fields are set (it names a concrete target — the stronger signal), commented at the helper. Adds a `pending-cancellation` fixture + tests (card copy, undo wiring, no grid marker, downgrade-wins precedence). Concurrency: confirm() takes a synchronous scheduling ref (mirroring useResumeFlow) so two same-tick clicks — before React commits busy='schedule' — cannot fire two schedule RPCs; the ref clears on every exit (simulated/stale/refusal/success). useResumeFlow reorders its unlock: a refusal releases immediately, a success holds runningRef/busy THROUGH the refetch so Undo never re-enables against the still-pending card. Test: a synchronous double-activation fires one schedule RPC. Inline nits: re-narrow link/action inside the click callbacks (`plan.link && …`, `tier.action && …`) instead of relying on outer narrowing / `?? ''`. Billing suite green (109); typecheck (app/electron/e2e) + lint clean. * refactor(desktop): move DEV billing simulation behind the api seam The fixture simulation lived as `simulate` / `simulateResume` prop drills and `if (simulated)` branches inside the flow hooks, and it could not actually produce the state it advertised (a simulated schedule never showed the pending card). Replaced with `createSimulatedBillingApi(fixture)` — a fully in-memory BillingApi built once, DEV-gated, in BillingSettingsWithDevFixtures where the fixture is known, and supplied to the whole subtree via a new `BillingApiProvider` (context override on `useBillingApi`; `null` = the real gateway api). It serves fetches from a mutable copy of the fixture and its subscription-change mutations WRITE that copy's pending state: schedule sets a pending downgrade, resume clears a pending downgrade OR cancellation. Fixture mode now flows through the SAME react-query path (fetch short-circuit deleted; queries always enabled; an effect refetches on fixture switch), so the click-through genuinely progresses — schedule → pending card + Undo + Scheduled marker, undo → cleared. Deleted `SubscriptionSimulation`, `simulationEnabled`, both prop drills, and every `if (simulated)` branch — the hooks are now production-pure. Added a test driving the full simulated loop (schedule → pending appears → resume → cleared), plus cancellation undo and no-shared-mutation coverage. Removed the now-obsolete simulate hook tests. Billing suite green (110); typecheck (app/electron/e2e) + lint clean. * refactor(desktop): extract billing row/card components out of index.tsx Purely mechanical, no behavior change: split the settings billing route file (1065 → 593 lines) into focused siblings now that the downgrade feature has settled their final shape. - billing-amounts.ts — the dollar parse/format/validate/clamp helpers. - account-row-value.tsx — RowValue (shared by AccountRow + AutoReloadRow). - current-plan-card.tsx — CurrentPlanCard. - auto-reload-row.tsx — AutoReloadRow (the in-place auto-refill editor). index.tsx keeps the page shell, AccountRow dispatch, BuyCredits flow, and the fixture wiring. Billing suite green (110); typecheck (app/electron/e2e) + lint clean. * refactor(desktop): tighten the downgrade flow — phase union, previewMessage, tidy shared modules Polish that composes with the api-seam rework: - ActiveDowngrade's four nullables become a `DowngradePhase` discriminated union (previewing | previewFailed | ready | scheduling | scheduleFailed). Impossible combinations (a preview AND a refusal, "ready" with no quote) can no longer be represented; the hook and panel branch on one `kind`, and `mutating` is simply `phase.kind === 'scheduling'`. - The five-way ternary in DowngradeConfirm is replaced by a pure `previewMessage(phase, fallbackTierName)` helper; the misnamed `caption` className local is renamed `captionCn`. - inline-feedback.tsx now holds ONLY the shared refusal/step-up pieces: `openExternal` moves to its own `open-external.ts` (a thin wrapper over `@/lib/external-link`'s `openExternalLink`), and `InlineMessage` moves back into its sole consumer (auto-reload-row.tsx). No behavior change. Billing suite green (110); typecheck (app/electron/e2e) + lint clean. * fmt(js): `npm run fix` on merge (#69067) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(gateway): hard-exit CLI runner after graceful teardown * test: update gateway run stub for hard-exit helper * fix(gateway): hard-exit on KeyboardInterrupt path too The KeyboardInterrupt handler in run_gateway() was the only exit path that still used bare 'return' instead of _hard_exit_after_gateway_teardown(). While less common than service-managed restarts, a console Ctrl+C still leaves the process vulnerable to the same Python finalization hang on non-daemon worker threads (cron ThreadPoolExecutor jobs). Route it through the same backstop, with a 'return' guard for test stubs that don't raise on code 0 (production os._exit never returns). * fix(cli): pass conversation_history on /new /resume /branch flush Closes #68454 Root cause: cold-resumed transcript rows lack _DB_PERSISTED_MARKER until a normal turn flush stamps them. Immediate /new,/resume,/branch flushed with no history boundary, so every restored row was re-appended to the old session. Fix: pass conversation_history=self.conversation_history at all three sites (mirrors #68205). Add offline regression coverage for noop + tail-only write. Verification: pytest tests/agent/test_session_rotation_flush_cold_resume_68454.py (4 passed) * test: drop source-grep change-detector from #68480 The three behavior tests (control proves dup, boundary is noop, tail-only write) fully cover the flush semantics. The source-grep test reading cli.py + cli_commands_mixin.py as text and asserting a string appears is a change-detector that breaks on benign refactors without adding coverage. * test: update mock assertions for conversation_history kwarg The /branch and /resume flush tests asserted the old positional-only call signature. Update to match the fix from #68480. * fix(gateway): make adapter fatal-error handoff cancellation-proof; exit if a platform is stranded The fatal-error notification runs on the failing adapter's own polling task, and adapter.disconnect() inside the handler can cancel that task (its current-task guard misses because _safe_adapter_disconnect runs the close in a wrapper task). The CancelledError killed the handler between the fatal log and the reconnect queue, leaving the platform permanently dead inside a live gateway process. #68447 fixed this for telegram at the adapter layer; this hardens the shared gateway dispatch so every platform gets the same protection (qqbot #25505/#29005, photon #68693). - _handle_adapter_fatal_error now runs the real handler in a detached task, awaited through asyncio.shield() so caller cancellation cannot tunnel into it (Task.cancel() also cancels the task's _fut_waiter). - If a retryable platform still ends up neither reconnected nor queued, the gateway exits with failure so launchd/systemd KeepAlive restarts it instead of running indefinitely with a dead platform (#68693). Fixes #68693 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: map anoop.mehendale@gmail.com -> anoopmehendale-cue For PR #69007 salvage (#69112). * fix(update): isolate systemctl timeouts per gateway unit during fleet restart A TimeoutExpired from one hermes-gateway*.service used to abort the whole per-scope restart loop, leaving later profile gateways on pre-update in-memory code after hermes update. Catch timeouts per unit, continue the fleet, warn with the exact stale units, and exit non-zero when any remain unrestarted (#68523). * test(update): cover fleet restart timeout isolation (#68523) * fix: refresh vulnerable npm lockfile entries * chore: AUTHOR_MAP for tinetwork * fix(compression): prevent stale-budget retry loops * fix(compression): harden startup route scoping * fix(providers): align custom route scoping * test(compression): cover overflow after blocked preflight * test(providers): cover route URL identity boundaries * test(providers): cover query path slash identity * test(providers): complete hermetic route coverage * test(providers): cover URL whitespace route identity * fix(providers): fail closed on missing active route * fix(providers): scope route-owned runtime settings * fix: restore base_url rstrip, extract should_clear_context_pin helper Salvage follow-up for PR #68899: - Restore .rstrip('/') on base_url in _swap_credential (both anthropic and OpenAI paths) to match every other assignment site. The route identity comparison still uses normalize_route_base_url which handles trailing slash correctly. - Extract should_clear_context_pin() into hermes_cli/route_identity.py, consolidating 7 copy-pasted call sites across cli.py, gateway/run.py, gateway/slash_commands.py, and hermes_cli/model_switch.py into a single fail-closed helper. C1 (anthropic path TLS re-application): pre-existing gap — the Anthropic adapter (build_anthropic_client) has no TLS customization support at all, so this is out of scope for this salvage. * fix(compression): ignore assistant handoff summaries in tail anchor Assistant-role compaction summaries were treated as the last visible assistant reply after head protection decayed. That pulled the tail boundary back to the summary itself and left zero new turns to summarize. Exclude internal context summaries from both the visible-reply search and the assistant fallback, mirroring the existing user-role summary exclusion. * chore: AUTHOR_MAP for McHermes * fix(telegram): group authz fallback + command sender identity - authz_mixin: add config.extra fallback for group_allowed_chats when observe-unmentioned mode strips user_id from env-var check - authz_mixin: check adapter allow_from/group_allow_from for user authorization from config.yaml without env vars - telegram/adapter: separate group_allow_from for group chats vs allow_from for DMs - telegram/adapter: preserve sender source for command messages so admin-only slash commands work in groups - telegram/adapter: add _telegram_extra fallback for group_allow_from config reading * fix(telegram): address review findings from PR #67816 - Update test_observed_group_context_preserves_slash_command_text_for_dispatch to assert user_id is preserved for COMMAND messages (new correct behavior) - Add _coerce_allow_set helper to handle both list and comma-separated string allowlist inputs (prevents character-by-character iteration bug) - Include 'channel' in chat_type checks for group-scoped authorization - Add _telegram_extra fallback for group_allowed_chats (consistent with group_allow_from fallback) - Add AUTHOR_MAP entry for nyaruko@hermes -> tsuk1nose * fix(telegram): update auth check tests for group_allow_from split Update test_telegram_auth_check.py to use group_allow_from for group messages (matching the PR's intentional behavior split: allow_from for DMs, group_allow_from for groups). Add test_is_user_authorized_from_message_group_allow_from to cover the new group path. * fix(openviking): recover pending session commits * docs: clarify OpenViking local setup (cherry picked from commita6807170f1) (cherry picked from commit6fb4e9aa8a) * fix(openviking): serialize orphan session recovery * fix(openviking): chunk structured session sync Preserve ordered structured turns across OpenViking's 100-message batch limit and resume retries from the first unconfirmed message. Based on the OpenViking batching work from commit1a567f7067in #58981. * refactor: cleanup follow-up for salvaged PR #58871 - Remove dead current_sid parameter from _recover_pending_sessions - Remove dead cleanup parameter from _release_owner_run_claim (always True) - Set _run_lock_path after flock succeeds, not before - Collapse redundant BlockingIOError branch (covered by OSError+errno check) - Track _pending_marked_sids to skip re-writing marker file on every sync_turn * fix(openviking): inject session-start memory context (cherry picked from commit18b474d0bd) * fix(openviking): align session context with shared profile contract * fix: discard both session IDs on compression for profile re-injection The _profile_prefetched_sessions set stores whichever session_id was passed to prefetch(), which may differ from self._session_id. On compression, only old_session_id (self._session_id) was discarded, missing the case where the stored key was the prefetch session_id parameter. Discard both old and new IDs to cover all cases. * chore: add kshitij@kshitij.dev to AUTHOR_MAP * fix(secrets): fall back to stale disk cache when bws live fetch fails Without this, a single DNS hiccup or BWS outage at gateway startup leaves the whole fleet running with an empty credential pool — every model call fails until someone restarts after the network recovers. When a previous successful fetch already populated the disk cache, return those secrets with an explicit warning instead of raising RuntimeError. `use_cache=False` (explicit opt-out) still raises so manual flows like the setup wizard surface the original error. The disk cache is not re-written on the fallback path so a process restart still triggers a proper TTL re-check. Fixes #41925 * fix(secrets): port stale-cache fallback to current DiskCache API + gate by error kind The stale-fallback branch called _read_disk_cache(), a helper removed indb495b0fbawhen disk-cache logic moved to the shared DiskCache class — every fallback attempt raised NameError instead of serving cached secrets, silently defeating the PR's whole purpose. Port to _DISK_CACHE.read(). Also tighten the fallback per DiskCache's TTL contract and the secret-source error taxonomy: - Gate on cache_ttl_seconds > 0 so a caller that opted out of caching entirely (ttl=0) never gets a secret value that didn't come from a live fetch, even on the failure path. - Gate on _classify_bws_error(str(exc)) being NETWORK or TIMEOUT, reusing the existing classifier — an AUTH_FAILED or malformed-output failure must still raise, since serving stale secrets there would mask a real credential/config problem instead of a transient outage. Ported the test helpers off the removed _write_disk_cache to a direct JSON write (matching this file's existing disk-cache test convention) and added tests for the auth-failure, malformed-output, and zero-TTL gates. Reverting the fix and re-running confirms 7 of 8 stale-fallback tests fail with the original NameError. * fix(secrets): fold OP_CONNECT_HOST/OP_CONNECT_TOKEN into 1Password auth cache-key _auth_fingerprint() built the 1Password secret cache-key from the service-account token, OP_ACCOUNT, and OP_SESSION_* vars but omitted OP_CONNECT_HOST/OP_CONNECT_TOKEN, which are in _OP_ENV_ALLOWLIST and are forwarded to the op child (the Connect-server auth path). Rotating OP_CONNECT_TOKEN or re-pointing OP_CONNECT_HOST at a different Connect identity left the fingerprint unchanged, so both the in-process and disk caches kept serving secrets resolved under the old Connect credentials for the full TTL (default 300s, disk-persisted across invocations). This contradicts the function's own docstring invariant that a value cached under a previous identity is never served under a new one; it closes the gap for the Connect path, matching the OP_SESSION_*/service-account paths that are already protected. * fix(secrets): pass OP_LOAD_DESKTOP_APP_SETTINGS through to the op child env The 1Password secret source builds a minimal allowlisted environment for the `op read` child process. The allowlist omits OP_LOAD_DESKTOP_APP_SETTINGS, so a user who exports it (shell, .env, or service unit) sees it silently stripped before it reaches `op`. That var is `op`'s documented switch to skip the desktop-app integration probe. When the 1Password desktop app is installed, `op` probes its settings/socket at startup *before* evaluating service-account auth. If the desktop app's group container is wedged (e.g. macOS 'Interrupted system call' on the 1Password group container), that probe blocks with no timeout, so `op read` hangs indefinitely even with a valid OP_SERVICE_ACCOUNT_TOKEN present. Setting OP_LOAD_DESKTOP_APP_SETTINGS=false is the intended escape hatch — but stripping it means it has no effect on exactly the headless boxes that need it. Fix: add OP_LOAD_DESKTOP_APP_SETTINGS to _OP_ENV_ALLOWLIST so the documented var reaches the child. No behavior change when it's unset. Adds a focused test alongside the existing allowlist test. Repro: on a machine with a wedged 1Password desktop container + a valid SA token, `op read` hangs 600s+ without the var and returns in ~4s with it — but only if it actually reaches the op process, which this allowlist entry ensures. Co-authored-by: Minh Nguyen <menhguin@users.noreply.github.com> * fix(mcp): pass secret-source-injected env vars to stdio servers Surgical reapply of PR #37523 onto current main (the original branch predates the SecretSource registry refactor). _build_safe_env() now forwards env vars tagged in env_loader._SECRET_SOURCES — widened from Bitwarden-only to any registered secret source (Bitwarden, 1Password, plugin backends), since the provenance map is source-agnostic. Explicit server env: config still wins; untagged secrets stay filtered. Fixes #37499. * fix(env): stop printing Bitwarden secret names * fix(secrets): validate bitwarden status token Keep the env-presence row, but add a real Bitwarden probe so revoked or malformed tokens no longer look healthy in hermes secrets bitwarden status. Also document the new status behavior and lock it in with a dedicated regression test. Refs: NousResearch/hermes-agent#40275 Tested: ./scripts/run_tests.sh tests/hermes_cli/test_bitwarden_status.py tests/test_bitwarden_secrets.py Tested: .venv/bin/python -m ruff check hermes_cli/secrets_cli.py tests/hermes_cli/test_bitwarden_status.py * fix(secrets): mark _APPLIED_HOMES only after a real fetch attempt (#40597) (#69056) _apply_external_secret_sources() added the home to _APPLIED_HOMES before loading config, so a malformed config.yaml, a missing secrets section, or all-sources-disabled permanently disabled secret loading for the process — even after the user fixed the config. Long-lived processes (gateway) never recovered without a restart. Now the home is marked only after apply_all() actually ran with at least one enabled source. Fetch errors still mark the home (so import-time load_hermes_dotenv() calls don't re-fetch and re-print the same failure 3-5x per startup); the cheap early-exit paths stay retryable. Fixes #40597. * fix(secrets): fall back to os.environ on scope miss when multiplexing is offfdab380a1wraps every cron job in a <home>/.env secret scope regardless of deployment mode. get_secret() treats any installed scope as authoritative, so in single-profile deployments where provider keys live only in the process environment (systemd Environment=, pass-cli/op run wrappers, shell exports) every cron credential read returns empty, the OpenAI client is built with the no-key-required placeholder, and each scheduled job 401s — while interactive turns keep working. Scope-miss reads now fall through to os.environ when multiplexing is off; multiplexed scopes stay authoritative. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(gateway): activate multiplex flag in cross-profile env isolation test The test installs a secret scope and asserts a scope miss does NOT fall back to the default profile's env — that isolation guarantee only holds under multiplexing, which the real gateway activates at startup via set_multiplex_active(). With the #67827 overlay fallthrough (scope miss → os.environ when multiplex is OFF), the test needs to model the multiplexed runtime it is actually testing. * feat(secrets): orchestrator-level preserve_existing + profile aliasing (#69058) Fixes the profile-clobber bug cluster at the apply_all() chokepoint so every secret source — bundled and plugin — gets both behaviors for free: - secrets.preserve_existing (#58073): env var names whose existing .env / shell value always wins, even against a source with override_existing: true. Escape hatch for per-profile platform secrets while everything else rotates centrally. - Profile aliasing (#51447): under a named profile, an applied FOO_<PROFILE> var (credential-shaped suffixes only) also hydrates the canonical FOO, so adapters/plugins that read fixed env names see the profile's value. Direct supply beats alias; protected/claimed/ override guards all apply; secrets.profile_alias: false disables. Reimplements the intent of PR #58085 (tianma-if, preserve_existing on the legacy Bitwarden apply shim) and PR #51616 (LeonSGP43, profile aliasing inside the Bitwarden backend) on the SecretSource orchestrator that superseded those code paths. Fixes #58073. Fixes #51447. Co-authored-by: tianma-if <5895871+tianma-if@users.noreply.github.com> Co-authored-by: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com> * test(state): accept FTS5-specific corruption message in read-probe test With the v23 external-content FTS layout, corrupt shadow segments surface as 'fts5: corrupt structure record for table ...' instead of the generic 'database disk image is malformed'. Same corruption class, same variance already documented and handled by _is_fts_write_corruption_error — widen the test assertion to match. --------- Co-authored-by: ethernet <arilotter@gmail.com> Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com> Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com> Co-authored-by: yoniebans <jonny@nousresearch.com> Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com> Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai> Co-authored-by: xxxigm <54813621+xxxigm@users.noreply.github.com> Co-authored-by: hermes-seaeye[bot] <307254004+hermes-seaeye[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com> Co-authored-by: HexLab98 <liruixinch@outlook.com> Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Co-authored-by: PRATHAMESH75 <prathamesh290504@gmail.com> Co-authored-by: x7peeps <xtpeeps@qq.com> Co-authored-by: Imgaojp <6065749+Imgaojp@users.noreply.github.com> Co-authored-by: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Co-authored-by: Ben Barclay <ben@nousresearch.com> Co-authored-by: sbe27 <283218367+sbe27@users.noreply.github.com> Co-authored-by: TARS <tars@users.noreply.github.com> Co-authored-by: Austin Pickett <pickett.austin@gmail.com> Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com> Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com> Co-authored-by: Joshua <joshua@amokk.net> Co-authored-by: alelpoan <155192176+alelpoan@users.noreply.github.com> Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> Co-authored-by: Cossackx <121278003+Cossackx@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Jaret Bottoms <jaretbottoms@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Enough1122 <chenjin@hermes.local> Co-authored-by: Frowtek <frowte3k@gmail.com> Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com> Co-authored-by: eazye19 <eazye19@users.noreply.github.com> Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com> Co-authored-by: hanyu1212 <28571259+hanyu1212@users.noreply.github.com> Co-authored-by: 雨哥 <hanyu1212@users.noreply.github.com> Co-authored-by: yungchentang <46495124+yungchentang@users.noreply.github.com> Co-authored-by: trevorgordon981 <trevorbgordon@gmail.com> Co-authored-by: Börje <borje@dqsverige.se> Co-authored-by: fazerluga-creator <fazerluga@gmail.com> Co-authored-by: yoma <yingwaizhiying@gmail.com> Co-authored-by: Kennedy Umege <kenmege@yahoo.com> Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com> Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com> Co-authored-by: iamwongeeeee <wykim777@naver.com> Co-authored-by: web3blind <264741654+web3blind@users.noreply.github.com> Co-authored-by: Bartok9 <danielrpike9@gmail.com> Co-authored-by: anoopmehendale-cue <anoop.mehendale@gmail.com> Co-authored-by: tinetwork <martin@tinetwork.com> Co-authored-by: cucurigoo <241698038+cucurigoo@users.noreply.github.com> Co-authored-by: McHermes <mchermes@edu.dreamcatcher.ai> Co-authored-by: Nyaruko <nyaruko@hermes> Co-authored-by: Hao Zhe <haozhe4547@gmail.com> Co-authored-by: Naveen Fernando <90832919+NPFernando@users.noreply.github.com> Co-authored-by: Chris Korhonen <ckorhonen@gmail.com> Co-authored-by: Flownium <157689911+itsflownium@users.noreply.github.com> Co-authored-by: kshitij <kshitij@kshitij.dev> Co-authored-by: JackJin <1037461232@qq.com> Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com> Co-authored-by: menhguin <17287724+menhguin@users.noreply.github.com> Co-authored-by: Minh Nguyen <menhguin@users.noreply.github.com> Co-authored-by: SeoYeonKim <28585885+westkite1201@users.noreply.github.com> Co-authored-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Co-authored-by: izumi0uu <izumi0uu@gmail.com> Co-authored-by: Soju06 <qlskssk@gmail.com> Co-authored-by: tianma-if <5895871+tianma-if@users.noreply.github.com> Co-authored-by: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com>
6765 lines
286 KiB
Python
6765 lines
286 KiB
Python
"""Tests for hermes_state.py — SessionDB SQLite CRUD, FTS5 search, export."""
|
||
|
||
import sqlite3
|
||
import time
|
||
import json
|
||
from unittest import mock
|
||
|
||
import pytest
|
||
|
||
import hermes_state
|
||
from hermes_state import SCHEMA_SQL, SCHEMA_VERSION, SessionDB
|
||
|
||
|
||
class _NoFtsCursor(sqlite3.Cursor):
|
||
"""Simulate a SQLite build without the fts5 module."""
|
||
|
||
def execute(self, sql, parameters=()):
|
||
probe = sql.strip()
|
||
if "USING fts5" in probe:
|
||
raise sqlite3.OperationalError("no such module: fts5")
|
||
if probe in (
|
||
"SELECT * FROM messages_fts LIMIT 0",
|
||
"SELECT * FROM messages_fts_trigram LIMIT 0",
|
||
):
|
||
raise sqlite3.OperationalError("no such table: " + probe.split()[-3])
|
||
return super().execute(sql, parameters)
|
||
|
||
def executescript(self, sql_script):
|
||
if "USING fts5" in sql_script:
|
||
raise sqlite3.OperationalError("no such module: fts5")
|
||
return super().executescript(sql_script)
|
||
|
||
|
||
class _NoFtsConnection(sqlite3.Connection):
|
||
def cursor(self, factory=None):
|
||
return super().cursor(factory or _NoFtsCursor)
|
||
|
||
|
||
class _NoFtsExistingTableCursor(_NoFtsCursor):
|
||
"""Simulate existing FTS virtual tables under a runtime without FTS5."""
|
||
|
||
def execute(self, sql, parameters=()):
|
||
probe = sql.strip()
|
||
if probe in (
|
||
"SELECT * FROM messages_fts LIMIT 0",
|
||
"SELECT * FROM messages_fts_trigram LIMIT 0",
|
||
):
|
||
raise sqlite3.OperationalError("no such module: fts5")
|
||
return super().execute(sql, parameters)
|
||
|
||
|
||
class _NoFtsExistingTableConnection(sqlite3.Connection):
|
||
def cursor(self, factory=None):
|
||
return super().cursor(factory or _NoFtsExistingTableCursor)
|
||
|
||
|
||
class _NoTrigramCursor(sqlite3.Cursor):
|
||
"""Simulate a SQLite build with FTS5 but without the trigram tokenizer."""
|
||
|
||
def executescript(self, sql_script):
|
||
if "tokenize='trigram'" in sql_script:
|
||
raise sqlite3.OperationalError("no such tokenizer: trigram")
|
||
return super().executescript(sql_script)
|
||
|
||
|
||
class _NoTrigramConnection(sqlite3.Connection):
|
||
def cursor(self, factory=None):
|
||
return super().cursor(factory or _NoTrigramCursor)
|
||
|
||
|
||
@pytest.fixture()
|
||
def db(tmp_path):
|
||
"""Create a SessionDB with a temp database file."""
|
||
db_path = tmp_path / "test_state.db"
|
||
session_db = SessionDB(db_path=db_path)
|
||
yield session_db
|
||
session_db.close()
|
||
|
||
|
||
# =========================================================================
|
||
# Session lifecycle
|
||
# =========================================================================
|
||
|
||
class TestSessionLifecycle:
|
||
def test_create_and_get_session(self, db):
|
||
sid = db.create_session(
|
||
session_id="s1",
|
||
source="cli",
|
||
model="test-model",
|
||
)
|
||
assert sid == "s1"
|
||
|
||
session = db.get_session("s1")
|
||
assert session is not None
|
||
assert session["source"] == "cli"
|
||
assert session["model"] == "test-model"
|
||
assert session["ended_at"] is None
|
||
|
||
|
||
def test_get_nonexistent_session(self, db):
|
||
assert db.get_session("nonexistent") is None
|
||
|
||
def test_create_session_enriches_null_metadata_on_conflict(self, db):
|
||
"""Gateway creates a bare row first; the agent's later create_session
|
||
must backfill model/model_config/system_prompt without clobbering the
|
||
gateway's source/user_id/chat_id. Regression for NULL gateway metadata
|
||
(sessions with NULL billing_provider/model)."""
|
||
# Gateway bare row (source + user_id only), before the agent exists.
|
||
db.create_session("s1", source="telegram", user_id="u1", chat_id="c1")
|
||
bare = db.get_session("s1")
|
||
assert bare["model"] is None
|
||
# Agent enriches — passes source="cli" but real metadata.
|
||
db.create_session(
|
||
"s1", source="cli", model="claude-opus-4-6",
|
||
model_config={"max_iterations": 90}, system_prompt="SYS",
|
||
)
|
||
enriched = db.get_session("s1")
|
||
assert enriched["model"] == "claude-opus-4-6"
|
||
assert enriched["system_prompt"] == "SYS"
|
||
# Gateway-owned fields preserved (NOT clobbered by source="cli").
|
||
assert enriched["source"] == "telegram"
|
||
assert enriched["user_id"] == "u1"
|
||
assert enriched["chat_id"] == "c1"
|
||
|
||
def test_create_session_does_not_overwrite_existing_metadata(self, db):
|
||
"""A later bare write (source='unknown', model=...) must not overwrite
|
||
a model/source an earlier writer already set."""
|
||
db.create_session("s1", source="cli", model="real-model")
|
||
db.create_session("s1", source="unknown", model="should-not-win")
|
||
session = db.get_session("s1")
|
||
assert session["model"] == "real-model"
|
||
assert session["source"] == "cli"
|
||
|
||
def test_update_session_cwd_persists_git_branch(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.update_session_cwd("s1", "/work/repo", git_branch="pets-feature")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["cwd"] == "/work/repo"
|
||
assert session["git_branch"] == "pets-feature"
|
||
|
||
def test_update_session_cwd_empty_branch_does_not_clobber(self, db):
|
||
"""A failed branch probe (empty string) must not wipe a branch we
|
||
already captured — only the cwd updates."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.update_session_cwd("s1", "/work/repo", git_branch="main")
|
||
db.update_session_cwd("s1", "/work/repo", git_branch="")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["git_branch"] == "main"
|
||
|
||
def test_update_session_cwd_without_branch_arg(self, db):
|
||
"""Back-compat: callers that pass only (id, cwd) still work."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.update_session_cwd("s1", "/work/repo")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["cwd"] == "/work/repo"
|
||
assert session["git_branch"] is None
|
||
|
||
def test_update_session_cwd_persists_git_repo_root(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.update_session_cwd("s1", "/work/repo/src", git_repo_root="/work/repo")
|
||
|
||
assert db.get_session("s1")["git_repo_root"] == "/work/repo"
|
||
|
||
def test_update_session_cwd_empty_repo_root_does_not_clobber(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.update_session_cwd("s1", "/work/repo", git_repo_root="/work/repo")
|
||
db.update_session_cwd("s1", "/work/repo", git_repo_root="")
|
||
|
||
assert db.get_session("s1")["git_repo_root"] == "/work/repo"
|
||
|
||
def test_distinct_session_cwds_aggregates_history(self, db):
|
||
db.create_session("s1", "cli", cwd="/repo")
|
||
db.create_session("s2", "cli", cwd="/repo")
|
||
db.create_session("s3", "cli", cwd="/other")
|
||
db.create_session("s4", "cli") # no cwd — excluded
|
||
|
||
rows = {r["cwd"]: r["sessions"] for r in db.distinct_session_cwds()}
|
||
assert rows == {"/repo": 2, "/other": 1}
|
||
|
||
def test_backfill_repo_roots_fills_only_empty(self, db):
|
||
db.create_session("s1", "cli", cwd="/repo/a")
|
||
db.create_session("s2", "cli", cwd="/repo/b")
|
||
db.update_session_cwd("s2", "/repo/b", git_repo_root="/already")
|
||
|
||
db.backfill_repo_roots({"/repo/a": "/repo", "/repo/b": "/repo"})
|
||
|
||
assert db.get_session("s1")["git_repo_root"] == "/repo"
|
||
# Pre-existing root is preserved, not clobbered.
|
||
assert db.get_session("s2")["git_repo_root"] == "/already"
|
||
|
||
def test_end_session(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.end_session("s1", end_reason="user_exit")
|
||
|
||
session = db.get_session("s1")
|
||
assert isinstance(session["ended_at"], float)
|
||
assert session["end_reason"] == "user_exit"
|
||
|
||
def test_end_session_preserves_original_end_reason(self, db):
|
||
"""The first end_reason wins — compression splits must not be
|
||
overwritten when a later stale ``end_session()`` call lands on the
|
||
same row (e.g. from a CLI session_id that desynced after compression
|
||
and then tried to /resume another session).
|
||
"""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.end_session("s1", end_reason="compression")
|
||
first_ended_at = db.get_session("s1")["ended_at"]
|
||
|
||
# Simulate a stale CLI holding the old session_id and calling
|
||
# end_session() again with a different reason.
|
||
time.sleep(0.01)
|
||
db.end_session("s1", end_reason="resumed_other")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["end_reason"] == "compression"
|
||
assert session["ended_at"] == first_ended_at
|
||
|
||
def test_end_session_after_reopen_allows_re_end(self, db):
|
||
"""reopen_session() is the explicit escape hatch for re-ending a
|
||
closed session. After reopen, end_session() takes effect again.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.end_session("s1", end_reason="compression")
|
||
db.reopen_session("s1")
|
||
db.end_session("s1", end_reason="user_exit")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["end_reason"] == "user_exit"
|
||
|
||
def test_update_system_prompt(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.update_system_prompt("s1", "You are a helpful assistant.")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["system_prompt"] == "You are a helpful assistant."
|
||
|
||
def test_update_token_counts(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.update_token_counts("s1", input_tokens=200, output_tokens=100)
|
||
db.update_token_counts("s1", input_tokens=100, output_tokens=50)
|
||
|
||
session = db.get_session("s1")
|
||
assert session["input_tokens"] == 300
|
||
assert session["output_tokens"] == 150
|
||
|
||
def test_update_token_counts_tracks_api_call_count(self, db):
|
||
"""api_call_count increments with each update_token_counts call."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.update_token_counts("s1", input_tokens=100, output_tokens=50, api_call_count=1)
|
||
db.update_token_counts("s1", input_tokens=100, output_tokens=50, api_call_count=1)
|
||
db.update_token_counts("s1", input_tokens=100, output_tokens=50, api_call_count=1)
|
||
|
||
session = db.get_session("s1")
|
||
assert session["api_call_count"] == 3
|
||
|
||
def test_update_token_counts_api_call_count_absolute(self, db):
|
||
"""absolute mode sets api_call_count directly."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.update_token_counts("s1", input_tokens=100, output_tokens=50, api_call_count=1)
|
||
db.update_token_counts("s1", input_tokens=300, output_tokens=150,
|
||
api_call_count=5, absolute=True)
|
||
|
||
session = db.get_session("s1")
|
||
assert session["api_call_count"] == 5
|
||
assert session["input_tokens"] == 300
|
||
|
||
def test_update_token_counts_backfills_model_when_null(self, db):
|
||
db.create_session(session_id="s1", source="telegram")
|
||
db.update_token_counts("s1", input_tokens=10, output_tokens=5, model="openai/gpt-5.4")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["model"] == "openai/gpt-5.4"
|
||
|
||
def test_first_accounted_fallback_replaces_requested_primary_route(self, db):
|
||
"""First successful fallback usage must persist one coherent route pair."""
|
||
db.create_session(session_id="s1", source="cli", model="gpt-5.6-sol")
|
||
|
||
db.update_token_counts(
|
||
"s1",
|
||
input_tokens=10,
|
||
output_tokens=5,
|
||
model="glm-5.2",
|
||
billing_provider="custom:zai",
|
||
billing_base_url="https://api.z.ai/api/coding/paas/v4/",
|
||
api_call_count=1,
|
||
)
|
||
|
||
session = db.get_session("s1")
|
||
assert session["model"] == "glm-5.2"
|
||
assert session["billing_provider"] == "custom:zai"
|
||
assert session["billing_base_url"] == "https://api.z.ai/api/coding/paas/v4/"
|
||
assert session["api_call_count"] == 1
|
||
|
||
def test_accounted_primary_route_is_not_rewritten_by_later_fallback(self, db):
|
||
"""A mixed-provider session keeps its first accounted route in the legacy row."""
|
||
db.create_session(session_id="s1", source="cli", model="gpt-5.6-sol")
|
||
db.update_token_counts(
|
||
"s1", input_tokens=10, output_tokens=5, model="gpt-5.6-sol",
|
||
billing_provider="openai-codex", api_call_count=1,
|
||
)
|
||
db.update_token_counts(
|
||
"s1", input_tokens=10, output_tokens=5, model="glm-5.2",
|
||
billing_provider="custom:zai", api_call_count=1,
|
||
)
|
||
|
||
session = db.get_session("s1")
|
||
assert session["model"] == "gpt-5.6-sol"
|
||
assert session["billing_provider"] == "openai-codex"
|
||
assert session["api_call_count"] == 2
|
||
|
||
def test_update_token_counts_preserves_existing_model(self, db):
|
||
db.create_session(session_id="s1", source="cli", model="anthropic/claude-opus-4.6")
|
||
db.update_token_counts("s1", input_tokens=10, output_tokens=5, model="openai/gpt-5.4")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["model"] == "anthropic/claude-opus-4.6"
|
||
|
||
def test_update_session_model_overwrites_existing(self, db):
|
||
"""A mid-session /model switch must overwrite the stored model.
|
||
|
||
update_token_counts uses COALESCE(model, ?) (first-writer-wins), so
|
||
the dashboard kept showing the original model after a switch (#34850).
|
||
update_session_model sets the column unconditionally.
|
||
"""
|
||
db.create_session(session_id="s1", source="telegram",
|
||
model="xiaomi/mimo-v2.5-pro")
|
||
# Token updates never change the model once set.
|
||
db.update_token_counts("s1", input_tokens=10, output_tokens=5,
|
||
model="xiaomi/mimo-v2.5-pro")
|
||
assert db.get_session("s1")["model"] == "xiaomi/mimo-v2.5-pro"
|
||
|
||
# Explicit switch overwrites it.
|
||
db.update_session_model("s1", "xiaomi/mimo-v2.5")
|
||
assert db.get_session("s1")["model"] == "xiaomi/mimo-v2.5"
|
||
|
||
# And a subsequent token update does NOT revert it (COALESCE no-ops
|
||
# because the column is now non-NULL).
|
||
db.update_token_counts("s1", input_tokens=10, output_tokens=5,
|
||
model="xiaomi/mimo-v2.5-pro")
|
||
assert db.get_session("s1")["model"] == "xiaomi/mimo-v2.5"
|
||
|
||
def test_update_session_billing_route_overwrites_after_switch(self, db):
|
||
"""A mid-session provider switch must overwrite the billing route.
|
||
|
||
update_token_counts writes billing fields with
|
||
COALESCE(billing_provider, ?) (first-writer-wins), so after a
|
||
provider switch the dashboard kept attributing cost to the original
|
||
provider (#48248). update_session_billing_route sets them
|
||
unconditionally and nulls system_prompt so the next turn rebuilds
|
||
the Model:/Provider: header (#48173).
|
||
"""
|
||
db.create_session(session_id="s1", source="telegram")
|
||
# First token update seeds the billing route.
|
||
db.update_token_counts("s1", input_tokens=10, output_tokens=5,
|
||
billing_provider="openrouter",
|
||
billing_base_url="https://openrouter.ai/api/v1",
|
||
billing_mode="api_key")
|
||
sess = db.get_session("s1")
|
||
assert sess["billing_provider"] == "openrouter"
|
||
# A later token update never changes it (COALESCE first-writer-wins).
|
||
db.update_token_counts("s1", input_tokens=10, output_tokens=5,
|
||
billing_provider="ollama",
|
||
billing_base_url="http://localhost:11434/v1",
|
||
billing_mode="local")
|
||
assert db.get_session("s1")["billing_provider"] == "openrouter"
|
||
|
||
# Seed a stale prompt snapshot, then switch the billing route.
|
||
db.update_system_prompt("s1", "Model: x/old\nProvider: openrouter")
|
||
assert db.get_session("s1")["system_prompt"] is not None
|
||
db.update_session_billing_route(
|
||
"s1", provider="ollama",
|
||
base_url="http://localhost:11434/v1", billing_mode="local",
|
||
)
|
||
sess = db.get_session("s1")
|
||
assert sess["billing_provider"] == "ollama"
|
||
assert sess["billing_base_url"] == "http://localhost:11434/v1"
|
||
assert sess["billing_mode"] == "local"
|
||
assert sess["system_prompt"] is None, \
|
||
"system_prompt must be nulled so the next turn rebuilds Model:/Provider:"
|
||
|
||
# billing_mode defaults to COALESCE — omitting it preserves the value.
|
||
db.update_session_billing_route(
|
||
"s1", provider="openai",
|
||
base_url="https://api.openai.com/v1",
|
||
)
|
||
sess = db.get_session("s1")
|
||
assert sess["billing_provider"] == "openai"
|
||
assert sess["billing_mode"] == "local" # preserved (COALESCE on None)
|
||
|
||
def test_per_model_usage_recorded_for_single_model(self, db):
|
||
"""Each per-call delta lands in session_model_usage (#51607)."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.update_token_counts("s1", input_tokens=200, output_tokens=100,
|
||
model="anthropic/claude-opus-4.8",
|
||
billing_provider="anthropic", api_call_count=1)
|
||
db.update_token_counts("s1", input_tokens=100, output_tokens=50,
|
||
model="anthropic/claude-opus-4.8",
|
||
billing_provider="anthropic", api_call_count=1)
|
||
|
||
rows = db._conn.execute(
|
||
"SELECT model, billing_provider, api_call_count, input_tokens, "
|
||
"output_tokens FROM session_model_usage WHERE session_id = 's1'"
|
||
).fetchall()
|
||
assert len(rows) == 1
|
||
row = rows[0]
|
||
assert row["model"] == "anthropic/claude-opus-4.8"
|
||
assert row["billing_provider"] == "anthropic"
|
||
assert row["api_call_count"] == 2
|
||
assert row["input_tokens"] == 300
|
||
assert row["output_tokens"] == 150
|
||
|
||
def test_mid_session_switch_splits_per_model_usage(self, db):
|
||
"""The headline #51607 case: tokens after a /model switch are
|
||
attributed to the new model, not the session's initial model.
|
||
|
||
The ``sessions`` summary row still holds combined totals + the latest
|
||
model, but session_model_usage keeps an accurate per-model split.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli",
|
||
model="deepseek/deepseek-v4-pro")
|
||
# Pre-switch calls on deepseek.
|
||
db.update_token_counts("s1", input_tokens=40_000, output_tokens=8_000,
|
||
model="deepseek/deepseek-v4-pro",
|
||
billing_provider="deepseek", api_call_count=2)
|
||
# User runs /model — the gateway persists the new model …
|
||
db.update_session_model("s1", "anthropic/claude-opus-4.8")
|
||
# … and subsequent per-call deltas carry the new model/provider.
|
||
db.update_token_counts("s1", input_tokens=50_000, output_tokens=4_000,
|
||
model="anthropic/claude-opus-4.8",
|
||
billing_provider="openrouter", api_call_count=3)
|
||
|
||
rows = {
|
||
r["model"]: r
|
||
for r in db._conn.execute(
|
||
"SELECT model, billing_provider, input_tokens, output_tokens, "
|
||
"api_call_count FROM session_model_usage WHERE session_id = 's1'"
|
||
).fetchall()
|
||
}
|
||
assert set(rows) == {"deepseek/deepseek-v4-pro",
|
||
"anthropic/claude-opus-4.8"}
|
||
assert rows["deepseek/deepseek-v4-pro"]["input_tokens"] == 40_000
|
||
assert rows["deepseek/deepseek-v4-pro"]["api_call_count"] == 2
|
||
assert rows["anthropic/claude-opus-4.8"]["input_tokens"] == 50_000
|
||
assert rows["anthropic/claude-opus-4.8"]["billing_provider"] == "openrouter"
|
||
assert rows["anthropic/claude-opus-4.8"]["api_call_count"] == 3
|
||
|
||
# Summary row: latest model + combined totals (unchanged behaviour).
|
||
session = db.get_session("s1")
|
||
assert session["model"] == "anthropic/claude-opus-4.8"
|
||
assert session["input_tokens"] == 90_000
|
||
assert session["output_tokens"] == 12_000
|
||
|
||
def test_per_model_usage_falls_back_to_session_model(self, db):
|
||
"""When a call omits the model, attribute it to the session's
|
||
recorded model — matches the COALESCE-from-session summary behaviour
|
||
and keeps existing callers (which pass no model) working.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli",
|
||
model="gpt-4o", )
|
||
db.update_token_counts("s1", input_tokens=10, output_tokens=5)
|
||
|
||
rows = db._conn.execute(
|
||
"SELECT model FROM session_model_usage WHERE session_id = 's1'"
|
||
).fetchall()
|
||
assert len(rows) == 1
|
||
assert rows[0]["model"] == "gpt-4o"
|
||
|
||
def test_absolute_update_does_not_record_per_model(self, db):
|
||
"""absolute=True overwrites the cumulative summary row (gateway path)
|
||
and must NOT add per-model rows — those are accumulated from the
|
||
per-call incremental path, so recording here would double-count.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli", model="gpt-4o")
|
||
db.update_token_counts("s1", input_tokens=500, output_tokens=200,
|
||
model="gpt-4o", absolute=True)
|
||
|
||
rows = db._conn.execute(
|
||
"SELECT COUNT(*) AS n FROM session_model_usage WHERE session_id = 's1'"
|
||
).fetchone()
|
||
assert rows["n"] == 0
|
||
|
||
def test_per_model_usage_keeps_distinct_billing_routes(self, db):
|
||
"""The same model through distinct billing routes must not collapse."""
|
||
db.create_session(session_id="routes", source="cli", model="shared-model")
|
||
db.update_token_counts(
|
||
"routes", input_tokens=10, model="shared-model",
|
||
billing_provider="custom", billing_base_url="https://one.example/v1",
|
||
billing_mode="api_key", estimated_cost_usd=0.01, api_call_count=1,
|
||
)
|
||
db.update_token_counts(
|
||
"routes", input_tokens=20, model="shared-model",
|
||
billing_provider="custom", billing_base_url="https://two.example/v1",
|
||
billing_mode="subscription_included", estimated_cost_usd=0.0,
|
||
cost_status="included", api_call_count=1,
|
||
)
|
||
|
||
rows = db._conn.execute(
|
||
"SELECT billing_base_url, billing_mode, input_tokens "
|
||
"FROM session_model_usage WHERE session_id = 'routes' "
|
||
"ORDER BY billing_base_url"
|
||
).fetchall()
|
||
assert [(r["billing_base_url"], r["billing_mode"], r["input_tokens"])
|
||
for r in rows] == [
|
||
("https://one.example/v1", "api_key", 10),
|
||
("https://two.example/v1", "subscription_included", 20),
|
||
]
|
||
|
||
def test_metadata_only_update_does_not_replace_requested_route(self, db):
|
||
db.create_session(session_id="metadata", source="cli", model="primary")
|
||
db.update_token_counts(
|
||
"metadata", model="fallback", billing_provider="fallback-provider",
|
||
api_call_count=0,
|
||
)
|
||
row = db.get_session("metadata")
|
||
assert row["model"] == "primary"
|
||
assert row["billing_provider"] is None
|
||
|
||
def test_first_accounted_route_replaces_all_route_fields_atomically(self, db):
|
||
db.create_session(session_id="route", source="cli", model="primary")
|
||
db.update_session_billing_route(
|
||
"route", provider="primary-provider",
|
||
base_url="https://primary.example/v1", billing_mode="api_key",
|
||
)
|
||
db.update_token_counts(
|
||
"route", model="fallback", billing_provider="fallback-provider",
|
||
billing_base_url=None, billing_mode=None, api_call_count=1,
|
||
)
|
||
row = db.get_session("route")
|
||
assert row["model"] == "fallback"
|
||
assert row["billing_provider"] == "fallback-provider"
|
||
assert row["billing_base_url"] is None
|
||
assert row["billing_mode"] is None
|
||
|
||
def test_v17_backfill_seeds_existing_session_usage(self, tmp_path):
|
||
"""A DB upgraded from <17 seeds one usage row per historical session
|
||
from its aggregate totals, so insights read uniformly from the table.
|
||
"""
|
||
db_path = tmp_path / "legacy.db"
|
||
db = SessionDB(db_path=db_path)
|
||
db.create_session(session_id="legacy1", source="cli", model="gpt-4o")
|
||
db.update_token_counts("legacy1", input_tokens=1234, output_tokens=567,
|
||
model="gpt-4o", billing_provider="openai")
|
||
# Simulate a pre-v17 database: drop the per-model rows and roll the
|
||
# recorded schema version back so the backfill migration re-runs.
|
||
db._conn.execute("DELETE FROM session_model_usage")
|
||
db._conn.execute("UPDATE schema_version SET version = 16")
|
||
db._conn.commit()
|
||
db.close()
|
||
|
||
# Reopen — _init_schema should backfill from the sessions aggregate.
|
||
db2 = SessionDB(db_path=db_path)
|
||
try:
|
||
rows = db2._conn.execute(
|
||
"SELECT model, billing_provider, input_tokens, output_tokens "
|
||
"FROM session_model_usage WHERE session_id = 'legacy1'"
|
||
).fetchall()
|
||
assert len(rows) == 1
|
||
assert rows[0]["model"] == "gpt-4o"
|
||
assert rows[0]["billing_provider"] == "openai"
|
||
assert rows[0]["input_tokens"] == 1234
|
||
assert rows[0]["output_tokens"] == 567
|
||
finally:
|
||
db2.close()
|
||
|
||
def test_parent_session(self, db):
|
||
db.create_session(session_id="parent", source="cli")
|
||
db.create_session(session_id="child", source="cli", parent_session_id="parent")
|
||
|
||
child = db.get_session("child")
|
||
assert child["parent_session_id"] == "parent"
|
||
|
||
def test_db_initializes_without_fts5_module(self, tmp_path, monkeypatch):
|
||
real_connect = sqlite3.connect
|
||
|
||
def connect_without_fts(*args, **kwargs):
|
||
kwargs["factory"] = _NoFtsConnection
|
||
return real_connect(*args, **kwargs)
|
||
|
||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
|
||
|
||
db = SessionDB(db_path=tmp_path / "state.db")
|
||
try:
|
||
assert db._fts_enabled is False
|
||
# Neither FTS5 virtual table should have been created on a build
|
||
# that lacks the fts5 module — both init paths must degrade.
|
||
assert db._fts_table_exists("messages_fts") is False
|
||
assert db._fts_table_exists("messages_fts_trigram") is False
|
||
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="hello from sqlite without fts")
|
||
|
||
messages = db.get_messages("s1")
|
||
assert len(messages) == 1
|
||
assert messages[0]["content"] == "hello from sqlite without fts"
|
||
assert db.search_messages("hello") == []
|
||
finally:
|
||
db.close()
|
||
|
||
def test_existing_fts_tables_do_not_break_without_fts5(
|
||
self, tmp_path, monkeypatch
|
||
):
|
||
db_path = tmp_path / "state.db"
|
||
seeded = SessionDB(db_path=db_path)
|
||
try:
|
||
seeded.create_session(session_id="s1", source="cli")
|
||
seeded.append_message("s1", role="user", content="before runtime change")
|
||
finally:
|
||
seeded.close()
|
||
|
||
real_connect = sqlite3.connect
|
||
|
||
def connect_without_fts(*args, **kwargs):
|
||
kwargs["factory"] = _NoFtsExistingTableConnection
|
||
return real_connect(*args, **kwargs)
|
||
|
||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
|
||
|
||
db = SessionDB(db_path=db_path)
|
||
try:
|
||
assert db._fts_enabled is False
|
||
assert db.get_session("s1") is not None
|
||
assert len(db.get_messages("s1")) == 1
|
||
|
||
# Existing FTS triggers must be disabled too; otherwise this write
|
||
# would try to insert into an unusable FTS virtual table.
|
||
db.append_message("s1", role="assistant", content="after runtime change")
|
||
messages = db.get_messages("s1")
|
||
assert len(messages) == 2
|
||
assert messages[1]["content"] == "after runtime change"
|
||
finally:
|
||
db.close()
|
||
|
||
def test_old_schema_without_fts5_does_not_crash(self, tmp_path, monkeypatch):
|
||
db_path = tmp_path / "legacy.db"
|
||
conn = sqlite3.connect(str(db_path))
|
||
conn.executescript(SCHEMA_SQL)
|
||
conn.execute("DELETE FROM schema_version")
|
||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", (9,))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
real_connect = sqlite3.connect
|
||
|
||
def connect_without_fts(*args, **kwargs):
|
||
kwargs["factory"] = _NoFtsConnection
|
||
return real_connect(*args, **kwargs)
|
||
|
||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
|
||
|
||
db = SessionDB(db_path=db_path)
|
||
try:
|
||
assert db._fts_enabled is False
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="legacy no fts")
|
||
assert db.get_messages("s1")[0]["content"] == "legacy no fts"
|
||
assert db.search_messages("legacy") == []
|
||
|
||
# Leave the FTS migration version in place so a future FTS-capable
|
||
# runtime can still rebuild and backfill the indexes.
|
||
row = db._conn.execute("SELECT version FROM schema_version").fetchone()
|
||
assert row["version"] == 9
|
||
finally:
|
||
db.close()
|
||
|
||
def test_fts_runtime_restores_triggers_after_no_fts_open(
|
||
self, tmp_path, monkeypatch
|
||
):
|
||
db_path = tmp_path / "state.db"
|
||
seeded = SessionDB(db_path=db_path)
|
||
try:
|
||
seeded.create_session(session_id="s1", source="cli")
|
||
seeded.append_message("s1", role="user", content="first searchable")
|
||
finally:
|
||
seeded.close()
|
||
|
||
real_connect = sqlite3.connect
|
||
|
||
def connect_without_fts(*args, **kwargs):
|
||
kwargs["factory"] = _NoFtsExistingTableConnection
|
||
return real_connect(*args, **kwargs)
|
||
|
||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
|
||
no_fts = SessionDB(db_path=db_path)
|
||
try:
|
||
no_fts.append_message("s1", role="assistant", content="not indexed yet")
|
||
finally:
|
||
no_fts.close()
|
||
|
||
monkeypatch.setattr("hermes_state.sqlite3.connect", real_connect)
|
||
restored = SessionDB(db_path=db_path)
|
||
try:
|
||
assert restored._fts_enabled is True
|
||
restored.append_message("s1", role="assistant", content="indexed again")
|
||
assert len(restored.search_messages("not indexed yet")) == 1
|
||
assert len(restored.search_messages("indexed")) == 2
|
||
finally:
|
||
restored.close()
|
||
|
||
def test_base_fts_rebuilds_after_trigger_repair_without_trigram(
|
||
self, tmp_path, monkeypatch
|
||
):
|
||
"""Trigger repair must rebuild base FTS even when trigram is unavailable."""
|
||
db_path = tmp_path / "state.db"
|
||
seeded = SessionDB(db_path=db_path)
|
||
try:
|
||
seeded.create_session(session_id="s1", source="cli")
|
||
seeded.append_message("s1", role="user", content="already indexed")
|
||
for trigger in (
|
||
"messages_fts_insert",
|
||
"messages_fts_delete",
|
||
"messages_fts_update",
|
||
"messages_fts_trigram_insert",
|
||
"messages_fts_trigram_delete",
|
||
"messages_fts_trigram_update",
|
||
):
|
||
seeded._conn.execute(f"DROP TRIGGER IF EXISTS {trigger}")
|
||
seeded._conn.commit()
|
||
seeded.append_message("s1", role="assistant", content="repair only base needle")
|
||
finally:
|
||
seeded.close()
|
||
|
||
real_connect = sqlite3.connect
|
||
|
||
def connect_without_trigram(*args, **kwargs):
|
||
kwargs["factory"] = _NoTrigramConnection
|
||
return real_connect(*args, **kwargs)
|
||
|
||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram)
|
||
restored = SessionDB(db_path=db_path)
|
||
try:
|
||
assert restored._fts_enabled is True
|
||
assert restored._trigram_available is False
|
||
assert restored._fts_table_exists("messages_fts") is True
|
||
assert len(restored.search_messages("needle")) == 1
|
||
finally:
|
||
restored.close()
|
||
|
||
def test_is_fts5_unavailable_error_catches_trigram_tokenizer(self):
|
||
"""Unit test: _is_fts5_unavailable_error matches 'no such tokenizer: trigram'."""
|
||
fts5_err = sqlite3.OperationalError("no such module: fts5")
|
||
trigram_err = sqlite3.OperationalError("no such tokenizer: trigram")
|
||
generic_tokenizer_err = sqlite3.OperationalError("no such tokenizer: foo")
|
||
unrelated_err = sqlite3.OperationalError("no such table: foo")
|
||
|
||
assert SessionDB._is_fts5_unavailable_error(fts5_err) is True
|
||
assert SessionDB._is_fts5_unavailable_error(trigram_err) is True
|
||
# Generic tokenizer errors should NOT match — only trigram.
|
||
assert SessionDB._is_fts5_unavailable_error(generic_tokenizer_err) is False
|
||
assert SessionDB._is_fts5_unavailable_error(unrelated_err) is False
|
||
|
||
def test_is_trigram_unavailable_error(self):
|
||
"""Unit test: _is_trigram_unavailable_error is scoped to trigram."""
|
||
trigram_err = sqlite3.OperationalError("no such tokenizer: trigram")
|
||
generic_err = sqlite3.OperationalError("no such tokenizer: foo")
|
||
fts5_err = sqlite3.OperationalError("no such module: fts5")
|
||
|
||
assert SessionDB._is_trigram_unavailable_error(trigram_err) is True
|
||
assert SessionDB._is_trigram_unavailable_error(generic_err) is False
|
||
assert SessionDB._is_trigram_unavailable_error(fts5_err) is False
|
||
|
||
def test_db_initializes_without_trigram_tokenizer(self, tmp_path, monkeypatch):
|
||
"""SessionDB must not crash when FTS5 exists but trigram tokenizer is missing."""
|
||
real_connect = sqlite3.connect
|
||
|
||
def connect_without_trigram(*args, **kwargs):
|
||
kwargs["factory"] = _NoTrigramConnection
|
||
return real_connect(*args, **kwargs)
|
||
|
||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram)
|
||
|
||
db = SessionDB(db_path=tmp_path / "state.db")
|
||
try:
|
||
# Base FTS5 should still work (trigram is optional).
|
||
assert db._fts_enabled is True
|
||
assert db._fts_table_exists("messages_fts") is True
|
||
# Trigram table should NOT have been created.
|
||
assert db._fts_table_exists("messages_fts_trigram") is False
|
||
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="hello without trigram")
|
||
|
||
messages = db.get_messages("s1")
|
||
assert len(messages) == 1
|
||
assert messages[0]["content"] == "hello without trigram"
|
||
|
||
# FTS5 keyword search should still work.
|
||
assert len(db.search_messages("hello")) == 1
|
||
finally:
|
||
db.close()
|
||
|
||
def test_v11_migration_backfills_base_fts_when_trigram_unavailable(
|
||
self, tmp_path, monkeypatch
|
||
):
|
||
"""A legacy inline-FTS DB opened under a no-trigram runtime keeps its
|
||
base FTS searchable (and is flagged optimizable) without crashing.
|
||
|
||
Opt-in model: opening never auto-migrates. The legacy single-column
|
||
index keeps working for content search; the trigram tokenizer being
|
||
unavailable must not break base FTS or the open itself.
|
||
"""
|
||
real_connect = sqlite3.connect
|
||
db_path = tmp_path / "state.db"
|
||
|
||
# Phase 1: build a genuine legacy inline DB by hand (single-column
|
||
# messages_fts, no trigram table), at an old schema version.
|
||
conn = sqlite3.connect(str(db_path))
|
||
conn.executescript(SCHEMA_SQL)
|
||
conn.executescript("""
|
||
DROP TABLE IF EXISTS messages_fts;
|
||
DROP TABLE IF EXISTS messages_fts_trigram;
|
||
DROP VIEW IF EXISTS messages_fts_trigram_src;
|
||
CREATE VIRTUAL TABLE messages_fts USING fts5(content);
|
||
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
|
||
INSERT INTO messages_fts(rowid, content) VALUES (new.id, COALESCE(new.content,''));
|
||
END;
|
||
""")
|
||
conn.execute("DELETE FROM schema_version")
|
||
conn.execute("INSERT INTO schema_version (version) VALUES (10)")
|
||
conn.execute(
|
||
"INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'cli', ?)",
|
||
(time.time(),),
|
||
)
|
||
for role, content in (
|
||
("user", "legacy message alpha"),
|
||
("assistant", "legacy reply beta"),
|
||
):
|
||
conn.execute(
|
||
"INSERT INTO messages (session_id, timestamp, role, content) "
|
||
"VALUES ('s1', ?, ?, ?)",
|
||
(time.time(), role, content),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# Phase 2: reopen with trigram disabled — must NOT crash, base FTS
|
||
# keeps working, and the DB is flagged optimizable (opt-in, so no
|
||
# auto-migration and the version stays put).
|
||
def connect_without_trigram(*args, **kwargs):
|
||
kwargs["factory"] = _NoTrigramConnection
|
||
return real_connect(*args, **kwargs)
|
||
|
||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram)
|
||
migrated_db = SessionDB(db_path=db_path)
|
||
try:
|
||
assert migrated_db._fts_enabled is True
|
||
assert migrated_db._trigram_available is False
|
||
assert migrated_db._fts_table_exists("messages_fts") is True
|
||
assert migrated_db._fts_table_exists("messages_fts_trigram") is False
|
||
assert migrated_db.fts_optimize_available() is True
|
||
|
||
# Existing messages must be searchable via base FTS.
|
||
results = migrated_db.search_messages("legacy message")
|
||
assert len(results) == 1
|
||
# snippet has FTS5 highlight markers (>>>...<<<); check raw content via get_messages
|
||
msgs = migrated_db.get_messages("s1")
|
||
assert any("legacy message" in m["content"] for m in msgs)
|
||
finally:
|
||
migrated_db.close()
|
||
|
||
def test_cjk_search_falls_back_to_like_when_trigram_unavailable(
|
||
self, tmp_path, monkeypatch
|
||
):
|
||
"""Regression: long CJK queries must fall back to LIKE when trigram is missing."""
|
||
real_connect = sqlite3.connect
|
||
db_path = tmp_path / "state.db"
|
||
|
||
def connect_without_trigram(*args, **kwargs):
|
||
kwargs["factory"] = _NoTrigramConnection
|
||
return real_connect(*args, **kwargs)
|
||
|
||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram)
|
||
db = SessionDB(db_path=db_path)
|
||
try:
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="大别山项目计划书")
|
||
db.append_message("s1", role="user", content="长江大桥设计方案")
|
||
|
||
# 3+ CJK chars would normally use trigram, but it's unavailable.
|
||
# Must fall back to LIKE and still return results.
|
||
results = db.search_messages("大别山")
|
||
assert len(results) == 1
|
||
# Note: search_messages strips 'content' from results; use 'snippet'.
|
||
assert "大别山" in results[0]["snippet"]
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
# =========================================================================
|
||
# Message storage
|
||
# =========================================================================
|
||
|
||
class TestMessageStorage:
|
||
def test_append_and_get_messages(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="Hello")
|
||
db.append_message("s1", role="assistant", content="Hi there!")
|
||
|
||
messages = db.get_messages("s1")
|
||
assert len(messages) == 2
|
||
assert messages[0]["role"] == "user"
|
||
assert messages[0]["content"] == "Hello"
|
||
assert messages[1]["role"] == "assistant"
|
||
|
||
def test_append_message_sets_active_for_transcript_loader(self, db):
|
||
"""Regression #51646: gateway loaders filter on active = 1."""
|
||
db.create_session(session_id="s1", source="discord")
|
||
mid = db.append_message("s1", role="user", content="Hello")
|
||
active = db._conn.execute(
|
||
"SELECT active FROM messages WHERE id = ?", (mid,)
|
||
).fetchone()[0]
|
||
assert active == 1
|
||
assert len(db.get_messages_as_conversation("s1")) == 1
|
||
|
||
def test_append_message_active_one_when_column_has_no_default(self, tmp_path):
|
||
"""Legacy DBs may have active added without a working INSERT default."""
|
||
db_path = tmp_path / "legacy_state.db"
|
||
conn = sqlite3.connect(db_path)
|
||
conn.executescript(
|
||
"""
|
||
CREATE TABLE schema_version (version INTEGER);
|
||
INSERT INTO schema_version VALUES (11);
|
||
CREATE TABLE sessions (
|
||
id TEXT PRIMARY KEY, source TEXT, started_at REAL, ended_at REAL,
|
||
message_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0,
|
||
title TEXT, parent_session_id TEXT, model_config TEXT
|
||
);
|
||
CREATE TABLE messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT,
|
||
tool_call_id TEXT, tool_calls TEXT, tool_name TEXT,
|
||
timestamp REAL NOT NULL, token_count INTEGER, finish_reason TEXT,
|
||
reasoning TEXT, reasoning_content TEXT, reasoning_details TEXT,
|
||
codex_reasoning_items TEXT, codex_message_items TEXT,
|
||
platform_message_id TEXT, observed INTEGER DEFAULT 0
|
||
);
|
||
CREATE TABLE state_meta (key TEXT PRIMARY KEY, value TEXT);
|
||
"""
|
||
)
|
||
conn.execute(
|
||
"INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'discord', 1.0)"
|
||
)
|
||
conn.execute("ALTER TABLE messages ADD COLUMN active INTEGER")
|
||
conn.execute("ALTER TABLE messages ADD COLUMN compacted INTEGER DEFAULT 0")
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
session_db = SessionDB(db_path=db_path)
|
||
try:
|
||
mid = session_db.append_message("s1", role="user", content="gateway turn")
|
||
active = session_db._conn.execute(
|
||
"SELECT active FROM messages WHERE id = ?", (mid,)
|
||
).fetchone()[0]
|
||
assert active == 1
|
||
assert len(session_db.get_messages_as_conversation("s1")) == 1
|
||
finally:
|
||
session_db.close()
|
||
|
||
def test_startup_heals_null_active_rows(self, tmp_path):
|
||
"""Rows written as active=NULL before the fix are un-hidden on startup.
|
||
|
||
The repair UPDATE used to be gated at schema_version < 12, so
|
||
already-v12+ databases (the exact population hit by #51646) never
|
||
healed their historical NULL rows. It now runs on every startup.
|
||
"""
|
||
db_path = tmp_path / "legacy_state.db"
|
||
conn = sqlite3.connect(db_path)
|
||
conn.executescript(
|
||
"""
|
||
CREATE TABLE schema_version (version INTEGER);
|
||
INSERT INTO schema_version VALUES (12);
|
||
CREATE TABLE sessions (
|
||
id TEXT PRIMARY KEY, source TEXT, started_at REAL, ended_at REAL,
|
||
message_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0,
|
||
title TEXT, parent_session_id TEXT, model_config TEXT
|
||
);
|
||
CREATE TABLE messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT,
|
||
tool_call_id TEXT, tool_calls TEXT, tool_name TEXT,
|
||
timestamp REAL NOT NULL, token_count INTEGER, finish_reason TEXT,
|
||
reasoning TEXT, reasoning_content TEXT, reasoning_details TEXT,
|
||
codex_reasoning_items TEXT, codex_message_items TEXT,
|
||
platform_message_id TEXT, observed INTEGER DEFAULT 0
|
||
);
|
||
CREATE TABLE state_meta (key TEXT PRIMARY KEY, value TEXT);
|
||
"""
|
||
)
|
||
# Default-less active column, as seen in the wild (#51646 PRAGMA).
|
||
conn.execute("ALTER TABLE messages ADD COLUMN active INTEGER")
|
||
conn.execute("ALTER TABLE messages ADD COLUMN compacted INTEGER DEFAULT 0")
|
||
conn.execute(
|
||
"INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'discord', 1.0)"
|
||
)
|
||
# A row written by the pre-fix INSERT: active is NULL.
|
||
conn.execute(
|
||
"INSERT INTO messages (session_id, role, content, timestamp) "
|
||
"VALUES ('s1', 'user', 'old hidden turn', 1.0)"
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
session_db = SessionDB(db_path=db_path)
|
||
try:
|
||
active = session_db._conn.execute(
|
||
"SELECT active FROM messages WHERE content = 'old hidden turn'"
|
||
).fetchone()[0]
|
||
assert active == 1
|
||
assert len(session_db.get_messages_as_conversation("s1")) == 1
|
||
finally:
|
||
session_db.close()
|
||
|
||
def test_append_message_accepts_explicit_timestamp(self, db):
|
||
db.create_session(session_id="s1", source="telegram")
|
||
event_ts = 1777383653.0
|
||
|
||
db.append_message("s1", role="user", content="Hello", timestamp=event_ts)
|
||
|
||
messages = db.get_messages_as_conversation("s1")
|
||
assert messages[0]["timestamp"] == event_ts
|
||
|
||
def test_message_increments_session_count(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="Hello")
|
||
db.append_message("s1", role="assistant", content="Hi")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["message_count"] == 2
|
||
|
||
def test_observed_flag_round_trips_for_gateway_replay(self, db):
|
||
db.create_session(session_id="s1", source="telegram:-100")
|
||
db.append_message(
|
||
"s1",
|
||
role="user",
|
||
content="[Alice|111]\nside chatter",
|
||
observed=True,
|
||
)
|
||
db.append_message("s1", role="assistant", content="ack")
|
||
|
||
messages = db.get_messages("s1")
|
||
assert messages[0]["observed"] == 1
|
||
assert messages[1]["observed"] == 0
|
||
|
||
conversation = db.get_messages_as_conversation("s1")
|
||
assert conversation[0]["role"] == "user"
|
||
assert conversation[0]["content"] == "[Alice|111]\nside chatter"
|
||
assert conversation[0]["observed"] is True
|
||
assert isinstance(conversation[0].get("timestamp"), float)
|
||
assert "observed" not in conversation[1]
|
||
|
||
def test_tool_response_does_not_increment_tool_count(self, db):
|
||
"""Tool responses (role=tool) should not increment tool_call_count.
|
||
|
||
Only assistant messages with tool_calls should count.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="tool", content="result", tool_name="web_search")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["tool_call_count"] == 0
|
||
|
||
def test_assistant_tool_calls_increment_by_count(self, db):
|
||
"""An assistant message with N tool_calls should increment by N."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
tool_calls = [
|
||
{"id": "call_1", "function": {"name": "web_search", "arguments": "{}"}},
|
||
]
|
||
db.append_message("s1", role="assistant", content="", tool_calls=tool_calls)
|
||
|
||
session = db.get_session("s1")
|
||
assert session["tool_call_count"] == 1
|
||
|
||
def test_tool_call_count_matches_actual_calls(self, db):
|
||
"""tool_call_count should equal the number of tool calls made, not messages."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
|
||
# Assistant makes 2 parallel tool calls in one message
|
||
tool_calls = [
|
||
{"id": "call_1", "function": {"name": "ha_call_service", "arguments": "{}"}},
|
||
{"id": "call_2", "function": {"name": "ha_call_service", "arguments": "{}"}},
|
||
]
|
||
db.append_message("s1", role="assistant", content="", tool_calls=tool_calls)
|
||
|
||
# Two tool responses come back
|
||
db.append_message("s1", role="tool", content="ok", tool_name="ha_call_service")
|
||
db.append_message("s1", role="tool", content="ok", tool_name="ha_call_service")
|
||
|
||
session = db.get_session("s1")
|
||
# Should be 2 (the actual number of tool calls), not 3
|
||
assert session["tool_call_count"] == 2, (
|
||
f"Expected 2 tool calls but got {session['tool_call_count']}. "
|
||
"tool responses are double-counted and multi-call messages are under-counted"
|
||
)
|
||
|
||
def test_tool_calls_serialization(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
tool_calls = [{"id": "call_1", "function": {"name": "web_search", "arguments": "{}"}}]
|
||
db.append_message("s1", role="assistant", tool_calls=tool_calls)
|
||
|
||
messages = db.get_messages("s1")
|
||
assert messages[0]["tool_calls"] == tool_calls
|
||
|
||
def test_multimodal_list_content_round_trip(self, db):
|
||
"""Multimodal ``content`` (list of parts) must survive the SQLite
|
||
round-trip. sqlite3 cannot bind Python lists directly, so the DB
|
||
layer JSON-encodes structured content on write and decodes on read.
|
||
|
||
Regression test for the "Error binding parameter 3: type 'list' is
|
||
not supported" crash users hit when pasting screenshots into the
|
||
TUI (issue #17522).
|
||
"""
|
||
db.create_session(session_id="s1", source="cli")
|
||
content = [
|
||
{"type": "text", "text": "describe this screenshot"},
|
||
{
|
||
"type": "image_url",
|
||
"image_url": {"url": "data:image/png;base64,iVBORw0KG..."},
|
||
},
|
||
]
|
||
|
||
# Write must not raise
|
||
db.append_message("s1", role="user", content=content)
|
||
|
||
# get_messages decodes back to the original list
|
||
msgs = db.get_messages("s1")
|
||
assert len(msgs) == 1
|
||
assert msgs[0]["content"] == content
|
||
|
||
# get_messages_as_conversation decodes back to the original list
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert len(conv) == 1
|
||
assert conv[0]["role"] == "user"
|
||
assert conv[0]["content"] == content
|
||
assert isinstance(conv[0].get("timestamp"), float)
|
||
|
||
def test_dict_content_round_trip(self, db):
|
||
"""Dict-shaped content (e.g. provider wrappers) also round-trips."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
content = {"parts": [{"text": "hi"}]}
|
||
|
||
db.append_message("s1", role="user", content=content)
|
||
msgs = db.get_messages("s1")
|
||
assert msgs[0]["content"] == content
|
||
|
||
def test_string_content_unchanged_by_encoding(self, db):
|
||
"""Plain strings must not be wrapped — FTS search and legacy
|
||
consumers depend on raw-string storage for text content.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="plain text")
|
||
|
||
# Peek at the raw column to confirm no encoding was applied
|
||
with db._lock:
|
||
row = db._conn.execute(
|
||
"SELECT content FROM messages WHERE session_id = ?", ("s1",)
|
||
).fetchone()
|
||
assert row["content"] == "plain text"
|
||
|
||
def test_replace_messages_persists_tool_name(self, db):
|
||
"""`replace_messages` (used by /retry, /undo, /compress) must write
|
||
tool_name to the DB for messages built by make_tool_result_message."""
|
||
from agent.tool_dispatch_helpers import make_tool_result_message
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.replace_messages(
|
||
"s1",
|
||
[
|
||
{"role": "user", "content": "do something"},
|
||
make_tool_result_message("web_search", "some results", "c1"),
|
||
],
|
||
)
|
||
|
||
msgs = db.get_messages("s1")
|
||
tool_msg = next(m for m in msgs if m["role"] == "tool")
|
||
assert tool_msg["tool_name"] == "web_search"
|
||
|
||
def test_tool_effect_disposition_round_trips_through_session_db(self, db):
|
||
from agent.tool_dispatch_helpers import make_tool_result_message
|
||
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.replace_messages(
|
||
"s1",
|
||
[make_tool_result_message(
|
||
"write_file", "worker detached", "c1", effect_disposition="unknown"
|
||
)],
|
||
)
|
||
|
||
assert db.get_messages_as_conversation("s1")[0]["effect_disposition"] == "unknown"
|
||
|
||
def test_replace_messages_handles_multimodal_content(self, db):
|
||
"""`replace_messages` (used by /retry, /undo, /compress) must also
|
||
handle list content without crashing."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
content = [
|
||
{"type": "text", "text": "look at this"},
|
||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}},
|
||
]
|
||
|
||
db.replace_messages(
|
||
"s1",
|
||
[
|
||
{"role": "user", "content": content},
|
||
{"role": "assistant", "content": "I see a screenshot."},
|
||
],
|
||
)
|
||
|
||
msgs = db.get_messages("s1")
|
||
assert len(msgs) == 2
|
||
assert msgs[0]["content"] == content
|
||
assert msgs[1]["content"] == "I see a screenshot."
|
||
|
||
def test_get_messages_as_conversation(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="Hello")
|
||
db.append_message("s1", role="assistant", content="Hi!")
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert len(conv) == 2
|
||
assert conv[0]["role"] == "user"
|
||
assert conv[0]["content"] == "Hello"
|
||
assert isinstance(conv[0]["timestamp"], float)
|
||
assert conv[1]["role"] == "assistant"
|
||
assert conv[1]["content"] == "Hi!"
|
||
assert isinstance(conv[1]["timestamp"], float)
|
||
|
||
def test_get_messages_as_conversation_orders_by_id_not_timestamp(self, db):
|
||
"""Replay must follow AUTOINCREMENT id (insertion order), never the
|
||
wall-clock timestamp.
|
||
|
||
``append_message`` stamps each row with ``time.time()``, which is not
|
||
monotonic — on WSL2, after an NTP step, or when a VM/laptop resumes
|
||
from sleep the clock can jump backwards mid-conversation. A later
|
||
row then carries an *earlier* timestamp than the row before it. If
|
||
``get_messages_as_conversation`` ordered by ``timestamp`` it would
|
||
sort an assistant ``tool_calls`` row after its ``tool`` response,
|
||
orphaning the tool call and triggering an HTTP 400 on the next
|
||
completion. Ordering by ``id`` keeps the real insertion order
|
||
regardless of clock skew. See c03acca50.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli")
|
||
|
||
# Simulate a clock regression across a single tool round-trip: the
|
||
# assistant tool_calls row is inserted first but stamped LATER than
|
||
# the tool response that follows it.
|
||
tool_calls = [
|
||
{"id": "call_1", "function": {"name": "web_search", "arguments": "{}"}},
|
||
]
|
||
db.append_message(
|
||
"s1", role="assistant", content="", tool_calls=tool_calls,
|
||
timestamp=1000.0,
|
||
)
|
||
db.append_message(
|
||
"s1", role="tool", content="result", tool_name="web_search",
|
||
tool_call_id="call_1", timestamp=999.0,
|
||
)
|
||
db.append_message("s1", role="user", content="thanks", timestamp=998.0)
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
|
||
# Insertion order is preserved even though timestamps decrease.
|
||
assert [m["role"] for m in conv] == ["assistant", "tool", "user"]
|
||
# The tool response stays immediately after the assistant tool_calls
|
||
# row — the adjacency invariant the model API enforces.
|
||
assert conv[0]["tool_calls"][0]["id"] == "call_1"
|
||
assert conv[1]["role"] == "tool"
|
||
assert conv[1]["tool_call_id"] == "call_1"
|
||
|
||
def test_platform_message_id_round_trips(self, db):
|
||
"""Platform-side message ids (yuanbao msg_id, telegram update_id, …)
|
||
survive append → get_messages_as_conversation under the
|
||
``message_id`` key so platform recall flows can match by exact id."""
|
||
db.create_session(session_id="s_pmi", source="yuanbao")
|
||
db.append_message(
|
||
"s_pmi",
|
||
role="user",
|
||
content="hi",
|
||
platform_message_id="abc-123",
|
||
)
|
||
db.append_message("s_pmi", role="assistant", content="hello")
|
||
|
||
conv = db.get_messages_as_conversation("s_pmi")
|
||
user_msg = next(m for m in conv if m["role"] == "user")
|
||
assistant_msg = next(m for m in conv if m["role"] == "assistant")
|
||
assert user_msg.get("message_id") == "abc-123"
|
||
# Assistant row had no platform id — must not gain one spuriously.
|
||
assert "message_id" not in assistant_msg
|
||
|
||
def test_replace_messages_preserves_platform_message_id(self, db):
|
||
"""``rewrite_transcript`` (which goes through replace_messages) must
|
||
keep the platform_message_id round-trip working for /retry, /undo,
|
||
/compress and yuanbao's recall rewrite path."""
|
||
db.create_session(session_id="s_rep", source="yuanbao")
|
||
db.replace_messages(
|
||
"s_rep",
|
||
[
|
||
{"role": "user", "content": "x", "message_id": "ext-1"},
|
||
{"role": "assistant", "content": "y"},
|
||
],
|
||
)
|
||
conv = db.get_messages_as_conversation("s_rep")
|
||
assert next(m for m in conv if m["role"] == "user").get("message_id") == "ext-1"
|
||
assert "message_id" not in next(m for m in conv if m["role"] == "assistant")
|
||
|
||
def test_get_messages_as_conversation_includes_ancestor_chain(self, db):
|
||
db.create_session("root", "tui")
|
||
db.append_message("root", role="user", content="first prompt")
|
||
db.append_message("root", role="assistant", content="first answer")
|
||
db.create_session("child", "tui", parent_session_id="root")
|
||
db.append_message("child", role="user", content="second prompt")
|
||
db.append_message("child", role="assistant", content="second answer")
|
||
|
||
conv = db.get_messages_as_conversation("child", include_ancestors=True)
|
||
|
||
assert [m["content"] for m in conv] == [
|
||
"first prompt",
|
||
"first answer",
|
||
"second prompt",
|
||
"second answer",
|
||
]
|
||
|
||
def test_get_messages_as_conversation_avoids_repeated_resume_prompts_from_ancestors(self, db):
|
||
db.create_session("root", "tui")
|
||
db.append_message("root", role="user", content="same prompt")
|
||
db.append_message("root", role="user", content="same prompt")
|
||
db.append_message("root", role="assistant", content="answer")
|
||
db.create_session("child", "tui", parent_session_id="root")
|
||
db.append_message("child", role="user", content="next prompt")
|
||
|
||
conv = db.get_messages_as_conversation("child", include_ancestors=True)
|
||
|
||
assert [m["content"] for m in conv if m["role"] == "user"] == ["same prompt", "next prompt"]
|
||
|
||
def test_get_resume_conversations_matches_separate_reads(self, db):
|
||
"""The one-fetch resume projections must be byte-identical to the two
|
||
separate get_messages_as_conversation reads they replace — the whole
|
||
point of the single-SELECT optimization (desktop audit P1). Includes a
|
||
dangling tool-call tail so repair_alternation drops rows and the model /
|
||
display lengths diverge (exercises session.resume's prefix computation).
|
||
"""
|
||
db.create_session("root", "tui")
|
||
db.append_message("root", role="user", content="first prompt")
|
||
db.append_message("root", role="assistant", content="first answer")
|
||
db.create_session("child", "tui", parent_session_id="root")
|
||
db.append_message("child", role="user", content="second prompt")
|
||
db.append_message(
|
||
"child", role="assistant", content="second answer", finish_reason="stop"
|
||
)
|
||
# Dangling assistant(tool_calls) tail with no tool response → repair
|
||
# drops it, so model_history is shorter than display_history.
|
||
db.append_message(
|
||
"child",
|
||
role="assistant",
|
||
content="",
|
||
tool_calls=[
|
||
{"id": "t1", "type": "function", "function": {"name": "x", "arguments": "{}"}}
|
||
],
|
||
)
|
||
|
||
model_expected = db.get_messages_as_conversation("child", repair_alternation=True)
|
||
display_expected = db.get_messages_as_conversation("child", include_ancestors=True)
|
||
|
||
model_history, display_history = db.get_resume_conversations("child")
|
||
|
||
assert model_history == model_expected
|
||
assert display_history == display_expected
|
||
# Sanity: the tail really did diverge the two projections.
|
||
assert len(display_history) > len(model_history)
|
||
|
||
def test_get_resume_conversations_single_session_no_ancestors(self, db):
|
||
db.create_session("solo", "cli")
|
||
db.append_message("solo", role="user", content="hi")
|
||
db.append_message("solo", role="assistant", content="hello")
|
||
|
||
model_expected = db.get_messages_as_conversation("solo", repair_alternation=True)
|
||
display_expected = db.get_messages_as_conversation("solo", include_ancestors=True)
|
||
model_history, display_history = db.get_resume_conversations("solo")
|
||
|
||
assert model_history == model_expected
|
||
assert display_history == display_expected
|
||
|
||
def test_get_resume_conversations_dedupes_replayed_ancestor_user(self, db):
|
||
db.create_session("root", "tui")
|
||
db.append_message("root", role="user", content="same prompt")
|
||
db.append_message("root", role="user", content="same prompt")
|
||
db.append_message("root", role="assistant", content="answer")
|
||
db.create_session("child", "tui", parent_session_id="root")
|
||
db.append_message("child", role="user", content="next prompt")
|
||
|
||
model_expected = db.get_messages_as_conversation("child", repair_alternation=True)
|
||
display_expected = db.get_messages_as_conversation("child", include_ancestors=True)
|
||
model_history, display_history = db.get_resume_conversations("child")
|
||
|
||
assert model_history == model_expected
|
||
assert display_history == display_expected
|
||
|
||
def test_get_ancestor_display_prefix_single_session_returns_empty(self, db):
|
||
"""A session with no compression ancestors has an empty prefix."""
|
||
db.create_session("solo", "cli")
|
||
db.append_message("solo", role="user", content="hi")
|
||
db.append_message("solo", role="assistant", content="hello")
|
||
|
||
assert db.get_ancestor_display_prefix("solo") == []
|
||
|
||
def test_get_ancestor_display_prefix_returns_ancestor_only_messages(self, db):
|
||
"""The prefix contains ONLY ancestor messages, not tip messages.
|
||
|
||
Previously the prefix was calculated as
|
||
display_history[:len(display) - len(raw)], which overcounts when
|
||
repair_message_sequence removes messages from the MIDDLE of the
|
||
tip history — the length difference includes both ancestor messages
|
||
AND repair-removed tip messages, but the slice captures the first N
|
||
display messages (tip messages when there are no ancestors),
|
||
causing duplication in _live_session_payload. (#65919)
|
||
"""
|
||
db.create_session("root", "tui")
|
||
db.append_message("root", role="user", content="ancestor prompt")
|
||
db.append_message("root", role="assistant", content="ancestor reply")
|
||
db.create_session("child", "tui", parent_session_id="root")
|
||
db.append_message("child", role="user", content="tip prompt")
|
||
db.append_message("child", role="assistant", content="tip reply")
|
||
# A verification candidate that repair_message_sequence collapses
|
||
# (consecutive-assistant merge replaces it with the next assistant).
|
||
db.append_message(
|
||
"child",
|
||
role="assistant",
|
||
content="verification candidate",
|
||
finish_reason="verification_required",
|
||
)
|
||
db.append_message("child", role="assistant", content="post-verification reply")
|
||
|
||
prefix = db.get_ancestor_display_prefix("child")
|
||
# Only the ancestor messages, not any tip messages.
|
||
assert len(prefix) == 2
|
||
assert prefix[0]["role"] == "user"
|
||
assert prefix[0]["content"] == "ancestor prompt"
|
||
assert prefix[1]["role"] == "assistant"
|
||
assert prefix[1]["content"] == "ancestor reply"
|
||
|
||
# The old broken calculation would produce a non-empty prefix
|
||
# (because repair collapses the verification candidate, making
|
||
# len(display) > len(raw)), even though there are 2 ancestor
|
||
# messages — it would overcount.
|
||
raw, display = db.get_resume_conversations("child")
|
||
old_prefix_len = max(0, len(display) - len(raw))
|
||
assert len(prefix) <= old_prefix_len
|
||
|
||
def test_finish_reason_stored(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="assistant", content="Done", finish_reason="stop")
|
||
|
||
messages = db.get_messages("s1")
|
||
assert messages[0]["finish_reason"] == "stop"
|
||
|
||
def test_get_messages_as_conversation_strips_leaked_memory_context(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(
|
||
"s1",
|
||
role="assistant",
|
||
content=(
|
||
"<memory-context>\n"
|
||
"[System note: The following is recalled memory context, NOT new user input. Treat as informational background data.]\n\n"
|
||
"## Honcho Context\n"
|
||
"stale memory\n"
|
||
"</memory-context>\n\n"
|
||
"Visible answer"
|
||
),
|
||
)
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert len(conv) == 1
|
||
assert conv[0]["role"] == "assistant"
|
||
assert conv[0]["content"] == "Visible answer"
|
||
assert isinstance(conv[0].get("timestamp"), float)
|
||
|
||
def test_reasoning_persisted_and_restored(self, db):
|
||
"""Reasoning text is stored for assistant messages and restored by
|
||
get_messages_as_conversation() so providers receive coherent multi-turn
|
||
reasoning context."""
|
||
db.create_session(session_id="s1", source="telegram")
|
||
db.append_message("s1", role="user", content="create a cron job")
|
||
db.append_message(
|
||
"s1",
|
||
role="assistant",
|
||
content=None,
|
||
tool_calls=[{"function": {"name": "cronjob", "arguments": "{}"}, "id": "c1", "type": "function"}],
|
||
reasoning="I should call the cronjob tool to schedule this.",
|
||
)
|
||
db.append_message("s1", role="tool", content='{"job_id": "abc"}', tool_call_id="c1")
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert len(conv) == 3
|
||
# reasoning must be present on the assistant message
|
||
assistant = conv[1]
|
||
assert assistant["role"] == "assistant"
|
||
assert assistant.get("reasoning") == "I should call the cronjob tool to schedule this."
|
||
# user and tool messages must NOT carry reasoning
|
||
assert "reasoning" not in conv[0]
|
||
assert "reasoning" not in conv[2]
|
||
|
||
def test_reasoning_details_persisted_and_restored(self, db):
|
||
"""reasoning_details (structured array) is round-tripped through JSON
|
||
serialization in the DB."""
|
||
db.create_session(session_id="s1", source="telegram")
|
||
details = [
|
||
{"type": "reasoning.summary", "summary": "Thinking about tools"},
|
||
{"type": "reasoning.encrypted_content", "encrypted_content": "abc123"},
|
||
]
|
||
db.append_message(
|
||
"s1",
|
||
role="assistant",
|
||
content="Hello",
|
||
reasoning="Thinking about what to say",
|
||
reasoning_details=details,
|
||
)
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert len(conv) == 1
|
||
msg = conv[0]
|
||
assert msg["reasoning"] == "Thinking about what to say"
|
||
assert msg["reasoning_details"] == details
|
||
|
||
def test_finish_reason_restored_by_get_messages_as_conversation(self, db):
|
||
"""finish_reason on assistant messages must survive conversation replay.
|
||
|
||
Without this, /branch copies and other transcript round-trips silently
|
||
drop the provider's stop signal.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(
|
||
"s1",
|
||
role="assistant",
|
||
content="Done",
|
||
finish_reason="tool_calls",
|
||
)
|
||
db.append_message("s1", role="user", content="next")
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert conv[0]["role"] == "assistant"
|
||
assert conv[0]["finish_reason"] == "tool_calls"
|
||
# Non-assistant rows should not have a finish_reason key added.
|
||
assert "finish_reason" not in conv[1]
|
||
|
||
def test_reasoning_content_persisted_and_restored(self, db):
|
||
"""reasoning_content must survive session replay as its own field."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(
|
||
"s1",
|
||
role="assistant",
|
||
content="Hello",
|
||
reasoning="Short summary",
|
||
reasoning_content="Longer provider-native scratchpad",
|
||
)
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert len(conv) == 1
|
||
assert conv[0]["reasoning"] == "Short summary"
|
||
assert conv[0]["reasoning_content"] == "Longer provider-native scratchpad"
|
||
|
||
def test_reasoning_content_empty_string_restored_for_assistant(self, db):
|
||
"""Empty reasoning_content still needs to round-trip for strict replays."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(
|
||
"s1",
|
||
role="assistant",
|
||
content="",
|
||
tool_calls=[{"id": "c1", "type": "function", "function": {"name": "date", "arguments": "{}"}}],
|
||
reasoning_content="",
|
||
)
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert len(conv) == 1
|
||
assert "reasoning_content" in conv[0]
|
||
assert conv[0]["reasoning_content"] == ""
|
||
|
||
def test_codex_message_items_persisted_and_restored(self, db):
|
||
"""codex_message_items must round-trip through JSON serialization."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
items = [
|
||
{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"status": "completed",
|
||
"id": "msg_123",
|
||
"phase": "commentary",
|
||
"content": [{"type": "output_text", "text": "Thinking..."}],
|
||
},
|
||
{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"status": "completed",
|
||
"id": "msg_456",
|
||
"phase": "final_answer",
|
||
"content": [{"type": "output_text", "text": "Done!"}],
|
||
},
|
||
]
|
||
db.append_message("s1", role="assistant", content="Done!", codex_message_items=items)
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert len(conv) == 1
|
||
assert conv[0].get("codex_message_items") == items
|
||
|
||
def test_reasoning_not_set_for_non_assistant(self, db):
|
||
"""reasoning is never leaked onto user or tool messages."""
|
||
db.create_session(session_id="s1", source="telegram")
|
||
db.append_message("s1", role="user", content="hi")
|
||
db.append_message("s1", role="assistant", content="hello", reasoning=None)
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert "reasoning" not in conv[0]
|
||
assert "reasoning" not in conv[1]
|
||
|
||
def test_reasoning_empty_string_not_restored(self, db):
|
||
"""Empty string reasoning is treated as absent."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="assistant", content="hi", reasoning="")
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert "reasoning" not in conv[0]
|
||
|
||
def test_codex_reasoning_items_persisted_and_restored(self, db):
|
||
"""codex_reasoning_items (encrypted blobs for Codex Responses API) are
|
||
round-tripped through JSON serialization in the DB."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
codex_items = [
|
||
{"type": "reasoning", "id": "rs_abc", "encrypted_content": "enc_blob_123"},
|
||
{"type": "reasoning", "id": "rs_def", "encrypted_content": "enc_blob_456"},
|
||
]
|
||
db.append_message(
|
||
"s1",
|
||
role="assistant",
|
||
content="Done",
|
||
codex_reasoning_items=codex_items,
|
||
)
|
||
|
||
conv = db.get_messages_as_conversation("s1")
|
||
assert len(conv) == 1
|
||
assert conv[0]["codex_reasoning_items"] == codex_items
|
||
assert conv[0]["codex_reasoning_items"][0]["encrypted_content"] == "enc_blob_123"
|
||
|
||
|
||
# =========================================================================
|
||
# FTS5 search
|
||
# =========================================================================
|
||
|
||
class TestFTS5Search:
|
||
def test_search_finds_content(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="How do I deploy with Docker?")
|
||
db.append_message("s1", role="assistant", content="Use docker compose up.")
|
||
|
||
results = db.search_messages("docker")
|
||
assert len(results) == 2
|
||
# At least one result should mention docker
|
||
snippets = [r.get("snippet", "") for r in results]
|
||
assert any("docker" in s.lower() or "Docker" in s for s in snippets)
|
||
|
||
def test_search_empty_query(self, db):
|
||
assert db.search_messages("") == []
|
||
assert db.search_messages(" ") == []
|
||
|
||
def test_search_with_source_filter(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="CLI question about Python")
|
||
|
||
db.create_session(session_id="s2", source="telegram")
|
||
db.append_message("s2", role="user", content="Telegram question about Python")
|
||
|
||
results = db.search_messages("Python", source_filter=["telegram"])
|
||
# Should only find the telegram message
|
||
sources = [r["source"] for r in results]
|
||
assert all(s == "telegram" for s in sources)
|
||
|
||
def test_search_default_sources_include_acp(self, db):
|
||
db.create_session(session_id="s1", source="acp")
|
||
db.append_message("s1", role="user", content="ACP question about Python")
|
||
|
||
results = db.search_messages("Python")
|
||
sources = [r["source"] for r in results]
|
||
assert "acp" in sources
|
||
|
||
def test_search_default_includes_all_platforms(self, db):
|
||
"""Default search (no source_filter) should find sessions from any platform."""
|
||
for src in ("cli", "telegram", "signal", "homeassistant", "acp", "matrix"):
|
||
sid = f"s-{src}"
|
||
db.create_session(session_id=sid, source=src)
|
||
db.append_message(sid, role="user", content=f"universal search test from {src}")
|
||
|
||
results = db.search_messages("universal search test")
|
||
found_sources = {r["source"] for r in results}
|
||
assert found_sources == {"cli", "telegram", "signal", "homeassistant", "acp", "matrix"}
|
||
|
||
def test_search_with_role_filter(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="What is FastAPI?")
|
||
db.append_message("s1", role="assistant", content="FastAPI is a web framework.")
|
||
|
||
results = db.search_messages("FastAPI", role_filter=["assistant"])
|
||
roles = [r["role"] for r in results]
|
||
assert all(r == "assistant" for r in roles)
|
||
|
||
def test_search_returns_context(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="Tell me about Kubernetes")
|
||
db.append_message("s1", role="assistant", content="Kubernetes is an orchestrator.")
|
||
|
||
results = db.search_messages("Kubernetes")
|
||
assert len(results) == 2
|
||
assert "context" in results[0]
|
||
assert isinstance(results[0]["context"], list)
|
||
assert len(results[0]["context"]) > 0
|
||
|
||
def test_search_context_uses_session_neighbors_when_ids_are_interleaved(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="cli")
|
||
|
||
db.append_message("s1", role="user", content="before needle")
|
||
db.append_message("s2", role="user", content="other session message")
|
||
db.append_message("s1", role="assistant", content="needle match")
|
||
db.append_message("s2", role="assistant", content="another other session message")
|
||
db.append_message("s1", role="user", content="after needle")
|
||
|
||
results = db.search_messages('"needle match"')
|
||
needle_result = next(r for r in results if r["session_id"] == "s1" and "needle match" in r["snippet"])
|
||
|
||
assert [msg["content"] for msg in needle_result["context"]] == [
|
||
"before needle",
|
||
"needle match",
|
||
"after needle",
|
||
]
|
||
|
||
def test_search_special_chars_do_not_crash(self, db):
|
||
"""FTS5 special characters in queries must not raise OperationalError."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="How do I use C++ templates?")
|
||
|
||
# Each of these previously caused sqlite3.OperationalError
|
||
dangerous_queries = [
|
||
'C++', # + is FTS5 column filter
|
||
'"unterminated', # unbalanced double-quote
|
||
'(problem', # unbalanced parenthesis
|
||
'hello AND', # dangling boolean operator
|
||
'***', # repeated wildcard
|
||
'{test}', # curly braces (column reference)
|
||
'OR hello', # leading boolean operator
|
||
'a AND OR b', # adjacent operators
|
||
]
|
||
for query in dangerous_queries:
|
||
# Must not raise — should return list (possibly empty)
|
||
results = db.search_messages(query)
|
||
assert isinstance(results, list), f"Query {query!r} did not return a list"
|
||
|
||
def test_search_sanitized_query_still_finds_content(self, db):
|
||
"""Sanitization must not break normal keyword search."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="Learning C++ templates today")
|
||
|
||
# "C++" sanitized to "C" should still match "C++"
|
||
results = db.search_messages("C++")
|
||
# The word "C" appears in the content, so FTS5 should find it
|
||
assert isinstance(results, list)
|
||
|
||
def test_search_hyphenated_term_does_not_crash(self, db):
|
||
"""Hyphenated terms like 'chat-send' must not crash FTS5."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="Run the chat-send command")
|
||
|
||
results = db.search_messages("chat-send")
|
||
assert isinstance(results, list)
|
||
assert len(results) >= 1
|
||
assert any("chat-send" in (r.get("snippet") or r.get("content", "")).lower()
|
||
for r in results)
|
||
|
||
def test_search_dotted_term_does_not_crash(self, db):
|
||
"""Dotted terms like 'P2.2' or 'simulate.p2.test.ts' should not crash FTS5."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="Working on P2.2 session_search edge cases")
|
||
db.append_message("s1", role="assistant", content="See simulate.p2.test.ts for details")
|
||
|
||
results = db.search_messages("P2.2")
|
||
assert isinstance(results, list)
|
||
assert len(results) >= 1
|
||
|
||
results2 = db.search_messages("simulate.p2.test.ts")
|
||
assert isinstance(results2, list)
|
||
assert len(results2) >= 1
|
||
|
||
def test_search_colon_query_still_finds_content(self, db):
|
||
"""Queries containing ':' must not silently return empty.
|
||
|
||
':' is FTS5's column-filter operator. With a single-column FTS table an
|
||
unquoted query like 'TODO: fix' parses as 'column:term', raises
|
||
"no such column: TODO", and the swallowed error turns into zero results
|
||
even though the content is present. Regression for that silent-empty bug.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="TODO fix the deployment script")
|
||
|
||
# Control: the same content is found without the colon.
|
||
assert len(db.search_messages("deployment")) >= 1
|
||
|
||
# The colon query must find the message, not silently return [].
|
||
results = db.search_messages("TODO: fix")
|
||
assert isinstance(results, list)
|
||
assert len(results) >= 1
|
||
assert any("deployment" in (r.get("snippet") or r.get("content", "")).lower()
|
||
for r in results)
|
||
|
||
def test_search_quoted_phrase_preserved(self, db):
|
||
"""User-provided quoted phrases should be preserved for exact matching."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="docker networking is complex")
|
||
db.append_message("s1", role="assistant", content="networking docker tips")
|
||
|
||
# Quoted phrase should match only the exact order
|
||
results = db.search_messages('"docker networking"')
|
||
assert isinstance(results, list)
|
||
# Should find the user message (exact phrase) but may or may not find
|
||
# the assistant message depending on FTS5 phrase matching
|
||
assert len(results) >= 1
|
||
|
||
def test_sanitize_fts5_query_strips_dangerous_chars(self):
|
||
"""Unit test for _sanitize_fts5_query static method."""
|
||
from hermes_state import SessionDB
|
||
s = SessionDB._sanitize_fts5_query
|
||
assert s('hello world') == 'hello world'
|
||
assert '+' not in s('C++')
|
||
assert '"' not in s('"unterminated')
|
||
assert '(' not in s('(problem')
|
||
assert '{' not in s('{test}')
|
||
# Dangling operators removed
|
||
assert s('hello AND') == 'hello'
|
||
assert s('OR world') == 'world'
|
||
# Leading bare * removed
|
||
assert s('***') == ''
|
||
# Valid prefix kept
|
||
assert s('deploy*') == 'deploy*'
|
||
# Colon (FTS5 column-filter operator) stripped, both terms preserved
|
||
assert ':' not in s('TODO: fix')
|
||
assert s('TODO: fix').split() == ['TODO', 'fix']
|
||
assert ':' not in s('error:timeout')
|
||
|
||
def test_sanitize_fts5_preserves_quoted_phrases(self):
|
||
"""Properly paired double-quoted phrases should be preserved."""
|
||
from hermes_state import SessionDB
|
||
s = SessionDB._sanitize_fts5_query
|
||
# Simple quoted phrase
|
||
assert s('"exact phrase"') == '"exact phrase"'
|
||
# Quoted phrase alongside unquoted terms
|
||
assert '"docker networking"' in s('"docker networking" setup')
|
||
# Multiple quoted phrases
|
||
result = s('"hello world" OR "foo bar"')
|
||
assert '"hello world"' in result
|
||
assert '"foo bar"' in result
|
||
# Unmatched quote still stripped
|
||
assert '"' not in s('"unterminated')
|
||
|
||
def test_sanitize_fts5_quotes_hyphenated_terms(self):
|
||
"""Hyphenated terms should be wrapped in quotes for exact matching."""
|
||
from hermes_state import SessionDB
|
||
s = SessionDB._sanitize_fts5_query
|
||
# Simple hyphenated term
|
||
assert s('chat-send') == '"chat-send"'
|
||
# Multiple hyphens
|
||
assert s('docker-compose-up') == '"docker-compose-up"'
|
||
# Hyphenated term with other words
|
||
result = s('fix chat-send bug')
|
||
assert '"chat-send"' in result
|
||
assert 'fix' in result
|
||
assert 'bug' in result
|
||
# Multiple hyphenated terms with OR
|
||
result = s('chat-send OR deploy-prod')
|
||
assert '"chat-send"' in result
|
||
assert '"deploy-prod"' in result
|
||
# Already-quoted hyphenated term — no double quoting
|
||
assert s('"chat-send"') == '"chat-send"'
|
||
# Hyphenated inside a quoted phrase stays as-is
|
||
assert s('"my chat-send thing"') == '"my chat-send thing"'
|
||
|
||
def test_sanitize_fts5_quotes_dotted_terms(self):
|
||
"""Dotted terms should be wrapped in quotes to avoid FTS5 query parse edge cases."""
|
||
from hermes_state import SessionDB
|
||
s = SessionDB._sanitize_fts5_query
|
||
|
||
assert s('P2.2') == '"P2.2"'
|
||
assert s('simulate.p2') == '"simulate.p2"'
|
||
assert s('simulate.p2.test.ts') == '"simulate.p2.test.ts"'
|
||
|
||
# Already quoted — no double quoting
|
||
assert s('"P2.2"') == '"P2.2"'
|
||
|
||
# Works with boolean syntax
|
||
result = s('P2.2 OR simulate.p2')
|
||
assert '"P2.2"' in result
|
||
assert '"simulate.p2"' in result
|
||
|
||
# Mixed dots and hyphens — single pass avoids double-quoting
|
||
assert s('my-app.config') == '"my-app.config"'
|
||
assert s('my-app.config.ts') == '"my-app.config.ts"'
|
||
|
||
def test_sanitize_fts5_quotes_underscored_terms(self):
|
||
"""Underscored terms should be wrapped in quotes for exact matching.
|
||
|
||
FTS5 default tokenizer splits 'sp_new1' into tokens 'sp' and 'new1'.
|
||
Without quoting, a search for 'sp_new' becomes an AND query
|
||
('sp AND new') that fails to match rows indexed as 'sp_new1'.
|
||
"""
|
||
from hermes_state import SessionDB
|
||
s = SessionDB._sanitize_fts5_query
|
||
# Simple underscored term
|
||
assert s('sp_new') == '"sp_new"'
|
||
# Multiple underscores
|
||
assert s('a_b_c') == '"a_b_c"'
|
||
# Mixed underscores and hyphens/dots — single pass avoids double-quoting
|
||
assert s('sp_new1') == '"sp_new1"'
|
||
assert s('docker-compose_up') == '"docker-compose_up"'
|
||
assert s('my.app_config.ts') == '"my.app_config.ts"'
|
||
# Already-quoted — no double quoting
|
||
assert s('"sp_new"') == '"sp_new"'
|
||
# Mixed with other words
|
||
result = s('sp_new and 血管瘤')
|
||
assert '"sp_new"' in result
|
||
assert '血管瘤' in result
|
||
|
||
def test_sanitize_fts5_query_runtime_is_bounded(self):
|
||
"""Adversarial quote/special-char runs should sanitize quickly."""
|
||
from hermes_state import MAX_FTS5_QUERY_CHARS, SessionDB
|
||
|
||
s = SessionDB._sanitize_fts5_query
|
||
query = ('"' * 100_000) + ("a." * 100_000) + ("*" * 100_000)
|
||
|
||
start = time.perf_counter()
|
||
result = s(query)
|
||
elapsed = time.perf_counter() - start
|
||
|
||
assert isinstance(result, str)
|
||
assert len(result) <= MAX_FTS5_QUERY_CHARS * 2
|
||
assert elapsed < 0.5
|
||
|
||
def test_long_search_query_is_capped_and_does_not_crash(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="bounded sanitizer target")
|
||
|
||
query = ('"' * 50_000) + (" bounded" * 10_000)
|
||
start = time.perf_counter()
|
||
results = db.search_messages(query)
|
||
elapsed = time.perf_counter() - start
|
||
|
||
assert isinstance(results, list)
|
||
assert elapsed < 1.0
|
||
|
||
|
||
# =========================================================================
|
||
# CJK (Chinese/Japanese/Korean) LIKE fallback
|
||
# =========================================================================
|
||
|
||
class TestCJKSearchFallback:
|
||
"""Regression tests for CJK search (see #11511).
|
||
|
||
SQLite FTS5's default tokenizer treats contiguous CJK runs as a single
|
||
token ("和其他agent的聊天记录" → one token), so substring queries like
|
||
"记忆断裂" return 0 rows despite the data being present. SessionDB falls
|
||
back to LIKE substring matching whenever FTS5 returns no results and
|
||
the query contains CJK characters.
|
||
"""
|
||
|
||
def test_cjk_detection_covers_all_ranges(self):
|
||
from hermes_state import SessionDB
|
||
f = SessionDB._contains_cjk
|
||
# Chinese (CJK Unified Ideographs)
|
||
assert f("记忆断裂") is True
|
||
# Japanese Hiragana + Katakana
|
||
assert f("こんにちは") is True
|
||
assert f("カタカナ") is True
|
||
# Korean Hangul syllables (both early and late — guards against
|
||
# the \ud7a0-\ud7af typo seen in one of the duplicate PRs)
|
||
assert f("안녕하세요") is True
|
||
assert f("기억") is True
|
||
# Non-CJK
|
||
assert f("hello world") is False
|
||
assert f("日本語mixedwithenglish") is True
|
||
assert f("") is False
|
||
|
||
def test_chinese_multichar_query_returns_results(self, db):
|
||
"""The headline bug: multi-char Chinese query must not return []."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(
|
||
"s1", role="user",
|
||
content="昨天和其他Agent的聊天记录,记忆断裂问题复现了",
|
||
)
|
||
results = db.search_messages("记忆断裂")
|
||
assert len(results) == 1
|
||
assert results[0]["session_id"] == "s1"
|
||
|
||
def test_chinese_bigram_query(self, db):
|
||
db.create_session(session_id="s1", source="telegram")
|
||
db.append_message("s1", role="user", content="今天讨论A2A通信协议的实现")
|
||
results = db.search_messages("通信")
|
||
assert len(results) == 1
|
||
|
||
def test_korean_query_returns_results(self, db):
|
||
"""Guards against Hangul range typos (\\uac00-\\ud7af, not \\ud7a0-)."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="안녕하세요 반갑습니다")
|
||
results = db.search_messages("안녕")
|
||
assert len(results) == 1
|
||
|
||
def test_japanese_query_returns_results(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="こんにちは世界")
|
||
assert len(db.search_messages("こんにちは")) == 1
|
||
assert len(db.search_messages("世界")) == 1
|
||
|
||
def test_cjk_fallback_preserves_source_filter(self, db):
|
||
"""Guards against the SQL-builder bug where filter clauses land
|
||
after LIMIT/OFFSET (seen in one of the duplicate PRs)."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="telegram")
|
||
db.append_message("s1", role="user", content="记忆断裂在CLI")
|
||
db.append_message("s2", role="user", content="记忆断裂在Telegram")
|
||
|
||
results = db.search_messages("记忆断裂", source_filter=["telegram"])
|
||
assert len(results) == 1
|
||
assert results[0]["source"] == "telegram"
|
||
|
||
def test_cjk_fallback_preserves_exclude_sources(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="tool")
|
||
db.append_message("s1", role="user", content="记忆断裂在CLI")
|
||
db.append_message("s2", role="assistant", content="记忆断裂在tool")
|
||
|
||
results = db.search_messages("记忆断裂", exclude_sources=["tool"])
|
||
sources = {r["source"] for r in results}
|
||
assert "tool" not in sources
|
||
assert "cli" in sources
|
||
|
||
def test_cjk_fallback_preserves_role_filter(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="用户说的记忆断裂")
|
||
db.append_message("s1", role="assistant", content="助手说的记忆断裂")
|
||
|
||
results = db.search_messages("记忆断裂", role_filter=["assistant"])
|
||
assert len(results) == 1
|
||
assert results[0]["role"] == "assistant"
|
||
|
||
def test_cjk_snippet_is_centered_on_match(self, db):
|
||
"""Snippet should contain the search term, not just the first N chars."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
long_prefix = "这是一段很长的前缀用来把匹配位置推到文档中间" * 3
|
||
long_suffix = "这是一段很长的后缀内容填充剩余空间" * 3
|
||
db.append_message(
|
||
"s1", role="user",
|
||
content=f"{long_prefix}记忆断裂{long_suffix}",
|
||
)
|
||
results = db.search_messages("记忆断裂")
|
||
assert len(results) == 1
|
||
# The centered substr() snippet must include the matched term.
|
||
assert "记忆断裂" in results[0]["snippet"]
|
||
|
||
def test_english_query_still_uses_fts5_fast_path(self, db):
|
||
"""English queries must not trigger the LIKE fallback (fast path regression)."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="Deploy docker containers")
|
||
results = db.search_messages("docker")
|
||
assert len(results) == 1
|
||
# No CJK in query → LIKE fallback must not run. We don't assert this
|
||
# directly (no instrumentation), but the FTS5 path produces an
|
||
# FTS5-style snippet with highlight markers when the term is short.
|
||
# At minimum: english queries must still match.
|
||
|
||
def test_cjk_query_with_no_matches_returns_empty(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="unrelated English content")
|
||
results = db.search_messages("记忆断裂")
|
||
assert results == []
|
||
|
||
def test_mixed_cjk_english_query(self, db):
|
||
"""Mixed queries should still fall back to LIKE when FTS5 misses."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="讨论Agent通信协议")
|
||
# "Agent通信" is CJK+English — FTS5 default tokenizer indexes the
|
||
# whole CJK run with embedded "agent" as separate tokens; the LIKE
|
||
# fallback handles the substring correctly.
|
||
results = db.search_messages("Agent通信")
|
||
assert len(results) == 1
|
||
|
||
def test_cjk_partial_fts5_results_supplemented_by_like(self, db):
|
||
"""When FTS5 returns *some* CJK results, LIKE must still find all matches.
|
||
|
||
Regression test for #15500 / #14829: FTS5 unicode61 tokenizer drops
|
||
certain CJK characters, so multi-character queries may return partial
|
||
results. The LIKE path must always run for CJK queries.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="telegram")
|
||
db.append_message("s1", role="user", content="昨晚讨论了记忆系统")
|
||
db.append_message("s2", role="user", content="昨晚的会议纪要已发送")
|
||
results = db.search_messages("昨晚")
|
||
assert len(results) == 2
|
||
session_ids = {r["session_id"] for r in results}
|
||
assert session_ids == {"s1", "s2"}
|
||
|
||
def test_cjk_like_dedup_no_duplicates(self, db):
|
||
"""When FTS5 and LIKE both find the same message, no duplicates."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="测试去重逻辑")
|
||
results = db.search_messages("测试")
|
||
assert len(results) == 1
|
||
|
||
def test_cjk_like_escapes_wildcards(self, db):
|
||
"""Special characters (%, _) in CJK queries are treated as literals."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="cli")
|
||
db.append_message("s1", role="user", content="达成100%完成率")
|
||
db.append_message("s2", role="user", content="达成100完成率是目标")
|
||
# The % in the query must be literal — should only match s1
|
||
results = db.search_messages("100%完成")
|
||
assert len(results) == 1
|
||
assert results[0]["session_id"] == "s1"
|
||
|
||
def test_cjk_trigram_preserves_boolean_operators(self, db):
|
||
"""Boolean operators (OR, AND, NOT) work in CJK trigram queries."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="cli")
|
||
db.append_message("s1", role="user", content="记忆系统很好用")
|
||
db.append_message("s2", role="user", content="断裂连接需要修复")
|
||
results = db.search_messages("记忆系统 OR 断裂连接")
|
||
assert len(results) == 2
|
||
session_ids = {r["session_id"] for r in results}
|
||
assert session_ids == {"s1", "s2"}
|
||
|
||
def test_cjk_or_combined_short_tokens_returns_results(self, db):
|
||
"""Regression test for #20494.
|
||
|
||
OR-combined 2-char CJK tokens (e.g. "广西 OR 桂林 OR 漓江 OR 旅游")
|
||
previously returned 0 results because _count_cjk of the whole query
|
||
was >=3 (8 chars here), selecting the trigram path, but each individual
|
||
token is only 2 CJK chars and trigram requires >=3 chars per token.
|
||
The per-token check must route such queries to the LIKE fallback.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="telegram")
|
||
db.create_session(session_id="s3", source="cli")
|
||
db.append_message("s1", role="user", content="广西是个好地方,去过桂林")
|
||
db.append_message("s2", role="user", content="漓江风景很美,值得旅游")
|
||
db.append_message("s3", role="user", content="unrelated English content")
|
||
|
||
results = db.search_messages("广西 OR 桂林 OR 漓江 OR 旅游")
|
||
session_ids = {r["session_id"] for r in results}
|
||
assert "s1" in session_ids, "广西/桂林 terms not matched"
|
||
assert "s2" in session_ids, "漓江/旅游 terms not matched"
|
||
assert "s3" not in session_ids, "unrelated message must not match"
|
||
|
||
def test_cjk_short_token_or_query_preserves_filters(self, db):
|
||
"""Source filter applies correctly in the short-token LIKE path (#20494)."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="telegram")
|
||
db.append_message("s1", role="user", content="广西旅游攻略cli")
|
||
db.append_message("s2", role="user", content="广西旅游攻略telegram")
|
||
|
||
results = db.search_messages("广西 OR 旅游", source_filter=["telegram"])
|
||
assert len(results) == 1
|
||
assert results[0]["source"] == "telegram"
|
||
|
||
|
||
# =========================================================================
|
||
# Session search and listing
|
||
# =========================================================================
|
||
|
||
class TestSearchSessions:
|
||
def test_list_all_sessions(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="telegram")
|
||
|
||
sessions = db.search_sessions()
|
||
assert len(sessions) == 2
|
||
|
||
def test_filter_by_source(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="telegram")
|
||
|
||
sessions = db.search_sessions(source="cli")
|
||
assert len(sessions) == 1
|
||
assert sessions[0]["source"] == "cli"
|
||
|
||
def test_pagination(self, db):
|
||
for i in range(5):
|
||
db.create_session(session_id=f"s{i}", source="cli")
|
||
|
||
page1 = db.search_sessions(limit=2)
|
||
page2 = db.search_sessions(limit=2, offset=2)
|
||
assert len(page1) == 2
|
||
assert len(page2) == 2
|
||
assert page1[0]["id"] != page2[0]["id"]
|
||
|
||
|
||
# =========================================================================
|
||
# Counts
|
||
# =========================================================================
|
||
|
||
class TestCounts:
|
||
def test_session_count(self, db):
|
||
assert db.session_count() == 0
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="telegram")
|
||
assert db.session_count() == 2
|
||
|
||
def test_session_count_by_source(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="telegram")
|
||
db.create_session(session_id="s3", source="cli")
|
||
assert db.session_count(source="cli") == 2
|
||
assert db.session_count(source="telegram") == 1
|
||
|
||
def test_session_count_by_cwd_prefix(self, db):
|
||
db.create_session("s1", "cli", cwd="/repo")
|
||
db.create_session("s2", "cli", cwd="/repo-wt-feature")
|
||
db.create_session("s3", "cli", cwd="/repo/subdir")
|
||
|
||
assert db.session_count(cwd_prefix="/repo") == 2
|
||
|
||
def test_message_count_total(self, db):
|
||
assert db.message_count() == 0
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="Hello")
|
||
db.append_message("s1", role="assistant", content="Hi")
|
||
assert db.message_count() == 2
|
||
|
||
def test_message_count_per_session(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="cli")
|
||
db.append_message("s1", role="user", content="A")
|
||
db.append_message("s2", role="user", content="B")
|
||
db.append_message("s2", role="user", content="C")
|
||
assert db.message_count(session_id="s1") == 1
|
||
assert db.message_count(session_id="s2") == 2
|
||
|
||
|
||
# =========================================================================
|
||
# Delete and export
|
||
# =========================================================================
|
||
|
||
class TestDeleteAndExport:
|
||
def test_delete_session(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="Hello")
|
||
|
||
assert db.delete_session("s1") is True
|
||
assert db.get_session("s1") is None
|
||
assert db.message_count(session_id="s1") == 0
|
||
|
||
def test_delete_session_cascades_per_model_usage(self, db):
|
||
db.create_session(session_id="usage", source="cli", model="gpt-5")
|
||
db.update_token_counts(
|
||
"usage", input_tokens=10, model="gpt-5",
|
||
billing_provider="openai", api_call_count=1,
|
||
)
|
||
assert db.delete_session("usage") is True
|
||
count = db._conn.execute(
|
||
"SELECT COUNT(*) FROM session_model_usage WHERE session_id = 'usage'"
|
||
).fetchone()[0]
|
||
assert count == 0
|
||
|
||
def test_delete_nonexistent(self, db):
|
||
assert db.delete_session("nope") is False
|
||
|
||
def test_resolve_session_id_exact(self, db):
|
||
db.create_session(session_id="20260315_092437_c9a6ff", source="cli")
|
||
assert db.resolve_session_id("20260315_092437_c9a6ff") == "20260315_092437_c9a6ff"
|
||
|
||
def test_resolve_session_id_unique_prefix(self, db):
|
||
db.create_session(session_id="20260315_092437_c9a6ff", source="cli")
|
||
assert db.resolve_session_id("20260315_092437_c9a6") == "20260315_092437_c9a6ff"
|
||
|
||
def test_resolve_session_id_ambiguous_prefix_returns_none(self, db):
|
||
db.create_session(session_id="20260315_092437_c9a6aa", source="cli")
|
||
db.create_session(session_id="20260315_092437_c9a6bb", source="cli")
|
||
assert db.resolve_session_id("20260315_092437_c9a6") is None
|
||
|
||
def test_resolve_session_id_escapes_like_wildcards(self, db):
|
||
db.create_session(session_id="20260315_092437_c9a6ff", source="cli")
|
||
db.create_session(session_id="20260315X092437_c9a6ff", source="cli")
|
||
assert db.resolve_session_id("20260315_092437") == "20260315_092437_c9a6ff"
|
||
|
||
def test_export_session(self, db):
|
||
db.create_session(session_id="s1", source="cli", model="test")
|
||
db.append_message("s1", role="user", content="Hello")
|
||
db.append_message("s1", role="assistant", content="Hi")
|
||
|
||
export = db.export_session("s1")
|
||
assert isinstance(export, dict)
|
||
assert export["source"] == "cli"
|
||
assert len(export["messages"]) == 2
|
||
|
||
def test_export_nonexistent(self, db):
|
||
assert db.export_session("nope") is None
|
||
|
||
def test_export_all(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="telegram")
|
||
db.append_message("s1", role="user", content="A")
|
||
|
||
exports = db.export_all()
|
||
assert len(exports) == 2
|
||
|
||
def test_export_all_with_source(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="telegram")
|
||
|
||
exports = db.export_all(source="cli")
|
||
assert len(exports) == 1
|
||
assert exports[0]["source"] == "cli"
|
||
|
||
def test_import_exported_session_round_trips(self, db, tmp_path):
|
||
db.create_session(
|
||
session_id="s1",
|
||
source="cli",
|
||
model="test-model",
|
||
model_config={"temperature": 0.2},
|
||
user_id="user-1",
|
||
cwd="/workspace",
|
||
)
|
||
db.set_session_title("s1", "Imported session")
|
||
db.update_session_cwd(
|
||
"s1",
|
||
"/workspace/project",
|
||
git_branch="feature/import",
|
||
git_repo_root="/workspace/project",
|
||
)
|
||
db.append_message("s1", role="user", content="Hello", timestamp=10)
|
||
db.append_message(
|
||
"s1",
|
||
role="assistant",
|
||
content="Hi",
|
||
timestamp=11,
|
||
tool_calls=[{"id": "call-1", "function": {"name": "noop"}}],
|
||
reasoning_details=[{"type": "summary", "text": "short"}],
|
||
)
|
||
db.end_session("s1", "complete")
|
||
|
||
exported = db.export_session("s1")
|
||
exported["handoff_state"] = "active"
|
||
exported["handoff_platform"] = "telegram"
|
||
exported["handoff_error"] = "stale runtime state"
|
||
exported["rewind_count"] = 3
|
||
target = SessionDB(db_path=tmp_path / "target_state.db")
|
||
try:
|
||
result = target.import_sessions([exported])
|
||
assert result["ok"] is True
|
||
assert result["imported"] == 1
|
||
assert result["skipped"] == 0
|
||
|
||
imported = target.get_session("s1")
|
||
assert imported["title"] == "Imported session"
|
||
assert imported["source"] == "cli"
|
||
assert imported["model"] == "test-model"
|
||
assert imported["cwd"] == "/workspace/project"
|
||
assert imported["git_branch"] == "feature/import"
|
||
assert imported["git_repo_root"] == "/workspace/project"
|
||
assert imported["message_count"] == 2
|
||
assert imported["tool_call_count"] == 1
|
||
assert imported["handoff_state"] is None
|
||
assert imported["handoff_platform"] is None
|
||
assert imported["handoff_error"] is None
|
||
assert imported["rewind_count"] == 0
|
||
|
||
messages = target.get_messages("s1")
|
||
assert [m["role"] for m in messages] == ["user", "assistant"]
|
||
assert messages[0]["content"] == "Hello"
|
||
assert messages[1]["tool_calls"][0]["id"] == "call-1"
|
||
|
||
duplicate = target.import_sessions([exported])
|
||
assert duplicate["imported"] == 0
|
||
assert duplicate["skipped"] == 1
|
||
assert duplicate["skipped_ids"] == ["s1"]
|
||
finally:
|
||
target.close()
|
||
|
||
def test_import_sessions_restores_valid_parents_and_detaches_missing(self, db):
|
||
result = db.import_sessions(
|
||
[
|
||
{
|
||
"id": "child",
|
||
"source": "cli",
|
||
"parent_session_id": "parent",
|
||
"messages": [],
|
||
},
|
||
{"id": "parent", "source": "cli", "messages": []},
|
||
{
|
||
"id": "orphan",
|
||
"source": "cli",
|
||
"parent_session_id": "missing",
|
||
"messages": [],
|
||
},
|
||
]
|
||
)
|
||
|
||
assert result["ok"] is True
|
||
assert result["imported"] == 3
|
||
assert result["detached"] == 1
|
||
assert db.get_session("child")["parent_session_id"] == "parent"
|
||
assert db.get_session("orphan")["parent_session_id"] is None
|
||
|
||
def test_import_sessions_rejects_invalid_batch_atomically(self, db):
|
||
result = db.import_sessions(
|
||
[
|
||
{"id": "valid", "source": "cli", "messages": []},
|
||
{"source": "cli", "messages": []},
|
||
]
|
||
)
|
||
|
||
assert result["ok"] is False
|
||
assert result["imported"] == 0
|
||
assert result["errors"] == [
|
||
{"index": 1, "error": "session id is required"}
|
||
]
|
||
assert db.get_session("valid") is None
|
||
|
||
def test_import_sessions_detaches_cycle_and_lineage_still_terminates(self, db):
|
||
result = db.import_sessions(
|
||
[
|
||
{
|
||
"id": "a",
|
||
"source": "cli",
|
||
"parent_session_id": "b",
|
||
"end_reason": "compression",
|
||
"messages": [],
|
||
},
|
||
{
|
||
"id": "b",
|
||
"source": "cli",
|
||
"parent_session_id": "a",
|
||
"end_reason": "compression",
|
||
"messages": [],
|
||
},
|
||
]
|
||
)
|
||
|
||
assert result["ok"] is True
|
||
assert result["detached"] == 1
|
||
assert db.get_session("a")["parent_session_id"] is None
|
||
assert db.get_session("b")["parent_session_id"] == "a"
|
||
assert db.get_compression_lineage("a") == ["a", "b"]
|
||
|
||
def test_import_sessions_detaches_self_parent(self, db):
|
||
result = db.import_sessions(
|
||
[
|
||
{
|
||
"id": "self",
|
||
"source": "cli",
|
||
"parent_session_id": "self",
|
||
"end_reason": "compression",
|
||
"messages": [],
|
||
}
|
||
]
|
||
)
|
||
|
||
assert result["ok"] is True
|
||
assert result["detached"] == 1
|
||
assert db.get_session("self")["parent_session_id"] is None
|
||
|
||
def test_compression_lineage_terminates_for_preexisting_cycle(self, db):
|
||
db.create_session("a", "cli")
|
||
db.end_session("a", "compression")
|
||
db.create_session("b", "cli", parent_session_id="a")
|
||
db.end_session("b", "compression")
|
||
db._conn.execute("UPDATE sessions SET parent_session_id = ? WHERE id = ?", ("b", "a"))
|
||
db._conn.commit()
|
||
|
||
lineage = db.get_compression_lineage("a")
|
||
assert set(lineage) == {"a", "b"}
|
||
assert len(lineage) == 2
|
||
assert set(db.export_session_lineage("a")["lineage_session_ids"]) == {"a", "b"}
|
||
|
||
@pytest.mark.parametrize(
|
||
("payload", "error"),
|
||
[
|
||
(
|
||
{"id": "bad-json", "model_config": "{not-json", "messages": []},
|
||
"model_config must be valid JSON",
|
||
),
|
||
(
|
||
{"id": "bad-text", "user_id": {"not": "text"}, "messages": []},
|
||
"user_id must be a string",
|
||
),
|
||
(
|
||
{"id": "missing-role", "messages": [{"content": "x"}]},
|
||
"messages[0].role must be a non-empty string",
|
||
),
|
||
(
|
||
{"id": "null-role", "messages": [{"role": None, "content": "x"}]},
|
||
"messages[0].role must be a non-empty string",
|
||
),
|
||
],
|
||
)
|
||
def test_import_sessions_rejects_invalid_metadata(self, db, payload, error):
|
||
result = db.import_sessions([payload])
|
||
|
||
assert result["ok"] is False
|
||
assert result["errors"] == [{"index": 0, "session_id": payload["id"], "error": error}]
|
||
assert db.get_session(payload["id"]) is None
|
||
|
||
def test_import_sessions_rejects_oversized_payloads_atomically(self, db):
|
||
oversized = "x" * (SessionDB._IMPORT_MAX_SESSION_BYTES + 1)
|
||
result = db.import_sessions(
|
||
[{"id": "oversized", "messages": [{"role": "user", "content": oversized}]}]
|
||
)
|
||
|
||
assert result["ok"] is False
|
||
assert result["errors"][0]["error"] == "session exceeds the import size limit"
|
||
assert db.get_session("oversized") is None
|
||
|
||
result = db.import_sessions(
|
||
[
|
||
{
|
||
"id": "too-many-messages",
|
||
"messages": [
|
||
{"role": "user", "content": "x"}
|
||
]
|
||
* (SessionDB._IMPORT_MAX_MESSAGES_PER_SESSION + 1),
|
||
}
|
||
]
|
||
)
|
||
|
||
assert result["ok"] is False
|
||
assert result["errors"][0]["error"] == "messages exceeds the per-session import limit"
|
||
assert db.get_session("too-many-messages") is None
|
||
|
||
|
||
# =========================================================================
|
||
# Prune
|
||
# =========================================================================
|
||
|
||
class TestPruneSessions:
|
||
def test_prune_old_ended_sessions(self, db):
|
||
# Create and end an "old" session
|
||
db.create_session(session_id="old", source="cli")
|
||
db.end_session("old", end_reason="done")
|
||
# Manually backdate started_at
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||
(time.time() - 100 * 86400, "old"),
|
||
)
|
||
db._conn.commit()
|
||
|
||
# Create a recent session
|
||
db.create_session(session_id="new", source="cli")
|
||
|
||
pruned = db.prune_sessions(older_than_days=90)
|
||
assert pruned == 1
|
||
assert db.get_session("old") is None
|
||
session = db.get_session("new")
|
||
assert session is not None
|
||
assert session["id"] == "new"
|
||
|
||
def test_prune_skips_active_sessions(self, db):
|
||
db.create_session(session_id="active", source="cli")
|
||
# Backdate but don't end
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||
(time.time() - 200 * 86400, "active"),
|
||
)
|
||
db._conn.commit()
|
||
|
||
pruned = db.prune_sessions(older_than_days=90)
|
||
assert pruned == 0
|
||
assert db.get_session("active") is not None
|
||
|
||
def test_prune_with_source_filter(self, db):
|
||
for sid, src in [("old_cli", "cli"), ("old_tg", "telegram")]:
|
||
db.create_session(session_id=sid, source=src)
|
||
db.end_session(sid, end_reason="done")
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||
(time.time() - 200 * 86400, sid),
|
||
)
|
||
db._conn.commit()
|
||
|
||
pruned = db.prune_sessions(older_than_days=90, source="cli")
|
||
assert pruned == 1
|
||
assert db.get_session("old_cli") is None
|
||
assert db.get_session("old_tg") is not None
|
||
|
||
def test_prune_with_multilevel_chain(self, db):
|
||
"""Pruning old sessions orphans newer children instead of crashing on FK."""
|
||
old_ts = time.time() - 200 * 86400
|
||
recent_ts = time.time() - 10 * 86400
|
||
|
||
# Chain: A (old) -> B (old) -> C (recent) -> D (recent)
|
||
db.create_session(session_id="A", source="cli")
|
||
db.end_session("A", end_reason="compressed")
|
||
db.create_session(session_id="B", source="cli", parent_session_id="A")
|
||
db.end_session("B", end_reason="compressed")
|
||
db.create_session(session_id="C", source="cli", parent_session_id="B")
|
||
db.end_session("C", end_reason="compressed")
|
||
db.create_session(session_id="D", source="cli", parent_session_id="C")
|
||
db.end_session("D", end_reason="done")
|
||
|
||
# Backdate A and B to be old; C and D stay recent
|
||
for sid, ts in [("A", old_ts), ("B", old_ts), ("C", recent_ts), ("D", recent_ts)]:
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at = ? WHERE id = ?", (ts, sid)
|
||
)
|
||
db._conn.commit()
|
||
|
||
# Should not raise IntegrityError
|
||
pruned = db.prune_sessions(older_than_days=90)
|
||
assert pruned == 2 # only A and B
|
||
assert db.get_session("A") is None
|
||
assert db.get_session("B") is None
|
||
# C and D survive, C is orphaned (parent_session_id NULL)
|
||
c = db.get_session("C")
|
||
assert c is not None
|
||
assert c["parent_session_id"] is None
|
||
d = db.get_session("D")
|
||
assert d is not None
|
||
assert d["parent_session_id"] == "C"
|
||
|
||
def test_prune_entire_old_chain(self, db):
|
||
"""All sessions in a chain are old — entire chain is pruned."""
|
||
old_ts = time.time() - 200 * 86400
|
||
|
||
db.create_session(session_id="X", source="cli")
|
||
db.end_session("X", end_reason="compressed")
|
||
db.create_session(session_id="Y", source="cli", parent_session_id="X")
|
||
db.end_session("Y", end_reason="compressed")
|
||
db.create_session(session_id="Z", source="cli", parent_session_id="Y")
|
||
db.end_session("Z", end_reason="done")
|
||
|
||
for sid in ("X", "Y", "Z"):
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at = ? WHERE id = ?", (old_ts, sid)
|
||
)
|
||
db._conn.commit()
|
||
|
||
pruned = db.prune_sessions(older_than_days=90)
|
||
assert pruned == 3
|
||
for sid in ("X", "Y", "Z"):
|
||
assert db.get_session(sid) is None
|
||
|
||
|
||
class TestPruneSessionFilters:
|
||
"""Extended filter surface shared by prune/archive/list_prune_candidates."""
|
||
|
||
@staticmethod
|
||
def _mk(db, sid, *, source="cli", age_seconds=0, title=None,
|
||
end_reason="done", message_count=0, cwd=None):
|
||
db.create_session(session_id=sid, source=source, cwd=cwd)
|
||
db.end_session(sid, end_reason=end_reason)
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at = ?, message_count = ?, title = ? "
|
||
"WHERE id = ?",
|
||
(time.time() - age_seconds, message_count, title, sid),
|
||
)
|
||
db._conn.commit()
|
||
|
||
def test_started_after_window_prunes_only_recent(self, db):
|
||
self._mk(db, "recent1", age_seconds=3600) # 1h ago
|
||
self._mk(db, "recent2", age_seconds=2 * 3600) # 2h ago
|
||
self._mk(db, "old", age_seconds=10 * 3600) # 10h ago
|
||
|
||
cutoff = time.time() - 5 * 3600
|
||
pruned = db.prune_sessions(older_than_days=None, started_after=cutoff)
|
||
assert pruned == 2
|
||
assert db.get_session("old") is not None
|
||
assert db.get_session("recent1") is None
|
||
|
||
def test_before_after_window(self, db):
|
||
self._mk(db, "inside", age_seconds=5 * 3600)
|
||
self._mk(db, "too_new", age_seconds=1 * 3600)
|
||
self._mk(db, "too_old", age_seconds=20 * 3600)
|
||
|
||
now = time.time()
|
||
pruned = db.prune_sessions(
|
||
older_than_days=None,
|
||
started_after=now - 10 * 3600,
|
||
started_before=now - 2 * 3600,
|
||
)
|
||
assert pruned == 1
|
||
assert db.get_session("inside") is None
|
||
assert db.get_session("too_new") is not None
|
||
assert db.get_session("too_old") is not None
|
||
|
||
def test_title_and_message_count_filters(self, db):
|
||
self._mk(db, "smoke1", age_seconds=60, title="Codex Smoke Test 1",
|
||
message_count=2)
|
||
self._mk(db, "smoke2", age_seconds=60, title="codex smoke test 2",
|
||
message_count=8)
|
||
self._mk(db, "real", age_seconds=60, title="Debugging auth",
|
||
message_count=8)
|
||
|
||
rows = db.list_prune_candidates(title_like="smoke")
|
||
assert {r["id"] for r in rows} == {"smoke1", "smoke2"}
|
||
|
||
pruned = db.prune_sessions(
|
||
older_than_days=None, title_like="Smoke", max_messages=3
|
||
)
|
||
assert pruned == 1
|
||
assert db.get_session("smoke1") is None
|
||
assert db.get_session("smoke2") is not None
|
||
assert db.get_session("real") is not None
|
||
|
||
def test_end_reason_and_cwd_filters(self, db):
|
||
self._mk(db, "s1", age_seconds=60, end_reason="done",
|
||
cwd="/home/u/scratch/x")
|
||
self._mk(db, "s2", age_seconds=60, end_reason="error",
|
||
cwd="/home/u/scratch")
|
||
self._mk(db, "s3", age_seconds=60, end_reason="done",
|
||
cwd="/home/u/work")
|
||
|
||
rows = db.list_prune_candidates(cwd_prefix="/home/u/scratch")
|
||
assert {r["id"] for r in rows} == {"s1", "s2"}
|
||
|
||
pruned = db.prune_sessions(
|
||
older_than_days=None, end_reason="done",
|
||
cwd_prefix="/home/u/scratch",
|
||
)
|
||
assert pruned == 1
|
||
assert db.get_session("s1") is None
|
||
|
||
def test_prune_excludes_archived_when_requested(self, db):
|
||
self._mk(db, "arch", age_seconds=60)
|
||
self._mk(db, "plain", age_seconds=60)
|
||
db.set_session_archived("arch", True)
|
||
|
||
pruned = db.prune_sessions(older_than_days=None, started_after=0,
|
||
archived=False)
|
||
assert pruned == 1
|
||
assert db.get_session("arch") is not None
|
||
assert db.get_session("plain") is None
|
||
|
||
def test_archive_sessions_bulk(self, db):
|
||
self._mk(db, "a1", age_seconds=3600)
|
||
self._mk(db, "a2", age_seconds=2 * 3600)
|
||
self._mk(db, "keep", age_seconds=10 * 3600)
|
||
# Active session in the window must never be touched
|
||
db.create_session(session_id="live", source="cli")
|
||
|
||
cutoff = time.time() - 5 * 3600
|
||
count = db.archive_sessions(started_after=cutoff)
|
||
assert count == 2
|
||
assert db.get_session("a1")["archived"] == 1
|
||
assert db.get_session("a2")["archived"] == 1
|
||
assert db.get_session("keep")["archived"] == 0
|
||
assert db.get_session("live")["archived"] == 0
|
||
# Idempotent: already-archived rows aren't re-selected
|
||
assert db.archive_sessions(started_after=cutoff) == 0
|
||
|
||
def test_list_prune_candidates_matches_prune(self, db):
|
||
self._mk(db, "c1", age_seconds=3600, source="cli")
|
||
self._mk(db, "c2", age_seconds=3600, source="telegram")
|
||
rows = db.list_prune_candidates(started_after=0, source="cli")
|
||
assert [r["id"] for r in rows] == ["c1"]
|
||
pruned = db.prune_sessions(older_than_days=None, started_after=0,
|
||
source="cli")
|
||
assert pruned == 1
|
||
|
||
def test_default_signature_unchanged(self, db):
|
||
"""Legacy positional call keeps working with identical semantics."""
|
||
self._mk(db, "ancient", age_seconds=200 * 86400)
|
||
self._mk(db, "fresh", age_seconds=60)
|
||
assert db.prune_sessions(90) == 1
|
||
assert db.get_session("ancient") is None
|
||
assert db.get_session("fresh") is not None
|
||
|
||
@staticmethod
|
||
def _mk_rich(db, sid, **cols):
|
||
"""Create an ended session then set arbitrary sessions columns."""
|
||
db.create_session(session_id=sid, source=cols.pop("source", "cli"))
|
||
db.end_session(sid, end_reason=cols.pop("end_reason", "done"))
|
||
cols.setdefault("started_at", time.time() - 60)
|
||
sets = ", ".join(f"{k} = ?" for k in cols)
|
||
db._conn.execute(
|
||
f"UPDATE sessions SET {sets} WHERE id = ?", (*cols.values(), sid)
|
||
)
|
||
db._conn.commit()
|
||
|
||
def test_model_like_filter(self, db):
|
||
self._mk_rich(db, "m1", model="anthropic/claude-sonnet-4.6")
|
||
self._mk_rich(db, "m2", model="openai/gpt-5.4")
|
||
self._mk_rich(db, "m3", model=None)
|
||
|
||
rows = db.list_prune_candidates(model_like="Sonnet")
|
||
assert [r["id"] for r in rows] == ["m1"]
|
||
assert db.prune_sessions(older_than_days=None, model_like="gpt-5") == 1
|
||
assert db.get_session("m2") is None
|
||
assert db.get_session("m1") is not None
|
||
assert db.get_session("m3") is not None
|
||
|
||
def test_provider_filter(self, db):
|
||
self._mk_rich(db, "p1", billing_provider="openrouter")
|
||
self._mk_rich(db, "p2", billing_provider="Anthropic")
|
||
self._mk_rich(db, "p3", billing_provider=None)
|
||
|
||
assert db.prune_sessions(older_than_days=None, provider="anthropic") == 1
|
||
assert db.get_session("p2") is None
|
||
assert db.get_session("p1") is not None
|
||
assert db.get_session("p3") is not None
|
||
|
||
def test_user_chat_filters(self, db):
|
||
self._mk_rich(db, "u1", user_id="alice", chat_id="c-1", chat_type="dm")
|
||
self._mk_rich(db, "u2", user_id="bob", chat_id="c-2", chat_type="group")
|
||
|
||
assert db.prune_sessions(older_than_days=None, user_id="alice") == 1
|
||
assert db.get_session("u1") is None
|
||
assert db.prune_sessions(
|
||
older_than_days=None, chat_id="c-2", chat_type="group"
|
||
) == 1
|
||
assert db.get_session("u2") is None
|
||
|
||
def test_branch_like_filter(self, db):
|
||
self._mk_rich(db, "b1", git_branch="feature/old-experiment")
|
||
self._mk_rich(db, "b2", git_branch="main")
|
||
|
||
assert db.prune_sessions(older_than_days=None, branch_like="experiment") == 1
|
||
assert db.get_session("b1") is None
|
||
assert db.get_session("b2") is not None
|
||
|
||
def test_token_cost_toolcall_bounds(self, db):
|
||
self._mk_rich(db, "cheap", input_tokens=100, output_tokens=50,
|
||
actual_cost_usd=0.001, tool_call_count=0)
|
||
self._mk_rich(db, "mid", input_tokens=5000, output_tokens=2000,
|
||
actual_cost_usd=None, estimated_cost_usd=0.5,
|
||
tool_call_count=12)
|
||
self._mk_rich(db, "big", input_tokens=90000, output_tokens=30000,
|
||
actual_cost_usd=4.2, tool_call_count=80)
|
||
|
||
rows = db.list_prune_candidates(max_tokens=200)
|
||
assert [r["id"] for r in rows] == ["cheap"]
|
||
rows = db.list_prune_candidates(min_tokens=7000, max_tokens=10000)
|
||
assert [r["id"] for r in rows] == ["mid"]
|
||
# Cost falls back to estimated when actual is NULL
|
||
rows = db.list_prune_candidates(min_cost=0.4, max_cost=1.0)
|
||
assert [r["id"] for r in rows] == ["mid"]
|
||
rows = db.list_prune_candidates(min_tool_calls=50)
|
||
assert [r["id"] for r in rows] == ["big"]
|
||
assert db.prune_sessions(older_than_days=None, max_tool_calls=0) == 1
|
||
assert db.get_session("cheap") is None
|
||
|
||
def test_unknown_filter_rejected(self, db):
|
||
import pytest as _pytest
|
||
with _pytest.raises(TypeError):
|
||
db.prune_sessions(older_than_days=None, bogus_filter="x")
|
||
|
||
|
||
class TestDeleteSessionOrphansChildren:
|
||
def test_delete_orphans_children(self, db):
|
||
"""Deleting a parent session orphans its children."""
|
||
db.create_session(session_id="parent", source="cli")
|
||
db.create_session(session_id="child", source="cli", parent_session_id="parent")
|
||
db.create_session(session_id="grandchild", source="cli", parent_session_id="child")
|
||
|
||
# Should not raise IntegrityError
|
||
result = db.delete_session("parent")
|
||
assert result is True
|
||
assert db.get_session("parent") is None
|
||
# Child is orphaned, not deleted
|
||
child = db.get_session("child")
|
||
assert child is not None
|
||
assert child["parent_session_id"] is None
|
||
# Grandchild is untouched
|
||
grandchild = db.get_session("grandchild")
|
||
assert grandchild is not None
|
||
assert grandchild["parent_session_id"] == "child"
|
||
|
||
|
||
class TestBulkDeleteSessions:
|
||
"""``delete_sessions(ids)`` — the bulk-delete primitive backing the
|
||
sessions-page "Delete N selected" button. Per-row contract matches
|
||
:meth:`SessionDB.delete_session` (children orphaned, not cascade-
|
||
deleted), but applied across the whole list in one transaction.
|
||
|
||
Invariants this class locks in:
|
||
|
||
1. Returns the real deleted count (existing intersection), not
|
||
just ``len(session_ids)`` — selection state in the UI can race
|
||
against another tab's delete.
|
||
2. Unknown IDs are silently skipped, never raise.
|
||
3. ``message_count > 0`` sessions are deleted too — unlike
|
||
``delete_empty_sessions``, the user explicitly picked them, so
|
||
we trust the selection.
|
||
4. Live (un-ended) and archived sessions ARE deleted on explicit
|
||
selection (no bulk-sweep safety guards apply when the user
|
||
hand-picks the row).
|
||
5. Children of any deleted parent are orphaned, even when the
|
||
parent is mid-list.
|
||
6. ``[]`` / ``None``-laden lists are safe no-ops.
|
||
"""
|
||
|
||
def test_deletes_listed_sessions(self, db):
|
||
db.create_session(session_id="a", source="cli")
|
||
db.append_message("a", role="user", content="hi")
|
||
db.create_session(session_id="b", source="cli")
|
||
db.create_session(session_id="c", source="cli")
|
||
|
||
deleted = db.delete_sessions(["a", "b"])
|
||
assert deleted == 2
|
||
assert db.get_session("a") is None
|
||
assert db.get_session("b") is None
|
||
# Unlisted survives.
|
||
assert db.get_session("c") is not None
|
||
|
||
def test_returns_real_count_skipping_unknown_ids(self, db):
|
||
"""Unknown IDs are silently skipped — the return value reflects
|
||
what was *actually* deleted, so the UI can show an accurate
|
||
toast even if the selection raced against another tab."""
|
||
db.create_session(session_id="real", source="cli")
|
||
|
||
deleted = db.delete_sessions(["real", "ghost1", "ghost2"])
|
||
assert deleted == 1
|
||
assert db.get_session("real") is None
|
||
|
||
def test_empty_list_is_noop(self, db):
|
||
"""``[]`` returns 0 without touching the DB. Guards against a
|
||
bulk endpoint with an empty payload triggering an
|
||
unconditional 'wipe everything' if the caller forgets the
|
||
WHERE clause."""
|
||
db.create_session(session_id="keep", source="cli")
|
||
assert db.delete_sessions([]) == 0
|
||
assert db.get_session("keep") is not None
|
||
|
||
def test_drops_non_string_entries(self, db):
|
||
"""Stray ``None`` / empty strings in the input list are
|
||
filtered out before hitting SQL. Callers may pull selection IDs
|
||
from a Set-like that occasionally contains noise; we don't want
|
||
a SQL parameter-type error to fail the whole batch."""
|
||
db.create_session(session_id="real", source="cli")
|
||
# noinspection PyTypeChecker
|
||
deleted = db.delete_sessions(["real", None, "", "ghost"]) # type: ignore[list-item]
|
||
assert deleted == 1
|
||
assert db.get_session("real") is None
|
||
|
||
def test_dedupes_duplicate_ids(self, db):
|
||
"""The same ID listed twice counts as one deletion. Defends
|
||
against a hand-crafted POST body or a UI bug that double-adds
|
||
the same selection."""
|
||
db.create_session(session_id="real", source="cli")
|
||
deleted = db.delete_sessions(["real", "real"])
|
||
assert deleted == 1
|
||
|
||
def test_orphans_children_of_deleted_parents(self, db):
|
||
"""Bulk-deleting a parent leaves its children alive but
|
||
re-parented to NULL. Same contract as the single-session
|
||
:meth:`delete_session` path."""
|
||
db.create_session(session_id="parent", source="cli")
|
||
db.create_session(
|
||
session_id="child", source="cli", parent_session_id="parent"
|
||
)
|
||
|
||
deleted = db.delete_sessions(["parent"])
|
||
assert deleted == 1
|
||
child = db.get_session("child")
|
||
assert child is not None
|
||
assert child["parent_session_id"] is None
|
||
|
||
def test_deletes_archived_and_active_when_selected(self, db):
|
||
"""Unlike the safety-gated ``delete_empty_sessions`` sweep,
|
||
explicit bulk-select trusts the user — archived sessions and
|
||
un-ended live sessions are both deleted when in the list.
|
||
Otherwise the selection UI would silently 'leak' rows the user
|
||
thought they'd removed."""
|
||
db.create_session(session_id="archived", source="cli")
|
||
db.end_session("archived", end_reason="done")
|
||
db.set_session_archived("archived", True)
|
||
db.create_session(session_id="live", source="cli")
|
||
|
||
deleted = db.delete_sessions(["archived", "live"])
|
||
assert deleted == 2
|
||
assert db.get_session("archived") is None
|
||
assert db.get_session("live") is None
|
||
|
||
def test_cleans_up_transcript_files(self, db, tmp_path):
|
||
"""When ``sessions_dir`` is provided, on-disk transcripts are
|
||
swept as part of the bulk operation — mirrors the per-row
|
||
:meth:`delete_session(sessions_dir=...)` behaviour so the
|
||
bulk-delete CLI / web flows don't leak files."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="cli")
|
||
(tmp_path / "s1.jsonl").write_text("")
|
||
(tmp_path / "s2.json").write_text("{}")
|
||
|
||
deleted = db.delete_sessions(["s1", "s2"], sessions_dir=tmp_path)
|
||
assert deleted == 2
|
||
assert not (tmp_path / "s1.jsonl").exists()
|
||
assert not (tmp_path / "s2.json").exists()
|
||
|
||
|
||
class TestDeleteEmptySessions:
|
||
"""``delete_empty_sessions`` sweeps every ended, non-archived session
|
||
whose ``message_count`` is 0. Backs the dashboard's "Delete empty"
|
||
button — see ``SessionsPage.tsx`` + ``DELETE /api/sessions/empty``
|
||
in ``hermes_cli/web_server.py``.
|
||
|
||
Invariants this class locks in:
|
||
|
||
1. Only ``message_count = 0`` rows are touched.
|
||
2. Active (un-ended) sessions are skipped even if they're empty —
|
||
the agent might be mid-handshake, and yanking the row would
|
||
race the live runtime.
|
||
3. Archived sessions are skipped — the user already filed them away.
|
||
4. Children of a deleted parent are orphaned (parent_session_id →
|
||
NULL) rather than cascade-deleted, matching the
|
||
``delete_session`` / ``prune_sessions`` contract.
|
||
5. The pre-DB count matches the post-DB delete return value.
|
||
"""
|
||
|
||
def test_count_and_delete_empties_only(self, db):
|
||
# Two empty + ended sessions → both should be in the kill list.
|
||
db.create_session(session_id="empty1", source="cli")
|
||
db.end_session("empty1", end_reason="done")
|
||
db.create_session(session_id="empty2", source="cli")
|
||
db.end_session("empty2", end_reason="done")
|
||
|
||
# One non-empty + ended session → must survive.
|
||
db.create_session(session_id="hasmsg", source="cli")
|
||
db.append_message("hasmsg", role="user", content="Hello")
|
||
db.end_session("hasmsg", end_reason="done")
|
||
|
||
assert db.count_empty_sessions() == 2
|
||
|
||
deleted = db.delete_empty_sessions()
|
||
assert deleted == 2
|
||
assert db.get_session("empty1") is None
|
||
assert db.get_session("empty2") is None
|
||
assert db.get_session("hasmsg") is not None
|
||
assert db.count_empty_sessions() == 0
|
||
|
||
def test_skips_active_empty_sessions(self, db):
|
||
"""A live (un-ended) empty session is what you get during the
|
||
race between session-create and the first message landing. The
|
||
sweep must not delete it — that would yank a session out from
|
||
under the agent before its first reply persists."""
|
||
db.create_session(session_id="live", source="cli")
|
||
# Deliberately no end_session() — session is "active".
|
||
|
||
assert db.count_empty_sessions() == 0
|
||
assert db.delete_empty_sessions() == 0
|
||
assert db.get_session("live") is not None
|
||
|
||
def test_skips_archived_empty_sessions(self, db):
|
||
"""Archived = soft-hidden by the user. They explicitly chose to
|
||
keep the row around (even though it's empty), so the bulk sweep
|
||
must not surprise them by deleting it. Restoring an archived
|
||
session is one click; resurrecting one we deleted is impossible."""
|
||
db.create_session(session_id="archived_empty", source="cli")
|
||
db.end_session("archived_empty", end_reason="done")
|
||
db.set_session_archived("archived_empty", True)
|
||
|
||
assert db.count_empty_sessions() == 0
|
||
assert db.delete_empty_sessions() == 0
|
||
assert db.get_session("archived_empty") is not None
|
||
|
||
def test_returns_zero_when_nothing_to_delete(self, db):
|
||
"""No-op path: no candidate rows → return 0, no error."""
|
||
db.create_session(session_id="hasmsg", source="cli")
|
||
db.append_message("hasmsg", role="user", content="Hello")
|
||
db.end_session("hasmsg", end_reason="done")
|
||
|
||
assert db.count_empty_sessions() == 0
|
||
assert db.delete_empty_sessions() == 0
|
||
assert db.get_session("hasmsg") is not None
|
||
|
||
def test_orphans_children_of_deleted_empty_parent(self, db):
|
||
"""Even an empty parent can have a child (e.g. a branch session
|
||
spawned before the parent received any messages). The sweep
|
||
must orphan that child, not cascade-delete it — same contract
|
||
as ``delete_session`` and ``prune_sessions``."""
|
||
db.create_session(session_id="empty_parent", source="cli")
|
||
db.end_session("empty_parent", end_reason="done")
|
||
db.create_session(
|
||
session_id="child", source="cli", parent_session_id="empty_parent"
|
||
)
|
||
db.append_message("child", role="user", content="something")
|
||
db.end_session("child", end_reason="done")
|
||
|
||
deleted = db.delete_empty_sessions()
|
||
assert deleted == 1
|
||
assert db.get_session("empty_parent") is None
|
||
child = db.get_session("child")
|
||
assert child is not None
|
||
assert child["parent_session_id"] is None
|
||
|
||
def test_cleans_up_on_disk_transcript_files(self, db, tmp_path):
|
||
"""When ``sessions_dir`` is provided, transcript files left
|
||
behind by a crashed gateway (``request_dump_*.json``) are swept
|
||
too. Empty sessions rarely have ``{id}.json`` / ``.jsonl``
|
||
transcripts, but the request-dump path is real — the gateway
|
||
writes one before the first reply lands, so a crash mid-reply
|
||
produces an empty session with a non-empty dump file."""
|
||
db.create_session(session_id="empty_with_dump", source="cli")
|
||
db.end_session("empty_with_dump", end_reason="done")
|
||
|
||
dump = tmp_path / "request_dump_empty_with_dump_0.json"
|
||
dump.write_text("{}")
|
||
transcript = tmp_path / "empty_with_dump.jsonl"
|
||
transcript.write_text("")
|
||
|
||
deleted = db.delete_empty_sessions(sessions_dir=tmp_path)
|
||
assert deleted == 1
|
||
assert not dump.exists()
|
||
assert not transcript.exists()
|
||
|
||
|
||
# =========================================================================
|
||
# Schema and WAL mode
|
||
# =========================================================================
|
||
|
||
# =========================================================================
|
||
# Session title
|
||
# =========================================================================
|
||
|
||
class TestSessionTitle:
|
||
def test_set_and_get_title(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
assert db.set_session_title("s1", "My Session") is True
|
||
|
||
session = db.get_session("s1")
|
||
assert session["title"] == "My Session"
|
||
|
||
def test_set_title_nonexistent_session(self, db):
|
||
assert db.set_session_title("nonexistent", "Title") is False
|
||
|
||
def test_title_initially_none(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
session = db.get_session("s1")
|
||
assert session["title"] is None
|
||
|
||
def test_update_title(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.set_session_title("s1", "First Title")
|
||
db.set_session_title("s1", "Updated Title")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["title"] == "Updated Title"
|
||
|
||
def test_auto_title_only_sets_an_empty_title(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
assert db.set_auto_title_if_empty("s1", "Generated Title") is True
|
||
assert db.set_auto_title_if_empty("s1", "Replacement Title") is False
|
||
assert db.get_session_title("s1") == "Generated Title"
|
||
|
||
def test_title_in_search_sessions(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.set_session_title("s1", "Debugging Auth")
|
||
db.create_session(session_id="s2", source="cli")
|
||
|
||
sessions = db.search_sessions()
|
||
titled = [s for s in sessions if s.get("title") == "Debugging Auth"]
|
||
assert len(titled) == 1
|
||
assert titled[0]["id"] == "s1"
|
||
|
||
def test_title_in_export(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.set_session_title("s1", "Export Test")
|
||
db.append_message("s1", role="user", content="Hello")
|
||
|
||
export = db.export_session("s1")
|
||
assert export["title"] == "Export Test"
|
||
|
||
def test_title_with_special_characters(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
title = "PR #438 — fixing the 'auth' middleware"
|
||
db.set_session_title("s1", title)
|
||
|
||
session = db.get_session("s1")
|
||
assert session["title"] == title
|
||
|
||
def test_title_empty_string_normalized_to_none(self, db):
|
||
"""Empty strings are normalized to None (clearing the title)."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.set_session_title("s1", "My Title")
|
||
# Setting to empty string should clear the title (normalize to None)
|
||
db.set_session_title("s1", "")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["title"] is None
|
||
|
||
def test_multiple_empty_titles_no_conflict(self, db):
|
||
"""Multiple sessions can have empty-string (normalized to NULL) titles."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.create_session(session_id="s2", source="cli")
|
||
db.set_session_title("s1", "")
|
||
db.set_session_title("s2", "")
|
||
# Both should be None, no uniqueness conflict
|
||
assert db.get_session("s1")["title"] is None
|
||
assert db.get_session("s2")["title"] is None
|
||
|
||
def test_title_survives_end_session(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.set_session_title("s1", "Before End")
|
||
db.end_session("s1", end_reason="user_exit")
|
||
|
||
session = db.get_session("s1")
|
||
assert session["title"] == "Before End"
|
||
assert session["ended_at"] is not None
|
||
|
||
|
||
class TestSessionTitleIndexRepair:
|
||
@staticmethod
|
||
def _seed_legacy_database(tmp_path, *, duplicate_titles):
|
||
db_path = tmp_path / "legacy_titles.db"
|
||
session_db = SessionDB(db_path=db_path)
|
||
session_db.create_session("older", "cli")
|
||
session_db.append_message("older", role="user", content="keep older message")
|
||
session_db.create_session("newer", "cli")
|
||
session_db.append_message(
|
||
"newer", role="assistant", content="keep newer message"
|
||
)
|
||
session_db.create_session("unique", "cli")
|
||
session_db.set_session_title("unique", "unique-title")
|
||
session_db.close()
|
||
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.execute("DROP INDEX idx_sessions_title_unique")
|
||
if duplicate_titles:
|
||
conn.execute(
|
||
"UPDATE sessions SET title = 'shared-title' "
|
||
"WHERE id IN ('older', 'newer')"
|
||
)
|
||
|
||
return db_path
|
||
|
||
def test_duplicate_titles_are_repaired_without_deleting_sessions(self, tmp_path):
|
||
db_path = self._seed_legacy_database(tmp_path, duplicate_titles=True)
|
||
|
||
reopened = SessionDB(db_path=db_path)
|
||
try:
|
||
conn = reopened._conn
|
||
assert conn is not None
|
||
rows = {
|
||
row["id"]: row
|
||
for row in conn.execute(
|
||
"SELECT id, title FROM sessions ORDER BY rowid"
|
||
).fetchall()
|
||
}
|
||
assert set(rows) == {"older", "newer", "unique"}
|
||
assert rows["older"]["title"] is None
|
||
assert rows["newer"]["title"] == "shared-title"
|
||
assert rows["unique"]["title"] == "unique-title"
|
||
assert reopened.get_messages("older")[0]["content"] == "keep older message"
|
||
assert reopened.get_messages("newer")[0]["content"] == "keep newer message"
|
||
index = conn.execute(
|
||
"SELECT sql FROM sqlite_master "
|
||
"WHERE type = 'index' AND name = 'idx_sessions_title_unique'"
|
||
).fetchone()
|
||
assert index is not None
|
||
finally:
|
||
reopened.close()
|
||
|
||
def test_repaired_index_rejects_future_duplicate_title(self, tmp_path):
|
||
db_path = self._seed_legacy_database(tmp_path, duplicate_titles=True)
|
||
|
||
reopened = SessionDB(db_path=db_path)
|
||
try:
|
||
reopened.create_session("future", "cli")
|
||
with pytest.raises(ValueError, match="already in use"):
|
||
reopened.set_session_title("future", "shared-title")
|
||
finally:
|
||
reopened.close()
|
||
|
||
def test_clean_legacy_database_keeps_existing_titles(self, tmp_path):
|
||
db_path = self._seed_legacy_database(tmp_path, duplicate_titles=False)
|
||
|
||
reopened = SessionDB(db_path=db_path)
|
||
try:
|
||
assert reopened.get_session_title("unique") == "unique-title"
|
||
assert reopened.get_session_title("older") is None
|
||
assert reopened.get_session_title("newer") is None
|
||
finally:
|
||
reopened.close()
|
||
|
||
|
||
class TestSessionTitleLineage:
|
||
"""Renaming a compression continuation back to its base title must succeed
|
||
by transferring the title off the ended, hidden predecessor.
|
||
|
||
After a context compaction the original session is ended and projected
|
||
behind its live tip in the session list (list_sessions_rich), so the user
|
||
cannot see or free it. Without lineage-aware handling, renaming the visible
|
||
tip back to the base name dead-ends with "already in use by <session they
|
||
can't find>".
|
||
"""
|
||
|
||
def _make_compression_chain(self, db, t0, *, root="root", tip="tip"):
|
||
db.create_session(root, "cli")
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, root))
|
||
db._conn.execute(
|
||
"UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?",
|
||
(t0 + 100, root),
|
||
)
|
||
db.create_session(tip, "cli", parent_session_id=root)
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 200, tip))
|
||
db._conn.commit()
|
||
|
||
def test_rename_continuation_back_to_base_transfers_title(self, db):
|
||
import time as _time
|
||
self._make_compression_chain(db, _time.time() - 3600)
|
||
db.set_session_title("root", "fingerprint-scanner")
|
||
db.set_session_title("tip", "fingerprint-scanner #2")
|
||
|
||
# User renames the visible tip back to the base name — must succeed.
|
||
assert db.set_session_title("tip", "fingerprint-scanner") is True
|
||
assert db.get_session("tip")["title"] == "fingerprint-scanner"
|
||
# Title transferred off the hidden ancestor — no duplicate titles.
|
||
assert db.get_session("root")["title"] is None
|
||
|
||
def test_transfer_walks_multi_level_chain(self, db):
|
||
import time as _time
|
||
t0 = _time.time() - 7200
|
||
# root (compression) -> mid (compression) -> tip
|
||
self._make_compression_chain(db, t0, root="root", tip="mid")
|
||
db._conn.execute(
|
||
"UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?",
|
||
(t0 + 300, "mid"),
|
||
)
|
||
db.create_session("tip", "cli", parent_session_id="mid")
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 400, "tip"))
|
||
db._conn.commit()
|
||
|
||
db.set_session_title("root", "deep-dive")
|
||
assert db.set_session_title("tip", "deep-dive") is True
|
||
assert db.get_session("tip")["title"] == "deep-dive"
|
||
assert db.get_session("root")["title"] is None
|
||
|
||
def test_unrelated_session_still_conflicts(self, db):
|
||
db.create_session("a", "cli")
|
||
db.create_session("b", "cli")
|
||
db.set_session_title("a", "shared")
|
||
with pytest.raises(ValueError, match="already in use"):
|
||
db.set_session_title("b", "shared")
|
||
# The unrelated holder keeps its title.
|
||
assert db.get_session("a")["title"] == "shared"
|
||
|
||
def test_non_compression_child_still_conflicts(self, db):
|
||
"""A child whose parent did NOT end via compression (delegate/branch
|
||
spawned while the parent was live) is not a continuation, so renaming it
|
||
to the parent's title must still raise."""
|
||
import time as _time
|
||
t0 = _time.time() - 3600
|
||
db.create_session("parent", "cli")
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "parent"))
|
||
db.create_session("child", "cli", parent_session_id="parent")
|
||
# Child started BEFORE parent ended, and parent ended for a non-
|
||
# compression reason — not a continuation edge.
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 10, "child"))
|
||
db._conn.execute(
|
||
"UPDATE sessions SET ended_at=?, end_reason='user_exit' WHERE id=?",
|
||
(t0 + 100, "parent"),
|
||
)
|
||
db._conn.commit()
|
||
db.set_session_title("parent", "shared")
|
||
with pytest.raises(ValueError, match="already in use"):
|
||
db.set_session_title("child", "shared")
|
||
|
||
|
||
class TestSanitizeTitle:
|
||
"""Tests for SessionDB.sanitize_title() validation and cleaning."""
|
||
|
||
def test_normal_title_unchanged(self):
|
||
assert SessionDB.sanitize_title("My Project") == "My Project"
|
||
|
||
def test_strips_whitespace(self):
|
||
assert SessionDB.sanitize_title(" hello world ") == "hello world"
|
||
|
||
def test_collapses_internal_whitespace(self):
|
||
assert SessionDB.sanitize_title("hello world") == "hello world"
|
||
|
||
def test_tabs_and_newlines_collapsed(self):
|
||
assert SessionDB.sanitize_title("hello\t\nworld") == "hello world"
|
||
|
||
def test_none_returns_none(self):
|
||
assert SessionDB.sanitize_title(None) is None
|
||
|
||
def test_empty_string_returns_none(self):
|
||
assert SessionDB.sanitize_title("") is None
|
||
|
||
def test_whitespace_only_returns_none(self):
|
||
assert SessionDB.sanitize_title(" \t\n ") is None
|
||
|
||
def test_control_chars_stripped(self):
|
||
# Null byte, bell, backspace, etc.
|
||
assert SessionDB.sanitize_title("hello\x00world") == "helloworld"
|
||
assert SessionDB.sanitize_title("\x07\x08test\x1b") == "test"
|
||
|
||
def test_del_char_stripped(self):
|
||
assert SessionDB.sanitize_title("hello\x7fworld") == "helloworld"
|
||
|
||
def test_zero_width_chars_stripped(self):
|
||
# Zero-width space (U+200B), zero-width joiner (U+200D)
|
||
assert SessionDB.sanitize_title("hello\u200bworld") == "helloworld"
|
||
assert SessionDB.sanitize_title("hello\u200dworld") == "helloworld"
|
||
|
||
def test_rtl_override_stripped(self):
|
||
# Right-to-left override (U+202E) — used in filename spoofing attacks
|
||
assert SessionDB.sanitize_title("hello\u202eworld") == "helloworld"
|
||
|
||
def test_bom_stripped(self):
|
||
# Byte order mark (U+FEFF)
|
||
assert SessionDB.sanitize_title("\ufeffhello") == "hello"
|
||
|
||
def test_only_control_chars_returns_none(self):
|
||
assert SessionDB.sanitize_title("\x00\x01\x02\u200b\ufeff") is None
|
||
|
||
def test_max_length_allowed(self):
|
||
title = "A" * 100
|
||
assert SessionDB.sanitize_title(title) == title
|
||
|
||
def test_exceeds_max_length_raises(self):
|
||
title = "A" * 101
|
||
with pytest.raises(ValueError, match="too long"):
|
||
SessionDB.sanitize_title(title)
|
||
|
||
def test_unicode_emoji_allowed(self):
|
||
assert SessionDB.sanitize_title("🚀 My Project 🎉") == "🚀 My Project 🎉"
|
||
|
||
def test_cjk_characters_allowed(self):
|
||
assert SessionDB.sanitize_title("我的项目") == "我的项目"
|
||
|
||
def test_accented_characters_allowed(self):
|
||
assert SessionDB.sanitize_title("Résumé éditing") == "Résumé éditing"
|
||
|
||
def test_special_punctuation_allowed(self):
|
||
title = "PR #438 — fixing the 'auth' middleware"
|
||
assert SessionDB.sanitize_title(title) == title
|
||
|
||
def test_sanitize_applied_in_set_session_title(self, db):
|
||
"""set_session_title applies sanitize_title internally."""
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", " hello\x00 world ")
|
||
assert db.get_session("s1")["title"] == "hello world"
|
||
|
||
def test_too_long_title_rejected_by_set(self, db):
|
||
"""set_session_title raises ValueError for overly long titles."""
|
||
db.create_session("s1", "cli")
|
||
with pytest.raises(ValueError, match="too long"):
|
||
db.set_session_title("s1", "X" * 150)
|
||
|
||
|
||
class TestSchemaInit:
|
||
def test_wal_mode(self, db):
|
||
cursor = db._conn.execute("PRAGMA journal_mode")
|
||
mode = cursor.fetchone()[0]
|
||
assert mode == "wal"
|
||
|
||
def test_foreign_keys_enabled(self, db):
|
||
cursor = db._conn.execute("PRAGMA foreign_keys")
|
||
assert cursor.fetchone()[0] == 1
|
||
|
||
def test_tables_exist(self, db):
|
||
cursor = db._conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||
)
|
||
tables = {row[0] for row in cursor.fetchall()}
|
||
assert "sessions" in tables
|
||
assert "messages" in tables
|
||
assert "schema_version" in tables
|
||
|
||
def test_schema_version(self, db):
|
||
from hermes_state import SCHEMA_VERSION
|
||
cursor = db._conn.execute("SELECT version FROM schema_version")
|
||
version = cursor.fetchone()[0]
|
||
assert version == SCHEMA_VERSION
|
||
|
||
def test_title_column_exists(self, db):
|
||
"""Verify the title column was created in the sessions table."""
|
||
cursor = db._conn.execute("PRAGMA table_info(sessions)")
|
||
columns = {row[1] for row in cursor.fetchall()}
|
||
assert "title" in columns
|
||
|
||
def test_topic_mode_schema_is_not_auto_migrated_on_open(self, tmp_path):
|
||
"""Opening an old DB should not add topic-mode columns until /topic opts in.
|
||
|
||
The gateway must remain rollback-safe: simply upgrading Hermes and starting
|
||
the old bot should not eagerly mutate the state DB for this feature.
|
||
"""
|
||
old_db = tmp_path / "old.db"
|
||
import sqlite3
|
||
|
||
conn = sqlite3.connect(old_db)
|
||
conn.executescript(
|
||
"""
|
||
CREATE TABLE schema_version (version INTEGER NOT NULL);
|
||
INSERT INTO schema_version VALUES (11);
|
||
CREATE TABLE sessions (
|
||
id TEXT PRIMARY KEY,
|
||
source TEXT NOT NULL,
|
||
user_id TEXT,
|
||
model TEXT,
|
||
model_config TEXT,
|
||
system_prompt TEXT,
|
||
parent_session_id TEXT,
|
||
started_at REAL NOT NULL,
|
||
ended_at REAL,
|
||
end_reason TEXT,
|
||
message_count INTEGER DEFAULT 0,
|
||
tool_call_count INTEGER DEFAULT 0,
|
||
input_tokens INTEGER DEFAULT 0,
|
||
output_tokens INTEGER DEFAULT 0,
|
||
cache_read_tokens INTEGER DEFAULT 0,
|
||
cache_write_tokens INTEGER DEFAULT 0,
|
||
reasoning_tokens INTEGER DEFAULT 0,
|
||
billing_provider TEXT,
|
||
billing_base_url TEXT,
|
||
billing_mode TEXT,
|
||
estimated_cost_usd REAL,
|
||
actual_cost_usd REAL,
|
||
cost_status TEXT,
|
||
cost_source TEXT,
|
||
pricing_version TEXT,
|
||
title TEXT,
|
||
api_call_count INTEGER DEFAULT 0,
|
||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||
);
|
||
CREATE TABLE messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||
role TEXT NOT NULL,
|
||
content TEXT,
|
||
tool_call_id TEXT,
|
||
tool_calls TEXT,
|
||
tool_name TEXT,
|
||
timestamp REAL NOT NULL,
|
||
token_count INTEGER,
|
||
finish_reason TEXT,
|
||
reasoning TEXT,
|
||
reasoning_content TEXT,
|
||
reasoning_details TEXT,
|
||
codex_reasoning_items TEXT,
|
||
codex_message_items TEXT
|
||
);
|
||
"""
|
||
)
|
||
conn.close()
|
||
|
||
db = SessionDB(db_path=old_db)
|
||
cursor = db._conn.execute("PRAGMA table_info(sessions)")
|
||
columns = {row[1] for row in cursor.fetchall()}
|
||
assert {"telegram_dm_topic_mode", "telegram_topic_thread_id"}.isdisjoint(columns)
|
||
db.close()
|
||
|
||
def test_apply_telegram_topic_migration_creates_topic_tables_explicitly(self, tmp_path):
|
||
"""The /topic opt-in path owns the DB migration for Telegram topic mode."""
|
||
old_db = tmp_path / "old.db"
|
||
import sqlite3
|
||
|
||
conn = sqlite3.connect(old_db)
|
||
conn.executescript(
|
||
"""
|
||
CREATE TABLE schema_version (version INTEGER NOT NULL);
|
||
INSERT INTO schema_version VALUES (11);
|
||
CREATE TABLE sessions (
|
||
id TEXT PRIMARY KEY,
|
||
source TEXT NOT NULL,
|
||
user_id TEXT,
|
||
model TEXT,
|
||
model_config TEXT,
|
||
system_prompt TEXT,
|
||
parent_session_id TEXT,
|
||
started_at REAL NOT NULL,
|
||
ended_at REAL,
|
||
end_reason TEXT,
|
||
message_count INTEGER DEFAULT 0,
|
||
tool_call_count INTEGER DEFAULT 0,
|
||
input_tokens INTEGER DEFAULT 0,
|
||
output_tokens INTEGER DEFAULT 0,
|
||
cache_read_tokens INTEGER DEFAULT 0,
|
||
cache_write_tokens INTEGER DEFAULT 0,
|
||
reasoning_tokens INTEGER DEFAULT 0,
|
||
billing_provider TEXT,
|
||
billing_base_url TEXT,
|
||
billing_mode TEXT,
|
||
estimated_cost_usd REAL,
|
||
actual_cost_usd REAL,
|
||
cost_status TEXT,
|
||
cost_source TEXT,
|
||
pricing_version TEXT,
|
||
title TEXT,
|
||
api_call_count INTEGER DEFAULT 0,
|
||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||
);
|
||
CREATE TABLE messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||
role TEXT NOT NULL,
|
||
content TEXT,
|
||
tool_call_id TEXT,
|
||
tool_calls TEXT,
|
||
tool_name TEXT,
|
||
timestamp REAL NOT NULL,
|
||
token_count INTEGER,
|
||
finish_reason TEXT,
|
||
reasoning TEXT,
|
||
reasoning_content TEXT,
|
||
reasoning_details TEXT,
|
||
codex_reasoning_items TEXT,
|
||
codex_message_items TEXT
|
||
);
|
||
"""
|
||
)
|
||
conn.close()
|
||
|
||
db = SessionDB(db_path=old_db)
|
||
db.apply_telegram_topic_migration()
|
||
|
||
tables = {
|
||
row[0]
|
||
for row in db._conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||
).fetchall()
|
||
}
|
||
assert "telegram_dm_topic_mode" in tables
|
||
assert "telegram_dm_topic_bindings" in tables
|
||
assert db.get_meta("telegram_dm_topic_schema_version") == "2"
|
||
db.close()
|
||
|
||
def test_telegram_topic_binding_roundtrip_requires_explicit_schema(self, tmp_path):
|
||
db = SessionDB(db_path=tmp_path / "state.db")
|
||
db.create_session(
|
||
session_id="topic-session",
|
||
source="telegram",
|
||
user_id="208214988",
|
||
)
|
||
|
||
assert db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") is None
|
||
|
||
db.bind_telegram_topic(
|
||
chat_id="208214988",
|
||
thread_id="17585",
|
||
user_id="208214988",
|
||
session_key="telegram:dm:208214988:thread:17585",
|
||
session_id="topic-session",
|
||
)
|
||
|
||
binding = db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585")
|
||
assert binding is not None
|
||
assert binding["chat_id"] == "208214988"
|
||
assert binding["thread_id"] == "17585"
|
||
assert binding["user_id"] == "208214988"
|
||
assert binding["session_key"] == "telegram:dm:208214988:thread:17585"
|
||
assert binding["session_id"] == "topic-session"
|
||
assert db.get_meta("telegram_dm_topic_schema_version") == "2"
|
||
db.close()
|
||
|
||
def test_telegram_topic_binding_refuses_to_relink_session_to_another_topic(self, tmp_path):
|
||
db = SessionDB(db_path=tmp_path / "state.db")
|
||
db.create_session(
|
||
session_id="topic-session",
|
||
source="telegram",
|
||
user_id="208214988",
|
||
)
|
||
db.bind_telegram_topic(
|
||
chat_id="208214988",
|
||
thread_id="17585",
|
||
user_id="208214988",
|
||
session_key="key-17585",
|
||
session_id="topic-session",
|
||
)
|
||
|
||
with pytest.raises(ValueError, match="already linked"):
|
||
db.bind_telegram_topic(
|
||
chat_id="208214988",
|
||
thread_id="99999",
|
||
user_id="208214988",
|
||
session_key="key-99999",
|
||
session_id="topic-session",
|
||
)
|
||
db.close()
|
||
|
||
def test_list_unlinked_telegram_sessions_for_user_excludes_bound_and_other_users(self, tmp_path):
|
||
db = SessionDB(db_path=tmp_path / "state.db")
|
||
db.create_session(
|
||
session_id="old-unlinked",
|
||
source="telegram",
|
||
user_id="208214988",
|
||
)
|
||
db.set_session_title("old-unlinked", "Old research")
|
||
db.append_message("old-unlinked", "user", "first prompt")
|
||
db.create_session(
|
||
session_id="already-linked",
|
||
source="telegram",
|
||
user_id="208214988",
|
||
)
|
||
db.bind_telegram_topic(
|
||
chat_id="208214988",
|
||
thread_id="17585",
|
||
user_id="208214988",
|
||
session_key="key-17585",
|
||
session_id="already-linked",
|
||
)
|
||
db.create_session(
|
||
session_id="other-user",
|
||
source="telegram",
|
||
user_id="someone-else",
|
||
)
|
||
|
||
sessions = db.list_unlinked_telegram_sessions_for_user(
|
||
chat_id="208214988",
|
||
user_id="208214988",
|
||
)
|
||
|
||
assert [s["id"] for s in sessions] == ["old-unlinked"]
|
||
assert sessions[0]["title"] == "Old research"
|
||
assert sessions[0]["preview"] == "first prompt"
|
||
db.close()
|
||
|
||
def test_migration_from_v2(self, tmp_path):
|
||
"""Simulate a v2 database and verify migration adds title column."""
|
||
import sqlite3
|
||
|
||
db_path = tmp_path / "migrate_test.db"
|
||
conn = sqlite3.connect(str(db_path))
|
||
# Create v2 schema (without title column)
|
||
conn.executescript("""
|
||
CREATE TABLE schema_version (version INTEGER NOT NULL);
|
||
INSERT INTO schema_version (version) VALUES (2);
|
||
|
||
CREATE TABLE sessions (
|
||
id TEXT PRIMARY KEY,
|
||
source TEXT NOT NULL,
|
||
user_id TEXT,
|
||
model TEXT,
|
||
model_config TEXT,
|
||
system_prompt TEXT,
|
||
parent_session_id TEXT,
|
||
started_at REAL NOT NULL,
|
||
ended_at REAL,
|
||
end_reason TEXT,
|
||
message_count INTEGER DEFAULT 0,
|
||
tool_call_count INTEGER DEFAULT 0,
|
||
input_tokens INTEGER DEFAULT 0,
|
||
output_tokens INTEGER DEFAULT 0
|
||
);
|
||
|
||
CREATE TABLE messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL,
|
||
role TEXT NOT NULL,
|
||
content TEXT,
|
||
tool_call_id TEXT,
|
||
tool_calls TEXT,
|
||
tool_name TEXT,
|
||
timestamp REAL NOT NULL,
|
||
token_count INTEGER,
|
||
finish_reason TEXT
|
||
);
|
||
""")
|
||
conn.execute(
|
||
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
|
||
("existing", "cli", 1000.0),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# Open with SessionDB — should migrate to v9
|
||
migrated_db = SessionDB(db_path=db_path)
|
||
|
||
# Verify migration
|
||
from hermes_state import SCHEMA_VERSION
|
||
cursor = migrated_db._conn.execute("SELECT version FROM schema_version")
|
||
assert cursor.fetchone()[0] == SCHEMA_VERSION
|
||
|
||
# Verify title column exists and is NULL for existing sessions
|
||
session = migrated_db.get_session("existing")
|
||
assert session is not None
|
||
assert session["title"] is None
|
||
|
||
# Verify api_call_count column was added with default 0
|
||
cursor = migrated_db._conn.execute(
|
||
"SELECT api_call_count FROM sessions WHERE id = 'existing'"
|
||
)
|
||
assert cursor.fetchone()[0] == 0
|
||
|
||
# Verify we can set title on migrated session
|
||
assert migrated_db.set_session_title("existing", "Migrated Title") is True
|
||
session = migrated_db.get_session("existing")
|
||
assert session["title"] == "Migrated Title"
|
||
|
||
migrated_db.close()
|
||
|
||
def test_v9_migration_skips_v10_trigram_backfill_before_v11_rebuild(self, tmp_path, monkeypatch):
|
||
"""Direct v9→current migration should do only the v23 FTS rebuild.
|
||
|
||
v10 backfilled ``messages_fts_trigram`` with content-only rows. The
|
||
current migration immediately drops and rebuilds both FTS tables in
|
||
external-content form, so running the v10 insert first is wasted work.
|
||
|
||
v23 contract: tool rows are excluded from the trigram index (they
|
||
remain fully searchable via the standard index); non-tool rows are
|
||
indexed in both.
|
||
"""
|
||
db_path = tmp_path / "v9_fts.db"
|
||
conn = sqlite3.connect(str(db_path))
|
||
conn.executescript(SCHEMA_SQL)
|
||
conn.execute("DELETE FROM schema_version")
|
||
conn.execute("INSERT INTO schema_version (version) VALUES (9)")
|
||
conn.execute(
|
||
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
|
||
("s1", "cli", 1000.0),
|
||
)
|
||
conn.execute(
|
||
"INSERT INTO messages (session_id, role, content, tool_name, tool_calls, timestamp) "
|
||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||
("s1", "tool", "plain content", "browser_snapshot", '{"name":"browser_snapshot"}', 1001.0),
|
||
)
|
||
conn.execute(
|
||
"INSERT INTO messages (session_id, role, content, timestamp) "
|
||
"VALUES (?, ?, ?, ?)",
|
||
("s1", "assistant", "assistant summary of the snapshot", 1002.0),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
trigram_content_only_inserts = []
|
||
real_connect = sqlite3.connect
|
||
|
||
def connect_with_trace(*args, **kwargs):
|
||
conn = real_connect(*args, **kwargs)
|
||
|
||
def trace(sql):
|
||
text = " ".join(str(sql).split())
|
||
if (
|
||
"INSERT INTO messages_fts_trigram" in text
|
||
and "SELECT id, content FROM messages" in text
|
||
):
|
||
trigram_content_only_inserts.append(text)
|
||
|
||
conn.set_trace_callback(trace)
|
||
return conn
|
||
|
||
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_with_trace)
|
||
migrated_db = SessionDB(db_path=db_path)
|
||
try:
|
||
assert trigram_content_only_inserts == []
|
||
version = migrated_db._conn.execute("SELECT version FROM schema_version").fetchone()[0]
|
||
# This DB was built via SCHEMA_SQL, so its FTS is already the v23
|
||
# external-content shape — not a legacy inline install. Opening it
|
||
# therefore advances the version to current (no opt-in gate) and
|
||
# runs no backfill (rows were indexed live by the v23 triggers).
|
||
assert version == SCHEMA_VERSION
|
||
assert migrated_db.fts_optimize_available() is False
|
||
assert migrated_db.fts_rebuild_status() is None
|
||
# Standard FTS indexes every row, including tool output (MATCH
|
||
# probes the index; COUNT(*) on external-content tables doesn't).
|
||
normal_count = migrated_db._conn.execute(
|
||
"SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH 'snapshot'"
|
||
).fetchone()[0]
|
||
assert normal_count == 2
|
||
# Trigram excludes role='tool' rows (v23) but keeps non-tool rows.
|
||
trigram_count = migrated_db._conn.execute(
|
||
"SELECT COUNT(*) FROM messages_fts_trigram "
|
||
"WHERE messages_fts_trigram MATCH 'snapshot'"
|
||
).fetchone()[0]
|
||
assert trigram_count == 1
|
||
# Tool metadata stays searchable via the standard index (#16751).
|
||
tool_hit = migrated_db._conn.execute(
|
||
"SELECT COUNT(*) FROM messages_fts "
|
||
"WHERE messages_fts MATCH 'browser_snapshot'"
|
||
).fetchone()[0]
|
||
assert tool_hit == 1
|
||
# And is intentionally absent from the trigram index.
|
||
tri_tool_hit = migrated_db._conn.execute(
|
||
"SELECT COUNT(*) FROM messages_fts_trigram "
|
||
"WHERE messages_fts_trigram MATCH 'browser_snapshot'"
|
||
).fetchone()[0]
|
||
assert tri_tool_hit == 0
|
||
finally:
|
||
migrated_db.close()
|
||
|
||
def test_reconciliation_adds_missing_columns(self, tmp_path):
|
||
"""Columns present in SCHEMA_SQL but missing from the live table
|
||
are added by _reconcile_columns regardless of schema_version.
|
||
|
||
Regression test: commit a7d78d3b inserted a new v7 migration
|
||
(reasoning_content) and renumbered the old v7 (api_call_count)
|
||
to v8. Users already at the old v7 had schema_version >= 7,
|
||
so the new v7 block was skipped and reasoning_content was never
|
||
created — causing 'no such column' on /continue.
|
||
"""
|
||
import sqlite3
|
||
|
||
db_path = tmp_path / "gap_test.db"
|
||
conn = sqlite3.connect(str(db_path))
|
||
# Simulate the old v7 state: api_call_count exists, reasoning_content does NOT
|
||
conn.executescript("""
|
||
CREATE TABLE schema_version (version INTEGER NOT NULL);
|
||
INSERT INTO schema_version (version) VALUES (7);
|
||
|
||
CREATE TABLE sessions (
|
||
id TEXT PRIMARY KEY,
|
||
source TEXT NOT NULL,
|
||
user_id TEXT,
|
||
model TEXT,
|
||
model_config TEXT,
|
||
system_prompt TEXT,
|
||
parent_session_id TEXT,
|
||
started_at REAL NOT NULL,
|
||
ended_at REAL,
|
||
end_reason TEXT,
|
||
message_count INTEGER DEFAULT 0,
|
||
tool_call_count INTEGER DEFAULT 0,
|
||
input_tokens INTEGER DEFAULT 0,
|
||
output_tokens INTEGER DEFAULT 0,
|
||
cache_read_tokens INTEGER DEFAULT 0,
|
||
cache_write_tokens INTEGER DEFAULT 0,
|
||
reasoning_tokens INTEGER DEFAULT 0,
|
||
billing_provider TEXT,
|
||
billing_base_url TEXT,
|
||
billing_mode TEXT,
|
||
estimated_cost_usd REAL,
|
||
actual_cost_usd REAL,
|
||
cost_status TEXT,
|
||
cost_source TEXT,
|
||
pricing_version TEXT,
|
||
title TEXT,
|
||
api_call_count INTEGER DEFAULT 0
|
||
);
|
||
|
||
CREATE TABLE messages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
session_id TEXT NOT NULL,
|
||
role TEXT NOT NULL,
|
||
content TEXT,
|
||
tool_call_id TEXT,
|
||
tool_calls TEXT,
|
||
tool_name TEXT,
|
||
timestamp REAL NOT NULL,
|
||
token_count INTEGER,
|
||
finish_reason TEXT,
|
||
reasoning TEXT,
|
||
reasoning_details TEXT,
|
||
codex_reasoning_items TEXT
|
||
);
|
||
""")
|
||
conn.execute(
|
||
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
|
||
("s1", "cli", 1000.0),
|
||
)
|
||
conn.execute(
|
||
"INSERT INTO messages (session_id, role, content, timestamp) "
|
||
"VALUES (?, ?, ?, ?)",
|
||
("s1", "assistant", "hello", 1001.0),
|
||
)
|
||
conn.commit()
|
||
# Verify reasoning_content is absent
|
||
cols = {r[1] for r in conn.execute("PRAGMA table_info(messages)").fetchall()}
|
||
assert "reasoning_content" not in cols
|
||
conn.close()
|
||
|
||
# Open with SessionDB — reconciliation should add the missing column
|
||
migrated_db = SessionDB(db_path=db_path)
|
||
|
||
msg_cols = {
|
||
r[1]
|
||
for r in migrated_db._conn.execute("PRAGMA table_info(messages)").fetchall()
|
||
}
|
||
assert "reasoning_content" in msg_cols
|
||
|
||
# The query that used to crash must now work
|
||
cursor = migrated_db._conn.execute(
|
||
"SELECT role, content, reasoning, reasoning_content, "
|
||
"reasoning_details, codex_reasoning_items "
|
||
"FROM messages WHERE session_id = ?",
|
||
("s1",),
|
||
)
|
||
row = cursor.fetchone()
|
||
assert row is not None
|
||
assert row[0] == "assistant"
|
||
assert row[3] is None # reasoning_content NULL for old rows
|
||
|
||
migrated_db.close()
|
||
|
||
def test_reconciliation_is_idempotent(self, tmp_path):
|
||
"""Opening the same database twice doesn't error or duplicate columns."""
|
||
db_path = tmp_path / "idempotent.db"
|
||
db1 = SessionDB(db_path=db_path)
|
||
cols1 = {r[1] for r in db1._conn.execute("PRAGMA table_info(messages)").fetchall()}
|
||
db1.close()
|
||
|
||
db2 = SessionDB(db_path=db_path)
|
||
cols2 = {r[1] for r in db2._conn.execute("PRAGMA table_info(messages)").fetchall()}
|
||
db2.close()
|
||
|
||
assert cols1 == cols2
|
||
|
||
def test_schema_sql_is_source_of_truth(self, db):
|
||
"""Every column in SCHEMA_SQL exists in the live database.
|
||
|
||
This is the architectural invariant: SCHEMA_SQL declares the
|
||
desired schema, _reconcile_columns ensures it matches reality.
|
||
"""
|
||
from hermes_state import SCHEMA_SQL
|
||
|
||
expected = SessionDB._parse_schema_columns(SCHEMA_SQL)
|
||
for table_name, declared_cols in expected.items():
|
||
live_cols = {
|
||
r[1]
|
||
for r in db._conn.execute(
|
||
f'PRAGMA table_info("{table_name}")'
|
||
).fetchall()
|
||
}
|
||
for col_name in declared_cols:
|
||
assert col_name in live_cols, (
|
||
f"Column {col_name} declared in SCHEMA_SQL for {table_name} "
|
||
f"but missing from live DB. Live columns: {live_cols}"
|
||
)
|
||
|
||
|
||
class TestTitleUniqueness:
|
||
"""Tests for unique title enforcement and title-based lookups."""
|
||
|
||
def test_duplicate_title_raises(self, db):
|
||
"""Setting a title already used by another session raises ValueError."""
|
||
db.create_session("s1", "cli")
|
||
db.create_session("s2", "cli")
|
||
db.set_session_title("s1", "my project")
|
||
with pytest.raises(ValueError, match="already in use"):
|
||
db.set_session_title("s2", "my project")
|
||
|
||
def test_same_session_can_keep_title(self, db):
|
||
"""A session can re-set its own title without error."""
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "my project")
|
||
# Should not raise — it's the same session
|
||
assert db.set_session_title("s1", "my project") is True
|
||
|
||
def test_null_titles_not_unique(self, db):
|
||
"""Multiple sessions can have NULL titles (no constraint violation)."""
|
||
db.create_session("s1", "cli")
|
||
db.create_session("s2", "cli")
|
||
# Both have NULL titles — no error
|
||
assert db.get_session("s1")["title"] is None
|
||
assert db.get_session("s2")["title"] is None
|
||
|
||
def test_get_session_by_title(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "refactoring auth")
|
||
result = db.get_session_by_title("refactoring auth")
|
||
assert result is not None
|
||
assert result["id"] == "s1"
|
||
|
||
def test_get_session_by_title_not_found(self, db):
|
||
assert db.get_session_by_title("nonexistent") is None
|
||
|
||
def test_get_session_title(self, db):
|
||
db.create_session("s1", "cli")
|
||
assert db.get_session_title("s1") is None
|
||
db.set_session_title("s1", "my title")
|
||
assert db.get_session_title("s1") == "my title"
|
||
|
||
def test_get_session_title_nonexistent(self, db):
|
||
assert db.get_session_title("nonexistent") is None
|
||
|
||
|
||
class TestTitleLineage:
|
||
"""Tests for title lineage resolution and auto-numbering."""
|
||
|
||
def test_resolve_exact_title(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "my project")
|
||
assert db.resolve_session_by_title("my project") == "s1"
|
||
|
||
def test_resolve_returns_latest_numbered(self, db):
|
||
"""When numbered variants exist, return the most recent one."""
|
||
import time
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "my project")
|
||
time.sleep(0.01)
|
||
db.create_session("s2", "cli")
|
||
db.set_session_title("s2", "my project #2")
|
||
time.sleep(0.01)
|
||
db.create_session("s3", "cli")
|
||
db.set_session_title("s3", "my project #3")
|
||
# Resolving "my project" should return s3 (latest numbered variant)
|
||
assert db.resolve_session_by_title("my project") == "s3"
|
||
|
||
def test_resolve_exact_numbered(self, db):
|
||
"""Resolving an exact numbered title returns that specific session."""
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "my project")
|
||
db.create_session("s2", "cli")
|
||
db.set_session_title("s2", "my project #2")
|
||
# Resolving "my project #2" exactly should return s2
|
||
assert db.resolve_session_by_title("my project #2") == "s2"
|
||
|
||
def test_resolve_nonexistent_title(self, db):
|
||
assert db.resolve_session_by_title("nonexistent") is None
|
||
|
||
def test_next_title_no_existing(self, db):
|
||
"""With no existing sessions, base title is returned as-is."""
|
||
assert db.get_next_title_in_lineage("my project") == "my project"
|
||
|
||
def test_next_title_first_continuation(self, db):
|
||
"""First continuation after the original gets #2."""
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "my project")
|
||
assert db.get_next_title_in_lineage("my project") == "my project #2"
|
||
|
||
def test_next_title_increments(self, db):
|
||
"""Each continuation increments the number."""
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "my project")
|
||
db.create_session("s2", "cli")
|
||
db.set_session_title("s2", "my project #2")
|
||
db.create_session("s3", "cli")
|
||
db.set_session_title("s3", "my project #3")
|
||
assert db.get_next_title_in_lineage("my project") == "my project #4"
|
||
|
||
def test_next_title_strips_existing_number(self, db):
|
||
"""Passing a numbered title strips the number and finds the base."""
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "my project")
|
||
db.create_session("s2", "cli")
|
||
db.set_session_title("s2", "my project #2")
|
||
# Even when called with "my project #2", it should return #3
|
||
assert db.get_next_title_in_lineage("my project #2") == "my project #3"
|
||
|
||
|
||
class TestTitleSqlWildcards:
|
||
"""Titles containing SQL LIKE wildcards (%, _) must not cause false matches."""
|
||
|
||
def test_resolve_title_with_underscore(self, db):
|
||
"""A title like 'test_project' should not match 'testXproject #2'."""
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "test_project")
|
||
db.create_session("s2", "cli")
|
||
db.set_session_title("s2", "testXproject #2")
|
||
# Resolving "test_project" should return s1 (exact), not s2
|
||
assert db.resolve_session_by_title("test_project") == "s1"
|
||
|
||
def test_resolve_title_with_percent(self, db):
|
||
"""A title with '%' should not wildcard-match unrelated sessions."""
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "100% done")
|
||
db.create_session("s2", "cli")
|
||
db.set_session_title("s2", "100X done #2")
|
||
# Should resolve to s1 (exact), not s2
|
||
assert db.resolve_session_by_title("100% done") == "s1"
|
||
|
||
def test_next_lineage_with_underscore(self, db):
|
||
"""get_next_title_in_lineage with underscores doesn't match wrong sessions."""
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "test_project")
|
||
db.create_session("s2", "cli")
|
||
db.set_session_title("s2", "testXproject #2")
|
||
# Only "test_project" exists, so next should be "test_project #2"
|
||
assert db.get_next_title_in_lineage("test_project") == "test_project #2"
|
||
|
||
|
||
class TestListSessionsRich:
|
||
"""Tests for enhanced session listing with preview and last_active."""
|
||
|
||
def test_preview_from_first_user_message(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.append_message("s1", "system", "You are a helpful assistant.")
|
||
db.append_message("s1", "user", "Help me refactor the auth module please")
|
||
db.append_message("s1", "assistant", "Sure, let me look at it.")
|
||
sessions = db.list_sessions_rich()
|
||
assert len(sessions) == 1
|
||
assert "Help me refactor the auth module" in sessions[0]["preview"]
|
||
|
||
def test_preview_truncated_at_60(self, db):
|
||
db.create_session("s1", "cli")
|
||
long_msg = "A" * 100
|
||
db.append_message("s1", "user", long_msg)
|
||
sessions = db.list_sessions_rich()
|
||
assert len(sessions[0]["preview"]) == 63 # 60 chars + "..."
|
||
assert sessions[0]["preview"].endswith("...")
|
||
|
||
def test_preview_empty_when_no_user_messages(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.append_message("s1", "system", "System prompt")
|
||
sessions = db.list_sessions_rich()
|
||
assert sessions[0]["preview"] == ""
|
||
|
||
def test_last_active_from_latest_message(self, db):
|
||
import time
|
||
db.create_session("s1", "cli")
|
||
db.append_message("s1", "user", "Hello")
|
||
time.sleep(0.01)
|
||
db.append_message("s1", "assistant", "Hi there!")
|
||
sessions = db.list_sessions_rich()
|
||
# last_active should be close to now (the assistant message)
|
||
assert sessions[0]["last_active"] > sessions[0]["started_at"]
|
||
|
||
def test_last_active_fallback_to_started_at(self, db):
|
||
db.create_session("s1", "cli")
|
||
sessions = db.list_sessions_rich()
|
||
# No messages, so last_active falls back to started_at
|
||
assert sessions[0]["last_active"] == sessions[0]["started_at"]
|
||
|
||
def test_order_by_last_active_surfaces_recently_touched_older_session_first(self, db):
|
||
t0 = 1709500000.0
|
||
db.create_session("old", "cli")
|
||
db.create_session("new", "cli")
|
||
|
||
with db._lock:
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "old"))
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 10, "new"))
|
||
|
||
db.append_message("old", "user", "old first")
|
||
db.append_message("new", "user", "new first")
|
||
db.append_message("old", "assistant", "old touched later")
|
||
|
||
with db._lock:
|
||
db._conn.execute(
|
||
"UPDATE messages SET timestamp=? WHERE session_id=? AND role=? AND content=?",
|
||
(t0 + 1, "old", "user", "old first"),
|
||
)
|
||
db._conn.execute(
|
||
"UPDATE messages SET timestamp=? WHERE session_id=? AND role=? AND content=?",
|
||
(t0 + 11, "new", "user", "new first"),
|
||
)
|
||
db._conn.execute(
|
||
"UPDATE messages SET timestamp=? WHERE session_id=? AND role=? AND content=?",
|
||
(t0 + 20, "old", "assistant", "old touched later"),
|
||
)
|
||
db._conn.commit()
|
||
|
||
assert [s["id"] for s in db.list_sessions_rich(limit=5)] == ["new", "old"]
|
||
assert [
|
||
s["id"] for s in db.list_sessions_rich(limit=5, order_by_last_active=True)
|
||
] == ["old", "new"]
|
||
|
||
def test_order_by_last_active_uses_compression_tip_activity(self, db):
|
||
"""A compression root whose tip was touched recently must rank above
|
||
a newer uncompressed session, even when that tip activity lives in a
|
||
different row and the outer LIMIT could otherwise cut it.
|
||
|
||
This is the case that forced SQL-level chain walking: a naive "cap
|
||
the SQL fetch at limit*K" optimization would drop the old root off
|
||
the SQL page before post-projection could promote it.
|
||
"""
|
||
t0 = 1709500000.0
|
||
db.create_session("root1", "cli")
|
||
with db._lock:
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root1"))
|
||
db._conn.execute(
|
||
"UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
|
||
(t0 + 100, "compression", "root1"),
|
||
)
|
||
db.append_message("root1", "user", "old ask")
|
||
|
||
# Continuation tip created after root ended; last activity much later.
|
||
db.create_session("tip1", "cli", parent_session_id="root1")
|
||
with db._lock:
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 101, "tip1"))
|
||
db.append_message("tip1", "user", "latest message")
|
||
|
||
# Bunch of newer, uncompressed sessions — fresher start_at but older
|
||
# last activity than the tip. Explicitly pin message timestamps so
|
||
# they don't pick up wall-clock from append_message.
|
||
for i in range(5):
|
||
sid = f"newer{i}"
|
||
db.create_session(sid, "cli")
|
||
with db._lock:
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at=? WHERE id=?",
|
||
(t0 + 500 + i, sid),
|
||
)
|
||
db.append_message(sid, "user", f"msg {i}")
|
||
with db._lock:
|
||
db._conn.execute(
|
||
"UPDATE messages SET timestamp=? WHERE session_id=? AND content=?",
|
||
(t0 + 500 + i, sid, f"msg {i}"),
|
||
)
|
||
|
||
# Tip activity timestamp is the latest thing in the DB.
|
||
with db._lock:
|
||
db._conn.execute(
|
||
"UPDATE messages SET timestamp=? WHERE session_id=? AND content=?",
|
||
(t0 + 10_000, "tip1", "latest message"),
|
||
)
|
||
db._conn.commit()
|
||
|
||
# limit=1 is the stress test: the old root must win the single slot.
|
||
top = db.list_sessions_rich(limit=1, order_by_last_active=True)
|
||
assert len(top) == 1
|
||
# Projection surfaces the tip's id in the root's slot.
|
||
assert top[0]["id"] == "tip1"
|
||
assert top[0]["_lineage_root_id"] == "root1"
|
||
|
||
def test_rich_list_includes_title(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "refactoring auth")
|
||
sessions = db.list_sessions_rich()
|
||
assert sessions[0]["title"] == "refactoring auth"
|
||
|
||
def test_rich_list_source_filter(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.create_session("s2", "telegram")
|
||
sessions = db.list_sessions_rich(source="cli")
|
||
assert len(sessions) == 1
|
||
assert sessions[0]["id"] == "s1"
|
||
|
||
def test_rich_list_cwd_prefix_filter(self, db):
|
||
db.create_session("s1", "cli", cwd="/repo")
|
||
db.create_session("s2", "cli", cwd="/repo/subdir")
|
||
db.create_session("s3", "cli", cwd="/repo-wt-feature")
|
||
|
||
sessions = db.list_sessions_rich(cwd_prefix="/repo")
|
||
assert [session["id"] for session in sessions] == ["s2", "s1"]
|
||
|
||
def test_preview_newlines_collapsed(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.append_message("s1", "user", "Line one\nLine two\nLine three")
|
||
sessions = db.list_sessions_rich()
|
||
assert "\n" not in sessions[0]["preview"]
|
||
assert "Line one Line two" in sessions[0]["preview"]
|
||
|
||
def test_branch_session_visible_in_list(self, db):
|
||
"""Branch sessions (parent ended with 'branched') must appear in list_sessions_rich."""
|
||
db.create_session("parent", "cli")
|
||
db.end_session("parent", "branched")
|
||
db.create_session("branch", "cli", parent_session_id="parent")
|
||
db.append_message("branch", "user", "Exploring the alternative approach")
|
||
|
||
sessions = db.list_sessions_rich()
|
||
ids = [s["id"] for s in sessions]
|
||
assert "branch" in ids, "Branch session should be visible in default list"
|
||
|
||
def test_delegate_subagent_marker_hides_orphaned_row(self, db):
|
||
"""``_delegate_from`` keeps delegate rows out of pickers after orphaning."""
|
||
db.create_session("parent", "cli")
|
||
db.create_session(
|
||
"delegate",
|
||
"cli",
|
||
parent_session_id="parent",
|
||
model_config={"_delegate_from": "parent"},
|
||
)
|
||
db.append_message("delegate", "user", "scan the repo")
|
||
|
||
assert "delegate" not in [s["id"] for s in db.list_sessions_rich()]
|
||
|
||
db._conn.execute(
|
||
"UPDATE sessions SET parent_session_id = NULL WHERE id = ?", ("delegate",)
|
||
)
|
||
db._conn.commit()
|
||
|
||
assert "delegate" not in [s["id"] for s in db.list_sessions_rich()]
|
||
|
||
def test_delete_parent_cascades_delegate_children(self, db):
|
||
db.create_session("parent", "cli")
|
||
db.create_session(
|
||
"delegate",
|
||
"cli",
|
||
parent_session_id="parent",
|
||
model_config={"_delegate_from": "parent"},
|
||
)
|
||
db.create_session(
|
||
"branch",
|
||
"cli",
|
||
parent_session_id="parent",
|
||
model_config={"_branched_from": "parent"},
|
||
)
|
||
|
||
assert db.delete_session("parent") is True
|
||
assert db.get_session("delegate") is None
|
||
assert db.get_session("branch") is not None
|
||
|
||
def test_v16_migration_tags_linked_delegate_rows(self, tmp_path):
|
||
"""Pre-marker linked subagent rows get tagged, then cascade with parent."""
|
||
import json
|
||
|
||
db_path = tmp_path / "state.db"
|
||
db = SessionDB(db_path=db_path)
|
||
db.create_session("parent", "cli")
|
||
db.create_session("delegate", "cli", parent_session_id="parent")
|
||
db._conn.execute("UPDATE schema_version SET version = 15")
|
||
db._conn.commit()
|
||
db.close()
|
||
|
||
db = SessionDB(db_path=db_path)
|
||
row = db.get_session("delegate")
|
||
assert json.loads(row["model_config"])["_delegate_from"] == "parent"
|
||
assert db.delete_session("parent") is True
|
||
assert db.get_session("delegate") is None
|
||
db.close()
|
||
|
||
def test_v16_migration_tags_orphaned_delegate_rows(self, tmp_path):
|
||
import json
|
||
|
||
db_path = tmp_path / "state.db"
|
||
db = SessionDB(db_path=db_path)
|
||
db.create_session("orphan", "cli")
|
||
db.append_message("orphan", "user", "Echo progress")
|
||
db.append_message("orphan", "tool", "step 1", tool_name="terminal")
|
||
db._conn.execute("UPDATE schema_version SET version = 15")
|
||
db._conn.commit()
|
||
db.close()
|
||
|
||
db = SessionDB(db_path=db_path)
|
||
assert "orphan" not in [s["id"] for s in db.list_sessions_rich()]
|
||
row = db.get_session("orphan")
|
||
assert json.loads(row["model_config"])["_delegate_from"] == "__orphaned__"
|
||
db.close()
|
||
|
||
def test_branch_session_visible_after_parent_reopen_and_reend(self, db):
|
||
"""Branch sessions stay visible after the parent is reopened and re-ended.
|
||
|
||
Regression for issue #20856: /branch (aka /fork) sessions vanished from
|
||
/resume and /sessions once the parent was reopened (e.g. resumed) and
|
||
re-ended with a different end_reason — tui_shutdown overwriting
|
||
'branched' — which broke the legacy end_reason heuristic. The stable
|
||
_branched_from marker in model_config keeps them visible.
|
||
"""
|
||
import json as _json
|
||
|
||
db.create_session("parent", "cli")
|
||
db.end_session("parent", "branched")
|
||
db.create_session(
|
||
"branch",
|
||
"cli",
|
||
model_config={"_branched_from": "parent"},
|
||
parent_session_id="parent",
|
||
)
|
||
db.append_message("branch", "user", "Exploring the alternative approach")
|
||
|
||
# Marker is persisted at creation time.
|
||
branch_row = db.get_session("branch")
|
||
cfg = _json.loads(branch_row["model_config"]) if branch_row["model_config"] else {}
|
||
assert cfg.get("_branched_from") == "parent"
|
||
|
||
# Visible immediately after branching.
|
||
assert "branch" in [s["id"] for s in db.list_sessions_rich()]
|
||
|
||
# Parent reopened + re-ended with a different reason (the bug trigger).
|
||
db.reopen_session("parent")
|
||
db.end_session("parent", "tui_shutdown")
|
||
|
||
# Branch must STILL be visible — the marker survives the parent's
|
||
# end_reason churn, unlike the legacy 'branched' heuristic.
|
||
ids = [s["id"] for s in db.list_sessions_rich()]
|
||
assert "branch" in ids, "Branch should stay visible after parent re-end"
|
||
|
||
def test_subagent_session_still_hidden(self, db):
|
||
"""Sub-agent children (parent NOT ended with 'branched') remain hidden."""
|
||
db.create_session("root", "cli")
|
||
db.create_session("delegate", "cli", parent_session_id="root")
|
||
|
||
sessions = db.list_sessions_rich()
|
||
ids = [s["id"] for s in sessions]
|
||
assert "delegate" not in ids, "Delegate sub-agent should not appear in default list"
|
||
assert "root" in ids
|
||
|
||
def test_compression_child_still_hidden(self, db):
|
||
"""Compression continuation sessions remain hidden (parent ended with 'compression')."""
|
||
import time as _time
|
||
t0 = _time.time()
|
||
db.create_session("root", "cli")
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root"))
|
||
db._conn.execute(
|
||
"UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?",
|
||
(t0 + 1800, "root"),
|
||
)
|
||
db._conn.commit()
|
||
db.create_session("continuation", "cli", parent_session_id="root")
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at=? WHERE id=?", (t0 + 1801, "continuation")
|
||
)
|
||
db._conn.commit()
|
||
|
||
sessions = db.list_sessions_rich(project_compression_tips=False)
|
||
ids = [s["id"] for s in sessions]
|
||
assert "continuation" not in ids, "Compression continuation should stay hidden"
|
||
|
||
|
||
class TestCompressionChainProjection:
|
||
"""Tests for lineage-aware list_sessions_rich — compressed conversations
|
||
surface as their live continuation tip, not the dead parent root.
|
||
"""
|
||
|
||
def _build_compression_chain(self, db, t0: float):
|
||
"""Helper: builds root -> delegate -> compression-child -> tip chain.
|
||
|
||
Returns (root_id, delegate_id, mid_id, tip_id).
|
||
"""
|
||
# Root that gets compressed
|
||
db.create_session("root1", "cli")
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root1"))
|
||
db.append_message("root1", "user", "help me refactor auth")
|
||
|
||
# Delegate subagent spawned while root1 was live (before it ended)
|
||
db.create_session("delegate1", "cli", parent_session_id="root1")
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at=?, ended_at=? WHERE id=?",
|
||
(t0 + 600, t0 + 650, "delegate1"),
|
||
)
|
||
db.append_message("delegate1", "user", "delegate task")
|
||
|
||
# root1 compressed at t0+1800
|
||
t_compress_root = t0 + 1800
|
||
db._conn.execute(
|
||
"UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
|
||
(t_compress_root, "compression", "root1"),
|
||
)
|
||
|
||
# Continuation mid created 1s after parent ended
|
||
db.create_session("mid1", "cli", parent_session_id="root1")
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at=? WHERE id=?",
|
||
(t_compress_root + 1, "mid1"),
|
||
)
|
||
db.append_message("mid1", "user", "continuing")
|
||
|
||
# mid1 also compressed
|
||
t_compress_mid = t_compress_root + 1800
|
||
db._conn.execute(
|
||
"UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
|
||
(t_compress_mid, "compression", "mid1"),
|
||
)
|
||
|
||
# Tip — latest continuation
|
||
db.create_session("tip1", "cli", parent_session_id="mid1")
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at=? WHERE id=?",
|
||
(t_compress_mid + 1, "tip1"),
|
||
)
|
||
db.append_message("tip1", "user", "latest message")
|
||
|
||
db._conn.commit()
|
||
return ("root1", "delegate1", "mid1", "tip1")
|
||
|
||
def test_get_compression_tip_walks_full_chain(self, db):
|
||
import time as _time
|
||
self._build_compression_chain(db, _time.time() - 3600)
|
||
assert db.get_compression_tip("root1") == "tip1"
|
||
assert db.get_compression_tip("mid1") == "tip1"
|
||
assert db.get_compression_tip("tip1") == "tip1"
|
||
|
||
def test_get_compression_tip_returns_self_for_uncompressed(self, db):
|
||
db.create_session("solo", "cli")
|
||
assert db.get_compression_tip("solo") == "solo"
|
||
|
||
def test_get_compression_tip_skips_delegate_children(self, db):
|
||
"""Delegate subagents have parent_session_id set but were created
|
||
BEFORE the parent ended. They must not be followed as compression
|
||
continuations — the started_at >= ended_at guard handles this.
|
||
"""
|
||
import time as _time
|
||
self._build_compression_chain(db, _time.time() - 3600)
|
||
# delegate1 is a child of root1 but NOT a compression continuation.
|
||
# root1's tip must be tip1 (via mid1), not delegate1.
|
||
assert db.get_compression_tip("root1") == "tip1"
|
||
|
||
def test_list_surfaces_tip_for_compressed_root(self, db):
|
||
"""The list must show the tip's id/message_count/preview in place of
|
||
the root row, so users can see and resume the live conversation.
|
||
"""
|
||
import time as _time
|
||
self._build_compression_chain(db, _time.time() - 3600)
|
||
# Add an uncompressed root for comparison.
|
||
db.create_session("solo", "cli")
|
||
db.append_message("solo", "user", "standalone")
|
||
db._conn.commit()
|
||
|
||
sessions = db.list_sessions_rich(source="cli", limit=20)
|
||
ids = [s["id"] for s in sessions]
|
||
# Only top-level conversations appear: tip1 (projected from root1) + solo.
|
||
# Delegate children, mid1, and the dead root1 must NOT be in the list.
|
||
assert "tip1" in ids
|
||
assert "solo" in ids
|
||
assert "root1" not in ids
|
||
assert "mid1" not in ids
|
||
assert "delegate1" not in ids
|
||
|
||
tip_row = next(s for s in sessions if s["id"] == "tip1")
|
||
# The row surfaces the tip's identity but preserves the root's start
|
||
# timestamp for stable ordering and lineage tracking.
|
||
assert tip_row["_lineage_root_id"] == "root1"
|
||
assert tip_row["preview"].startswith("latest message")
|
||
assert tip_row["ended_at"] is None # tip is still live
|
||
assert tip_row["end_reason"] is None
|
||
|
||
def test_list_projection_uses_tip_cwd(self, db):
|
||
"""Projected lineage rows should carry cwd from the live tip row.
|
||
|
||
Without this, compressed conversations can lose workspace grouping
|
||
even after the continuation session persists its cwd.
|
||
"""
|
||
import time as _time
|
||
|
||
self._build_compression_chain(db, _time.time() - 3600)
|
||
db.update_session_cwd("tip1", "/tmp/workspaces/tip")
|
||
db._conn.commit()
|
||
|
||
sessions = db.list_sessions_rich(source="cli", limit=20)
|
||
tip_row = next(s for s in sessions if s["id"] == "tip1")
|
||
|
||
assert tip_row["_lineage_root_id"] == "root1"
|
||
assert tip_row["cwd"] == "/tmp/workspaces/tip"
|
||
|
||
def test_list_without_projection_returns_raw_root(self, db):
|
||
"""project_compression_tips=False returns the raw parent-NULL root
|
||
rows — useful for admin/debug UIs.
|
||
"""
|
||
import time as _time
|
||
self._build_compression_chain(db, _time.time() - 3600)
|
||
sessions = db.list_sessions_rich(
|
||
source="cli", limit=20, project_compression_tips=False
|
||
)
|
||
ids = [s["id"] for s in sessions]
|
||
assert "root1" in ids
|
||
assert "tip1" not in ids
|
||
|
||
root_row = next(s for s in sessions if s["id"] == "root1")
|
||
assert root_row["end_reason"] == "compression"
|
||
assert "_lineage_root_id" not in root_row
|
||
|
||
def test_list_preserves_sort_by_started_at(self, db):
|
||
"""Chronological ordering uses the ROOT's started_at (conversation
|
||
start), not the tip's. This keeps lineage entries stable in the list
|
||
even as new compressions push the tip forward in time.
|
||
"""
|
||
import time as _time
|
||
t0 = _time.time() - 3600
|
||
self._build_compression_chain(db, t0)
|
||
|
||
# Create a newer standalone session that should sort above the lineage
|
||
# if we used tip.started_at, but below if we correctly use root.started_at.
|
||
t_between = t0 + 120 # between root1 and its compression
|
||
db.create_session("newer", "cli")
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t_between, "newer"))
|
||
db.append_message("newer", "user", "newer session started after root1")
|
||
db._conn.commit()
|
||
|
||
sessions = db.list_sessions_rich(source="cli", limit=20)
|
||
ids_in_order = [s["id"] for s in sessions]
|
||
# 'newer' started AFTER root1 but BEFORE tip1's actual started_at.
|
||
# Correct ordering (by root started_at): newer > tip1's lineage entry.
|
||
assert ids_in_order.index("newer") < ids_in_order.index("tip1")
|
||
|
||
def test_list_handles_broken_chain_gracefully(self, db):
|
||
"""A compression root with no child (e.g. DB corruption or a partial
|
||
end_session call that didn't finish creating the child) must not
|
||
crash the list — it should fall back to surfacing the root as-is.
|
||
"""
|
||
import time as _time
|
||
t0 = _time.time() - 100
|
||
db.create_session("orphan", "cli")
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "orphan"))
|
||
db._conn.execute(
|
||
"UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
|
||
(t0 + 10, "compression", "orphan"),
|
||
)
|
||
db._conn.commit()
|
||
|
||
sessions = db.list_sessions_rich(source="cli", limit=10)
|
||
ids = [s["id"] for s in sessions]
|
||
assert "orphan" in ids
|
||
row = next(s for s in sessions if s["id"] == "orphan")
|
||
# No tip means no projection — row stays raw.
|
||
assert "_lineage_root_id" not in row
|
||
assert row["end_reason"] == "compression"
|
||
|
||
|
||
# =========================================================================
|
||
# Session source exclusion (--source flag for third-party isolation)
|
||
# =========================================================================
|
||
|
||
class TestExcludeSources:
|
||
"""Tests for exclude_sources on list_sessions_rich and search_messages."""
|
||
|
||
def test_list_sessions_rich_excludes_tool_source(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.create_session("s2", "tool")
|
||
db.create_session("s3", "telegram")
|
||
sessions = db.list_sessions_rich(exclude_sources=["tool"])
|
||
ids = [s["id"] for s in sessions]
|
||
assert "s1" in ids
|
||
assert "s3" in ids
|
||
assert "s2" not in ids
|
||
|
||
def test_list_sessions_rich_no_exclusion_returns_all(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.create_session("s2", "tool")
|
||
sessions = db.list_sessions_rich()
|
||
ids = [s["id"] for s in sessions]
|
||
assert "s1" in ids
|
||
assert "s2" in ids
|
||
|
||
def test_list_sessions_rich_source_and_exclude_combined(self, db):
|
||
"""When source= is explicit, exclude_sources should not conflict."""
|
||
db.create_session("s1", "cli")
|
||
db.create_session("s2", "tool")
|
||
db.create_session("s3", "telegram")
|
||
# Explicit source filter: only tool sessions, no exclusion
|
||
sessions = db.list_sessions_rich(source="tool")
|
||
ids = [s["id"] for s in sessions]
|
||
assert ids == ["s2"]
|
||
|
||
def test_list_sessions_rich_exclude_multiple_sources(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.create_session("s2", "tool")
|
||
db.create_session("s3", "cron")
|
||
db.create_session("s4", "telegram")
|
||
sessions = db.list_sessions_rich(exclude_sources=["tool", "cron"])
|
||
ids = [s["id"] for s in sessions]
|
||
assert "s1" in ids
|
||
assert "s4" in ids
|
||
assert "s2" not in ids
|
||
assert "s3" not in ids
|
||
|
||
def test_search_messages_excludes_tool_source(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.append_message("s1", "user", "Python deployment question")
|
||
db.create_session("s2", "tool")
|
||
db.append_message("s2", "user", "Python automated question")
|
||
results = db.search_messages("Python", exclude_sources=["tool"])
|
||
sources = [r["source"] for r in results]
|
||
assert "cli" in sources
|
||
assert "tool" not in sources
|
||
|
||
def test_search_messages_no_exclusion_returns_all_sources(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.append_message("s1", "user", "Rust deployment question")
|
||
db.create_session("s2", "tool")
|
||
db.append_message("s2", "user", "Rust automated question")
|
||
results = db.search_messages("Rust")
|
||
sources = [r["source"] for r in results]
|
||
assert "cli" in sources
|
||
assert "tool" in sources
|
||
|
||
def test_search_messages_source_include_and_exclude(self, db):
|
||
"""source_filter (include) and exclude_sources can coexist."""
|
||
db.create_session("s1", "cli")
|
||
db.append_message("s1", "user", "Golang test")
|
||
db.create_session("s2", "telegram")
|
||
db.append_message("s2", "user", "Golang test")
|
||
db.create_session("s3", "tool")
|
||
db.append_message("s3", "user", "Golang test")
|
||
# Include cli+tool, but exclude tool → should only return cli
|
||
results = db.search_messages(
|
||
"Golang", source_filter=["cli", "tool"], exclude_sources=["tool"]
|
||
)
|
||
sources = [r["source"] for r in results]
|
||
assert sources == ["cli"]
|
||
|
||
|
||
class TestResolveSessionByNameOrId:
|
||
"""Tests for the main.py helper that resolves names or IDs."""
|
||
|
||
def test_resolve_by_id(self, db):
|
||
db.create_session("test-id-123", "cli")
|
||
session = db.get_session("test-id-123")
|
||
assert session is not None
|
||
assert session["id"] == "test-id-123"
|
||
|
||
def test_resolve_by_title_falls_back(self, db):
|
||
db.create_session("s1", "cli")
|
||
db.set_session_title("s1", "my project")
|
||
result = db.resolve_session_by_title("my project")
|
||
assert result == "s1"
|
||
|
||
|
||
# =========================================================================
|
||
# Concurrent write safety / lock contention fixes (#3139)
|
||
# =========================================================================
|
||
|
||
class TestConcurrentWriteSafety:
|
||
def test_create_session_insert_or_ignore_is_idempotent(self, db):
|
||
"""create_session with the same ID twice must not raise (INSERT OR IGNORE)."""
|
||
db.create_session(session_id="dup-1", source="cli", model="m")
|
||
# Second call should be silent — no IntegrityError
|
||
db.create_session(session_id="dup-1", source="gateway", model="m2")
|
||
session = db.get_session("dup-1")
|
||
# Row should exist (first write wins with OR IGNORE)
|
||
assert session is not None
|
||
assert session["source"] == "cli"
|
||
|
||
def test_ensure_session_creates_missing_row(self, db):
|
||
"""ensure_session must create a minimal row when the session doesn't exist."""
|
||
assert db.get_session("orphan-session") is None
|
||
db.ensure_session("orphan-session", source="gateway", model="test-model")
|
||
row = db.get_session("orphan-session")
|
||
assert row is not None
|
||
assert row["source"] == "gateway"
|
||
assert row["model"] == "test-model"
|
||
|
||
def test_ensure_session_is_idempotent(self, db):
|
||
"""ensure_session on an existing row must be a no-op (no overwrite)."""
|
||
db.create_session(session_id="existing", source="cli", model="original-model")
|
||
db.ensure_session("existing", source="gateway", model="overwrite-model")
|
||
row = db.get_session("existing")
|
||
# First write wins — ensure_session must not overwrite
|
||
assert row["source"] == "cli"
|
||
assert row["model"] == "original-model"
|
||
|
||
def test_ensure_session_allows_append_message_after_failed_create(self, db):
|
||
"""Messages can be flushed even when create_session failed at startup.
|
||
|
||
Simulates the #3139 scenario: create_session raises (lock), then
|
||
ensure_session is called during flush, then append_message succeeds.
|
||
"""
|
||
# Simulate failed create_session — row absent
|
||
db.ensure_session("late-session", source="gateway", model="gpt-4")
|
||
db.append_message(
|
||
session_id="late-session",
|
||
role="user",
|
||
content="hello after lock",
|
||
)
|
||
msgs = db.get_messages("late-session")
|
||
assert len(msgs) == 1
|
||
assert msgs[0]["content"] == "hello after lock"
|
||
|
||
def test_sqlite_timeout_is_at_least_30s(self, db):
|
||
"""Connection timeout should be >= 30s to survive CLI/gateway contention."""
|
||
# Access the underlying connection timeout via sqlite3 introspection.
|
||
# There is no public API, so we check the kwarg via the module default.
|
||
import inspect
|
||
from hermes_state import SessionDB as _SessionDB
|
||
src = inspect.getsource(_SessionDB.__init__)
|
||
assert "30" in src, (
|
||
"SQLite timeout should be at least 30s to handle CLI/gateway lock contention"
|
||
)
|
||
|
||
|
||
# =========================================================================
|
||
# Auto-maintenance: state_meta + vacuum + maybe_auto_prune_and_vacuum
|
||
# =========================================================================
|
||
|
||
class TestStateMeta:
|
||
def test_get_meta_missing_returns_none(self, db):
|
||
assert db.get_meta("nonexistent") is None
|
||
|
||
def test_set_then_get_meta(self, db):
|
||
db.set_meta("foo", "bar")
|
||
assert db.get_meta("foo") == "bar"
|
||
|
||
def test_set_meta_upsert(self, db):
|
||
"""set_meta overwrites existing value (ON CONFLICT DO UPDATE)."""
|
||
db.set_meta("key", "v1")
|
||
db.set_meta("key", "v2")
|
||
assert db.get_meta("key") == "v2"
|
||
|
||
|
||
class TestVacuum:
|
||
def test_vacuum_runs_without_error(self, db):
|
||
"""VACUUM must succeed on a fresh DB (no rows to reclaim)."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(session_id="s1", role="user", content="hi")
|
||
# Should not raise, even though there's nothing significant to reclaim.
|
||
db.vacuum()
|
||
|
||
|
||
class TestOptimizeFts:
|
||
def test_optimize_returns_index_count(self, db):
|
||
"""A fresh DB has both FTS indexes; optimize merges both."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(session_id="s1", role="user", content="hello world")
|
||
assert db.optimize_fts() == 2
|
||
|
||
def test_optimize_preserves_search_and_snippet(self, db):
|
||
"""Optimize is layout-only: MATCH results + snippets are unchanged."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
for i in range(50):
|
||
db.append_message(
|
||
session_id="s1",
|
||
role="user",
|
||
content=f"needle alpha bravo charlie message {i}",
|
||
)
|
||
before = db.search_messages("needle")
|
||
n = db.optimize_fts()
|
||
assert n == 2
|
||
after = db.search_messages("needle")
|
||
assert len(after) == len(before)
|
||
assert len(after) > 0
|
||
# Snippet must still be populated (would be empty/None if the FTS
|
||
# content shadow were lost during optimize).
|
||
assert all(row.get("snippet") for row in after)
|
||
# IDs and snippets are identical before/after — pure layout change.
|
||
assert [r["id"] for r in after] == [r["id"] for r in before]
|
||
assert [r["snippet"] for r in after] == [r["snippet"] for r in before]
|
||
|
||
def test_optimize_skips_missing_trigram_table(self, db):
|
||
"""When the trigram index is absent, optimize handles only the porter
|
||
index and does not raise."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(session_id="s1", role="user", content="hello")
|
||
# Drop the trigram table + triggers to simulate a disabled/absent index.
|
||
with db._lock:
|
||
for trig in (
|
||
"messages_fts_trigram_insert",
|
||
"messages_fts_trigram_delete",
|
||
"messages_fts_trigram_update",
|
||
):
|
||
db._conn.execute(f"DROP TRIGGER IF EXISTS {trig}")
|
||
db._conn.execute("DROP TABLE IF EXISTS messages_fts_trigram")
|
||
assert db._fts_table_exists("messages_fts_trigram") is False
|
||
assert db._fts_table_exists("messages_fts") is True
|
||
# Only the porter index remains -> 1 optimized, no error.
|
||
assert db.optimize_fts() == 1
|
||
|
||
def test_optimize_idempotent(self, db):
|
||
"""Running optimize twice is safe (second pass is a no-op merge)."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(session_id="s1", role="user", content="repeat me")
|
||
assert db.optimize_fts() == 2
|
||
assert db.optimize_fts() == 2
|
||
# Search still works after repeated optimization.
|
||
assert len(db.search_messages("repeat")) == 1
|
||
|
||
def test_write_path_optimizes_fts_on_cadence(self, db, monkeypatch):
|
||
"""Writes periodically merge FTS segments so they never accumulate
|
||
into the tens-of-thousands that lengthen the write-lock hold and
|
||
starve competing writers ("database is locked")."""
|
||
db._OPTIMIZE_EVERY_N_WRITES = 5
|
||
calls = {"n": 0}
|
||
real_optimize = db.optimize_fts
|
||
|
||
def _counting_optimize():
|
||
calls["n"] += 1
|
||
return real_optimize()
|
||
|
||
monkeypatch.setattr(db, "optimize_fts", _counting_optimize)
|
||
# create_session is write #1; appends are #2.. -> #5 and #10 trigger.
|
||
db.create_session(session_id="s1", source="cli")
|
||
for i in range(9):
|
||
db.append_message(session_id="s1", role="user", content=f"needle {i}")
|
||
assert calls["n"] == 2
|
||
# The auto-merge is layout-only: search is unaffected.
|
||
assert len(db.search_messages("needle")) == 9
|
||
|
||
def test_write_path_optimize_failure_never_breaks_write(self, db, monkeypatch):
|
||
"""A failing periodic optimize must not fail the surrounding write."""
|
||
db._OPTIMIZE_EVERY_N_WRITES = 2
|
||
|
||
def _boom():
|
||
raise sqlite3.OperationalError("simulated optimize failure")
|
||
|
||
monkeypatch.setattr(db, "optimize_fts", _boom)
|
||
db.create_session(session_id="s1", source="cli") # write #1
|
||
# write #2 trips the cadence; the swallowed failure must not propagate.
|
||
db.append_message(session_id="s1", role="user", content="still persists")
|
||
assert len(db.get_messages("s1")) == 1
|
||
|
||
|
||
class TestAutoMaintenance:
|
||
def _make_old_ended(self, db, sid: str, days_old: int = 100):
|
||
"""Create a session that is ended and was started `days_old` days ago."""
|
||
db.create_session(session_id=sid, source="cli")
|
||
db.end_session(sid, end_reason="done")
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||
(time.time() - days_old * 86400, sid),
|
||
)
|
||
db._conn.commit()
|
||
|
||
def test_first_run_prunes_and_vacuums(self, db):
|
||
self._make_old_ended(db, "old1", days_old=100)
|
||
self._make_old_ended(db, "old2", days_old=100)
|
||
db.create_session(session_id="new", source="cli") # active, must survive
|
||
|
||
result = db.maybe_auto_prune_and_vacuum(retention_days=90)
|
||
assert result["skipped"] is False
|
||
assert result["pruned"] == 2
|
||
assert result["vacuumed"] is True
|
||
assert result.get("error") is None
|
||
assert db.get_session("old1") is None
|
||
assert db.get_session("old2") is None
|
||
assert db.get_session("new") is not None
|
||
|
||
def test_second_call_within_interval_skips(self, db):
|
||
self._make_old_ended(db, "old", days_old=100)
|
||
first = db.maybe_auto_prune_and_vacuum(
|
||
retention_days=90, min_interval_hours=24
|
||
)
|
||
assert first["skipped"] is False
|
||
assert first["pruned"] == 1
|
||
|
||
# Create another prunable session; a second call within
|
||
# min_interval_hours should still skip without touching it.
|
||
self._make_old_ended(db, "old2", days_old=100)
|
||
second = db.maybe_auto_prune_and_vacuum(
|
||
retention_days=90, min_interval_hours=24
|
||
)
|
||
assert second["skipped"] is True
|
||
assert second["pruned"] == 0
|
||
assert db.get_session("old2") is not None # untouched
|
||
|
||
def test_second_call_after_interval_runs_again(self, db):
|
||
self._make_old_ended(db, "old", days_old=100)
|
||
db.maybe_auto_prune_and_vacuum(retention_days=90, min_interval_hours=24)
|
||
|
||
# Backdate the last-run marker to force another run.
|
||
db.set_meta("last_auto_prune", str(time.time() - 48 * 3600))
|
||
|
||
self._make_old_ended(db, "old2", days_old=100)
|
||
result = db.maybe_auto_prune_and_vacuum(
|
||
retention_days=90, min_interval_hours=24
|
||
)
|
||
assert result["skipped"] is False
|
||
assert result["pruned"] == 1
|
||
assert db.get_session("old2") is None
|
||
|
||
def test_no_prunable_sessions_no_vacuum(self, db):
|
||
"""When prune deletes 0 rows, VACUUM is skipped (wasted I/O)."""
|
||
db.create_session(session_id="fresh", source="cli") # too recent
|
||
result = db.maybe_auto_prune_and_vacuum(retention_days=90)
|
||
assert result["skipped"] is False
|
||
assert result["pruned"] == 0
|
||
assert result["vacuumed"] is False
|
||
# But last-run is still recorded so we don't retry immediately.
|
||
assert db.get_meta("last_auto_prune") is not None
|
||
|
||
def test_vacuum_disabled_via_flag(self, db):
|
||
self._make_old_ended(db, "old", days_old=100)
|
||
result = db.maybe_auto_prune_and_vacuum(retention_days=90, vacuum=False)
|
||
assert result["pruned"] == 1
|
||
assert result["vacuumed"] is False
|
||
|
||
def test_corrupt_last_run_marker_treated_as_no_prior_run(self, db):
|
||
"""A non-numeric marker must not break maintenance."""
|
||
db.set_meta("last_auto_prune", "not-a-timestamp")
|
||
self._make_old_ended(db, "old", days_old=100)
|
||
result = db.maybe_auto_prune_and_vacuum(retention_days=90)
|
||
assert result["skipped"] is False
|
||
assert result["pruned"] == 1
|
||
|
||
def test_state_meta_survives_vacuum(self, db):
|
||
"""Marker written just before VACUUM must still be readable after."""
|
||
self._make_old_ended(db, "old", days_old=100)
|
||
db.maybe_auto_prune_and_vacuum(retention_days=90)
|
||
marker = db.get_meta("last_auto_prune")
|
||
assert marker is not None
|
||
# Should parse as a float timestamp close to now.
|
||
assert abs(float(marker) - time.time()) < 60
|
||
|
||
def test_auto_prune_deletes_transcript_files(self, db, tmp_path):
|
||
"""Issue #3015: auto-prune must also delete on-disk transcript files."""
|
||
sessions_dir = tmp_path / "sessions"
|
||
sessions_dir.mkdir()
|
||
|
||
self._make_old_ended(db, "old1", days_old=100)
|
||
self._make_old_ended(db, "old2", days_old=100)
|
||
db.create_session(session_id="new", source="cli") # active
|
||
|
||
# Transcript files mimicking real gateway/CLI layout
|
||
(sessions_dir / "old1.json").write_text("{}")
|
||
(sessions_dir / "old1.jsonl").write_text("{}\n")
|
||
(sessions_dir / "old2.jsonl").write_text("{}\n")
|
||
(sessions_dir / "request_dump_old1_001.json").write_text("{}")
|
||
(sessions_dir / "new.jsonl").write_text("{}\n") # active, must survive
|
||
|
||
result = db.maybe_auto_prune_and_vacuum(
|
||
retention_days=90, sessions_dir=sessions_dir
|
||
)
|
||
assert result["pruned"] == 2
|
||
|
||
# Pruned transcript files are gone
|
||
assert not (sessions_dir / "old1.json").exists()
|
||
assert not (sessions_dir / "old1.jsonl").exists()
|
||
assert not (sessions_dir / "old2.jsonl").exists()
|
||
assert not (sessions_dir / "request_dump_old1_001.json").exists()
|
||
# Active session's transcript is untouched
|
||
assert (sessions_dir / "new.jsonl").exists()
|
||
|
||
def test_auto_prune_without_sessions_dir_preserves_files(self, db, tmp_path):
|
||
"""Backward-compat: no sessions_dir = DB-only cleanup (legacy behavior)."""
|
||
sessions_dir = tmp_path / "sessions"
|
||
sessions_dir.mkdir()
|
||
self._make_old_ended(db, "old", days_old=100)
|
||
(sessions_dir / "old.jsonl").write_text("{}\n")
|
||
|
||
result = db.maybe_auto_prune_and_vacuum(retention_days=90)
|
||
assert result["pruned"] == 1
|
||
# File stays — caller didn't opt in
|
||
assert (sessions_dir / "old.jsonl").exists()
|
||
|
||
def test_prune_sessions_deletes_files_for_pruned_only(self, db, tmp_path):
|
||
"""Active-session transcripts must never be deleted by prune."""
|
||
sessions_dir = tmp_path / "sessions"
|
||
sessions_dir.mkdir()
|
||
self._make_old_ended(db, "old", days_old=100)
|
||
db.create_session(session_id="active", source="cli") # not ended
|
||
(sessions_dir / "old.jsonl").write_text("{}\n")
|
||
(sessions_dir / "active.jsonl").write_text("{}\n")
|
||
|
||
count = db.prune_sessions(older_than_days=90, sessions_dir=sessions_dir)
|
||
assert count == 1
|
||
assert not (sessions_dir / "old.jsonl").exists()
|
||
assert (sessions_dir / "active.jsonl").exists()
|
||
|
||
|
||
# =========================================================================
|
||
# FTS5 indexing of tool_calls / tool_name (#16751)
|
||
# =========================================================================
|
||
|
||
class TestFTS5ToolCallIndexing:
|
||
"""Regression tests: search_messages must see tool_name and tool_calls.
|
||
|
||
Before #16751's fix, `messages_fts` only indexed `messages.content`, so
|
||
tokens that only appeared in `tool_name` or the serialized `tool_calls`
|
||
JSON were invisible to session_search even though the row was in the DB.
|
||
"""
|
||
|
||
def test_tool_name_is_searchable(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(
|
||
"s1", role="assistant", content="",
|
||
tool_name="UNIQUETOOLNAME",
|
||
)
|
||
results = db.search_messages("UNIQUETOOLNAME")
|
||
assert len(results) == 1
|
||
|
||
def test_tool_calls_args_are_searchable(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(
|
||
"s1", role="assistant", content="",
|
||
tool_calls=[{
|
||
"id": "c1",
|
||
"type": "function",
|
||
"function": {
|
||
"name": "web_search",
|
||
"arguments": '{"query": "UNIQUESEARCHTOKEN"}',
|
||
},
|
||
}],
|
||
)
|
||
results = db.search_messages("UNIQUESEARCHTOKEN")
|
||
assert len(results) == 1
|
||
|
||
def test_tool_function_name_in_tool_calls_is_searchable(self, db):
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(
|
||
"s1", role="assistant", content="",
|
||
tool_calls=[{
|
||
"id": "c1",
|
||
"type": "function",
|
||
"function": {"name": "UNIQUEFUNCNAME", "arguments": "{}"},
|
||
}],
|
||
)
|
||
results = db.search_messages("UNIQUEFUNCNAME")
|
||
assert len(results) == 1
|
||
|
||
def test_delete_message_row_does_not_crash(self, db):
|
||
"""DELETE on messages must not raise when FTS rows reference tool fields.
|
||
|
||
Previously the messages_fts_delete trigger passed old.content to the
|
||
FTS5 delete-command but the inserted row was the concatenation of
|
||
content || tool_name || tool_calls, so FTS5 rejected the delete with
|
||
'SQL logic error' and every session delete path broke.
|
||
"""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(
|
||
"s1", role="assistant", content="hello",
|
||
tool_name="web_search",
|
||
tool_calls=[{
|
||
"id": "c1",
|
||
"type": "function",
|
||
"function": {"name": "web_search", "arguments": '{"q": "x"}'},
|
||
}],
|
||
)
|
||
# end_session + end-time prune path would exercise DELETE; hit the
|
||
# row directly through the write helper to keep the regression focused.
|
||
def _delete(conn):
|
||
conn.execute("DELETE FROM messages WHERE session_id = ?", ("s1",))
|
||
db._execute_write(_delete) # must not raise
|
||
|
||
assert db.search_messages("hello") == []
|
||
assert db.search_messages("web_search") == []
|
||
|
||
def test_update_message_reindexes_tool_fields(self, db):
|
||
"""UPDATE must refresh the FTS row so old tokens drop out and new tokens appear."""
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message(
|
||
"s1", role="assistant", content="",
|
||
tool_name="ORIGINALTOOL",
|
||
)
|
||
assert len(db.search_messages("ORIGINALTOOL")) == 1
|
||
|
||
def _update(conn):
|
||
conn.execute(
|
||
"UPDATE messages SET tool_name = ? WHERE session_id = ?",
|
||
("RENAMEDTOOL", "s1"),
|
||
)
|
||
db._execute_write(_update)
|
||
|
||
assert db.search_messages("ORIGINALTOOL") == []
|
||
assert len(db.search_messages("RENAMEDTOOL")) == 1
|
||
|
||
|
||
class TestFTS5ToolCallMigration:
|
||
"""v11 migration: pre-existing state.db with old external-content FTS tables
|
||
must be re-indexed so tool_name / tool_calls become searchable after upgrade."""
|
||
|
||
def test_v10_to_v11_upgrade_backfills_tool_fields(self, tmp_path):
|
||
"""Simulate an existing user: build a v10-shaped DB by hand, insert a
|
||
row with tool_calls, then open via SessionDB (which runs migrations).
|
||
After upgrade, the tool_calls token must be searchable."""
|
||
import sqlite3
|
||
|
||
db_path = tmp_path / "legacy.db"
|
||
|
||
# Build the pre-v11 schema by hand: external-content FTS tables +
|
||
# old triggers that only reference new.content.
|
||
conn = sqlite3.connect(str(db_path))
|
||
conn.executescript("""
|
||
CREATE TABLE schema_version (version INTEGER NOT NULL);
|
||
INSERT INTO schema_version (version) VALUES (10);
|
||
|
||
CREATE TABLE sessions (
|
||
id TEXT PRIMARY KEY,
|
||
source TEXT,
|
||
started_at REAL,
|
||
ended_at REAL,
|
||
title TEXT,
|
||
parent_session_id TEXT,
|
||
message_count INTEGER DEFAULT 0,
|
||
tool_call_count INTEGER DEFAULT 0,
|
||
api_call_count INTEGER DEFAULT 0
|
||
);
|
||
CREATE TABLE messages (
|
||
id INTEGER PRIMARY KEY,
|
||
session_id TEXT NOT NULL,
|
||
timestamp REAL NOT NULL,
|
||
role TEXT NOT NULL,
|
||
content TEXT,
|
||
tool_name TEXT,
|
||
tool_calls TEXT,
|
||
tool_call_id TEXT,
|
||
token_count INTEGER,
|
||
finish_reason TEXT,
|
||
reasoning TEXT,
|
||
reasoning_content TEXT,
|
||
reasoning_details TEXT,
|
||
codex_reasoning_items TEXT,
|
||
codex_message_items TEXT
|
||
);
|
||
|
||
CREATE VIRTUAL TABLE messages_fts USING fts5(
|
||
content, content=messages, content_rowid=id
|
||
);
|
||
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
|
||
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
|
||
END;
|
||
|
||
CREATE VIRTUAL TABLE messages_fts_trigram USING fts5(
|
||
content, content=messages, content_rowid=id, tokenize='trigram'
|
||
);
|
||
CREATE TRIGGER messages_fts_trigram_insert AFTER INSERT ON messages BEGIN
|
||
INSERT INTO messages_fts_trigram(rowid, content) VALUES (new.id, new.content);
|
||
END;
|
||
""")
|
||
conn.execute(
|
||
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
|
||
("s1", "cli", time.time()),
|
||
)
|
||
conn.execute(
|
||
"INSERT INTO messages (session_id, timestamp, role, content, tool_name, tool_calls) "
|
||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||
("s1", time.time(), "assistant", "", "LEGACYTOOL",
|
||
'{"function":{"name":"web_search","arguments":"{\\"q\\":\\"LEGACYARG\\"}"}}'),
|
||
)
|
||
conn.commit()
|
||
|
||
# Verify the legacy FTS rows don't contain the tool tokens yet.
|
||
legacy_hits = conn.execute(
|
||
"SELECT rowid FROM messages_fts WHERE messages_fts MATCH 'LEGACYTOOL'"
|
||
).fetchall()
|
||
assert legacy_hits == [], "sanity: legacy FTS must NOT contain tool_name"
|
||
conn.close()
|
||
|
||
# Open via SessionDB — the legacy DB is detected as optimizable but
|
||
# NOT auto-migrated (opt-in). Its old content-only index still works
|
||
# for content, but doesn't yet cover tool_name/tool_calls (#16751).
|
||
session_db = SessionDB(db_path=db_path)
|
||
try:
|
||
assert session_db.fts_optimize_available() is True
|
||
|
||
# `hermes db optimize` performs the v23 transition; afterwards the
|
||
# tool fields are searchable.
|
||
result = session_db.optimize_fts_storage(vacuum=False)
|
||
assert result["ok"] is True
|
||
assert len(session_db.search_messages("LEGACYTOOL")) == 1, \
|
||
"v23 optimize must index tool_name into FTS"
|
||
assert len(session_db.search_messages("LEGACYARG")) == 1, \
|
||
"v23 optimize must index tool_calls JSON into FTS"
|
||
# schema_version bumped once the FTS layer is v23
|
||
from hermes_state import SCHEMA_VERSION
|
||
row = session_db._conn.execute(
|
||
"SELECT version FROM schema_version LIMIT 1"
|
||
).fetchone()
|
||
version = row["version"] if hasattr(row, "keys") else row[0]
|
||
assert version == SCHEMA_VERSION
|
||
finally:
|
||
session_db.close()
|
||
|
||
|
||
class TestFTSExternalContentMigration:
|
||
"""v23 migration: inline-mode FTS tables (v11-v22) are rebuilt as
|
||
external-content tables, and role='tool' rows are excluded from the
|
||
trigram index while remaining searchable via the standard index."""
|
||
|
||
@staticmethod
|
||
def _build_v22_db(db_path):
|
||
"""Build a v22-shaped DB by hand: inline FTS tables + concat triggers."""
|
||
conn = sqlite3.connect(str(db_path))
|
||
conn.executescript(SCHEMA_SQL)
|
||
# Replace the current (v23) FTS objects with the v22 inline shape.
|
||
conn.executescript("""
|
||
DROP TABLE IF EXISTS messages_fts;
|
||
DROP TABLE IF EXISTS messages_fts_trigram;
|
||
DROP VIEW IF EXISTS messages_fts_trigram_src;
|
||
|
||
CREATE VIRTUAL TABLE messages_fts USING fts5(content);
|
||
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
|
||
INSERT INTO messages_fts(rowid, content) VALUES (
|
||
new.id,
|
||
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
|
||
);
|
||
END;
|
||
|
||
CREATE VIRTUAL TABLE messages_fts_trigram USING fts5(content, tokenize='trigram');
|
||
CREATE TRIGGER messages_fts_trigram_insert AFTER INSERT ON messages BEGIN
|
||
INSERT INTO messages_fts_trigram(rowid, content) VALUES (
|
||
new.id,
|
||
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
|
||
);
|
||
END;
|
||
""")
|
||
conn.execute("DELETE FROM schema_version")
|
||
conn.execute("INSERT INTO schema_version (version) VALUES (22)")
|
||
conn.execute(
|
||
"INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'cli', ?)",
|
||
(time.time(),),
|
||
)
|
||
rows = [
|
||
("user", "find the 大别山项目 deployment notes", None, None),
|
||
("assistant", "关于大别山项目的总结在这里", None,
|
||
'{"function":{"name":"send_message","arguments":"{}"}}'),
|
||
("tool", "TOOLBLOB " + "x" * 5000 + " 项目文件内容测试", "read_file", None),
|
||
]
|
||
for role, content, tool_name, tool_calls in rows:
|
||
conn.execute(
|
||
"INSERT INTO messages (session_id, timestamp, role, content, tool_name, tool_calls) "
|
||
"VALUES ('s1', ?, ?, ?, ?, ?)",
|
||
(time.time(), role, content, tool_name, tool_calls),
|
||
)
|
||
conn.commit()
|
||
# Sanity: v22 inline tables have their own content shadow tables.
|
||
shadow = conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE name = 'messages_fts_content'"
|
||
).fetchall()
|
||
assert shadow, "sanity: v22 inline FTS must have a content shadow table"
|
||
conn.close()
|
||
|
||
def test_v22_open_leaves_legacy_untouched_and_advertises(self, tmp_path):
|
||
"""Opening a legacy v22 DB must NOT auto-migrate the FTS layout, but
|
||
the main schema_version DOES advance (decoupled) so future non-FTS
|
||
migrations aren't blocked. The inline index keeps working and the
|
||
opt-in flag is set."""
|
||
db_path = tmp_path / "v22.db"
|
||
self._build_v22_db(db_path)
|
||
|
||
db = SessionDB(db_path=db_path)
|
||
try:
|
||
# DECOUPLED: the main schema_version advances to current even though
|
||
# the FTS layout stays legacy — future migrations must not be gated
|
||
# behind the FTS opt-in.
|
||
version = db._conn.execute(
|
||
"SELECT version FROM schema_version"
|
||
).fetchone()[0]
|
||
assert version == SCHEMA_VERSION, "main schema version must advance"
|
||
# But the FTS storage layout is NOT stamped current — it's legacy.
|
||
assert db.get_meta("fts_storage_version") is None
|
||
assert db.fts_optimize_available() is True
|
||
assert db.get_meta("fts_optimize_available") == "1"
|
||
|
||
# Legacy inline shape is intact (content shadow table still there).
|
||
assert db._conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE name = 'messages_fts_content'"
|
||
).fetchone() is not None
|
||
|
||
# Search still works on the legacy index (no deferred rebuild).
|
||
assert db.fts_rebuild_status() is None
|
||
assert len(db.search_messages("deployment")) == 1
|
||
assert len(db.search_messages("send_message")) == 1 # #16751 held
|
||
|
||
# A new write is indexed live by the legacy triggers.
|
||
db.append_message("s1", role="user", content="AFTEROPEN token")
|
||
assert len(db.search_messages("AFTEROPEN")) == 1
|
||
finally:
|
||
db.close()
|
||
|
||
def test_optimize_fts_storage_transitions_to_v23(self, tmp_path):
|
||
"""`optimize_fts_storage()` migrates a legacy DB to v23 external-content
|
||
to completion: no shadow copies, tool rows excluded from trigram,
|
||
version bumped, everything searchable exactly once."""
|
||
db_path = tmp_path / "v22.db"
|
||
self._build_v22_db(db_path)
|
||
|
||
db = SessionDB(db_path=db_path)
|
||
try:
|
||
assert db.fts_optimize_available() is True
|
||
result = db.optimize_fts_storage(vacuum=False)
|
||
assert result["ok"] is True
|
||
|
||
# Layout stamped current; flag cleared; no longer "available".
|
||
assert db.get_meta("fts_storage_version") == str(
|
||
hermes_state.FTS_STORAGE_VERSION
|
||
)
|
||
assert db._conn.execute(
|
||
"SELECT version FROM schema_version"
|
||
).fetchone()[0] == SCHEMA_VERSION
|
||
assert db.fts_optimize_available() is False
|
||
assert db.fts_rebuild_status() is None
|
||
|
||
# External-content: no *_content shadow tables, no trash left.
|
||
for shadow in ("messages_fts_content", "messages_fts_trigram_content"):
|
||
assert db._conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE name = ?", (shadow,)
|
||
).fetchone() is None
|
||
assert db._conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE name LIKE '%_v22_trash%'"
|
||
).fetchall() == []
|
||
|
||
# Standard FTS: all rows incl tool metadata (#16751).
|
||
assert len(db.search_messages("TOOLBLOB")) == 1
|
||
assert len(db.search_messages("send_message")) == 1
|
||
# Trigram excludes tool rows; CJK conversation search works.
|
||
assert len(db.search_messages("大别山项目")) == 2
|
||
assert db._conn.execute(
|
||
"SELECT COUNT(*) FROM messages_fts_trigram "
|
||
"WHERE messages_fts_trigram MATCH '\"项目文件内容\"'"
|
||
).fetchone()[0] == 0
|
||
assert db.search_messages("项目文件内容", role_filter=["tool"]) != []
|
||
# No duplicate index entries; integrity clean.
|
||
for term in ("TOOLBLOB", "deployment"):
|
||
assert db._conn.execute(
|
||
"SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH ?",
|
||
(term,),
|
||
).fetchone()[0] == 1
|
||
db._conn.execute(
|
||
"INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1)"
|
||
)
|
||
finally:
|
||
db.close()
|
||
|
||
def test_optimize_fts_storage_resumable_after_interrupt(self, tmp_path):
|
||
"""A partially-completed optimize resumes on re-run: after demote +
|
||
one chunk, re-invoking finishes without duplicating rows."""
|
||
db_path = tmp_path / "v22.db"
|
||
self._build_v22_db(db_path)
|
||
|
||
db = SessionDB(db_path=db_path)
|
||
try:
|
||
# Simulate an interrupted run: demote + a single backfill chunk,
|
||
# then stop (as if the process died mid-optimize).
|
||
db._demote_legacy_fts_to_trash()
|
||
assert db.fts_rebuild_status() is not None
|
||
db.fts_rebuild_step() # one chunk only
|
||
|
||
# Old rows not yet backfilled are still findable via gap supplement.
|
||
assert len(db.search_messages("TOOLBLOB")) == 1
|
||
|
||
# Re-run the full command — must resume, not restart or duplicate.
|
||
result = db.optimize_fts_storage(vacuum=False)
|
||
assert result["ok"] is True
|
||
assert db.fts_rebuild_status() is None
|
||
assert db._conn.execute(
|
||
"SELECT version FROM schema_version"
|
||
).fetchone()[0] == SCHEMA_VERSION
|
||
for term in ("TOOLBLOB", "deployment"):
|
||
assert db._conn.execute(
|
||
"SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH ?",
|
||
(term,),
|
||
).fetchone()[0] == 1
|
||
db._conn.execute(
|
||
"INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1)"
|
||
)
|
||
finally:
|
||
db.close()
|
||
|
||
def test_interrupted_optimize_reopen_still_reports_available(self, tmp_path):
|
||
"""An interrupted optimize followed by a process restart must keep
|
||
offering the resume: the legacy vtables are gone (demoted), so the
|
||
legacy-shape check alone would say "already compact" — the gate has
|
||
to accept pending rebuild markers / trash tables too. And the reopen
|
||
must NOT stamp fts_storage_version (the transition isn't done)."""
|
||
db_path = tmp_path / "v22.db"
|
||
self._build_v22_db(db_path)
|
||
|
||
db = SessionDB(db_path=db_path)
|
||
try:
|
||
db._demote_legacy_fts_to_trash()
|
||
db.fts_rebuild_step() # one chunk, then "the process dies"
|
||
finally:
|
||
db.close()
|
||
|
||
# Fresh open, as the CLI would after the interrupt.
|
||
db = SessionDB(db_path=db_path)
|
||
try:
|
||
# The CLI gate must still offer optimize-storage (resume).
|
||
assert db.fts_optimize_available() is True
|
||
# The layout must NOT be stamped current mid-transition.
|
||
assert db.get_meta("fts_storage_version") is None
|
||
# Search stays complete through the gap supplement meanwhile.
|
||
assert len(db.search_messages("TOOLBLOB")) == 1
|
||
|
||
# Re-running the command resumes and completes the transition.
|
||
result = db.optimize_fts_storage(vacuum=False)
|
||
assert result["ok"] is True
|
||
assert db.fts_optimize_available() is False
|
||
assert db.get_meta("fts_storage_version") == str(
|
||
hermes_state.FTS_STORAGE_VERSION
|
||
)
|
||
assert db._conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE name LIKE '%_v22_trash%'"
|
||
).fetchall() == []
|
||
for term in ("TOOLBLOB", "deployment"):
|
||
assert db._conn.execute(
|
||
"SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH ?",
|
||
(term,),
|
||
).fetchone()[0] == 1
|
||
db._conn.execute(
|
||
"INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1)"
|
||
)
|
||
finally:
|
||
db.close()
|
||
|
||
def test_v23_fresh_db_born_optimized(self, tmp_path):
|
||
"""A brand-new DB is born on v23 — no legacy layout, no opt-in flag,
|
||
no pending rebuild."""
|
||
db = SessionDB(db_path=tmp_path / "fresh.db")
|
||
try:
|
||
assert db.fts_optimize_available() is False
|
||
assert db.fts_rebuild_status() is None
|
||
assert db.get_meta("fts_optimize_available") is None
|
||
# Already external-content: no shadow copy tables.
|
||
assert db._conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE name = 'messages_fts_content'"
|
||
).fetchone() is None
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="hello fresh world")
|
||
assert len(db.search_messages("fresh")) == 1
|
||
finally:
|
||
db.close()
|
||
|
||
def test_v23_trigram_stays_in_sync_on_write_paths(self, tmp_path):
|
||
"""INSERT/UPDATE/DELETE through SessionDB keep both indexes coherent
|
||
under the new trigger shape (integrity-check verifies external
|
||
content agreement)."""
|
||
db = SessionDB(db_path=tmp_path / "fresh.db")
|
||
try:
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="搜索大别山项目相关资料")
|
||
db.append_message("s1", role="tool", content="工具输出的大段内容在这里",
|
||
tool_name="web_search")
|
||
db.append_message("s1", role="assistant", content="assistant reply")
|
||
|
||
# Trigram: user+assistant only; standard: everything.
|
||
assert db._conn.execute("SELECT COUNT(*) FROM messages_fts_trigram").fetchone()[0] == 2
|
||
assert db._conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0] == 3
|
||
|
||
# Rewind-style UPDATE (active=0) must not desync the index — the
|
||
# triggers only fire on content/tool column changes.
|
||
def _deactivate(conn):
|
||
conn.execute("UPDATE messages SET active = 0 WHERE role = 'assistant'")
|
||
db._execute_write(_deactivate)
|
||
|
||
# FTS5 integrity-check raises SQLITE_CORRUPT_VTAB on any
|
||
# index/content disagreement; passing = indexes are coherent.
|
||
db._conn.execute(
|
||
"INSERT INTO messages_fts(messages_fts, rank) VALUES('integrity-check', 1)"
|
||
)
|
||
db._conn.execute(
|
||
"INSERT INTO messages_fts_trigram(messages_fts_trigram, rank) "
|
||
"VALUES('integrity-check', 1)"
|
||
)
|
||
finally:
|
||
db.close()
|
||
|
||
def test_v23_cjk_tool_role_filter_uses_like_fallback(self, tmp_path):
|
||
"""A CJK query with role_filter=['tool'] must bypass the trigram index
|
||
(tool rows aren't in it) and still find matches via LIKE."""
|
||
db = SessionDB(db_path=tmp_path / "fresh.db")
|
||
try:
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="tool", content="错误日志:数据库连接超时",
|
||
tool_name="terminal")
|
||
hits = db.search_messages("数据库连接", role_filter=["tool"])
|
||
assert len(hits) == 1
|
||
assert hits[0]["role"] == "tool"
|
||
finally:
|
||
db.close()
|
||
|
||
def test_cjk_like_fallback_hides_rewound_messages(self, tmp_path):
|
||
"""The CJK LIKE fallback must honor the same visibility rule as the
|
||
FTS5 paths: rewound rows (active=0, compacted=0) are hidden unless
|
||
include_inactive=True; compaction-archived rows (active=0,
|
||
compacted=1) stay discoverable (#38763)."""
|
||
db = SessionDB(db_path=tmp_path / "fresh.db")
|
||
try:
|
||
db.create_session(session_id="s1", source="cli")
|
||
db.append_message("s1", role="user", content="被撤销的搜索目标内容")
|
||
db.append_message("s1", role="user", content="被压缩归档的搜索目标内容")
|
||
|
||
def _flags(conn):
|
||
# First row: rewound (active=0, compacted=0) — hidden.
|
||
conn.execute(
|
||
"UPDATE messages SET active = 0 WHERE content LIKE '%撤销%'"
|
||
)
|
||
# Second row: compaction-archived (active=0, compacted=1) — visible.
|
||
conn.execute(
|
||
"UPDATE messages SET active = 0, compacted = 1 "
|
||
"WHERE content LIKE '%归档%'"
|
||
)
|
||
db._execute_write(_flags)
|
||
|
||
# Short-CJK query (2 chars — below the 3-char trigram minimum)
|
||
# forces the LIKE fallback; both rows contain the token 内容.
|
||
# search_messages strips full content from results — assert on
|
||
# the snippet column instead.
|
||
hits = db.search_messages("内容")
|
||
snippets = [h["snippet"] or "" for h in hits]
|
||
assert any("归档" in s for s in snippets), "archived row must stay visible"
|
||
assert not any("撤销" in s for s in snippets), "rewound row must be hidden"
|
||
|
||
# include_inactive=True surfaces everything.
|
||
all_hits = db.search_messages("内容", include_inactive=True)
|
||
assert len(all_hits) == 2
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# apply_wal_with_fallback — read-only probe tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestApplyWalProbe:
|
||
"""Unit tests for the journal_mode probe in apply_wal_with_fallback."""
|
||
|
||
def test_skips_set_pragma_when_already_wal(self, tmp_path):
|
||
"""Already-WAL connection must not trigger the set-pragma."""
|
||
import sqlite3
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
class _TracingConn(sqlite3.Connection):
|
||
def __init__(self, *a, **kw):
|
||
super().__init__(*a, **kw)
|
||
self.executed = []
|
||
|
||
def execute(self, sql, params=()):
|
||
self.executed.append(sql)
|
||
return super().execute(sql, params)
|
||
|
||
db_path = tmp_path / "wal.db"
|
||
# Prime the file into WAL mode first.
|
||
with sqlite3.connect(str(db_path)) as seed:
|
||
seed.execute("PRAGMA journal_mode=WAL")
|
||
|
||
conn = _TracingConn(str(db_path))
|
||
try:
|
||
result = apply_wal_with_fallback(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
assert result == "wal"
|
||
# Only the probe should have fired; the set-pragma must NOT appear.
|
||
assert any("PRAGMA journal_mode" == sql.strip() for sql in conn.executed), (
|
||
"probe PRAGMA should have run"
|
||
)
|
||
assert not any("journal_mode=WAL" in sql for sql in conn.executed), (
|
||
"set-pragma must not run when already in WAL mode"
|
||
)
|
||
|
||
def test_sets_wal_on_fresh_connection(self, tmp_path):
|
||
"""Probe sees 'delete', then set-pragma runs and returns 'wal'."""
|
||
import sqlite3
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
class _TracingConn(sqlite3.Connection):
|
||
def __init__(self, *a, **kw):
|
||
super().__init__(*a, **kw)
|
||
self.executed = []
|
||
|
||
def execute(self, sql, params=()):
|
||
self.executed.append(sql)
|
||
return super().execute(sql, params)
|
||
|
||
db_path = tmp_path / "fresh.db"
|
||
conn = _TracingConn(str(db_path))
|
||
try:
|
||
result = apply_wal_with_fallback(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
assert result == "wal"
|
||
assert any("journal_mode=WAL" in sql for sql in conn.executed), (
|
||
"set-pragma must fire on a fresh (non-WAL) connection"
|
||
)
|
||
|
||
def test_macos_checkpoint_fullsync_barrier_applied(self, tmp_path, monkeypatch):
|
||
"""On Darwin, apply_wal_with_fallback sets checkpoint_fullfsync=1 (issue #30636)."""
|
||
import sqlite3
|
||
import hermes_state
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
class _TracingConn(sqlite3.Connection):
|
||
def __init__(self, *a, **kw):
|
||
super().__init__(*a, **kw)
|
||
self.executed = []
|
||
|
||
def execute(self, sql, params=()):
|
||
self.executed.append(sql)
|
||
return super().execute(sql, params)
|
||
|
||
monkeypatch.setattr(hermes_state.sys, "platform", "darwin")
|
||
|
||
db_path = tmp_path / "macos_fresh.db"
|
||
conn = _TracingConn(str(db_path))
|
||
try:
|
||
result = apply_wal_with_fallback(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
assert result == "wal"
|
||
assert any("checkpoint_fullfsync=1" in sql for sql in conn.executed), (
|
||
"checkpoint_fullfsync barrier must be applied on macOS"
|
||
)
|
||
|
||
def test_macos_barrier_applied_when_already_wal(self, tmp_path, monkeypatch):
|
||
"""The Darwin barrier fires on the already-WAL early-return path too."""
|
||
import sqlite3
|
||
import hermes_state
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
class _TracingConn(sqlite3.Connection):
|
||
def __init__(self, *a, **kw):
|
||
super().__init__(*a, **kw)
|
||
self.executed = []
|
||
|
||
def execute(self, sql, params=()):
|
||
self.executed.append(sql)
|
||
return super().execute(sql, params)
|
||
|
||
db_path = tmp_path / "macos_wal.db"
|
||
with sqlite3.connect(str(db_path)) as seed:
|
||
seed.execute("PRAGMA journal_mode=WAL")
|
||
|
||
monkeypatch.setattr(hermes_state.sys, "platform", "darwin")
|
||
|
||
conn = _TracingConn(str(db_path))
|
||
try:
|
||
result = apply_wal_with_fallback(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
assert result == "wal"
|
||
assert any("checkpoint_fullfsync=1" in sql for sql in conn.executed), (
|
||
"checkpoint_fullfsync barrier must fire on the already-WAL path"
|
||
)
|
||
|
||
def test_checkpoint_fullsync_barrier_skipped_off_darwin(self, tmp_path, monkeypatch):
|
||
"""Non-macOS platforms must NOT issue the macOS-only PRAGMA."""
|
||
import sqlite3
|
||
import hermes_state
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
class _TracingConn(sqlite3.Connection):
|
||
def __init__(self, *a, **kw):
|
||
super().__init__(*a, **kw)
|
||
self.executed = []
|
||
|
||
def execute(self, sql, params=()):
|
||
self.executed.append(sql)
|
||
return super().execute(sql, params)
|
||
|
||
monkeypatch.setattr(hermes_state.sys, "platform", "linux")
|
||
|
||
db_path = tmp_path / "linux_fresh.db"
|
||
conn = _TracingConn(str(db_path))
|
||
try:
|
||
result = apply_wal_with_fallback(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
assert result == "wal"
|
||
assert not any("checkpoint_fullfsync" in sql for sql in conn.executed), (
|
||
"checkpoint_fullfsync must not be issued off macOS"
|
||
)
|
||
assert not any("synchronous=FULL" in sql for sql in conn.executed), (
|
||
"synchronous=FULL must not be issued off macOS"
|
||
)
|
||
|
||
def test_macos_synchronous_full_enforced_fresh(self, tmp_path, monkeypatch):
|
||
"""On Darwin, apply_wal_with_fallback enforces synchronous=FULL (issue #63531)."""
|
||
import sqlite3
|
||
import hermes_state
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
class _TracingConn(sqlite3.Connection):
|
||
def __init__(self, *a, **kw):
|
||
super().__init__(*a, **kw)
|
||
self.executed = []
|
||
|
||
def execute(self, sql, params=()):
|
||
self.executed.append(sql)
|
||
return super().execute(sql, params)
|
||
|
||
monkeypatch.setattr(hermes_state.sys, "platform", "darwin")
|
||
|
||
db_path = tmp_path / "macos_fresh_sync.db"
|
||
conn = _TracingConn(str(db_path))
|
||
try:
|
||
result = apply_wal_with_fallback(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
assert result == "wal"
|
||
assert any("synchronous=FULL" in sql for sql in conn.executed), (
|
||
"synchronous=FULL must be enforced on macOS"
|
||
)
|
||
|
||
def test_macos_synchronous_full_enforced_already_wal(self, tmp_path, monkeypatch):
|
||
"""synchronous=FULL is enforced even when DB is already in WAL mode (issue #63531)."""
|
||
import sqlite3
|
||
import hermes_state
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
class _TracingConn(sqlite3.Connection):
|
||
def __init__(self, *a, **kw):
|
||
super().__init__(*a, **kw)
|
||
self.executed = []
|
||
|
||
def execute(self, sql, params=()):
|
||
self.executed.append(sql)
|
||
return super().execute(sql, params)
|
||
|
||
# Prime the file into WAL mode first (simulating an existing WAL DB).
|
||
db_path = tmp_path / "macos_wal_sync.db"
|
||
with sqlite3.connect(str(db_path)) as seed:
|
||
seed.execute("PRAGMA journal_mode=WAL")
|
||
|
||
monkeypatch.setattr(hermes_state.sys, "platform", "darwin")
|
||
|
||
conn = _TracingConn(str(db_path))
|
||
try:
|
||
result = apply_wal_with_fallback(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
assert result == "wal"
|
||
# The early-return path for existing WAL must also enforce synchronous=FULL.
|
||
assert any("synchronous=FULL" in sql for sql in conn.executed), (
|
||
"synchronous=FULL must be enforced even on existing WAL DBs"
|
||
)
|
||
assert not any("journal_mode=WAL" in sql for sql in conn.executed), (
|
||
"set-pragma must not run when already in WAL mode"
|
||
)
|
||
|
||
def test_apply_wal_concurrent_connects_no_eio(self, tmp_path):
|
||
"""20 threads calling connect() on the same DB must not see disk I/O error."""
|
||
import sys
|
||
import threading
|
||
import sqlite3
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
db_path = tmp_path / "concurrent.db"
|
||
errors = []
|
||
|
||
def _connect_cycle():
|
||
for _ in range(5):
|
||
try:
|
||
conn = sqlite3.connect(str(db_path))
|
||
apply_wal_with_fallback(conn)
|
||
conn.close()
|
||
except sqlite3.OperationalError as exc:
|
||
if "disk i/o error" in str(exc).lower():
|
||
errors.append(exc)
|
||
|
||
threads = [threading.Thread(target=_connect_cycle) for _ in range(20)]
|
||
for t in threads:
|
||
t.start()
|
||
for t in threads:
|
||
t.join()
|
||
|
||
assert not errors, f"disk I/O errors from concurrent connects: {errors}"
|
||
|
||
# Linux-only: no (deleted) WAL/SHM FDs should accumulate.
|
||
if sys.platform == "linux":
|
||
import os
|
||
|
||
fd_dir = f"/proc/{os.getpid()}/fd"
|
||
deleted_fds = []
|
||
for fd_name in os.listdir(fd_dir):
|
||
try:
|
||
target = os.readlink(os.path.join(fd_dir, fd_name))
|
||
if "(deleted)" in target and (
|
||
"wal" in target.lower() or "shm" in target.lower()
|
||
):
|
||
deleted_fds.append(target)
|
||
except OSError:
|
||
pass
|
||
assert not deleted_fds, f"stale deleted WAL/SHM FDs: {deleted_fds}"
|
||
|
||
def test_fallback_to_delete_still_works(self, tmp_path):
|
||
"""When set-pragma raises a WAL-incompat error, falls back to DELETE."""
|
||
import sqlite3
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
class _IncompatConn(sqlite3.Connection):
|
||
def __init__(self, *a, **kw):
|
||
super().__init__(*a, **kw)
|
||
self._call_count = 0
|
||
|
||
def execute(self, sql, params=()):
|
||
self._call_count += 1
|
||
# First call is the read probe; let it return "delete".
|
||
# Second call is the set-pragma; raise a WAL-incompat error.
|
||
if "journal_mode=WAL" in sql:
|
||
raise sqlite3.OperationalError("locking protocol")
|
||
return super().execute(sql, params)
|
||
|
||
db_path = tmp_path / "incompat.db"
|
||
conn = _IncompatConn(str(db_path))
|
||
try:
|
||
result = apply_wal_with_fallback(conn, db_label="test.db")
|
||
finally:
|
||
conn.close()
|
||
|
||
assert result == "delete"
|
||
|
||
def test_probe_failure_falls_through_to_set_pragma(self, tmp_path):
|
||
"""When the read probe raises OperationalError, fall through to set-pragma."""
|
||
import sqlite3
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
class _ProbeFails(sqlite3.Connection):
|
||
def __init__(self, *a, **kw):
|
||
super().__init__(*a, **kw)
|
||
self._first = True
|
||
|
||
def execute(self, sql, params=()):
|
||
if self._first and "journal_mode" in sql and "WAL" not in sql:
|
||
self._first = False
|
||
raise sqlite3.OperationalError("simulated probe failure")
|
||
return super().execute(sql, params)
|
||
|
||
db_path = tmp_path / "probe_fail.db"
|
||
conn = _ProbeFails(str(db_path))
|
||
try:
|
||
result = apply_wal_with_fallback(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
# Despite probe failure, set-pragma must still run and succeed.
|
||
assert result == "wal"
|
||
|
||
def test_no_downgrade_from_wal_to_delete_on_eio(self, tmp_path):
|
||
"""OperationalError NOT in _WAL_INCOMPAT_MARKERS must propagate, not downgrade."""
|
||
import sqlite3
|
||
import pytest
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
class _EIOConn(sqlite3.Connection):
|
||
def __init__(self, *a, **kw):
|
||
super().__init__(*a, **kw)
|
||
self._first = True
|
||
|
||
def execute(self, sql, params=()):
|
||
# Let the probe succeed (returns "delete" for fresh DB).
|
||
if "journal_mode=WAL" in sql:
|
||
raise sqlite3.OperationalError("some unexpected hardware failure")
|
||
return super().execute(sql, params)
|
||
|
||
db_path = tmp_path / "eio.db"
|
||
conn = _EIOConn(str(db_path))
|
||
try:
|
||
with pytest.raises(
|
||
sqlite3.OperationalError, match="some unexpected hardware failure"
|
||
):
|
||
apply_wal_with_fallback(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
def test_returns_wal_not_delete_from_probe(self, tmp_path):
|
||
"""Early-return only on 'wal'; 'delete' or 'memory' must fall through to set-pragma."""
|
||
import sqlite3
|
||
from hermes_state import apply_wal_with_fallback
|
||
|
||
class _TracingConn(sqlite3.Connection):
|
||
def __init__(self, *a, **kw):
|
||
super().__init__(*a, **kw)
|
||
self.executed = []
|
||
|
||
def execute(self, sql, params=()):
|
||
self.executed.append(sql)
|
||
return super().execute(sql, params)
|
||
|
||
# Fresh DB is in "delete" mode — probe returns "delete", must NOT early-return.
|
||
db_path = tmp_path / "delete_mode.db"
|
||
conn = _TracingConn(str(db_path))
|
||
try:
|
||
result = apply_wal_with_fallback(conn)
|
||
finally:
|
||
conn.close()
|
||
|
||
assert result == "wal"
|
||
assert any("journal_mode=WAL" in sql for sql in conn.executed), (
|
||
"set-pragma must fire when probe returns 'delete'"
|
||
)
|
||
|
||
|
||
class TestSessionArchive:
|
||
"""Soft-archiving hides a session from default listings without deleting it."""
|
||
|
||
def _seed(self, db, sid, *, archived=False):
|
||
db.create_session(session_id=sid, source="cli")
|
||
db.append_message(session_id=sid, role="user", content=f"hello from {sid}")
|
||
if archived:
|
||
db.set_session_archived(sid, True)
|
||
|
||
def test_set_session_archived_roundtrip(self, db):
|
||
self._seed(db, "s1")
|
||
assert db.set_session_archived("s1", True) is True
|
||
assert db.get_session("s1")["archived"] == 1
|
||
assert db.set_session_archived("s1", False) is True
|
||
assert db.get_session("s1")["archived"] == 0
|
||
|
||
def test_set_session_archived_missing_row(self, db):
|
||
assert db.set_session_archived("nope", True) is False
|
||
|
||
def test_archived_excluded_by_default(self, db):
|
||
self._seed(db, "live")
|
||
self._seed(db, "hidden", archived=True)
|
||
|
||
ids = [s["id"] for s in db.list_sessions_rich()]
|
||
assert ids == ["live"]
|
||
assert db.session_count() == 1
|
||
|
||
def test_archived_only_and_include(self, db):
|
||
self._seed(db, "live")
|
||
self._seed(db, "hidden", archived=True)
|
||
|
||
only = [s["id"] for s in db.list_sessions_rich(archived_only=True)]
|
||
assert only == ["hidden"]
|
||
assert db.session_count(archived_only=True) == 1
|
||
|
||
both = {s["id"] for s in db.list_sessions_rich(include_archived=True)}
|
||
assert both == {"live", "hidden"}
|
||
assert db.session_count(include_archived=True) == 2
|
||
|
||
|
||
|
||
class TestSessionIdSearch:
|
||
"""Session id search backs Desktop's Search Sessions UX."""
|
||
|
||
def _seed(self, db, sid, *, content="ordinary message", archived=False):
|
||
db.create_session(session_id=sid, source="cli", model="test-model")
|
||
db.append_message(session_id=sid, role="user", content=content)
|
||
if archived:
|
||
db.set_session_archived(sid, True)
|
||
|
||
def test_search_sessions_by_id_matches_exact_prefix_and_substring(self, db):
|
||
self._seed(db, "20260603_090200_abcd12", content="content without id")
|
||
self._seed(db, "20260602_111111_other99", content="other content")
|
||
|
||
assert [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12")] == [
|
||
"20260603_090200_abcd12"
|
||
]
|
||
assert [s["id"] for s in db.search_sessions_by_id("20260603")] == ["20260603_090200_abcd12"]
|
||
assert [s["id"] for s in db.search_sessions_by_id("ABCD12")] == ["20260603_090200_abcd12"]
|
||
|
||
def test_search_sessions_by_id_respects_limit_and_prioritizes_exact_matches(self, db):
|
||
self._seed(db, "20260603_090200_abcd12")
|
||
self._seed(db, "20260603_090200_abcd12_child")
|
||
self._seed(db, "x_20260603_090200_abcd12")
|
||
|
||
ids = [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12", limit=2)]
|
||
|
||
assert ids == ["20260603_090200_abcd12", "20260603_090200_abcd12_child"]
|
||
|
||
def test_search_sessions_by_id_can_include_or_exclude_archived(self, db):
|
||
self._seed(db, "20260603_090200_live")
|
||
self._seed(db, "20260603_090200_archived", archived=True)
|
||
|
||
included = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=True)}
|
||
excluded = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=False)}
|
||
|
||
assert included == {"20260603_090200_live", "20260603_090200_archived"}
|
||
assert excluded == {"20260603_090200_live"}
|
||
|
||
def test_search_sessions_by_id_matches_projected_lineage_root_id(self, db):
|
||
root = "20260602_235959_root99"
|
||
tip = "20260603_010000_tip01"
|
||
db.create_session(session_id=root, source="cli")
|
||
db.append_message(root, role="user", content="root conversation")
|
||
db.end_session(root, "compression")
|
||
db.create_session(session_id=tip, source="cli", parent_session_id=root)
|
||
db.append_message(tip, role="user", content="continued conversation")
|
||
|
||
matches = db.search_sessions_by_id("root99")
|
||
|
||
assert [s["id"] for s in matches] == [tip]
|
||
assert matches[0]["_lineage_root_id"] == root
|
||
|
||
|
||
class TestListCronJobRuns:
|
||
"""``list_cron_job_runs`` powers the desktop cron run-history endpoint.
|
||
|
||
It must scope to exactly one job's runs via an id prefix range (not a
|
||
substring), order newest-first, enrich with preview/last_active, and stay
|
||
bounded by the requested window rather than the whole cron history.
|
||
"""
|
||
|
||
def _seed_run(self, db, job_id: str, idx: int, started_at: float):
|
||
sid = f"cron_{job_id}_{idx:08d}"
|
||
db.create_session(session_id=sid, source="cron")
|
||
db.append_message(sid, role="user", content=f"run {idx} for {job_id}")
|
||
db.append_message(sid, role="assistant", content="done")
|
||
db.end_session(sid, "completed")
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at = ? WHERE id = ?", (started_at, sid)
|
||
)
|
||
db._conn.commit()
|
||
return sid
|
||
|
||
def test_scopes_to_job_newest_first_and_enriched(self, db):
|
||
base = 1_700_000_000.0
|
||
# Target job: 5 runs, ascending started_at.
|
||
for i in range(5):
|
||
self._seed_run(db, "alpha", i, base + i * 60)
|
||
# A different job that must not leak in.
|
||
for i in range(3):
|
||
self._seed_run(db, "beta", i, base + i * 60)
|
||
|
||
runs = db.list_cron_job_runs("alpha", limit=20)
|
||
|
||
assert len(runs) == 5
|
||
assert all(r["id"].startswith("cron_alpha_") for r in runs)
|
||
# Newest started_at first.
|
||
sts = [r["started_at"] for r in runs]
|
||
assert sts == sorted(sts, reverse=True)
|
||
# Enriched like list_sessions_rich.
|
||
assert runs[0]["preview"].startswith("run 4 for alpha")
|
||
assert runs[0]["last_active"] >= runs[0]["started_at"]
|
||
|
||
def test_prefix_match_excludes_substring_collision(self, db):
|
||
"""A job whose id contains the target id as a substring must not leak.
|
||
|
||
The old code used a leading-wildcard ``LIKE %cron_<id>_%`` which would
|
||
also match ``cron_xalpha_...``; the range scan binds to the true prefix.
|
||
"""
|
||
base = 1_700_000_000.0
|
||
self._seed_run(db, "alpha", 0, base)
|
||
# Collision: id is "xalpha", which contains "alpha".
|
||
self._seed_run(db, "xalpha", 0, base + 10)
|
||
# Collision the other way: id "alpha2" extends past the underscore.
|
||
self._seed_run(db, "alpha2", 0, base + 20)
|
||
|
||
runs = db.list_cron_job_runs("alpha", limit=20)
|
||
|
||
assert [r["id"] for r in runs] == ["cron_alpha_00000000"]
|
||
|
||
def test_ignores_non_cron_sessions(self, db):
|
||
base = 1_700_000_000.0
|
||
self._seed_run(db, "alpha", 0, base)
|
||
# A non-cron session whose id happens to share the prefix shape.
|
||
db.create_session(session_id="cron_alpha_99999999", source="cli")
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||
(base + 100, "cron_alpha_99999999"),
|
||
)
|
||
db._conn.commit()
|
||
|
||
runs = db.list_cron_job_runs("alpha", limit=20)
|
||
|
||
assert [r["id"] for r in runs] == ["cron_alpha_00000000"]
|
||
|
||
def test_limit_and_offset_paging(self, db):
|
||
base = 1_700_000_000.0
|
||
for i in range(10):
|
||
self._seed_run(db, "alpha", i, base + i * 60)
|
||
|
||
page1 = db.list_cron_job_runs("alpha", limit=4, offset=0)
|
||
page2 = db.list_cron_job_runs("alpha", limit=4, offset=4)
|
||
|
||
assert len(page1) == 4
|
||
assert len(page2) == 4
|
||
assert {r["id"] for r in page1}.isdisjoint({r["id"] for r in page2})
|
||
# Combined window is still newest-first and contiguous.
|
||
combined = [r["started_at"] for r in page1 + page2]
|
||
assert combined == sorted(combined, reverse=True)
|
||
|
||
def test_uses_index_range_scan(self, db):
|
||
"""The query must use the (source, id) index, not a full table scan."""
|
||
prefix = "cron_alpha_"
|
||
prefix_hi = prefix[:-1] + chr(ord(prefix[-1]) + 1)
|
||
plan = db._conn.execute(
|
||
"EXPLAIN QUERY PLAN "
|
||
"SELECT s.* FROM sessions s "
|
||
"WHERE s.source = 'cron' AND s.id >= ? AND s.id < ? "
|
||
"ORDER BY s.started_at DESC LIMIT 20",
|
||
(prefix, prefix_hi),
|
||
).fetchall()
|
||
detail = " ".join(row[-1] for row in plan)
|
||
assert "USING INDEX" in detail or "USING COVERING INDEX" in detail, detail
|
||
assert "idx_sessions_source" in detail, detail
|
||
|
||
|
||
def test_gateway_session_peer_round_trip_and_recovery(db):
|
||
db.create_session(
|
||
"gw-session",
|
||
"telegram",
|
||
user_id="user-1",
|
||
session_key="agent:main:telegram:dm:chat-1",
|
||
chat_id="chat-1",
|
||
chat_type="dm",
|
||
thread_id=None,
|
||
)
|
||
db.append_message("gw-session", "user", "hello")
|
||
|
||
row = db.get_session("gw-session")
|
||
assert row["session_key"] == "agent:main:telegram:dm:chat-1"
|
||
assert row["chat_id"] == "chat-1"
|
||
assert row["chat_type"] == "dm"
|
||
|
||
recovered = db.find_latest_gateway_session_for_peer(
|
||
source="telegram",
|
||
user_id="user-1",
|
||
session_key="agent:main:telegram:dm:chat-1",
|
||
chat_id="chat-1",
|
||
chat_type="dm",
|
||
)
|
||
assert recovered["id"] == "gw-session"
|
||
|
||
|
||
def test_gateway_session_recovery_reopens_ws_orphan_reap_rows(db):
|
||
"""Rows wrongly ended by the TUI ws-orphan reaper must be recoverable (#63207)."""
|
||
db.create_session(
|
||
"reaped-gw-session",
|
||
"telegram",
|
||
user_id="user-1",
|
||
session_key="agent:main:telegram:dm:chat-1",
|
||
chat_id="chat-1",
|
||
chat_type="dm",
|
||
)
|
||
db.append_message("reaped-gw-session", "user", "hello")
|
||
db.end_session("reaped-gw-session", "ws_orphan_reap")
|
||
|
||
recovered = db.find_latest_gateway_session_for_peer(
|
||
source="telegram",
|
||
user_id="user-1",
|
||
session_key="agent:main:telegram:dm:chat-1",
|
||
chat_id="chat-1",
|
||
chat_type="dm",
|
||
)
|
||
assert recovered["id"] == "reaped-gw-session"
|
||
|
||
db.reopen_session("reaped-gw-session")
|
||
row = db.get_session("reaped-gw-session")
|
||
assert row["ended_at"] is None
|
||
assert row["end_reason"] is None
|
||
|
||
|
||
def test_gateway_session_recovery_reopens_legacy_agent_close_rows(db):
|
||
db.create_session(
|
||
"closed-gw-session",
|
||
"telegram",
|
||
user_id="user-1",
|
||
session_key="agent:main:telegram:dm:chat-1",
|
||
chat_id="chat-1",
|
||
chat_type="dm",
|
||
)
|
||
db.append_message("closed-gw-session", "user", "hello")
|
||
db.end_session("closed-gw-session", "agent_close")
|
||
|
||
recovered = db.find_latest_gateway_session_for_peer(
|
||
source="telegram",
|
||
user_id="user-1",
|
||
session_key="agent:main:telegram:dm:chat-1",
|
||
chat_id="chat-1",
|
||
chat_type="dm",
|
||
)
|
||
assert recovered["id"] == "closed-gw-session"
|
||
|
||
db.end_session("closed-gw-session", "session_reset")
|
||
# First end reason wins, so force explicit reset state for this branch.
|
||
db._conn.execute(
|
||
"UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ?",
|
||
(time.time(), "session_reset", "closed-gw-session"),
|
||
)
|
||
db._conn.commit()
|
||
|
||
assert db.find_latest_gateway_session_for_peer(
|
||
source="telegram",
|
||
user_id="user-1",
|
||
session_key="agent:main:telegram:dm:chat-1",
|
||
chat_id="chat-1",
|
||
chat_type="dm",
|
||
) is None
|
||
|
||
|
||
def test_gateway_metadata_display_name_origin_round_trip(db):
|
||
"""record_gateway_session_peer persists display_name/origin_json (#9006)."""
|
||
db.create_session("gw-meta", "telegram", user_id="u1")
|
||
origin = {"platform": "telegram", "chat_id": "c1", "chat_name": "Alice", "chat_type": "dm"}
|
||
db.record_gateway_session_peer(
|
||
"gw-meta",
|
||
source="telegram",
|
||
user_id="u1",
|
||
session_key="agent:main:telegram:dm:c1",
|
||
chat_id="c1",
|
||
chat_type="dm",
|
||
thread_id=None,
|
||
display_name="Alice",
|
||
origin_json=json.dumps(origin),
|
||
)
|
||
row = db.get_session("gw-meta")
|
||
assert row["display_name"] == "Alice"
|
||
assert json.loads(row["origin_json"])["chat_name"] == "Alice"
|
||
|
||
# None values must not clobber existing metadata.
|
||
db.record_gateway_session_peer(
|
||
"gw-meta",
|
||
source="telegram",
|
||
user_id="u1",
|
||
session_key="agent:main:telegram:dm:c1",
|
||
chat_id="c1",
|
||
chat_type="dm",
|
||
)
|
||
row = db.get_session("gw-meta")
|
||
assert row["display_name"] == "Alice"
|
||
assert row["origin_json"] is not None
|
||
|
||
|
||
def test_set_expiry_finalized_round_trip(db):
|
||
db.create_session("gw-exp", "telegram", session_key="agent:main:telegram:dm:x")
|
||
row = db.get_session("gw-exp")
|
||
assert not row["expiry_finalized"]
|
||
db.set_expiry_finalized("gw-exp")
|
||
assert db.get_session("gw-exp")["expiry_finalized"] == 1
|
||
db.set_expiry_finalized("gw-exp", False)
|
||
assert db.get_session("gw-exp")["expiry_finalized"] == 0
|
||
|
||
|
||
def test_list_gateway_sessions_filters_and_dedupes(db):
|
||
# Two rows on the same session_key: only the newest should be returned.
|
||
db.create_session(
|
||
"gw-old", "telegram",
|
||
session_key="agent:main:telegram:dm:c1", chat_id="c1", chat_type="dm",
|
||
)
|
||
db._conn.execute(
|
||
"UPDATE sessions SET started_at = started_at - 100 WHERE id = 'gw-old'"
|
||
)
|
||
db._conn.commit()
|
||
db.create_session(
|
||
"gw-new", "telegram",
|
||
session_key="agent:main:telegram:dm:c1", chat_id="c1", chat_type="dm",
|
||
)
|
||
db.create_session(
|
||
"gw-discord", "discord",
|
||
session_key="agent:main:discord:group:g1:u1", chat_id="g1", chat_type="group",
|
||
)
|
||
# Non-gateway session (no session_key) must never appear.
|
||
db.create_session("cli-session", "cli")
|
||
# Ended gateway session excluded when active_only.
|
||
db.create_session(
|
||
"gw-ended", "slack",
|
||
session_key="agent:main:slack:dm:s1", chat_id="s1", chat_type="dm",
|
||
)
|
||
db.end_session("gw-ended", "session_reset")
|
||
|
||
rows = db.list_gateway_sessions(active_only=True)
|
||
ids = {r["id"] for r in rows}
|
||
assert ids == {"gw-new", "gw-discord"}
|
||
|
||
tg_rows = db.list_gateway_sessions(platform="telegram", active_only=True)
|
||
assert [r["id"] for r in tg_rows] == ["gw-new"]
|
||
|
||
all_rows = db.list_gateway_sessions(active_only=False)
|
||
assert "gw-ended" in {r["id"] for r in all_rows}
|
||
assert "cli-session" not in {r["id"] for r in all_rows}
|
||
|
||
|
||
def test_find_session_by_origin_matching_rules(db):
|
||
db.create_session(
|
||
"gw-o1", "telegram", user_id="u1",
|
||
session_key="agent:main:telegram:group:c9:u1", chat_id="c9", chat_type="group",
|
||
)
|
||
db.create_session(
|
||
"gw-o2", "telegram", user_id="u2",
|
||
session_key="agent:main:telegram:group:c9:u2", chat_id="c9", chat_type="group",
|
||
)
|
||
|
||
# Exact user match wins.
|
||
assert db.find_session_by_origin(
|
||
platform="telegram", chat_id="c9", user_id="u2"
|
||
) == "gw-o2"
|
||
# Unknown user among multiple distinct users -> None (no contamination).
|
||
assert db.find_session_by_origin(
|
||
platform="telegram", chat_id="c9", user_id="u3"
|
||
) is None
|
||
# No user given + multiple distinct users -> None.
|
||
assert db.find_session_by_origin(platform="telegram", chat_id="c9") is None
|
||
# Ended sessions are ignored: only gw-o1 remains as a live candidate.
|
||
# A single remaining candidate is returned even without an exact user
|
||
# match — mirrors the original sessions.json scan semantics.
|
||
db.end_session("gw-o2", "session_reset")
|
||
assert db.find_session_by_origin(
|
||
platform="telegram", chat_id="c9", user_id="u2"
|
||
) == "gw-o1"
|
||
# Single remaining candidate resolves without user_id.
|
||
assert db.find_session_by_origin(platform="telegram", chat_id="c9") == "gw-o1"
|
||
# Thread filter.
|
||
db.create_session(
|
||
"gw-th", "discord", user_id="u9",
|
||
session_key="agent:main:discord:thread:t7", chat_id="ch7",
|
||
chat_type="thread", thread_id="t7",
|
||
)
|
||
assert db.find_session_by_origin(
|
||
platform="discord", chat_id="ch7", thread_id="t7"
|
||
) == "gw-th"
|
||
assert db.find_session_by_origin(
|
||
platform="discord", chat_id="ch7", thread_id="other"
|
||
) is None
|
||
|
||
|
||
def test_v18_backfill_from_sessions_json(tmp_path, monkeypatch):
|
||
"""Migration backfills display_name/origin_json/expiry_finalized from sessions.json."""
|
||
import hermes_state as hs
|
||
|
||
home = tmp_path / ".hermes"
|
||
(home / "sessions").mkdir(parents=True)
|
||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||
monkeypatch.setattr(hs, "DEFAULT_DB_PATH", home / "state.db")
|
||
|
||
# Seed a pre-v18 database: create schema, downgrade version, add a bare row.
|
||
db = hs.SessionDB(home / "state.db")
|
||
db.create_session("legacy-gw", "telegram", user_id="u1")
|
||
db._conn.execute("UPDATE schema_version SET version = 17")
|
||
db._conn.execute(
|
||
"UPDATE sessions SET session_key = NULL, display_name = NULL, "
|
||
"origin_json = NULL WHERE id = 'legacy-gw'"
|
||
)
|
||
db._conn.commit()
|
||
db.close()
|
||
|
||
origin = {"platform": "telegram", "chat_id": "123", "chat_name": "Alice",
|
||
"chat_type": "dm", "user_id": "u1"}
|
||
(home / "sessions" / "sessions.json").write_text(json.dumps({
|
||
"_README": "sentinel",
|
||
"agent:main:telegram:dm:123": {
|
||
"session_id": "legacy-gw",
|
||
"display_name": "Alice",
|
||
"chat_type": "dm",
|
||
"expiry_finalized": True,
|
||
"origin": origin,
|
||
},
|
||
}))
|
||
|
||
db = hs.SessionDB(home / "state.db")
|
||
row = db.get_session("legacy-gw")
|
||
db.close()
|
||
assert row["session_key"] == "agent:main:telegram:dm:123"
|
||
assert row["display_name"] == "Alice"
|
||
assert row["chat_id"] == "123"
|
||
assert json.loads(row["origin_json"])["chat_name"] == "Alice"
|
||
assert row["expiry_finalized"] == 1
|
||
|
||
|
||
def test_compression_failure_cooldown_round_trips_and_clears(db):
|
||
db.create_session("s1", "cli")
|
||
|
||
cooldown_until = time.time() + 60.0
|
||
db.record_compression_failure_cooldown("s1", cooldown_until, "timeout")
|
||
|
||
state = db.get_compression_failure_cooldown("s1")
|
||
assert state is not None
|
||
assert state["cooldown_until"] == cooldown_until
|
||
assert state["error"] == "timeout"
|
||
|
||
db.clear_compression_failure_cooldown("s1")
|
||
assert db.get_compression_failure_cooldown("s1") is None
|
||
|
||
row = db.get_session("s1")
|
||
assert row["compression_failure_cooldown_until"] is None
|
||
assert row["compression_failure_error"] is None
|
||
|
||
|
||
def test_expired_compression_failure_cooldown_is_ignored(db):
|
||
db.create_session("s1", "cli")
|
||
|
||
db.record_compression_failure_cooldown("s1", time.time() - 60.0, "stale")
|
||
|
||
assert db.get_compression_failure_cooldown("s1") is None
|
||
|
||
|
||
def test_compression_fallback_streak_round_trips(db):
|
||
db.create_session("s1", "cli")
|
||
|
||
assert db.get_compression_fallback_streak("s1") == 0
|
||
db.set_compression_fallback_streak("s1", 2)
|
||
assert db.get_compression_fallback_streak("s1") == 2
|
||
|
||
|
||
def test_refresh_compression_lock_requires_holder_and_preserves_reclaimability(db, monkeypatch):
|
||
db.create_session("s1", "cli")
|
||
|
||
monkeypatch.setattr(hermes_state.time, "time", lambda: 1000.0)
|
||
assert db.try_acquire_compression_lock("s1", "holder-a", ttl_seconds=10.0) is True
|
||
|
||
original_expires = db._conn.execute(
|
||
"SELECT expires_at FROM compression_locks WHERE session_id = ?",
|
||
("s1",),
|
||
).fetchone()[0]
|
||
|
||
monkeypatch.setattr(hermes_state.time, "time", lambda: 1005.0)
|
||
assert db.refresh_compression_lock("s1", "holder-a", ttl_seconds=10.0) is True
|
||
refreshed_expires = db._conn.execute(
|
||
"SELECT expires_at FROM compression_locks WHERE session_id = ?",
|
||
("s1",),
|
||
).fetchone()[0]
|
||
assert refreshed_expires > original_expires
|
||
|
||
assert db.refresh_compression_lock("s1", "holder-b", ttl_seconds=10.0) is False
|
||
|
||
monkeypatch.setattr(hermes_state.time, "time", lambda: 1016.0)
|
||
assert db.try_acquire_compression_lock("s1", "holder-b", ttl_seconds=10.0) is True
|
||
|
||
|
||
# =========================================================================
|
||
# compact_rows — lightweight column projection (issue #47414)
|
||
# =========================================================================
|
||
|
||
class TestCompactRows:
|
||
"""list_sessions_rich and _get_session_rich_row with compact_rows=True
|
||
must omit system_prompt but return all other metadata fields."""
|
||
|
||
def _create(self, db, sid, *, system_prompt="big blob " * 500):
|
||
db.create_session(session_id=sid, source="cli", model="m")
|
||
db.update_system_prompt(sid, system_prompt)
|
||
return sid
|
||
|
||
def test_compact_rows_omits_system_prompt(self, db):
|
||
self._create(db, "s1")
|
||
rows = db.list_sessions_rich(compact_rows=True)
|
||
assert len(rows) == 1
|
||
assert "system_prompt" not in rows[0]
|
||
|
||
def test_full_rows_include_system_prompt(self, db):
|
||
self._create(db, "s1", system_prompt="keep me")
|
||
rows = db.list_sessions_rich(compact_rows=False)
|
||
assert rows[0]["system_prompt"] == "keep me"
|
||
|
||
def test_compact_rows_preserves_metadata_fields(self, db):
|
||
self._create(db, "s1")
|
||
rows = db.list_sessions_rich(compact_rows=True)
|
||
row = rows[0]
|
||
for field in ("id", "source", "model", "started_at", "message_count",
|
||
"input_tokens", "output_tokens", "title", "cwd",
|
||
"archived", "preview", "last_active"):
|
||
assert field in row, f"missing field: {field}"
|
||
|
||
def test_compact_rows_order_by_last_active(self, db):
|
||
"""compact_rows=True also works with the CTE / order_by_last_active path."""
|
||
self._create(db, "s1")
|
||
self._create(db, "s2")
|
||
rows = db.list_sessions_rich(compact_rows=True, order_by_last_active=True)
|
||
assert len(rows) == 2
|
||
assert all("system_prompt" not in r for r in rows)
|
||
|
||
def test_get_session_rich_row_compact_omits_system_prompt(self, db):
|
||
self._create(db, "s1", system_prompt="should be gone")
|
||
row = db._get_session_rich_row("s1", compact_rows=True)
|
||
assert row is not None
|
||
assert "system_prompt" not in row
|
||
assert row["id"] == "s1"
|
||
|
||
def test_get_session_rich_row_full_includes_system_prompt(self, db):
|
||
self._create(db, "s1", system_prompt="stay")
|
||
row = db._get_session_rich_row("s1", compact_rows=False)
|
||
assert row["system_prompt"] == "stay"
|
||
|
||
def test_compact_rows_default_is_false(self, db):
|
||
"""Default behaviour (compact_rows not passed) is unchanged — full rows."""
|
||
self._create(db, "s1", system_prompt="present")
|
||
rows = db.list_sessions_rich()
|
||
assert "system_prompt" in rows[0]
|
||
|
||
def test_compact_projection_tracks_schema(self, db):
|
||
"""Behavior contract: compact rows carry EVERY sessions column except
|
||
the excluded blob — including gateway/desktop fields (git_branch,
|
||
session_key) and any column added later via declarative
|
||
reconciliation. Guards against a hardcoded column list going stale."""
|
||
self._create(db, "s1")
|
||
live_cols = {
|
||
row[1] for row in db._conn.execute("PRAGMA table_info(sessions)")
|
||
}
|
||
row = db.list_sessions_rich(compact_rows=True)[0]
|
||
# Hardcode the one sanctioned exclusion: if the excluded set ever
|
||
# widens (or the projection silently drops a column), this fails and
|
||
# forces a conscious review of what list consumers lose.
|
||
missing = live_cols - set(row) - {"system_prompt"}
|
||
assert not missing, f"compact projection lost schema columns: {missing}"
|
||
assert "system_prompt" not in row
|
||
|
||
def test_compact_rows_tip_projection_omits_system_prompt(self, db):
|
||
"""Compression-tip projection must not reintroduce the blob: the
|
||
merged tip row is fetched with the same compact_rows flag (salvage
|
||
follow-up for #47437)."""
|
||
import time as _time
|
||
t0 = _time.time() - 3600
|
||
db.create_session("root", "cli")
|
||
db.update_system_prompt("root", "root blob " * 200)
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root"))
|
||
db._conn.execute(
|
||
"UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?",
|
||
(t0 + 100, "root"),
|
||
)
|
||
db.create_session("tip", "cli", parent_session_id="root")
|
||
db.update_system_prompt("tip", "tip blob " * 200)
|
||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 200, "tip"))
|
||
db._conn.commit()
|
||
|
||
rows = db.list_sessions_rich(compact_rows=True)
|
||
projected = [r for r in rows if r.get("_lineage_root_id") == "root"]
|
||
assert projected, "compression root should be projected to its tip"
|
||
assert all("system_prompt" not in r for r in rows)
|
||
|
||
|
||
# =========================================================================
|
||
# get_messages pagination (salvage follow-up for #60347)
|
||
# =========================================================================
|
||
|
||
class TestGetMessagesPagination:
|
||
"""get_messages(limit=, offset=) pages in insertion order; the default
|
||
(limit=None) returns the full transcript unchanged."""
|
||
|
||
def _seed(self, db, n=10):
|
||
db.create_session(session_id="s1", source="cli")
|
||
for i in range(n):
|
||
db.append_message("s1", "user" if i % 2 == 0 else "assistant", f"msg-{i}")
|
||
|
||
def test_default_returns_all_messages(self, db):
|
||
self._seed(db)
|
||
messages = db.get_messages("s1")
|
||
assert [m["content"] for m in messages] == [f"msg-{i}" for i in range(10)]
|
||
|
||
def test_limit_pages_in_insertion_order(self, db):
|
||
self._seed(db)
|
||
page1 = db.get_messages("s1", limit=4, offset=0)
|
||
page2 = db.get_messages("s1", limit=4, offset=4)
|
||
page3 = db.get_messages("s1", limit=4, offset=8)
|
||
assert [m["content"] for m in page1] == ["msg-0", "msg-1", "msg-2", "msg-3"]
|
||
assert [m["content"] for m in page2] == ["msg-4", "msg-5", "msg-6", "msg-7"]
|
||
assert [m["content"] for m in page3] == ["msg-8", "msg-9"]
|
||
|
||
def test_offset_past_end_returns_empty(self, db):
|
||
self._seed(db, n=3)
|
||
assert db.get_messages("s1", limit=5, offset=10) == []
|
||
|
||
def test_pagination_respects_active_flag(self, db):
|
||
"""Soft-deleted (inactive) rows must not consume page slots."""
|
||
self._seed(db, n=6)
|
||
# Soft-delete the first two rows the way rewind does.
|
||
db._conn.execute(
|
||
"UPDATE messages SET active = 0 WHERE session_id = 's1' "
|
||
"AND id IN (SELECT id FROM messages WHERE session_id = 's1' ORDER BY id LIMIT 2)"
|
||
)
|
||
db._conn.commit()
|
||
page = db.get_messages("s1", limit=3, offset=0)
|
||
assert [m["content"] for m in page] == ["msg-2", "msg-3", "msg-4"]
|
||
|
||
def test_offset_without_limit_pages(self, db):
|
||
"""offset alone must not be silently ignored (review finding):
|
||
SQLite needs LIMIT for OFFSET, emitted as LIMIT -1."""
|
||
self._seed(db, n=5)
|
||
rows = db.get_messages("s1", offset=3)
|
||
assert [m["content"] for m in rows] == ["msg-3", "msg-4"]
|
||
|
||
|
||
# =========================================================================
|
||
# Lone-surrogate persistence
|
||
# =========================================================================
|
||
|
||
class TestLoneSurrogatePersistence:
|
||
"""sqlite3 encodes bound str params as UTF-8 and raises UnicodeEncodeError
|
||
on lone surrogates (U+D800..U+DFFF). Tool results scraped from the web can
|
||
carry them, so a single such code point aborted the whole message write —
|
||
and because run_agent swallows the failure with a warning, the session then
|
||
silently stopped persisting for the rest of its life.
|
||
"""
|
||
|
||
DIRTY = "scraped \ud835 price"
|
||
|
||
def test_append_message_survives_lone_surrogate_content(self, db):
|
||
db.create_session("s1", source="cli")
|
||
db.append_message("s1", "assistant", "hello world")
|
||
db.append_message("s1", "tool", self.DIRTY, tool_name="web_search")
|
||
|
||
rows = db.get_messages("s1")
|
||
assert len(rows) == 2
|
||
# Surrogate replaced with U+FFFD; the surrounding text is intact.
|
||
assert rows[1]["content"] == "scraped <20> price"
|
||
|
||
def test_append_message_survives_lone_surrogate_reasoning(self, db):
|
||
db.create_session("s1", source="cli")
|
||
db.append_message("s1", "assistant", "fine", reasoning=self.DIRTY)
|
||
assert len(db.get_messages("s1")) == 1
|
||
|
||
def test_replace_messages_keeps_persisting_after_dirty_row(self, db):
|
||
"""The regression that mattered: one poisoned row froze the session.
|
||
|
||
replace_messages re-sends the full history each turn, so once a dirty
|
||
tool result entered it, every later save raised and nothing after it
|
||
was ever written.
|
||
"""
|
||
db.create_session("s1", source="cli")
|
||
history = [
|
||
{"role": "user", "content": "turn 1"},
|
||
{"role": "assistant", "content": "answer 1"},
|
||
{"role": "tool", "content": self.DIRTY, "tool_name": "web_search"},
|
||
{"role": "assistant", "content": "answer 2"},
|
||
]
|
||
db.replace_messages("s1", history)
|
||
assert len(db.get_messages("s1")) == 4
|
||
|
||
# Later turns still persist rather than freezing at the poisoned row.
|
||
history += [
|
||
{"role": "user", "content": "turn 3"},
|
||
{"role": "assistant", "content": "answer 3"},
|
||
]
|
||
db.replace_messages("s1", history)
|
||
rows = db.get_messages("s1")
|
||
assert len(rows) == 6
|
||
assert rows[-1]["content"] == "answer 3"
|
||
|
||
def test_well_formed_unicode_is_unchanged(self, db):
|
||
"""Accents, CJK and emoji must round-trip byte-identically."""
|
||
db.create_session("s1", source="cli")
|
||
benign = "Ünïcödé ok — 日本語 🎉 emoji fine"
|
||
db.append_message("s1", "assistant", benign)
|
||
assert db.get_messages("s1")[0]["content"] == benign
|
||
|
||
# -- sibling raw-str bind sites (follow-up widening of the same bug class)
|
||
|
||
def test_append_message_survives_lone_surrogate_api_content(self, db):
|
||
db.create_session("s1", source="cli")
|
||
db.append_message("s1", "user", "clean", api_content=self.DIRTY)
|
||
assert db.get_messages("s1")[0]["api_content"] == "scraped \ufffd price"
|
||
|
||
def test_append_message_survives_lone_surrogate_tool_name(self, db):
|
||
db.create_session("s1", source="cli")
|
||
db.append_message("s1", "tool", "ok", tool_name="web\ud835search")
|
||
assert len(db.get_messages("s1")) == 1
|
||
|
||
def test_replace_messages_survives_lone_surrogate_api_content(self, db):
|
||
db.create_session("s1", source="cli")
|
||
db.replace_messages(
|
||
"s1", [{"role": "user", "content": "u1", "api_content": self.DIRTY}]
|
||
)
|
||
assert db.get_messages("s1")[0]["api_content"] == "scraped \ufffd price"
|
||
|
||
def test_set_latest_user_api_content_survives_lone_surrogate(self, db):
|
||
db.create_session("s1", source="cli")
|
||
db.append_message("s1", "user", "turn text")
|
||
assert db.set_latest_user_api_content("s1", "turn text", self.DIRTY) == 1
|
||
|
||
def test_session_title_survives_lone_surrogate(self, db):
|
||
db.create_session("s1", source="cli")
|
||
assert db.set_session_title("s1", "title \ud835 bad") is True
|
||
assert db.get_session("s1")["title"] == "title \ufffd bad"
|
||
|