Skip to content

feat(knowledge): inline citations & sources + simplified embeddings config UX#468

Open
dolev31 wants to merge 143 commits into
mainfrom
feat/knowledge-citations
Open

feat(knowledge): inline citations & sources + simplified embeddings config UX#468
dolev31 wants to merge 143 commits into
mainfrom
feat/knowledge-citations

Conversation

@dolev31

@dolev31 dolev31 commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ships inline citations & sources for the knowledge engine end-to-end, plus a ground-up simplification of the knowledge / embeddings configuration UX.

Citations & sources — Closes #154, Closes #155

  • Backend data model ([Design]: Citations & sources data model for knowledge retrieval #154): a thread-scoped SourceLedger stamps a stable, content-hashed cite_id on every retrieved chunk at both retrieval seams (client.search_envelope + routes.py::search, covering chat / sandbox / MCP / SDK). The id is carried on the wire and resolved by FinalAnswerNode into per-message [1]..[k] numbers with self-contained source snapshots that survive history replay. Marker parsing is hardened against model drift (ASCII + fullwidth/CJK bracket families) with a code-aware canary that logs unsupported bracket styles instead of silently dropping them.
  • UI ([Design]: Inline citation markers + hover previews + sources dropdown (UI) #155): a shared <cuga-cite> web component renders [n] markers as clickable chips with a hover preview (filename · page · snippet). The preview renders in the browser top layer (Popover API) so it isn't clipped inside markdown tables. A Sources panel/footer enumerates the cited documents; clicking a chip opens the source. Works in both the carbon-chat web UI and the extension CardManager surface.
  • SDK: CugaAgent(enable_knowledge=True, enable_citations=True)InvokeResult.sources ([{n, cite_id, filename, page, section_path, scope, snippet, score, query}]); per-agent and per-session citation toggles.

Knowledge / embeddings configuration UX

  • Reworked the embeddings/provider setup for progressive disclosure: the entire embedder config now lives under Advanced settings (hidden by default); the provider picker is a native grouped Select (no clipped labels, no confusing non-clickable rows); and the re-index prompt sits prominently at the seam directly under the Retrieval Profile cards, decoupled from the now-hidden config.
  • Per-provider field visibility, required-ness, and the security-critical field reset (prevents a stale base_url sending a key to the wrong server) all derive from a single providerSpec source of truth.
  • Fixes along the way: non-ASCII (e.g. Hebrew) document filenames on "Open document"; reindex-banner persistence; "Modified"-tag semantics; a temporal-dead-zone render crash that blanked the config modal; adopt-on-empty-KB semantics; unit-test DB isolation.

Verification

  • Reviews: ponytail (over-engineering), high-effort correctness, and an xhigh ship-review — all confirmed findings fixed.
  • tsc clean; citation unit + integration tests green (a deterministic real search → resolve → sources net, plus an opt-in live-model smoke); served frontend bundle rebuilt.
  • Branch is up to date / merges into main with no conflicts.

Follow-ups (tracked in #444)

Live-model citation e2e in CI, storage-at-scale validation (pgvector / Milvus), ingest streaming & performance, and the chunk_size-vs-embedder cap UX.

Part of the knowledge production-grade epic #444.


@sami-marreed — would you mind giving this a review when you get a chance? 🙏 It's a biggish one, so happy to walk you through any area (or split it) if that's easier. Thanks so much!

Summary by CodeRabbit

  • New Features

    • Added numbered citations and source metadata to knowledge-based answers.
    • Added clickable citation chips, source lists, previews, and a Sources side panel.
    • Added per-conversation citation controls and agent-level citation settings.
    • SDK responses now include answer sources.
    • Added improved embedding health visibility and session knowledge settings.
  • Bug Fixes

    • Improved reindexing safeguards, task tracking, document handling, and stream-event persistence.
    • Improved citation handling across streamed, resumed, and fallback answers.

Citation correctness (added on this branch)

Fixes #505 — cite_ids from an earlier turn no longer resolve in a later answer (turn-scoping), and stream_events is now cumulative (durable reload + citation rehydration). Reuse follow-ups: #506 (content verification) and #507 (provenance binding).

dolev31 added 30 commits June 28, 2026 14:16
Replaces the inline JSX block in KnowledgeConfig.tsx (lines 2678-2761,
93 lines) with a focused EnvPresetsPanel component (~165 lines) that
applies the 4-expert + product-lead synthesis from the redesign debate.

What changed (visible):
  - "Quick setup from environment" -> "Detected in your environment"
  - Subtitle prose deleted; replaced by a single Information tooltip
    next to the section label
  - Colored traffic-light dot -> 24x24 monogram (OA / OR / Wx / Az / Co)
    on $layer-02
  - Provider label + Information tooltip (model string moved here)
  - Category Tag (Cloud / Enterprise) instead of model string in row
  - "Apply" -> "Use" (verb-only; provider name is one column left)
  - Disabled "Incomplete" button -> gray "Set up" Tag with Tooltip
    listing the missing env vars
  - Active preset -> blue "Active" Tag, no button (currentProvider +
    currentModel prefix is the single source of truth)
  - Bottom Link "Or run locally with Fastembed or Ollama - no keys
    needed." focuses + scrolls to the Provider Select below

What changed (structural):
  - EnvPresetsPanel.tsx extracted as a focused component with explicit
    props {presets, currentProvider, currentModel, onApply,
    onFocusProviderSelect}; KnowledgeConfig.tsx shrinks by 76 lines
  - Carbon primitives: ContainedList + ContainedListItem + Tag +
    Tooltip + Button kind="ghost" + Link + Information icon
  - isPresetActive() handles the litellm shared-provider case by
    comparing model prefix (so Watsonx / Azure / Cohere don't all
    show Active simultaneously when one is selected)
  - No new dependencies. No backend contract change.

What got ponytail-trimmed from the original 12-step plan:
  - Storybook stories (no storybook in agentic_chat)
  - RTL tests (no @testing-library; not adding a dep for one test)
  - Snapshot test (same — no infra to lean on)
  - Manual QA happens once #352 merges and this lands on main

Bundle rebuilt: main.93465aec5a842d6e178a.

NOT pushed yet — this branch is held locally until PR #352 merges,
then rebases to main and opens as a focused follow-up PR (per the
expert debate's shipping decision: clean review boundary beats
expanding #352's 32k-line review surface).
…e5-large, expose non-secret env values, +5 providers, alias support, stricter Active match

Backend (manage_routes.py):
  - Watsonx default_model -> watsonx/intfloat/multilingual-e5-large
    (multilingual coverage + stronger retrieval quality than ibm/slate-30m)
  - Pipe-aliased required slots: "WATSONX_APIKEY|WATSONX_API_KEY" and
    "WATSONX_URL|WATSONX_API_BASE" — accepts both spellings LiteLLM does.
    Eliminates the watsonx special case in the loop.
  - Reject angle-bracket placeholders (<your-key>) in env_set check.
  - +5 providers: Gemini, Voyage AI, Mistral, Together AI, Jina AI.
    Each ships hidden by the UI row filter until env vars are set.
  - NEW response field env_values: dict[str, str] — non-secret detected
    values (URL / project ID / region / etc.) for the UI to surface.
    Credential material (KEY/APIKEY/TOKEN/SECRET/PASSWORD substring rule)
    is filtered server-side and NEVER reaches the client.

Frontend (EnvPresetsPanel.tsx):
  - EnvPreset interface extended with optional env_values.
  - Per-row Information tooltip now shows Model + every detected
    non-secret env value (envKeyLabel maps WATSONX_PROJECT_ID -> "Project
    ID", WATSONX_URL -> "URL", AZURE_API_BASE -> "Endpoint", etc.).
    Values truncated to 48 chars for compact display.
  - Monograms for the new providers: Gm / Vy / Ms / Tg / Ja.
  - isPresetActive tightened: non-litellm providers now require EXACT
    model match (preset.default_model === currentModel). Previously a
    user who picked "openai" via the Provider Select and typed a custom
    model saw a misleading "Active" indicator. Litellm match (prefix)
    is unchanged.

Provider-switching UX review (the scenarios I walked through):
  1. Click "Use Watsonx" -> Watsonx row goes Active, OpenRouter row
     becomes "Use" — coherent.
  2. Click "Use OpenRouter" after #1 -> roles swap — coherent.
  3. Provider Select "openai" with empty model -> NO preset shows
     Active (exact match fails). Correct.
  4. User customizes model on a preset provider -> Active deactivates,
     "Use" reappears. Clicking "Use" overwrites custom model with
     default (same as before — the panel's role is to bind to a preset).

Verified live against the user's actual .env:
  - WATSONX_APIKEY (no underscore) detected via alias slot.
  - env_values exposes WATSONX_URL + WATSONX_PROJECT_ID; APIKEY filtered.
  - default_model is the new intfloat/multilingual-e5-large.

Bundle: main.020a715825345eb30db8.

Scope note: backend changes (default_model swap, env_values field, +5
providers) extend PR #352's surface area since the endpoint was
introduced there. They land on this stacked branch (#383) which
auto-retargets to main on #352 merge.
…on + canonical Watsonx var name

Two changes to .env.example:

1. Fix the LLM section's Watsonx entry: WATSONX_API_KEY -> WATSONX_APIKEY
   (canonical name LiteLLM reads; the underscore variant is an alias
   but not the form documented in LiteLLM's IBM Watsonx provider docs).

2. Add an "Embedding Model — Knowledge retrieval" section before
   the trailing copy-instructions, listing every provider the Knowledge
   tab's "Detected in your environment" panel auto-discovers. Ten
   providers ordered alphabetically — Azure, Cohere, Gemini, Watsonx,
   Jina, Mistral, OpenAI, OpenRouter, Together AI, Voyage. Each block
   shows the canonical env var name(s) + a recommended default model.

Watsonx no longer leads the embedding section — alphabetical order
keeps it neutral so the file reads as "pick the provider you use"
rather than "Watsonx is the recommended path."

The new section sits below the optional features (Langfuse, OIDC,
OpenLit, E2B, LLM timeout) and above the "Copy to .env" line, so the
.env.example walks top-to-bottom from "required for LLM" through
"optional add-ons" to "optional for embeddings" to "how to apply."
…ted; add "Modified" tag

The profile picker treated user edits to advanced settings as a reason
to DROP the named profile from the selected visual state. So:

  pick Standard → edit chunk_size from 800 to 1000 →
  Standard tile loses border + background highlight → user can't tell
  which profile they're working from.

That misframes the relationship. The profile is a baseline; edits to
advanced settings are overrides on top of it, not a switch away from it.

Fix:

  - ``isSelected`` is now ``isNamedProfile`` alone (matches the
    ``rag_profile`` field in config — the user's intent). Edits no
    longer hide which profile is the baseline.
  - New flag ``isModified = isNamedProfile && !vectorConfigMatches``
    drives a small Carbon ``Tag type="cool-gray"`` reading "Modified"
    inside the selected tile's name slot.
  - In-tile hint when modified: "Your edits override profile defaults
    — re-indexing will run on Publish." Surfaces the consequence at
    the place the user made the change, not via a separate banner.
  - The pre-existing "Requires re-indexing existing documents" hint on
    NON-selected tiles is unchanged — that one informs the
    would-be-switch decision before they click, not the
    already-selected-with-edits state.

No behavior change to ``rag_profile`` semantics — it was already
preserved through ``onKnowledgeConfigChange`` (no auto-flip to
"custom"). The bug was purely visual selection logic.

Bundle: main.f58baaf8f09a27b92da3.
…d stale-bundle defense (1/4)

Backend foundation for the config-apply-flow synthesis (issue #384 follow-up).
Three small additions that make the existing draft+publish flow legible
without changing its architecture:

1. **PATCH /api/manage/config/draft/knowledge response** now carries:
   - ``vector_config_hash``: server-computed hash of embedder + chunking
     + metric. The UI compares to the LIVE hash to render the "Draft saved
     · differs from Live" pill — server fact, not a client guess.
   - ``apply_generation``: monotonic counter (Slice B). Bumps only on
     supersede-relevant change. Distinguishes a successful apply from a
     no-op (chunking only) or a soft-failed preflight.
   - ``reindex_required``: boolean = (embedding OR chunking OR metric
     changed). Convenience so the UI doesn't AND the three flags itself.

   These were already computed server-side; we just stop swallowing them.

2. **``commit_knowledge_update``** re-exposes ``apply_generation`` and
   ``vector_config_hash`` in its result dict. (apply_generation was
   trimmed earlier as YAGNI per the Slice B ponytail pass; the UI now
   has a concrete consumer — restoring earns its keep.)

3. **``X-Cuga-Build-Id`` response header middleware** stamps every
   ``/api/manage/*`` and ``/api/knowledge/*`` response. Source order:
   env ``CUGA_BUILD_ID`` (container build) > ``git rev-parse --short=12 HEAD``
   (local dev) > ``"dev"`` fallback. The webpack ``DefinePlugin`` injects
   the same value into ``CUGA_BUILD_ID`` on the client; ``apiFetch``
   compares headers vs constant on every response and fires a one-time
   ``cuga:stale-bundle`` CustomEvent on mismatch (the app renders a
   forced-reload banner on this event in Phase 5).

   ``"dev" === any`` is an explicit non-mismatch — local-dev iterators
   shouldn't see the banner.

Why this matters: the user just hit a real failure where they thought
they'd switched to Watsonx but the live config still had bge-small.
Three indistinguishable silent modes (no autosave / no Publish / stale
bundle) were possible. Phases 2–5 (frontend) use these new server facts
+ the build-id event to make each state legible.

Tests: 39/39 knowledge unit tests still pass.

Part of the 14-change synthesis from the 6-expert config-apply-flow
debate. Phases 3–5 (frontend state lift, Live pill, polish) follow in
the next commits on this branch.
…CH lifecycle (2/4)

The literal bug the user just hit: clicking "Use Watsonx" showed
"Saved" after 1500ms regardless of whether the PATCH actually returned
or what status it carried. Three indistinguishable silent failure
modes (no autosave / no Publish / stale bundle) led to a real config
that thought it was Watsonx but actually still embedded with bge-small.

Fix per the 6-expert synthesis: lift the save status out of
KnowledgeConfig (where it was a setTimeout-driven lie) into ManagePage
(where the actual PATCH .then/.catch live), and pass it back down
as a controlled prop.

**ManagePage.tsx** — new ``draftSaveStatus`` state machine + wiring:
  - { kind: "idle" }     — no pending change
  - { kind: "saving" }   — set when the PATCH goes out (debounced 800ms)
  - { kind: "saved", vectorConfigHash, applyGeneration, reindexRequired }
                          — on PATCH 2xx, carrying the SERVER-ECHOED
                            facts (hash + generation) for downstream
                            comparison with Live
  - { kind: "failed", error } — on PATCH 422 / non-2xx / network error

  The previously SILENT network-error catch at line ~1192 (literal
  source of the user's confusion — "Network failure (real) — silent")
  now flips the state to ``failed`` with a human-readable error.

**KnowledgeConfig.tsx** — accepts the new prop, removes the local
timer-driven ``saveState``:
  - Old: ``setTimeout(() => setSaveState("saved"), 1500)`` — lied.
    Deleted.
  - New: ``saveState`` is derived from ``draftSaveStatus.kind``.
    "Saved" pill auto-hides after 3s via ``recentlySaved`` flag so
    the user doesn't see a permanent sticker.
  - Added ``failed`` branch: red ghost "Couldn't save — Retry"
    button with the server error as tooltip + onClick fires
    ``onRetryDraftSave``.

**Retry mechanism** — minimal: spread-copy ``knowledgeConfig`` to
re-trigger the autosave useEffect. No new state, no parallel pipeline.

What you'll see in the UI now:
  - Click "Use Watsonx" → "Saving…" tag appears IMMEDIATELY (network
    call in flight)
  - PATCH returns 2xx → "Saved" appears, auto-hides in 3s
  - PATCH returns non-2xx OR network drops → red "Couldn't save —
    Retry" button persists until clicked or until next edit
  - No more lying about saved state. Every visible state corresponds
    to a real network event.

Bundle: main.b2e8c14f9ec1dfdab163.

Part of the 14-change synthesis from the 6-expert config-apply-flow
debate (issue #384 follow-up). Phase 1 (backend facts) shipped in
b5d22a4. Phases 4 (Live pill) + 5 (polish, including the misleading
"Reindex needed" banner deletion) follow.
…ftened reindex banner (3/4)

Phase 4 (Live pill) + the high-value Phase 5 polish items from the
6-expert config-apply-flow synthesis. Three changes:

**1. Live pill in the header — the truth anchor (Phase 4)**

  New ``liveKnowledge`` state in ManagePage, sourced from
  GET /api/manage/config (published=true) on mount and refreshed after
  every successful Publish — never updated optimistically from draft
  state. The pill renders in the existing manage-save-bar-status row
  showing:

      ● Live: fastembed · BAAI/bge-small-en-v1.5 · v7

  Dot color: green when ``draftSaveStatus.reindexRequired`` is false
  (live matches the just-saved draft for vector-config), yellow when
  the draft has changes the publish hasn't applied yet.

  This is the single most-cited UX addition from the synthesis —
  the user's failure mode ("I switched to Watsonx but logs show
  bge-small") had no UI surface that answered "what is actually
  running RIGHT NOW?" without log-reading. Now there is one, visible
  on every Manage-page load.

**2. Stale-bundle banner — consumes the X-Cuga-Build-Id mismatch event (Phase 5)**

  Listens for the ``cuga:stale-bundle`` CustomEvent that ``apiFetch``
  dispatches when the server's X-Cuga-Build-Id header doesn't match
  the webpack-injected client constant (both shipped in commit
  b5d22a4). Renders a non-dismissable warning InlineNotification at
  the top of the Manage page with a single Reload button.

  We do NOT auto-reload — that would destroy in-progress edits. The
  user chooses the moment.

  This kills the third silent failure mode from the user's report:
  "did I click the old JS that doesn't match the current API?"

**3. Softened "Re-index recommended" banner (Phase 5)**

  The "most-cited source of confusion" from the synthesis: the
  banner persisted alongside the new save-status pill, so users saw
  both "Saved" AND "Re-index needed" simultaneously after an
  auto-reindexed config change. We narrow the trigger condition
  from:

      (knowledgeReindexNeeded || knowledgeStale || knowledgeReindexDeferred)

  to:

      (knowledgeStale || knowledgeReindexDeferred)

  Dropping the transient ``knowledgeReindexNeeded`` flag (snapshot
  diff) — now redundant with the save-status pill that fires the
  moment the user changes a vector-config field. The persistent
  cases (knowledgeStale: vectors-under-old-config that survived a
  failed prior reindex; knowledgeReindexDeferred: user explicitly
  dismissed an earlier prompt) still surface the banner — the
  safety net for genuinely stuck states stays.

  New subtitle copy is more specific: "Some documents weren't
  re-indexed after a prior config change. Run re-index to update
  them." (Previously the generic "Settings changed. Existing
  documents may use outdated embeddings." which sounded urgent
  even when the system had already handled the change.)

Bundle: main.00fe751c7cd0c0cd311b.

Part of the 14-change synthesis from the 6-expert config-apply-flow
debate (issue #384 follow-up). Phases 1+2 backend foundation shipped
in b5d22a4; Phase 3 timer-kill shipped in c4deb33. Commit 4/4 to follow
covers the remaining smaller polish (Publish popover on non-trivial
reindex + preset-click debounce bypass).
…d PATCH-response fields

Ladder pass on b5d22a4 + b5ec4b8:

  vector_config_hash + apply_generation in PATCH response
    → UI reads only reindexRequired (for Live pill dot color)
    → reindexRequired is derivable client-side from draft vs liveKnowledge
    → DELETE all three (server + client)

  apply_generation + vector_config_hash in engine.commit_knowledge_update return
    → no external consumer after the above
    → DELETE (restore the prior Slice B ponytail trim)

  X-Cuga-Build-Id middleware + webpack DefinePlugin + apiFetch listener + banner
    → cuga is a LOCAL single-user agent; server + bundle restart together
    → user's actual bug was the timer lie, not a stale bundle
    → YAGNI. DELETE across 4 files (~110 LOC)

  draftSaveStatus.saved payload (vectorConfigHash / applyGeneration / reindexRequired)
    → field-comparison can compute "diverged" locally from
      knowledgeConfig vs liveKnowledge
    → SIMPLIFY to { kind: "saved" }

  Live pill dot color
    → now compares embedding_provider + embedding_model directly
    → no server-side hash round-trip needed

Net trim: 168 LOC across 6 files. The bug fix that earns its keep
(killing the 1500ms timer + lifting saveStatus to PATCH lifecycle +
Live pill in header + softened reindex banner) stays. The infra that
was speculative scaffolding (build-id defense for a local agent;
server-echoed hash for client-derivable comparison) is gone.

What remains as the FULL fix for the user's reported bug:
  - "Saving…" / "Saved" / "Couldn't save — Retry" tag driven by
    the actual PATCH .then/.catch lifecycle
  - Live pill in header showing what's actually serving traffic
  - Yellow dot when draft.embedding_* differs from live.embedding_*
  - Softened "Re-index recommended" banner (kept for genuinely stuck
    states only)

Bundle: main.32e5d102bb5fee28adee.
The 800ms debounce on the knowledge autosave coalesces rapid
keystrokes — but on an explicit "Use Watsonx" button click it just
adds visible lag before "Saving…" appears. Preset clicks are atomic
button presses with no coalescing benefit.

Mechanism — one ref, one signal:

  - ``forceImmediateSaveRef`` lives in ManagePage alongside the
    AbortController + other autosave refs.
  - The EnvPresetsPanel ``onApply`` in KnowledgeConfig now calls
    ``onPresetApplied?.()`` BEFORE ``onKnowledgeConfigChange``, so
    the ref bump lands before the autosave effect re-runs.
  - The autosave effect reads + consumes the ref on entry:
    ``debounceMs = forceImmediateSaveRef.current ? 0 : 800``
    then clears the ref. The next plain field edit goes back to
    the keystroke-debounce path.

Plain text-input edits (adaptation_text, base_url, etc) still get
the 800ms coalesce — only explicit button clicks get the bypass.

Skipped the synthesis's "Publish confirm popover" item:
``handleSaveClick`` already opens setShowReindexConfirm when
``knowledgeReindexNeeded && knowledgeDocCount > 0``. Modal-vs-Popover
is a visual swap, not a missing feature — ponytail. Add when actual
UX testing flags the modal as too intrusive.

Bundle: main.fae14f56ad097a483d83.

Closes the last item from the 14-change config-flow synthesis.
…gging + console breadcrumb + 60s safety-net

User reported the autosave pill stuck on "Saving…" after switching to
LiteLLM + watsonx/intfloat/multilingual-e5-large. Server log line 1681
showed an empty-message exception:

  ERROR | patch_draft_knowledge - Failed to patch draft knowledge:

Three issues attacked, none speculative — all in response to the
observed failure:

**1. Server: ``logger.exception`` instead of ``logger.error(f"...: {e}")``**
   The old format swallowed everything when ``str(e)`` was empty
   (some libs raise ``Exception()`` with no args). Now we get the
   full traceback in the log, and the HTTPException ``detail`` falls
   back to ``repr(e)`` so even an empty-message exception surfaces
   its class name to the client.

**2. Client: console.error on non-2xx + read response body**
   The autosave's else-branch (4xx/5xx without a 422 body) now logs
   the status + first 200 chars of the response body to the browser
   console, and includes the body in the failed-state error message.
   When the user reports "stuck on Saving" we now have a breadcrumb
   in dev tools to confirm the response did or didn't come back.

**3. Client: 60s safety-net timeout on "saving" state**
   useEffect that fires when saveStatus transitions to "saving" and
   schedules a fallback transition to "failed" if still saving after
   60s. Covers the edge case where a response goes to an aborted
   controller and no newer PATCH replaces the "saving" state — the
   pill no longer sticks forever. 60s is generous for a local-backend
   PATCH; beyond that means something's wrong worth surfacing.

Bundle: main.56c92335e9f35e92ebf4.

To reproduce the diagnosis: restart the backend so the new exception
logging takes effect, hard-refresh the browser to load the new bundle,
trigger the autosave failure again, share the full server log block
+ browser console output. The actual underlying exception class will
now be visible.
…auto-fire on PATCH)

User feedback: "a user might configure few things, and no need for automatic
reindexing every little change, we should show again the reindexing button
and clicking it will show the reindexing process".

Today's auto-reindex on PATCH fires the moment a single vector-config field
changes (embedder / chunking / metric). If a user opens Knowledge config to
tweak 3 things, they get 3 reindex passes — CPU burn + hyperactive UI. The
fix moves the trigger from "every PATCH that changed dim" to "the user
clicks Re-index when they're done editing".

Three coordinated changes:

**1. Backend (manage_routes.py) — gate auto-reindex off**
   The dim_changed auto-reindex block in patch_draft_knowledge is deleted
   (no more reindex during PATCH). Response still signals
   ``auto_reindex: {triggered: false, reason: "manual_required"}`` so the
   UI knows where it stands. The migration logic (copy source files + drop
   stale + reindex target + update knowledge_config_hash) is extracted
   into ``_migrate_and_reindex_for_agent`` and exposed via a new endpoint.

**2. Backend (manage_routes.py) — new POST /api/manage/knowledge/reindex_for_config**
   User-triggered. Computes the engine's current vector_config_hash,
   finds source collections (old-hash dirs), copies files to the target
   dir, drops + re-embeds with the active engine config, and updates
   ``app_state.knowledge_config_hash`` so searches/ingests go to the
   migrated data. Returns the same ``{triggered, target, collections}``
   shape the old auto-reindex returned — the frontend reindex-tile
   arming code stays unchanged.

**3. Frontend (api.ts + ManagePage.tsx + KnowledgeConfig.tsx)**
   - New API helper ``triggerKnowledgeReindexForConfig(agentId)`` calls
     the new endpoint.
   - ``onReindex`` routes to the new endpoint when
     ``knowledgeReindexNeeded`` (snapshot diff says vectors are stale
     vs current config) and falls back to the plain ``/api/knowledge/reindex``
     otherwise. The new-shape response is normalised so the same callsite
     handles both.
   - "Re-index recommended" banner trigger restored to include
     ``knowledgeReindexNeeded`` (which I'd previously narrowed away when
     auto-reindex was making it redundant). The banner is now the user's
     only signal that reindex is needed — restoring the trigger is the
     visible half of this change.
   - Subtitle copy updated: "Settings changed. Click Re-index to update
     existing documents with the new configuration."

What the user sees now:

  Open Knowledge config → pick Watsonx → "Saved" pill flashes →
  "Re-index recommended" banner appears with [Re-index all documents]
  button → user picks chunk_size → still just "Saved" + the banner
  (no second reindex fires) → user finally clicks Re-index → progress
  tile arms + workers run once with the final settings.

Tests: 38/38 knowledge unit tests pass. Bundle:
main.27c7b8c912fb8c43b279.
…he Re-index banner

User reported: pick Watsonx -> "Re-index recommended" shows -> revert
via Provider Select dropdown to the original provider -> banner STAYS
even though config is effectively back to where it started.

Root cause: the Provider Select onChange resets ``embedding_model``
to "" (alongside api_key/base_url/extra_params), but the saved
snapshot still holds the original model name like
"BAAI/bge-small-en-v1.5". The diff treats "" != "BAAI/..." as a
change and keeps the banner up — even though "" means "use provider
default" which IS the original.

Fix: normalise empty ``embedding_model`` as "no change" when the
provider matches the snapshot. Concretely:

  providerChanged = current.provider !== saved.provider
  modelChanged   = providerChanged
                    ? true
                    : current.model && current.model !== saved.model

  banner_needed = providerChanged || modelChanged || chunking/metric changed

Result: picking Watsonx then reverting to fastembed via the dropdown
clears the model to "", but the provider matches saved -> modelChanged
is false -> banner hides. Honest match between UI state and engine
behaviour (engine picks the provider default for empty model).

Same trap doesn't apply to chunk_size / chunk_overlap (numeric, never
empty-string after edits).
…ralize the principle

User's principle: "it should work the same with every configuration that
effects the indexing — we should manage it efficiently". The previous
commit fixed embedding_model specifically; this generalises the pattern
into one place that any future index-affecting field can extend.

``isIndexConfigEquivalent(current, saved)`` lives next to
DEFAULT_KNOWLEDGE_CONFIG so the equivalence rule sits beside the field
shape it operates on. Returns true iff the two configs would produce
the same vector_config_hash on the server.

The rule, per field:

  embedding_provider  : strict equality
  embedding_model     : empty current on same provider == match saved
                        (Provider Select resets to "" on provider change;
                        engine resolves empty -> provider default at embed
                        time, so the snapshot's specific model and the
                        current empty are functionally the same)
  chunk_size          : strict equality (numeric, never empty after edits)
  chunk_overlap       : strict equality
  metric_type         : strict equality (Select with enum values)

If a future UI surface introduces another empty-string-on-reset for a
hash-relevant field, add the same empty-means-default branch in this
function. One file, one place.

The Re-index banner trigger collapses to:

  changed = !isIndexConfigEquivalent(current, saved)

— no per-field branching in the useEffect, no risk of forgetting a
field. The principle is named, not implicit.
…eviewer pass

Three reviewers (2 UX experts + 1 Carbon designer) converged on a 6.3/10
"needs-refinement" verdict with a tight pre-client checklist. This commit
ships items 1–5 (the must-fixes that gate the demo); items 6–8 are
polish that can ship after.

**1. Lift EnvPresetsPanel out of Advanced (the one-chance moment)**

The env-presets panel was buried behind the off-by-default Advanced
toggle. For the enterprise client persona with a Watsonx key in .env,
this hid the single most reassuring moment ("we noticed your key"). Now
renders at the TOP of the knowledge-enabled section, above the
Retrieval Profile picker, gated on rows.length > 0. The Provider
Select inside Advanced is still the place for manual model entry;
``onFocusProviderSelect`` opens Advanced (if closed) and scrolls.

**2. Soften "Re-index recommended" banner**

Was kind="warning" + "danger--tertiary" button — alarming for a routine
config change. Now kind="info" + "tertiary" button, retitled from
"Re-index recommended" to "Update existing documents". The action is
routine; the framing now matches.

**3. Tooltip + cursor on Live yellow-dot state**

The Live pill's yellow dot (draft differs from Live) had no signal of
what it meant or what to do. Wrapped in Carbon ``Tooltip``: "Draft
differs from Live (now {provider} · {model}). Click Publish to apply."
Button-element trigger for keyboard reachability; cursor: help when
diverged.

**4. 60s safety-net → two-stage 25s/90s with in-flight abort**

The prior single-flip at 60s lied on slow corporate VPNs where 30–45s
saves are normal. New behaviour:

  - 25s: pill softens to "Still saving — network is slow" (new
    ``saving-slow`` state in the DraftSaveStatus union). User
    informed, not failed.
  - 90s: aborts the in-flight controller AND flips to failed.
    Abort prevents a stale-snapshot overwrite if the response
    arrives later.

**5. Replace title-attr error with InlineNotification**

"Couldn't save — Retry" hid the server error in a native title
attribute (truncated to ~80 chars, invisible to most users). Now an
Inline ``InlineNotification kind="error"`` renders below the Retry
button with the FULL server error as subtitle. Close button on the
notification re-triggers the PATCH (same effect as clicking Retry).

What's deferred (polish items 6–8, ~50 min total):
  - Second save pill at the Retrieval Profile section header
  - Carbon vocabulary pass (Live pill as Tag, drop purple Enterprise tag)
  - Delete the duplicate Re-index InlineNotification in ManagePage

Reviewer verdict was "ship after items 1–5". Now ready.
…g, drop duplicate banner, save pill at visible surface

Three polish items from the pre-client review (items 6-8) + a small
ponytail trim on the must-fix commit (15c5812). All optional-prop
additions, fully backward-compatible for SDK consumers.

**6. Save pill at the Retrieval Profile section header**

The save pill previously lived inside the Advanced accordion next to
Test connection — invisible during the most common edit path (clicking
profile tiles). New instance renders inline next to the Retrieval
Profile h4 so users see Saving/Saved/Couldn't-save feedback on the
exact surface they're interacting with. ``recentlySaved`` keeps the
green tag visible only 3s so it doesn't become a permanent sticker.

**7. Carbon vocabulary pass**

  - Live pill: was a custom button-wrapped p+span with 30 lines of
    inline styles mimicking the plain text layout. Now a Carbon Tag
    (green in-sync / warm-gray diverged). Tooltip stays for the
    diverged case only — in-sync needs no explanation. Uses
    isIndexConfigEquivalent (the shared helper) so the Live-pill dot
    color and the Re-index banner trigger agree on what "diverged"
    means.
  - "Active" preset state: was blue → now green + Checkmark icon.
    Matches the "Saved" pill's green-with-Checkmark vocabulary — one
    visual signal for "this is the current state."
  - Enterprise category Tag: was purple, now outline (same as Cloud).
    Single muted category indicator instead of color-coded
    classification that fought the action-color vocabulary.

**8. Delete duplicate Re-index InlineNotification on the agent card**

ManagePage rendered "Re-index recommended" as kind="warning" right
above the "Configure knowledge base" button — duplicated the in-modal
notification the user sees when they actually open the config. Most-
cited noise source in the pre-client review. Removed. The Live pill's
yellow dot + tooltip already signal divergence from outside the
modal; the actionable notification inside the modal is the right
place for the Re-index button.

**Ponytail cleanups on 15c5812**:

  - The Live pill's local ``diverged`` calc duplicated the rule that
    isIndexConfigEquivalent already encodes. Now uses the shared
    helper so the in-sync tooltip / yellow dot / banner trigger
    can't drift apart.
  - In-sync tooltip ("Live config matches your draft") deleted — the
    visible label already says that. Tooltip wraps the Tag only when
    diverged.
  - 30 lines of inline button styles to mimic plain text →
    Tooltip+button wrapper around a Carbon Tag. Lighter, idiomatic.

**SDK backward compat verified**:

  - All new KnowledgePanelProps fields stay optional. Consumers that
    don't pass ``draftSaveStatus`` get ``saveState = "idle"`` →
    save-pill branches don't render → behavior matches pre-PR.
  - No new required props, no breaking type changes.
  - Carbon idiom switches affect only the existing KnowledgePanel
    consumer (ManagePage) — App.tsx in agentic_chat uses
    KnowledgeSidePanel (separate component), unaffected.

Bundle: rebuilt. Reviewer's 8/8 checklist complete; #383 ready for
client demo after the manual QA below.
Two user-feedback fixes:

**1. "Detected in your environment" lives in an Embedding model section**

The env-presets panel was floating above the Retrieval Profile picker
with no conceptual frame — visually surfaced but semantically loose.
Now wrapped in its own labeled section "Embedding model" placed
between Retrieval Profile and the Advanced toggle:

  Knowledge Base toggle
  Agent + Session scope tiles
  Retrieval Profile (4 tiles)        <- WHICH retrieval strategy
  Embedding model (env-presets)      <- WHICH embedder runs   <-- new label
  Advanced toggle                    <- manual override path

Heading + 1-line description tell the user what the section picks
("How your documents get embedded for retrieval") before the env-
presets panel renders. Renders only when ≥1 provider is detected
(otherwise a heading with no rows reads broken).

**2. Re-index copy: sharper, action-first**

The prior "Update existing documents" was reviewer-correct but user-
ambiguous — sounded optional when it's mandatory for the config
change to take effect on already-indexed documents. New copy leads
with the action:

  Title:    "Re-index to apply your changes"
  Subtitle: "Existing documents still use the previous embedder. New
             uploads will use the new configuration, but already-
             indexed documents need a re-index to switch."
  Button:   "Re-index now" (kind="primary", was tertiary)

Primary-kind button because re-index IS the next step — making it
tertiary back when we softened the banner from kind="warning"
overcorrected. Tertiary kept the visual quiet but lost the call
to action. Primary restores it without the alarm-yellow surface.
…nfig change isn't blocked

User-reported edge case: after a successful re-index, the green "Re-
index complete" InlineNotification stays up until the user clicks X.
If they then change profile, the new "Re-index to apply your changes"
banner is suppressed because its trigger condition is
``!reindexProgress`` — and reindexProgress is still non-null while
the success banner shows.

Fix: auto-dismiss the success banner after 3.5s. The banner's X is
hidden on success too (closeButton only on failure, where the user
needs time to read the failed-count).

Failure path unchanged: ``failed > 0`` keeps the banner with X so the
user can dismiss after reading what failed.

Edge cases covered (per "all edge cases should be considered"):

  - User changes profile mid-celebration → success auto-dismisses,
    new "Re-index to apply" banner takes its place
  - User clicks X during the 3.5s window → cleanup clears the timer
  - User closes + reopens modal → reindexProgress is local state,
    resets to null on remount, success can't re-fire
  - User triggers another reindex during the window → new
    reindexProgress with done=false; effect re-evaluates and skips
    the dismiss timer
  - Reindex completes with partial failures → banner stays until X
…n local config DB

Two protections for UI-entered secrets (API keys, tokens, passwords)
identified in the security audit after the UX review. Closes the
gaps an enterprise client would notice in a credential-storage demo.

**A. Redact secrets in GET /api/manage/config responses**

The prior GET response returned the full config including
``knowledge.embedding_api_key`` and ``llm.api_key`` in cleartext.
Anyone with HTTP access to the running server (default 127.0.0.1:7860,
no auth gate) could curl out keys typed via the UI.

New behaviour:

  - ``_redact_secrets_in_config`` walks the response dict and replaces
    every secret-named field with ``""``. Substring rule
    (API_KEY / APIKEY / TOKEN / SECRET / PASSWORD) mirrors the env-
    presets endpoint's ``_is_secret`` — both API surfaces agree on
    what counts as secret.
  - The engine doesn't depend on the GET API to read keys; it loads
    from config_store on startup into in-memory state. So redaction
    is purely a network-surface fix.

PATCH symmetry — preserve stored secrets when incoming is empty:

  - The UI form re-saves with empty api_key after a redacted GET
    populated the field. Naive merge would WIPE the stored value.
  - The PATCH path now strips empty-string secret fields from the
    incoming payload before merging — empty means "preserve stored"
    not "clear stored". The user can still explicitly type a new
    value to overwrite (their clear intent).

**B. chmod 600 on the local config SQLite DB**

``~/.cuga/cuga.db`` previously inherited the default umask (0o644,
world-readable on the host). It holds agent configs including UI-
entered embedding_api_key / llm.api_key in plaintext (no encryption
layer). Now pre-created (touch) with 0o600 before SQLite opens it,
so mode is set at creation not retroactively. Matches the
``.internal_token`` pattern already in use elsewhere.

Best-effort: catches OSError so Windows / network FS that don't
honor POSIX modes don't fail at startup. The OS layer enforces its
own ACLs there.

Smoke-tested:

  - Redaction: ``embedding_api_key=sk-real`` → ``""`` in response;
    non-secret fields (``embedding_base_url``) preserved.
  - Preserve: empty incoming ``embedding_api_key`` + existing
    ``sk-stored-real`` → merged config keeps ``sk-stored-real``.
…from ALL source collections

**1. Env-presets panel relocated back inside the Embeddings accordion**

I'd misread the earlier "should be in the embeddings section" feedback
as "promote to its own visible section above Advanced." Now it lives
inside Advanced > Embeddings AccordionItem, where conceptually it
belongs (same scope as the manual Provider Select + API key + base
URL fields). Two modes of entry to ONE config: pick a detected preset
(env-presets) or fill the manual fields yourself.

The "Embedding model" h4 + 1-line description I'd added above Advanced
also went with this — the AccordionItem title "Embeddings" carries
the section name now.

**2. Migration copies from ALL non-target collections (bug fix)**

User-reported: had 5 documents listed in the Documents tab; clicked
Re-index; progress tile showed only 2 docs being indexed.

Root cause: ``_migrate_and_reindex_for_agent`` walked every
non-target collection dir but stopped after the FIRST successful
``copy_source_files``. Any docs in OTHER old-hash dirs (because the
user changed config more than once, or uploaded across two different
embedder states) were tagged ``orphan_stale`` and silently dropped.

Symptom on disk before this fix:

  ~/.cuga/knowledge/files/
    kb_agent_cuga_default_<old_hash_A>/   ← 2 .pdf files (migrated)
    kb_agent_cuga_default_<old_hash_B>/   ← 3 .pdf files (orphaned)
    kb_agent_cuga_default_<new_hash_C>/   ← target, ends with only 2

Now the loop copies files from EVERY non-target collection into the
target dir BEFORE the single reindex pass. Filename collisions (same
PDF uploaded twice across configs) resolve to last-copy-wins via
shutil.copy2 — acceptable for the common case.

Also tightened: ``target_done`` now only flips true on a real
non-empty reindex result; ``no_documents`` (empty merged set) no
longer promotes ``knowledge_config_hash`` to point at a void.
@coderabbitai coderabbitai Bot added complexity: high Large or risky change — needs careful review readability: good Clear PR goal and description; easy to review labels Jul 9, 2026
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
putComment timed out

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/cuga/sdk.py (1)

2439-2466: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fallback citation resolution bypasses the citations-disabled gate.

FinalAnswerNode.apply_citation_resolution only resolves via a live ledger when effective_citations_enabled(thread_id) is true, otherwise it strips markers (ledger=None). This fallback path (hit when final_answer is empty, e.g. reasoning models) skips that check and always does get_ledger(thread_id, create=False) — if a ledger already exists for the thread from an earlier turn, markers get resolved into display numbers even when citations were subsequently disabled for this thread/session, diverging from the primary path's documented and tested behavior.

🐛 Proposed fix
             from cuga.backend.knowledge.sources import (
+                effective_citations_enabled,
                 get_ledger,
                 has_citation_markers,
                 resolve_citations,
             )

             if final_answer and has_citation_markers(final_answer):
-                ledger = get_ledger(thread_id, create=False)
+                ledger = (
+                    get_ledger(thread_id, create=False)
+                    if thread_id and effective_citations_enabled(thread_id)
+                    else None
+                )
                 final_answer, fallback_sources = resolve_citations(final_answer, ledger)
             else:
                 fallback_sources = []
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cuga/sdk.py` around lines 2439 - 2466, The fallback citation cleanup in
src/cuga/sdk.py is bypassing the citations-disabled behavior used by
FinalAnswerNode.apply_citation_resolution. Update the fallback path that
extracts final_answer from the last AI chat message so it first checks
effective_citations_enabled(thread_id) and only calls
get_ledger(...)/resolve_citations when citations are enabled; otherwise strip
citation markers or leave them unresolved to match the primary path’s behavior.
Keep the fix localized around the fallback_sources/final_answer logic and reuse
the same citation-resolution helpers already used by FinalAnswerNode.
🧹 Nitpick comments (10)
src/cuga/backend/knowledge/client.py (1)

240-260: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated citation-annotation gate into a helper.

The if citations_enabled_for(...): annotate_envelope_with_citations(...) block is duplicated verbatim between the multi-scope (_run_multi) and single-scope paths. Extracting a small helper (e.g. _maybe_annotate_citations(envelope, results, config, thread_id, query)) would keep the gate logic in one place as citation rules evolve.

♻️ Proposed refactor
+    def _maybe_annotate_citations(envelope, results):
+        if citations_enabled_for(self._engine._config, thread_id):
+            annotate_envelope_with_citations(envelope, results, thread_id=thread_id, query=query)
+
     async def _run_multi(scope_requested: str, fallback_from: str | None) -> dict[str, Any]:
         ...
             envelope = build_retrieval_envelope(...)
-            if citations_enabled_for(self._engine._config, thread_id):
-                annotate_envelope_with_citations(envelope, results, thread_id=thread_id, query=query)
+            _maybe_annotate_citations(envelope, results)
             return envelope
     ...
         envelope = build_retrieval_envelope(...)
-        if citations_enabled_for(self._engine._config, thread_id):
-            annotate_envelope_with_citations(envelope, results, thread_id=thread_id, query=query)
+        _maybe_annotate_citations(envelope, results)
         return envelope

Also applies to: 295-307

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cuga/backend/knowledge/client.py` around lines 240 - 260, The
citation-annotation gate is duplicated in the multi-scope and single-scope
retrieval paths, so centralize it in a small helper to keep the rule in one
place. Add a private helper near `_run_multi`/the single-scope equivalent, such
as `_maybe_annotate_citations(envelope, results, thread_id, query)`, that checks
`citations_enabled_for(self._engine._config, thread_id)` and calls
`annotate_envelope_with_citations(...)`; then replace both inline `if` blocks
with that helper.
tests/unit/test_knowledge_reindex_busy_mapping.py (1)

20-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Mutable class-level defaults could leak state across instances.

_reindex_in_progress/_reindex_deferred are shared set() objects at the class level; any instance mutation (.add()/.discard()) would be visible to every other instance/subclass unless overridden in __init__. Not exercised today, but worth an __init__ assignment to avoid future cross-test leakage.

♻️ Suggested fix
 class _FakeEngineBase:
-    _reindex_in_progress: set = set()
-    _reindex_deferred: set = set()
     _files_dir = None
+
+    def __init__(self):
+        self._reindex_in_progress: set = set()
+        self._reindex_deferred: set = set()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_knowledge_reindex_busy_mapping.py` around lines 20 - 37, The
_FakeEngineBase test helper uses mutable class-level defaults for
_reindex_in_progress and _reindex_deferred, which can leak state across
instances and subclasses. Move these set initializations into an __init__ method
on _FakeEngineBase so each instance gets its own fresh sets, and keep the
existing _Config/vector_config_hash behavior unchanged for the reindex path
being tested.

Source: Linters/SAST tools

src/cuga/backend/knowledge/engine.py (1)

4193-4302: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

zip() without strict=True in reindex worker.

zip(file_list, task_ids) (lines 4240, 4261) will silently truncate if the two lists ever diverge in length. Traced the construction: task_ids is appended 1:1 from file_list right before the worker is defined, and any exception during that loop aborts before the worker starts — so this can't currently misfire. Adding strict=True is cheap defensive insurance against a future refactor breaking that invariant silently.

♻️ Suggested tweak
-                                for fp, tid in zip(file_list, task_ids)
+                                for fp, tid in zip(file_list, task_ids, strict=True)

and similarly at the timeout-handling zip on line 4261.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cuga/backend/knowledge/engine.py` around lines 4193 - 4302, The reindex
worker uses zip(file_list, task_ids) in two places, which can silently truncate
if the lists ever diverge. Update the pairing logic in the reindex flow around
_reindex_worker and the timeout cleanup to use a strict zip variant so
mismatches fail fast instead of dropping entries, preserving the intended 1:1
mapping between file_list and task_ids.

Source: Linters/SAST tools

tests/unit/conftest.py (1)

29-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider logging the swallowed exception instead of silent pass.

Static analysis flags the bare except Exception: pass blocks. Since this fixture exists specifically to prevent state leaking across tests, silently swallowing an invalidate failure could hide the exact class of bug it's meant to guard against.

♻️ Suggested tweak
     try:
         facade.get_storage().invalidate_relational_stores()
-    except Exception:
-        pass
+    except Exception as e:
+        logger.debug(f"invalidate_relational_stores (setup) skipped: {e}")
     yield
     try:
         facade.get_storage().invalidate_relational_stores()
-    except Exception:
-        pass
+    except Exception as e:
+        logger.debug(f"invalidate_relational_stores (teardown) skipped: {e}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/conftest.py` around lines 29 - 37, The fixture in conftest.py is
swallowing failures from facade.get_storage().invalidate_relational_stores()
with bare except blocks, which hides useful debugging context. Update the
teardown/setup around the shared invalidate_relational_stores call to catch the
exception and log it through the existing test logging mechanism (or re-raise
after logging if appropriate), so failures are visible instead of being silently
ignored.

Source: Linters/SAST tools

tests/unit/test_knowledge_chunking_tokenizer_accessor.py (1)

154-163: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace assert False with pytest.fail/raised assertion.

Static analysis flags both: a blind except Exception (BLE001) and two assert False statements (B011) that are stripped under python -O, silently defeating the intended failure checks.

♻️ Proposed fix
-        try:
-            dataclasses.replace(tok, kind="hf")  # via replace is fine
-        except Exception:
-            assert False, "replace must work on frozen dataclass"
+        dataclasses.replace(tok, kind="hf")  # via replace is fine; let any error surface naturally
         # Direct mutation must fail.
         try:
             tok.kind = "hf"  # type: ignore[misc]
         except dataclasses.FrozenInstanceError:
             return
-        assert False, "direct attribute mutation should have raised"
+        pytest.fail("direct attribute mutation should have raised")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_knowledge_chunking_tokenizer_accessor.py` around lines 154 -
163, The frozen-dataclass test in the token accessor check uses blind exception
handling and assert False fallbacks; update the test body to use explicit
failure signaling instead. In the test around dataclasses.replace(tok,
kind="hf") and the direct tok.kind mutation, replace the broad except Exception
and both assert False statements with pytest.fail or a raised assertion so
failures remain visible under optimization and the intended checks are explicit.

Source: Linters/SAST tools

src/cuga/backend/knowledge/sources.py (1)

352-362: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Add strict=True to zip() to enforce the chunk-result correspondence invariant.

The docstring states envelope["results"][i] must correspond to results[i], but zip(chunks, results) silently truncates on length mismatch. If envelope.get("results") returns None or a shorter list than results, some chunks would be left unstamped without any error. strict=True turns this silent failure into a loud ValueError.

♻️ Proposed fix
-        for chunk, result in zip(chunks, results):
+        for chunk, result in zip(chunks, results, strict=True):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cuga/backend/knowledge/sources.py` around lines 352 - 362, Add
strict=True to the zip in the citation stamping logic so the chunk/result
pairing invariant is enforced. In the function that gets the ledger and iterates
over chunks and results, change the `zip(chunks, results)` usage to a strict zip
so a length mismatch raises immediately instead of silently truncating and
leaving some chunks unstamped.
src/cuga/backend/knowledge/routes.py (1)

760-771: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Missing exception chaining (raise ... from).

Static analysis (Ruff B904) flags three raise HTTPException(...) statements inside except blocks without from err/from None (Lines 760-771, 848-851, 862-863). This is a minor observability nit — without chaining, the original traceback context is dropped from logs, making it harder to debug the underlying cause (e.g. a malformed JSON body vs. a deserialization bug).

♻️ Suggested fix (session settings excerpt)
     try:
         body = await request.json()
-    except Exception:
-        raise HTTPException(status_code=400, detail="request body must be a JSON object")
+    except Exception as exc:
+        raise HTTPException(status_code=400, detail="request body must be a JSON object") from exc
     ...
     except ValueError as exc:
-        raise HTTPException(status_code=400, detail=str(exc))
+        raise HTTPException(status_code=400, detail=str(exc)) from exc

Also applies to: 848-863

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cuga/backend/knowledge/routes.py` around lines 760 - 771, Add explicit
exception chaining to the HTTPException raises inside the except blocks flagged
by Ruff B904 in routes.py. Update the reindex/delete handler and the other
HTTPException paths in the same module so the raised HTTPException preserves the
caught exception context using the original exception variable (or suppresses it
with from None where appropriate). Use the surrounding except blocks around
ReindexInProgressError and the other affected route handlers to locate and fix
all three raise sites consistently.

Source: Linters/SAST tools

src/frontend_workspaces/frontend/src/carbon-chat/customSendMessage.ts (1)

487-575: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Wrap the Answer/FinalAnswer case body in a block to clear the Biome noSwitchDeclarations errors.

Biome flags the new let/const declarations (answerSources, answerCompleteItem, answerGenericItems, sourcesItem, finalResponse) as errors because their scope leaks across the other case clauses. There is no runtime fallthrough today (the case ends in return), but these are lint errors that can fail CI. Wrapping the case body in { } — as the sibling customLoadHistory.ts already does — resolves them.

♻️ Proposed block-scoping
         case "Answer":
-        case "FinalAnswer":
+        case "FinalAnswer": {
           console.log("Received Answer event, finalizing message...");

           let answerText = accumulatedText || "";
           let answerSources: MessageSource[] = [];
           ...
           instance.messaging.addMessageChunk(finalResponse);
           
           console.log("Message finalized successfully");
           return; // Exit after finalizing
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend_workspaces/frontend/src/carbon-chat/customSendMessage.ts` around
lines 487 - 575, The Answer/FinalAnswer branch in customSendMessage’s switch
needs block scoping to satisfy Biome’s noSwitchDeclarations rule. Wrap the
entire case body in its own braces so the local declarations like answerSources,
answerCompleteItem, answerGenericItems, sourcesItem, and finalResponse are
scoped to that branch only, matching the pattern used in customLoadHistory.ts
and preventing lint errors from leaking into other case clauses.

Source: Linters/SAST tools

src/cuga/backend/server/conversation_history.py (1)

435-467: 🧹 Nitpick | 🔵 Trivial

Consider scheduling gc_ephemeral_stream_events periodically, not just at startup.

It currently only runs once during lifespan startup (main.py ~1042-1047). For long-running deployments, orphaned Try-It-Out rows will keep accumulating between restarts. Consider wiring it into the existing periodic background-task pattern (same style as the knowledge engine's background maintenance tasks).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cuga/backend/server/conversation_history.py` around lines 435 - 467,
gc_ephemeral_stream_events is only invoked during startup, so orphaned
Try-It-Out stream_events can accumulate in long-running deployments. Wire
conversation_history.gc_ephemeral_stream_events into the existing periodic
background-task mechanism used elsewhere (for example, the knowledge engine
maintenance pattern) so it runs on a schedule, while keeping the existing
startup call as-is if desired.
src/cuga/backend/server/manage_routes/knowledge_routes.py (1)

95-108: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoff

asyncio.wait_for won't stop the embed thread — a hung/unreachable endpoint leaks a shared-executor worker.

On timeout, wait_for cancels the future wrapping to_thread(_do_test), but the underlying thread keeps running create_embeddings/embed_query until the network call itself returns. Since to_thread uses the loop's default ThreadPoolExecutor (shared with e.g. patch_draft_knowledge's apply_knowledge_config), repeated Test-connection calls against a hanging host can pin all workers and stall unrelated request-thread offloads. Consider running _do_test on a dedicated single-worker executor for this endpoint, and/or documenting the leak so operators size the pool accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cuga/backend/server/manage_routes/knowledge_routes.py` around lines 95 -
108, The timeout handling in the Test-connection flow does not actually stop the
underlying embedding work, so a hung endpoint can keep a shared executor thread
busy. Update the async test path in knowledge_routes’ endpoint around the
_do_test / asyncio.wait_for / asyncio.to_thread call so it uses a dedicated
single-worker executor (or equivalent isolated execution) instead of the loop
default ThreadPoolExecutor, and keep the timeout response behavior intact.
Reference the _do_test helper and the wait_for/to_thread block so the fix stays
localized to this route and does not affect other offloaded work like
apply_knowledge_config.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/cuga/sdk.py`:
- Around line 2439-2466: The fallback citation cleanup in src/cuga/sdk.py is
bypassing the citations-disabled behavior used by
FinalAnswerNode.apply_citation_resolution. Update the fallback path that
extracts final_answer from the last AI chat message so it first checks
effective_citations_enabled(thread_id) and only calls
get_ledger(...)/resolve_citations when citations are enabled; otherwise strip
citation markers or leave them unresolved to match the primary path’s behavior.
Keep the fix localized around the fallback_sources/final_answer logic and reuse
the same citation-resolution helpers already used by FinalAnswerNode.

---

Nitpick comments:
In `@src/cuga/backend/knowledge/client.py`:
- Around line 240-260: The citation-annotation gate is duplicated in the
multi-scope and single-scope retrieval paths, so centralize it in a small helper
to keep the rule in one place. Add a private helper near `_run_multi`/the
single-scope equivalent, such as `_maybe_annotate_citations(envelope, results,
thread_id, query)`, that checks `citations_enabled_for(self._engine._config,
thread_id)` and calls `annotate_envelope_with_citations(...)`; then replace both
inline `if` blocks with that helper.

In `@src/cuga/backend/knowledge/engine.py`:
- Around line 4193-4302: The reindex worker uses zip(file_list, task_ids) in two
places, which can silently truncate if the lists ever diverge. Update the
pairing logic in the reindex flow around _reindex_worker and the timeout cleanup
to use a strict zip variant so mismatches fail fast instead of dropping entries,
preserving the intended 1:1 mapping between file_list and task_ids.

In `@src/cuga/backend/knowledge/routes.py`:
- Around line 760-771: Add explicit exception chaining to the HTTPException
raises inside the except blocks flagged by Ruff B904 in routes.py. Update the
reindex/delete handler and the other HTTPException paths in the same module so
the raised HTTPException preserves the caught exception context using the
original exception variable (or suppresses it with from None where appropriate).
Use the surrounding except blocks around ReindexInProgressError and the other
affected route handlers to locate and fix all three raise sites consistently.

In `@src/cuga/backend/knowledge/sources.py`:
- Around line 352-362: Add strict=True to the zip in the citation stamping logic
so the chunk/result pairing invariant is enforced. In the function that gets the
ledger and iterates over chunks and results, change the `zip(chunks, results)`
usage to a strict zip so a length mismatch raises immediately instead of
silently truncating and leaving some chunks unstamped.

In `@src/cuga/backend/server/conversation_history.py`:
- Around line 435-467: gc_ephemeral_stream_events is only invoked during
startup, so orphaned Try-It-Out stream_events can accumulate in long-running
deployments. Wire conversation_history.gc_ephemeral_stream_events into the
existing periodic background-task mechanism used elsewhere (for example, the
knowledge engine maintenance pattern) so it runs on a schedule, while keeping
the existing startup call as-is if desired.

In `@src/cuga/backend/server/manage_routes/knowledge_routes.py`:
- Around line 95-108: The timeout handling in the Test-connection flow does not
actually stop the underlying embedding work, so a hung endpoint can keep a
shared executor thread busy. Update the async test path in knowledge_routes’
endpoint around the _do_test / asyncio.wait_for / asyncio.to_thread call so it
uses a dedicated single-worker executor (or equivalent isolated execution)
instead of the loop default ThreadPoolExecutor, and keep the timeout response
behavior intact. Reference the _do_test helper and the wait_for/to_thread block
so the fix stays localized to this route and does not affect other offloaded
work like apply_knowledge_config.

In `@src/frontend_workspaces/frontend/src/carbon-chat/customSendMessage.ts`:
- Around line 487-575: The Answer/FinalAnswer branch in customSendMessage’s
switch needs block scoping to satisfy Biome’s noSwitchDeclarations rule. Wrap
the entire case body in its own braces so the local declarations like
answerSources, answerCompleteItem, answerGenericItems, sourcesItem, and
finalResponse are scoped to that branch only, matching the pattern used in
customLoadHistory.ts and preventing lint errors from leaking into other case
clauses.

In `@tests/unit/conftest.py`:
- Around line 29-37: The fixture in conftest.py is swallowing failures from
facade.get_storage().invalidate_relational_stores() with bare except blocks,
which hides useful debugging context. Update the teardown/setup around the
shared invalidate_relational_stores call to catch the exception and log it
through the existing test logging mechanism (or re-raise after logging if
appropriate), so failures are visible instead of being silently ignored.

In `@tests/unit/test_knowledge_chunking_tokenizer_accessor.py`:
- Around line 154-163: The frozen-dataclass test in the token accessor check
uses blind exception handling and assert False fallbacks; update the test body
to use explicit failure signaling instead. In the test around
dataclasses.replace(tok, kind="hf") and the direct tok.kind mutation, replace
the broad except Exception and both assert False statements with pytest.fail or
a raised assertion so failures remain visible under optimization and the
intended checks are explicit.

In `@tests/unit/test_knowledge_reindex_busy_mapping.py`:
- Around line 20-37: The _FakeEngineBase test helper uses mutable class-level
defaults for _reindex_in_progress and _reindex_deferred, which can leak state
across instances and subclasses. Move these set initializations into an __init__
method on _FakeEngineBase so each instance gets its own fresh sets, and keep the
existing _Config/vector_config_hash behavior unchanged for the reindex path
being tested.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f3a68c7f-27f8-46d9-a55c-5875c929ce52

📥 Commits

Reviewing files that changed from the base of the PR and between df17367 and 029a17d.

⛔ Files ignored due to path filters (4)
  • src/cuga/frontend/dist/index.html is excluded by !**/dist/**
  • src/cuga/frontend/dist/main.5fd7de660b36475e3f99.js is excluded by !**/dist/**
  • src/cuga/frontend/dist/main.8f3239b467c01791bc29.js is excluded by !**/dist/**
  • src/cuga/frontend/dist/vendors.254bfd43ed6c401b5770.js is excluded by !**/dist/**
📒 Files selected for processing (69)
  • docs/superpowers/plans/2026-07-06-knowledge-citations.md
  • scripts/build_frontend.sh
  • src/cuga/backend/cuga_graph/nodes/answer/final_answer.py
  • src/cuga/backend/cuga_graph/nodes/answer/final_answer_agent/prompts/system.jinja2
  • src/cuga/backend/cuga_graph/nodes/cuga_lite/adapter/prepare_node.py
  • src/cuga/backend/cuga_graph/policy/enactment.py
  • src/cuga/backend/cuga_graph/state/agent_state.py
  • src/cuga/backend/cuga_graph/utils/agent_loop.py
  • src/cuga/backend/knowledge/awareness.py
  • src/cuga/backend/knowledge/client.py
  • src/cuga/backend/knowledge/config.py
  • src/cuga/backend/knowledge/engine.py
  • src/cuga/backend/knowledge/envelope.py
  • src/cuga/backend/knowledge/routes.py
  • src/cuga/backend/knowledge/sources.py
  • src/cuga/backend/server/conversation_history.py
  • src/cuga/backend/server/main.py
  • src/cuga/backend/server/manage_routes/__init__.py
  • src/cuga/backend/server/manage_routes/config_routes.py
  • src/cuga/backend/server/manage_routes/knowledge_reindex.py
  • src/cuga/backend/server/manage_routes/knowledge_routes.py
  • src/cuga/cli/main.py
  • src/cuga/configurations/knowledge/knowledge_settings.toml
  • src/cuga/sdk.py
  • src/frontend_workspaces/agentic_chat/package.json
  • src/frontend_workspaces/agentic_chat/src/App.tsx
  • src/frontend_workspaces/agentic_chat/src/CardManager.tsx
  • src/frontend_workspaces/agentic_chat/src/KnowledgeConfig.tsx
  • src/frontend_workspaces/agentic_chat/src/KnowledgeSidePanel.css
  • src/frontend_workspaces/agentic_chat/src/KnowledgeSidePanel.tsx
  • src/frontend_workspaces/agentic_chat/src/citations/MessageSources.tsx
  • src/frontend_workspaces/agentic_chat/src/citations/SourcesPanel.css
  • src/frontend_workspaces/agentic_chat/src/citations/SourcesPanel.tsx
  • src/frontend_workspaces/agentic_chat/src/citations/citeElement.ts
  • src/frontend_workspaces/agentic_chat/src/citations/index.ts
  • src/frontend_workspaces/agentic_chat/src/citations/injectCitations.test.ts
  • src/frontend_workspaces/agentic_chat/src/citations/injectCitations.ts
  • src/frontend_workspaces/agentic_chat/src/citations/types.ts
  • src/frontend_workspaces/agentic_chat/vite.config.ts
  • src/frontend_workspaces/frontend/src/ChatLanding.tsx
  • src/frontend_workspaces/frontend/src/ManagePage.tsx
  • src/frontend_workspaces/frontend/src/api.ts
  • src/frontend_workspaces/frontend/src/carbon-chat/CarbonChat.tsx
  • src/frontend_workspaces/frontend/src/carbon-chat/carbonChatHelpers.ts
  • src/frontend_workspaces/frontend/src/carbon-chat/customLoadHistory.ts
  • src/frontend_workspaces/frontend/src/carbon-chat/customSendMessage.ts
  • tests/integration/test_knowledge_citation_e2e.py
  • tests/integration/test_knowledge_citation_e2e_live.py
  • tests/unit/conftest.py
  • tests/unit/test_citation_resolver.py
  • tests/unit/test_citation_settings.py
  • tests/unit/test_envelope_citations.py
  • tests/unit/test_ephemeral_stream_events.py
  • tests/unit/test_final_answer_citations.py
  • tests/unit/test_knowledge_chunker_hf_tokenizer.py
  • tests/unit/test_knowledge_chunking_tokenizer_accessor.py
  • tests/unit/test_knowledge_cli_publish.py
  • tests/unit/test_knowledge_reindex_busy_mapping.py
  • tests/unit/test_knowledge_reindex_in_progress_guard.py
  • tests/unit/test_knowledge_resplit_unit_mismatch.py
  • tests/unit/test_knowledge_routes.py
  • tests/unit/test_knowledge_text_splitter_tiktoken_canary.py
  • tests/unit/test_knowledge_tokenizer_w5i1_synth_fixes.py
  • tests/unit/test_manage_publish_sync.py
  • tests/unit/test_sdk_citations.py
  • tests/unit/test_server_citation_egress.py
  • tests/unit/test_session_citation_override.py
  • tests/unit/test_session_settings_routes.py
  • tests/unit/test_source_ledger.py

- Per review on #410: scripts/build_frontend.sh duplicated the existing
  src/frontend_workspaces/frontend/build.sh (rm dist → prod build → copy into
  cuga/frontend/dist). Drop the duplicate and point the plan doc at build.sh.
- Resolves overlap with the merged reindex-UX PR (#410). Conflicts in
  knowledge_routes.py, KnowledgeConfig.tsx, ManagePage.tsx, and the reindex
  guard test all take the citations-branch version (newer adopt-only-when-empty
  logic + the embedder redesign that superseded the old provider UI); #410's
  non-conflicting reindex work is preserved. Bundle rebuilt from merged source.
@coderabbitai coderabbitai Bot added readability: fair Partial context or goal not obvious on first read and removed readability: good Clear PR goal and description; easy to review labels Jul 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
docs/superpowers/plans/2026-07-06-knowledge-citations.md (2)

2203-2208: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Open the document window before awaiting the fetch.

Calling window.open after await api.getKnowledgeDocumentFile(...) can be blocked by browser popup protection. Open a blank tab synchronously from the click handler, then assign its URL after the blob request succeeds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-06-knowledge-citations.md` around lines 2203 -
2208, Update the onOpenDocument handler to synchronously open a blank window
before awaiting api.getKnowledgeDocumentFile, then assign the created object URL
to that window after the fetch succeeds. Preserve the existing scope, filename,
threadId arguments and successful return behavior.

2173-2183: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve citation clicks against the owning message.

The listener currently falls back to the latest stored source set, so clicking a citation in an older message can open the wrong sources. Also, event.target is retargeted across shadow DOM; carry the message key in CITE_CLICK_EVENT.detail from <cuga-cite> and look up that exact entry. Add a two-message E2E case to verify this mapping.

Also applies to: 2215-2220

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-06-knowledge-citations.md` around lines 2173 -
2183, The CITE_CLICK_EVENT listener must resolve sources by the citation’s
owning message instead of falling back to the latest entry. Pass the message key
in the event detail from <cuga-cite>, use that key to look up
lastSourcesByMessage.current, and preserve the citation number as activeN; add a
two-message E2E case confirming each citation opens its own sources.
🧹 Nitpick comments (1)
docs/superpowers/plans/2026-07-06-knowledge-citations.md (1)

2425-2431: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Include integration tests in “full-suite verification.”

The listed commands run tests/unit only, despite the PR objectives explicitly requiring citation unit and integration coverage. Add the repository’s integration-test command or rename this step so it does not imply full-suite coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-06-knowledge-citations.md` around lines 2425 -
2431, Update “Step 2: Full-suite verification” to include the repository’s
integration-test command alongside the existing unit-test command, ensuring
citation integration coverage is exercised; alternatively, rename the step to
accurately describe unit-only verification if no integration command is
available.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@docs/superpowers/plans/2026-07-06-knowledge-citations.md`:
- Around line 2203-2208: Update the onOpenDocument handler to synchronously open
a blank window before awaiting api.getKnowledgeDocumentFile, then assign the
created object URL to that window after the fetch succeeds. Preserve the
existing scope, filename, threadId arguments and successful return behavior.
- Around line 2173-2183: The CITE_CLICK_EVENT listener must resolve sources by
the citation’s owning message instead of falling back to the latest entry. Pass
the message key in the event detail from <cuga-cite>, use that key to look up
lastSourcesByMessage.current, and preserve the citation number as activeN; add a
two-message E2E case confirming each citation opens its own sources.

---

Nitpick comments:
In `@docs/superpowers/plans/2026-07-06-knowledge-citations.md`:
- Around line 2425-2431: Update “Step 2: Full-suite verification” to include the
repository’s integration-test command alongside the existing unit-test command,
ensuring citation integration coverage is exercised; alternatively, rename the
step to accurately describe unit-only verification if no integration command is
available.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 23b23bc0-0bd2-4379-a17a-51b60471a680

📥 Commits

Reviewing files that changed from the base of the PR and between 029a17d and 2ae94de.

⛔ Files ignored due to path filters (2)
  • src/cuga/frontend/dist/index.html is excluded by !**/dist/**
  • src/cuga/frontend/dist/main.2ea10f484d495d5d953e.js is excluded by !**/dist/**
📒 Files selected for processing (4)
  • docs/superpowers/plans/2026-07-06-knowledge-citations.md
  • src/cuga/backend/cuga_graph/nodes/cuga_lite/adapter/prepare_node.py
  • src/cuga/backend/server/main.py
  • src/frontend_workspaces/agentic_chat/src/KnowledgeConfig.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/cuga/backend/cuga_graph/nodes/cuga_lite/adapter/prepare_node.py
  • src/cuga/backend/server/main.py
  • src/frontend_workspaces/agentic_chat/src/KnowledgeConfig.tsx

@sami-marreed

Copy link
Copy Markdown
Contributor

@dolev31 can you check the rebase if needed?

@dolev31

dolev31 commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @sami-marreed — already rebased ✅

#468 now has #410's merged work integrated (merge 2ae94de5, all overlapping files resolved to keep both the citations UI and your reindex/publish hardening), it's 0 commits behind main, merges cleanly, CI is fully green, and CodeRabbit re-reviewed with no findings.

Should be all set — would really appreciate your review whenever you get a chance 🙏 (and as flagged earlier, this one's an important customer-facing feature for us). Thanks!

dolev31 added 2 commits July 19, 2026 15:09
Resolves the conflicts blocking PR #468.

ManagePage.tsx: adopted main's #465 refactor (save logic split into
manage/hooks/*) as the base, then re-applied this branch's changes into
their new homes rather than resurrecting the deleted monolithic save:

  - citations_enabled: kept in the knowledge interface + defaults
  - isDivergedFromLive(): kept as the single shared definition backing
    both the Live pill and the profile "Modified" tag
  - adopted-existing-collection fix moved to useKnowledgeDraftSave.ts —
    a draft autosave must not advance the reindex snapshot, or the
    "Re-index to apply your changes" banner flashes and vanishes
  - live embedding_model stored raw ("" = provider default) in
    usePublishConfig.ts; "(default)" is now display-only, so it can't
    corrupt the isIndexConfigEquivalent divergence check

frontend/dist: rebuilt from the merged source (NODE_ENV=production)
instead of hand-merging bundles. Both sides' vendors chunks were
byte-identical under different content hashes; the rebuild yields one
consistent main.*.js + index.html pair.

Verified: tsc shows no new type errors vs either parent; 83 citation and
knowledge unit tests pass.
reset_config_db() removed only cuga.db, leaving the -wal/-shm pair behind.
WAL mode makes those three files one unit, so the orphaned sidecars still
described pages the recreated database no longer had. The next connection
read past EOF and raised SQLITE_IOERR_SHORT_READ, which Python surfaces as
a bare "disk I/O error" — breaking `cuga start demo_knowledge` with no clue
as to the cause. Any hard kill (crash, SIGKILL, Ctrl-C mid-write) strands
the sidecars and poisons the following start.

Remove all three files, and cover it with a regression test that fails
against the old one-file delete.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/frontend_workspaces/frontend/src/manage/hooks/useKnowledgeDraftSave.ts (1)

194-232: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Retry budget is never reset for a genuinely new edit, causing premature failure.

knowledgeSaveRetryRef.current is only reset to 0 on a successful save (Line 141) or via the parent's manual "Retry" click. If a save attempt exhausts MAX_REINDEX_SAVE_RETRIES (20 × 3s ≈ 60s) against a still-running reindex_in_progress, and the user then edits the config again while that same reindex is still in flight, the new debounce cycle re-enters this branch with the counter already at 20 — it immediately reports "Re-index still running — click Retry once it finishes." instead of giving the new edit its own retry window. The counter should be reset whenever the debounce cycle is triggered by an actual config change rather than by the retry nonce.

🔧 Proposed fix: reset the retry budget when the attempted config actually changes
+  const lastAttemptedConfigRef = useRef<string>("");
+
   useEffect(() => {
     if (skipDraftSaveRef.current) return;
 
     if (knowledgeReindexing) {
       knowledgeAbortRef.current?.abort();
       return;
     }
 
     knowledgeAbortRef.current?.abort();
     const ac = new AbortController();
     knowledgeAbortRef.current = ac;
 
     const debounceMs = forceImmediateSaveRef.current ? 0 : 800;
     forceImmediateSaveRef.current = false;
 
+    const serializedConfig = JSON.stringify(knowledgeConfig);
+    if (serializedConfig !== lastAttemptedConfigRef.current) {
+      knowledgeSaveRetryRef.current = 0;
+    }
+    lastAttemptedConfigRef.current = serializedConfig;
+
     const t = setTimeout(async () => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend_workspaces/frontend/src/manage/hooks/useKnowledgeDraftSave.ts`
around lines 194 - 232, Reset knowledgeSaveRetryRef.current when a debounce
cycle is triggered by a genuinely changed configuration, while preserving the
existing counter for retry-nonce-driven reattempts. Update the effect or
change-detection logic surrounding the save flow in useKnowledgeDraftSave so new
edits receive a fresh MAX_REINDEX_SAVE_RETRIES budget without resetting it on
scheduled retries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/frontend_workspaces/frontend/src/manage/hooks/useKnowledgeDraftSave.ts`:
- Around line 194-232: Reset knowledgeSaveRetryRef.current when a debounce cycle
is triggered by a genuinely changed configuration, while preserving the existing
counter for retry-nonce-driven reattempts. Update the effect or change-detection
logic surrounding the save flow in useKnowledgeDraftSave so new edits receive a
fresh MAX_REINDEX_SAVE_RETRIES budget without resetting it on scheduled retries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ec4ed2b4-21c1-482c-8195-04049359ca4e

📥 Commits

Reviewing files that changed from the base of the PR and between 2ae94de and c638d9d.

⛔ Files ignored due to path filters (2)
  • src/cuga/frontend/dist/index.html is excluded by !**/dist/**
  • src/cuga/frontend/dist/main.aab7d1812e3bee202945.js is excluded by !**/dist/**
📒 Files selected for processing (7)
  • src/cuga/backend/server/config_store.py
  • src/cuga/sdk.py
  • src/frontend_workspaces/frontend/src/ManagePage.tsx
  • src/frontend_workspaces/frontend/src/api.ts
  • src/frontend_workspaces/frontend/src/manage/hooks/useKnowledgeDraftSave.ts
  • src/frontend_workspaces/frontend/src/manage/hooks/usePublishConfig.ts
  • tests/unit/test_config_db_reset_wal_sidecars.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/frontend_workspaces/frontend/src/api.ts
  • src/cuga/sdk.py

@offerakrabi offerakrabi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A personal note before the chewed up summary, this PR is too big, it is very hard to review (even for an LLM) and it is hard to track what issues/bugs/fixed are done in each file , ideally it should be split to at least 2 PRs (frontend and backend).

PR Review: #468 — feat(knowledge): inline citations & sources + simplified embeddings config UX

Summary

Verdict: merge with fixes.

This is a substantial, well-executed feature. The architecture is genuinely sound: the SourceLedger design — content-hash deduplication, thread-scoped registry, LRU eviction, and restart rehydration from persisted Answer events — is the right model for multi-hop, multi-turn citation stability and was clearly thought through carefully. The dual-seam stamping (in-process KnowledgeClient.search_envelope + HTTP POST /api/knowledge/search) is correctly placed and covers every execution path including MCP, E2B, and SDK. Citation resolution at FinalAnswerNode with bracket-family support (ASCII, fullwidth, lenticular, tortoise-shell) and a monitoring canary for unsupported styles is a smart hedge against model drift. The frontend <cuga-cite> component's use of the Popover API to escape CSS overflow clipping — with a correct CSS fallback for browsers without support — is technically sharp. Test coverage is extensive: 16 new test files, including deterministic integration tests against a real KnowledgeEngine/fastembed pipeline. The PR delivers exactly what #154 and #155 specified.

Strengths:

  • Content-hash chunk identity (sha1(scope|filename|page|sha1(text))) gives stable cite_id across turns and across reindex without schema changes.
  • resolve_citations is correctness-first: code fence detection (fenced blocks, unterminated fences, double-backtick inline spans), explicit strip-mode for feature-off, and per-marker warnings only for real ledger misses.
  • The _rehydrate_citation_ledger helper correctly gates on the session-aware citations_enabled_for() predicate — not just the agent-level flag — so a per-session override can't cause counter collisions after a server restart.
  • Session settings endpoints (GET/PATCH /api/knowledge/session/settings) are properly ownership-enforced and have full route-level tests.
  • SourcesPanel query-term highlighting is built with DOM text nodes (<mark> via React children), never innerHTML — hostile snippets are inert.
  • All new backend code runs ruff clean and follows the established sources.py no-engine-import discipline.

What to resolve before merge:

Major:

  1. All 13 new test files (9 unit + 4 integration) lack the required pytest type marker — pytest -m unit silently skips every new unit test and the fastembed integration tests run without a marker gate. See MAJ-1.
  2. apply_citation_resolution clears state.sources when final_answer has no [sN] markers (already-resolved [1]...[n] text). The supervisor callback path (state.sender == CUGA_SUPERVISOR) can reach this with an already-resolved last_planner_answer, silently dropping correctly-resolved sources. See MAJ-2.
  3. remove plan file (under docs/superpowers/plans)

Minor:
4. SourcesPanel keys list items by s.n — per-message display numbers repeat (both message A and B have n=1). When the panel stays mounted and sources prop switches to a different message's set, React reuses key="1" DOM nodes, causing stale expanded/ref state. s.cite_id is the correct key. See MIN-1.
5. handleSessionCitationsChange in KnowledgeSidePanel only guards if (!threadId) before calling PATCH /api/knowledge/session/settings. If sessionScopeEnabled is false but threadId is non-empty, programmatic dispatch (automation/test tooling) bypasses the disabled checkbox and creates a phantom override entry for an inactive session. See MIN-2.

Nice to have:
6. customLoadHistory.ts unconditionally console.logs every event including full source snippets (filename, page, content) on reload. See NTH-1.
7. citeElement.ts Popover card position is computed once on mouseenter and not updated on scroll — card drifts off the chip if the user scrolls while hovering. See NTH-2.

Notes / out of scope:

  • The three compiled dist bundles (src/cuga/frontend/dist/*.js, index.html) are tracked in git and replaced by this PR. Not a finding introduced by this PR — this is pre-existing policy — but worth a decision: binary artifacts in git make diffs unreadable and cause guaranteed merge conflicts on concurrent frontend PRs.
  • 3 candidate findings were dropped after sub-agent verification: MIN-1 ("+N more" scroll target) was invalid — no upstream filtering exists; MIN-4 (TOCTOU in rehydration) was invalid — get_ledger(create=True) holds its lock across both check and creation, making the race benign; NTH-1 (layering on apply_citation_resolution) was invalid — AGENTS.md's layering rule targets cross-node-layer deps, and knowledge imports already exist in multiple other node files.

Issues

🟠 Major

[MAJ-1] All 13 new test files lack the required pytest type marker
tests/integration/test_knowledge_citation_e2e.py:119

What's wrong: AGENTS.md (project root) explicitly mandates that every test carry a registered pytest type marker (@pytest.mark.unit, @pytest.mark.e2e, @pytest.mark.slow, etc.) and states "Do not leave tests unmarked when a type marker applies." The 4 integration tests in test_knowledge_citation_e2e.py carry only @pytest.mark.asyncio — no type marker. All 9 new unit test files (test_source_ledger.py, test_citation_resolver.py, test_citation_settings.py, test_envelope_citations.py, test_final_answer_citations.py, test_session_citation_override.py, test_server_citation_egress.py, test_sdk_citations.py, test_session_settings_routes.py) carry no type marker at all. test_knowledge_citation_e2e_live.py correctly uses @pytest.mark.e2e — so the author knows the convention.

What it can cause: pytest -m unit skips all 9 new unit tests silently — a unit-only CI gate passes while the entire citation test net goes unexercised. pytest -m "not slow" picks up the fastembed-backed integration tests, which drive a real KnowledgeEngine, embed documents, and can take 30+ seconds — causing unexpected slowness in a supposedly-fast suite. The convention is explicit in AGENTS.md; callers who copy these files as templates will perpetuate the gap.


[MAJ-2] Supervisor callback path clears already-resolved state.sources via a second call to apply_citation_resolution
src/cuga/backend/cuga_graph/nodes/answer/final_answer.py:168-170

What's wrong: apply_citation_resolution is documented to be destructive when called on already-resolved text: "The second run sees no [sN] markers in the already-resolved text and clears state.sources." Lines 99-102 confirm this — if final_answer has no [sN] markers, state.sources = [] and return. In the supervisor callback path at lines 167-170: when state.final_answer is empty but state.last_planner_answer holds an already-resolved answer (e.g., CugaLite resolved [s1][1] and set sources before the supervisor transition), answer_to_forward = state.last_planner_answer is non-empty, the if branch executes at line 168, state.final_answer is set to that already-resolved text, then apply_citation_resolution is called — which sees no [sN] markers and wipes state.sources.

What it can cause: A CugaLite subgraph run that correctly resolved [s1][1] and set state.sources = [{n:1, cite_id:"s1", filename:"report.pdf", ...}] before the supervisor transition arrives at FinalAnswerNode with state.final_answer="" and state.last_planner_answer="The answer is 42 [1].". Lines 167–170 fire, apply_citation_resolution sees "The answer is 42 [1]." (no [sN] markers), sets state.sources = [], and the user receives an answer with [1] in the text but no clickable chips and no Sources panel. The test test_supervisor_empty_fallback_clears_stale_sources only verifies the desired clearing when both final_answer and last_planner_answer are empty; this path is untested.


🟡 Minor

[MIN-1] SourcesPanel keys list items by s.n — per-message display numbers repeat across messages, causing stale React DOM when the panel updates to a different message's sources
src/frontend_workspaces/agentic_chat/src/citations/SourcesPanel.tsx:82

What's wrong: {items.map((s) => (<div key={s.n} ...>))} uses s.n as the React reconciliation key. Display numbers are 1-based per message — message A and message B both have a source {n:1}. Both CarbonChat.tsx and App.tsx keep SourcesPanel mounted persistently (rendered conditionally via sourcesPanel && / sourcesView &&) and update the sources prop when a different message's chip is clicked. When the prop switches from A's sources to B's sources, React sees key="1" in both renders and reuses the existing DOM node instead of replacing it.

What it can cause: User clicks chip [1] in message A (filename: "report.pdf", snippet: "...Q4 revenue..."). Panel opens. User then clicks chip [1] in message B (filename: "handbook.docx", snippet: "...onboarding..."). React patches the existing DOM node keyed n=1: the activeRef does not re-fire (ref callback only fires on mount), scrollIntoView does not run, and the expanded state in SourcesPanel at line 47 — keyed by s.n — carries over. Users see stale expand state for "report.pdf" applied to "handbook.docx". Using s.cite_id (e.g., "s3" vs "s7") as the key is unique across all messages and fixes this.


[MIN-2] handleSessionCitationsChange in KnowledgeSidePanel calls the API even when the session scope is disabled
src/frontend_workspaces/agentic_chat/src/KnowledgeSidePanel.tsx:132-143

What's wrong: The handler guards only if (!threadId) return at line 133 before calling api.patchSessionKnowledgeSettings. The checkbox carries disabled={!conversationReady} where conversationReady = sessionScopeEnabled && Boolean(effectiveThreadId). In normal browser use a disabled checkbox cannot fire onChange, but the disabled attribute does not prevent programmatic dispatch (Cypress cy.get(…).click({force:true}), Playwright .dispatchEvent('change'), or direct element.dispatchEvent(new Event('change'))). When sessionScopeEnabled is false but threadId is non-empty, the !threadId guard passes, and the PATCH fires against a session that is not active.

What it can cause: An automated test or accessibility tool forces a click on the disabled citations toggle when session knowledge is turned off. PATCH /api/knowledge/session/settings fires with {citations_enabled: false} for that threadId. _apply_session_settings_patch creates a SessionKnowledgeState entry with overrides: {citations_enabled: false}. Later, when session knowledge is re-enabled for that agent, the stale override is loaded for the thread and citations silently start disabled, inconsistent with the agent-level default of true.


🔵 Nice to Have

[NTH-1] customLoadHistory.ts unconditionally logs every event including full citation source data to the browser console
src/frontend_workspaces/frontend/src/carbon-chat/customLoadHistory.ts:79,87,206

What's wrong: Lines 79, 87, and 206 call console.log(...) unconditionally, including logging each full event object on line 87. These log calls pre-date this PR, but the PR adds sources payloads (filename, page, snippet, score, query) to Answer events, so reloading a conversation now logs document content to the console.

What it can cause: In a production deployment any user who opens DevTools on history load can see the full source snippets and filenames of every cited document in that conversation. This may expose document content to users who should not have direct file access (they have search access through the agent, not raw read access). It also produces log noise that buries real errors.


[NTH-2] citeElement.ts Popover card position is computed once on hover enter — card drifts from the chip if the user scrolls while the card is visible
src/frontend_workspaces/agentic_chat/src/citations/citeElement.ts:101-105

What's wrong: place() computes left/top from this.getBoundingClientRect() once on mouseenter / focusin. The Popover API places the card in the browser top layer at position:fixed, relative to the viewport. If the user scrolls the page while the card is visible (e.g., in long answers with many paragraphs), the chip scrolls but the card stays fixed at its original coordinates.

What it can cause: On a long answer the user hovers chip [1] near the bottom, the card pops up above it. The user scrolls up slightly to read context. The chip moves up 50px but the card remains at the original top-layer coordinate, floating in empty space. Minor cosmetic issue; the card dismisses on mouseleave, which fires as soon as the pointer leaves the chip, so practical exposure is limited to the brief scroll-while-hovering window.


Test Suite Audit

114 tests across 14 new files. The suite is well-designed — 110 tests are fully justified. 4 should be cut, 2 collapsed into 1.

Cut these 4 tests

File Test Why
test_source_ledger.py test_get_unknown_returns_none Returns None on a cache miss — covered implicitly by every test that checks a non-existent ID
test_source_ledger.py test_get_ledger_without_create_returns_none_on_miss Tests that create=False doesn't auto-create — covered implicitly by the thread isolation test
test_sdk_citations.py test_enable_citations_param_stored Verifies agent._enable_citations = False after CugaAgent(enable_citations=False) — testing Python assignment, not behavior
test_sdk_citations.py test_enable_citations_defaults_to_none Verifies omitting a param gives None — testing Python default argument semantics

Reduce these 2 into 1

test_footer_single_entry_with_page and test_footer_single_entry_no_page in test_server_citation_egress.py both call _format_sources_footer with a one-element list, differing only in whether page is an int or None. Collapse into a single @pytest.mark.parametrize. Keep test_footer_page_zero_is_included standalone — page=0 is a real edge case (falsy in Python, must still render).

Everything else: keep

The remaining 110 tests all pin real, non-obvious behavioral invariants: the SourceLedger eviction policy, the regex edge cases in resolve_citations (CJK brackets, unterminated fences, double-backtick spans), the rehydration counter-bump regression, the HTTP ownership enforcement, and the end-to-end fastembed pipeline that caught the lenticular-bracket regression that unit tests with hand-seeded ledgers missed.

No tests were found that test the wrong thing (implementation details rather than behavior), and no assertion was found that would survive a real bug.

The audit also confirmed that test_session_citation_override.py (3 tests) should be kept despite appearing redundant with test_session_settings_routes.py — the helper-level tests verify the allowlist filtering and string coercion in isolation; the route tests exercise the full HTTP stack on top of it. Both layers are worth pinning.

The primary CI concern is not the count — it's the missing @pytest.mark type markers (MAJ-1). Without them, pytest -m unit silently skips all 9 new unit test files.

@@ -0,0 +1,2484 @@
# Knowledge Citations & Sources — Implementation Plan

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file should not be committed, please remove

…istory

Fixes citation mis-attribution: a cite_id from an earlier turn could resolve
in a later turn's answer, pointing at the wrong document (a scholarship
answer cited an AppWorld-benchmark chunk from a prior question). Root cause:
generated code stripped provenance from derived text, and the model then
attached a stale, still-resolvable id from the thread ledger.

Turn scoping (the fix for the symptom)
  SourceLedger tracks which cite_ids were retrieved THIS turn; resolve_citations
  strips any marker not in that set (distinct log lines: "not in ledger" for a
  hallucinated id vs "not from this turn's retrieval" for a stale one). An
  earlier-turn id no longer resolves unless it is re-retrieved this turn, so the
  answer renders correct-and-uncited instead of correct-and-wrong.

  Turn boundaries are reset at the two real entry points, both gated on a
  genuinely NEW user turn:
  - server event_stream via _rehydrate_citation_ledger(is_resume=...): a HITL
    resume (tool approval / clarifying answer) re-enters the stream but is the
    SAME turn — begin_turn must NOT run there, or the post-resume answer loses
    the citations from the pre-interrupt search. (adversarial-review blocker)
  - SDK invoke() via begin_ledger_turn(): the SDK bypassed the server hook
    entirely, so cross-turn resolution still happened there. (review gap)

Durable event history (Fix 2, subsumes Fix 3)
  event_stream reset stream_events_buffer=[] every turn and the save does
  UPDATE ... SET events=?, so the persisted row was clobbered down to the last
  turn only — breaking conversation reload (the frontend replays stream_events
  first) and cross-turn citation rehydration. Seed the buffer from persisted
  events so each turn APPENDS with a continuous sequence. This also delivers
  integrity: rehydration now sees every turn's cited sources, so the id counter
  stays above all issued ids and can never recycle one to different content.

Prompt contract updated to match: use only THIS turn's ids (the "earlier turns
remain citable" promise would now contradict the runtime).

Reuse (citing a past source without re-retrieving) is intentionally NOT enabled
here — it needs content verification or provenance binding; tracked as epics.

Verified: 643 citation/knowledge/stream/session tests pass, including new
regression tests for HITL-resume scope preservation, the SDK boundary, and
cumulative persistence.
offerakrabi flagged the 2484-line superpowers plan doc as not-for-commit.
It was a working design note, not product docs.
@dolev31

dolev31 commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Removed the committed plan doc in 963247c — thanks @offerakrabi. It was a working design note, not product docs; won't be re-added.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/cuga/backend/server/main.py (1)

214-227: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Redundant DB read of the same stream-events row on new/restarted threads.

_seed_stream_events_buffer (called at Line 1536) and _rehydrate_citation_ledger's internal fetch (Lines 271-272) both call get_conversation_db().get_stream_events(app_state.agent_id, thread_id, user_id) for the identical (agent_id, thread_id, user_id) within the same turn. The second fetch only happens when the in-memory ledger is absent (first turn of a thread, or any turn right after a server restart), but that's still a common hot path, and it duplicates a query whose result (stream_events_buffer) is already sitting in scope at the call site (Line 1537).

Consider threading the already-fetched buffer into _rehydrate_citation_ledger instead of re-querying:

♻️ Reuse the seeded buffer instead of re-fetching
 async def _rehydrate_citation_ledger(
-    app_state: "AppState", thread_id: str, user_id: str, is_resume: bool = False
+    app_state: "AppState", thread_id: str, user_id: str, is_resume: bool = False,
+    events_list: list | None = None,
 ) -> None:
     ...
         _ledger = _get_ledger(thread_id, create=False)
         if _ledger is None:
-            conversation_db = get_conversation_db()
-            stream_history = await conversation_db.get_stream_events(app_state.agent_id, thread_id, user_id)
-            events_list = stream_history.events if stream_history else []
+            events_list = events_list or []
             _ledger = _get_ledger(thread_id)  # create
             for ev in events_list:
-                if ev.event_name != "Answer":
+                name = ev.get("event_name") if isinstance(ev, dict) else ev.event_name
+                if name != "Answer":
                     continue
                 try:
-                    payload = json.loads(ev.event_data)
+                    data = ev.get("event_data") if isinstance(ev, dict) else ev.event_data
+                    payload = json.loads(data)

And at the call site (Line 1598):

-        await _rehydrate_citation_ledger(app_state, thread_id, user_id, is_resume=bool(resume))
+        await _rehydrate_citation_ledger(
+            app_state, thread_id, user_id, is_resume=bool(resume), events_list=stream_events_buffer
+        )

Also applies to: 269-276

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cuga/backend/server/main.py` around lines 214 - 227, Update
_rehydrate_citation_ledger to accept the already-seeded stream_events_buffer as
an argument and use it when reconstructing the citation ledger, removing its
duplicate get_stream_events database query. At the call site near the stream
buffer initialization, pass the buffer returned by _seed_stream_events_buffer
while preserving existing behavior when the buffer is empty or the in-memory
ledger already exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cuga/backend/server/main.py`:
- Around line 253-254: Update the ledger initialization flow so the resume path
skips only begin_turn(), not ledger rehydration. In the logic around
_get_ledger(thread_id, create=False), continue loading or restoring the existing
ledger when thread_id is present even when is_resume is true, while retaining
the early return for missing thread IDs and preserving normal begin_turn()
behavior for non-resume requests.

---

Nitpick comments:
In `@src/cuga/backend/server/main.py`:
- Around line 214-227: Update _rehydrate_citation_ledger to accept the
already-seeded stream_events_buffer as an argument and use it when
reconstructing the citation ledger, removing its duplicate get_stream_events
database query. At the call site near the stream buffer initialization, pass the
buffer returned by _seed_stream_events_buffer while preserving existing behavior
when the buffer is empty or the in-memory ledger already exists.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 04148729-3d1c-4bf2-b880-51186fdb94dc

📥 Commits

Reviewing files that changed from the base of the PR and between c638d9d and 212bd21.

📒 Files selected for processing (8)
  • src/cuga/backend/knowledge/awareness.py
  • src/cuga/backend/knowledge/sources.py
  • src/cuga/backend/server/main.py
  • src/cuga/sdk.py
  • tests/unit/test_citation_resolver.py
  • tests/unit/test_citation_settings.py
  • tests/unit/test_ephemeral_stream_events.py
  • tests/unit/test_sdk_citations.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/cuga/backend/knowledge/awareness.py
  • tests/unit/test_sdk_citations.py
  • tests/unit/test_citation_settings.py
  • src/cuga/sdk.py
  • src/cuga/backend/knowledge/sources.py

Comment thread src/cuga/backend/server/main.py Outdated
…ion, review nits

Resolves the actionable findings from offerakrabi's review + CodeRabbit.

MAJ-1 (CI coverage): the citation / source-ledger / session-settings /
durability-reset test files were never referenced by CI's explicit allowlist,
so 13 test files (93 tests) ran only locally. Added them to tests.yml.

MAJ-2 (correctness): apply_citation_resolution cleared state.sources whenever
the text had no [sN] markers. The supervisor callback re-enters with an
already-resolved last_planner_answer ([n] chips, no [sN]) and silently dropped
its sources. Made it idempotent: keep sources when the current text still
references them via resolved [n] markers; still clear a genuinely uncited
answer (so stale prior-turn sources never leak). +2 regression tests.

CodeRabbit (resume gate, my prior commit): _rehydrate_citation_ledger returned
early on is_resume, so a restart DURING an interrupt never rehydrated the
ledger. Now only begin_turn() is skipped on resume; rehydration still runs.

MIN-1: SourcesPanel keyed list items by s.n (repeats across messages) — switch
to the stable s.cite_id so React doesn't reuse DOM nodes / carry stale
expanded state when the panel's sources prop changes.

MIN-2: handleSessionCitationsChange guarded only !threadId; a programmatic
dispatch could write a phantom session override while session scope was off.
Guard on conversationReady (mirrors the toggle's own disabled condition).

Frontend bundle rebuilt (production). Plan doc already removed (963247c).
@dolev31

dolev31 commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @offerakrabi — genuinely thorough review, and the architecture notes are appreciated. I've pushed fixes for all the actionable findings (682c590):

  • MAJ-1 (CI coverage): the citation / source-ledger / session-settings / durability-reset test files were never in CI's explicit allowlist — 13 files (93 tests) ran only locally. Added them to tests.yml.
  • MAJ-2 (source clobber): apply_citation_resolution is now idempotent — the supervisor callback re-entering with an already-resolved last_planner_answer keeps its sources instead of clearing them; a genuinely uncited answer still clears (no stale leak). +2 regression tests.
  • MIN-1: SourcesPanel now keys by the stable cite_id, not the per-message n.
  • MIN-2: handleSessionCitationsChange now guards on conversationReady (mirrors the toggle's own disabled condition), so a programmatic dispatch can't write a phantom session override.
  • MAJ-3 (plan doc): removed in 963247c.
  • Plus a CodeRabbit follow-up: the resume path now rehydrates on restart-during-interrupt and only skips begin_turn().

NTH-1/NTH-2 (reload console.log, popover reposition-on-scroll) I've left as follow-ups — happy to fold in if you'd prefer them here.

On splitting into frontend/backend PRs — I hear you that the size makes review hard, and I don't disagree in principle. My reasoning for keeping it together this once:

  • The feature is a single vertical contract: the backend stamps cite_id on the wire and the frontend <cuga-cite> renders it. Split down the middle, neither half is independently testable or mergeable — a frontend PR renders chips with no ids to bind, a backend PR emits ids nothing consumes — so the integration tests (which are much of the value here) can't live in either half.
  • Two PRs are already stacked on this branch (fix(knowledge): make the embedding-model cache durable and self-healing #497 embedder-cache, feat(knowledge): open a cited PDF at the cited page #498 open-at-page); re-basing a mid-flight split onto two new bases would churn all three.
  • The single biggest source of "unreadable diff" is the committed src/cuga/frontend/dist/* bundles (minified JS), not the source. That's pre-existing repo policy — if we .gitignore the dist and build on release, future frontend PRs get dramatically smaller diffs. Happy to open that as a separate proposal.

If you still want it split after this pass, I'll carve the frontend out — just confirm and I'll coordinate the stack.

Re CI red: the failing checks are all groq 413 rate_limit_exceeded (org TPM cap) on the live-LLM stability/integration steps — the pure unit + lint + pgvector + CodeQL steps are green. Not from this change; a re-run once TPM resets should clear them.

@sami-marreed / @offerakrabi — ready for another look when you have a moment.

@dolev31
dolev31 requested a review from offerakrabi July 20, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

complexity: high Large or risky change — needs careful review readability: fair Partial context or goal not obvious on first read

Projects

None yet

3 participants