Commit Graph

815 Commits

Author SHA1 Message Date
Kenny Van de Maele b4a0700da4 fix: exclude slash-command/setup messages from LLM context (#2634) (#2640)
Slash-command replies and the echoed /setup command are persisted to session
history so they render in the transcript, but they are UI chatter the user
never meant as conversation. They were sent to the model on the next turn,
which then commented on '/setup ...' and exposed transient values (e.g. the
Copilot device user_code) to the LLM.

- get_context_messages() (the LLM-API view) now skips messages tagged
  metadata.source == 'slash'. Display/history-load paths use raw history and
  are unaffected.
- slashCommands.js tags the echoed user command with source:'slash' too (the
  assistant replies already carried it); the user line was the one untagged
  path that still reached context.

Fixes #2634.
2026-06-04 21:42:23 +02:00
Afonso Coutinho db6136baa3 Fix truncate_messages persisting an inflated message_count (#2052)
truncate_messages deletes db_messages[keep_count:] (a no-op when
keep_count >= the real message total) then unconditionally wrote
db_session.message_count = keep_count. When keep_count exceeds the
number of messages that actually exist — e.g. the manage_session AI
tool defaults keep_count to 10, and the HTTP truncate endpoint passes
any client value — the persisted count is set too high (10 on a
3-message session), diverging from the real row count. That column
gates lazy DB-hydration in get_session (message_count > 0) and is
surfaced to the history UI, so it is correctness-relevant. Clamp to
min(keep_count, len(db_messages)); the in-memory slice already caps
naturally.
2026-06-04 21:19:16 +02:00
Giulio Zelante cdf31835e2 fix(docker): opt-in INSTALL_OPTIONAL build arg for AGPL extras (#2633)
Default image installs requirements.txt only. Set INSTALL_OPTIONAL=true
at build time to add requirements-optional.txt (PyMuPDF, markitdown, etc.)
without baking AGPL into the standard distributed image.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 21:15:44 +02:00
Kenny Van de Maele a6c0869baa feat(provider): add GitHub Copilot provider with device-flow auth (#1480)
* feat(provider): add GitHub Copilot provider with device-flow auth

Adds GitHub Copilot as a model provider, so Copilot models (gpt-4o/4.1/5,
Claude, Gemini, …) work through the normal chat + agent loop, incl. native
tool calling and vision.

Auth is one-click via the GitHub OAuth device flow; the access token is stored
as the endpoint's (encrypted) api_key and sent directly as `Authorization:
Bearer` (no Copilot-token exchange, no refresh — matching how editors talk to
the Copilot API). Copilot is a normal ModelEndpoint detected by host; the only
provider-specific behaviour is a small set of required request headers,
injected centrally.

Sign-in is available from Settings → model endpoints ("Connect GitHub
Copilot") and from chat via `/setup copilot`.

- src/copilot.py (new), routes/copilot_routes.py (new): constants, header
  builders, device-flow start/poll, model discovery, owner-scoped endpoint
  provisioning.
- src/llm_core.py, src/endpoint_resolver.py: detect `copilot`, inject headers,
  per-request x-initiator/vision.
- src/agent_loop.py: allowlist api.githubcopilot.com for native tool schemas.
- src/model_context.py: known context windows for Copilot (no unauthenticated
  /models probe).
- static/, README, tests/test_copilot*.py.

* Tidy copilot_routes: clarify supports_tools, note _PENDING is per-process
2026-06-04 21:13:14 +02:00
Ocean Bennett 782d00617f fix(history): tolerate tool-call turns during compact (#2626) 2026-06-04 20:59:41 +02:00
Maruf Hasan 059e902565 chore: remove orphaned static/landing.html (superseded by docs/index.html) (#2632) 2026-06-04 20:55:51 +02:00
Vykos cf146b396d Isolate HTML popup openers (#2501) 2026-06-04 20:52:41 +02:00
Vykos 978d5a39bc Guard image and QR DOM attributes (#2500) 2026-06-04 20:51:23 +02:00
Vykos 8ee14ab2aa Harden chat streaming DOM sinks (#2498) 2026-06-04 20:49:37 +02:00
Vykos d10b1c14fd Harden email HTML URL sanitization (#2496) 2026-06-04 20:47:47 +02:00
Vykos fad9b9c701 Harden markdown raw HTML sanitization (#2497) 2026-06-04 20:46:10 +02:00
Vykos 266240f3e1 Whitelist research source links (#2499) 2026-06-04 20:41:35 +02:00
Afonso Coutinho c10a2b93b4 fix: renaming a user leaves their API tokens resolving to the old owner (#1932)
* fix: renaming a user leaves their API tokens resolving to the old owner

* Drive rename token-cache test through the real auth resolver instead of patching a closure
2026-06-04 20:37:59 +02:00
Alex Little 1b54a29d8e fix(ui): modal drag + removed startDrag func (#2430)
* fixed

* removed legacy startDrag fc, unified modal dragging

* fixes post feedback
2026-06-04 20:34:18 +02:00
ooovenenoso 7ea6093af2 fix(research): support timeout defaults in direct tests (#2624)
fix(research): honor planning query timeouts
2026-06-04 20:23:17 +02:00
Giuseppe c443bca3bf fix(llm): auto-detect <think> in content stream for unregistered thinking models (#2588)
* fix(llm): auto-detect <think> in content stream for unregistered thinking models

_THINKING_MODEL_PATTERNS only covers known model families by name. Qwen3-derived
models with non-standard names (e.g. Qwopus, custom QwQ forks) are not matched,
so their <think>...</think> content streams through as visible chat text instead
of being routed to the thinking display.

When the first content delta opens with <think> and the model was not already
identified as a thinking model, dynamically flag the stream as a thinking model
for the remainder of the response. This enables the existing </think> repair path
(line below) and ensures the frontend receives the full <think>...</think> wrapper
it needs to split thinking from the final answer.

The check is restricted to the very first content delta (_first_content_sent is
False) to avoid misidentifying models that happen to write "<think>" mid-answer.

Fixes #2225
Related: #2420 (covered by separate PR from @AmmarS-Analyst), #2224 (@RaresKeY)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(llm): replace inert _thinking_model flag with _in_think_tag state machine

The original auto-detect set _thinking_model=True on the first <think> chunk
but still emitted it as a regular delta and set _first_content_sent=True
immediately, so no subsequent chunk could enter the repair path.

Replace with _in_think_tag bool: enter thinking mode when first content starts
with <think>, route all chunks to the thinking channel until </think> is found,
then the tail becomes the first regular delta. Adds three regression tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(llm): replace _first_content_sent guard with _think_open_stripped

Opening-tag stripping used `not _first_content_sent` as the guard, but
_first_content_sent stays False throughout the entire think block (it only
flips when regular content is emitted). So `find(">")` ran on every
reasoning chunk — not just the first — and silently truncated everything
before the first ">" in any reasoning text containing comparisons, arrows,
or code.

Fix: add `_think_open_stripped = False` alongside `_in_think_tag`. Use it
as the strip guard in both the "still inside <think>" path and the
"</think> found in same chunk" split path. Set it True once the opening
tag is consumed so all subsequent chunks reach the thinking channel
unmolested.

Add regression test: 3-chunk stream where the middle chunk contains
"c > d" — confirms "more c " is not dropped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:18:19 +02:00
Alexandre Teixeira bf8ded3ab1 fix(tests): isolate compare endpoint owner-scope test
Removes module-level core.database stubbing from the compare endpoint owner-scope regression test and patches ModelEndpoint per test with monkeypatch. Restores one focused part of the Python CI baseline tracked in #2580.
2026-06-04 19:17:15 +01:00
Giuseppe d249cc7e94 fix(agent): default bash/python cwd to data/ to prevent ephemeral file loss (#2586)
Agent subprocesses (bash, python) previously inherited the container's default
working directory (/app), so files created with relative paths landed in the
ephemeral container layer and were silently destroyed on any docker compose up
--build or container recreation.

Set cwd=_AGENT_WORKDIR (resolved to <repo_root>/data at import time) and
HOME=_AGENT_WORKDIR on both subprocess launchers so that:
- pwd inside a bash tool returns the persistent data directory
- relative paths and ~ resolve to a location that survives rebuilds
- the agent can still cd to any absolute path it needs

The resolution uses pathlib.Path(__file__).parent.parent / "data", which
works for both Docker (/app/src → /app/data) and manual installs
(<repo>/src → <repo>/data) without requiring a new env var or compose change.

Fixes #2512

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:16:04 +02:00
Zen0-99 13631c7fad fix(hwfit): filter non-GGUF models on Windows (#2530)
Odysseus only supports llama.cpp on Windows (vLLM/SGLang are
explicitly blocked). llama.cpp requires GGUF, so AWQ/GPTQ/FP8
safetensors models without a GGUF alternate should not be
recommended in the Cookbook on Windows hosts.

Changes:
- hardware.py: add 'platform': 'windows' to _detect_windows()
  so downstream logic can identify Windows hosts.
- fit.py: include is_windows in the existing GGUF-only filter
  alongside apple_silicon and consumer_amd.
- tests: add test_hwfit_windows.py with regression tests.

Fixes #122, #614 (root cause: unservable models recommended).
2026-06-04 20:02:13 +02:00
Afonso Coutinho 607fc91155 fix: merge-last-assistant deletes tool/system rows from the DB (history desync) (#1929) 2026-06-04 19:47:08 +02:00
Giuseppe 39ad302987 fix: bool('false') is True coerces endpoint toggles incorrectly (#2361)
Python's bool('false') returns True because the string is non-empty.
A JS client serialising a boolean as the string 'false' would have
supports_tools or is_enabled silently flipped to True — so 'disable
tool support' would actually enable it.

Use an explicit lookup dict for supports_tools and a case-insensitive
string check for is_enabled so both string and native bool inputs are
handled correctly.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:43:38 +02:00
Alexandre Teixeira caf9d472ef fix(tests): align gallery owner filter null-user expectation
Updates the stale gallery owner-filter null-user test to match current single-user/auth-disabled behavior. Restores one focused part of the Python CI baseline tracked in #2580.
2026-06-04 18:39:45 +01:00
Giuseppe 88aa53f8de fix: KeyError on missing 'content' key in system messages (#2362)
A system message that arrives without a 'content' key — possible via
malformed tool results — raised a KeyError in the hot path of llm_call,
llm_call_async, and stream_llm. Replace m["content"] with
m.get("content") or "" in all three functions so a missing key degrades
to an empty string instead of crashing.

Also removes a redundant .rstrip() after .strip() in _model_activity_key.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:38:45 +02:00
Giuseppe cf647a4614 fix: llm_call_async does not retry on HTTP 429/502/503/504 (#2364)
The retry loop raised immediately for any non-success HTTP response
regardless of attempt count. For transient upstream errors (rate limit,
bad gateway, gateway timeout) the function should back off and retry
within the existing attempt budget.

Also lets ConnectError / ConnectTimeout retry when the host has not been
cooled and attempts remain, instead of always raising on the first
connect failure.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:35:55 +02:00
RaresKeY f372a27619 fix: normalize Gemma 4 thought-channel output (#2224) 2026-06-04 19:26:58 +02:00
Alexandre Teixeira d7e793e6d1 fix(tests): use line-level PDF marker assertion
Updates the PDF marker regression test to check corrupted markers at line level instead of using a broad substring assertion. Restores one focused part of the Python CI baseline tracked in #2580.
2026-06-04 18:20:41 +01:00
WasserEsser 678b8f44f2 fix(models): make pinned models visible in chat UI (#2481)
Two bugs prevented pinned models from appearing in the chat model picker:

1. _fetch_models() only used _cached_model_ids(), ignoring pinned_models.
   Since Fireworks AI doesn't list kimi-k2p6-turbo in /v1/models, the
   cached list was empty, so the endpoint showed as offline with no models.

2. _curate_models() filtered unknown pinned IDs into models_extra, but the
   chat UI only reads models (primary list). Pinned models stayed invisible.

Fix: use _visible_models() to merge cached + pinned, then promote pinned
IDs from models_extra to models so they appear in the dropdown.

Closes #1521 follow-up
2026-06-04 19:17:37 +02:00
Ocean Bennett e0878d94ae docs: point PR checklist at dev (#2594) 2026-06-04 19:15:08 +02:00
Alexandre Teixeira 4de90251b4 fix(tests): use non-repeating split chunk fixture
Updates the split_chunks containment regression test to use deterministic non-repeating records instead of a repeating fixture that could produce accidental substring matches. Restores one focused part of the Python CI baseline tracked in #2580.
2026-06-04 18:11:42 +01:00
nubs 0b96061793 fix(mcp): confine oauth file paths (#2272) 2026-06-04 19:10:23 +02:00
nubs 13284bb132 refactor(search): make src analytics a service shim (#2264) 2026-06-04 18:57:24 +02:00
Alexandre Teixeira 66805d947d fix(tests): accept verify in endpoint HTTP mocks
Updates endpoint/model-route test HTTP mocks to accept the verify keyword argument passed by endpoint probing code. Restores one focused part of the Python CI baseline tracked in #2580.
2026-06-04 17:53:18 +01:00
Kenny Van de Maele c2964cf475 feat: add code-navigation tools (grep, glob, ls) + read_file line ranges (#1670)
Gives the agent first-class code navigation instead of shelling out via bash
(token-heavy, unreliable on weaker models, unstructured). Mirrors the
Grep/Glob/Read primitives that Claude Code / opencode expose.

- grep: regex search over file contents across a tree. Uses ripgrep when
  available (with explicit excludes so junk dirs are skipped even without a
  .gitignore); falls back to a pure-Python walk+regex when rg is absent.
  Returns file:line:match, capped.
- glob: find files by glob pattern (recursive), newest first.
- ls: list a directory (folders first, then files with sizes).
- read_file: optional offset/limit for line-range reads of large files
  (plain-path calls stay back-compatible).

All confined by the same path policy as read_file (_resolve_tool_path:
data/tmp allowlist + sensitive-file deny). Junk dirs (.git, node_modules,
venv, __pycache__, dist/build, …) skipped. Output capped (200 hits,
400 chars/line). Admin-gated like the other filesystem tools.

Wiring: schemas + native arg->content serializer (src/tool_schemas.py), tool
tags (src/agent_tools.py), always-available + descriptions (src/tool_index.py),
admin gate (src/tool_security.py), dispatch + impls (src/tool_execution.py).

Tests: tests/test_code_nav_tools.py — match/skip-junk/ignore-case/glob-filter,
allowlist rejection, glob/ls, read-range, and the no-ripgrep Python fallback.
2026-06-04 18:37:32 +02:00
Kenny Van de Maele 616860a34a feat: Add edit_file tool + file-change diffs (#1239)
* Add edit_file tool + file-change diffs

edit_file is an exact old_string -> new_string replacement on a file on disk
(fails if old_string is missing or non-unique unless replace_all); write_file
also returns a unified diff. Diffs render collapsed in the tool bubble
(filename + +adds/-dels, theme colors); the raw JSON command box is hidden.

Security: edit_file is a sensitive filesystem-write tool, treated everywhere
write_file is —
  - added to NON_ADMIN_BLOCKED_TOOLS (is_public_blocked_tool / blocked_tools_for_owner),
    so on auth-enabled deployments a non-admin cannot run it; execute_tool_block
    refuses it for non-admin owners.
  - confined by the same path policy as read_file/write_file (allowlist +
    sensitive-file deny) via _resolve_tool_path.

Disambiguation in tool descriptions + bash prompt: edit_file/write_file are the
only way to write files (they show a diff) — never edit_document (editor panel)
or a bash heredoc/redirect.

Tests (tests/test_edit_file.py): non-admin block (policy + execution gate),
successful edit, not-found old_string, non-unique old_string (+ replace_all),
and path outside the allowed roots.

Files: src/tool_execution.py, src/agent_loop.py, src/tool_schemas.py,
src/agent_tools.py, src/tool_index.py, static/js/chat.js, static/style.css,
tests/test_edit_file.py.

* Drop redundant import os in write_file closure

os is already imported at module top.
2026-06-04 18:29:10 +02:00
Kenny Van de Maele d76d54e0ce Show the serving provider in the model-info card (#2185)
* Show the serving provider in the model-info card

The model-info popup (click the model name on a message) shows the model
and pricing, with a logo inferred from the model NAME. But the same model
can be served by different endpoints — e.g. claude-haiku via OpenRouter
vs GitHub Copilot vs Anthropic direct — which the name-based logo can't
distinguish.

Add a 'Provider' line derived from the session's endpoint URL:
- new providerLabel(endpointUrl) in static/js/providers.js maps the host
  to a friendly name (GitHub Copilot, OpenRouter, Anthropic, OpenAI,
  Google, AWS Bedrock, DeepSeek, Mistral, Groq, Together, Fireworks,
  Perplexity, xAI), 'Local' for loopback/LAN, else the bare host.
- static/js/chatRenderer.js renders it under Model in the card, from
  window.sessionModule.getCurrentEndpointUrl().

* Anchor provider-label patterns to the hostname

providerLabel matched its patterns against the full endpoint URL with
unanchored substrings, so a host like max.airlines.com matched /x\.ai/ and was
mislabeled "xAI". Anchor each pattern to the end of the hostname ((^|.)domain$)
and test against the parsed host instead of the raw URL.
2026-06-04 18:22:31 +02:00
Kenny Van de Maele c1dfe31431 chore: deduplicate src/search modules (cache, content, query) into shims (#2506)
* chore: dedupe src/search/cache.py into a re-export shim

src/search/cache.py was a byte-identical copy of services/search/cache.py.
Convert it to a sys.modules alias of the canonical services module (matching
src/search/core.py, providers.py, ranking.py) so the two cannot drift, and add
an identity assertion to test_search_module_consolidation.py.

content.py and query.py are intentionally left as-is: the copies have drifted
and services lacks fixes that src has, so they need services reconciled first
before they can be shimmed safely.

* chore: dedupe src/search content.py and query.py into shims

Convert src/search/content.py and query.py to sys.modules aliases of the
canonical services/search/* (matching cache.py, core.py, providers.py,
ranking.py) so the duplicate copies cannot drift.

Repoint the two tests that were coupled to the src-copy internals onto the
canonical services surface (behaviour is equivalent):
- test_src_search_query_nonstring.py: import services.search.query instead of
  loading the src file by path.
- test_security_regressions.py::test_web_fetch_guard_blocks_redirect_into_private:
  mock httpx.get (services uses the module-level get, not httpx.Client) and
  assert on the canonical 'Blocked' message.

Drop the now-redundant [src_content, service_content] parametrization in
test_search_content_extraction_parity.py and test_search_content_url_guards.py
(after the shim both params are the same object); add content/query identity
assertions to test_search_module_consolidation.py.
2026-06-04 18:10:55 +02:00
Kenny Van de Maele 1fa43b4c07 fix: live-resume chat stream on session re-entry (#2539) (#2561)
* fix: live-resume chat stream on session re-entry (#2539)

When a session was re-entered after a page refresh or in a new tab while
its agent run was still streaming, the UI showed a frozen "Generating
response..." spinner, polled stream_status until the run finished, and
then did a full reload. The live tokens were never shown.

Add resumeStream() in chat.js: it consumes GET /api/chat/resume/{id}
(which replays the run's buffer then streams live), renders reply tokens
as they arrive, and reloads the session on completion for the canonical
final render. sessions.js _checkServerStream now calls it on re-entry and
falls back to the previous spinner+poll path if it is unavailable.

* Finalize plain-text resume in place instead of reloading

On stream completion, resumeStream() called selectSession(), forcing a full
history re-fetch and a visible flicker right as the stream finished.

For plain text replies (no tool calls, sources, doc streaming, or multi-round
output) the live tokens are already rendered, so finalize in place: replace the
live bubble with a canonical single message via chatRenderer.addMessage (markdown
+ footer actions + metrics, the same renderer history uses), captured from the
streamed metrics event. No history refetch, no extra round-trip, no flicker.

Rich responses still reload, since their canonical render (tool bubbles, sources,
multi-bubble) is rebuilt from the saved DB record.

* Use a dedicated set for the resume re-attach lock; fix stale docblock

resumeStream() marked its re-attach lock in _backgroundStreams, which
checkBackgroundStream() also reads. On a second re-entry of the same session
while a resume was still live, checkBackgroundStream() mistook that entry for a
same-tab POST stream and spawned its own spinner+poll bubble. Move the lock to a
dedicated _resumingStreams set (also covered by hasActiveStream) so the two paths
no longer collide. Also update the resumeStream docblock to describe the
in-place finalize vs reload split.
2026-06-04 17:56:15 +02:00
Nicholai d572749a42 feat(memory): add provider interface (#72) 2026-06-04 16:26:11 +01:00
Kenny Van de Maele c8dbd5bcfa Merge pull request #2214 from vdmkenny/chore/rm-unused-upload-dir-import
chore: remove unused UPLOAD_DIR imports in document_routes
2026-06-04 17:11:15 +02:00
Kenny Van de Maele 171286c9e7 Merge pull request #2218 from vdmkenny/chore/rm-unused-uuid-import
chore: remove unused uuid import in app.py
2026-06-04 17:10:29 +02:00
Kenny Van de Maele 93c9ef94a4 Merge pull request #1966 from vdmkenny/ci-checks
feat(ci): add CI workflow (syntax + tests)
2026-06-04 16:54:32 +02:00
Alexandre Teixeira 16e25517d3 refactor(tests): add shared CLI test helpers
Adds shared test helpers for CLI script loading and scoped core.database stubs, then converts a low-conflict pilot set of CLI tests. Part of #2523.
2026-06-04 15:44:25 +01:00
raf 0ff02b8589 fix(hwfit): return no_fit instead of None when target_quant is a GGUF tier on multi-GPU (#2375)
The multi-GPU GGUF filter at fit.py:380 returned None unconditionally
for Q*/IQ quants on 2+ GPU systems. When the caller explicitly passes
target_quant, they are asking 'what happens if I try this?' and expect
a structured no_fit response, not a silent None.

Fix: skip the filter when target_quant is explicitly provided so the
call falls through to the existing no_fit path.

Fixes #
2026-06-04 14:25:36 +01:00
ooovenenoso 2bfd4533fa fix(document): render Mermaid in markdown preview (#2415) 2026-06-04 14:25:15 +01:00
Wes Huber 6213f8f76b fix: re-export _SPORTS_HINT_RE from search ranking shim (#2273)
The compatibility re-export shim at src/search/ranking.py forgot
_SPORTS_HINT_RE, so tests importing src.search.ranking raised
AttributeError on the [src] parametrize variant.

Fixes #1995

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 14:24:53 +01:00
raf 82bc5f5540 fix(tests): update search service mock to match current API signature (#2334)
comprehensive_web_search now called with (query, max_pages, return_sources)
and returns a tuple (_context, results). The test mock still used the old
async signature with max_results/fetch_content and returned a plain list,
causing TypeError on every run.

Fixes #2331
2026-06-04 14:19:51 +01:00
Fellah Youssef e217d4f65e feat(ui): allow expanding consolidated file chip regardless of count (#1849) (#2086) 2026-06-04 14:02:52 +01:00
Giuseppe 5ca6bd7ba9 fix: SSE stream parser crashes with NoneType on providers sending null choice/usage/tc entries (#2389)
* fix: SSE parser crashes with NoneType on MiniMax-M3 (and any provider sending null choice/usage/tc)

Three guards added in stream_llm:

1. choices[0] null check — MiniMax (and some other providers) send a
   choices entry as None. `_choices[0].get("delta")` raised
   AttributeError. Now checks `_choices[0] is not None` before calling
   .get().

2. usage null guard — j["usage"] can arrive as None (not a dict) on
   some providers. Added `or {}` so subsequent .get() calls don't crash.

3. tool_calls null entry skip — individual entries in the tool_calls
   array can be None. Added `if tc is None: continue` before
   tc.get("function").

All three match the `or {}` / null-guard pattern used elsewhere in the
same block. Safe for all OpenAI-compatible providers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: guard null choice in elif-choices SSE branch

The usage-chunk path already guarded _choices[0] is not None, but the
elif "choices" branch that processes content/tool-call deltas did not.
A chunk like {"choices": [null]} or {"choices": [null], "usage": null}
reaches j["choices"][0].get("delta") and crashes with:

    'NoneType' object has no attribute 'get'

Fix: extract choices[0] into _c0 and continue to the next chunk when
it is None, matching the guard already applied in the usage path.

Adds three focused regressions covering the paths the maintainer flagged:
- {"choices": [null]}
- {"choices": [null], "usage": null}
- tool_calls array containing a null entry alongside a valid call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 13:53:10 +01:00
Joeseph Grey 6378717731 security: sanitize rendered research-report HTML (#364)
The visual research report is assembled from LLM output over crawled web
pages (untrusted content) and served under a relaxed `script-src
'unsafe-inline'` CSP. Two values reached that HTML without sanitization:

- `_md_to_html` rendered the report markdown via python-markdown, which
  passes raw HTML through verbatim, so `<script>` / `<img onerror>` /
  `<svg onload>` / `javascript:` links carried in crawled content ran in
  the app origin.
- `category` (from the /api/research/start request body, no enum check) was
  interpolated raw into `<body class="category-{category}">`.

Allowlist-sanitize the rendered markdown with nh3, keeping the formatting
the report emits (tables, code, details/summary, toc anchors, codehilite
classes, external-link target/rel) while dropping active content, and
html.escape the category. Adds regression tests.
2026-06-04 13:42:49 +01:00
Massab K. d22a0adc16 Fix issue 135 chat context bleed (#281)
* Fix issue 135 chat context bleed

* Guard task delivery metadata access
2026-06-04 13:27:46 +01:00