feat(agents): make sources optional and drop the synthetic "Default" source

The sources list used to start with a fake "Default" entry that had no id
and, at run time, meant "no source, skip retrieval". The agent form
pre-selected it, snapped back to it when the last source was deselected,
and refused to publish without it, so a new agent always looked like it
had a knowledge base when it had none.

Backend
- /api/sources returns only ingested sources; no placeholder row.
- Publishing an agent no longer requires a source on create or update.
  The legacy "default" value is still accepted and maps to NULL.

Frontend
- The agent source picker starts empty, can be cleared, and shows a hint
  that a source-less agent answers from the model and its tools only.
- The picker groups sources into "Your sources" and "Shared with team"
  when any team-shared source exists, shows "N sources selected" for a
  multi-selection, and gets the same "Go to Sources" / "Upload new"
  footer as the chat picker. A source uploaded from the form is selected
  when it lands.
- Source selection serialisation and the picker id live in one helper
  shared with the chat picker; the four copies in the form are gone.
- The client no longer seeds a placeholder source in the store, and the
  dead auto-select of a "default" document is removed.
This commit is contained in:
Alex committed 2026-09-09 17:59:08 +01:00
1 parent fb15649a3f
commit 79d418f32a
23 files changed
+532 -365

No files matched your search

+66 -18
View File
@@ -782,12 +782,10 @@ class TestUpdateAgent:
response = UpdateAgent().put(str(agent["id"]))
assert response.status_code == 400
def test_publish_with_default_source_succeeds(self, app, pg_conn):
"""The frontend's auto-selected "Default" source has no UUID — it
sends ``source: ''`` and ``sources: []`` with ``retriever: 'classic'``.
Pre-fix, the publish gate rejected this with
``Missing or invalid required fields: Source``. The retriever
carries the runtime identity, so the gate now accepts it.
def test_publish_with_no_source_succeeds(self, app, pg_conn):
"""An agent with no source is a valid published state: it answers
from the model and its tools. The form sends ``source: ''`` and
``sources: []`` with the default ``retriever``.
"""
from docsgpt.api.user.agents.routes import UpdateAgent
from docsgpt.storage.db.repositories.agents import AgentsRepository
@@ -827,11 +825,11 @@ class TestUpdateAgent:
f"unexpected error: {response.json}"
)
def test_publish_without_source_or_retriever_returns_400(
def test_publish_without_source_or_retriever_succeeds(
self, app, pg_conn,
):
"""If neither a source nor a retriever is configured, the gate
still trips — the agent has no way to retrieve anything."""
"""Neither a source nor a retriever is required to publish; a
source-less agent simply skips retrieval at run time."""
from docsgpt.api.user.agents.routes import UpdateAgent
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.prompts import PromptsRepository
@@ -864,8 +862,12 @@ class TestUpdateAgent:
from flask import request
request.decoded_token = {"sub": user}
response = UpdateAgent().put(str(agent["id"]))
assert response.status_code == 400
assert "Source or retriever" in response.json.get("message", "")
assert response.status_code == 200, (
f"unexpected error: {response.json}"
)
stored = AgentsRepository(pg_conn).get_by_id(str(agent["id"]))
assert stored["status"] == "published"
assert stored["source_id"] is None
def test_publishing_generates_api_key(self, app, pg_conn):
from docsgpt.api.user.agents.routes import UpdateAgent
@@ -1075,25 +1077,71 @@ class TestCreateAgentMore:
)
assert status == 400
def test_publish_classic_without_source_returns_400(self, app):
def test_publish_classic_without_source_succeeds(self, app, pg_conn):
"""Publishing with ``source: ''`` and ``sources: []`` creates a
source-less agent (``source_id`` NULL) instead of a 400."""
from docsgpt.api.user.agents.routes import CreateAgent
from docsgpt.storage.db.repositories.agents import AgentsRepository
with app.test_request_context(
user = "u-create-no-source"
with _patch_db(pg_conn), app.test_request_context(
"/api/create_agent", method="POST",
json={
"name": "n",
"name": "no-source",
"description": "d",
"status": "published",
"agent_type": "classic",
"chunks": "2",
"retriever": "classic",
"prompt_id": "default",
"source": "",
"sources": [],
},
):
from flask import request
request.decoded_token = {"sub": "u"}
request.decoded_token = {"sub": user}
response = CreateAgent().post()
status = (
response[1] if isinstance(response, tuple) else response.status_code
assert response.status_code == 201, (
f"unexpected error: {response.json}"
)
assert status == 400
agents = AgentsRepository(pg_conn).list_for_user(user)
created = next(a for a in agents if a["name"] == "no-source")
assert created["status"] == "published"
assert created["source_id"] is None
assert not created.get("extra_source_ids")
assert created["key"]
def test_publish_classic_with_legacy_default_sentinel_succeeds(
self, app, pg_conn,
):
"""Older clients still send ``source: "default"``; it keeps mapping
to a NULL source rather than being rejected."""
from docsgpt.api.user.agents.routes import CreateAgent
from docsgpt.storage.db.repositories.agents import AgentsRepository
user = "u-create-legacy-default"
with _patch_db(pg_conn), app.test_request_context(
"/api/create_agent", method="POST",
json={
"name": "legacy-default",
"description": "d",
"status": "published",
"agent_type": "classic",
"chunks": "2",
"retriever": "classic",
"prompt_id": "default",
"source": "default",
},
):
from flask import request
request.decoded_token = {"sub": user}
response = CreateAgent().post()
assert response.status_code == 201, (
f"unexpected error: {response.json}"
)
agents = AgentsRepository(pg_conn).list_for_user(user)
created = next(a for a in agents if a["name"] == "legacy-default")
assert created["source_id"] is None
def test_unknown_agent_type_falls_back_classic(self, app, pg_conn):
from docsgpt.api.user.agents.routes import CreateAgent
+17 -3
View File
@@ -80,7 +80,9 @@ class TestCombinedJson:
response = CombinedJson().get()
assert response.status_code == 401
def test_returns_default_plus_user_sources(self, app, pg_conn):
def test_returns_only_real_user_sources(self, app, pg_conn):
"""No synthetic "Default" row: every entry is an ingested source
with an id, so clients never have to special-case a placeholder."""
from docsgpt.api.user.sources.routes import CombinedJson
user = "u-list-sources"
@@ -93,8 +95,20 @@ class TestCombinedJson:
assert response.status_code == 200
names = [d["name"] for d in response.json]
assert "Default" in names
assert "doc1" in names
assert "Default" not in names
assert names == ["doc1"]
assert all(d.get("id") for d in response.json)
def test_returns_empty_list_when_user_has_no_sources(self, app, pg_conn):
from docsgpt.api.user.sources.routes import CombinedJson
with _patch_db(pg_conn), app.test_request_context("/api/sources"):
from flask import request
request.decoded_token = {"sub": "u-no-sources"}
response = CombinedJson().get()
assert response.status_code == 200
assert response.json == []
def test_db_error_returns_400(self, app):
from docsgpt.api.user.sources.routes import CombinedJson