
### 🔒 Security

- Extended `redact_secrets()` coverage from the Google provider (#4070) to every credential-bearing exception-logging call site: the OpenAI-compatible provider base (`list_models`), the custom OpenAI endpoint provider, the OpenAI embedding provider, and the web search-engine subsystem (`BaseSearchEngine.run()` plus the main HTTP-call error paths in Tavily, Exa, Serper, Google PSE, Mojeek, PubMed, Semantic Scholar, Guardian, ScaleSerp, SerpAPI, and GitHub). API keys embedded in upstream SDK error messages or echoed-back URLs/headers no longer leak into logs. Guardian and ScaleSerp chain explicit-value redaction with their existing regex sanitizers for defense in depth. Consolidates #4168, #4175, and #4181. See #4131. ([#4426](https://github.com/LearningCircuit/local-deep-research/pull/4426))
- Extended `redact_secrets()` coverage to the NASA ADS search engine, which was missed by the original #4131 sweep. Its `_get_previews` catch-all used `logger.exception`, whose traceback frames hold `self.headers` (the `Authorization: Bearer <key>` value) and would render the API key under loguru diagnose mode. The except block now uses a redacted `logger.warning`. Follow-up to #4131.
- Loguru ``diagnose=True`` exception rendering (which dumps the ``repr()`` of every local variable in every traceback frame) is now gated behind a separate explicit ``LDR_LOGURU_DIAGNOSE`` opt-in instead of riding on ``LDR_APP_DEBUG``. Previously, enabling ``LDR_APP_DEBUG`` for general debug output also turned on localvar dumps, so any exception could write frame-local credentials — API keys, the SQLCipher master password, ``Authorization`` headers — into every log sink. ``LDR_APP_DEBUG`` now controls log *level* only; ``diagnose`` stays off unless the operator additionally sets ``LDR_LOGURU_DIAGNOSE=true``, and an explicit warning is emitted when they do.
- The MCP server subprocess (``ldr-mcp``) now pins ``diagnose=False`` on its stderr sink. loguru defaults ``diagnose=True``, which renders ``repr()`` of every traceback frame's locals on exception, so the many ``logger.exception(...)`` paths in the MCP request handlers were writing frame-local credentials (``api_key``, ``Authorization`` headers, search-engine secrets) into the MCP client's stderr log on any failure. Companion to the ``LDR_LOGURU_DIAGNOSE`` lockdown for the web/CLI logger; the MCP subprocess has no debug mode, so the gate is unconditionally off.
- The benchmark CLI (``ldr-benchmark`` / ``benchmarks/cli/benchmark_commands.py``) now pins ``diagnose=False`` on both of its ``logger.add(sys.stderr, ...)`` calls. loguru defaults ``diagnose=True``, which renders ``repr()`` of every traceback frame's locals on exception, so the many ``logger.exception(...)`` paths in the benchmarks package (LLM grader calls, search-engine runners, dataset loaders) were writing frame-local credentials — LLM ``api_key``, ``Authorization`` headers, search-engine secrets — to stderr on any failure. Companion to the ``LDR_LOGURU_DIAGNOSE`` lockdown for the web/CLI logger (#4384) and the MCP subprocess (#4394); the benchmark CLI also bypasses ``config_logger``, so the env-var gate did not reach it.
- The chat `PATCH` (rename/archive) and `DELETE` session routes now carry the same per-user rate limit as the other state-changing chat endpoints. They were previously bounded only by the global limiter, leaving an uneven abuse surface across the session API.
- The example scripts under ``examples/`` (``example_browsecomp.py`` and the three ``api_usage/programmatic/`` snippets) now pass ``diagnose=False`` to their ``logger.add(sys.stderr, ...)`` calls. Loguru defaults ``diagnose=True``, which renders ``repr()`` of every local in every traceback frame on exception. These files are templates that users copy into their own scripts, so the previous default propagated the same credential-in-traceback leak fixed for the application logger and MCP subprocess (#4185, #4384) into anyone's downstream code.

### ✨ New Features

- **Chat Mode** — Interactive multi-turn research conversations. Each session accumulates context across turns (entities, topics, source count), streams research steps and citations live as the answer is built, and persists in your per-user database (encrypted by default). Sessions can be archived, reactivated, deleted, or exported as Markdown. Reach it from the **Chat** sidebar link or `/chat/`. See [features.md#chat-mode](../docs/features.md#chat-mode) for the full feature description. ([#2953](https://github.com/LearningCircuit/local-deep-research/pull/2953))
- **"Summarizing…" indicator for Chat follow-ups** — When you send a follow-up question, the thinking indicator now reads "Summarizing previous conversation…" while the earlier turns are condensed into context for the new research, instead of showing blank dots. It clears the moment research starts streaming its progress.
- **Configurable follow-up context in Chat Mode** — Follow-up questions now build on the earlier conversation instead of starting cold. By default, the prior conversation is condensed into a short summary focused on your new question (using your configured LLM) and passed to the follow-up research as its "previous findings", keeping multi-turn research on-topic and within context limits. You can change this with the new **Follow-up Context Mode** setting (`chat.followup_context_mode`):

  - `summary` (default) — an LLM summary of the conversation, focused on your new question
  - `raw` — the most recent research findings, truncated
  - `full` — the entire conversation transcript
  - `none` — no prior context (just your new question and the original topic)

  Only `summary` makes an extra LLM call per follow-up; the other modes add no model cost.
- Chat Mode: the thinking bubble now shows what the agent is currently reasoning about between tool calls — the LLM's intermediate prose (e.g. "I should compare the published benchmark results next…") appears above the bouncing dots and overwrites with each new step, instead of leaving a static indicator for the entire research duration.
- Chat mode now shows an elapsed-seconds counter ("Stopping research… (Ns)") in the progress task line after the user clicks Stop, so it's clear the click registered even when the worker thread is blocked inside an LLM HTTP call (thinking-mode models like Qwen 3 and DeepSeek-R1 can take 30+ seconds to surface a stopping checkpoint because no chunks yield during ``<think>`` blocks). The counter only appears after 3 seconds elapsed — quick stops show the plain "Stopping research…" label without a number. The counter is cleared automatically whenever research stops, completes, or errors out. This is a UX-only mitigation; reducing the underlying termination latency would require deeper work such as per-token streaming or signal-based HTTP interruption.
- Chat mode now streams inline citation hyperlinks *as the answer is written* rather than only after the final save. Each chunk sent to the client is run through the same citation formatter the final answer uses, with a small carry buffer that holds incomplete `[N` tokens straddling chunk boundaries until the closing `]` arrives — so the user sees `[[arxiv.org-1]](url)` (or whichever format their ``report.citation_format`` setting selects) appearing live, and there is no visible format-flip when the saved answer replaces the streaming bubble on completion. The server-side partial-content buffer still stores the raw chunk so terminate-mid-stream saves aren't double-formatted on resume.
- Chat-mode live milestones now use friendlier wording for the agent's tool calls and observations. ``Tool: search_pubmed — "covid"`` becomes ``🔍 Searching PubMed: "covid"``, ``Tool: fetch_url — "https://…"`` becomes ``📖 Reading the page: "https://…"``, and tool results show as ``📄 From {engine}: …`` so the thinking-text reads like a narration of what the agent is doing rather than a dump of internal tool names. Engines without an explicit display name fall back to a cleaned ``Title Case`` form of the raw tool name, so newly added search engines work without a code change.

### 🐛 Bug Fixes

- Added a `weakref.finalize`-based safety net inside the Ollama
  embeddings factory so that programmatic API callers and example
  scripts that construct `OllamaEmbeddings` directly — bypassing
  the managed RAG service lifecycle — still release their underlying
  httpx clients when the instance is garbage-collected.
- Chat Mode correctness, settings, and accessibility fixes. (1) The streaming citation path in ``base_citation_handler._invoke_with_streaming`` now routes its joined chunks through ``get_llm_response_text`` before returning, so a reasoning model's ``<think>…</think>`` block no longer leaks into the persisted chat answer — ``.stream()`` bypasses ``ProcessingLLMWrapper.invoke`` (the only place tags were stripped), and the streamed result now matches the non-streaming ``invoke()`` contract. (2) ``SettingsManager.set_setting``'s self-heal block only re-points a row's ``type`` when the key matches a known prefix; keys outside the dispatch map (``focused_iteration.*``, ``langgraph_agent.*``, which ship as ``type=SEARCH``) are no longer silently demoted to ``APP`` on every edit. (3) ``report_assembly_service.assemble_full_report`` no longer wraps ``_build_sources_markdown`` in a blanket ``except`` that dropped the entire Sources block on error — failures propagate to the caller's existing 500 path instead of rendering a report that looks complete but is silently missing all sources. (4) The chat ``PATCH /sessions`` route now checks the boolean returns of ``update_session_title`` / ``reactivate_session`` / ``archive_session`` and returns 500 when a DB write failed but the session still exists, instead of reporting ``success: true`` with the stale row. (5) ``ChatService.delete_session`` sets the in-memory termination flags only **after** the delete commits, so a failed commit can no longer kill the in-flight research of a session that still exists. (6) ``build_research_context`` now populates ``original_query`` (the session's first user message) so the contextual follow-up strategy keeps its topic anchor instead of reading an empty string. (7) The mid-stream termination paths set ``streaming_state["_termination_handled"]`` so the ``ResearchTerminatedException`` handler no longer calls ``handle_termination`` a second time — eliminating the duplicate SUSPENDED status update, duplicate final socket emit, and doubled test-mode sleep. (8) The chat page route uses ``render_template_with_defaults`` so ``?v={{ version }}`` asset cache-busting is no longer empty. (9) The direct-mode capacity-reject path in ``processor_v2._start_research_directly`` now increments ``QueueStatus.queued_tasks`` (via ``add_task_metadata``) for the re-queued research — previously the counter stayed 0, so ``_process_user_queue`` could treat the queue as empty and leave the row undispatched until later user activity re-pumped the counter. (10) The streaming chat bubble no longer sets a nested ``role="status"`` inside the ``role="log"`` message list, which had made screen readers announce every streamed chunk twice. A regression test asserts the streaming path strips ``<think>`` from the returned synthesis.
- Chat Mode data-integrity fixes — message ordering now respects ``sequence_number`` for same-millisecond rows, the "Load older" cursor uses a composite ``(created_at, id)`` key so boundary rows are no longer silently dropped, Markdown export pages through the full conversation (previously truncated to 50 messages), and the partial-save idempotency flag is set only after the database write succeeds so a transient failure no longer permanently loses the assistant response.
- Chat Mode fixes for error/stopped-state button visibility, streaming citation carry-over, and accessibility. ``handleResearchError`` and ``handleResearchSuspended`` in ``chat.js`` now call ``showSessionButtons()`` so the edit-title / export controls reappear after a Stop or a failed research — previously only the successful-completion path restored them, leaving the buttons hidden until page reload after a stop or error. The streaming completion finalizer now flushes the citation carry buffer (``_flush_carry`` exposed via ``streaming_state``) before emitting the ``is_final`` sentinel, so an LLM stream that ends mid-token like ``"[12"`` no longer silently drops the leading bracket from the client's accumulated text. ``_PARTIAL_BRACKET_RE`` accepts the lenticular opener ``【`` in addition to ASCII ``[`` — some LLMs emit Chinese-style citation brackets and the citation formatter already recognises them, so the carry buffer needed to hold those back the same way. The error-path ``add_message`` in ``research_service.py`` now passes ``allow_archived=True`` matching the completion and stop-and-partial paths, so a session archived mid-flight no longer silently swallows the "research failed" message. ``ChatService.get_in_progress_research_id`` now re-raises ``DB_EXCEPTIONS`` instead of returning ``None``: a transient DB error during session load no longer looks identical to "no research running" and the route handler returns a 500 the client can surface as an error banner. Chat-css gains visible ``:focus-visible`` rings on send / stop / edit-title / ``#chat-page .ldr-btn``, a real focus ring on ``#chat-input`` (replacing the invisible 15%-opacity wrapper shadow), ``prefers-reduced-motion`` gates on the streaming caret pulse and thinking-dot bounce animations, ``:focus-within`` reveal for the per-message timestamp meta (previously hover-only and therefore invisible to keyboard users), a 44×44 minimum touch target for the mobile send button (WCAG 2.5.5), and the responsive breakpoint aligned to ``767px`` so the chat container's top-bar height assumption no longer disagrees with the rest of the project at exactly 768px. Minor cleanup: ``timedelta`` is now imported at module scope in ``chat/routes.py`` instead of inside ``send_message``, ``ChatContextManager`` is no longer re-exported from ``chat/__init__.py`` (every caller imports from ``chat.context`` directly), and the docs/features.md chat-mode section now mentions Markdown export.
- Chat Mode input-validation and resource-bound hardening. ``send_message`` now parses the numeric search settings (``search.iterations``, ``search.questions_per_iteration``) **before** the atomic write that commits the user message + IN_PROGRESS research row — a malformed (non-numeric) settings value now returns a clean 400 instead of raising an unhandled 500 after the rows are committed, which previously orphaned them and left the session unusable via the per-session 409 guard. The inline-citation carry buffer in ``research_service._make_chat_stream_callback`` is now capped at 64 bytes: a never-closing ``[`` followed by an endless digit run from a misbehaving LLM is flushed raw past the cap instead of growing the buffer without bound. The client-side ``streamedContent`` accumulator in ``chat.js`` is likewise capped at 256 KB (mirroring the server's ``_MAX_PARTIAL_BUFFER_BYTES``) with a one-time "(Response truncated — exceeded display limit.)" notice, so a model with no ``max_tokens`` can't OOM the browser tab. Two secondary citation regexes that the earlier lenticular-bracket work had missed now also accept ``【N】``: the source-list parser in ``citation_formatter.py`` (RIS export) and the citation-strip in ``benchmarks/graders.py``. Regression tests were added for the carry-buffer flush + overflow contract and the LLM-title newline strip.
- Chat Mode reliability fixes. The log panel now loads historical logs when expanded on a chat page (the toggle previously held a stale null research id). Delete / clear-history failures now surface an error notification instead of silently doing nothing. When a completed research's answer can't be loaded into the chat, the message now links to the full report (which is still saved) rather than a dead-end "no report available". The header "New Chat" / "Export" buttons are now styled (their base CSS wasn't loaded on the chat page). Stopping a research while it is still starting up now reliably terminates it instead of being silently ignored, and a late error event can no longer overwrite an already-rendered completed answer.
- Chat Mode rendering and log-safety fixes. ``chat.css``'s `.ldr-chat-container` height now uses `calc(100dvh - …)` (with `100vh` retained as the previous-line fallback, matching the pattern already used in ``mobile-navigation.css``) so iOS Safari no longer buries the chat input below the collapsible URL bar. ``chat.js``'s ``handleResearchComplete`` else-branch (the one that fires when streaming didn't reach its `is_final` sentinel — most commonly after a flush-then-disconnect race introduced by the `_flush_carry` change) now reuses the in-place-swap pattern from the streaming-complete branch when a partial bubble is already on screen: it removes the streaming class and renders the formatted message into the same `.ldr-chat-message-text` element instead of `.remove()` + `addMessageToUI`. Eliminates the 5-8.5 s vanish-then-refetch flicker. The original detach-and-reinsert path is preserved for the no-partial-bubble case where no flicker would occur anyway. ``ChatService.regenerate_title_with_llm`` now strips `\n` and `\r` from the LLM-returned title before storage; the title is interpolated into loguru f-strings (the "title already set" log line one above this fix), so an embedded newline would otherwise forge what looked like a second log entry in aggregators. Also keeps ``document.title`` and ``chatTitle.textContent`` visually clean.
- Chat Mode report-assembly, layout, and accessibility fixes. ``format_links_to_markdown`` (``utilities/search_utilities.py``) now coerces citation indices to ``str`` before ``sorted()`` so mixed ``int``/``str`` values arriving from different strategies (e.g. ``recursive_decomposition_strategy.py`` emits ``int``, ``_build_sources_markdown``'s fallback emits ``str``) no longer crash report assembly with ``TypeError: '<' not supported between instances of 'str' and 'int'``; the sort still produces numeric order. ``chat.css`` desktop and mobile chat-container heights now subtract the real ``.ldr-top-bar`` heights (60px / 50px) instead of the previous over-reserved 80px / 60px, ending the viewport overflow that clipped the chat input area. ``research_service.py`` ``add_progress_step`` failure path now zeroes ``event_data`` inside its ``except`` block so a DB-lock that swallows the persistence call also suppresses the live socket emit — preserving the documented live/reload symmetry invariant (a chat step the client sees live is the same set the persisted log returns on reload). Two icon-only delete buttons in ``history.js`` gained ``aria-label`` / ``title`` for WCAG 2.1 Level A compliance.
- Chat Mode: when research completes but the streaming-bubble swap path doesn't end up with a visible assistant response (transient socket drop, race during session switch, missed ``is_final`` chunk, etc.), the chat now silently re-renders from the DB-authoritative ``/messages`` endpoint instead of leaving the user staring at an empty page until they manually refresh.
- Citation formatter: ``apply_inline_hyperlinks`` (the fallback path that chat-mode answers always hit because the langgraph-agent synthesis doesn't emit a ``## Sources`` block in its prose) now dispatches on ``CitationFormatter.mode`` instead of hard-coding ``NUMBER_HYPERLINKS``. The user's ``report.citation_format`` setting is now honored for chat-mode citations — picking "Domain ID Hyperlinks" or "Source-Tagged Hyperlinks" actually produces ``[[arxiv.org-1]](url)`` / ``[[arxiv-1]](url)`` instead of every chat answer coming out as ``[[1]](url)``.
- Citation formatter: ``apply_inline_hyperlinks`` now accepts either ``"url"`` or ``"link"`` as the destination key on each source dict. Searxng-sourced results carry the destination under ``"link"`` (``search_engine_searxng.py``), which the fallback hyperlink path silently dropped — so chat-mode answers that did not emit a ``## Sources`` block in their synthesis shipped with plain ``[N]`` brackets instead of clickable citations even though the Sources section beneath the answer was fully populated.
- Fix report structure parsing crashing with `IndexError` on a numbered section line without a period (e.g. `1 Introduction`), and stop truncating section names that contain periods (`1. U.S. Policy` is now kept whole instead of becoming `U`). The section number is now split on the first period only, with a guard for malformed lines.
- Fixes for bugs that affect the **non-chat** research path as well as chat. (1) ``LangGraphAgentStrategy.analyze_topic`` no longer overwrites its ``query`` parameter with a truncated tool-call argument inside the tool-call display loop — because ``langgraph-agent`` is the default strategy, after the first ``web_search`` the original research question was silently replaced by a ≤80-char search arg, which then fed the citation re-synthesis, the fallback synthesis, and the recorded ``question`` field, steering the final answer at the wrong question. (2) The queue dispatcher (``processor_v2._start_queued_researches``) now has a dedicated ``except SystemAtCapacityError`` clause: a transient at-capacity rejection is left QUEUED for the next tick instead of being counted toward ``SPAWN_RETRY_LIMIT`` and wrongly marked FAILED after a few ticks under load. (3) ``cleanup_research_resources`` now reports the real terminal status (passed in by the caller — SUSPENDED on user termination, FAILED on error) instead of a hard-coded ``COMPLETED``, so stopping a research no longer emits a spurious "completed" socket signal (which produced a stray "[Stopped]" bubble in chat and a misleading 100%/Completed flip on the standard progress page); chat.js's completion/suspension handlers also cross-guard each other. (4) Chat-triggered research now inserts a ``UserActiveResearch`` row, so it counts toward the per-user ``app.max_concurrent_researches`` cap the same way UI-launched research does — previously chat research was invisible to the cap (queried but never recorded), letting multiple chat tabs bypass it. The row is committed atomically with the research row, cleaned up on spawn failure, and removed on normal completion by the existing completion-sweep middleware.

  Regression tests added for all four.
- GitHub search relevance ranking now rejects negative, out-of-range, and non-integer result indices and deduplicates repeated ones, so a malformed LLM response can no longer select the wrong result, list the same repository twice, or discard all results.
- Library RAG service is now closed at the end of every HTTP request
  that uses it — including streaming endpoints. Together with the
  embedding-manager close path, this stops file-descriptor accumulation
  under sustained library indexing/search traffic.
- News subscriptions run via "Run now" or the overdue-subscription check no longer force the LLM to ``ollama``/``llama3`` when the subscription has no explicit model configured. The run paths in ``news/flask_api.py`` hardcoded those values, which (being truthy) overrode the user's configured ``llm.provider``/``llm.model`` — so a user on OpenAI/Anthropic whose subscription carried no model would silently get an Ollama run, typically failing. The hardcoded defaults are removed; an unset model now passes through as ``None`` so ``start_research`` falls back to the user's settings.
- Stopped a per-RAG-request file-descriptor leak introduced when the
  embeddings provider migrated to `langchain_ollama.OllamaEmbeddings`.
  The library RAG service now closes the underlying httpx clients on
  teardown, preventing eventpoll FD accumulation under sustained
  indexing/search load.
- Strip `<think>` reasoning blocks from the main synthesis path (`synthesize_findings` and the standard knowledge generator), not just the citation handlers — reasoning-model output no longer leaks `<think>…</think>` into final answers. Also fixes precision/forced answer extractors emitting a stray `". <content>"` when the model returns an empty (or think-only) response.
- The WebSocket subscribe ownership check now recognizes benchmark runs in addition to normal research. The new per-user ownership gate (and the removal of the cross-user broadcast fallback) only matched ``ResearchHistory`` UUIDs, so the benchmark page — which subscribes with an integer ``BenchmarkRun`` id — was rejected and its live progress (current-task detail and the SearXNG rate-limit warning) was silently dropped. ``_user_owns_research`` now also accepts the user's own ``BenchmarkRun`` rows from their per-user encrypted database, restoring benchmark live progress without widening the authorization boundary.
- The central LLM wrapper now normalizes string-returning providers into a message object and applies `<think>`-tag stripping to async (`ainvoke`) calls too, so any LLM obtained from `get_llm` yields a consistent, think-free `.content` — eliminating `'str' object has no attribute 'content'` crashes at the source. Message objects keep their `tool_calls`/`reasoning_content` (only `.content` is rewritten).
- The chat ``send_message`` endpoint now uses the shared ``@require_json_body`` decorator like the other state-changing chat POSTs. It was the only one validating the body inline, so a non-JSON content type slipped past the consistent 400 contract; requiring an ``application/json`` body also hardens CSRF on the heaviest chat endpoint (it launches a research run). Behavior for valid requests is unchanged.
- The queued-research dispatch loop now reverts its queue-counter claim when a global-capacity reject re-queues a research. Previously, ``_start_queued_researches`` marked the task ``processing`` (decrementing ``queued_tasks``, incrementing ``active_tasks``) and, on ``SystemAtCapacityError``, only reset the row's ``is_processing`` flag — leaking a slot into ``active_tasks`` on every capacity-rejected retry. Under sustained capacity pressure ``queued_tasks`` would drift to 0, at which point the per-user queue processor treated the queue as empty and stopped dispatching the still-present rows. ``update_task_status`` now supports a ``processing`` → ``queued`` transition that restores the counts, and the capacity-reject path uses it.
- `GET /api/report/<id>` now returns the `sources` field from the structured `research_resources` table instead of the dead `all_links_of_system` metadata key. After the chat-mode-v2 report refactor that key is never written, so the field returned an empty list for every research created since — even though the assembled `content` and the news feed already read sources from the table. (#3665)

### 📝 Other Changes

- **`research_history.report_content` shape — answer-only at persistence time.** Internal/library change tied to the Chat Mode work: `report_content` now stores the synthesized answer (LLM prose + inline citations) rather than the full `format_findings` blob (which previously embedded `## Sources` and `## Research Metrics` sections). User-facing views are unchanged — `assemble_full_report()` in `web/services/report_assembly_service.py` reconstructs the legacy display shape on demand for the history page, exports, and the chat "View full research" link, and legacy rows that still contain the inline sections are detected and not double-appended. **Direct callers of `storage.get_report()` / `storage.get_report_with_metadata()` should switch to `assemble_full_report()` if they need the legacy shape.**
- CI: fix the freshly-merged ``check-shadow-tests`` pre-commit hook using ``os.path.basename()``, which the repo's own ``check-pathlib-usage`` hook forbids. Because PR pre-commit runs ``--all-files`` against the PR-merged-into-main tree, this one line on main turned every open PR's pre-commit red. Switched to ``pathlib.Path(path).name`` (behaviour verified identical to ``os.path.basename`` across separator/edge cases).
- CI: split ``tests/web/routes/test_settings_routes_coverage.py`` out of the parallel pytest run into a dedicated serial step (same pattern as the existing ``fd_canary`` step). The 94 tests in that file all use the ``authenticated_client`` fixture (register + login + SQLCipher KDF + 10 Alembic migrations per test), and under ``-n auto`` they accumulate enough connection / FD pressure inside the assigned xdist worker to deadlock it silently late in the run — the worker dies with ``[gw0] node down: Not properly terminated`` (no timeout printed), its coverage data is dropped, and the 50% fail-under gate then fails the job even though every test that completed actually passed. Skipping individual tests in this file just relocates the death (confirmed empirically on this PR's earlier iteration). The serial step keeps all 94 tests running, appends into the main run's ``.coverage`` data via ``--cov-append``, and runs the final coverage report + 50% gate after both contributions have merged. Defence-in-depth: pairs with #4393's 180s/thread timeout.
- CI: switch ``pytest-timeout`` from ``signal`` to ``thread`` method and raise the global per-test timeout from 60s to 180s. On Python 3.14 the SIGALRM-based interrupt was firing inside ``weakref.py`` cleanup, corrupting xdist workers and dropping their coverage data — which then put total coverage below the 50% gate even when every test that completed actually passed. The thread method raises a Python-level exception in the main thread instead of interrupting at an arbitrary safe-point, and 180s gives the heavy ``authenticated_client`` register+login fixture (SQLCipher KDF + Alembic migrations) headroom under ``-n auto`` + coverage contention.
- Chat Mode polish and test-quality improvements. ``chat/routes.py`` adopts the shared ``@require_json_body(error_format="success")`` decorator on the three POST/PATCH endpoints (``create_session``, ``generate_session_title``, ``update_session``), replacing 4-block inline ``isinstance(data, dict)`` guards. ``UserActiveResearch`` stale-row reclaim is extracted from both ``chat.routes.send_message`` and ``research_routes.start_research`` into a shared ``reclaim_stale_user_active_research(db, username, *, grace_cutoff_dt=None, logger=None)`` helper in ``web/routes/globals.py`` — chat passes a 30s grace cutoff (because chat send can race with its own concurrent sibling), research_routes passes None (matching its pre-existing behaviour). ``ChatService.delete_session`` now imports ``set_termination_flag`` at module top-level instead of inside the function body (no circular import requires the deferred import). The dummy ``"I understand. What else would you like to know?"`` assistant bubble that ``chat.js`` injected when the server returned no ``research_id`` is gone — the thinking indicator is now cleared without injecting a placeholder reply. ``chat.js::loadSession`` now focuses the input on completion, matching ``startNewChat``. Three brittle test patterns are replaced: the seven copy-pasted ``captured_username`` + ``@contextmanager`` blocks in ``test_chat_user_isolation.py`` are consolidated under a single ``username_capturing_db`` fixture; the 16 inline ``SocketIOService._instance = None`` reset lines in ``test_chat_socket_events.py`` become a single autouse fixture that runs in setup AND teardown regardless of test outcome; ``test_chat_settings_integration.py``'s three tautological dict-assertion tests are replaced with four real tests that exercise ``_load_settings`` against patched ``SettingsManager`` and validate every snapshot-extraction key still exists in ``default_settings.json``. ``test_chat_send_message_reclaim.py``'s three source-grep tests against ``chat/routes.py`` are replaced with eight behaviour tests that drive the new shared reclaim helper against a real SQLite session (covering grace-window respect, live-thread skip, username scoping, and the without-cutoff immediate-reclaim mode used by research_routes). ``test_chat_service.py`` drops three ``if hasattr(...)`` assertion guards that would have silently passed when a real model regressed; the ``get_in_progress_research_id`` DB-error test inverts to assert propagation now that the service re-raises instead of returning None. ``test_chat_api.py``'s ordering assertion no longer hides behind ``if len(sessions) >= 2:``.
- Chat Mode polish fixes. The agent-thinking milestone for ``research_subtopic`` now reads ``🔬 Investigating subtopic: "topic1, topic2"`` instead of empty quotes — the display code now picks up the tool's actual ``subtopics: list[str]`` argument and stringifies the list. ``send_message``'s ``UserActiveResearch`` reclaim block now emits a ``logger.warning`` mirroring the ``ResearchHistory`` reclaim above it, so operators can trace why a per-user concurrency cap was released. ``_validate_title`` strips Unicode format / line-separator characters (``Cf``/``Zl``/``Zp`` — zero-width spaces ``U+200B-U+200D`` and BOM ``U+FEFF``) before the emptiness check, so a title of 500 zero-width chars is rejected instead of saving a session that looks blank in the UI. ``regenerate_title_with_llm`` is now idempotent: it skips the LLM call if the session title no longer matches the non-LLM fallback, so a concurrent tab's edit (or the user's manual edit completing first) is not overwritten by a stale LLM round-trip. The dead ``build_research_context_for_session()`` wrapper in ``chat/context.py`` is removed along with its dedicated tests — production code uses ``ChatContextManager`` directly.
- Chat follow-up research now logs which prior-context mode ran and how many characters of context it built. Previously the context-building step (especially the LLM summary) produced no log output, so a follow-up's preparation looked like an unexplained pause.
- Clarified the auto-generated `app.debug` / `LDR_APP_DEBUG` description in `default_settings.json` (and therefore `docs/CONFIGURATION.md`) to reflect the split introduced when `LDR_LOGURU_DIAGNOSE` was added: `LDR_APP_DEBUG` now raises log level only and explicitly does NOT enable Loguru's local-variable dumps on its own. Companion to the `LDR_LOGURU_DIAGNOSE` security fix.
- Document a known limitation: LangGraph `create_agent`/`bind_tools` (in the `langgraph-agent` and `mcp` strategies) resolve to the base LLM via `ProcessingLLMWrapper.__getattr__`, bypassing the wrapper's `<think>`-tag stripping. Added in-code notes at the three call sites. This is a cosmetic leak only (reasoning-model `<think>` blocks may appear in agent output); it does not affect direct `invoke()` calls, which still go through the wrapper.
- Documented that the News API `findings` field is the answer-only report content after the chat-mode-v2 refactor (intentional): structured top-N source links live in the separate `links` array rather than an embedded `## Sources` blob. No behavior change. (#3665)
- Test fixtures and dev scripts that re-configure loguru (``tests/conftest.py`` loguru-caplog fixture, the two ``tests/api_tests/`` ``__main__`` runners, ``tests/test_openai_api_key_e2e.py``, ``tests/settings/test_manager_behavior.py``, ``tests/journal_quality/test_db.py``, ``tests/performance/mcp/echo_server.py``) now pass ``diagnose=False`` to their ``logger.add(...)`` calls, matching the production policy locked in by #4384 / #4394. loguru defaults ``diagnose=True``, which renders ``repr()`` of every traceback frame's local on exception; pinning it off keeps frame-local credentials (api keys, SQLCipher passwords, ``Authorization`` headers) out of pytest output and CI logs even when a test crashes mid-fixture. Hygiene change only — no behavior change for green tests.
- The news-feed "time ago" formatter no longer swallows unparseable timestamps behind a silent "Recently" label. Since `created_at` is always written as a valid ISO timestamp, a value that won't parse means a corrupt row — that row is now logged and skipped by the feed builder instead of rendering with a misleading time.
