fix(state): adopt upstream v21 FTS + stop per-turn message duplication (+ upstream merge) - #117
Conversation
…ays, tab primitives
…ers, keybind helpers
…ll; views as contributions
The Thinking disclosure rendered blank for every reasoning-emitting model (Fable, DeepSeek, GPT-5.5, ...). Two causes: 1. ReasoningTextPart read a `text` prop that assistant-ui never populates — reasoning parts arrive via context, same as text parts — so it always got an empty string. Read the text via useMessagePartReasoning() instead, mirroring how MarkdownText uses useMessagePartText(). 2. The reasoning-only SmoothStreamingText / useSmoothReveal layer stalled at revealed="": the reasoning part stays isRunning for the whole message while the answer streams and thrashes re-renders, so the char-reveal never advanced past 0. Render reasoning through the same DeferStreamingText → surface path the assistant answer uses, and drop the dead smoothing code.
run_job() constructs SessionDB() synchronously with no timeout of its own, unlike the agent's run_conversation call further down, which is already bounded by HERMES_CRON_TIMEOUT. A wedged sqlite3.connect (e.g. a stale flock from a crashed sibling process) hangs this call indefinitely. That hang is invisible to every existing cron safeguard because it happens before _submit_with_guard's future exists: the finally block that discards the job ID from _running_job_ids never runs. The job stays wedged "running" — every later tick logs "already running — skipping" — until the whole gateway process is restarted. Observed in production: a cron job's worker thread was confirmed via a live py-spy thread dump to be parked inside SessionDB.__init__'s sqlite3.connect for 3+ days, silently skipping every scheduled fire in between across a gateway process that otherwise stayed healthy. Bound the SessionDB() construction with its own timeout (HERMES_CRON_SESSION_DB_TIMEOUT, default 10s), following the same bounded-thread-pool pattern already used elsewhere in this file (the delivery retry path, and the agent inactivity watchdog just below). On timeout, log at ERROR and proceed with session_db=None instead of degrading silently to debug level, since an actual hang here is a new condition worth surfacing. Adds tests/cron/test_sessiondb_init_hang.py, including an end-to-end regression proving the dispatch guard is released and a subsequent tick can fire the same job again after a simulated hang.
Salvage of NousResearch#63935. The original fix read HERMES_CRON_SESSION_DB_TIMEOUT from a bare env var, but AGENTS.md requires non-secret behavioral settings to live in config.yaml with an env var bridge only for backward compatibility. Changes: - Add cron.session_db_timeout_seconds to DEFAULT_CONFIG (default 10s) - Resolution order: HERMES_CRON_SESSION_DB_TIMEOUT env override → cron.session_db_timeout_seconds in config.yaml → 10s default (mirrors the existing script_timeout_seconds pattern) - 0 = unlimited (opt-in for debugging, skips the bound) - Strengthen test: assert the warning is logged on invalid env value (caplog was taken but never asserted) - Add test: verify config.yaml resolution path works end-to-end Co-authored-by: LoicHmh <26006141+LoicHmh@users.noreply.github.com>
Retain a distinct CLI history baseline during the signal window before a turn's normal persistence flush. When CLI history aliases the live agent list, use marker-only persistence so a genuinely unflushed tail is written.
Preserve one durable staged input across terminal close and the worker's early turn flush, without duplicating resumed transcripts or creating a session with a null prompt. Fixes NousResearch#63766.
STANDARD_PROFILES, normalize_profile, validate_profile_name, and
is_standard_profile in hermes_constants were superseded by
hermes_cli.profiles.{normalize_profile_name, validate_profile_name}
but never removed. profile_routing.py is updated to import from the
canonical location; the old helpers are deleted.
Lazy import inside parse_profile_routes avoids the circular dependency
at module load time (hermes_constants -> hermes_cli -> hermes_constants).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
_adapter_credential_fingerprint only looked at adapter.token directly, but Discord (and similar) adapters store the bot token on their config sub-object, not on self. Every Discord adapter in a multiplexed gateway therefore returned None, the same-token conflict check was silently skipped, and N adapters all polled the same bot token — producing a per-message race where whichever adapter won the GIL answered the user. Adds a config-token fallback (token, then bot_token) so the check actually fires for config-backed adapters. Direct adapter.token still takes precedence when both exist. Tests cover: config-backed token produces a fingerprint, distinct tokens produce distinct fingerprints, direct token wins over config, config without token attributes returns None. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
load_gateway_config only forwarded the top-level multiplex_profiles key, ignoring the nested gateway.multiplex_profiles form. The latter is what `hermes config set gateway.multiplex_profiles true` writes, so users who ran that command got multiplex_profiles=False silently — no warning, no fallback, profile_routes just stopped matching. Loader now checks the top-level key first, falls back to the nested gateway section, and only then defaults to False. Same precedence is applied to other nested-form keys (profile_routes already did this). Tests cover: top-level honored, nested honored (regression test for the silent-fallback bug), default False, top-level overrides nested. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two follow-ups observed after deploying profile routing:
1. sessions.profile_name was NULL even when the agent ran inside the
routed profile scope. _insert_session_row never wrote it,
get_or_create_session / reset_session never passed it through, and
the agent-side _ensure_db_session fallback had no way to read it.
- Declare profile_name TEXT in SCHEMA_SQL so _reconcile_columns
auto-adds it on existing DBs.
- _insert_session_row takes profile_name and writes it.
- SessionStore passes source.profile (or old_entry.origin.profile
on reset) into db_create_kwargs.
- _ensure_db_session reads the active profile via
get_active_profile_name() inside _profile_runtime_scope.
2. DiscordAdapter._text_batch_key called build_session_key without
profile=, so the batch key always landed in agent:main even when
the routed profile differed — diverging from the agent session
key namespace (agent:crypto-trader, agent:ai-expert, ...).
Pass event.source.profile through so both namespaces agree.
Live verification (jth-server-2, 2026-06-28): a test message in a
routed #coin thread produced agent:crypto-trader:discord:thread:...
in the batch log and profile_name=crypto-trader in the sessions
row. Default-routed chat still produced agent:main / NULL.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…way_runner Addresses hermes-sweeper review on NousResearch#20096. Problem 1 (profile_routing.py): route matching returned True on a chat_id hit before the guild_id constraint was consulted, so a route declaring both guild_id and chat_id matched on chat_id alone. Restored conjunctive (AND) semantics — every declared discriminator must hold; hierarchical parent_chat_id matching is preserved. Added a regression test for the guild+chat case. Problem 2 (base.py / run.py): gateway_runner was injected only when an adapter pre-declared the attribute, and only Discord did — so build_source never called _profile_name_for_source for Telegram/Feishu/Slack/etc., despite the platform-generic claim. Declared gateway_runner on BasePlatformAdapter and made the plugin-registry injection unconditional, so profile routing now reaches every platform. Added non-Discord (Telegram) resolution coverage and an injection-inheritance test. Also adds docs/profile-routing.md documenting gateway.profile_routes (matching rules, specificity, profile isolation) — requested in review. Co-Authored-By: Claude <noreply@anthropic.com>
Completes the review's ask for "adapter-to-session-key integration coverage for Discord and a non-Discord platform" on NousResearch#20096. Drives a concrete adapter's real BasePlatformAdapter.build_source with an injected gateway_runner, asserts the matched route's profile is stamped on the source, and that build_session_key scopes the key under agent:<profile>: (versus the shared agent:main: namespace). Covers Discord and Telegram — the Telegram case is the bug-#2 path that previously fell through to default. Adds a regression anchor: without gateway_runner, profile stays None and the key lands in agent:main (the silent fallback the fix removes for non-Discord). Co-Authored-By: Claude <noreply@anthropic.com>
…h-key routing to all adapters Follow-ups on the salvaged NousResearch#20096 profile-routing feature: - _profile_name_for_source now returns None unless gateway.multiplex_profiles is on. Routing stamps source.profile, which namespaces session/batch keys, but the profile-scoped agent run only activates under multiplexing — without the gate, configured routes with multiplexing off split batch/session keys into agent:<profile> while the agent still ran from agent:main. - Widen the profile-aware _text_batch_key fix from Discord to every adapter that builds batch keys via build_session_key (telegram, whatsapp, matrix, feishu, wecom, weixin) — routing is platform-generic, so the batch-key namespace fix must be too. - Downgrade the no-route-matched log from INFO to DEBUG (fired on every unrouted inbound message). - GatewayConfig.to_dict(): serialize profile_routes as plain dicts (ProfileRoute dataclasses are not JSON-safe). - Docs: correct the 'independent of multiplexing' claim in docs/profile-routing.md (routing requires multiplexing), fix the platform-only specificity row (0, not 1), and document profile_routes in website/docs/user-guide/multi-profile-gateways.md. - Tests: pin the multiplex gate (routes ignored when off, active when on, build_source end-to-end stays in agent:main when off).
…d slots Three fixes in model-settings.tsx: 1. Filter incomplete slots before autosave: sanitizeMoaRefsForSave() strips reference slots with empty model before any autosave (the 600ms debounce was sending half-filled provider-but-no-model slots to the backend, where _clean_slot rejects them and _normalize_preset falls back to hardcoded defaults). Presets with zero valid refs keep their empty reference_models array rather than being silently dropped. The aggregator slot is also sanitized when its model is empty. 2. Add withActive() to MoA provider dropdowns: the reference and aggregator provider Selects filtered to authenticated-only, so unauthenticated current values (e.g. openai-codex) rendered blank. Mirror the existing pattern from the model Select. 3. Add generation counter to scheduleMoaSave(): stale save responses could overwrite newer state. Bump a counter on each save and skip setMoa/setError if a newer save was scheduled in the meantime.
…esktop autosave until slots are complete Follow-up hardening on top of NousResearch#64158 (@DavidMetcalfe): Backend (the root-cause fix): - hermes_cli/moa_config.py: add validate_moa_payload() — strict write-time counterpart to the deliberately tolerant normalize_moa_config(). Flags half-filled slots, empty reference lists, recursive moa slots, naming the exact preset/slot. - hermes_cli/web_server.py: PUT /api/model/moa validates before normalizing and returns 422 with the specific problems instead of silently swapping the user's preset for hardcoded defaults (NousResearch#64156). Also declares fanout / reference_max_tokens / reasoning_effort on the Pydantic payload so client round-trips no longer erase hand-set values. Desktop: - Replace sanitize-then-send with hold-while-incomplete: the debounced autosave is deferred (not repaired) while any slot is half-filled, and flushes once the model pick completes the edit. Mid-edit UI state is never repainted by a save response (generation guard covers held edits too). - updateMoaSlot only clears the model when the provider actually changed. - Explicit preset ops (set default / add / delete) cancel the pending autosave and invalidate in-flight responses so the two writers can't race. - Stable row keys (preset+index) so mid-edit rows don't remount; cleared model shows the 'Model' placeholder instead of vanishing. Both TS clients' MoaConfigResponse types now declare the round-tripped fields (fanout, reference_max_tokens, reasoning_effort). Tests: 12 new backend unit tests (validate_moa_payload contract incl. validate/normalize agreement), 3 new web_server endpoint tests (422 on half-filled ref/aggregator, fanout round-trip), 3 new desktop vitest cases (autosave held while half-filled, flush on completion, same-provider reselect no-op). E2E validated against a live TestClient with isolated HERMES_HOME: bug sequence now 422s with config untouched. Fixes NousResearch#64156
…x only) (NousResearch#65103) The docs search theme (@easyops-cn/docusaurus-search-local) defaults fuzzyMatchingDistance to 1, so every term also matched words one edit away. Two user-visible failures on the 14.4 MB production index: - Wrong results: 'keet' returned 'Microsoft Teams Meetings', 'google_meet', 'Keep the Model Loaded' etc. — 'meet' and 'keep' are both one edit from 'keet', and the stemmer indexes 'meetings' as 'meet'. - Search appearing to die: fuzzy matching multiplies the generated lunr queries (distance matrix x maybe-typing variants x leave-one-out terms — up to 210 queries per keystroke on multi-word input), and fuzzy REQUIRED terms are the expensive scan kind. A typo'd 3-word query stalled the single-threaded search Web Worker for 25+ seconds; every later keystroke's search queued behind it, so the bar stopped returning results. Setting fuzzyMatchingDistance: 0 keeps exact-word-or-prefix semantics (keet -> keet*), which is the behavior users asked for. Validated by running the plugin's shipped smartQueries/tokenize code against the downloaded production search-index.json: legitimate queries (cron, telegram, prefix 'memor') return identical results; worst-case typo queries drop from 210 queries / 365-532ms per keystroke to 50-105 / 53-188ms; false 'keet' matches gone.
…e + bearer token routing Three fixes for the Bedrock Claude path: 1. Streaming fallback: When AnthropicBedrock SDK raises 'Unexpected event order' (SDK misparses Bedrock error events as message_start), auto-switch to native Converse API for the rest of the session instead of failing after 3 retries. 2. Image base64 decode (NousResearch#33317): data URL payloads were passed as base64 strings to source.bytes, but boto3 re-encodes at the wire layer. Now decoded to raw bytes before passing to Converse API. 3. Bearer token routing (NousResearch#28156): Users with AWS_BEARER_TOKEN_BEDROCK are now routed through Converse API regardless of model, since the AnthropicBedrock SDK only supports SigV4 signing. 3 new tests. 121 bedrock_adapter tests passing.
Rewrites the cherry-picked test to import os locally and monkeypatch the resolver seams instead of patching bedrock_adapter internals; adds the inverse assertion (no bearer -> AnthropicBedrock SDK path preserved).
…ousResearch#28156) Bug 2 of NousResearch#28156: the picker offered us./global. inference profiles to EU-region endpoints (unroutable — AWS rejects them regardless of credentials) and _RECOMMENDED hardcoded us.anthropic.* ids, so non-US pickers pinned profiles their endpoint can't invoke. - bedrock_model_routable_from_region(): geo-prefixed profiles are only offered in their own geography (full AWS prefix set incl. apac./jp./ ca./sa./me./af.); bare ids and global.* pass everywhere; unknown region shapes hide nothing. - Recommendations match geo-agnostically on the base model id, so an EU picker pins eu.anthropic.claude-sonnet-4-6; in-region geo profiles sort above global.* for the same model (addresses the global.*-first ordering complaint from the issue thread). - Dedup generalized from (us., global.) to all profile prefixes.
# Conflicts: # apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
feat(desktop): contribution-driven shell on a layout-tree model
… FTS + WAL watchdog hermes_state.py had diverged from upstream (local #110: v20 view-backed external-content FTS + WAL watchdog + trigram gate; #114: trigram LIKE fallback), while the live DB had already migrated to upstream's v21 inline FTS — leaving code(20) vs DB(21) inconsistent and every upstream pull conflicting on this file. Reset hermes_state.py to upstream/main verbatim (SCHEMA_VERSION 20->21, matches the DB) and remove the two gateway/run.py wal_watchdog call sites (#110) that referenced the now-removed method. Verified against a copy of the live DB: opens at v21 with no destructive re-migration, FTS search + append work, the orphaned messages_search_v view is harmless. Upstream's every-50-writes wal_checkpoint(TRUNCATE) remains as WAL bounding. Trade-off: keeps the ~0.9GB inline FTS bloat (upstream NousResearch#22478 still open) and drops the WAL watchdog; both acceptable now that the duplication write-storm is fixed. Goal: zero divergence in hermes_state.py so upstream pulls stop breaking. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsbWmnp8Q5o8oupgYAsjaE
When an agent turn finishes while the user is viewing a different session, the sidebar now shows a steady green dot on that session — distinct from the blue pulsing dot of a running turn and the gray dot of an idle one. Opening the session clears the indicator. The unread state is ephemeral renderer-side state, matching the existing $workingSessionIds and $attentionSessionIds pattern: no persistence, no backend involvement, wiped on gateway-mode switch. Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com> Co-authored-by: dschnurbusch <dschnurbusch@users.noreply.github.com> Co-authored-by: Flow Digital Inc. <flow-digital-ny@users.noreply.github.com>
…e-persistence The gateway re-persists the whole prior history every turn. _flush_messages_to_session_db dedups via an in-memory _DB_PERSISTED_MARKER, but that marker is _-prefixed and stripped by the wire sanitizer, and history reloaded via get_messages_as_conversation (resume/undo/restore) or deep-copied for the API request returns unmarked and without object identity — so every prior message is re-INSERTed each turn. On one live Signal session this stored the first message 62x; state.db held 63,111 duplicate rows (21%), driving a 2.2GB DB + 2.2GB WAL and inflating model context. This is NousResearch#860 / NousResearch#42039 reopened: the marker fix (NousResearch#50372) does not survive its own sanitizer, and upstream's 2026-07-15 _session_persist_lock only guards concurrency, not this deterministic single-threaded path (verified: merging upstream/main leaves the duplication byte-identical). Guard append_message with a natural-key idempotency check (session_id, role, timestamp, content, tool_call_id, tool_calls) inside the BEGIN IMMEDIATE txn: a byte-identical row is a re-persist (new messages always get a fresh time.time() timestamp), so return the existing id without inserting or double-counting. Migration-free; defense-in-depth for the whole re-persist class regardless of which caller loses the marker. Verified: 5-mode multi-turn repro all clean (was U0x32 -> U0x1); 7 edge cases incl. distinct messages and same-content/different-timestamp NOT collapsed (no data loss); 60 existing append/message/persist/codex tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsbWmnp8Q5o8oupgYAsjaE
…d-indicator feat(desktop): green unread dot for background-finished sessions
Conflicts resolved: - hermes_state.py: took upstream's new `profile_name` sessions column; the append_message idempotency guard merged cleanly and is intact. - tests/agent/test_compression_rotation_state.py: kept BOTH test classes (local TestUserIdPropagatesOnRotation + upstream TestFallbackStreakFollowsRotation). - AGENTS.md: took upstream's newer desktop-slash-commands + testing-philosophy docs. - tools/approval.py: took upstream's new _is_verification_artifact_cleanup; COMBINED it with the local branch-aware force-push carve-out (skip_force_push, used in the loop body); kept the local credential-redaction block (display-only, prevents sk-... leaks into screenshottable chat renders). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsbWmnp8Q5o8oupgYAsjaE
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3e90a8feb8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| """SELECT id FROM messages | ||
| WHERE session_id = ? AND role = ? AND timestamp = ? | ||
| AND content IS ? AND tool_call_id IS ? AND tool_calls IS ? | ||
| LIMIT 1""", |
There was a problem hiding this comment.
Include platform IDs in append-message dedupe
When a real platform delivers two identical messages in the same second, the gateway persists the platform event time as the message timestamp, so this new natural-key check treats the second message as a re-flush and returns the first row id without inserting or incrementing message_count. Because the key ignores platform_message_id (and other distinct metadata), repeated inputs like two Telegram messages with the same text and second-precision timestamp are silently dropped from state/replay; either include the durable platform id in the key or keep this dedupe scoped to known internal re-persistence paths.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Code Review
This pull request introduces comprehensive updates to the Hermes Agent, focusing on robust error handling, platform-specific optimizations (especially for Windows/WSL), and layout/state modularization. Key improvements include extracting Windows child process hiding and tree-killing logic into dedicated modules, implementing WSL path bridging, refactoring the context compressor to handle malformed arguments gracefully, and modularizing session and route tiles into layout-tree pane contributions. Additionally, support was added for DeepInfra and Upstage Solar models, along with a turn-end guard for Kanban workers and orphan recovery for interrupted side-effecting tools. The review feedback points out a redundant check in the write_file tool result summarization within agent/context_compressor.py that could introduce a subtle bug when the content is falsy.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if tool_name == "write_file": | ||
| path = args.get("path", "?") | ||
| written_lines = args.get("content", "").count("\n") + 1 if args.get("content") else "?" | ||
| written_lines = _str_arg(args, "content").count("\n") + 1 if args.get("content") else "?" |
There was a problem hiding this comment.
This line is redundant as it checks args.get("content") before calling _str_arg, which already handles retrieving and processing the argument. This also introduces a subtle bug: if the model provides 0 as content, args.get("content") would be falsy, resulting in ?, while the intent is likely to count it as one line.
A clearer and more correct implementation would be to use _str_arg directly.
| written_lines = _str_arg(args, "content").count("\n") + 1 if args.get("content") else "?" | |
| written_lines = (lambda c: c.count('\n') + 1 if c else '?')(_str_arg(args, 'content')) |
References
- Avoid redundant defensive checks (such as checking if an object is None before a function call) if the target function already handles None safely.
Real merge (history-preserving) so live-config actually contains upstream's history — the earlier squash-merge of #117 lost the ancestry and left the branch showing 514 behind. Conflicts resolved (5 files): - tools/approval.py: kept local branch-aware force-push carve-out + credential redaction; took upstream's _is_verification_artifact_cleanup (merged clean). - hermes_cli/kanban_db.py: kept local workspace-scheme healing (upstream absent). - tests/agent/test_compression_rotation_state.py: kept local TestUserIdPropagatesOnRotation. - apps/desktop/.../fallback.tsx + .test.ts: took upstream's UNBOUNDABLE_TOOLS generalization (clarify + image_generate), a superset of the local clarify-only guard. hermes_state.py stays at upstream v21 + the append_message idempotency guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsbWmnp8Q5o8oupgYAsjaE
Three commits.
1. Adopt upstream v21 FTS/schema (drop local v20 divergence)
hermes_state.pyhad diverged (local #110 v20 view-backed FTS + WAL watchdog + trigram gate; #114 trigram LIKE fallback) while the live DB had already migrated to upstream's v21 inline FTS — code(20)/DB(21) inconsistent, every upstream pull conflicting on this file.hermes_state.pyreset toupstream/mainverbatim (SCHEMA_VERSION 20→21, matches the DB); removed the twogateway/run.pywal_watchdogcall sites. Verified against a copy of the live DB: opens at v21, no destructive re-migration, FTS + append work. Trade-off: keeps ~0.9GB inline FTS bloat (upstream NousResearch#22478) and drops the WAL watchdog; acceptable now the write-storm is fixed.2. Stop per-turn history re-persistence (reopens NousResearch#860 / NousResearch#42039)
The gateway re-persists the whole prior history every turn:
_flush_messages_to_session_db's in-memory_DB_PERSISTED_MARKERis_-prefixed and stripped by the wire sanitizer, and reloaded/deep-copied history returns unmarked/without object identity, so every prior message is re-INSERTed. One live session stored its first message 62×; state.db held 63,111 dup rows (21%). The marker fix (NousResearch#50372) doesn't survive its own sanitizer; upstream's_session_persist_lock(2026-07-15) only guards concurrency (merging upstream leaves the duplication byte-identical). Fix: natural-key idempotency guard inappend_message(session_id, role, timestamp, content, tool_call_id, tool_calls) insideBEGIN IMMEDIATE. Migration-free.3. Merge latest upstream/main
Conflicts resolved: kept both rotation test classes; took upstream docs (AGENTS.md); combined approval.py's new
_is_verification_artifact_cleanupwith the local force-push carve-out; kept the local credential-redaction block.Validation
Dedup repro all-clean pre/post-merge (reload/deepcopy went U0×32→U0×1); 7 edge cases (distinct + same-content/different-timestamp not collapsed, no data loss); 60 persist/message + 6 rotation + 352/353 approval tests pass. The 1 approval failure is a pre-existing upstream macOS-only tempdir issue (
realpath("/tmp")→/private/tmp), passes on Linux CI.🤖 Generated with Claude Code
https://claude.ai/code/session_01PsbWmnp8Q5o8oupgYAsjaE