From 3c78540931b3c11f987c1f0205e025701f12bf51 Mon Sep 17 00:00:00 2001
From: Pavel
Date: Tue, 18 Aug 2026 17:10:26 +0200
Subject: [PATCH 1/6] Workflow export
---
application/api/user/agents/portability.py | 498 ++-
application/api/user/agents/routes.py | 35 +-
.../storage/db/repositories/workflows.py | 87 +
frontend/src/agents/AgentCard.tsx | 11 +-
frontend/src/locale/de.json | 3 +
frontend/src/locale/en.json | 3 +
frontend/src/locale/es.json | 3 +
frontend/src/locale/jp.json | 3 +
frontend/src/locale/ru.json | 3 +
frontend/src/locale/zh-TW.json | 3 +
frontend/src/locale/zh.json | 3 +
frontend/src/modals/ImportAgentModal.tsx | 34 +-
tests/api/test_agent_portability.py | 19 +
tests/api/test_workflow_portability.py | 385 ++
tests/api/user/agents/test_workflow_agents.py | 387 ++
tests/api/user/test_agents_routes.py | 3645 -----------------
tests/api/user/test_workflows.py | 12 -
17 files changed, 1434 insertions(+), 3700 deletions(-)
create mode 100644 tests/api/test_workflow_portability.py
create mode 100644 tests/api/user/agents/test_workflow_agents.py
delete mode 100644 tests/api/user/test_agents_routes.py
delete mode 100644 tests/api/user/test_workflows.py
diff --git a/application/api/user/agents/portability.py b/application/api/user/agents/portability.py
index 34ae3ec7..e29ab510 100644
--- a/application/api/user/agents/portability.py
+++ b/application/api/user/agents/portability.py
@@ -10,10 +10,18 @@ Re-import is idempotent at every level: the agent is matched by
prompts by ``(name, content)``, custom models by
``(display_name, upstream_model_id, base_url)`` — so the same file
applied twice updates rather than duplicating.
+
+Workflow agents additionally carry their graph under ``spec.workflow``.
+The graph's agent nodes reference tools, sources and models by raw id in
+their config; export rewrites those into the same portable identities the
+top-level sections use (``tool-N`` keys into ``spec.tools``, source names,
+custom-model display names), and import resolves them back and writes the
+graph through the same validation gate as the workflow API.
"""
from __future__ import annotations
+import copy
import re
from typing import Any, Optional
@@ -39,6 +47,9 @@ from application.storage.db.repositories.user_custom_models import (
UserCustomModelsRepository,
)
from application.storage.db.repositories.user_tools import UserToolsRepository
+from application.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
+from application.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
+from application.storage.db.repositories.workflows import WorkflowsRepository
from application.storage.db.session import db_readonly, db_session
@@ -49,6 +60,8 @@ KIND = "Agent"
# child resources a single import may reference / create.
MAX_IMPORT_BYTES = 512 * 1024
MAX_LIST_ITEMS = 100
+MAX_WORKFLOW_NODES = 200
+MAX_WORKFLOW_EDGES = 400
class AgentImportError(Exception):
@@ -266,6 +279,137 @@ def _serialize_models(conn, agent: dict, user: str) -> dict:
return {"default": default_value, "available": available}
+def _effective_node_config(config: dict) -> dict:
+ """Return the dict a node's settings actually live in.
+
+ The builder writes some node configs nested under ``config`` while other
+ payloads keep them flat; the engine reads
+ ``node.config.get("config", node.config)``. Mirror that here so reference
+ rewriting touches the dict the engine will read.
+ """
+ nested = config.get("config")
+ return nested if isinstance(nested, dict) else config
+
+
+def _serialize_workflow(conn, agent: dict, user: str):
+ """Build the portable ``spec.workflow`` block plus its reference sections.
+
+ Agent nodes hold raw tool/source/model ids; those are collected (deduped,
+ in first-appearance order) into the same top-level ``tools`` / ``sources``
+ / ``model`` sections a classic export uses, and the node configs are
+ rewritten to portable keys: ``tool-N``, the source's name, a custom
+ model's display name. Returns ``(workflow_block, tools, sources, model)``;
+ the block is None when the agent has no resolvable workflow.
+ """
+ empty_model = {"default": "", "available": []}
+ workflow_id = agent.get("workflow_id")
+ if not workflow_id:
+ return None, [], [], empty_model
+ row = WorkflowsRepository(conn).get(str(workflow_id), user)
+ if row is None:
+ return None, [], [], empty_model
+ # Lazy import, same as _apply_workflow — keeps route modules decoupled at load.
+ from application.api.user.workflows.routes import get_workflow_graph_version
+
+ version = get_workflow_graph_version(row)
+ node_rows = WorkflowNodesRepository(conn).find_by_version(str(row["id"]), version)
+ edge_rows = WorkflowEdgesRepository(conn).find_by_version(str(row["id"]), version)
+
+ # Pass 1: collect referenced ids across agent nodes, deduped and ordered.
+ tool_ids: list[str] = []
+ source_ids: list[str] = []
+ model_ids: list[str] = []
+ for n in node_rows:
+ if n.get("node_type") != "agent":
+ continue
+ cfg = _effective_node_config(n.get("config") or {})
+ for tid in cfg.get("tools") or []:
+ if str(tid) and str(tid) not in tool_ids:
+ tool_ids.append(str(tid))
+ for sid in cfg.get("sources") or []:
+ if str(sid) and str(sid) not in source_ids:
+ source_ids.append(str(sid))
+ mid = cfg.get("model_id")
+ if mid and str(mid) not in model_ids:
+ model_ids.append(str(mid))
+
+ # Serialize each reference through the classic per-resource serializers so
+ # secret redaction and identity rules stay in one place. Unresolvable ids
+ # (dangling rows) are dropped from both the section and the node config.
+ tools: list = []
+ tool_key_by_id: dict[str, str] = {}
+ for tid in tool_ids:
+ entries = _serialize_tools(conn, {"tools": [tid]}, user)
+ if not entries:
+ continue
+ tool_key_by_id[tid] = _tool_key(len(tools))
+ tools.append(entries[0])
+
+ sources: list = []
+ source_name_by_id: dict[str, str] = {}
+ for sid in source_ids:
+ entries = _serialize_sources(conn, {"source_id": sid}, user)
+ if not entries:
+ continue
+ source_name_by_id[sid] = entries[0]["name"]
+ sources.append(entries[0])
+
+ model = _serialize_models(conn, {"models": model_ids, "default_model_id": ""}, user)
+ model_name_by_id: dict[str, str] = {}
+ for entry in model["available"]:
+ if isinstance(entry, dict):
+ model_name_by_id[str(entry.get("ref") or "")] = entry.get("display_name") or ""
+ elif isinstance(entry, str):
+ model_name_by_id[entry] = entry
+
+ # Pass 2: emit nodes/edges in the workflow API's wire shape, with agent
+ # node configs rewritten to the portable keys.
+ nodes_out = []
+ for n in node_rows:
+ config = copy.deepcopy(n.get("config") or {})
+ if n.get("node_type") == "agent":
+ cfg = _effective_node_config(config)
+ cfg["tools"] = [
+ tool_key_by_id[str(t)]
+ for t in cfg.get("tools") or []
+ if str(t) in tool_key_by_id
+ ]
+ cfg["sources"] = [
+ source_name_by_id[str(s)]
+ for s in cfg.get("sources") or []
+ if str(s) in source_name_by_id
+ ]
+ if cfg.get("model_id"):
+ cfg["model_id"] = model_name_by_id.get(str(cfg["model_id"]))
+ nodes_out.append(
+ {
+ "id": n["node_id"],
+ "type": n["node_type"],
+ "title": n.get("title") or "",
+ "description": n.get("description") or "",
+ "position": n.get("position") or {"x": 0, "y": 0},
+ "config": config,
+ }
+ )
+ edges_out = [
+ {
+ "id": e["edge_id"],
+ "source": e.get("source_id"),
+ "target": e.get("target_id"),
+ "sourceHandle": e.get("source_handle"),
+ "targetHandle": e.get("target_handle"),
+ }
+ for e in edge_rows
+ ]
+ block = {
+ "name": row.get("name") or "",
+ "description": row.get("description") or "",
+ "nodes": nodes_out,
+ "edges": edges_out,
+ }
+ return block, tools, sources, model
+
+
def _import_config(spec: dict) -> dict:
"""Validate a spec's ``config`` through the same gate as the API.
@@ -286,16 +430,29 @@ def _import_config(spec: dict) -> dict:
def serialize_agent(conn, agent: dict, user: str) -> dict:
"""Build the portable export document for an agent row."""
+ is_workflow = (agent.get("agent_type") or "") == "workflow"
+ if is_workflow:
+ # A workflow agent's behavior lives in its graph: the agent-level
+ # prompt/source/tool/model slots are unused, so the top-level sections
+ # instead carry the references collected from the graph's agent nodes.
+ workflow_block, tools, sources, model = _serialize_workflow(conn, agent, user)
+ prompt = "default"
+ else:
+ workflow_block = None
+ tools = _serialize_tools(conn, agent, user)
+ sources = _serialize_sources(conn, agent, user)
+ model = _serialize_models(conn, agent, user)
+ prompt = _serialize_prompt(conn, agent, user)
spec = {
"name": agent.get("name") or "",
"description": agent.get("description") or "",
"agent_type": agent.get("agent_type") or "classic",
"retriever": agent.get("retriever") or "classic",
"chunks": int(agent["chunks"]) if agent.get("chunks") is not None else None,
- "prompt": _serialize_prompt(conn, agent, user),
- "model": _serialize_models(conn, agent, user),
- "sources": _serialize_sources(conn, agent, user),
- "tools": _serialize_tools(conn, agent, user),
+ "prompt": prompt,
+ "model": model,
+ "sources": sources,
+ "tools": tools,
"limits": {
"limited_token_mode": bool(agent.get("limited_token_mode", False)),
"token_limit": agent.get("token_limit"),
@@ -306,6 +463,8 @@ def serialize_agent(conn, agent: dict, user: str) -> dict:
"allow_system_prompt_override": bool(agent.get("allow_system_prompt_override", False)),
"config": agent.get("config") or {},
}
+ if is_workflow:
+ spec["workflow"] = workflow_block
return {
"apiVersion": API_VERSION,
"kind": KIND,
@@ -342,6 +501,33 @@ def agent_to_yaml(export: dict) -> str:
# ---------------------------------------------------------------------------
+def _validate_workflow_shape(spec: dict) -> None:
+ """Shallow-validate ``spec.workflow`` (deep graph validation runs at apply).
+
+ A missing key or explicit null is allowed — a draft workflow agent may have
+ no graph yet; only a present block must be well-formed and bounded.
+ """
+ workflow = spec.get("workflow")
+ if workflow is None:
+ return
+ if not isinstance(workflow, dict):
+ raise AgentImportError("spec.workflow must be a mapping")
+ nodes = workflow.get("nodes")
+ edges = workflow.get("edges")
+ if not isinstance(nodes, list) or not isinstance(edges, list):
+ raise AgentImportError("spec.workflow.nodes and spec.workflow.edges must be lists")
+ if len(nodes) > MAX_WORKFLOW_NODES:
+ raise AgentImportError(f"Too many workflow nodes (max {MAX_WORKFLOW_NODES})")
+ if len(edges) > MAX_WORKFLOW_EDGES:
+ raise AgentImportError(f"Too many workflow edges (max {MAX_WORKFLOW_EDGES})")
+ for node in nodes:
+ if not isinstance(node, dict) or not node.get("id") or not node.get("type"):
+ raise AgentImportError("Every workflow node needs an 'id' and a 'type'")
+ for edge in edges:
+ if not isinstance(edge, dict) or not edge.get("id"):
+ raise AgentImportError("Every workflow edge needs an 'id'")
+
+
def parse_agent_yaml(text: str) -> dict:
"""Parse and shallow-validate an agent YAML document."""
if text and len(text) > MAX_IMPORT_BYTES:
@@ -362,7 +548,7 @@ def parse_agent_yaml(text: str) -> dict:
if not spec.get("name"):
raise AgentImportError("spec.name is required")
if spec.get("agent_type") == "workflow":
- raise AgentImportError("Workflow agents are not supported for import yet")
+ _validate_workflow_shape(spec)
for key in ("sources", "tools"):
value = spec.get(key)
if isinstance(value, list) and len(value) > MAX_LIST_ITEMS:
@@ -528,7 +714,29 @@ def plan_import(conn, user: str, doc: dict) -> dict:
}
)
- return {"target": target, "sources": sources, "tools": tools, "prompt": prompt, "models": models}
+ workflow_plan = None
+ wf_spec = spec.get("workflow")
+ if spec.get("agent_type") == "workflow" and isinstance(wf_spec, dict):
+ action = "create"
+ if target.get("action") == "update" and target.get("agent_id"):
+ agent_row = AgentsRepository(conn).get(str(target["agent_id"]), user)
+ existing_wf = (agent_row or {}).get("workflow_id")
+ if existing_wf and WorkflowsRepository(conn).get(str(existing_wf), user):
+ action = "update"
+ workflow_plan = {
+ "nodes": len(wf_spec.get("nodes") or []),
+ "edges": len(wf_spec.get("edges") or []),
+ "action": action,
+ }
+
+ return {
+ "target": target,
+ "sources": sources,
+ "tools": tools,
+ "prompt": prompt,
+ "models": models,
+ "workflow": workflow_plan,
+ }
# ---------------------------------------------------------------------------
@@ -546,26 +754,36 @@ def _apply_prompt(conn, user: str, spec: dict) -> Optional[str]:
return str(row["id"])
-def _apply_sources(conn, user: str, spec: dict, resolution: dict, warnings: list) -> list:
+def _apply_sources(conn, user: str, spec: dict, resolution: dict, warnings: list):
+ """Resolve spec sources; returns ``(ids, id_by_name)``.
+
+ ``id_by_name`` maps each spec source name to its resolved id (or None) so
+ a workflow graph's node configs — which reference sources by name — can
+ be rewritten from the same resolution.
+ """
repo = SourcesRepository(conn)
mapping = resolution.get("sources")
if not isinstance(mapping, dict):
mapping = {}
resolved: list[str] = []
+ id_by_name: dict[str, Optional[str]] = {}
for src in spec.get("sources") or []:
name = src.get("name") or ""
+ id_by_name.setdefault(name, None)
mapped = mapping.get(name)
if mapped:
# Ownership-check the client-supplied id before linking (IDOR guard).
owned = repo.get_any(str(mapped), user)
if owned:
resolved.append(str(owned["id"]))
+ id_by_name[name] = str(owned["id"])
else:
warnings.append(f"Source mapping for '{name}' is not yours; ignored")
continue
match = repo.find_by_name(user, name)
if match:
resolved.append(str(match["id"]))
+ id_by_name[name] = str(match["id"])
else:
warnings.append(f"Source '{name}' not found; left unattached")
seen: set = set()
@@ -574,7 +792,7 @@ def _apply_sources(conn, user: str, spec: dict, resolution: dict, warnings: list
if sid not in seen:
seen.add(sid)
out.append(sid)
- return out
+ return out, id_by_name
def _validate_tool_urls(tool_type: str, config: dict) -> Optional[str]:
@@ -635,16 +853,31 @@ def _create_tool_from_spec(conn, user: str, tool: dict, secrets: dict, warnings:
return str(created["id"])
-def _apply_tools(conn, user: str, spec: dict, resolution: dict, warnings: list) -> list:
+def _apply_tools(conn, user: str, spec: dict, resolution: dict, warnings: list):
+ """Resolve spec tools; returns ``(ids, id_by_key)``.
+
+ ``id_by_key`` maps each spec entry's ``tool-N`` key to its resolved id
+ (or None when skipped/unavailable) so a workflow graph's node configs —
+ which reference tools by those keys — can be rewritten from the same
+ resolution.
+ """
repo = UserToolsRepository(conn)
user_tools = repo.list_for_user(user)
decisions = resolution.get("tools")
if not isinstance(decisions, dict):
decisions = {}
out: list[str] = []
+ id_by_key: dict[str, Optional[str]] = {}
+
+ def _resolve(key: str, tid: Optional[str]) -> None:
+ id_by_key[key] = tid
+ if tid:
+ out.append(tid)
+
for index, tool in enumerate(spec.get("tools") or []):
+ key = _tool_key(index)
tool_type = tool.get("type") or ""
- decision = decisions.get(_tool_key(index))
+ decision = decisions.get(key)
if not isinstance(decision, dict):
decision = {}
choice = decision.get("decision")
@@ -652,33 +885,36 @@ def _apply_tools(conn, user: str, spec: dict, resolution: dict, warnings: list)
if tool.get("builtin"):
if synthesize_tool_by_name(tool_type) is None:
warnings.append(f"Built-in tool '{tool_type}' unavailable; skipped")
+ _resolve(key, None)
continue
- out.append(default_tool_id(tool_type))
+ _resolve(key, default_tool_id(tool_type))
continue
if choice == "skip":
+ _resolve(key, None)
continue
if choice == "reuse" and decision.get("tool_id"):
# Ownership-check the client-supplied id before linking (IDOR guard).
tid = str(decision["tool_id"])
if is_synthesized_tool_id(tid) or repo.get_any(tid, user):
- out.append(tid)
+ _resolve(key, tid)
else:
warnings.append(f"Tool reuse id '{tid}' is not yours; ignored")
+ _resolve(key, None)
continue
custom_name = tool.get("name") or ""
match = _find_user_tool(user_tools, tool_type, custom_name)
if match and choice != "create":
- out.append(str(match["id"]))
+ _resolve(key, str(match["id"]))
continue
created_id = _create_tool_from_spec(conn, user, tool, decision.get("secrets") or {}, warnings)
+ _resolve(key, created_id)
if created_id:
- out.append(created_id)
# Record so a later identical spec entry reuses rather than re-creates.
user_tools.append({"id": created_id, "name": tool_type, "custom_name": custom_name})
- return out
+ return out, id_by_key
def _apply_models(conn, user: str, spec: dict, resolution: dict, warnings: list):
@@ -749,13 +985,186 @@ def _apply_models(conn, user: str, spec: dict, resolution: dict, warnings: list)
default_id = str(owned["id"])
else:
warnings.append(f"Default model '{raw_default}' not resolved")
- return (models or None), (default_id or None)
+ return (models or None), (default_id or None), name_to_id
def _limit(spec: dict, key: str):
return (spec.get("limits") or {}).get(key)
+# Sentinel: the file omitted ``spec.workflow`` entirely, so the agent's
+# existing workflow link is left untouched (vs. explicit null = clear).
+_WORKFLOW_UNCHANGED = object()
+
+
+def _rewrite_node_refs(
+ conn,
+ user: str,
+ cfg: dict,
+ tool_ids_by_key: dict,
+ source_ids_by_name: dict,
+ model_ids_by_name: dict,
+ warnings: list,
+) -> None:
+ """Resolve an agent node's portable references back to the importer's ids.
+
+ Export writes ``tool-N`` keys / source names / model display names, and a
+ hand-edited file may also carry raw ids — those are accepted only when
+ they are a builtin synthetic id or ownership-check against the importing
+ user (IDOR guard). Anything unresolvable is dropped with a warning so the
+ graph imports degraded rather than pointing at foreign resources.
+ """
+ tools_repo = UserToolsRepository(conn)
+ sources_repo = SourcesRepository(conn)
+
+ resolved_tools = []
+ for ref in cfg.get("tools") or []:
+ ref = str(ref)
+ if ref in tool_ids_by_key:
+ tid = tool_ids_by_key[ref]
+ if tid:
+ resolved_tools.append(tid)
+ else:
+ warnings.append(f"Workflow node tool '{ref}' not resolved; removed")
+ elif is_synthesized_tool_id(ref):
+ resolved_tools.append(ref)
+ elif looks_like_uuid(ref) and tools_repo.get_any(ref, user):
+ resolved_tools.append(ref)
+ else:
+ warnings.append(f"Workflow node tool '{ref}' not resolved; removed")
+ cfg["tools"] = resolved_tools
+
+ resolved_sources = []
+ for ref in cfg.get("sources") or []:
+ ref = str(ref)
+ if ref in source_ids_by_name:
+ sid = source_ids_by_name[ref]
+ if sid:
+ resolved_sources.append(sid)
+ else:
+ warnings.append(f"Workflow node source '{ref}' not resolved; removed")
+ elif looks_like_uuid(ref) and sources_repo.get_any(ref, user):
+ resolved_sources.append(ref)
+ else:
+ warnings.append(f"Workflow node source '{ref}' not resolved; removed")
+ cfg["sources"] = resolved_sources
+
+ raw_model = cfg.get("model_id")
+ if raw_model:
+ raw_model = str(raw_model)
+ if raw_model in model_ids_by_name:
+ cfg["model_id"] = model_ids_by_name[raw_model]
+ elif validate_model_id(raw_model, user):
+ cfg["model_id"] = raw_model
+ else:
+ warnings.append(f"Workflow node model '{raw_model}' not resolved; removed")
+ cfg["model_id"] = None
+
+
+def _apply_workflow(
+ conn,
+ user: str,
+ spec: dict,
+ target: dict,
+ tool_ids_by_key: dict,
+ source_ids_by_name: dict,
+ model_ids_by_name: dict,
+ warnings: list,
+):
+ """Write the imported graph; returns the workflow id, None, or the sentinel.
+
+ The graph goes through the same validation gate as the workflow API
+ (``validate_workflow_structure``) so a hand-edited YAML can't install a
+ graph the write path would have rejected. When the matched agent already
+ owns a workflow the graph lands as a new version of it (mirroring
+ ``PUT /api/workflows/``); otherwise a new workflow is created.
+ """
+ if "workflow" not in spec:
+ return _WORKFLOW_UNCHANGED
+ wf_spec = spec.get("workflow")
+ if not isinstance(wf_spec, dict):
+ return None
+ from application.api.user.workflows.routes import (
+ _write_graph,
+ get_workflow_graph_version,
+ normalize_agent_node_json_schemas,
+ validate_workflow_structure,
+ )
+
+ nodes_data = []
+ for node in wf_spec.get("nodes") or []:
+ config = copy.deepcopy(node.get("config") or {})
+ if not isinstance(config, dict):
+ config = {}
+ if node.get("type") == "agent":
+ _rewrite_node_refs(
+ conn,
+ user,
+ _effective_node_config(config),
+ tool_ids_by_key,
+ source_ids_by_name,
+ model_ids_by_name,
+ warnings,
+ )
+ position = node.get("position")
+ nodes_data.append(
+ {
+ "id": str(node.get("id")),
+ "type": str(node.get("type")),
+ "title": node.get("title") or "",
+ "description": node.get("description") or "",
+ "position": position if isinstance(position, dict) else {"x": 0, "y": 0},
+ "data": config,
+ }
+ )
+ edges_data = [
+ {
+ "id": str(edge.get("id")),
+ "source": edge.get("source"),
+ "target": edge.get("target"),
+ "sourceHandle": edge.get("sourceHandle"),
+ "targetHandle": edge.get("targetHandle"),
+ }
+ for edge in wf_spec.get("edges") or []
+ ]
+
+ errors = validate_workflow_structure(nodes_data, edges_data, user_id=user)
+ if errors:
+ raise AgentImportError("Workflow validation failed: " + "; ".join(errors[:5]))
+ nodes_data = normalize_agent_node_json_schemas(nodes_data)
+
+ wf_repo = WorkflowsRepository(conn)
+ name = wf_spec.get("name") or spec.get("name") or "Imported workflow"
+ description = wf_spec.get("description") or ""
+
+ existing = None
+ if target.get("action") == "update" and target.get("agent_id"):
+ agent_row = AgentsRepository(conn).get(str(target["agent_id"]), user)
+ existing_id = (agent_row or {}).get("workflow_id")
+ if existing_id:
+ existing = wf_repo.get(str(existing_id), user)
+ if existing is not None:
+ pg_workflow_id = str(existing["id"])
+ next_version = get_workflow_graph_version(existing) + 1
+ _write_graph(conn, pg_workflow_id, next_version, nodes_data, edges_data)
+ wf_repo.update(
+ pg_workflow_id,
+ user,
+ {
+ "name": name,
+ "description": description,
+ "current_graph_version": next_version,
+ },
+ )
+ WorkflowNodesRepository(conn).delete_other_versions(pg_workflow_id, next_version)
+ WorkflowEdgesRepository(conn).delete_other_versions(pg_workflow_id, next_version)
+ return pg_workflow_id
+
+ created = wf_repo.create(user, name, description=description)
+ _write_graph(conn, str(created["id"]), 1, nodes_data, edges_data)
+ return str(created["id"])
+
+
def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None) -> dict:
"""Create or update an agent from a parsed YAML doc.
@@ -778,9 +1187,25 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
target = _resolve_target(conn, user, metadata)
prompt_id = _apply_prompt(conn, user, spec)
- source_ids = _apply_sources(conn, user, spec, resolution, warnings)
- tool_ids = _apply_tools(conn, user, spec, resolution, warnings)
- models, default_model_id = _apply_models(conn, user, spec, resolution, warnings)
+ source_ids, source_ids_by_name = _apply_sources(conn, user, spec, resolution, warnings)
+ tool_ids, tool_ids_by_key = _apply_tools(conn, user, spec, resolution, warnings)
+ models, default_model_id, model_ids_by_name = _apply_models(
+ conn, user, spec, resolution, warnings
+ )
+
+ is_workflow = (spec.get("agent_type") or "classic") == "workflow"
+ workflow_result = _WORKFLOW_UNCHANGED
+ if is_workflow:
+ workflow_result = _apply_workflow(
+ conn,
+ user,
+ spec,
+ target,
+ tool_ids_by_key,
+ source_ids_by_name,
+ model_ids_by_name,
+ warnings,
+ )
agents_repo = AgentsRepository(conn)
is_update = bool(target.get("action") == "update" and target.get("agent_id"))
@@ -817,6 +1242,27 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
"config": _import_config(spec),
"slug": slug,
}
+ if is_workflow:
+ # A workflow agent's behavior lives in its graph — the agent-level
+ # prompt/tool/model/source slots in the file exist only to carry the
+ # graph's references through resolution, so they never land on the row.
+ authoritative.update(
+ {
+ "prompt_id": None,
+ "tools": [],
+ "models": None,
+ "default_model_id": None,
+ "extra_source_ids": [],
+ }
+ )
+ if workflow_result is not _WORKFLOW_UNCHANGED:
+ if workflow_result is None and (target.get("status") or "") == "published":
+ warnings.append(
+ "File has no workflow; the published agent kept its existing one"
+ )
+ else:
+ authoritative["workflow_id"] = workflow_result
+
# Optional fields — applied only when present so a partial file doesn't wipe them.
optional = {
"retriever": spec.get("retriever") or ("classic" if not source_ids else None),
@@ -836,6 +1282,7 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
"agent_id": str(target["agent_id"]),
"action": "updated",
"status": target.get("status") or "draft",
+ "agent_type": authoritative["agent_type"],
"slug": slug,
"warnings": warnings,
}
@@ -846,6 +1293,7 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
"agent_id": str(row["id"]),
"action": "created",
"status": "draft",
+ "agent_type": authoritative["agent_type"],
"slug": slug,
"warnings": warnings,
}
@@ -906,13 +1354,6 @@ class ExportAgent(Resource):
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
- if (agent.get("agent_type") or "") == "workflow":
- return make_response(
- jsonify(
- {"success": False, "message": "Workflow agents can't be exported yet"}
- ),
- 400,
- )
agent["slug"] = ensure_agent_slug(conn, agent, user)
export = serialize_agent(conn, agent, user)
body = agent_to_yaml(export)
@@ -961,6 +1402,11 @@ class ImportAgent(Resource):
try:
with db_session() as conn:
result = apply_import(conn, user, doc, resolution)
+ except AgentImportError as exc:
+ # Apply-time rejection of the user's document (e.g. the workflow
+ # graph failing the API's validation gate) — a client error, with
+ # the detail surfaced, matching how PUT /workflows reports it.
+ return make_response(jsonify({"success": False, "message": str(exc)}), 400)
except Exception:
current_app.logger.error("Agent import failed", exc_info=True)
return make_response(
diff --git a/application/api/user/agents/routes.py b/application/api/user/agents/routes.py
index fc8f3753..83d2103a 100644
--- a/application/api/user/agents/routes.py
+++ b/application/api/user/agents/routes.py
@@ -38,8 +38,6 @@ from application.storage.db.repositories.shared_conversations import (
from application.storage.db.repositories.stack_logs import StackLogsRepository
from application.storage.db.repositories.token_usage import TokenUsageRepository
from application.storage.db.repositories.users import UsersRepository
-from application.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
-from application.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
from application.storage.db.repositories.workflows import WorkflowsRepository
from application.storage.db.session import db_readonly, db_session
from application.utils import (
@@ -1473,15 +1471,13 @@ class DeleteAgent(Resource):
workflow_id = agent.get("workflow_id")
# For workflow-type agents, delete the owned workflow in the
# same transaction. workflow_nodes/workflow_edges cascade
- # via ON DELETE CASCADE so a single workflow delete suffices.
+ # via ON DELETE CASCADE so the single owner-scoped delete
+ # suffices — and it must stay the ONLY delete: an explicit
+ # (unscoped) node/edge cleanup here once let deleting an agent
+ # whose ``workflow_id`` pointed at another user's workflow gut
+ # that user's graph.
if agent.get("agent_type") == "workflow" and workflow_id:
try:
- WorkflowNodesRepository(conn).delete_by_workflow(
- str(workflow_id),
- )
- WorkflowEdgesRepository(conn).delete_by_workflow(
- str(workflow_id),
- )
WorkflowsRepository(conn).delete(str(workflow_id), user)
except Exception as wf_err:
current_app.logger.warning(
@@ -1667,13 +1663,32 @@ class AdoptAgent(Resource):
for col in (
"description", "agent_type", "retriever",
"default_model_id",
- "source_id", "prompt_id", "folder_id", "workflow_id",
+ "source_id", "prompt_id", "folder_id",
"extra_source_ids",
):
val = template.get(col)
if val not in (None, ""):
create_kwargs[col] = val
+ # ``workflow_id`` must NOT be copied by reference: the runtime
+ # resolves it strictly owner-scoped, so the adopter would get
+ # "Workflow not found or inaccessible" on every run. Deep-copy
+ # the template's graph into a workflow the adopter owns.
+ if template.get("workflow_id"):
+ cloned_workflow = WorkflowsRepository(conn).clone_to_user(
+ str(template["workflow_id"]),
+ user,
+ from_owner=template.get("user_id"),
+ )
+ if cloned_workflow is not None:
+ create_kwargs["workflow_id"] = str(cloned_workflow["id"])
+ else:
+ current_app.logger.warning(
+ "Adopt: template %s references missing workflow %s; "
+ "adopted agent created without one",
+ template.get("id"), template.get("workflow_id"),
+ )
+
# The avatar must live under the adopter's own upload
# directory: image paths are validated against their owner,
# so a copied reference to the template owner's blob would
diff --git a/application/storage/db/repositories/workflows.py b/application/storage/db/repositories/workflows.py
index bc9e4cc9..42ba12f9 100644
--- a/application/storage/db/repositories/workflows.py
+++ b/application/storage/db/repositories/workflows.py
@@ -115,6 +115,93 @@ class WorkflowsRepository:
row = result.fetchone()
return row[0] if row else None
+ def clone_to_user(
+ self,
+ workflow_id: str,
+ new_owner: str,
+ *,
+ from_owner: Optional[str] = None,
+ ) -> Optional[dict]:
+ """Deep-copy a workflow's current graph into a new workflow owned by ``new_owner``.
+
+ The runtime resolves ``agents.workflow_id`` strictly owner-scoped, so a
+ workflow reference can never be shared across users by id — it must be
+ copied. Only the current graph version is copied; the clone starts at
+ version 1. The source is resolved scoped to ``from_owner`` when given
+ (callers pass the expected owner so a stray id can't clone another
+ tenant's graph). Omitting ``from_owner`` resolves the source with NO
+ ownership check — only do that when ``workflow_id`` comes from trusted
+ server-side state, never from client input. Returns the new workflow
+ row, or None if the source is missing.
+ """
+ from application.storage.db.repositories.workflow_edges import (
+ WorkflowEdgesRepository,
+ )
+ from application.storage.db.repositories.workflow_nodes import (
+ WorkflowNodesRepository,
+ )
+
+ if not looks_like_uuid(workflow_id):
+ return None
+ source = (
+ self.get(workflow_id, from_owner)
+ if from_owner is not None
+ else self.get_by_id(workflow_id)
+ )
+ if source is None:
+ return None
+ try:
+ version = int(source.get("current_graph_version") or 1)
+ except (TypeError, ValueError):
+ version = 1
+ if version <= 0:
+ version = 1
+
+ clone = self.create(
+ new_owner, source.get("name") or "", description=source.get("description")
+ )
+ clone_id = str(clone["id"])
+
+ nodes_repo = WorkflowNodesRepository(self._conn)
+ node_rows = nodes_repo.find_by_version(workflow_id, version)
+ created_nodes = nodes_repo.bulk_create(
+ clone_id,
+ 1,
+ [
+ {
+ "node_id": n["node_id"],
+ "node_type": n["node_type"],
+ "title": n.get("title"),
+ "description": n.get("description"),
+ "position": n.get("position") or {"x": 0, "y": 0},
+ "config": n.get("config") or {},
+ }
+ for n in node_rows
+ ],
+ )
+ uuid_by_node_id = {n["node_id"]: n["id"] for n in created_nodes}
+
+ edges_repo = WorkflowEdgesRepository(self._conn)
+ edge_rows = edges_repo.find_by_version(workflow_id, version)
+ translated = []
+ for e in edge_rows:
+ from_uuid = uuid_by_node_id.get(e.get("source_id"))
+ to_uuid = uuid_by_node_id.get(e.get("target_id"))
+ if not from_uuid or not to_uuid:
+ continue
+ translated.append(
+ {
+ "edge_id": e["edge_id"],
+ "from_node_id": from_uuid,
+ "to_node_id": to_uuid,
+ "source_handle": e.get("source_handle"),
+ "target_handle": e.get("target_handle"),
+ "config": e.get("config") or {},
+ }
+ )
+ edges_repo.bulk_create(clone_id, 1, translated)
+ return clone
+
def delete(self, workflow_id: str, user_id: str) -> bool:
# Run artifacts parent to ``workflow_runs`` (which cascade-delete with the
# workflow), but ``artifacts.workflow_run_id`` is a bare uuid with no FK, so
diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx
index cc12413b..1e5ae660 100644
--- a/frontend/src/agents/AgentCard.tsx
+++ b/frontend/src/agents/AgentCard.tsx
@@ -297,7 +297,13 @@ export default function AgentCard({
const handleExport = async () => {
try {
const response = await userService.exportAgent(agent.id ?? '', token);
- if (!response.ok) throw new Error('Failed to export agent');
+ if (!response.ok) {
+ const message = await response
+ .json()
+ .then((data) => data?.message)
+ .catch(() => null);
+ throw new Error(message || t('agents.exportAgentFailed'));
+ }
const yamlText = await response.text();
const blob = new Blob([yamlText], { type: 'application/x-yaml' });
const url = URL.createObjectURL(blob);
@@ -310,6 +316,9 @@ export default function AgentCard({
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error:', error);
+ alert(
+ error instanceof Error ? error.message : t('agents.exportAgentFailed'),
+ );
}
};
diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json
index 6f9701c7..ef1244ef 100644
--- a/frontend/src/locale/de.json
+++ b/frontend/src/locale/de.json
@@ -1030,6 +1030,8 @@
"importError": "Import failed",
"willCreate": "Creates a new agent.",
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
+ "workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
+ "workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
"sources": "Sources",
"tools": "Tools",
"models": "Models",
@@ -1149,6 +1151,7 @@
"newAgent": "Neuer Agent",
"importAgent": "Import",
"exportAgent": "Export as YAML",
+ "exportAgentFailed": "Failed to export agent",
"shareWithTeam": "Mit Team teilen",
"backToAll": "Zurück zu allen Agenten",
"pageHeader": {
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 52adfe35..ff6e22f5 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -1035,6 +1035,8 @@
"importError": "Import failed",
"willCreate": "Creates a new agent.",
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
+ "workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
+ "workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
"sources": "Sources",
"tools": "Tools",
"models": "Models",
@@ -1158,6 +1160,7 @@
"newAgent": "New Agent",
"importAgent": "Import",
"exportAgent": "Export as YAML",
+ "exportAgentFailed": "Failed to export agent",
"shareWithTeam": "Share with team",
"backToAll": "Back to all agents",
"pageHeader": {
diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json
index 62029b09..3065caa8 100644
--- a/frontend/src/locale/es.json
+++ b/frontend/src/locale/es.json
@@ -1030,6 +1030,8 @@
"importError": "Import failed",
"willCreate": "Creates a new agent.",
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
+ "workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
+ "workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
"sources": "Sources",
"tools": "Tools",
"models": "Models",
@@ -1149,6 +1151,7 @@
"newAgent": "Nuevo Agente",
"importAgent": "Import",
"exportAgent": "Export as YAML",
+ "exportAgentFailed": "Failed to export agent",
"shareWithTeam": "Compartir con el equipo",
"backToAll": "Volver a todos los agentes",
"pageHeader": {
diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json
index a1d7d0a7..8bfd42ee 100644
--- a/frontend/src/locale/jp.json
+++ b/frontend/src/locale/jp.json
@@ -1030,6 +1030,8 @@
"importError": "Import failed",
"willCreate": "Creates a new agent.",
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
+ "workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
+ "workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
"sources": "Sources",
"tools": "Tools",
"models": "Models",
@@ -1149,6 +1151,7 @@
"newAgent": "新しいエージェント",
"importAgent": "Import",
"exportAgent": "Export as YAML",
+ "exportAgentFailed": "Failed to export agent",
"shareWithTeam": "チームと共有",
"backToAll": "すべてのエージェントに戻る",
"pageHeader": {
diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json
index cb364aa1..a025eb32 100644
--- a/frontend/src/locale/ru.json
+++ b/frontend/src/locale/ru.json
@@ -1030,6 +1030,8 @@
"importError": "Import failed",
"willCreate": "Creates a new agent.",
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
+ "workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
+ "workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
"sources": "Sources",
"tools": "Tools",
"models": "Models",
@@ -1149,6 +1151,7 @@
"newAgent": "Новый Агент",
"importAgent": "Import",
"exportAgent": "Export as YAML",
+ "exportAgentFailed": "Failed to export agent",
"shareWithTeam": "Поделиться с командой",
"backToAll": "Вернуться ко всем агентам",
"pageHeader": {
diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json
index f2739086..dd8bc6ee 100644
--- a/frontend/src/locale/zh-TW.json
+++ b/frontend/src/locale/zh-TW.json
@@ -1030,6 +1030,8 @@
"importError": "Import failed",
"willCreate": "Creates a new agent.",
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
+ "workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
+ "workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
"sources": "Sources",
"tools": "Tools",
"models": "Models",
@@ -1149,6 +1151,7 @@
"newAgent": "新建代理",
"importAgent": "Import",
"exportAgent": "Export as YAML",
+ "exportAgentFailed": "Failed to export agent",
"shareWithTeam": "與團隊分享",
"backToAll": "返回所有代理",
"pageHeader": {
diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json
index bb0d7680..b7ca42a4 100644
--- a/frontend/src/locale/zh.json
+++ b/frontend/src/locale/zh.json
@@ -1030,6 +1030,8 @@
"importError": "Import failed",
"willCreate": "Creates a new agent.",
"willUpdate": "Updates an existing agent (matched by {{matchedBy}}).",
+ "workflowCreate": "Includes a workflow with {{nodes}} nodes — it will be created for you.",
+ "workflowUpdate": "Includes a workflow with {{nodes}} nodes — your existing workflow will be updated.",
"sources": "Sources",
"tools": "Tools",
"models": "Models",
@@ -1149,6 +1151,7 @@
"newAgent": "新建代理",
"importAgent": "Import",
"exportAgent": "Export as YAML",
+ "exportAgentFailed": "Failed to export agent",
"shareWithTeam": "与团队共享",
"backToAll": "返回所有代理",
"pageHeader": {
diff --git a/frontend/src/modals/ImportAgentModal.tsx b/frontend/src/modals/ImportAgentModal.tsx
index 579c7f90..7a46a35a 100644
--- a/frontend/src/modals/ImportAgentModal.tsx
+++ b/frontend/src/modals/ImportAgentModal.tsx
@@ -54,6 +54,11 @@ type ImportPlan = {
tools: PlanTool[];
prompt: { status: string; name?: string };
models: PlanModel[];
+ workflow?: {
+ nodes: number;
+ edges: number;
+ action: 'create' | 'update';
+ } | null;
};
interface ImportAgentModalProps {
@@ -85,7 +90,7 @@ export default function ImportAgentModal({
const [modelKeys, setModelKeys] = useState>({});
const [warnings, setWarnings] = useState(null);
const [importedStatus, setImportedStatus] = useState(null);
- const [goToAgentId, setGoToAgentId] = useState(null);
+ const [goToEditPath, setGoToEditPath] = useState(null);
const reset = () => {
setYamlText('');
@@ -99,7 +104,7 @@ export default function ImportAgentModal({
setModelKeys({});
setWarnings(null);
setImportedStatus(null);
- setGoToAgentId(null);
+ setGoToEditPath(null);
};
const handleClose = () => {
@@ -187,16 +192,20 @@ export default function ImportAgentModal({
return;
}
const agentId = data.agent_id as string;
+ const editPath =
+ data.agent_type === 'workflow'
+ ? `/agents/workflow/edit/${agentId}`
+ : `/agents/edit/${agentId}`;
if (data.warnings && data.warnings.length > 0) {
// Keep the modal open so the user sees what was skipped.
- setGoToAgentId(agentId);
+ setGoToEditPath(editPath);
setWarnings(data.warnings as string[]);
setImportedStatus((data.status as string) || null);
setPlan(null);
return;
}
handleClose();
- navigate(`/agents/edit/${agentId}`);
+ navigate(editPath);
} catch {
setError(t('modals.importAgent.importError'));
} finally {
@@ -217,9 +226,9 @@ export default function ImportAgentModal({
{
- const id = goToAgentId;
+ const path = goToEditPath;
handleClose();
- if (id) navigate(`/agents/edit/${id}`);
+ if (path) navigate(path);
}}
className="rounded-3xl px-5"
>
@@ -328,6 +337,19 @@ export default function ImportAgentModal({
: t('modals.importAgent.willCreate')}
+ {plan.workflow && (
+
+
+ {plan.workflow.action === 'update'
+ ? t('modals.importAgent.workflowUpdate', {
+ nodes: plan.workflow.nodes,
+ })
+ : t('modals.importAgent.workflowCreate', {
+ nodes: plan.workflow.nodes,
+ })}
+
+ )}
+
{plan.sources.length > 0 && (
{plan.sources.map((s) =>
diff --git a/tests/api/test_agent_portability.py b/tests/api/test_agent_portability.py
index 8ef90167..eae9ae1e 100644
--- a/tests/api/test_agent_portability.py
+++ b/tests/api/test_agent_portability.py
@@ -286,10 +286,29 @@ def test_parse_rejects_bad_documents():
parse_agent_yaml("kind: Agent\napiVersion: other/v1\nspec:\n name: x\n")
with pytest.raises(AgentImportError):
parse_agent_yaml("kind: Agent\napiVersion: docsgpt.arc53.com/v1\nspec: {}\n")
+ # A workflow agent without a graph parses (drafts may have none)…
+ doc = parse_agent_yaml(
+ "kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
+ "spec:\n name: x\n agent_type: workflow\n"
+ )
+ assert doc["spec"]["agent_type"] == "workflow"
+ # …but a present workflow block must be well-formed.
+ with pytest.raises(AgentImportError):
+ parse_agent_yaml(
+ "kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
+ "spec:\n name: x\n agent_type: workflow\n workflow: nonsense\n"
+ )
with pytest.raises(AgentImportError):
parse_agent_yaml(
"kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
"spec:\n name: x\n agent_type: workflow\n"
+ " workflow:\n nodes: {}\n edges: []\n"
+ )
+ with pytest.raises(AgentImportError):
+ parse_agent_yaml(
+ "kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
+ "spec:\n name: x\n agent_type: workflow\n"
+ " workflow:\n nodes:\n - id: n1\n edges: []\n"
)
diff --git a/tests/api/test_workflow_portability.py b/tests/api/test_workflow_portability.py
new file mode 100644
index 00000000..706427f8
--- /dev/null
+++ b/tests/api/test_workflow_portability.py
@@ -0,0 +1,385 @@
+"""Workflow-agent YAML export/import (application.api.user.agents.portability).
+
+Real SQL against the ephemeral ``pg_conn`` fixture, like
+``test_agent_portability.py``. Covers the graph block: reference rewriting
+(tools → ``tool-N`` keys, sources → names, custom models → display names),
+secret redaction inside node configs, graph validation at apply, and
+idempotent re-import as a new graph version of the same workflow.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from application.agents.default_tools import default_tool_id
+from application.api.user.agents.portability import (
+ AgentImportError,
+ agent_to_yaml,
+ apply_import,
+ parse_agent_yaml,
+ plan_import,
+ serialize_agent,
+)
+from application.storage.db.repositories.agents import AgentsRepository
+from application.storage.db.repositories.sources import SourcesRepository
+from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+)
+from application.storage.db.repositories.user_tools import UserToolsRepository
+from application.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
+from application.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
+from application.storage.db.repositories.workflows import WorkflowsRepository
+
+
+pytestmark = pytest.mark.integration
+
+
+def _graph(conn, workflow_row):
+ version = int(workflow_row.get("current_graph_version") or 1)
+ nodes = WorkflowNodesRepository(conn).find_by_version(str(workflow_row["id"]), version)
+ edges = WorkflowEdgesRepository(conn).find_by_version(str(workflow_row["id"]), version)
+ return nodes, edges
+
+
+def _seed_workflow_agent(conn, user, *, tool_id=None, source_id=None, model_id=None):
+ """Workflow agent with a start → agent → end graph referencing real resources."""
+ agent_cfg = {
+ "agent_type": "classic",
+ "system_prompt": "Answer briefly.",
+ "tools": [t for t in [tool_id, default_tool_id("read_document")] if t],
+ "sources": [s for s in [source_id] if s],
+ "model_id": model_id,
+ }
+ wf = WorkflowsRepository(conn).create(user, "Pipeline", description="wd")
+ wf_id = str(wf["id"])
+ nodes = WorkflowNodesRepository(conn).bulk_create(
+ wf_id,
+ 1,
+ [
+ {"node_id": "start-1", "node_type": "start", "title": "Start"},
+ {"node_id": "agent-1", "node_type": "agent", "title": "Answer", "config": agent_cfg},
+ {"node_id": "end-1", "node_type": "end", "title": "End"},
+ ],
+ )
+ by_str = {n["node_id"]: n["id"] for n in nodes}
+ WorkflowEdgesRepository(conn).bulk_create(
+ wf_id,
+ 1,
+ [
+ {"edge_id": "e1", "from_node_id": by_str["start-1"], "to_node_id": by_str["agent-1"]},
+ {"edge_id": "e2", "from_node_id": by_str["agent-1"], "to_node_id": by_str["end-1"]},
+ ],
+ )
+ agent = AgentsRepository(conn).create(
+ user,
+ "Flow Bot",
+ "published",
+ description="wf agent",
+ agent_type="workflow",
+ workflow_id=wf_id,
+ )
+ return agent, wf
+
+
+def _seed_tool(conn, user):
+ return UserToolsRepository(conn).create(
+ user,
+ "brave",
+ config={"encrypted_credentials": "SECRETBLOB", "region": "US"},
+ custom_name="My Brave",
+ display_name="Brave Search",
+ description="search",
+ config_requirements={"token": {"secret": True, "required": True}, "region": {}},
+ actions=[],
+ )
+
+
+def test_export_rewrites_node_refs_and_redacts_secrets(pg_conn):
+ user = "u_wf_export"
+ tool = _seed_tool(pg_conn, user)
+ src = SourcesRepository(pg_conn).create("KB", user_id=user, type="file")
+ model = UserCustomModelsRepository(pg_conn).create(
+ user, "llama-3", "My Llama", "https://models.example.com/v1", "sk-plain"
+ )
+ agent, wf = _seed_workflow_agent(
+ pg_conn,
+ user,
+ tool_id=str(tool["id"]),
+ source_id=str(src["id"]),
+ model_id=str(model["id"]),
+ )
+
+ export = serialize_agent(pg_conn, agent, user)
+ text = agent_to_yaml(export)
+ spec = export["spec"]
+
+ assert "SECRETBLOB" not in text and "sk-plain" not in text
+ # Raw ids never appear inside the graph block (the sections may carry
+ # ``ref`` hints, same as classic export).
+ workflow_text = agent_to_yaml(spec["workflow"])
+ assert str(tool["id"]) not in workflow_text
+ assert str(src["id"]) not in workflow_text
+ assert str(model["id"]) not in workflow_text
+
+ assert spec["workflow"]["name"] == "Pipeline"
+ assert [n["id"] for n in spec["workflow"]["nodes"]] == ["agent-1", "end-1", "start-1"]
+ agent_node = next(n for n in spec["workflow"]["nodes"] if n["id"] == "agent-1")
+ assert agent_node["config"]["tools"] == ["tool-0", "tool-1"]
+ assert agent_node["config"]["sources"] == ["KB"]
+ assert agent_node["config"]["model_id"] == "My Llama"
+
+ # The collected sections use the classic serializers' portable identities.
+ assert spec["tools"][0]["type"] == "brave"
+ assert spec["tools"][0]["requires_secrets"] == ["token"]
+ assert spec["tools"][1] == {"type": "read_document", "builtin": True}
+ assert spec["sources"][0]["name"] == "KB"
+ assert spec["model"]["available"][0]["display_name"] == "My Llama"
+ assert spec["prompt"] == "default"
+
+ edges = spec["workflow"]["edges"]
+ assert {(e["source"], e["target"]) for e in edges} == {
+ ("start-1", "agent-1"),
+ ("agent-1", "end-1"),
+ }
+
+
+def test_export_without_workflow_emits_null_block(pg_conn):
+ user = "u_wf_export2"
+ agent = AgentsRepository(pg_conn).create(
+ user, "Draft Flow", "draft", agent_type="workflow"
+ )
+ export = serialize_agent(pg_conn, agent, user)
+ assert export["spec"]["workflow"] is None
+ assert export["spec"]["tools"] == []
+ assert export["spec"]["sources"] == []
+
+
+def test_round_trip_same_user_reuses_everything(pg_conn):
+ user = "u_wf_round"
+ tool = _seed_tool(pg_conn, user)
+ src = SourcesRepository(pg_conn).create("KB", user_id=user, type="file")
+ model = UserCustomModelsRepository(pg_conn).create(
+ user, "llama-3", "My Llama", "https://models.example.com/v1", "sk-plain"
+ )
+ agent, wf = _seed_workflow_agent(
+ pg_conn,
+ user,
+ tool_id=str(tool["id"]),
+ source_id=str(src["id"]),
+ model_id=str(model["id"]),
+ )
+
+ doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
+ result = apply_import(pg_conn, user, doc)
+
+ assert result["action"] == "updated"
+ assert result["agent_id"] == str(agent["id"])
+ updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
+ # Same workflow row, bumped to a new graph version.
+ assert str(updated["workflow_id"]) == str(wf["id"])
+ wf_row = WorkflowsRepository(pg_conn).get(str(wf["id"]), user)
+ assert int(wf_row["current_graph_version"]) == 2
+ nodes, edges = _graph(pg_conn, wf_row)
+ assert len(nodes) == 3 and len(edges) == 2
+ agent_node = next(n for n in nodes if n["node_id"] == "agent-1")
+ # References resolved back to the same owned resources — no duplicates.
+ assert set(agent_node["config"]["tools"]) == {
+ str(tool["id"]), default_tool_id("read_document"),
+ }
+ assert agent_node["config"]["sources"] == [str(src["id"])]
+ assert agent_node["config"]["model_id"] == str(model["id"])
+ assert len(UserToolsRepository(pg_conn).list_for_user(user)) == 1
+ # Old graph versions are pruned.
+ assert WorkflowNodesRepository(pg_conn).find_by_version(str(wf["id"]), 1) == []
+
+
+def test_import_into_fresh_user_creates_workflow(pg_conn):
+ owner = "u_wf_owner"
+ tool = _seed_tool(pg_conn, owner)
+ src = SourcesRepository(pg_conn).create("KB", user_id=owner, type="file")
+ agent, _ = _seed_workflow_agent(
+ pg_conn, owner, tool_id=str(tool["id"]), source_id=str(src["id"])
+ )
+ doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, owner)))
+
+ importer = "u_wf_importer"
+ result = apply_import(
+ pg_conn,
+ importer,
+ doc,
+ {"tools": {"tool-0": {"decision": "create", "secrets": {"token": "tok"}}}},
+ )
+
+ assert result["action"] == "created"
+ created = AgentsRepository(pg_conn).get(result["agent_id"], importer)
+ assert created["status"] == "draft"
+ assert created["workflow_id"] is not None
+ wf_row = WorkflowsRepository(pg_conn).get(str(created["workflow_id"]), importer)
+ assert wf_row is not None and wf_row["name"] == "Pipeline"
+ nodes, edges = _graph(pg_conn, wf_row)
+ assert len(nodes) == 3 and len(edges) == 2
+ agent_node = next(n for n in nodes if n["node_id"] == "agent-1")
+ # The tool was created for the importer; the missing source was dropped.
+ my_tool = UserToolsRepository(pg_conn).list_for_user(importer)[0]
+ assert set(agent_node["config"]["tools"]) == {
+ str(my_tool["id"]), default_tool_id("read_document"),
+ }
+ assert agent_node["config"]["sources"] == []
+ assert any("Source 'KB' not found" in w for w in result["warnings"])
+ assert any("not resolved" in w for w in result["warnings"])
+
+
+def test_plan_reports_workflow_block(pg_conn):
+ user = "u_wf_plan"
+ agent, wf = _seed_workflow_agent(pg_conn, user)
+ doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
+
+ plan = plan_import(pg_conn, user, doc)
+ assert plan["workflow"] == {"nodes": 3, "edges": 2, "action": "update"}
+
+ other_plan = plan_import(pg_conn, "someone_else", doc)
+ assert other_plan["workflow"]["action"] == "create"
+
+
+def test_import_rejects_invalid_graph(pg_conn):
+ user = "u_wf_invalid"
+ doc = {
+ "apiVersion": "docsgpt.arc53.com/v1",
+ "kind": "Agent",
+ "metadata": {"slug": "broken-flow"},
+ "spec": {
+ "name": "Broken Flow",
+ "agent_type": "workflow",
+ "workflow": {
+ "name": "Broken",
+ # No start/end nodes — the API's validation gate must reject it.
+ "nodes": [{"id": "a", "type": "agent", "config": {}}],
+ "edges": [],
+ },
+ },
+ }
+ with pytest.raises(AgentImportError, match="Workflow validation failed"):
+ apply_import(pg_conn, user, doc)
+ # Nothing half-imported.
+ assert AgentsRepository(pg_conn).list_for_user(user) == []
+ assert WorkflowsRepository(pg_conn).list_for_user(user) == []
+
+
+def test_import_drops_foreign_raw_ids_from_node_config(pg_conn):
+ """A hand-edited file carrying someone else's raw ids must not link them."""
+ victim = "u_wf_victim"
+ victim_tool = _seed_tool(pg_conn, victim)
+ victim_src = SourcesRepository(pg_conn).create("Secret KB", user_id=victim, type="file")
+
+ doc = {
+ "apiVersion": "docsgpt.arc53.com/v1",
+ "kind": "Agent",
+ "metadata": {"slug": "sneaky-flow"},
+ "spec": {
+ "name": "Sneaky Flow",
+ "agent_type": "workflow",
+ "workflow": {
+ "name": "Sneaky",
+ "nodes": [
+ {"id": "s", "type": "start"},
+ {
+ "id": "a",
+ "type": "agent",
+ "config": {
+ "tools": [str(victim_tool["id"])],
+ "sources": [str(victim_src["id"])],
+ },
+ },
+ {"id": "e", "type": "end"},
+ ],
+ "edges": [
+ {"id": "e1", "source": "s", "target": "a"},
+ {"id": "e2", "source": "a", "target": "e"},
+ ],
+ },
+ },
+ }
+ result = apply_import(pg_conn, "u_wf_attacker", doc)
+ created = AgentsRepository(pg_conn).get(result["agent_id"], "u_wf_attacker")
+ wf_row = WorkflowsRepository(pg_conn).get(str(created["workflow_id"]), "u_wf_attacker")
+ nodes, _ = _graph(pg_conn, wf_row)
+ agent_node = next(n for n in nodes if n["node_id"] == "a")
+ assert agent_node["config"]["tools"] == []
+ assert agent_node["config"]["sources"] == []
+ assert len(result["warnings"]) == 2
+
+
+def test_import_route_rejects_invalid_graph_as_client_error(pg_conn):
+ """Apply-time graph rejection is a 400 carrying the validation detail.
+
+ Regression: an ``AgentImportError`` raised while applying (the file parses,
+ but the graph fails the workflow API's validation gate) used to fall into
+ the route's generic handler and surface as a 500 with no explanation.
+ """
+ from contextlib import contextmanager
+ from unittest.mock import patch
+
+ from flask import Flask, request
+
+ from application.api.user.agents.portability import ImportAgent
+
+ @contextmanager
+ def _conn():
+ yield pg_conn
+
+ yaml_text = (
+ "kind: Agent\napiVersion: docsgpt.arc53.com/v1\n"
+ "spec:\n"
+ " name: Broken Flow\n"
+ " agent_type: workflow\n"
+ " workflow:\n"
+ " name: Broken\n"
+ " nodes:\n"
+ " - id: a\n"
+ " type: agent\n"
+ " edges: []\n"
+ )
+ app = Flask(__name__)
+ with patch(
+ "application.api.user.agents.portability.db_session", _conn
+ ), app.test_request_context(
+ "/api/import_agent", method="POST", json={"yaml": yaml_text}
+ ):
+ request.decoded_token = {"sub": "u_wf_route"}
+ response = ImportAgent().post()
+ assert response.status_code == 400
+ body = response.get_json()
+ assert body["success"] is False
+ assert "Workflow validation failed" in body["message"]
+ assert AgentsRepository(pg_conn).list_for_user("u_wf_route") == []
+
+
+def test_import_without_workflow_keeps_published_agents_graph(pg_conn):
+ user = "u_wf_keep"
+ agent, wf = _seed_workflow_agent(pg_conn, user)
+ doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
+ del doc["spec"]["workflow"]
+
+ result = apply_import(pg_conn, user, doc)
+ assert result["action"] == "updated"
+ updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
+ assert str(updated["workflow_id"]) == str(wf["id"])
+
+
+def test_import_with_null_workflow_keeps_published_but_clears_draft(pg_conn):
+ user = "u_wf_null"
+ agent, wf = _seed_workflow_agent(pg_conn, user)
+ doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
+ doc["spec"]["workflow"] = None
+
+ # Published agent: explicit null must not break it — kept with a warning.
+ result = apply_import(pg_conn, user, doc)
+ updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
+ assert str(updated["workflow_id"]) == str(wf["id"])
+ assert any("kept its existing" in w for w in result["warnings"])
+
+ # Draft agent: explicit null clears the link.
+ AgentsRepository(pg_conn).update(str(agent["id"]), user, {"status": "draft"})
+ result = apply_import(pg_conn, user, doc)
+ updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
+ assert updated["workflow_id"] is None
diff --git a/tests/api/user/agents/test_workflow_agents.py b/tests/api/user/agents/test_workflow_agents.py
new file mode 100644
index 00000000..8cf9bc60
--- /dev/null
+++ b/tests/api/user/agents/test_workflow_agents.py
@@ -0,0 +1,387 @@
+"""Workflow-agent behavior of the agents routes, against real Postgres.
+
+Rebuilds the workflow-related coverage of the retired Mongo-era
+``tests/api/user/test_agents_routes.py`` on the ``pg_conn`` fixture:
+adopting a workflow template (the graph must be deep-copied, never
+shared by id), deleting a workflow agent (only the owner's graph may
+go), and creating/updating agents with a workflow reference.
+"""
+
+from contextlib import contextmanager
+from unittest.mock import patch
+
+import pytest
+from flask import Flask
+
+
+@pytest.fixture
+def app():
+ return Flask(__name__)
+
+
+@contextmanager
+def _patch_db(conn):
+ @contextmanager
+ def _yield():
+ yield conn
+
+ with patch(
+ "application.api.user.agents.routes.db_session", _yield
+ ), patch(
+ "application.api.user.agents.routes.db_readonly", _yield
+ ):
+ yield
+
+
+def _seed_workflow(pg_conn, user, *, name="WF"):
+ """Create a start → agent → end workflow; returns (workflow, nodes, edges)."""
+ from application.storage.db.repositories.workflow_edges import (
+ WorkflowEdgesRepository,
+ )
+ from application.storage.db.repositories.workflow_nodes import (
+ WorkflowNodesRepository,
+ )
+ from application.storage.db.repositories.workflows import WorkflowsRepository
+
+ wf = WorkflowsRepository(pg_conn).create(user, name, description="d")
+ wf_id = str(wf["id"])
+ nodes = WorkflowNodesRepository(pg_conn).bulk_create(
+ wf_id,
+ 1,
+ [
+ {"node_id": "start-1", "node_type": "start", "title": "Start"},
+ {
+ "node_id": "agent-1",
+ "node_type": "agent",
+ "title": "Agent",
+ "config": {"system_prompt": "hi", "tools": [], "sources": []},
+ },
+ {"node_id": "end-1", "node_type": "end", "title": "End"},
+ ],
+ )
+ by_str = {n["node_id"]: n["id"] for n in nodes}
+ edges = WorkflowEdgesRepository(pg_conn).bulk_create(
+ wf_id,
+ 1,
+ [
+ {
+ "edge_id": "e1",
+ "from_node_id": by_str["start-1"],
+ "to_node_id": by_str["agent-1"],
+ },
+ {
+ "edge_id": "e2",
+ "from_node_id": by_str["agent-1"],
+ "to_node_id": by_str["end-1"],
+ },
+ ],
+ )
+ return wf, nodes, edges
+
+
+def _graph(pg_conn, workflow_id, version=1):
+ from application.storage.db.repositories.workflow_edges import (
+ WorkflowEdgesRepository,
+ )
+ from application.storage.db.repositories.workflow_nodes import (
+ WorkflowNodesRepository,
+ )
+
+ nodes = WorkflowNodesRepository(pg_conn).find_by_version(str(workflow_id), version)
+ edges = WorkflowEdgesRepository(pg_conn).find_by_version(str(workflow_id), version)
+ return nodes, edges
+
+
+class TestCloneToUser:
+ def test_clones_graph_for_new_owner(self, pg_conn):
+ from application.storage.db.repositories.workflows import WorkflowsRepository
+
+ wf, _, _ = _seed_workflow(pg_conn, "owner-a", name="Source WF")
+ clone = WorkflowsRepository(pg_conn).clone_to_user(
+ str(wf["id"]), "owner-b", from_owner="owner-a"
+ )
+ assert clone is not None
+ assert str(clone["id"]) != str(wf["id"])
+ assert clone["user_id"] == "owner-b"
+ assert clone["name"] == "Source WF"
+
+ nodes, edges = _graph(pg_conn, clone["id"])
+ assert {n["node_id"] for n in nodes} == {"start-1", "agent-1", "end-1"}
+ agent_node = next(n for n in nodes if n["node_id"] == "agent-1")
+ assert agent_node["config"] == {
+ "system_prompt": "hi", "tools": [], "sources": [],
+ }
+ # Edges are re-keyed onto the clone's node rows, not the source's.
+ clone_node_uuids = {n["id"] for n in nodes}
+ assert len(edges) == 2
+ for e in edges:
+ assert e["from_node_id"] in clone_node_uuids
+ assert e["to_node_id"] in clone_node_uuids
+ # The source graph is untouched.
+ src_nodes, src_edges = _graph(pg_conn, wf["id"])
+ assert len(src_nodes) == 3 and len(src_edges) == 2
+
+ def test_wrong_owner_returns_none(self, pg_conn):
+ from application.storage.db.repositories.workflows import WorkflowsRepository
+
+ wf, _, _ = _seed_workflow(pg_conn, "owner-a")
+ assert (
+ WorkflowsRepository(pg_conn).clone_to_user(
+ str(wf["id"]), "thief", from_owner="not-the-owner"
+ )
+ is None
+ )
+
+ def test_missing_workflow_returns_none(self, pg_conn):
+ from application.storage.db.repositories.workflows import WorkflowsRepository
+
+ assert (
+ WorkflowsRepository(pg_conn).clone_to_user(
+ "00000000-0000-0000-0000-000000000000", "u"
+ )
+ is None
+ )
+
+
+class TestAdoptWorkflowAgent:
+ def test_adopt_clones_the_graph(self, app, pg_conn):
+ from application.api.user.agents.routes import AdoptAgent
+ from application.storage.db.repositories.agents import AgentsRepository
+ from application.storage.db.repositories.workflows import WorkflowsRepository
+
+ wf, _, _ = _seed_workflow(pg_conn, "__system__", name="Template WF")
+ repo = AgentsRepository(pg_conn)
+ template = repo.create(
+ "__system__",
+ "WF Template",
+ "template",
+ agent_type="workflow",
+ workflow_id=str(wf["id"]),
+ )
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/adopt_agent?id={template['id']}", method="POST"
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "u-wf-adopter"}
+ response = AdoptAgent().post()
+ assert response.status_code == 200
+
+ adopted = next(
+ a for a in repo.list_for_user("u-wf-adopter") if a["name"] == "WF Template"
+ )
+ assert adopted["workflow_id"] is not None
+ assert str(adopted["workflow_id"]) != str(wf["id"])
+ cloned = WorkflowsRepository(pg_conn).get(
+ str(adopted["workflow_id"]), "u-wf-adopter"
+ )
+ assert cloned is not None
+ nodes, edges = _graph(pg_conn, cloned["id"])
+ assert {n["node_id"] for n in nodes} == {"start-1", "agent-1", "end-1"}
+ assert len(edges) == 2
+
+ def test_adopt_survives_unresolvable_workflow(self, app, pg_conn):
+ """A template pointing at a graph its owner doesn't own adopts without one."""
+ from application.api.user.agents.routes import AdoptAgent
+ from application.storage.db.repositories.agents import AgentsRepository
+
+ wf, _, _ = _seed_workflow(pg_conn, "someone-else")
+ repo = AgentsRepository(pg_conn)
+ template = repo.create(
+ "__system__",
+ "Broken WF Template",
+ "template",
+ agent_type="workflow",
+ workflow_id=str(wf["id"]),
+ )
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/adopt_agent?id={template['id']}", method="POST"
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "u-wf-adopter2"}
+ response = AdoptAgent().post()
+ assert response.status_code == 200
+ adopted = next(
+ a
+ for a in repo.list_for_user("u-wf-adopter2")
+ if a["name"] == "Broken WF Template"
+ )
+ assert adopted["workflow_id"] is None
+ # The unowned graph was not cloned or mutated.
+ nodes, edges = _graph(pg_conn, wf["id"])
+ assert len(nodes) == 3 and len(edges) == 2
+
+
+class TestDeleteWorkflowAgent:
+ def test_delete_removes_owned_workflow(self, app, pg_conn):
+ from application.api.user.agents.routes import DeleteAgent
+ from application.storage.db.repositories.agents import AgentsRepository
+ from application.storage.db.repositories.workflows import WorkflowsRepository
+
+ user = "u-wf-del"
+ wf, _, _ = _seed_workflow(pg_conn, user)
+ repo = AgentsRepository(pg_conn)
+ agent = repo.create(
+ user, "WF Agent", "draft", agent_type="workflow", workflow_id=str(wf["id"])
+ )
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/delete_agent?id={agent['id']}", method="DELETE"
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": user}
+ response = DeleteAgent().delete()
+ assert response.status_code == 200
+ assert repo.get_any(str(agent["id"]), user) is None
+ assert WorkflowsRepository(pg_conn).get_by_id(str(wf["id"])) is None
+ nodes, edges = _graph(pg_conn, wf["id"])
+ assert nodes == [] and edges == []
+
+ def test_delete_leaves_unowned_workflow_intact(self, app, pg_conn):
+ """Regression: deleting an agent whose ``workflow_id`` points at another
+ user's workflow (the pre-clone adopted shape) must not touch that graph.
+ The old explicit node/edge cleanup was not owner-scoped and gutted it."""
+ from application.api.user.agents.routes import DeleteAgent
+ from application.storage.db.repositories.agents import AgentsRepository
+ from application.storage.db.repositories.workflows import WorkflowsRepository
+
+ wf, _, _ = _seed_workflow(pg_conn, "__system__", name="Shared Template WF")
+ repo = AgentsRepository(pg_conn)
+ agent = repo.create(
+ "u-wf-del2",
+ "Adopted WF Agent",
+ "draft",
+ agent_type="workflow",
+ workflow_id=str(wf["id"]),
+ )
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/delete_agent?id={agent['id']}", method="DELETE"
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "u-wf-del2"}
+ response = DeleteAgent().delete()
+ assert response.status_code == 200
+ assert repo.get_any(str(agent["id"]), "u-wf-del2") is None
+ # The system's workflow and its whole graph survive.
+ assert WorkflowsRepository(pg_conn).get_by_id(str(wf["id"])) is not None
+ nodes, edges = _graph(pg_conn, wf["id"])
+ assert len(nodes) == 3 and len(edges) == 2
+
+
+class TestCreateWorkflowAgent:
+ def test_create_published_with_owned_workflow(self, app, pg_conn):
+ from application.api.user.agents.routes import CreateAgent
+ from application.storage.db.repositories.agents import AgentsRepository
+
+ user = "u-wf-create"
+ wf, _, _ = _seed_workflow(pg_conn, user)
+
+ with _patch_db(pg_conn), app.test_request_context(
+ "/api/create_agent",
+ method="POST",
+ json={
+ "name": "My WF Agent",
+ "description": "d",
+ "agent_type": "workflow",
+ "status": "published",
+ "workflow": str(wf["id"]),
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": user}
+ response = CreateAgent().post()
+ assert response.status_code == 201
+ agent = next(
+ a
+ for a in AgentsRepository(pg_conn).list_for_user(user)
+ if a["name"] == "My WF Agent"
+ )
+ assert str(agent["workflow_id"]) == str(wf["id"])
+ assert agent["status"] == "published"
+
+ def test_create_published_with_unowned_workflow_returns_404(self, app, pg_conn):
+ from application.api.user.agents.routes import CreateAgent
+
+ wf, _, _ = _seed_workflow(pg_conn, "someone-else")
+
+ with _patch_db(pg_conn), app.test_request_context(
+ "/api/create_agent",
+ method="POST",
+ json={
+ "name": "Stolen WF Agent",
+ "description": "d",
+ "agent_type": "workflow",
+ "status": "published",
+ "workflow": str(wf["id"]),
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "u-wf-thief"}
+ response = CreateAgent().post()
+ assert response.status_code == 404
+
+
+class TestUpdateWorkflowAgent:
+ def test_update_sets_and_clears_workflow(self, app, pg_conn):
+ from application.api.user.agents.routes import UpdateAgent
+ from application.storage.db.repositories.agents import AgentsRepository
+
+ user = "u-wf-update"
+ wf, _, _ = _seed_workflow(pg_conn, user)
+ repo = AgentsRepository(pg_conn)
+ agent = repo.create(user, "WF Agent", "draft", agent_type="workflow")
+ agent_id = str(agent["id"])
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/update_agent/{agent_id}",
+ method="PUT",
+ json={"workflow": str(wf["id"])},
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": user}
+ response = UpdateAgent().put(agent_id)
+ assert response.status_code == 200
+ assert str(repo.get(agent_id, user)["workflow_id"]) == str(wf["id"])
+
+ # Clearing is allowed while the agent stays a draft.
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/update_agent/{agent_id}",
+ method="PUT",
+ json={"workflow": None},
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": user}
+ response = UpdateAgent().put(agent_id)
+ assert response.status_code == 200
+ assert repo.get(agent_id, user)["workflow_id"] is None
+
+ def test_publish_without_workflow_is_rejected(self, app, pg_conn):
+ from application.api.user.agents.routes import UpdateAgent
+ from application.storage.db.repositories.agents import AgentsRepository
+
+ user = "u-wf-update2"
+ agent = AgentsRepository(pg_conn).create(
+ user, "WF Agent", "draft", agent_type="workflow"
+ )
+ agent_id = str(agent["id"])
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/update_agent/{agent_id}",
+ method="PUT",
+ json={"status": "published"},
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": user}
+ response = UpdateAgent().put(agent_id)
+ assert response.status_code == 400
diff --git a/tests/api/user/test_agents_routes.py b/tests/api/user/test_agents_routes.py
deleted file mode 100644
index 3e6fd5d8..00000000
--- a/tests/api/user/test_agents_routes.py
+++ /dev/null
@@ -1,3645 +0,0 @@
-"""Tests for application.api.user.agents.routes module."""
-
-import uuid
-from unittest.mock import Mock, patch
-
-import pytest
-from flask import Flask
-
-pytestmark = pytest.mark.skip(
- reason="Asserts Mongo-era *_collection call shapes + references removed helpers "
- "(validate_workflow_access, build_agent_document); needs PG repository-based rewrite. "
- "Tracked as migration debt."
-)
-
-
-@pytest.fixture
-def app():
- app = Flask(__name__)
- return app
-
-
-# ---------------------------------------------------------------------------
-# Helper functions
-# ---------------------------------------------------------------------------
-
-
-@pytest.mark.unit
-class TestNormalizeWorkflowReference:
-
- def test_returns_none_for_none(self):
- from application.api.user.agents.routes import normalize_workflow_reference
-
- assert normalize_workflow_reference(None) is None
-
- def test_extracts_id_from_dict(self):
- from application.api.user.agents.routes import normalize_workflow_reference
-
- result = normalize_workflow_reference({"id": "abc123"})
- assert result == "abc123"
-
- def test_extracts_underscore_id_from_dict(self):
- from application.api.user.agents.routes import normalize_workflow_reference
-
- result = normalize_workflow_reference({"_id": "abc123"})
- assert result == "abc123"
-
- def test_extracts_workflow_id_from_dict(self):
- from application.api.user.agents.routes import normalize_workflow_reference
-
- result = normalize_workflow_reference({"workflow_id": "abc123"})
- assert result == "abc123"
-
- def test_returns_empty_string_for_blank_string(self):
- from application.api.user.agents.routes import normalize_workflow_reference
-
- assert normalize_workflow_reference(" ") == ""
-
- def test_returns_plain_string_value(self):
- from application.api.user.agents.routes import normalize_workflow_reference
-
- oid = str(uuid.uuid4().hex)
- assert normalize_workflow_reference(oid) == oid
-
- def test_parses_json_string_value(self):
- from application.api.user.agents.routes import normalize_workflow_reference
-
- result = normalize_workflow_reference('"some_id"')
- assert result == "some_id"
-
- def test_parses_json_dict_value(self):
- from application.api.user.agents.routes import normalize_workflow_reference
-
- result = normalize_workflow_reference('{"id": "xyz"}')
- assert result == "xyz"
-
- def test_returns_string_for_non_json_string(self):
- from application.api.user.agents.routes import normalize_workflow_reference
-
- assert normalize_workflow_reference("plain_value") == "plain_value"
-
- def test_converts_non_string_non_dict_to_str(self):
- from application.api.user.agents.routes import normalize_workflow_reference
-
- assert normalize_workflow_reference(42) == "42"
-
- def test_dict_priority_id_over_workflow_id(self):
- from application.api.user.agents.routes import normalize_workflow_reference
-
- result = normalize_workflow_reference(
- {"id": "first", "_id": "second", "workflow_id": "third"}
- )
- assert result == "first"
-
-
-@pytest.mark.unit
-class TestValidateWorkflowAccess:
-
- def test_returns_none_when_not_required_and_empty(self, app):
- from application.api.user.agents.routes import validate_workflow_access
-
- with app.app_context():
- wf_id, err = validate_workflow_access(None, "user1", required=False)
- assert wf_id is None
- assert err is None
-
- def test_returns_error_when_required_and_empty(self, app):
- from application.api.user.agents.routes import validate_workflow_access
-
- with app.app_context():
- wf_id, err = validate_workflow_access(None, "user1", required=True)
- assert wf_id is None
- assert err is not None
- assert err.status_code == 400
-
- def test_returns_error_for_invalid_oid_format(self, app):
- from application.api.user.agents.routes import validate_workflow_access
-
- with app.app_context():
- wf_id, err = validate_workflow_access("not-a-valid-oid", "user1")
- assert wf_id is None
- assert err.status_code == 400
-
- def test_returns_404_when_workflow_not_found(self, app):
- from application.api.user.agents.routes import validate_workflow_access
-
- mock_wf_col = Mock()
- mock_wf_col.find_one.return_value = None
- oid = str(uuid.uuid4().hex)
-
- with app.app_context():
- with patch(
- "application.api.user.agents.routes.workflows_collection", mock_wf_col
- ):
- wf_id, err = validate_workflow_access(oid, "user1")
- assert err.status_code == 404
-
- def test_returns_workflow_id_on_success(self, app):
- from application.api.user.agents.routes import validate_workflow_access
-
- mock_wf_col = Mock()
- mock_wf_col.find_one.return_value = {"_id": uuid.uuid4().hex, "user": "user1"}
- oid = str(uuid.uuid4().hex)
-
- with app.app_context():
- with patch(
- "application.api.user.agents.routes.workflows_collection", mock_wf_col
- ):
- wf_id, err = validate_workflow_access(oid, "user1")
- assert wf_id == oid
- assert err is None
-
-
-@pytest.mark.unit
-class TestBuildAgentDocument:
-
- def test_builds_classic_agent(self):
- from application.api.user.agents.routes import build_agent_document
-
- data = {
- "name": "Test Agent",
- "description": "desc",
- "status": "draft",
- "chunks": "5",
- "retriever": "classic",
- "prompt_id": "default",
- }
- doc = build_agent_document(
- data, "user1", "key123", "classic", image_url="img.png"
- )
- assert doc["user"] == "user1"
- assert doc["name"] == "Test Agent"
- assert doc["image"] == "img.png"
- assert doc["agent_type"] == "classic"
- assert "createdAt" in doc
- assert "updatedAt" in doc
-
- def test_builds_workflow_agent(self):
- from application.api.user.agents.routes import build_agent_document
-
- data = {
- "name": "WF Agent",
- "status": "published",
- "workflow": "wf123",
- "folder_id": "folder1",
- }
- doc = build_agent_document(data, "user1", "key123", "workflow")
- assert doc["workflow"] == "wf123"
- assert doc["folder_id"] == "folder1"
- # Workflow agents should not have classic-specific fields
- assert "image" not in doc
- assert "source" not in doc
-
- def test_defaults_to_classic_for_unknown_type(self):
- from application.api.user.agents.routes import build_agent_document
-
- data = {"name": "Agent", "status": "draft"}
- doc = build_agent_document(data, "user1", "k", "unknown_type")
- assert doc["agent_type"] == "classic"
-
- def test_defaults_to_classic_for_empty_type(self):
- from application.api.user.agents.routes import build_agent_document
-
- data = {"name": "Agent", "status": "draft"}
- doc = build_agent_document(data, "user1", "k", "")
- assert doc["agent_type"] == "classic"
-
- def test_limited_token_mode_string_true(self):
- from application.api.user.agents.routes import build_agent_document
-
- data = {"name": "A", "status": "draft", "limited_token_mode": "True"}
- doc = build_agent_document(data, "user1", "k", "classic")
- assert doc["limited_token_mode"] is True
-
- def test_limited_token_mode_string_false(self):
- from application.api.user.agents.routes import build_agent_document
-
- data = {"name": "A", "status": "draft", "limited_token_mode": "False"}
- doc = build_agent_document(data, "user1", "k", "classic")
- assert doc["limited_token_mode"] is False
-
- def test_limited_request_mode_bool(self):
- from application.api.user.agents.routes import build_agent_document
-
- data = {"name": "A", "status": "draft", "limited_request_mode": True}
- doc = build_agent_document(data, "user1", "k", "classic")
- assert doc["limited_request_mode"] is True
-
- def test_filters_to_allowed_fields(self):
- from application.api.user.agents.routes import build_agent_document
-
- data = {"name": "A", "status": "draft"}
- doc = build_agent_document(data, "user1", "k", "workflow")
- # Workflow doc should not have classic-only fields
- for classic_field in ["image", "source", "sources", "chunks", "retriever"]:
- assert classic_field not in doc
-
- def test_source_and_sources_passed_through(self):
- from application.api.user.agents.routes import build_agent_document
-
- source_ref = uuid.uuid4().hex # TODO: was DBRef("sources", uuid.uuid4().hex)
- doc = build_agent_document(
- {"name": "A", "status": "draft"},
- "user1",
- "k",
- "classic",
- source_field=source_ref,
- sources_list=[source_ref],
- )
- assert doc["source"] == source_ref
- assert doc["sources"] == [source_ref]
-
-
-# ---------------------------------------------------------------------------
-# Route classes
-# ---------------------------------------------------------------------------
-
-
-@pytest.mark.unit
-class TestGetAgent:
-
- def test_returns_401_unauthenticated(self, app):
- from application.api.user.agents.routes import GetAgent
-
- with app.test_request_context("/api/get_agent?id=abc"):
- from flask import request
-
- request.decoded_token = None
- result = GetAgent().get()
- # Returns tuple (dict, status_code)
- assert result[1] == 401
-
- def test_returns_400_missing_id(self, app):
- from application.api.user.agents.routes import GetAgent
-
- with app.test_request_context("/api/get_agent"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- result = GetAgent().get()
- assert result[1] == 400
-
- def test_returns_404_agent_not_found(self, app):
- from application.api.user.agents.routes import GetAgent
-
- agent_id = uuid.uuid4().hex
- mock_col = Mock()
- mock_col.find_one.return_value = None
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context(f"/api/get_agent?id={agent_id}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- result = GetAgent().get()
- assert result[1] == 404
-
- def test_returns_agent_data_on_success(self, app):
- from application.api.user.agents.routes import GetAgent
-
- agent_id = uuid.uuid4().hex
- key = str(uuid.uuid4())
- mock_col = Mock()
- mock_col.find_one.return_value = {
- "_id": agent_id,
- "user": "user1",
- "name": "Test Agent",
- "description": "desc",
- "chunks": "5",
- "retriever": "classic",
- "prompt_id": "default",
- "tools": [],
- "agent_type": "classic",
- "status": "published",
- "key": key,
- }
-
- mock_resolve = Mock(return_value=[])
- mock_db = Mock()
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.resolve_tool_details", mock_resolve
- ), patch(
- "application.api.user.agents.routes.db", mock_db
- ):
- with app.test_request_context(f"/api/get_agent?id={agent_id}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = GetAgent().get()
- assert response.status_code == 200
- data = response.json
- assert data["id"] == str(agent_id)
- assert data["name"] == "Test Agent"
- assert data["key"].startswith(key[:4])
-
- def test_returns_400_on_exception(self, app):
- from application.api.user.agents.routes import GetAgent
-
- mock_col = Mock()
- mock_col.find_one.side_effect = Exception("DB error")
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context(f"/api/get_agent?id={uuid.uuid4().hex}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- result = GetAgent().get()
- assert result[1] == 400
-
-
-@pytest.mark.unit
-class TestGetAgents:
-
- def test_returns_401_unauthenticated(self, app):
- from application.api.user.agents.routes import GetAgents
-
- with app.test_request_context("/api/get_agents"):
- from flask import request
-
- request.decoded_token = None
- result = GetAgents().get()
- assert result[1] == 401
-
- def test_returns_agents_list(self, app):
- from application.api.user.agents.routes import GetAgents
-
- agent_id = uuid.uuid4().hex
- key = str(uuid.uuid4())
- mock_agents_col = Mock()
- mock_agents_col.find.return_value = [
- {
- "_id": agent_id,
- "user": "user1",
- "name": "Agent1",
- "source": "default",
- "retriever": "classic",
- "key": key,
- }
- ]
-
- mock_ensure = Mock(
- return_value={
- "user_id": "user1",
- "agent_preferences": {"pinned": [str(agent_id)]},
- }
- )
- mock_resolve = Mock(return_value=[])
- mock_db = Mock()
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure
- ), patch(
- "application.api.user.agents.routes.resolve_tool_details", mock_resolve
- ), patch(
- "application.api.user.agents.routes.db", mock_db
- ):
- with app.test_request_context("/api/get_agents"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = GetAgents().get()
- assert response.status_code == 200
- data = response.json
- assert len(data) == 1
- assert data[0]["name"] == "Agent1"
- assert data[0]["pinned"] is True
-
- def test_filters_agents_without_source_or_retriever(self, app):
- from application.api.user.agents.routes import GetAgents
-
- mock_agents_col = Mock()
- # Agent without source/retriever and not workflow type -> filtered out
- mock_agents_col.find.return_value = [
- {
- "_id": uuid.uuid4().hex,
- "user": "user1",
- "name": "BadAgent",
- "agent_type": "classic",
- }
- ]
-
- mock_ensure = Mock(
- return_value={
- "user_id": "user1",
- "agent_preferences": {"pinned": []},
- }
- )
- mock_resolve = Mock(return_value=[])
- mock_db = Mock()
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure
- ), patch(
- "application.api.user.agents.routes.resolve_tool_details", mock_resolve
- ), patch(
- "application.api.user.agents.routes.db", mock_db
- ):
- with app.test_request_context("/api/get_agents"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = GetAgents().get()
- assert response.status_code == 200
- assert len(response.json) == 0
-
- def test_includes_workflow_agent_without_source(self, app):
- from application.api.user.agents.routes import GetAgents
-
- agent_id = uuid.uuid4().hex
- mock_agents_col = Mock()
- mock_agents_col.find.return_value = [
- {
- "_id": agent_id,
- "user": "user1",
- "name": "WFAgent",
- "agent_type": "workflow",
- "key": "abcd1234efgh",
- }
- ]
-
- mock_ensure = Mock(
- return_value={
- "user_id": "user1",
- "agent_preferences": {"pinned": []},
- }
- )
- mock_resolve = Mock(return_value=[])
- mock_db = Mock()
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure
- ), patch(
- "application.api.user.agents.routes.resolve_tool_details", mock_resolve
- ), patch(
- "application.api.user.agents.routes.db", mock_db
- ):
- with app.test_request_context("/api/get_agents"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = GetAgents().get()
- assert response.status_code == 200
- assert len(response.json) == 1
- assert response.json[0]["name"] == "WFAgent"
-
- def test_returns_400_on_exception(self, app):
- from application.api.user.agents.routes import GetAgents
-
- mock_ensure = Mock(side_effect=Exception("DB error"))
-
- with patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure
- ):
- with app.test_request_context("/api/get_agents"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = GetAgents().get()
- assert response.status_code == 400
-
-
-@pytest.mark.unit
-class TestCreateAgent:
-
- def test_returns_401_unauthenticated(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={"name": "A"},
- ):
- from flask import request
-
- request.decoded_token = None
- result = CreateAgent().post()
- assert result[1] == 401
-
- def test_returns_400_invalid_status(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={"name": "A", "status": "invalid"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 400
-
- def test_creates_draft_agent_success(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- inserted_id = uuid.uuid4().hex
- mock_agents_col = Mock()
- mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id)
-
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "Draft Agent",
- "status": "draft",
- "agent_type": "classic",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 201
- data = response.json
- assert data["id"] == str(inserted_id)
- # Draft agents get empty key
- assert data["key"] == ""
-
- def test_creates_published_classic_agent(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- inserted_id = uuid.uuid4().hex
- mock_agents_col = Mock()
- mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id)
- mock_handle_img = Mock(return_value=("img.png", None))
- source_id = str(uuid.uuid4().hex)
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "Published Agent",
- "description": "A test agent",
- "status": "published",
- "agent_type": "classic",
- "source": source_id,
- "chunks": "5",
- "retriever": "classic",
- "prompt_id": "default",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 201
- data = response.json
- assert data["id"] == str(inserted_id)
- # Published agents get a uuid key
- assert data["key"] != ""
-
- def test_published_classic_requires_source(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "No Source",
- "description": "desc",
- "status": "published",
- "agent_type": "classic",
- "chunks": "5",
- "retriever": "classic",
- "prompt_id": "default",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 400
-
- def test_creates_workflow_agent(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- inserted_id = uuid.uuid4().hex
- wf_id = str(uuid.uuid4().hex)
- mock_agents_col = Mock()
- mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id)
- mock_handle_img = Mock(return_value=("", None))
- mock_wf_col = Mock()
- mock_wf_col.find_one.return_value = {"_id": wf_id, "user": "user1"}
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ), patch(
- "application.api.user.agents.routes.workflows_collection", mock_wf_col
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "WF Agent",
- "status": "published",
- "agent_type": "workflow",
- "workflow": wf_id,
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 201
-
- def test_image_upload_failure(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- mock_handle_img = Mock(return_value=(None, Mock()))
-
- with patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "Agent",
- "status": "draft",
- "agent_type": "classic",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 400
-
- def test_invalid_json_schema(self, app):
- from application.api.user.agents.routes import CreateAgent
- from application.core.json_schema_utils import JsonSchemaValidationError
-
- def raise_exc(val):
- raise JsonSchemaValidationError("is invalid")
-
- with patch(
- "application.api.user.agents.routes.normalize_json_schema_payload",
- side_effect=raise_exc,
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "Agent",
- "status": "draft",
- "json_schema": {"bad": True},
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 400
-
- def test_folder_id_not_found(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- mock_handle_img = Mock(return_value=("", None))
- mock_folders = Mock()
- mock_folders.find_one.return_value = None
- folder_id = str(uuid.uuid4().hex)
-
- with patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ), patch(
- "application.api.user.agents.routes.agent_folders_collection", mock_folders
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "Agent",
- "status": "draft",
- "agent_type": "classic",
- "folder_id": folder_id,
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 404
-
- def test_invalid_folder_id_format(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "Agent",
- "status": "draft",
- "agent_type": "classic",
- "folder_id": "not-valid",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 400
-
- def test_form_data_with_json_fields(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- inserted_id = uuid.uuid4().hex
- mock_agents_col = Mock()
- mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- content_type="multipart/form-data",
- data={
- "name": "FormAgent",
- "status": "draft",
- "agent_type": "classic",
- "tools": '["tool1", "tool2"]',
- "sources": '["src1"]',
- "models": '["model1"]',
- "json_schema": "null",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 201
-
- def test_form_data_invalid_json_tools(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- inserted_id = uuid.uuid4().hex
- mock_agents_col = Mock()
- mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- content_type="multipart/form-data",
- data={
- "name": "FormAgent",
- "status": "draft",
- "agent_type": "classic",
- "tools": "not-valid-json",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- # invalid json for tools falls back to []
- assert response.status_code == 201
-
- def test_create_with_sources_list(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- inserted_id = uuid.uuid4().hex
- src_id = str(uuid.uuid4().hex)
- mock_agents_col = Mock()
- mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "MultiSource",
- "description": "desc",
- "status": "published",
- "agent_type": "classic",
- "sources": [src_id, "default"],
- "chunks": "5",
- "retriever": "classic",
- "prompt_id": "default",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 201
-
- def test_db_insert_failure(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- mock_agents_col = Mock()
- mock_agents_col.insert_one.side_effect = Exception("insert error")
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "Agent",
- "status": "draft",
- "agent_type": "classic",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 400
-
-
-@pytest.mark.unit
-class TestUpdateAgent:
-
- def _make_existing_agent(self, agent_id=None):
- return {
- "_id": agent_id or uuid.uuid4().hex,
- "user": "user1",
- "name": "Existing Agent",
- "description": "existing desc",
- "source": "default",
- "chunks": "5",
- "retriever": "classic",
- "prompt_id": "default",
- "status": "published",
- "agent_type": "classic",
- "key": str(uuid.uuid4()),
- }
-
- def test_returns_401_unauthenticated(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- with app.test_request_context(
- "/api/update_agent/abc",
- method="PUT",
- json={"name": "A"},
- ):
- from flask import request
-
- request.decoded_token = None
- response = UpdateAgent().put("abc")
- assert response.status_code == 401
-
- def test_returns_400_invalid_agent_id(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- with app.test_request_context(
- "/api/update_agent/not-valid",
- method="PUT",
- json={"name": "A"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put("not-valid")
- assert response.status_code == 400
-
- def test_returns_404_agent_not_found(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = str(uuid.uuid4().hex)
- mock_col = Mock()
- mock_col.find_one.return_value = None
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"name": "Updated"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(agent_id)
- assert response.status_code == 404
-
- def test_updates_agent_name_success(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_repo = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- def _run_dual_write(_repo_cls, fn):
- fn(mock_repo)
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ), patch(
- "application.api.user.agents.routes.dual_write",
- side_effect=_run_dual_write,
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"name": "Updated Name"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
- assert response.json["success"] is True
- mock_repo.update_by_legacy_id.assert_called_once_with(
- str(agent_id),
- "user1",
- {"name": "Updated Name"},
- )
-
- def test_returns_400_invalid_status(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"status": "invalid_status"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_returns_400_negative_chunks(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"chunks": -1},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_returns_400_tools_not_list(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"tools": "not-a-list"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_returns_400_no_update_data(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"unknown_field": "value"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_limited_token_mode_without_limit(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"limited_token_mode": True},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_limited_request_mode_without_limit(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"limited_request_mode": True},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_token_limit_without_mode(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"token_limit": 1000},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_request_limit_without_mode(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"request_limit": 100},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_source_with_invalid_oid(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"source": "not-valid-oid"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_sources_list_with_invalid_oid(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"sources": ["bad-id"]},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_update_source_to_default(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"source": "default"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_update_empty_source_on_draft(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- existing["status"] = "draft"
- existing["key"] = ""
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"source": ""},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_update_empty_source_on_published_fails(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- # Published agent with only "default" source
- existing["source"] = "default"
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"source": ""},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_update_chunks_empty_defaults_to_2(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"chunks": ""},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
- call_args = mock_col.update_one.call_args[0][1]["$set"]
- assert call_args["chunks"] == "2"
-
- def test_update_invalid_chunks_value(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"chunks": "abc"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_update_matched_but_not_modified(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=0)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"name": "Same Name"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
- assert "No changes detected" in response.json["message"]
-
- def test_update_matched_zero_returns_404(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=0, modified_count=0)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"name": "New Name"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 404
-
- def test_publish_draft_generates_key(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- existing["status"] = "draft"
- existing["key"] = ""
- existing["source"] = "default"
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"status": "published"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
- assert "key" in response.json
-
- def test_publish_missing_required_fields(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = {
- "_id": agent_id,
- "user": "user1",
- "name": "",
- "description": "",
- "source": "",
- "chunks": "",
- "retriever": "",
- "prompt_id": "",
- "status": "draft",
- "agent_type": "classic",
- "key": "",
- }
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"status": "published"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_db_update_exception(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.side_effect = Exception("DB error")
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"name": "Updated"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 500
-
- def test_update_json_schema_valid(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- schema_val = {"type": "object", "properties": {"x": {"type": "string"}}}
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ), patch(
- "application.api.user.agents.routes.normalize_json_schema_payload",
- return_value=schema_val,
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"json_schema": schema_val},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_update_json_schema_invalid(self, app):
- from application.api.user.agents.routes import UpdateAgent
- from application.core.json_schema_utils import JsonSchemaValidationError
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- def raise_exc(val):
- raise JsonSchemaValidationError("is invalid")
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ), patch(
- "application.api.user.agents.routes.normalize_json_schema_payload",
- side_effect=raise_exc,
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"json_schema": {"bad": True}},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_update_json_schema_none(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"json_schema": None},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_update_folder_id_valid(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- folder_id = str(uuid.uuid4().hex)
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
- mock_folders = Mock()
- mock_folders.find_one.return_value = {"_id": folder_id, "user": "user1"}
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ), patch(
- "application.api.user.agents.routes.agent_folders_collection", mock_folders
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"folder_id": folder_id},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_update_folder_id_invalid_format(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"folder_id": "invalid"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_update_folder_not_found(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- folder_id = str(uuid.uuid4().hex)
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
- mock_folders = Mock()
- mock_folders.find_one.return_value = None
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ), patch(
- "application.api.user.agents.routes.agent_folders_collection", mock_folders
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"folder_id": folder_id},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 404
-
- def test_update_folder_id_empty_sets_none(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"folder_id": ""},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
- call_args = mock_col.update_one.call_args[0][1]["$set"]
- assert call_args["folder_id"] is None
-
- def test_empty_name_field_rejected(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"name": ""},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_update_workflow_field(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- wf_id = str(uuid.uuid4().hex)
- existing = self._make_existing_agent(agent_id)
- existing["agent_type"] = "workflow"
- existing["status"] = "draft"
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
- mock_wf_col = Mock()
- mock_wf_col.find_one.return_value = {"_id": wf_id, "user": "user1"}
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ), patch(
- "application.api.user.agents.routes.workflows_collection", mock_wf_col
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"workflow": wf_id},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_publish_workflow_without_workflow_field(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = {
- "_id": agent_id,
- "user": "user1",
- "name": "WF Agent",
- "status": "draft",
- "agent_type": "workflow",
- "key": "",
- "workflow": None,
- }
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"status": "published"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_form_data_json_parse_error(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = str(uuid.uuid4().hex)
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- content_type="multipart/form-data",
- data={"tools": "not-valid-json"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(agent_id)
- assert response.status_code == 400
-
- def test_limited_token_mode_string_true(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={
- "limited_token_mode": "True",
- "token_limit": 5000,
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_update_sources_with_default(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- src_id = str(uuid.uuid4().hex)
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"sources": ["default", src_id]},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
-
-@pytest.mark.unit
-class TestDeleteAgent:
-
- def test_returns_401_unauthenticated(self, app):
- from application.api.user.agents.routes import DeleteAgent
-
- with app.test_request_context("/api/delete_agent"):
- from flask import request
-
- request.decoded_token = None
- response = DeleteAgent().delete()
- assert response.status_code == 401
-
- def test_returns_400_missing_id(self, app):
- from application.api.user.agents.routes import DeleteAgent
-
- with app.test_request_context("/api/delete_agent"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = DeleteAgent().delete()
- assert response.status_code == 400
-
- def test_returns_404_agent_not_found(self, app):
- from application.api.user.agents.routes import DeleteAgent
-
- mock_col = Mock()
- mock_col.find_one_and_delete.return_value = None
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context(f"/api/delete_agent?id={uuid.uuid4().hex}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = DeleteAgent().delete()
- assert response.status_code == 404
-
- def test_deletes_classic_agent_success(self, app):
- from application.api.user.agents.routes import DeleteAgent
-
- agent_id = uuid.uuid4().hex
- mock_col = Mock()
- mock_repo = Mock()
- mock_col.find_one_and_delete.return_value = {
- "_id": agent_id,
- "user": "user1",
- "agent_type": "classic",
- }
-
- def _run_dual_write(_repo_cls, fn):
- fn(mock_repo)
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.dual_write",
- side_effect=_run_dual_write,
- ):
- with app.test_request_context(f"/api/delete_agent?id={agent_id}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = DeleteAgent().delete()
- assert response.status_code == 200
- assert response.json["id"] == str(agent_id)
- mock_repo.delete_by_legacy_id.assert_called_once_with(str(agent_id), "user1")
-
- def test_deletes_workflow_agent_cleans_up(self, app):
- from application.api.user.agents.routes import DeleteAgent
-
- agent_id = uuid.uuid4().hex
- wf_id = str(uuid.uuid4().hex)
- mock_agents_col = Mock()
- mock_agents_col.find_one_and_delete.return_value = {
- "_id": agent_id,
- "user": "user1",
- "agent_type": "workflow",
- "workflow": wf_id,
- }
- mock_wf_col = Mock()
- mock_wf_col.find_one.return_value = {"_id": wf_id, "user": "user1"}
- mock_nodes_col = Mock()
- mock_edges_col = Mock()
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.workflows_collection", mock_wf_col
- ), patch(
- "application.api.user.agents.routes.workflow_nodes_collection", mock_nodes_col
- ), patch(
- "application.api.user.agents.routes.workflow_edges_collection", mock_edges_col
- ):
- with app.test_request_context(f"/api/delete_agent?id={agent_id}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = DeleteAgent().delete()
- assert response.status_code == 200
- mock_nodes_col.delete_many.assert_called_once()
- mock_edges_col.delete_many.assert_called_once()
- mock_wf_col.delete_one.assert_called_once()
-
- def test_deletes_workflow_agent_non_owned_skips_cleanup(self, app):
- from application.api.user.agents.routes import DeleteAgent
-
- agent_id = uuid.uuid4().hex
- wf_id = str(uuid.uuid4().hex)
- mock_agents_col = Mock()
- mock_agents_col.find_one_and_delete.return_value = {
- "_id": agent_id,
- "user": "user1",
- "agent_type": "workflow",
- "workflow": wf_id,
- }
- mock_wf_col = Mock()
- mock_wf_col.find_one.return_value = None # Not owned
- mock_nodes_col = Mock()
- mock_edges_col = Mock()
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.workflows_collection", mock_wf_col
- ), patch(
- "application.api.user.agents.routes.workflow_nodes_collection", mock_nodes_col
- ), patch(
- "application.api.user.agents.routes.workflow_edges_collection", mock_edges_col
- ):
- with app.test_request_context(f"/api/delete_agent?id={agent_id}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = DeleteAgent().delete()
- assert response.status_code == 200
- mock_nodes_col.delete_many.assert_not_called()
-
- def test_returns_400_on_exception(self, app):
- from application.api.user.agents.routes import DeleteAgent
-
- mock_col = Mock()
- mock_col.find_one_and_delete.side_effect = Exception("DB error")
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context(f"/api/delete_agent?id={uuid.uuid4().hex}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = DeleteAgent().delete()
- assert response.status_code == 400
-
-
-@pytest.mark.unit
-class TestPinnedAgents:
-
- def test_returns_401_unauthenticated(self, app):
- from application.api.user.agents.routes import PinnedAgents
-
- with app.test_request_context("/api/pinned_agents"):
- from flask import request
-
- request.decoded_token = None
- response = PinnedAgents().get()
- assert response.status_code == 401
-
- def test_returns_empty_when_no_pinned(self, app):
- from application.api.user.agents.routes import PinnedAgents
-
- mock_ensure = Mock(
- return_value={
- "user_id": "user1",
- "agent_preferences": {"pinned": []},
- }
- )
-
- with patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure
- ):
- with app.test_request_context("/api/pinned_agents"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = PinnedAgents().get()
- assert response.status_code == 200
- assert response.json == []
-
- def test_returns_pinned_agents(self, app):
- from application.api.user.agents.routes import PinnedAgents
-
- agent_id = uuid.uuid4().hex
- key = str(uuid.uuid4())
- mock_ensure = Mock(
- return_value={
- "user_id": "user1",
- "agent_preferences": {"pinned": [str(agent_id)]},
- }
- )
- mock_agents_col = Mock()
- mock_agents_col.find.return_value = [
- {
- "_id": agent_id,
- "name": "Pinned Agent",
- "source": "default",
- "retriever": "classic",
- "key": key,
- }
- ]
- mock_resolve = Mock(return_value=[])
- mock_users_col = Mock()
- mock_db = Mock()
-
- with patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure
- ), patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.resolve_tool_details", mock_resolve
- ), patch(
- "application.api.user.agents.routes.users_collection", mock_users_col
- ), patch(
- "application.api.user.agents.routes.db", mock_db
- ):
- with app.test_request_context("/api/pinned_agents"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = PinnedAgents().get()
- assert response.status_code == 200
- data = response.json
- assert len(data) == 1
- assert data[0]["pinned"] is True
-
- def test_cleans_up_stale_pinned_ids(self, app):
- from application.api.user.agents.routes import PinnedAgents
-
- stale_id = str(uuid.uuid4().hex)
- mock_ensure = Mock(
- return_value={
- "user_id": "user1",
- "agent_preferences": {"pinned": [stale_id]},
- }
- )
- mock_agents_col = Mock()
- mock_agents_col.find.return_value = []
- mock_users_col = Mock()
-
- with patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure
- ), patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.users_collection", mock_users_col
- ):
- with app.test_request_context("/api/pinned_agents"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = PinnedAgents().get()
- assert response.status_code == 200
- mock_users_col.update_one.assert_called_once()
-
- def test_returns_400_on_exception(self, app):
- from application.api.user.agents.routes import PinnedAgents
-
- mock_ensure = Mock(side_effect=Exception("DB error"))
-
- with patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure
- ):
- with app.test_request_context("/api/pinned_agents"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = PinnedAgents().get()
- assert response.status_code == 400
-
-
-@pytest.mark.unit
-class TestGetTemplateAgents:
-
- def test_returns_template_agents(self, app):
- from application.api.user.agents.routes import GetTemplateAgents
-
- agent_id = uuid.uuid4().hex
- mock_col = Mock()
- mock_col.find.return_value = [
- {
- "_id": agent_id,
- "name": "Template1",
- "description": "A template",
- "image": "img.png",
- }
- ]
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context("/api/template_agents"):
- response = GetTemplateAgents().get()
- assert response.status_code == 200
- data = response.json
- assert len(data) == 1
- assert data[0]["name"] == "Template1"
-
- def test_returns_400_on_exception(self, app):
- from application.api.user.agents.routes import GetTemplateAgents
-
- mock_col = Mock()
- mock_col.find.side_effect = Exception("DB error")
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context("/api/template_agents"):
- response = GetTemplateAgents().get()
- assert response.status_code == 400
-
-
-@pytest.mark.unit
-class TestAdoptAgent:
-
- def test_returns_401_unauthenticated(self, app):
- from application.api.user.agents.routes import AdoptAgent
-
- with app.test_request_context("/api/adopt_agent?id=abc"):
- from flask import request
-
- request.decoded_token = None
- response = AdoptAgent().post()
- assert response.status_code == 401
-
- def test_returns_400_missing_id(self, app):
- from application.api.user.agents.routes import AdoptAgent
-
- with app.test_request_context("/api/adopt_agent"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = AdoptAgent().post()
- assert response.status_code == 400
-
- def test_returns_404_template_not_found(self, app):
- from application.api.user.agents.routes import AdoptAgent
-
- mock_col = Mock()
- mock_col.find_one.return_value = None
- agent_id = str(uuid.uuid4().hex)
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context(f"/api/adopt_agent?id={agent_id}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = AdoptAgent().post()
- assert response.status_code == 404
-
- def test_adopts_agent_success(self, app):
- from application.api.user.agents.routes import AdoptAgent
-
- agent_id = uuid.uuid4().hex
- new_id = uuid.uuid4().hex
- mock_col = Mock()
- mock_col.find_one.return_value = {
- "_id": agent_id,
- "user": "system",
- "name": "Template Agent",
- "description": "A template",
- "source": "default",
- "tools": [],
- }
- mock_col.insert_one.return_value = Mock(inserted_id=new_id)
- mock_resolve = Mock(return_value=[])
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.resolve_tool_details", mock_resolve
- ):
- with app.test_request_context(f"/api/adopt_agent?id={agent_id}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = AdoptAgent().post()
- assert response.status_code == 200
- data = response.json
- assert data["success"] is True
- assert data["agent"]["id"] == str(new_id)
-
- def test_returns_400_on_exception(self, app):
- from application.api.user.agents.routes import AdoptAgent
-
- mock_col = Mock()
- mock_col.find_one.side_effect = Exception("DB error")
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context(f"/api/adopt_agent?id={uuid.uuid4().hex}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = AdoptAgent().post()
- assert response.status_code == 400
-
-
-@pytest.mark.unit
-class TestPinAgent:
-
- def test_returns_401_unauthenticated(self, app):
- from application.api.user.agents.routes import PinAgent
-
- with app.test_request_context("/api/pin_agent"):
- from flask import request
-
- request.decoded_token = None
- response = PinAgent().post()
- assert response.status_code == 401
-
- def test_returns_400_missing_id(self, app):
- from application.api.user.agents.routes import PinAgent
-
- with app.test_request_context("/api/pin_agent"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = PinAgent().post()
- assert response.status_code == 400
-
- def test_returns_404_agent_not_found(self, app):
- from application.api.user.agents.routes import PinAgent
-
- mock_col = Mock()
- mock_col.find_one.return_value = None
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context(f"/api/pin_agent?id={uuid.uuid4().hex}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = PinAgent().post()
- assert response.status_code == 404
-
- def test_pins_agent(self, app):
- from application.api.user.agents.routes import PinAgent
-
- agent_id = str(uuid.uuid4().hex)
- mock_col = Mock()
- mock_col.find_one.return_value = {"_id": agent_id}
-
- mock_ensure = Mock(
- return_value={
- "user_id": "user1",
- "agent_preferences": {"pinned": []},
- }
- )
- mock_users_col = Mock()
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure
- ), patch(
- "application.api.user.agents.routes.users_collection", mock_users_col
- ):
- with app.test_request_context(f"/api/pin_agent?id={agent_id}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = PinAgent().post()
- assert response.status_code == 200
- assert response.json["action"] == "pinned"
-
- def test_unpins_agent(self, app):
- from application.api.user.agents.routes import PinAgent
-
- agent_id = str(uuid.uuid4().hex)
- mock_col = Mock()
- mock_col.find_one.return_value = {"_id": agent_id}
-
- mock_ensure = Mock(
- return_value={
- "user_id": "user1",
- "agent_preferences": {"pinned": [agent_id]},
- }
- )
- mock_users_col = Mock()
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure
- ), patch(
- "application.api.user.agents.routes.users_collection", mock_users_col
- ):
- with app.test_request_context(f"/api/pin_agent?id={agent_id}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = PinAgent().post()
- assert response.status_code == 200
- assert response.json["action"] == "unpinned"
-
- def test_returns_500_on_exception(self, app):
- from application.api.user.agents.routes import PinAgent
-
- mock_col = Mock()
- mock_col.find_one.side_effect = Exception("DB error")
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context(f"/api/pin_agent?id={uuid.uuid4().hex}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = PinAgent().post()
- assert response.status_code == 500
-
-
-@pytest.mark.unit
-class TestRemoveSharedAgent:
-
- def test_returns_401_unauthenticated(self, app):
- from application.api.user.agents.routes import RemoveSharedAgent
-
- with app.test_request_context("/api/remove_shared_agent"):
- from flask import request
-
- request.decoded_token = None
- response = RemoveSharedAgent().delete()
- assert response.status_code == 401
-
- def test_returns_400_missing_id(self, app):
- from application.api.user.agents.routes import RemoveSharedAgent
-
- with app.test_request_context("/api/remove_shared_agent"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = RemoveSharedAgent().delete()
- assert response.status_code == 400
-
- def test_returns_404_shared_agent_not_found(self, app):
- from application.api.user.agents.routes import RemoveSharedAgent
-
- mock_col = Mock()
- mock_col.find_one.return_value = None
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context(
- f"/api/remove_shared_agent?id={uuid.uuid4().hex}"
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = RemoveSharedAgent().delete()
- assert response.status_code == 404
-
- def test_removes_shared_agent_success(self, app):
- from application.api.user.agents.routes import RemoveSharedAgent
-
- agent_id = str(uuid.uuid4().hex)
- mock_col = Mock()
- mock_col.find_one.return_value = {
- "_id": agent_id,
- "shared_publicly": True,
- }
-
- mock_ensure = Mock(
- return_value={
- "user_id": "user1",
- "agent_preferences": {"pinned": [], "shared_with_me": [agent_id]},
- }
- )
- mock_users_col = Mock()
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure
- ), patch(
- "application.api.user.agents.routes.users_collection", mock_users_col
- ):
- with app.test_request_context(
- f"/api/remove_shared_agent?id={agent_id}"
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = RemoveSharedAgent().delete()
- assert response.status_code == 200
- assert response.json["action"] == "removed"
- mock_users_col.update_one.assert_called_once()
-
- def test_returns_500_on_exception(self, app):
- from application.api.user.agents.routes import RemoveSharedAgent
-
- mock_col = Mock()
- mock_col.find_one.side_effect = Exception("DB error")
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context(
- f"/api/remove_shared_agent?id={uuid.uuid4().hex}"
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = RemoveSharedAgent().delete()
- assert response.status_code == 500
-
-
-# ---------------------------------------------------------------------------
-# Additional coverage tests
-# ---------------------------------------------------------------------------
-
-
-@pytest.mark.unit
-class TestCreateAgentFormDataEdgeCases:
- """Cover form-data JSON-parse fallback branches in CreateAgent."""
-
- def test_form_data_invalid_sources_json_falls_back(self, app):
- """Lines 474-475: invalid JSON for 'sources' falls back to []."""
- from application.api.user.agents.routes import CreateAgent
-
- inserted_id = uuid.uuid4().hex
- mock_agents_col = Mock()
- mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- content_type="multipart/form-data",
- data={
- "name": "Agent",
- "status": "draft",
- "agent_type": "classic",
- "sources": "not-valid-json",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 201
-
- def test_form_data_invalid_json_schema_falls_back(self, app):
- """Lines 479-480: invalid JSON for 'json_schema' falls back to None."""
- from application.api.user.agents.routes import CreateAgent
-
- inserted_id = uuid.uuid4().hex
- mock_agents_col = Mock()
- mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- content_type="multipart/form-data",
- data={
- "name": "Agent",
- "status": "draft",
- "agent_type": "classic",
- "json_schema": "{bad json",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 201
-
- def test_form_data_invalid_models_json_falls_back(self, app):
- """Lines 484-485: invalid JSON for 'models' falls back to []."""
- from application.api.user.agents.routes import CreateAgent
-
- inserted_id = uuid.uuid4().hex
- mock_agents_col = Mock()
- mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- content_type="multipart/form-data",
- data={
- "name": "Agent",
- "status": "draft",
- "agent_type": "classic",
- "models": "not-json",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 201
-
- def test_create_with_unknown_agent_type_defaults_classic(self, app):
- """Line 511-517: unknown agent_type not in AGENT_TYPE_SCHEMAS."""
- from application.api.user.agents.routes import CreateAgent
-
- inserted_id = uuid.uuid4().hex
- mock_agents_col = Mock()
- mock_agents_col.insert_one.return_value = Mock(inserted_id=inserted_id)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_agents_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "Agent",
- "status": "draft",
- "agent_type": "totally_unknown",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 201
-
-
-@pytest.mark.unit
-class TestUpdateAgentFormDataEdgeCases:
- """Cover form-data parsing and edge cases in UpdateAgent."""
-
- def _make_existing_agent(self, agent_id=None):
- return {
- "_id": agent_id or uuid.uuid4().hex,
- "user": "user1",
- "name": "Existing Agent",
- "description": "existing desc",
- "source": "default",
- "chunks": "5",
- "retriever": "classic",
- "prompt_id": "default",
- "status": "published",
- "agent_type": "classic",
- "key": str(uuid.uuid4()),
- }
-
- def test_form_data_parses_json_fields(self, app):
- """Line 699: form.to_dict() branch in UpdateAgent."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- content_type="multipart/form-data",
- data={
- "name": "Updated",
- "tools": '["tool1"]',
- "sources": '["default"]',
- "models": '["m1"]',
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_form_data_invalid_json_sources_returns_400(self, app):
- """Lines 712-713: invalid JSON for sources in form data."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- content_type="multipart/form-data",
- data={"sources": "{bad json"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
- assert "Invalid JSON format" in response.json["message"]
-
- def test_form_data_invalid_json_models_returns_400(self, app):
- """Lines 712-713: invalid JSON for models in form data."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- content_type="multipart/form-data",
- data={"models": "not-json{"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
- assert "Invalid JSON format" in response.json["message"]
-
- def test_form_data_empty_json_schema_sets_none(self, app):
- """Line 715-716: empty json_schema string sets None."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- content_type="multipart/form-data",
- data={"json_schema": ""},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_find_agent_db_error(self, app):
- """Line 730: exception when finding agent in DB."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = str(uuid.uuid4().hex)
- mock_col = Mock()
- mock_col.find_one.side_effect = Exception("DB connection lost")
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"name": "Updated"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(agent_id)
- assert response.status_code == 500
- assert "Database error" in response.json["message"]
-
- def test_handle_image_upload_error_forwarded(self, app):
- """Line 746: image upload error is returned directly."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- error_response = Mock(status_code=400)
- mock_handle_img = Mock(return_value=(None, error_response))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"name": "Updated"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- result = UpdateAgent().put(str(agent_id))
- assert result == error_response
-
- def test_limited_request_mode_string_true(self, app):
- """Lines 904-907: limited_request_mode as string 'True'."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={
- "limited_request_mode": "True",
- "request_limit": 100,
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_publish_workflow_missing_workflow_id(self, app):
- """Line 1031: workflow_id is missing when publishing workflow."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = {
- "_id": agent_id,
- "user": "user1",
- "name": "WF Agent",
- "status": "draft",
- "agent_type": "workflow",
- "key": "",
- "workflow": "",
- }
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"status": "published"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
- assert "Workflow" in response.json["message"]
-
- def test_publish_workflow_invalid_workflow_id_format(self, app):
- """Line 1032-1033: workflow_id is present but not valid ObjectId."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = {
- "_id": agent_id,
- "user": "user1",
- "name": "WF Agent",
- "status": "draft",
- "agent_type": "workflow",
- "key": "",
- "workflow": "not-an-objectid",
- }
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"status": "published"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_publish_workflow_workflow_not_found(self, app):
- """Lines 1035-1039: workflow exists but not found in DB."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- wf_id = str(uuid.uuid4().hex)
- existing = {
- "_id": agent_id,
- "user": "user1",
- "name": "WF Agent",
- "status": "draft",
- "agent_type": "workflow",
- "key": "",
- "workflow": wf_id,
- }
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
- mock_wf_col = Mock()
- mock_wf_col.find_one.return_value = None
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ), patch(
- "application.api.user.agents.routes.workflows_collection", mock_wf_col
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"status": "published"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
- assert "Workflow access" in response.json["message"]
-
- def test_update_with_image_url_set(self, app):
- """Line 1000-1001: image_url is truthy, added to update_fields."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("new_img.png", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- content_type="multipart/form-data",
- data={"name": "Updated"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_publish_generates_key_in_response(self, app):
- """Line 1134-1136: newly_generated_key is included in response."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- existing["status"] = "draft"
- existing["key"] = ""
- existing["source"] = "default"
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"status": "published"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
- assert "key" in response.json
- assert len(response.json["key"]) > 0
-
-
-@pytest.mark.unit
-class TestDeleteAgentWorkflowEdgeCases:
- """Cover workflow cleanup edge cases in DeleteAgent."""
-
- def test_delete_workflow_invalid_workflow_id(self, app):
- """Lines 1179-1181: invalid workflow id skips cleanup."""
- from application.api.user.agents.routes import DeleteAgent
-
- agent_id = uuid.uuid4().hex
- mock_col = Mock()
- mock_col.find_one_and_delete.return_value = {
- "_id": agent_id,
- "user": "user1",
- "agent_type": "workflow",
- "workflow": "not-a-valid-oid",
- }
- mock_wf_col = Mock()
- mock_nodes_col = Mock()
- mock_edges_col = Mock()
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.workflows_collection", mock_wf_col
- ), patch(
- "application.api.user.agents.routes.workflow_nodes_collection",
- mock_nodes_col,
- ), patch(
- "application.api.user.agents.routes.workflow_edges_collection",
- mock_edges_col,
- ):
- with app.test_request_context(f"/api/delete_agent?id={agent_id}"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = DeleteAgent().delete()
- assert response.status_code == 200
- mock_nodes_col.delete_many.assert_not_called()
- mock_wf_col.delete_one.assert_not_called()
-
-
-# ---------------------------------------------------------------------------
-# Coverage — additional uncovered lines
-# ---------------------------------------------------------------------------
-
-
-@pytest.mark.unit
-class TestCreateAgentEmptyAgentType:
- """Cover line 517: agent_type defaults to 'classic' when empty."""
-
- def test_empty_agent_type_defaults_to_classic(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- mock_col = Mock()
- mock_col.insert_one.return_value = Mock(inserted_id=uuid.uuid4().hex)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "Test",
- "status": "draft",
- "agent_type": "",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 201
-
-
-@pytest.mark.unit
-class TestUpdateAgentFormDataPath:
- """Cover lines 666, 699, 815-816, 859, 887, 948-951, 1073, 1108, 1136."""
-
- def _make_existing_agent(self, agent_id):
- return {
- "_id": agent_id,
- "user": "user1",
- "name": "Existing",
- "status": "draft",
- "agent_type": "classic",
- "key": "abcdefghijklmnop",
- "source": "default",
- }
-
- def test_form_data_parsing(self, app):
- """Cover line 699: form data parsing path."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- content_type="application/x-www-form-urlencoded",
- data={"name": "Updated"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_invalid_source_id_in_sources_list(self, app):
- """Cover lines 815-816: invalid source ID in sources list."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"sources": ["not-a-valid-oid"]},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
- assert "Invalid source ID" in response.json["message"]
-
- def test_tools_not_a_list(self, app):
- """Cover line 859: tools is not a list."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"tools": "not-a-list"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
- assert "Tools must be a list" in response.json["message"]
-
- def test_limited_token_mode_string_true(self, app):
- """Cover line 887: limited_token_mode string 'True' converted."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"limited_token_mode": "True", "token_limit": 5000},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_request_limit_without_limited_mode(self, app):
- """Cover lines 948-951: request_limit without limited_request_mode."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={
- "request_limit": 100,
- "limited_request_mode": False,
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
- assert "Request limit cannot be set" in response.json["message"]
-
- def test_folder_id_update(self, app):
- """Cover lines 950-951: folder_id field update."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
- folder_oid = str(uuid.uuid4().hex)
- mock_folders_col = Mock()
- mock_folders_col.find_one.return_value = {"_id": folder_oid, "user": "user1"}
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ), patch(
- "application.api.user.agents.routes.agent_folders_collection", mock_folders_col
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"folder_id": folder_oid},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
- def test_publish_missing_source_returns_400(self, app):
- """Cover line 1073: published with source_val check."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = {
- "_id": agent_id,
- "user": "user1",
- "name": "Agent",
- "description": "desc",
- "prompt_id": "default",
- "status": "draft",
- "agent_type": "classic",
- "key": "abcdefghijklmnop",
- "retriever": "classic",
- "chunks": 5,
- }
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"status": "published"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
-
- def test_update_not_found_returns_404(self, app):
- """Cover line 1108: matched_count==0 returns 404."""
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=0, modified_count=0)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"name": "Updated"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 404
-
-
-@pytest.mark.unit
-class TestGetPinnedAgents:
- """Cover lines 1253, 1262: pinned agents key masking and return."""
-
- def test_returns_pinned_agents_with_masked_key(self, app):
- from application.api.user.agents.routes import PinnedAgents
-
- agent_id = uuid.uuid4().hex
- mock_col = Mock()
- mock_col.find.return_value = [
- {
- "_id": agent_id,
- "name": "Pinned",
- "description": "desc",
- "agent_type": "classic",
- "source": "default",
- "retriever": "classic",
- "status": "published",
- "key": "abcdefghijklmnop",
- "createdAt": "",
- "updatedAt": "",
- "lastUsedAt": "",
- }
- ]
- mock_ensure_user = Mock(
- return_value={"agent_preferences": {"pinned": [str(agent_id)]}}
- )
- mock_resolve_tools = Mock(return_value=[])
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure_user
- ), patch(
- "application.api.user.agents.routes.resolve_tool_details", mock_resolve_tools
- ):
- with app.test_request_context("/api/pinned_agents"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = PinnedAgents().get()
- assert response.status_code == 200
- data = response.json
- assert len(data) == 1
- assert data[0]["key"] == "abcd...mnop"
-
- def test_returns_empty_for_no_pinned(self, app):
- from application.api.user.agents.routes import PinnedAgents
-
- mock_ensure_user = Mock(
- return_value={"agent_preferences": {"pinned": []}}
- )
-
- with patch(
- "application.api.user.agents.routes.ensure_user_doc", mock_ensure_user
- ):
- with app.test_request_context("/api/pinned_agents"):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = PinnedAgents().get()
- assert response.status_code == 200
- assert response.json == []
-
-
-@pytest.mark.unit
-class TestGetTemplateAgentsErrorPath:
- """Cover line 1283: template agents error path."""
-
- def test_template_agents_error_returns_400(self, app):
- from application.api.user.agents.routes import GetTemplateAgents
-
- mock_col = Mock()
- mock_col.find.side_effect = Exception("DB error")
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ):
- with app.test_request_context("/api/template_agents"):
- response = GetTemplateAgents().get()
- assert response.status_code == 400
-
-
-# ---------------------------------------------------------------------------
-# Additional coverage for agents/routes.py
-# Lines: 526 (workflow validation error), 666 (models field),
-# 815-816 (invalid source id in list), 887 (limited_token_mode bool),
-# 948-951 (request_limit without mode), 1073 (has_valid_source),
-# 1253 (key masking), 1262 (return pinned agents)
-# ---------------------------------------------------------------------------
-
-
-@pytest.mark.unit
-class TestCreateAgentWorkflowError:
- """Cover lines 525-526: workflow validation returns error."""
-
- def test_workflow_validation_error_on_create(self, app):
- from application.api.user.agents.routes import CreateAgent
-
- mock_col = Mock()
- mock_col.insert_one.return_value = Mock(inserted_id=uuid.uuid4().hex)
-
- error_response = Mock()
- error_response.status_code = 400
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.validate_workflow_access",
- return_value=(None, error_response),
- ), patch(
- "application.api.user.agents.routes.handle_image_upload",
- return_value=("", None),
- ):
- with app.test_request_context(
- "/api/create_agent",
- method="POST",
- json={
- "name": "Test",
- "agent_type": "workflow",
- "status": "draft",
- },
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = CreateAgent().post()
- assert response.status_code == 400
-
-
-@pytest.mark.unit
-class TestUpdateAgentInvalidSourceInList:
- """Cover lines 815-816: invalid source ID in sources list."""
-
- def _make_existing_agent(self, agent_id):
- return {
- "_id": agent_id,
- "user": "user1",
- "name": "Test",
- "agent_type": "classic",
- "status": "draft",
- }
-
- def test_invalid_source_in_list(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"sources": ["not_valid_source_id!!!"]},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 400
- assert "Invalid source ID" in response.json["message"]
-
-
-@pytest.mark.unit
-class TestUpdateAgentModelsField:
- """Cover line 666: models list field in update."""
-
- def _make_existing_agent(self, agent_id):
- return {
- "_id": agent_id,
- "user": "user1",
- "name": "Test",
- "agent_type": "classic",
- "status": "draft",
- }
-
- def test_models_field_update(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"models": ["model-1", "model-2"]},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
-
-
-@pytest.mark.unit
-class TestUpdateAgentHasValidSourceDefault:
- """Cover line 1073: has_valid_source with source == 'default'."""
-
- def _make_existing_agent(self, agent_id):
- return {
- "_id": agent_id,
- "user": "user1",
- "name": "Test",
- "agent_type": "classic",
- "status": "published",
- "description": "desc",
- "prompt_id": "default",
- "source": "default",
- "chunks": 2,
- }
-
- def test_has_valid_source_with_default(self, app):
- from application.api.user.agents.routes import UpdateAgent
-
- agent_id = uuid.uuid4().hex
- existing = self._make_existing_agent(agent_id)
- mock_col = Mock()
- mock_col.find_one.return_value = existing
- mock_col.update_one.return_value = Mock(matched_count=1, modified_count=1)
- mock_handle_img = Mock(return_value=("", None))
-
- with patch(
- "application.api.user.agents.routes.agents_collection", mock_col
- ), patch(
- "application.api.user.agents.routes.handle_image_upload", mock_handle_img
- ):
- with app.test_request_context(
- f"/api/update_agent/{agent_id}",
- method="PUT",
- json={"name": "UpdatedName"},
- ):
- from flask import request
-
- request.decoded_token = {"sub": "user1"}
- response = UpdateAgent().put(str(agent_id))
- assert response.status_code == 200
diff --git a/tests/api/user/test_workflows.py b/tests/api/user/test_workflows.py
deleted file mode 100644
index b85fdf3e..00000000
--- a/tests/api/user/test_workflows.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""Tests for application/api/user/workflows/routes.py.
-
-Previously asserted on bson.ObjectId serialization. Workflow persistence is
-now Postgres-backed; coverage will be rebuilt via pg_conn + WorkflowsRepository.
-"""
-
-import pytest
-
-
-@pytest.mark.skip(reason="needs PG fixture rewrite - tracked separately")
-def test_user_workflows_routes_pending_pg_rewrite():
- pass
From 1040a7efcc7424b52aab1e00f4659f209a848050 Mon Sep 17 00:00:00 2001
From: Pavel
Date: Wed, 19 Aug 2026 23:08:46 +0200
Subject: [PATCH 2/6] tool content loss fixes
---
application/api/user/agents/portability.py | 99 ++++++++++
frontend/src/modals/ImportAgentModal.tsx | 30 +--
frontend/src/modals/ImportSpecModal.tsx | 31 ++--
tests/api/test_agent_portability.py | 206 +++++++++++++++++++++
4 files changed, 337 insertions(+), 29 deletions(-)
diff --git a/application/api/user/agents/portability.py b/application/api/user/agents/portability.py
index e29ab510..905a36d5 100644
--- a/application/api/user/agents/portability.py
+++ b/application/api/user/agents/portability.py
@@ -174,6 +174,101 @@ def _safe_export_config(stored_config: dict, config_requirements: dict) -> dict:
return safe
+# Param-type sections an action may carry. Fixed ``value``s are exported only
+# for ``parameters`` (declared function args, treated as non-secret throughout:
+# returned to the client, shown in the actions editor). The free-form HTTP
+# types routinely hold credentials (an ``Authorization`` header fixed as a
+# value), so for those only the ``filled_by_llm`` flag travels — same posture
+# as ``_safe_export_config`` dropping free-form config.
+_ACTION_PARAM_TYPES = ("parameters", "query_params", "headers", "body")
+_ACTION_VALUE_SAFE_TYPES = frozenset({"parameters"})
+
+
+def _export_action_overrides(actions) -> list:
+ """Compact per-action customizations that deviate from factory defaults.
+
+ ``transform_actions`` initializes every action ``active`` with every param
+ ``filled_by_llm: true, value: ""`` — anything else is a user customization
+ (a fixed ``chat_id``, a disabled action) that must survive export, or the
+ imported tool silently reverts to "let the LLM fill everything".
+ """
+ out = []
+ for action in actions or []:
+ if not isinstance(action, dict) or not action.get("name"):
+ continue
+ override: dict[str, Any] = {"name": action["name"]}
+ customized = False
+ if action.get("active", True) is False:
+ override["active"] = False
+ customized = True
+ for param_type in _ACTION_PARAM_TYPES:
+ section = action.get(param_type)
+ props = section.get("properties") if isinstance(section, dict) else None
+ if not isinstance(props, dict):
+ continue
+ for pname, details in props.items():
+ if not isinstance(details, dict):
+ continue
+ filled = details.get("filled_by_llm", True)
+ value = details.get("value", "")
+ if filled and not value:
+ continue
+ entry: dict[str, Any] = {"filled_by_llm": bool(filled)}
+ if param_type in _ACTION_VALUE_SAFE_TYPES:
+ entry["value"] = value
+ override.setdefault(param_type, {})[pname] = entry
+ customized = True
+ if customized:
+ out.append(override)
+ return out
+
+
+def _apply_action_overrides(base_actions: list, overrides, tool_type: str, warnings: list) -> list:
+ """Overlay exported customizations onto freshly transformed live metadata.
+
+ Matched strictly by (action name, param-type, param name): the YAML
+ contributes flags and scalar values only, never schema — a hand-edited
+ file can't inject a doctored action definition — and the merge survives
+ tool-version drift. Unknown names are skipped with a warning. Values are
+ accepted only for the ``parameters`` type, mirroring export.
+ """
+ if not isinstance(overrides, list) or not overrides:
+ return base_actions
+ by_name = {a.get("name"): a for a in base_actions if isinstance(a, dict)}
+ for override in overrides:
+ if not isinstance(override, dict):
+ continue
+ name = override.get("name") or ""
+ action = by_name.get(name)
+ if action is None:
+ warnings.append(
+ f"Tool '{tool_type}' action '{name}' no longer exists; customization skipped"
+ )
+ continue
+ if override.get("active") is False:
+ action["active"] = False
+ for param_type in _ACTION_PARAM_TYPES:
+ override_props = override.get(param_type)
+ if not isinstance(override_props, dict):
+ continue
+ section = action.get(param_type)
+ props = section.get("properties") if isinstance(section, dict) else {}
+ for pname, entry in override_props.items():
+ details = props.get(pname) if isinstance(props, dict) else None
+ if not isinstance(details, dict) or not isinstance(entry, dict):
+ warnings.append(
+ f"Tool '{tool_type}' action '{name}' has no parameter "
+ f"'{pname}'; customization skipped"
+ )
+ continue
+ details["filled_by_llm"] = bool(entry.get("filled_by_llm", True))
+ if param_type in _ACTION_VALUE_SAFE_TYPES:
+ value = entry.get("value", "")
+ if isinstance(value, (str, int, float, bool)) or value is None:
+ details["value"] = "" if value is None else value
+ return base_actions
+
+
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
@@ -236,6 +331,9 @@ def _serialize_tools(conn, agent: dict, user: str) -> list:
"config": _safe_export_config(row.get("config") or {}, requirements),
"ref": tid_str,
}
+ action_overrides = _export_action_overrides(row.get("actions"))
+ if action_overrides:
+ entry["actions"] = action_overrides
if requires_secrets:
entry["requires_secrets"] = requires_secrets
out.append(entry)
@@ -839,6 +937,7 @@ def _create_tool_from_spec(conn, user: str, tool: dict, secrets: dict, warnings:
warnings.append(f"Tool '{tool_type}' secret encryption failed; not created")
return None
actions = transform_actions(inst.get_actions_metadata() or [])
+ actions = _apply_action_overrides(actions, tool.get("actions"), tool_type, warnings)
created = UserToolsRepository(conn).create(
user,
tool_type,
diff --git a/frontend/src/modals/ImportAgentModal.tsx b/frontend/src/modals/ImportAgentModal.tsx
index 7a46a35a..2c4fa399 100644
--- a/frontend/src/modals/ImportAgentModal.tsx
+++ b/frontend/src/modals/ImportAgentModal.tsx
@@ -1,5 +1,6 @@
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
-import { useRef, useState } from 'react';
+import { useState } from 'react';
+import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
@@ -74,7 +75,6 @@ export default function ImportAgentModal({
const token = useSelector(selectToken);
const sourceDocs = useSelector(selectSourceDocs);
const navigate = useNavigate();
- const fileInputRef = useRef(null);
const [yamlText, setYamlText] = useState('');
const [fileName, setFileName] = useState('');
@@ -112,9 +112,7 @@ export default function ImportAgentModal({
reset();
};
- const handleFileChange = async (e: React.ChangeEvent) => {
- const selectedFile = e.target.files?.[0];
- if (!selectedFile) return;
+ const processFile = async (selectedFile: File) => {
const name = selectedFile.name.toLowerCase();
if (!name.endsWith('.yaml') && !name.endsWith('.yml')) {
setError(t('modals.importAgent.invalidFileType'));
@@ -126,6 +124,13 @@ export default function ImportAgentModal({
setYamlText(await selectedFile.text());
};
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop: (acceptedFiles: File[]) => {
+ if (acceptedFiles[0]) processFile(acceptedFiles[0]);
+ },
+ multiple: false,
+ });
+
const handleAnalyze = async () => {
if (!yamlText) return;
setLoading(true);
@@ -306,8 +311,11 @@ export default function ImportAgentModal({
{t('modals.importAgent.description')}
-
{error && {error}
}
) : (
diff --git a/frontend/src/modals/ImportSpecModal.tsx b/frontend/src/modals/ImportSpecModal.tsx
index 69cf382d..3da09444 100644
--- a/frontend/src/modals/ImportSpecModal.tsx
+++ b/frontend/src/modals/ImportSpecModal.tsx
@@ -1,4 +1,5 @@
-import { useRef, useState } from 'react';
+import { useState } from 'react';
+import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -36,7 +37,6 @@ export default function ImportSpecModal({
}: ImportSpecModalProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
- const fileInputRef = useRef(null);
const [file, setFile] = useState(null);
const [loading, setLoading] = useState(false);
@@ -57,10 +57,7 @@ export default function ImportSpecModal({
setBaseUrl('');
};
- const handleFileChange = (e: React.ChangeEvent) => {
- const selectedFile = e.target.files?.[0];
- if (!selectedFile) return;
-
+ const processFile = (selectedFile: File) => {
const validExtensions = ['.json', '.yaml', '.yml'];
const hasValidExtension = validExtensions.some((ext) =>
selectedFile.name.toLowerCase().endsWith(ext),
@@ -76,6 +73,13 @@ export default function ImportSpecModal({
setParsedResult(null);
};
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop: (acceptedFiles: File[]) => {
+ if (acceptedFiles[0]) processFile(acceptedFiles[0]);
+ },
+ multiple: false,
+ });
+
const handleParse = async () => {
if (!file) return;
@@ -196,8 +200,11 @@ export default function ImportSpecModal({
{error && (
diff --git a/tests/api/test_agent_portability.py b/tests/api/test_agent_portability.py
index eae9ae1e..3732339c 100644
--- a/tests/api/test_agent_portability.py
+++ b/tests/api/test_agent_portability.py
@@ -497,3 +497,209 @@ def test_tool_skipped_when_encryption_fails(pg_conn, monkeypatch):
agent = AgentsRepository(pg_conn).get(result["agent_id"], user)
assert agent["tools"] == []
assert any("encryption failed" in w for w in result["warnings"])
+
+
+# ---------------------------------------------------------------------------
+# Action customizations (per-parameter fixed values / filled_by_llm flags)
+# ---------------------------------------------------------------------------
+
+
+_SEND_MESSAGE_META = {
+ "name": "telegram_send_message",
+ "description": "Send a message",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "chat_id": {"type": "string", "description": "Chat id"},
+ "text": {"type": "string", "description": "Message text"},
+ },
+ "required": ["chat_id", "text"],
+ },
+}
+_GET_UPDATES_META = {
+ "name": "telegram_get_updates",
+ "description": "Poll updates",
+ "parameters": {"type": "object", "properties": {}, "required": []},
+}
+
+
+def _fake_telegram(monkeypatch):
+ import copy as _copy
+
+ fake_tool = Mock()
+ fake_tool.get_config_requirements.return_value = {
+ "token": {"secret": True, "required": True, "label": "Bot token"}
+ }
+ fake_tool.get_actions_metadata.side_effect = lambda: _copy.deepcopy(
+ [_SEND_MESSAGE_META, _GET_UPDATES_META]
+ )
+ monkeypatch.setattr(
+ "application.api.user.agents.portability._tool_instance",
+ lambda tool_type: fake_tool,
+ )
+ return fake_tool
+
+
+def _stored_telegram_actions():
+ """The shape ``update_tool_actions`` persists after the user fixes chat_id
+ and disables the updates action."""
+ return [
+ {
+ "name": "telegram_send_message",
+ "description": "Send a message",
+ "active": True,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "chat_id": {
+ "type": "string",
+ "description": "Chat id",
+ "filled_by_llm": False,
+ "value": "123456789",
+ },
+ "text": {
+ "type": "string",
+ "description": "Message text",
+ "filled_by_llm": True,
+ "value": "",
+ },
+ },
+ "required": ["chat_id", "text"],
+ },
+ },
+ {
+ "name": "telegram_get_updates",
+ "description": "Poll updates",
+ "active": False,
+ "parameters": {"type": "object", "properties": {}, "required": []},
+ },
+ ]
+
+
+def test_action_customizations_round_trip(pg_conn, monkeypatch):
+ user = "u_actions"
+ _fake_telegram(monkeypatch)
+ tool = UserToolsRepository(pg_conn).create(
+ user,
+ "telegram",
+ config={"encrypted_credentials": "BLOB"},
+ custom_name="My TG",
+ display_name="Telegram",
+ description="tg",
+ config_requirements={"token": {"secret": True, "required": True}},
+ actions=_stored_telegram_actions(),
+ )
+ agent = AgentsRepository(pg_conn).create(
+ user, "TG Bot", "published", description="d", tools=[str(tool["id"])]
+ )
+
+ export = serialize_agent(pg_conn, agent, user)
+ overrides = export["spec"]["tools"][0]["actions"]
+ # Only deviations from factory defaults travel: the fixed chat_id and the
+ # disabled action — the default-state ``text`` param does not.
+ assert overrides == [
+ {
+ "name": "telegram_send_message",
+ "parameters": {"chat_id": {"filled_by_llm": False, "value": "123456789"}},
+ },
+ {"name": "telegram_get_updates", "active": False},
+ ]
+
+ # Import as a fresh user (tool re-created with the supplied secret).
+ doc = parse_agent_yaml(agent_to_yaml(export))
+ importer = "u_actions_2"
+ result = apply_import(
+ pg_conn,
+ importer,
+ doc,
+ resolution={"tools": {"tool-0": {"decision": "create", "secrets": {"token": "tok"}}}},
+ )
+ imported_agent = AgentsRepository(pg_conn).get(result["agent_id"], importer)
+ row = UserToolsRepository(pg_conn).get_any(imported_agent["tools"][0], importer)
+ by_name = {a["name"]: a for a in row["actions"]}
+ chat_id = by_name["telegram_send_message"]["parameters"]["properties"]["chat_id"]
+ text = by_name["telegram_send_message"]["parameters"]["properties"]["text"]
+ assert chat_id["filled_by_llm"] is False
+ assert chat_id["value"] == "123456789"
+ assert text["filled_by_llm"] is True and text["value"] == ""
+ assert by_name["telegram_get_updates"]["active"] is False
+ assert by_name["telegram_send_message"]["active"] is True
+
+
+def test_action_header_values_never_exported(pg_conn):
+ user = "u_actions_hdr"
+ tool = UserToolsRepository(pg_conn).create(
+ user,
+ "custom_http",
+ config={},
+ custom_name="HTTP",
+ display_name="HTTP",
+ description="",
+ config_requirements={},
+ actions=[
+ {
+ "name": "call",
+ "active": True,
+ "headers": {
+ "properties": {
+ "Authorization": {
+ "type": "string",
+ "filled_by_llm": False,
+ "value": "Bearer sk-live-XYZ",
+ }
+ }
+ },
+ }
+ ],
+ )
+ agent = AgentsRepository(pg_conn).create(
+ user, "Hdr Bot", "published", description="d", tools=[str(tool["id"])]
+ )
+
+ export = serialize_agent(pg_conn, agent, user)
+ text = agent_to_yaml(export)
+ assert "Bearer sk-live-XYZ" not in text
+ # The flag still travels so the imported action keeps its shape.
+ override = export["spec"]["tools"][0]["actions"][0]
+ assert override["headers"]["Authorization"] == {"filled_by_llm": False}
+
+
+def test_action_override_for_removed_action_warns(pg_conn, monkeypatch):
+ user = "u_actions_gone"
+ _fake_telegram(monkeypatch)
+ doc = _doc(
+ name="Drifted Bot",
+ _slug="drifted",
+ tools=[
+ {
+ "type": "telegram",
+ "name": "My TG",
+ "config": {},
+ "requires_secrets": ["token"],
+ "actions": [
+ {"name": "telegram_removed_action", "active": False},
+ {
+ "name": "telegram_send_message",
+ "parameters": {
+ "gone_param": {"filled_by_llm": False, "value": "x"},
+ "chat_id": {"filled_by_llm": False, "value": "42"},
+ },
+ },
+ ],
+ }
+ ],
+ )
+ result = apply_import(
+ pg_conn,
+ user,
+ doc,
+ resolution={"tools": {"tool-0": {"secrets": {"token": "tok"}}}},
+ )
+ agent = AgentsRepository(pg_conn).get(result["agent_id"], user)
+ row = UserToolsRepository(pg_conn).get_any(agent["tools"][0], user)
+ by_name = {a["name"]: a for a in row["actions"]}
+ # The surviving customization applied; the drifted ones warned and skipped.
+ chat_id = by_name["telegram_send_message"]["parameters"]["properties"]["chat_id"]
+ assert chat_id["filled_by_llm"] is False and chat_id["value"] == "42"
+ assert any("telegram_removed_action" in w for w in result["warnings"])
+ assert any("gone_param" in w for w in result["warnings"])
From 7baf33aea00aa24d5d3f7e7d5d803fe1fdc63e3f Mon Sep 17 00:00:00 2001
From: Pavel
Date: Thu, 20 Aug 2026 11:37:29 +0200
Subject: [PATCH 3/6] edits v1
---
application/api/user/agents/portability.py | 4 ++--
tests/api/test_workflow_portability.py | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/application/api/user/agents/portability.py b/application/api/user/agents/portability.py
index 905a36d5..406fbdca 100644
--- a/application/api/user/agents/portability.py
+++ b/application/api/user/agents/portability.py
@@ -536,7 +536,6 @@ def serialize_agent(conn, agent: dict, user: str) -> dict:
workflow_block, tools, sources, model = _serialize_workflow(conn, agent, user)
prompt = "default"
else:
- workflow_block = None
tools = _serialize_tools(conn, agent, user)
sources = _serialize_sources(conn, agent, user)
model = _serialize_models(conn, agent, user)
@@ -1293,7 +1292,6 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
)
is_workflow = (spec.get("agent_type") or "classic") == "workflow"
- workflow_result = _WORKFLOW_UNCHANGED
if is_workflow:
workflow_result = _apply_workflow(
conn,
@@ -1305,6 +1303,8 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
model_ids_by_name,
warnings,
)
+ else:
+ workflow_result = _WORKFLOW_UNCHANGED
agents_repo = AgentsRepository(conn)
is_update = bool(target.get("action") == "update" and target.get("agent_id"))
diff --git a/tests/api/test_workflow_portability.py b/tests/api/test_workflow_portability.py
index 706427f8..8741834c 100644
--- a/tests/api/test_workflow_portability.py
+++ b/tests/api/test_workflow_portability.py
@@ -380,6 +380,6 @@ def test_import_with_null_workflow_keeps_published_but_clears_draft(pg_conn):
# Draft agent: explicit null clears the link.
AgentsRepository(pg_conn).update(str(agent["id"]), user, {"status": "draft"})
- result = apply_import(pg_conn, user, doc)
+ apply_import(pg_conn, user, doc)
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
assert updated["workflow_id"] is None
From 7553e74a96f8c33719ec5ec27dc3f03edf16976e Mon Sep 17 00:00:00 2001
From: Pavel
Date: Thu, 20 Aug 2026 14:05:42 +0200
Subject: [PATCH 4/6] Fix 2
---
application/api/user/agents/portability.py | 23 ++++++++++------------
1 file changed, 10 insertions(+), 13 deletions(-)
diff --git a/application/api/user/agents/portability.py b/application/api/user/agents/portability.py
index 406fbdca..cdb1b5c8 100644
--- a/application/api/user/agents/portability.py
+++ b/application/api/user/agents/portability.py
@@ -1292,19 +1292,6 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
)
is_workflow = (spec.get("agent_type") or "classic") == "workflow"
- if is_workflow:
- workflow_result = _apply_workflow(
- conn,
- user,
- spec,
- target,
- tool_ids_by_key,
- source_ids_by_name,
- model_ids_by_name,
- warnings,
- )
- else:
- workflow_result = _WORKFLOW_UNCHANGED
agents_repo = AgentsRepository(conn)
is_update = bool(target.get("action") == "update" and target.get("agent_id"))
@@ -1354,6 +1341,16 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
"extra_source_ids": [],
}
)
+ workflow_result = _apply_workflow(
+ conn,
+ user,
+ spec,
+ target,
+ tool_ids_by_key,
+ source_ids_by_name,
+ model_ids_by_name,
+ warnings,
+ )
if workflow_result is not _WORKFLOW_UNCHANGED:
if workflow_result is None and (target.get("status") or "") == "published":
warnings.append(
From 33d93ba0cace5a947c11936d6bf35bc2be100736 Mon Sep 17 00:00:00 2001
From: Pavel
Date: Thu, 20 Aug 2026 23:07:15 +0200
Subject: [PATCH 5/6] fixes 3
---
application/api/user/agents/portability.py | 266 ++++++++++++++++--
application/api/user/agents/routes.py | 111 +++++++-
application/storage/db/repositories/agents.py | 13 +
.../storage/db/repositories/workflow_edges.py | 10 -
.../storage/db/repositories/workflow_nodes.py | 10 -
.../storage/db/repositories/workflows.py | 16 +-
frontend/src/modals/ImportAgentModal.tsx | 9 +-
frontend/src/modals/ImportSpecModal.tsx | 9 +-
tests/api/test_agent_portability.py | 119 ++++++++
tests/api/test_workflow_portability.py | 29 +-
tests/api/user/agents/test_workflow_agents.py | 86 +++++-
.../db/repositories/test_workflow_edges.py | 8 -
.../db/repositories/test_workflow_nodes.py | 11 -
13 files changed, 612 insertions(+), 85 deletions(-)
diff --git a/application/api/user/agents/portability.py b/application/api/user/agents/portability.py
index cdb1b5c8..2bd2f563 100644
--- a/application/api/user/agents/portability.py
+++ b/application/api/user/agents/portability.py
@@ -68,6 +68,10 @@ class AgentImportError(Exception):
"""Raised when an agent YAML document is malformed or unsupported."""
+class AgentExportError(Exception):
+ """Raised when an agent cannot be exported as a document import would accept."""
+
+
class _SafeNoAliasLoader(yaml.SafeLoader):
"""SafeLoader that also rejects anchors/aliases (billion-laughs guard)."""
@@ -158,12 +162,14 @@ def _safe_export_config(stored_config: dict, config_requirements: dict) -> dict:
Export must never leak a credential. A key is emitted ONLY if
``config_requirements`` declares it and does not mark it secret.
- Anything not described by requirements — e.g. ``api_tool``/MCP free-form
- ``headers``, ``url`` query strings, ``server_url`` that routinely embed
- tokens, or tools with empty ``config_requirements`` — is dropped. Reuse
- on import matches the existing tool; create re-collects config from the
- user. This blocklist-free approach can't be defeated by a stale or empty
- ``config_requirements``.
+ Anything not described by requirements — e.g. MCP free-form ``headers``,
+ ``url`` query strings, ``server_url`` that routinely embed tokens, or
+ tools with empty ``config_requirements`` — is dropped. Reuse on import
+ matches the existing tool; create re-collects config from the user. This
+ blocklist-free approach can't be defeated by a stale or empty
+ ``config_requirements``. (``api_tool`` is the one exception: its
+ user-authored ``config["actions"]`` travel via
+ ``_export_api_tool_actions``, redacted under the same posture.)
"""
requirements = config_requirements or {}
safe: dict[str, Any] = {}
@@ -180,9 +186,121 @@ def _safe_export_config(stored_config: dict, config_requirements: dict) -> dict:
# types routinely hold credentials (an ``Authorization`` header fixed as a
# value), so for those only the ``filled_by_llm`` flag travels — same posture
# as ``_safe_export_config`` dropping free-form config.
+#
+# Scope note: ``actions``-column rows come from ``transform_actions`` over live
+# tool metadata, which today only ever emits ``parameters`` sections — the
+# HTTP types appear only in ``api_tool``'s ``config["actions"]``, which travel
+# through ``_export_api_tool_actions`` / ``_sanitize_api_tool_actions`` below.
+# The full tuple is kept here so a hand-edited file naming those sections is
+# handled (flags applied, values refused) rather than silently ignored.
_ACTION_PARAM_TYPES = ("parameters", "query_params", "headers", "body")
_ACTION_VALUE_SAFE_TYPES = frozenset({"parameters"})
+# Action keys copied verbatim by ``_export_api_tool_actions`` — everything
+# except ``url`` (query-string-stripped) and the param sections (redacted).
+_API_ACTION_SAFE_KEYS = (
+ "name",
+ "description",
+ "method",
+ "active",
+ "require_approval",
+ "body_content_type",
+ "body_encoding_rules",
+)
+
+
+def _export_api_tool_actions(stored_config: dict) -> dict:
+ """Redacted copy of an ``api_tool``'s ``config["actions"]``.
+
+ ``api_tool`` is the one tool type whose actions are user-authored data in
+ ``config["actions"]`` (a dict keyed by action name) rather than the
+ ``actions`` column (the executor reads exactly that — see ToolExecutor).
+ The action *shape* — endpoint, method, param schemas, ``filled_by_llm``
+ flags, fixed ``parameters`` values — is what makes the tool work, so it
+ travels. Fixed values in the free-form HTTP sections and URL query
+ strings routinely hold credentials, so those are blanked — same posture
+ as ``_safe_export_config``.
+ """
+ actions = stored_config.get("actions")
+ if not isinstance(actions, dict):
+ return {}
+ out: dict[str, Any] = {}
+ for name, action in actions.items():
+ if not isinstance(action, dict):
+ continue
+ entry: dict[str, Any] = {
+ k: copy.deepcopy(action[k]) for k in _API_ACTION_SAFE_KEYS if k in action
+ }
+ entry["url"] = str(action.get("url") or "").split("?", 1)[0]
+ for param_type in _ACTION_PARAM_TYPES:
+ section = action.get(param_type)
+ if not isinstance(section, dict):
+ continue
+ section = copy.deepcopy(section)
+ props = section.get("properties")
+ if param_type not in _ACTION_VALUE_SAFE_TYPES and isinstance(props, dict):
+ for details in props.values():
+ if isinstance(details, dict) and details.get("value"):
+ details["value"] = ""
+ entry[param_type] = section
+ out[str(name)] = entry
+ return out
+
+
+def _sanitize_api_tool_actions(raw, tool_label: str, warnings: list) -> dict:
+ """Vet a hand-editable ``config["actions"]`` block before storing it.
+
+ Unlike column actions — whose schema comes from live tool code, so the
+ YAML may contribute flags and scalar values only — ``api_tool`` actions
+ ARE user-authored schema, and the whole definition is accepted. Each
+ action's URL goes through the same SSRF gate as the tool-config routes
+ (``_validate_tool_urls``); an action with a missing or unsafe URL is
+ dropped with a warning rather than failing the import.
+ """
+ if not isinstance(raw, dict):
+ return {}
+ if len(raw) > MAX_LIST_ITEMS:
+ raise AgentImportError(f"Too many api_tool actions (max {MAX_LIST_ITEMS})")
+ out: dict[str, Any] = {}
+ for name, action in raw.items():
+ if not isinstance(action, dict):
+ continue
+ url = str(action.get("url") or "").strip()
+ if not url:
+ warnings.append(
+ f"Tool '{tool_label}' action '{name}' has no URL; dropped"
+ )
+ continue
+ try:
+ validate_url(url)
+ except SSRFError:
+ warnings.append(
+ f"Tool '{tool_label}' action '{name}' has an unsafe URL; dropped"
+ )
+ continue
+ # Export blanks fixed values in the free-form HTTP sections (they
+ # routinely hold credentials); a param left pinned with an empty
+ # value is silently omitted from the request at run time, so
+ # surface it now instead.
+ pinned_empty = [
+ f"{param_type}.{pname}"
+ for param_type in _ACTION_PARAM_TYPES
+ if param_type not in _ACTION_VALUE_SAFE_TYPES
+ and isinstance(action.get(param_type), dict)
+ for pname, details in (action[param_type].get("properties") or {}).items()
+ if isinstance(details, dict)
+ and details.get("filled_by_llm") is False
+ and not details.get("value")
+ ]
+ if pinned_empty:
+ warnings.append(
+ f"Tool '{tool_label}' action '{name}': fixed values for "
+ f"{', '.join(pinned_empty)} don't travel in the file; set them "
+ "in the tool's settings or the request will omit them"
+ )
+ out[str(name)] = action
+ return out
+
def _export_action_overrides(actions) -> list:
"""Compact per-action customizations that deviate from factory defaults.
@@ -266,6 +384,17 @@ def _apply_action_overrides(base_actions: list, overrides, tool_type: str, warni
value = entry.get("value", "")
if isinstance(value, (str, int, float, bool)) or value is None:
details["value"] = "" if value is None else value
+ elif not details["filled_by_llm"] and not details.get("value"):
+ # Pinned but valueless: the executor omits the param from
+ # the LLM schema AND skips the empty fixed value, so the
+ # request silently goes out without it. Values for the
+ # free-form HTTP types never travel in the file, so this
+ # can only be repaired in the tool's settings.
+ warnings.append(
+ f"Tool '{tool_type}' action '{name}': fixed value for "
+ f"{param_type} '{pname}' doesn't travel in the file; set "
+ "it in the tool's settings or the request will omit it"
+ )
return base_actions
@@ -314,13 +443,26 @@ def _serialize_tools(conn, agent: dict, user: str) -> list:
for tid in agent.get("tools") or []:
tid_str = str(tid)
if is_synthesized_tool_id(tid_str):
- out.append({"type": synthesized_tool_name_for_id(tid_str), "builtin": True})
+ out.append(
+ {
+ "type": synthesized_tool_name_for_id(tid_str),
+ "builtin": True,
+ "ref": tid_str,
+ }
+ )
continue
row = repo.get_any(tid_str, user)
if not row:
continue
requirements = row.get("config_requirements") or {}
requires_secrets = _secret_field_names(requirements)
+ config = _safe_export_config(row.get("config") or {}, requirements)
+ if (row.get("name") or "") == "api_tool":
+ # api_tool's actions live in config, not the actions column — an
+ # export without them imports as a dead tool.
+ exported_actions = _export_api_tool_actions(row.get("config") or {})
+ if exported_actions:
+ config["actions"] = exported_actions
entry: dict[str, Any] = {
"type": row.get("name") or "",
# Raw custom_name (may be "") so import matching is symmetric and
@@ -328,7 +470,7 @@ def _serialize_tools(conn, agent: dict, user: str) -> list:
"name": row.get("custom_name") or "",
"display_name": row.get("display_name") or "",
"description": row.get("description") or "",
- "config": _safe_export_config(row.get("config") or {}, requirements),
+ "config": config,
"ref": tid_str,
}
action_overrides = _export_action_overrides(row.get("actions"))
@@ -389,6 +531,19 @@ def _effective_node_config(config: dict) -> dict:
return nested if isinstance(nested, dict) else config
+def _strip_shadowed_ref_keys(config: dict) -> None:
+ """Drop flat ``tools``/``sources``/``model_id`` shadowed by a nested config.
+
+ When a node stores its settings under ``config["config"]``, the engine
+ reads only the nested dict — flat leftovers of those keys are inert, but
+ on export they would leak the exporter's raw ids into the file, and on
+ import they would store a hand-edited file's raw ids verbatim.
+ """
+ if isinstance(config.get("config"), dict):
+ for key in ("tools", "sources", "model_id"):
+ config.pop(key, None)
+
+
def _serialize_workflow(conn, agent: dict, user: str):
"""Build the portable ``spec.workflow`` block plus its reference sections.
@@ -431,26 +586,21 @@ def _serialize_workflow(conn, agent: dict, user: str):
if mid and str(mid) not in model_ids:
model_ids.append(str(mid))
- # Serialize each reference through the classic per-resource serializers so
+ # Serialize the references through the classic per-resource serializers so
# secret redaction and identity rules stay in one place. Unresolvable ids
- # (dangling rows) are dropped from both the section and the node config.
+ # (dangling rows) are dropped from both the section and the node config;
+ # every emitted entry carries its ``ref``, which keys the maps below.
tools: list = []
tool_key_by_id: dict[str, str] = {}
- for tid in tool_ids:
- entries = _serialize_tools(conn, {"tools": [tid]}, user)
- if not entries:
- continue
- tool_key_by_id[tid] = _tool_key(len(tools))
- tools.append(entries[0])
+ for entry in _serialize_tools(conn, {"tools": tool_ids}, user):
+ tool_key_by_id[str(entry["ref"])] = _tool_key(len(tools))
+ tools.append(entry)
sources: list = []
source_name_by_id: dict[str, str] = {}
- for sid in source_ids:
- entries = _serialize_sources(conn, {"source_id": sid}, user)
- if not entries:
- continue
- source_name_by_id[sid] = entries[0]["name"]
- sources.append(entries[0])
+ for entry in _serialize_sources(conn, {"extra_source_ids": source_ids}, user):
+ source_name_by_id[str(entry["ref"])] = entry["name"]
+ sources.append(entry)
model = _serialize_models(conn, {"models": model_ids, "default_model_id": ""}, user)
model_name_by_id: dict[str, str] = {}
@@ -460,8 +610,22 @@ def _serialize_workflow(conn, agent: dict, user: str):
elif isinstance(entry, str):
model_name_by_id[entry] = entry
- # Pass 2: emit nodes/edges in the workflow API's wire shape, with agent
- # node configs rewritten to the portable keys.
+ # ``parse_agent_yaml`` bounds these sections at import (DoS cap), so a
+ # graph referencing more would export a file that import then rejects —
+ # refuse here with a clear message instead.
+ if (
+ len(tools) > MAX_LIST_ITEMS
+ or len(sources) > MAX_LIST_ITEMS
+ or len(model["available"]) > MAX_LIST_ITEMS
+ ):
+ raise AgentExportError(
+ "This workflow references more than "
+ f"{MAX_LIST_ITEMS} tools, sources, or models and cannot be exported"
+ )
+
+ # Pass 2: emit nodes/edges with agent node configs rewritten to the
+ # portable keys. (Node settings sit under ``config`` here; the workflow
+ # API's wire shape calls that field ``data`` — import converts.)
nodes_out = []
for n in node_rows:
config = copy.deepcopy(n.get("config") or {})
@@ -479,6 +643,7 @@ def _serialize_workflow(conn, agent: dict, user: str):
]
if cfg.get("model_id"):
cfg["model_id"] = model_name_by_id.get(str(cfg["model_id"]))
+ _strip_shadowed_ref_keys(config)
nodes_out.append(
{
"id": n["node_id"],
@@ -922,6 +1087,19 @@ def _create_tool_from_spec(conn, user: str, tool: dict, secrets: dict, warnings:
config_requirements = inst.get_config_requirements() or {}
config = dict(tool.get("config") or {})
config.update(secrets or {})
+ if tool_type == "api_tool":
+ label = tool.get("display_name") or tool.get("name") or tool_type
+ config["actions"] = _sanitize_api_tool_actions(
+ config.get("actions"), label, warnings
+ )
+ if not config["actions"]:
+ # Zero actions means a dead tool — the executor resolves api_tool
+ # calls exclusively through config["actions"]. Reachable via older
+ # exports (which couldn't carry actions) or a fully-dropped block.
+ warnings.append(
+ f"API tool '{label}' was created without any actions; "
+ "configure its actions in the tool's settings before use"
+ )
missing = [k for k in _secret_field_names(config_requirements, required_only=True) if not config.get(k)]
if missing:
warnings.append(f"Tool '{tool_type}' needs secret(s) {missing}; not created")
@@ -1025,7 +1203,22 @@ def _apply_models(conn, user: str, spec: dict, resolution: dict, warnings: list)
name_to_id: dict[str, str] = {}
models: list[str] = []
- for entry in model_spec.get("available") or []:
+ available = model_spec.get("available") or []
+ # ``name_to_id`` (and a workflow node's ``model_id`` reference) keys on
+ # display_name alone, while custom-model identity is the full
+ # (display_name, upstream_model_id, base_url) triple — duplicates
+ # collapse onto the last entry, so surface that instead of silently
+ # rewiring nodes.
+ custom_names = [
+ e.get("display_name") or "" for e in available if isinstance(e, dict)
+ ]
+ for dup in sorted({n for n in custom_names if n and custom_names.count(n) > 1}):
+ warnings.append(
+ f"Multiple custom models share the display name '{dup}'; "
+ "references to it resolve to the last one in the file"
+ )
+
+ for entry in available:
if isinstance(entry, str):
if validate_model_id(entry, user):
models.append(entry)
@@ -1204,6 +1397,7 @@ def _apply_workflow(
model_ids_by_name,
warnings,
)
+ _strip_shadowed_ref_keys(config)
position = node.get("position")
nodes_data.append(
{
@@ -1328,6 +1522,7 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
"config": _import_config(spec),
"slug": slug,
}
+ orphaned_workflow_id: Optional[str] = None
if is_workflow:
# A workflow agent's behavior lives in its graph — the agent-level
# prompt/tool/model/source slots in the file exist only to carry the
@@ -1358,6 +1553,14 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
)
else:
authoritative["workflow_id"] = workflow_result
+ if workflow_result is None and is_update:
+ # Explicit ``workflow: null`` on a draft: remember the old
+ # link so the row can be reaped after the update — there is
+ # no workflow-list API independent of agents, so an
+ # unlinked workflow would be unreachable forever.
+ prior = agents_repo.get(str(target["agent_id"]), user) or {}
+ if prior.get("workflow_id"):
+ orphaned_workflow_id = str(prior["workflow_id"])
# Optional fields — applied only when present so a partial file doesn't wipe them.
optional = {
@@ -1374,6 +1577,12 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
# Only a brand-new agent (the create path below) starts as a draft.
fields = {**authoritative, **optional, "name": spec.get("name")}
if agents_repo.update(str(target["agent_id"]), user, fields):
+ if orphaned_workflow_id and not agents_repo.count_by_workflow(
+ orphaned_workflow_id, user
+ ):
+ # Nothing references it anymore; delete (cascades nodes/edges
+ # and reaps run artifacts) rather than stranding the row.
+ WorkflowsRepository(conn).delete(orphaned_workflow_id, user)
return {
"agent_id": str(target["agent_id"]),
"action": "updated",
@@ -1451,7 +1660,12 @@ class ExportAgent(Resource):
jsonify({"success": False, "message": "Agent not found"}), 404
)
agent["slug"] = ensure_agent_slug(conn, agent, user)
- export = serialize_agent(conn, agent, user)
+ try:
+ export = serialize_agent(conn, agent, user)
+ except AgentExportError as exc:
+ return make_response(
+ jsonify({"success": False, "message": str(exc)}), 400
+ )
body = agent_to_yaml(export)
filename = f"{export['metadata'].get('slug') or 'agent'}.agent.yaml"
response = make_response(body, 200)
diff --git a/application/api/user/agents/routes.py b/application/api/user/agents/routes.py
index 83d2103a..f71c7afb 100644
--- a/application/api/user/agents/routes.py
+++ b/application/api/user/agents/routes.py
@@ -29,8 +29,13 @@ from application.api.user.team_sharing import (
team_access_for,
visible_with_access,
)
+from application.agents.default_tools import is_synthesized_tool_id
from application.storage.db.repositories.agent_folders import AgentFoldersRepository
from application.storage.db.repositories.agents import AgentsRepository
+from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+)
+from application.storage.db.repositories.user_tools import UserToolsRepository
from application.storage.db.repositories.conversations import ConversationsRepository
from application.storage.db.repositories.shared_conversations import (
SharedConversationsRepository,
@@ -1615,6 +1620,71 @@ class GetTemplateAgents(Resource):
return make_response(jsonify({"success": False}), 400)
+def _filter_adoptable_tool_ids(conn, user: str, tool_ids) -> list:
+ """Keep only tool ids the adopter can actually run.
+
+ Builtin synthetic ids resolve for everyone; a ``user_tools`` row must be
+ the adopter's own — the runtime resolves tools strictly owner-scoped, so
+ a template owner's tool id would just be dropped (with a log line) on
+ every run. Filtering at adopt time makes the gap visible in the builder
+ instead of at run time.
+ """
+ tools_repo = UserToolsRepository(conn)
+ kept = []
+ for tid in tool_ids or []:
+ tid = str(tid)
+ if is_synthesized_tool_id(tid) or (
+ looks_like_uuid(tid) and tools_repo.get_any(tid, user)
+ ):
+ kept.append(tid)
+ return kept
+
+
+def _strip_foreign_node_refs(conn, user: str):
+ """Build a ``clone_to_user`` node-config transform for adoption.
+
+ Agent nodes carry raw tool/source/model ids from the template owner. The
+ engine resolves all three owner-scoped at run time (dropping misses with
+ only a log line), so unresolvable refs would survive the clone as ghost
+ references — shown in the builder, never used. Strip them instead, so
+ the adopter sees empty fields to fill in.
+ """
+
+ def transform(node_type: str, config: dict) -> dict:
+ if node_type != "agent":
+ return config
+ # Node settings may sit nested under ``config`` — mirror the engine,
+ # which reads ``node.config.get("config", node.config)``.
+ nested = config.get("config")
+ cfg = nested if isinstance(nested, dict) else config
+ if cfg.get("tools"):
+ cfg["tools"] = _filter_adoptable_tool_ids(conn, user, cfg["tools"])
+ if cfg.get("sources"):
+ sources = cfg["sources"]
+ if not isinstance(sources, list):
+ sources = [sources]
+ cfg["sources"] = [
+ str(s)
+ for s in sources
+ if s and can_access(conn, "source", str(s), user)
+ ]
+ model_id = cfg.get("model_id")
+ if (
+ model_id
+ and looks_like_uuid(str(model_id))
+ and not UserCustomModelsRepository(conn).get(str(model_id), user)
+ ):
+ cfg["model_id"] = None
+ if cfg is not config:
+ # Flat leftovers of these keys are ignored by the engine but would
+ # carry the template owner's raw ids along; drop them.
+ for key in ("tools", "sources", "model_id"):
+ config.pop(key, None)
+ return config
+
+ return transform
+
+
@agents_ns.route("/adopt_agent")
class AdoptAgent(Resource):
@api.doc(params={"id": "Agent ID"}, description="Adopt an agent by ID")
@@ -1673,21 +1743,37 @@ class AdoptAgent(Resource):
# ``workflow_id`` must NOT be copied by reference: the runtime
# resolves it strictly owner-scoped, so the adopter would get
# "Workflow not found or inaccessible" on every run. Deep-copy
- # the template's graph into a workflow the adopter owns.
+ # the template's graph into a workflow the adopter owns,
+ # stripping node refs (tools/sources/models) the adopter can't
+ # resolve — they are owner-scoped at run time too.
if template.get("workflow_id"):
cloned_workflow = WorkflowsRepository(conn).clone_to_user(
str(template["workflow_id"]),
user,
from_owner=template.get("user_id"),
+ node_config_transform=_strip_foreign_node_refs(conn, user),
)
- if cloned_workflow is not None:
- create_kwargs["workflow_id"] = str(cloned_workflow["id"])
- else:
- current_app.logger.warning(
- "Adopt: template %s references missing workflow %s; "
- "adopted agent created without one",
- template.get("id"), template.get("workflow_id"),
+ if cloned_workflow is None:
+ # A workflow agent without its graph can never run —
+ # fail the adopt instead of publishing a broken agent.
+ current_app.logger.error(
+ "Adopt: template %s references workflow %s not owned "
+ "by %s; adoption refused",
+ template.get("id"),
+ template.get("workflow_id"),
+ template.get("user_id"),
)
+ return make_response(
+ jsonify(
+ {
+ "success": False,
+ "message": "This template's workflow is "
+ "unavailable; it cannot be adopted",
+ }
+ ),
+ 500,
+ )
+ create_kwargs["workflow_id"] = str(cloned_workflow["id"])
# The avatar must live under the adopter's own upload
# directory: image paths are validated against their owner,
@@ -1698,9 +1784,16 @@ class AdoptAgent(Resource):
)
if adopted_image:
create_kwargs["image"] = adopted_image
- for col in ("tools", "json_schema", "models", "shared_metadata", "config"):
+ for col in ("json_schema", "models", "shared_metadata", "config"):
if template.get(col) is not None:
create_kwargs[col] = template[col]
+ # Tool ids resolve owner-scoped at run time, so the template
+ # owner's rows would silently drop on every run — keep only
+ # what the adopter can actually use (builtins, own tools).
+ if template.get("tools") is not None:
+ create_kwargs["tools"] = _filter_adoptable_tool_ids(
+ conn, user, template["tools"]
+ )
for col in ("chunks", "token_limit", "request_limit"):
if template.get(col) is not None:
create_kwargs[col] = template[col]
diff --git a/application/storage/db/repositories/agents.py b/application/storage/db/repositories/agents.py
index cdec8a2a..97234e4a 100644
--- a/application/storage/db/repositories/agents.py
+++ b/application/storage/db/repositories/agents.py
@@ -160,6 +160,19 @@ class AgentsRepository:
)
return [row_to_dict(r) for r in result.fetchall()]
+ def count_by_workflow(self, workflow_id: str, user_id: str) -> int:
+ """Number of this user's agents linked to ``workflow_id``."""
+ if not looks_like_uuid(workflow_id):
+ return 0
+ result = self._conn.execute(
+ text(
+ "SELECT COUNT(*) FROM agents "
+ "WHERE workflow_id = CAST(:wf AS uuid) AND user_id = :user_id"
+ ),
+ {"wf": workflow_id, "user_id": user_id},
+ )
+ return int(result.scalar() or 0)
+
def get_by_id(self, agent_id: str) -> Optional[dict]:
"""Fetch an agent by id with NO ownership scoping.
diff --git a/application/storage/db/repositories/workflow_edges.py b/application/storage/db/repositories/workflow_edges.py
index 63610c2b..b41b9b48 100644
--- a/application/storage/db/repositories/workflow_edges.py
+++ b/application/storage/db/repositories/workflow_edges.py
@@ -149,16 +149,6 @@ class WorkflowEdgesRepository:
row = result.fetchone()
return str(row[0]) if row else None
- def delete_by_workflow(self, workflow_id: str) -> int:
- result = self._conn.execute(
- text(
- "DELETE FROM workflow_edges "
- "WHERE workflow_id = CAST(:wf_id AS uuid)"
- ),
- {"wf_id": workflow_id},
- )
- return result.rowcount
-
def delete_by_version(self, workflow_id: str, graph_version: int) -> int:
result = self._conn.execute(
text(
diff --git a/application/storage/db/repositories/workflow_nodes.py b/application/storage/db/repositories/workflow_nodes.py
index e8ef1025..ca24a6f1 100644
--- a/application/storage/db/repositories/workflow_nodes.py
+++ b/application/storage/db/repositories/workflow_nodes.py
@@ -143,16 +143,6 @@ class WorkflowNodesRepository:
row = result.fetchone()
return row_to_dict(row) if row is not None else None
- def delete_by_workflow(self, workflow_id: str) -> int:
- result = self._conn.execute(
- text(
- "DELETE FROM workflow_nodes "
- "WHERE workflow_id = CAST(:wf_id AS uuid)"
- ),
- {"wf_id": workflow_id},
- )
- return result.rowcount
-
def delete_by_version(self, workflow_id: str, graph_version: int) -> int:
result = self._conn.execute(
text(
diff --git a/application/storage/db/repositories/workflows.py b/application/storage/db/repositories/workflows.py
index 42ba12f9..fb3f96c1 100644
--- a/application/storage/db/repositories/workflows.py
+++ b/application/storage/db/repositories/workflows.py
@@ -8,6 +8,7 @@ Covers CRUD on workflow metadata:
from __future__ import annotations
+import copy
from typing import Optional
from sqlalchemy import Connection, text
@@ -121,6 +122,7 @@ class WorkflowsRepository:
new_owner: str,
*,
from_owner: Optional[str] = None,
+ node_config_transform=None,
) -> Optional[dict]:
"""Deep-copy a workflow's current graph into a new workflow owned by ``new_owner``.
@@ -133,6 +135,12 @@ class WorkflowsRepository:
ownership check — only do that when ``workflow_id`` comes from trusted
server-side state, never from client input. Returns the new workflow
row, or None if the source is missing.
+
+ ``node_config_transform(node_type, config) -> dict`` lets the caller
+ rewrite each node's config as it is copied (it receives a deep copy) —
+ e.g. adoption strips refs the new owner can't resolve, since node
+ configs carry raw tool/source/model ids that are owner-scoped at run
+ time and would otherwise ride along as ghost references.
"""
from application.storage.db.repositories.workflow_edges import (
WorkflowEdgesRepository,
@@ -162,6 +170,12 @@ class WorkflowsRepository:
)
clone_id = str(clone["id"])
+ def _cloned_config(n: dict) -> dict:
+ config = n.get("config") or {}
+ if node_config_transform is not None:
+ config = node_config_transform(n["node_type"], copy.deepcopy(config))
+ return config if isinstance(config, dict) else {}
+
nodes_repo = WorkflowNodesRepository(self._conn)
node_rows = nodes_repo.find_by_version(workflow_id, version)
created_nodes = nodes_repo.bulk_create(
@@ -174,7 +188,7 @@ class WorkflowsRepository:
"title": n.get("title"),
"description": n.get("description"),
"position": n.get("position") or {"x": 0, "y": 0},
- "config": n.get("config") or {},
+ "config": _cloned_config(n),
}
for n in node_rows
],
diff --git a/frontend/src/modals/ImportAgentModal.tsx b/frontend/src/modals/ImportAgentModal.tsx
index 2c4fa399..f299e9b7 100644
--- a/frontend/src/modals/ImportAgentModal.tsx
+++ b/frontend/src/modals/ImportAgentModal.tsx
@@ -129,6 +129,13 @@ export default function ImportAgentModal({
if (acceptedFiles[0]) processFile(acceptedFiles[0]);
},
multiple: false,
+ // Declared here (not via getInputProps) so drag-and-drop is filtered
+ // too, with drag-over rejection feedback; processFile's extension check
+ // stays as a backstop for odd MIME reports.
+ accept: {
+ 'application/x-yaml': ['.yaml', '.yml'],
+ 'text/yaml': ['.yaml', '.yml'],
+ },
});
const handleAnalyze = async () => {
@@ -325,7 +332,7 @@ export default function ImportAgentModal({
{fileName || t('modals.importAgent.dropzoneText')}
-
+
{error && {error}
}
diff --git a/frontend/src/modals/ImportSpecModal.tsx b/frontend/src/modals/ImportSpecModal.tsx
index 3da09444..2f636087 100644
--- a/frontend/src/modals/ImportSpecModal.tsx
+++ b/frontend/src/modals/ImportSpecModal.tsx
@@ -78,6 +78,13 @@ export default function ImportSpecModal({
if (acceptedFiles[0]) processFile(acceptedFiles[0]);
},
multiple: false,
+ // Declared here (not via getInputProps) so drag-and-drop is filtered
+ // too; processFile's extension check stays as a backstop.
+ accept: {
+ 'application/json': ['.json'],
+ 'application/x-yaml': ['.yaml', '.yml'],
+ 'text/yaml': ['.yaml', '.yml'],
+ },
});
const handleParse = async () => {
@@ -217,7 +224,7 @@ export default function ImportSpecModal({
{t('modals.importSpec.supportedFormats')}
-
+
{error && (
diff --git a/tests/api/test_agent_portability.py b/tests/api/test_agent_portability.py
index 3732339c..2f5197d6 100644
--- a/tests/api/test_agent_portability.py
+++ b/tests/api/test_agent_portability.py
@@ -703,3 +703,122 @@ def test_action_override_for_removed_action_warns(pg_conn, monkeypatch):
assert chat_id["filled_by_llm"] is False and chat_id["value"] == "42"
assert any("telegram_removed_action" in w for w in result["warnings"])
assert any("gone_param" in w for w in result["warnings"])
+
+
+def _fake_api_tool(monkeypatch):
+ """api_tool's live surface: no declared config, no column actions."""
+ fake_tool = Mock()
+ fake_tool.get_config_requirements.return_value = {}
+ fake_tool.get_actions_metadata.return_value = []
+ monkeypatch.setattr(
+ "application.api.user.agents.portability._tool_instance",
+ lambda tool_type: fake_tool,
+ )
+ # The sanitizer SSRF-gates each action URL; DNS isn't available in tests.
+ monkeypatch.setattr(
+ "application.api.user.agents.portability.validate_url", lambda url: url
+ )
+ return fake_tool
+
+
+def _stored_api_tool_config():
+ """``config["actions"]`` as ToolConfig persists it (dict keyed by name)."""
+ return {
+ "actions": {
+ "get_pet": {
+ "name": "get_pet",
+ "url": "https://api.example.com/pets/{petId}?api_key=QSSECRET",
+ "method": "GET",
+ "active": True,
+ "headers": {
+ "properties": {
+ "Authorization": {
+ "type": "string",
+ "filled_by_llm": False,
+ "value": "Bearer sk-live-XYZ",
+ }
+ }
+ },
+ "query_params": {
+ "properties": {
+ "petId": {
+ "type": "string",
+ "filled_by_llm": True,
+ "value": "",
+ }
+ }
+ },
+ }
+ }
+ }
+
+
+def test_api_tool_actions_round_trip_redacted(pg_conn, monkeypatch):
+ """api_tool actions live in config, not the actions column — they must
+ travel (redacted) or the imported tool is dead (executor resolves calls
+ exclusively through ``config["actions"]``)."""
+ user = "u_api_tool"
+ _fake_api_tool(monkeypatch)
+ tool = UserToolsRepository(pg_conn).create(
+ user,
+ "api_tool",
+ config=_stored_api_tool_config(),
+ custom_name="Petstore",
+ display_name="Petstore",
+ description="pets",
+ config_requirements={},
+ actions=[],
+ )
+ agent = AgentsRepository(pg_conn).create(
+ user, "Pet Bot", "published", description="d", tools=[str(tool["id"])]
+ )
+
+ export = serialize_agent(pg_conn, agent, user)
+ text = agent_to_yaml(export)
+ assert "sk-live-XYZ" not in text and "QSSECRET" not in text
+ exported = export["spec"]["tools"][0]["config"]["actions"]["get_pet"]
+ # Shape travels; the URL loses its query string; the pinned header
+ # value is blanked while its flag and schema survive.
+ assert exported["url"] == "https://api.example.com/pets/{petId}"
+ auth = exported["headers"]["properties"]["Authorization"]
+ assert auth["filled_by_llm"] is False and auth["value"] == ""
+ assert exported["query_params"]["properties"]["petId"]["filled_by_llm"] is True
+
+ doc = parse_agent_yaml(text)
+ importer = "u_api_tool_2"
+ result = apply_import(
+ pg_conn,
+ importer,
+ doc,
+ resolution={"tools": {"tool-0": {"decision": "create"}}},
+ )
+ imported_agent = AgentsRepository(pg_conn).get(result["agent_id"], importer)
+ row = UserToolsRepository(pg_conn).get_any(imported_agent["tools"][0], importer)
+ action = row["config"]["actions"]["get_pet"]
+ assert action["url"] == "https://api.example.com/pets/{petId}"
+ assert action["method"] == "GET"
+ # The blanked pinned header is called out — the request would silently
+ # omit it until the importer sets a value in the tool's settings.
+ assert any("Authorization" in w for w in result["warnings"])
+
+
+def test_api_tool_without_actions_imports_with_warning(pg_conn, monkeypatch):
+ """Older exports carried no actions for api_tool; the tool is created but
+ the dead state is surfaced instead of silent."""
+ user = "u_api_tool_empty"
+ _fake_api_tool(monkeypatch)
+ doc = _doc(
+ name="Dead API Bot",
+ _slug="dead-api",
+ tools=[{"type": "api_tool", "name": "Legacy", "config": {}}],
+ )
+ result = apply_import(
+ pg_conn,
+ user,
+ doc,
+ resolution={"tools": {"tool-0": {"decision": "create"}}},
+ )
+ assert any("without any actions" in w for w in result["warnings"])
+ agent = AgentsRepository(pg_conn).get(result["agent_id"], user)
+ row = UserToolsRepository(pg_conn).get_any(agent["tools"][0], user)
+ assert row["config"]["actions"] == {}
diff --git a/tests/api/test_workflow_portability.py b/tests/api/test_workflow_portability.py
index 8741834c..138d8858 100644
--- a/tests/api/test_workflow_portability.py
+++ b/tests/api/test_workflow_portability.py
@@ -131,7 +131,11 @@ def test_export_rewrites_node_refs_and_redacts_secrets(pg_conn):
# The collected sections use the classic serializers' portable identities.
assert spec["tools"][0]["type"] == "brave"
assert spec["tools"][0]["requires_secrets"] == ["token"]
- assert spec["tools"][1] == {"type": "read_document", "builtin": True}
+ assert spec["tools"][1] == {
+ "type": "read_document",
+ "builtin": True,
+ "ref": default_tool_id("read_document"),
+ }
assert spec["sources"][0]["name"] == "KB"
assert spec["model"]["available"][0]["display_name"] == "My Llama"
assert spec["prompt"] == "default"
@@ -378,8 +382,29 @@ def test_import_with_null_workflow_keeps_published_but_clears_draft(pg_conn):
assert str(updated["workflow_id"]) == str(wf["id"])
assert any("kept its existing" in w for w in result["warnings"])
- # Draft agent: explicit null clears the link.
+ # Draft agent: explicit null clears the link AND reaps the now-orphaned
+ # workflow row — there is no workflow-list API independent of agents, so
+ # an unlinked row would be unreachable forever.
AgentsRepository(pg_conn).update(str(agent["id"]), user, {"status": "draft"})
apply_import(pg_conn, user, doc)
updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
assert updated["workflow_id"] is None
+ assert WorkflowsRepository(pg_conn).get(str(wf["id"]), user) is None
+
+
+def test_null_workflow_keeps_row_still_referenced_by_another_agent(pg_conn):
+ user = "u_wf_null_shared"
+ agent, wf = _seed_workflow_agent(pg_conn, user)
+ # A second draft agent points at the same workflow.
+ AgentsRepository(pg_conn).create(
+ user, "Sibling", "draft", agent_type="workflow", workflow_id=str(wf["id"])
+ )
+ doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
+ doc["spec"]["workflow"] = None
+
+ AgentsRepository(pg_conn).update(str(agent["id"]), user, {"status": "draft"})
+ apply_import(pg_conn, user, doc)
+ updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
+ assert updated["workflow_id"] is None
+ # Still referenced — must not be deleted.
+ assert WorkflowsRepository(pg_conn).get(str(wf["id"]), user) is not None
diff --git a/tests/api/user/agents/test_workflow_agents.py b/tests/api/user/agents/test_workflow_agents.py
index 8cf9bc60..99810021 100644
--- a/tests/api/user/agents/test_workflow_agents.py
+++ b/tests/api/user/agents/test_workflow_agents.py
@@ -181,8 +181,12 @@ class TestAdoptWorkflowAgent:
assert {n["node_id"] for n in nodes} == {"start-1", "agent-1", "end-1"}
assert len(edges) == 2
- def test_adopt_survives_unresolvable_workflow(self, app, pg_conn):
- """A template pointing at a graph its owner doesn't own adopts without one."""
+ def test_adopt_refuses_unresolvable_workflow(self, app, pg_conn):
+ """A template pointing at a graph its owner doesn't own cannot adopt.
+
+ Publishing the agent anyway would hand the adopter a workflow agent
+ with no graph — permanently unrunnable — so the adopt fails instead.
+ """
from application.api.user.agents.routes import AdoptAgent
from application.storage.db.repositories.agents import AgentsRepository
@@ -203,17 +207,87 @@ class TestAdoptWorkflowAgent:
request.decoded_token = {"sub": "u-wf-adopter2"}
response = AdoptAgent().post()
- assert response.status_code == 200
- adopted = next(
+ assert response.status_code == 500
+ assert not [
a
for a in repo.list_for_user("u-wf-adopter2")
if a["name"] == "Broken WF Template"
- )
- assert adopted["workflow_id"] is None
+ ]
# The unowned graph was not cloned or mutated.
nodes, edges = _graph(pg_conn, wf["id"])
assert len(nodes) == 3 and len(edges) == 2
+ def test_adopt_strips_foreign_node_refs(self, app, pg_conn):
+ """Cloned agent nodes keep builtin tool ids but shed the owner's refs."""
+ from application.agents.default_tools import default_tool_id
+ from application.api.user.agents.routes import AdoptAgent
+ from application.storage.db.repositories.agents import AgentsRepository
+ from application.storage.db.repositories.user_tools import (
+ UserToolsRepository,
+ )
+ from application.storage.db.repositories.workflow_nodes import (
+ WorkflowNodesRepository,
+ )
+ from application.storage.db.repositories.workflows import WorkflowsRepository
+
+ owner_tool = UserToolsRepository(pg_conn).create(
+ "__system__", "brave", config={}, display_name="Brave"
+ )
+ builtin_id = default_tool_id("artifact_generator")
+ wf = WorkflowsRepository(pg_conn).create("__system__", "Reffy WF")
+ WorkflowNodesRepository(pg_conn).bulk_create(
+ str(wf["id"]),
+ 1,
+ [
+ {"node_id": "start-1", "node_type": "start", "title": "Start"},
+ {
+ "node_id": "agent-1",
+ "node_type": "agent",
+ "title": "Agent",
+ "config": {
+ "tools": [builtin_id, str(owner_tool["id"])],
+ "sources": ["00000000-0000-0000-0000-00000000dead"],
+ "model_id": "00000000-0000-0000-0000-00000000beef",
+ },
+ },
+ ],
+ )
+ repo = AgentsRepository(pg_conn)
+ template = repo.create(
+ "__system__",
+ "Reffy Template",
+ "template",
+ agent_type="workflow",
+ workflow_id=str(wf["id"]),
+ tools=[builtin_id, str(owner_tool["id"])],
+ )
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/adopt_agent?id={template['id']}", method="POST"
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "u-wf-adopter3"}
+ response = AdoptAgent().post()
+ assert response.status_code == 200
+
+ adopted = next(
+ a
+ for a in repo.list_for_user("u-wf-adopter3")
+ if a["name"] == "Reffy Template"
+ )
+ # Classic-column tools: the builtin survives, the foreign row doesn't.
+ assert adopted["tools"] == [builtin_id]
+ nodes, _ = _graph(pg_conn, adopted["workflow_id"])
+ agent_node = next(n for n in nodes if n["node_id"] == "agent-1")
+ assert agent_node["config"]["tools"] == [builtin_id]
+ assert agent_node["config"]["sources"] == []
+ assert agent_node["config"]["model_id"] is None
+ # The template's own graph is untouched.
+ src_nodes, _ = _graph(pg_conn, wf["id"])
+ src_agent = next(n for n in src_nodes if n["node_id"] == "agent-1")
+ assert str(owner_tool["id"]) in src_agent["config"]["tools"]
+
class TestDeleteWorkflowAgent:
def test_delete_removes_owned_workflow(self, app, pg_conn):
diff --git a/tests/storage/db/repositories/test_workflow_edges.py b/tests/storage/db/repositories/test_workflow_edges.py
index 48ec34b5..3c14c358 100644
--- a/tests/storage/db/repositories/test_workflow_edges.py
+++ b/tests/storage/db/repositories/test_workflow_edges.py
@@ -96,14 +96,6 @@ class TestFindByVersion:
class TestDelete:
- def test_delete_by_workflow(self, pg_conn):
- wf, n1, n2 = _setup(pg_conn)
- repo = _repo(pg_conn)
- repo.create(wf["id"], 1, "e1", n1["id"], n2["id"])
- deleted = repo.delete_by_workflow(wf["id"])
- assert deleted == 1
- assert repo.find_by_version(wf["id"], 1) == []
-
def test_delete_by_version(self, pg_conn):
wf = WorkflowsRepository(pg_conn).create("user-1", "wf")
node_repo = WorkflowNodesRepository(pg_conn)
diff --git a/tests/storage/db/repositories/test_workflow_nodes.py b/tests/storage/db/repositories/test_workflow_nodes.py
index 3a2be6d6..c3f131ec 100644
--- a/tests/storage/db/repositories/test_workflow_nodes.py
+++ b/tests/storage/db/repositories/test_workflow_nodes.py
@@ -161,17 +161,6 @@ class TestBulkCreateOnConflict:
class TestDelete:
- def test_delete_by_workflow(self, pg_conn):
- wf = _wf(pg_conn)
- repo = _repo(pg_conn)
- repo.bulk_create(wf["id"], 1, [
- {"node_id": "n1", "node_type": "start"},
- {"node_id": "n2", "node_type": "end"},
- ])
- deleted = repo.delete_by_workflow(wf["id"])
- assert deleted == 2
- assert repo.find_by_version(wf["id"], 1) == []
-
def test_delete_by_version(self, pg_conn):
wf = _wf(pg_conn)
repo = _repo(pg_conn)
From b47bfa8a37e9e76280a4f2b4c72c7cd21fec437a Mon Sep 17 00:00:00 2001
From: Alex
Date: Sun, 23 Aug 2026 11:22:12 +0100
Subject: [PATCH 6/6] fix: mini hardening
---
application/api/user/agents/portability.py | 131 ++++++++++++++++-----
frontend/src/agents/AgentCard.tsx | 35 +++++-
frontend/src/locale/de.json | 2 +
frontend/src/locale/en.json | 2 +
frontend/src/locale/es.json | 2 +
frontend/src/locale/jp.json | 2 +
frontend/src/locale/ru.json | 2 +
frontend/src/locale/zh-TW.json | 2 +
frontend/src/locale/zh.json | 2 +
frontend/src/modals/ImportAgentModal.tsx | 79 ++++++++-----
frontend/src/modals/ImportSpecModal.tsx | 43 ++++---
tests/api/test_workflow_portability.py | 125 ++++++++++++++++++++
12 files changed, 353 insertions(+), 74 deletions(-)
diff --git a/application/api/user/agents/portability.py b/application/api/user/agents/portability.py
index 2bd2f563..00b5888d 100644
--- a/application/api/user/agents/portability.py
+++ b/application/api/user/agents/portability.py
@@ -888,6 +888,38 @@ def _tool_key(index: int) -> str:
# ---------------------------------------------------------------------------
+def _plan_workflow_removal(conn, user: str, target: dict) -> Optional[dict]:
+ """Describe what an explicit ``workflow: null`` would destroy, if anything.
+
+ Returns a ``delete`` block sized with the current graph, or None when the
+ workflow survives — the target is new, published (``_apply_workflow``
+ keeps a published agent's graph), has no workflow, or another agent still
+ references it.
+ """
+ if target.get("action") != "update" or not target.get("agent_id"):
+ return None
+ if (target.get("status") or "") == "published":
+ return None
+ agent_row = AgentsRepository(conn).get(str(target["agent_id"]), user) or {}
+ existing_id = agent_row.get("workflow_id")
+ if not existing_id:
+ return None
+ row = WorkflowsRepository(conn).get(str(existing_id), user)
+ if row is None:
+ return None
+ if AgentsRepository(conn).count_by_workflow(str(existing_id), user) > 1:
+ return None
+ # Lazy import, same as _apply_workflow — keeps route modules decoupled at load.
+ from application.api.user.workflows.routes import get_workflow_graph_version
+
+ version = get_workflow_graph_version(row)
+ return {
+ "action": "delete",
+ "nodes": len(WorkflowNodesRepository(conn).find_by_version(str(row["id"]), version)),
+ "edges": len(WorkflowEdgesRepository(conn).find_by_version(str(row["id"]), version)),
+ }
+
+
def plan_import(conn, user: str, doc: dict) -> dict:
"""Resolve every reference without writing; returns a resolution report."""
spec = doc["spec"]
@@ -978,18 +1010,25 @@ def plan_import(conn, user: str, doc: dict) -> dict:
workflow_plan = None
wf_spec = spec.get("workflow")
- if spec.get("agent_type") == "workflow" and isinstance(wf_spec, dict):
- action = "create"
- if target.get("action") == "update" and target.get("agent_id"):
- agent_row = AgentsRepository(conn).get(str(target["agent_id"]), user)
- existing_wf = (agent_row or {}).get("workflow_id")
- if existing_wf and WorkflowsRepository(conn).get(str(existing_wf), user):
- action = "update"
- workflow_plan = {
- "nodes": len(wf_spec.get("nodes") or []),
- "edges": len(wf_spec.get("edges") or []),
- "action": action,
- }
+ if spec.get("agent_type") == "workflow":
+ if isinstance(wf_spec, dict):
+ action = "create"
+ if target.get("action") == "update" and target.get("agent_id"):
+ agent_row = AgentsRepository(conn).get(str(target["agent_id"]), user)
+ existing_wf = (agent_row or {}).get("workflow_id")
+ if existing_wf and WorkflowsRepository(conn).get(str(existing_wf), user):
+ action = "update"
+ workflow_plan = {
+ "nodes": len(wf_spec.get("nodes") or []),
+ "edges": len(wf_spec.get("edges") or []),
+ "action": action,
+ }
+ elif "workflow" in spec:
+ # Explicit ``workflow: null`` (vs. an omitted key, which changes
+ # nothing). Apply clears the link and reaps the row when no other
+ # agent references it, destroying the graph, its run history and
+ # its artifacts — the dry-run has to say so before the user commits.
+ workflow_plan = _plan_workflow_removal(conn, user, target)
return {
"target": target,
@@ -1029,7 +1068,19 @@ def _apply_sources(conn, user: str, spec: dict, resolution: dict, warnings: list
mapping = {}
resolved: list[str] = []
id_by_name: dict[str, Optional[str]] = {}
- for src in spec.get("sources") or []:
+ entries = [src for src in spec.get("sources") or [] if isinstance(src, dict)]
+ # ``id_by_name`` (and a workflow node's source reference) keys on the name
+ # alone, but names aren't unique per user — ``find_by_name`` returns the
+ # oldest match, so duplicates collapse onto one source. Same hazard the
+ # custom-model display names carry below; surface it rather than silently
+ # rewiring a node to a different source.
+ names = [src.get("name") or "" for src in entries]
+ for dup in sorted({n for n in names if n and names.count(n) > 1}):
+ warnings.append(
+ f"Multiple sources share the name '{dup}'; references to it all "
+ "resolve to the oldest one"
+ )
+ for src in entries:
name = src.get("name") or ""
id_by_name.setdefault(name, None)
mapped = mapping.get(name)
@@ -1338,7 +1389,9 @@ def _rewrite_node_refs(
resolved_sources.append(ref)
else:
warnings.append(f"Workflow node source '{ref}' not resolved; removed")
- cfg["sources"] = resolved_sources
+ # Distinct names can collapse onto one source (see ``_apply_sources``), so
+ # dedupe rather than storing the same id twice on the node.
+ cfg["sources"] = list(dict.fromkeys(resolved_sources))
raw_model = cfg.get("model_id")
if raw_model:
@@ -1536,6 +1589,12 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
"extra_source_ids": [],
}
)
+ prior_workflow_id = None
+ if is_update:
+ prior_workflow_id = (
+ agents_repo.get(str(target["agent_id"]), user) or {}
+ ).get("workflow_id")
+ published = (target.get("status") or "") == "published"
workflow_result = _apply_workflow(
conn,
user,
@@ -1546,21 +1605,31 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
model_ids_by_name,
warnings,
)
- if workflow_result is not _WORKFLOW_UNCHANGED:
- if workflow_result is None and (target.get("status") or "") == "published":
- warnings.append(
- "File has no workflow; the published agent kept its existing one"
+ # A published workflow agent with no graph can't run — the create and
+ # update routes both reject that state, so import must not install it
+ # through the back door (e.g. a file that flips ``agent_type`` to
+ # workflow over a published classic agent).
+ if workflow_result is _WORKFLOW_UNCHANGED:
+ if published and not prior_workflow_id:
+ raise AgentImportError(
+ "A published workflow agent needs a workflow; this file has none"
)
- else:
- authoritative["workflow_id"] = workflow_result
- if workflow_result is None and is_update:
- # Explicit ``workflow: null`` on a draft: remember the old
- # link so the row can be reaped after the update — there is
- # no workflow-list API independent of agents, so an
- # unlinked workflow would be unreachable forever.
- prior = agents_repo.get(str(target["agent_id"]), user) or {}
- if prior.get("workflow_id"):
- orphaned_workflow_id = str(prior["workflow_id"])
+ elif workflow_result is None and published:
+ if not prior_workflow_id:
+ raise AgentImportError(
+ "A published workflow agent needs a workflow; this file has none"
+ )
+ warnings.append(
+ "File has no workflow; the published agent kept its existing one"
+ )
+ else:
+ authoritative["workflow_id"] = workflow_result
+ if workflow_result is None and prior_workflow_id:
+ # Explicit ``workflow: null`` on a draft: remember the old
+ # link so the row can be reaped after the update — there is
+ # no workflow-list API independent of agents, so an
+ # unlinked workflow would be unreachable forever.
+ orphaned_workflow_id = str(prior_workflow_id)
# Optional fields — applied only when present so a partial file doesn't wipe them.
optional = {
@@ -1583,6 +1652,12 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None)
# Nothing references it anymore; delete (cascades nodes/edges
# and reaps run artifacts) rather than stranding the row.
WorkflowsRepository(conn).delete(orphaned_workflow_id, user)
+ # Destructive and irreversible — the graph, its run history and
+ # its artifacts (rows and stored bytes) all go. Say so.
+ warnings.append(
+ "File has no workflow; the agent's workflow was deleted "
+ "along with its run history and artifacts"
+ )
return {
"agent_id": str(target["agent_id"]),
"action": "updated",
diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx
index 1e5ae660..64ae3dc3 100644
--- a/frontend/src/agents/AgentCard.tsx
+++ b/frontend/src/agents/AgentCard.tsx
@@ -17,12 +17,14 @@ import Trash from '../assets/red-trash.svg';
import ThreeDots from '../assets/three-dots.svg';
import UnPin from '../assets/unpin.svg';
import { Avatar } from '../components/ui/avatar';
+import { Button } from '../components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../components/ui/dropdown-menu';
+import { Modal } from '../components/ui/modal';
import ConfirmationModal from '../modals/ConfirmationModal';
import MoveToFolderModal from '../modals/MoveToFolderModal';
import { ActiveState } from '../models/misc';
@@ -67,6 +69,7 @@ export default function AgentCard({
useState('INACTIVE');
const [moveModalState, setMoveModalState] = useState('INACTIVE');
const [shareModalOpen, setShareModalOpen] = useState(false);
+ const [exportError, setExportError] = useState(null);
const menuOptionsConfig: Record = {
template: [
@@ -302,7 +305,12 @@ export default function AgentCard({
.json()
.then((data) => data?.message)
.catch(() => null);
- throw new Error(message || t('agents.exportAgentFailed'));
+ // Server-side refusals (e.g. a workflow referencing too many
+ // resources) carry an actionable message; flag it so the catch can
+ // tell it apart from a transport error like "Failed to fetch".
+ const error = new Error(message || t('agents.exportAgentFailed'));
+ error.name = 'ExportRefused';
+ throw error;
}
const yamlText = await response.text();
const blob = new Blob([yamlText], { type: 'application/x-yaml' });
@@ -316,8 +324,10 @@ export default function AgentCard({
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error:', error);
- alert(
- error instanceof Error ? error.message : t('agents.exportAgentFailed'),
+ setExportError(
+ error instanceof Error && error.name === 'ExportRefused'
+ ? error.message
+ : t('agents.exportAgentFailed'),
);
}
};
@@ -470,6 +480,25 @@ export default function AgentCard({
cancelLabel="Cancel"
variant="danger"
/>
+ {
+ if (!open) setExportError(null);
+ }}
+ title={t('agents.exportAgentFailed')}
+ size="sm"
+ footer={
+ setExportError(null)}
+ className="rounded-3xl px-5"
+ >
+ {t('agents.close')}
+
+ }
+ >
+ {exportError}
+
{
- if (acceptedFiles[0]) processFile(acceptedFiles[0]);
- },
- multiple: false,
- // Declared here (not via getInputProps) so drag-and-drop is filtered
- // too, with drag-over rejection feedback; processFile's extension check
- // stays as a backstop for odd MIME reports.
- accept: {
- 'application/x-yaml': ['.yaml', '.yml'],
- 'text/yaml': ['.yaml', '.yml'],
- },
- });
+ const { getRootProps, getInputProps, isDragActive, isDragReject } =
+ useDropzone({
+ onDrop: (acceptedFiles: File[], fileRejections: FileRejection[]) => {
+ // A rejected file never reaches acceptedFiles, so without this the
+ // drop is a silent no-op and any previously picked file stays staged.
+ if (fileRejections.length > 0) {
+ setFileName('');
+ setYamlText('');
+ setError(t('modals.importAgent.invalidFileType'));
+ return;
+ }
+ if (acceptedFiles[0]) processFile(acceptedFiles[0]);
+ },
+ multiple: false,
+ // Declared here (not via getInputProps) so drag-and-drop is filtered
+ // too; processFile's extension check stays as a backstop for odd MIME
+ // reports.
+ accept: {
+ 'application/x-yaml': ['.yaml', '.yml'],
+ 'text/yaml': ['.yaml', '.yml'],
+ },
+ });
const handleAnalyze = async () => {
if (!yamlText) return;
@@ -320,7 +329,11 @@ export default function ImportAgentModal({
@@ -346,18 +359,28 @@ export default function ImportAgentModal({
: t('modals.importAgent.willCreate')}
- {plan.workflow && (
-
-
- {plan.workflow.action === 'update'
- ? t('modals.importAgent.workflowUpdate', {
- nodes: plan.workflow.nodes,
- })
- : t('modals.importAgent.workflowCreate', {
- nodes: plan.workflow.nodes,
- })}
-
- )}
+ {plan.workflow &&
+ (plan.workflow.action === 'delete' ? (
+ // Irreversible: the graph, its run history and its artifacts
+ // all go. Warn rather than confirming with a green check.
+
+
+ {t('modals.importAgent.workflowDelete', {
+ nodes: plan.workflow.nodes,
+ })}
+
+ ) : (
+
+
+ {plan.workflow.action === 'update'
+ ? t('modals.importAgent.workflowUpdate', {
+ nodes: plan.workflow.nodes,
+ })
+ : t('modals.importAgent.workflowCreate', {
+ nodes: plan.workflow.nodes,
+ })}
+
+ ))}
{plan.sources.length > 0 && (
diff --git a/frontend/src/modals/ImportSpecModal.tsx b/frontend/src/modals/ImportSpecModal.tsx
index 2f636087..a5bee57b 100644
--- a/frontend/src/modals/ImportSpecModal.tsx
+++ b/frontend/src/modals/ImportSpecModal.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { useDropzone } from 'react-dropzone';
+import { type FileRejection, useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -73,19 +73,28 @@ export default function ImportSpecModal({
setParsedResult(null);
};
- const { getRootProps, getInputProps, isDragActive } = useDropzone({
- onDrop: (acceptedFiles: File[]) => {
- if (acceptedFiles[0]) processFile(acceptedFiles[0]);
- },
- multiple: false,
- // Declared here (not via getInputProps) so drag-and-drop is filtered
- // too; processFile's extension check stays as a backstop.
- accept: {
- 'application/json': ['.json'],
- 'application/x-yaml': ['.yaml', '.yml'],
- 'text/yaml': ['.yaml', '.yml'],
- },
- });
+ const { getRootProps, getInputProps, isDragActive, isDragReject } =
+ useDropzone({
+ onDrop: (acceptedFiles: File[], fileRejections: FileRejection[]) => {
+ // A rejected file never reaches acceptedFiles, so without this the
+ // drop is a silent no-op and any previously picked file stays staged.
+ if (fileRejections.length > 0) {
+ setFile(null);
+ setParsedResult(null);
+ setError(t('modals.importSpec.invalidFileType'));
+ return;
+ }
+ if (acceptedFiles[0]) processFile(acceptedFiles[0]);
+ },
+ multiple: false,
+ // Declared here (not via getInputProps) so drag-and-drop is filtered
+ // too; processFile's extension check stays as a backstop.
+ accept: {
+ 'application/json': ['.json'],
+ 'application/x-yaml': ['.yaml', '.yml'],
+ 'text/yaml': ['.yaml', '.yml'],
+ },
+ });
const handleParse = async () => {
if (!file) return;
@@ -209,7 +218,11 @@ export default function ImportSpecModal({
diff --git a/tests/api/test_workflow_portability.py b/tests/api/test_workflow_portability.py
index 138d8858..68875846 100644
--- a/tests/api/test_workflow_portability.py
+++ b/tests/api/test_workflow_portability.py
@@ -408,3 +408,128 @@ def test_null_workflow_keeps_row_still_referenced_by_another_agent(pg_conn):
assert updated["workflow_id"] is None
# Still referenced — must not be deleted.
assert WorkflowsRepository(pg_conn).get(str(wf["id"]), user) is not None
+
+
+def test_publish_guard_rejects_workflow_agent_with_no_graph(pg_conn):
+ """A file that makes a published agent a graph-less workflow agent is refused.
+
+ The create and update routes both reject that state; import must not
+ install it through the back door.
+ """
+ user = "u_wf_publish_guard"
+ agent = AgentsRepository(pg_conn).create(
+ user, "Probe Bot", "published", agent_type="classic", slug="probe-bot"
+ )
+ doc = {
+ "apiVersion": "docsgpt.arc53.com/v1",
+ "kind": "Agent",
+ "metadata": {"id": str(agent["id"]), "slug": "probe-bot"},
+ "spec": {"name": "Probe Bot", "agent_type": "workflow"},
+ }
+
+ # Key omitted entirely.
+ with pytest.raises(AgentImportError, match="published workflow agent needs a workflow"):
+ apply_import(pg_conn, user, doc)
+
+ # Explicit null, with no prior workflow to fall back on.
+ doc["spec"]["workflow"] = None
+ with pytest.raises(AgentImportError, match="published workflow agent needs a workflow"):
+ apply_import(pg_conn, user, doc)
+
+ unchanged = AgentsRepository(pg_conn).get(str(agent["id"]), user)
+ assert unchanged["agent_type"] == "classic"
+ assert unchanged["status"] == "published"
+
+
+def test_plan_distinguishes_absent_workflow_from_explicit_null(pg_conn):
+ """Omitting the key is a no-op; an explicit null destroys the graph.
+
+ The two used to produce an identical plan, so the modal showed nothing
+ either way while the outcomes differed completely.
+ """
+ user = "u_wf_plan_null"
+ agent, wf = _seed_workflow_agent(pg_conn, user)
+ AgentsRepository(pg_conn).update(str(agent["id"]), user, {"status": "draft"})
+ doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
+
+ absent = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
+ del absent["spec"]["workflow"]
+ assert plan_import(pg_conn, user, absent)["workflow"] is None
+
+ doc["spec"]["workflow"] = None
+ assert plan_import(pg_conn, user, doc)["workflow"] == {
+ "action": "delete",
+ "nodes": 3,
+ "edges": 2,
+ }
+
+
+def test_plan_reports_no_removal_when_workflow_survives(pg_conn):
+ """No delete block when the row lives on — published, or still referenced."""
+ user = "u_wf_plan_keep"
+ agent, wf = _seed_workflow_agent(pg_conn, user)
+ doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
+ doc["spec"]["workflow"] = None
+
+ # Published: _apply_workflow keeps the existing graph.
+ assert plan_import(pg_conn, user, doc)["workflow"] is None
+
+ # Draft, but a sibling agent still references the row.
+ AgentsRepository(pg_conn).update(str(agent["id"]), user, {"status": "draft"})
+ AgentsRepository(pg_conn).create(
+ user, "Sibling", "draft", agent_type="workflow", workflow_id=str(wf["id"])
+ )
+ assert plan_import(pg_conn, user, doc)["workflow"] is None
+
+
+def test_null_workflow_deletion_is_warned_about(pg_conn):
+ """The destructive reap must not be silent."""
+ user = "u_wf_null_warn"
+ agent, wf = _seed_workflow_agent(pg_conn, user)
+ doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
+ doc["spec"]["workflow"] = None
+ AgentsRepository(pg_conn).update(str(agent["id"]), user, {"status": "draft"})
+
+ result = apply_import(pg_conn, user, doc)
+ assert WorkflowsRepository(pg_conn).get(str(wf["id"]), user) is None
+ assert any("was deleted" in w and "run history" in w for w in result["warnings"])
+
+
+def test_duplicate_source_names_warn_and_dedupe(pg_conn):
+ """Two sources sharing a name collapse onto the oldest — say so."""
+ user = "u_wf_dup_src"
+ repo = SourcesRepository(pg_conn)
+ older = repo.create("KB", user_id=user, type="file")
+ newer = repo.create("KB", user_id=user, type="file")
+ agent, _wf = _seed_workflow_agent(pg_conn, user, source_id=str(older["id"]))
+
+ # Point the node at both same-named sources.
+ version = 1
+ nodes_repo = WorkflowNodesRepository(pg_conn)
+ node = [n for n in nodes_repo.find_by_version(str(agent["workflow_id"]), version)
+ if n["node_type"] == "agent"][0]
+ config = dict(node["config"])
+ config["sources"] = [str(older["id"]), str(newer["id"])]
+ nodes_repo.bulk_create(
+ str(agent["workflow_id"]),
+ version,
+ [{
+ "node_id": node["node_id"],
+ "node_type": node["node_type"],
+ "title": node.get("title"),
+ "position": node.get("position"),
+ "config": config,
+ }],
+ )
+
+ doc = parse_agent_yaml(agent_to_yaml(serialize_agent(pg_conn, agent, user)))
+ assert [s["name"] for s in doc["spec"]["sources"]] == ["KB", "KB"]
+
+ result = apply_import(pg_conn, user, doc)
+ assert any("share the name 'KB'" in w for w in result["warnings"])
+
+ updated = AgentsRepository(pg_conn).get(str(agent["id"]), user)
+ graph_nodes, _ = _graph(pg_conn, WorkflowsRepository(pg_conn).get(str(updated["workflow_id"]), user))
+ agent_node = [n for n in graph_nodes if n["node_type"] == "agent"][0]
+ # Collapsed onto the oldest match, and not stored twice.
+ assert agent_node["config"]["sources"] == [str(older["id"])]