mirror of
https://github.com/arc53/DocsGPT.git
synced 2026-02-22 12:21:39 +00:00
Artifacts-backed persistence for Agent “Self” tools (Notes / Todo) + streaming artifact_id support (#2267)
* (feat:memory) use fs/storage for files
* (feat:todo) artifact_id via sse
* (feat:notes) artifact id return
* (feat:artifact) add get endpoint, store todos with conv id
* (feat: artifacts) fe integration
* feat(artifacts): ui enhancements, notes as mkdwn
* chore(artifacts) updated artifact tests
* (feat:todo_tool) return all todo items
* (feat:tools) use specific tool names in bubble
* feat: add conversationId prop to artifact components in Conversation
* Revert "(feat:memory) use fs/storage for files"
This reverts commit d1ce3bea31.
* (fix:fe) build fail
This commit is contained in:
194
tests/agents/test_get_artifact.py
Normal file
194
tests/agents/test_get_artifact.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from bson.objectid import ObjectId
|
||||
from flask import request
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetArtifact:
|
||||
def test_note_artifact_success(self, mock_mongo_db, flask_app, decoded_token):
|
||||
from application.core.settings import settings
|
||||
from application.api.user.tools.routes import GetArtifact
|
||||
|
||||
db = mock_mongo_db[settings.MONGO_DB_NAME]
|
||||
note_id = ObjectId()
|
||||
db["notes"].insert_one(
|
||||
{
|
||||
"_id": note_id,
|
||||
"user_id": decoded_token["sub"],
|
||||
"tool_id": "tool1",
|
||||
"note": "a\nb",
|
||||
"updated_at": datetime(2025, 1, 1),
|
||||
}
|
||||
)
|
||||
|
||||
with flask_app.app_context():
|
||||
with flask_app.test_request_context():
|
||||
request.decoded_token = decoded_token
|
||||
resource = GetArtifact()
|
||||
resp = resource.get(str(note_id))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json["artifact"]["artifact_type"] == "note"
|
||||
assert resp.json["artifact"]["data"]["content"] == "a\nb"
|
||||
assert resp.json["artifact"]["data"]["line_count"] == 2
|
||||
|
||||
def test_todo_artifact_success(self, mock_mongo_db, flask_app, decoded_token):
|
||||
from application.core.settings import settings
|
||||
from application.api.user.tools.routes import GetArtifact
|
||||
|
||||
db = mock_mongo_db[settings.MONGO_DB_NAME]
|
||||
todo_id_1 = ObjectId()
|
||||
todo_id_2 = ObjectId()
|
||||
db["todos"].insert_many([
|
||||
{
|
||||
"_id": todo_id_1,
|
||||
"user_id": decoded_token["sub"],
|
||||
"tool_id": "tool1",
|
||||
"todo_id": 1,
|
||||
"title": "First task",
|
||||
"status": "open",
|
||||
"created_at": datetime(2025, 1, 1),
|
||||
"updated_at": datetime(2025, 1, 1),
|
||||
},
|
||||
{
|
||||
"_id": todo_id_2,
|
||||
"user_id": decoded_token["sub"],
|
||||
"tool_id": "tool1",
|
||||
"todo_id": 2,
|
||||
"title": "Second task",
|
||||
"status": "completed",
|
||||
"created_at": datetime(2025, 1, 1),
|
||||
"updated_at": datetime(2025, 1, 2),
|
||||
},
|
||||
])
|
||||
|
||||
with flask_app.app_context():
|
||||
with flask_app.test_request_context():
|
||||
request.decoded_token = decoded_token
|
||||
resource = GetArtifact()
|
||||
resp = resource.get(str(todo_id_1))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json["artifact"]["artifact_type"] == "todo_list"
|
||||
data = resp.json["artifact"]["data"]
|
||||
assert data["total_count"] == 2
|
||||
assert data["open_count"] == 1
|
||||
assert data["completed_count"] == 1
|
||||
assert len(data["items"]) == 2
|
||||
# Verify both todos are returned
|
||||
todo_ids = [item["todo_id"] for item in data["items"]]
|
||||
assert 1 in todo_ids
|
||||
assert 2 in todo_ids
|
||||
|
||||
def test_todo_artifact_all_param(self, mock_mongo_db, flask_app, decoded_token):
|
||||
"""Test that all todos are returned regardless of the 'all' query parameter."""
|
||||
from application.core.settings import settings
|
||||
from application.api.user.tools.routes import GetArtifact
|
||||
|
||||
db = mock_mongo_db[settings.MONGO_DB_NAME]
|
||||
todo_id_1 = ObjectId()
|
||||
todo_id_2 = ObjectId()
|
||||
db["todos"].insert_many([
|
||||
{
|
||||
"_id": todo_id_1,
|
||||
"user_id": decoded_token["sub"],
|
||||
"tool_id": "tool1",
|
||||
"todo_id": 1,
|
||||
"title": "First task",
|
||||
"status": "open",
|
||||
"created_at": datetime(2025, 1, 1),
|
||||
"updated_at": datetime(2025, 1, 1),
|
||||
},
|
||||
{
|
||||
"_id": todo_id_2,
|
||||
"user_id": decoded_token["sub"],
|
||||
"tool_id": "tool1",
|
||||
"todo_id": 2,
|
||||
"title": "Second task",
|
||||
"status": "completed",
|
||||
"created_at": datetime(2025, 1, 1),
|
||||
"updated_at": datetime(2025, 1, 2),
|
||||
},
|
||||
])
|
||||
|
||||
# Test without query parameter - should return all todos
|
||||
with flask_app.app_context():
|
||||
with flask_app.test_request_context():
|
||||
request.decoded_token = decoded_token
|
||||
resource = GetArtifact()
|
||||
resp = resource.get(str(todo_id_1))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json["artifact"]["artifact_type"] == "todo_list"
|
||||
data = resp.json["artifact"]["data"]
|
||||
assert data["total_count"] == 2
|
||||
assert data["open_count"] == 1
|
||||
assert data["completed_count"] == 1
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
# Test with query parameter (should still return all todos, parameter is ignored)
|
||||
with flask_app.app_context():
|
||||
with flask_app.test_request_context(query_string={"all": "true"}):
|
||||
request.decoded_token = decoded_token
|
||||
resource = GetArtifact()
|
||||
resp = resource.get(str(todo_id_1))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json["artifact"]["artifact_type"] == "todo_list"
|
||||
data = resp.json["artifact"]["data"]
|
||||
assert data["total_count"] == 2
|
||||
assert data["open_count"] == 1
|
||||
assert data["completed_count"] == 1
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
def test_invalid_artifact_id_returns_400(self, mock_mongo_db, flask_app, decoded_token):
|
||||
from application.api.user.tools.routes import GetArtifact
|
||||
|
||||
with flask_app.app_context():
|
||||
with flask_app.test_request_context():
|
||||
request.decoded_token = decoded_token
|
||||
resource = GetArtifact()
|
||||
resp = resource.get("not_an_object_id")
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json["message"] == "Invalid artifact ID"
|
||||
|
||||
def test_artifact_not_found_returns_404(self, mock_mongo_db, flask_app, decoded_token):
|
||||
from application.api.user.tools.routes import GetArtifact
|
||||
|
||||
non_existent_id = ObjectId()
|
||||
|
||||
with flask_app.app_context():
|
||||
with flask_app.test_request_context():
|
||||
request.decoded_token = decoded_token
|
||||
resource = GetArtifact()
|
||||
resp = resource.get(str(non_existent_id))
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.json["message"] == "Artifact not found"
|
||||
|
||||
def test_other_user_artifact_returns_404(self, mock_mongo_db, flask_app, decoded_token):
|
||||
from application.core.settings import settings
|
||||
from application.api.user.tools.routes import GetArtifact
|
||||
|
||||
db = mock_mongo_db[settings.MONGO_DB_NAME]
|
||||
note_id = ObjectId()
|
||||
db["notes"].insert_one(
|
||||
{
|
||||
"_id": note_id,
|
||||
"user_id": "other_user",
|
||||
"tool_id": "tool1",
|
||||
"note": "secret",
|
||||
"updated_at": datetime(2025, 1, 1),
|
||||
}
|
||||
)
|
||||
|
||||
with flask_app.app_context():
|
||||
with flask_app.test_request_context():
|
||||
request.decoded_token = decoded_token
|
||||
resource = GetArtifact()
|
||||
resp = resource.get(str(note_id))
|
||||
|
||||
assert resp.status_code == 404
|
||||
@@ -191,3 +191,11 @@ def mock_tool_manager(mock_tool, monkeypatch):
|
||||
"application.agents.base.ToolManager", Mock(return_value=manager)
|
||||
)
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app():
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
return app
|
||||
|
||||
@@ -759,4 +759,4 @@ def test_view_file_line_numbers(memory_tool: MemoryTool) -> None:
|
||||
|
||||
assert "3: Line 2" not in result # Wrong line number
|
||||
assert "4: Line 3" not in result # Wrong line number
|
||||
assert "5: Line 4" not in result # Wrong line number
|
||||
assert "5: Line 4" not in result # Wrong line number
|
||||
@@ -10,20 +10,23 @@ def notes_tool(monkeypatch) -> NotesTool:
|
||||
class FakeCollection:
|
||||
def __init__(self) -> None:
|
||||
self.docs = {} # key: user_id:tool_id -> doc
|
||||
self._id_counter = 0
|
||||
|
||||
def _generate_id(self):
|
||||
self._id_counter += 1
|
||||
return f"fake_id_{self._id_counter}"
|
||||
|
||||
def update_one(self, q, u, upsert=False):
|
||||
user_id = q.get("user_id")
|
||||
tool_id = q.get("tool_id")
|
||||
key = f"{user_id}:{tool_id}"
|
||||
|
||||
# emulate single-note storage with optional upsert
|
||||
|
||||
if key not in self.docs and not upsert:
|
||||
return type("res", (), {"modified_count": 0})
|
||||
if key not in self.docs and upsert:
|
||||
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": ""}
|
||||
if "$set" in u and "note" in u["$set"]:
|
||||
self.docs[key]["note"] = u["$set"]["note"]
|
||||
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": "", "_id": self._generate_id()}
|
||||
if "$set" in u:
|
||||
self.docs[key].update(u["$set"])
|
||||
return type("res", (), {"modified_count": 1})
|
||||
|
||||
def find_one(self, q):
|
||||
@@ -32,6 +35,28 @@ def notes_tool(monkeypatch) -> NotesTool:
|
||||
key = f"{user_id}:{tool_id}"
|
||||
return self.docs.get(key)
|
||||
|
||||
def find_one_and_update(self, q, u, upsert=False, return_document=None):
|
||||
user_id = q.get("user_id")
|
||||
tool_id = q.get("tool_id")
|
||||
key = f"{user_id}:{tool_id}"
|
||||
|
||||
if key not in self.docs and not upsert:
|
||||
return None
|
||||
if key not in self.docs and upsert:
|
||||
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": "", "_id": self._generate_id()}
|
||||
if "$set" in u:
|
||||
self.docs[key].update(u["$set"])
|
||||
return self.docs[key]
|
||||
|
||||
def find_one_and_delete(self, q):
|
||||
user_id = q.get("user_id")
|
||||
tool_id = q.get("tool_id")
|
||||
key = f"{user_id}:{tool_id}"
|
||||
if key in self.docs:
|
||||
doc = self.docs.pop(key)
|
||||
return doc
|
||||
return None
|
||||
|
||||
def delete_one(self, q):
|
||||
user_id = q.get("user_id")
|
||||
tool_id = q.get("tool_id")
|
||||
@@ -147,12 +172,9 @@ def test_insert_line(notes_tool: NotesTool) -> None:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_delete_nonexistent_note(monkeypatch):
|
||||
class FakeResult:
|
||||
deleted_count = 0
|
||||
|
||||
class FakeCollection:
|
||||
def delete_one(self, *args, **kwargs):
|
||||
return FakeResult()
|
||||
def find_one_and_delete(self, q):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"application.core.mongo_db.MongoDB.get_client",
|
||||
@@ -171,6 +193,11 @@ def test_notes_tool_isolation(monkeypatch) -> None:
|
||||
class FakeCollection:
|
||||
def __init__(self) -> None:
|
||||
self.docs = {}
|
||||
self._id_counter = 0
|
||||
|
||||
def _generate_id(self):
|
||||
self._id_counter += 1
|
||||
return f"fake_id_{self._id_counter}"
|
||||
|
||||
def update_one(self, q, u, upsert=False):
|
||||
user_id = q.get("user_id")
|
||||
@@ -180,9 +207,9 @@ def test_notes_tool_isolation(monkeypatch) -> None:
|
||||
if key not in self.docs and not upsert:
|
||||
return type("res", (), {"modified_count": 0})
|
||||
if key not in self.docs and upsert:
|
||||
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": ""}
|
||||
if "$set" in u and "note" in u["$set"]:
|
||||
self.docs[key]["note"] = u["$set"]["note"]
|
||||
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": "", "_id": self._generate_id()}
|
||||
if "$set" in u:
|
||||
self.docs[key].update(u["$set"])
|
||||
return type("res", (), {"modified_count": 1})
|
||||
|
||||
def find_one(self, q):
|
||||
@@ -191,6 +218,19 @@ def test_notes_tool_isolation(monkeypatch) -> None:
|
||||
key = f"{user_id}:{tool_id}"
|
||||
return self.docs.get(key)
|
||||
|
||||
def find_one_and_update(self, q, u, upsert=False, return_document=None):
|
||||
user_id = q.get("user_id")
|
||||
tool_id = q.get("tool_id")
|
||||
key = f"{user_id}:{tool_id}"
|
||||
|
||||
if key not in self.docs and not upsert:
|
||||
return None
|
||||
if key not in self.docs and upsert:
|
||||
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": "", "_id": self._generate_id()}
|
||||
if "$set" in u:
|
||||
self.docs[key].update(u["$set"])
|
||||
return self.docs[key]
|
||||
|
||||
fake_collection = FakeCollection()
|
||||
fake_db = {"notes": fake_collection}
|
||||
fake_client = {settings.MONGO_DB_NAME: fake_db}
|
||||
|
||||
@@ -24,14 +24,21 @@ class FakeCursor(list):
|
||||
class FakeCollection:
|
||||
def __init__(self):
|
||||
self.docs = {}
|
||||
self._id_counter = 0
|
||||
|
||||
def _generate_id(self):
|
||||
self._id_counter += 1
|
||||
return f"fake_id_{self._id_counter}"
|
||||
|
||||
def create_index(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def insert_one(self, doc):
|
||||
key = (doc["user_id"], doc["tool_id"], doc["todo_id"])
|
||||
if "_id" not in doc:
|
||||
doc["_id"] = self._generate_id()
|
||||
self.docs[key] = doc
|
||||
return type("res", (), {"inserted_id": key})
|
||||
return type("res", (), {"inserted_id": doc["_id"]})
|
||||
|
||||
def find_one(self, query):
|
||||
key = (query.get("user_id"), query.get("tool_id"), query.get("todo_id"))
|
||||
@@ -52,12 +59,25 @@ class FakeCollection:
|
||||
self.docs[key].update(update.get("$set", {}))
|
||||
return type("res", (), {"matched_count": 1})
|
||||
elif upsert:
|
||||
new_doc = {**query, **update.get("$set", {})}
|
||||
new_doc = {**query, **update.get("$set", {}), "_id": self._generate_id()}
|
||||
self.docs[key] = new_doc
|
||||
return type("res", (), {"matched_count": 1})
|
||||
else:
|
||||
return type("res", (), {"matched_count": 0})
|
||||
|
||||
def find_one_and_update(self, query, update):
|
||||
key = (query.get("user_id"), query.get("tool_id"), query.get("todo_id"))
|
||||
if key in self.docs:
|
||||
self.docs[key].update(update.get("$set", {}))
|
||||
return self.docs[key]
|
||||
return None
|
||||
|
||||
def find_one_and_delete(self, query):
|
||||
key = (query.get("user_id"), query.get("tool_id"), query.get("todo_id"))
|
||||
if key in self.docs:
|
||||
return self.docs.pop(key)
|
||||
return None
|
||||
|
||||
def delete_one(self, query):
|
||||
key = (query.get("user_id"), query.get("tool_id"), query.get("todo_id"))
|
||||
if key in self.docs:
|
||||
|
||||
Reference in New Issue
Block a user