mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 08:17:34 +00:00
fix: more artefact hardening
This commit is contained in:
1 parent
47cc0314af
commit
94a845aa82
37 files changed
+1233
-165
No files matched your search
@@ -7,8 +7,11 @@ against the ephemeral ``pg_conn`` fixture. Agent construction is bypassed via
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
|
||||
def _make_agent(*, workflow_id=None, workflow=None, workflow_owner=None,
|
||||
@@ -334,3 +337,48 @@ class TestGen:
|
||||
assert results == [
|
||||
{"type": "error", "error": "Failed to load workflow configuration."}
|
||||
]
|
||||
|
||||
|
||||
class TestAgentNodeApprovalPause:
|
||||
"""A node agent whose tool pauses for approval must fail the node visibly, not emit empty output."""
|
||||
|
||||
def test_tool_calls_pending_raises_clear_error(self, monkeypatch):
|
||||
from application.agents.workflows import workflow_engine as we
|
||||
from application.agents.workflows.schemas import (
|
||||
NodeType,
|
||||
Workflow,
|
||||
WorkflowGraph,
|
||||
WorkflowNode,
|
||||
)
|
||||
from application.agents.workflows.workflow_engine import WorkflowEngine
|
||||
|
||||
# An ephemeral node agent whose LLM handler yields the pause signal and ends,
|
||||
# emitting no "answer". Previously the engine dropped it and the node completed
|
||||
# with empty output (or raised a confusing "Structured output was expected").
|
||||
class _PendingAgent:
|
||||
attachments = None
|
||||
|
||||
def gen(self, prompt):
|
||||
yield {"type": "tool_calls_pending", "data": {"pending_tool_calls": [{"id": "x"}]}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
we.WorkflowNodeAgentFactory, "create", lambda **kw: _PendingAgent()
|
||||
)
|
||||
|
||||
graph = WorkflowGraph(workflow=Workflow(name="Approval Pause"), nodes=[], edges=[])
|
||||
agent = SimpleNamespace(
|
||||
endpoint="stream", llm_name="openai", model_id="gpt-4o-mini", api_key="k",
|
||||
chat_history=[], user="u", decoded_token={"sub": "u"},
|
||||
)
|
||||
engine = WorkflowEngine(
|
||||
graph, agent, workflow_run_id="22222222-2222-2222-2222-222222222222"
|
||||
)
|
||||
|
||||
node = WorkflowNode(
|
||||
id="agent_1", workflow_id="wf-1", type=NodeType.AGENT, title="Extractor",
|
||||
position={"x": 0, "y": 0},
|
||||
config={"llm_name": "openai", "system_prompt": "s", "tools": ["t"]},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="requires approval"):
|
||||
list(engine._execute_agent_node(node))
|
||||
@@ -396,6 +396,57 @@ def test_materialize_code_inputs_rejects_oversize(monkeypatch):
|
||||
assert manager.put_files == [] # nothing staged
|
||||
|
||||
|
||||
def test_materialize_code_inputs_dedupes_same_filename(monkeypatch):
|
||||
"""Two code-node inputs sharing a filename stage to DISTINCT inputs/ paths (no clobber)."""
|
||||
from contextlib import contextmanager
|
||||
|
||||
monkeypatch.setattr(
|
||||
"application.agents.tools.artifact_ref.resolve_artifact_id",
|
||||
lambda repo, raw, **k: str(raw),
|
||||
)
|
||||
|
||||
class _Repo:
|
||||
def __init__(self, conn):
|
||||
pass
|
||||
|
||||
def get_artifact_in_parent(self, artifact_id, *, workflow_run_id=None, conversation_id=None):
|
||||
return {"id": artifact_id, "current_version": 1}
|
||||
|
||||
def get_version(self, artifact_id, version):
|
||||
# Same filename, distinct stored bytes per input.
|
||||
return {"filename": "data.csv", "storage_path": f"p/{artifact_id}.csv"}
|
||||
|
||||
@contextmanager
|
||||
def _readonly():
|
||||
yield object()
|
||||
|
||||
class _Storage:
|
||||
def get_file(self, path):
|
||||
import io
|
||||
|
||||
return io.BytesIO(path.encode())
|
||||
|
||||
monkeypatch.setattr(
|
||||
"application.storage.db.repositories.artifacts.ArtifactsRepository", _Repo
|
||||
)
|
||||
monkeypatch.setattr("application.storage.db.session.db_readonly", _readonly)
|
||||
monkeypatch.setattr(
|
||||
"application.storage.storage_creator.StorageCreator.get_storage",
|
||||
staticmethod(lambda: _Storage()),
|
||||
)
|
||||
|
||||
engine = _engine()
|
||||
manager = _FakeManager(_Result(ok=True, stdout="ok"))
|
||||
loaded = engine._materialize_code_inputs(
|
||||
manager, engine._session_id(), ["art-a", "art-b"], "user-code"
|
||||
)
|
||||
|
||||
assert loaded == ["inputs/data.csv", "inputs/data-2.csv"]
|
||||
staged = dict(manager.put_files)
|
||||
assert set(staged) == {"inputs/data.csv", "inputs/data-2.csv"}
|
||||
assert staged["inputs/data.csv"] != staged["inputs/data-2.csv"] # no clobber
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pass-by-reference: survives serialization + CEL branches on the metadata.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -102,3 +102,62 @@ def test_resolve_ref_under_workflow_run_parent():
|
||||
repo = _FakeRepo(mapping={2: target}, run="run-9")
|
||||
assert resolve_artifact_id(repo, "A2", workflow_run_id="run-9") == target
|
||||
assert repo.calls == [(2, None, "run-9")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stable per-parent ref_seq: a ref no longer re-points after an earlier delete
|
||||
# ---------------------------------------------------------------------------
|
||||
class _SeqRepo:
|
||||
"""Fake repo backing resolve_id_by_ref_seq (stable) with the legacy positional fallback."""
|
||||
|
||||
def __init__(self, *, seq_map=None, pos_map=None, conv=None, run=None):
|
||||
self.seq_map = seq_map or {}
|
||||
self.pos_map = pos_map or {}
|
||||
self.conv = conv
|
||||
self.run = run
|
||||
self.seq_calls = []
|
||||
self.pos_calls = []
|
||||
|
||||
def _in_scope(self, conversation_id, workflow_run_id) -> bool:
|
||||
if self.conv is not None and conversation_id != self.conv:
|
||||
return False
|
||||
if self.run is not None and workflow_run_id != self.run:
|
||||
return False
|
||||
return True
|
||||
|
||||
def resolve_id_by_ref_seq(self, seq, *, conversation_id=None, workflow_run_id=None):
|
||||
self.seq_calls.append((seq, conversation_id, workflow_run_id))
|
||||
if not self._in_scope(conversation_id, workflow_run_id):
|
||||
return None
|
||||
return self.seq_map.get(seq)
|
||||
|
||||
def artifact_id_at_position(self, n, *, conversation_id=None, workflow_run_id=None):
|
||||
self.pos_calls.append((n, conversation_id, workflow_run_id))
|
||||
if not self._in_scope(conversation_id, workflow_run_id):
|
||||
return None
|
||||
return self.pos_map.get(n)
|
||||
|
||||
|
||||
def test_ref_resolves_by_stable_seq_not_shifted_position():
|
||||
b, c = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
# After deleting the earlier A(seq 1): ref_seq keeps B=2/C=3; positions shifted to B=1/C=2.
|
||||
repo = _SeqRepo(seq_map={2: b, 3: c}, pos_map={1: b, 2: c}, conv="conv-1")
|
||||
assert resolve_artifact_id(repo, "A2", conversation_id="conv-1") == b
|
||||
assert resolve_artifact_id(repo, "A3", conversation_id="conv-1") == c
|
||||
# The stable ref_seq hit; the (shifted) positional fallback was never consulted.
|
||||
assert repo.pos_calls == []
|
||||
|
||||
|
||||
def test_ref_falls_back_to_position_for_legacy_rows():
|
||||
legacy = str(uuid.uuid4())
|
||||
# Legacy row has no ref_seq -> resolve_id_by_ref_seq misses -> positional fallback resolves it.
|
||||
repo = _SeqRepo(seq_map={}, pos_map={1: legacy}, conv="conv-1")
|
||||
assert resolve_artifact_id(repo, "A1", conversation_id="conv-1") == legacy
|
||||
assert repo.seq_calls == [(1, "conv-1", None)]
|
||||
assert repo.pos_calls == [(1, "conv-1", None)]
|
||||
|
||||
|
||||
def test_ref_seq_does_not_cross_parents():
|
||||
b = str(uuid.uuid4())
|
||||
repo = _SeqRepo(seq_map={2: b}, pos_map={2: b}, conv="conv-1")
|
||||
assert resolve_artifact_id(repo, "A2", conversation_id="conv-OTHER") is None
|
||||
@@ -302,3 +302,55 @@ def test_malformed_json_schema_rejected_before_enqueue(monkeypatch):
|
||||
)
|
||||
out = _tool().execute_action("read_document", input=_ART_ID, json_schema={"properties": {}}, persist=False)
|
||||
assert out["status"] == "error" and "invalid json_schema" in out["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker-context: parse INLINE inside a Celery worker; dispatch from the web
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.unit
|
||||
def test_dispatch_inline_when_in_worker(monkeypatch):
|
||||
_stub_repo(monkeypatch, found=True, conv="conv-1", run=None)
|
||||
# Inside a worker current_task is truthy -> parse inline, never enqueue (else the
|
||||
# parsing queue self-deadlocks the worker that also serves it).
|
||||
monkeypatch.setattr(rd, "current_task", object())
|
||||
|
||||
import application.api.user.tasks as tasks
|
||||
monkeypatch.setattr(
|
||||
tasks.parse_document, "apply_async",
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not enqueue inside a worker")),
|
||||
)
|
||||
|
||||
import application.worker as worker
|
||||
called: Dict[str, Any] = {}
|
||||
|
||||
def _fake_run(artifact_id, parent, user_id, options):
|
||||
called["args"] = (artifact_id, parent, user_id)
|
||||
called["options"] = options
|
||||
return {"status": "ok", "content": "inline", "truncated": False}
|
||||
|
||||
monkeypatch.setattr(worker, "run_parse_document", _fake_run)
|
||||
|
||||
out = _tool().execute_action("read_document", input=_ART_ID, persist=False)
|
||||
assert out["status"] == "ok" and out["content"] == "inline"
|
||||
# Same auth re-resolution + parent shape as the dispatch path.
|
||||
assert called["args"] == (_ART_ID, {"conversation_id": "conv-1"}, "u-1")
|
||||
assert called["options"]["persist"] is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_dispatch_enqueues_when_not_in_worker(monkeypatch):
|
||||
_stub_repo(monkeypatch, found=True, conv="conv-1", run=None)
|
||||
# Web process: current_task falsy -> dispatch to the parsing queue, never inline.
|
||||
monkeypatch.setattr(rd, "current_task", None)
|
||||
captured = _patch_task(monkeypatch, payload={"status": "ok", "content": "queued", "truncated": False})
|
||||
|
||||
import application.worker as worker
|
||||
monkeypatch.setattr(
|
||||
worker, "run_parse_document",
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("web path must dispatch, not inline")),
|
||||
)
|
||||
|
||||
out = _tool().execute_action("read_document", input=_ART_ID, persist=False)
|
||||
assert out["status"] == "ok" and out["content"] == "queued"
|
||||
assert captured["args"][0] == _ART_ID
|
||||
assert captured["args"][1] == {"conversation_id": "conv-1"}
|
||||
Reference in new issue
Block a user