hermes-agent/tests/test_hermes_state.py
Teknium 9acc4b47f5
perf(state): external-content FTS + tool-row-free trigram index (schema v23) (#65798)
* 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 commit 3a9b9d65d5.

* 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 of f842733 (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 commit a6807170f1)
(cherry picked from commit 6fb4e9aa8a)

* 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 commit 1a567f7067 in #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 commit 18b474d0bd)

* 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 in
db495b0fba when 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 off

fdab380a1 wraps 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>
2026-07-22 04:42:50 -07:00

6765 lines
286 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"