From ee4abf1038a84b6a4de87dea5a75771ad4ed6654 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 14 Jun 2026 14:44:27 +0100 Subject: [PATCH] fix: keep agent published on update --- application/api/user/agents/portability.py | 38 ++++++++++++++++++---- 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 | 7 +++- tests/api/test_agent_portability.py | 35 +++++++++++++++++++- 10 files changed, 86 insertions(+), 15 deletions(-) diff --git a/application/api/user/agents/portability.py b/application/api/user/agents/portability.py index 00586893..498f6cd2 100644 --- a/application/api/user/agents/portability.py +++ b/application/api/user/agents/portability.py @@ -366,13 +366,23 @@ def _resolve_target(conn, user: str, metadata: dict) -> dict: if agent_id and looks_like_uuid(str(agent_id)): row = repo.get(str(agent_id), user) if row: - return {"action": "update", "agent_id": str(row["id"]), "matched_by": "id"} + return { + "action": "update", + "agent_id": str(row["id"]), + "matched_by": "id", + "status": row.get("status"), + } slug = metadata.get("slug") if slug: row = repo.find_by_slug(user, str(slug)) if row: - return {"action": "update", "agent_id": str(row["id"]), "matched_by": "slug"} - return {"action": "create", "agent_id": None, "matched_by": None} + return { + "action": "update", + "agent_id": str(row["id"]), + "matched_by": "slug", + "status": row.get("status"), + } + return {"action": "create", "agent_id": None, "matched_by": None, "status": None} def _find_user_tool(user_tools: list, tool_type: str, custom_name: str): @@ -728,7 +738,12 @@ def _limit(spec: dict, key: str): def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None) -> dict: - """Create or update an agent from a parsed YAML doc. Always lands as draft. + """Create or update an agent from a parsed YAML doc. + + A newly created agent lands as a draft. An update to an existing agent + (matched by id or slug) preserves that agent's current status, so + re-importing over a published agent keeps it live — its API key and any + active users are unaffected — rather than silently reverting it to draft. On update the YAML is authoritative: fields it specifies are written even when that clears a value (e.g. removing all models, dropping ``json_schema``, @@ -791,18 +806,29 @@ def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None) optional = {k: v for k, v in optional.items() if v is not None} if is_update: - fields = {**authoritative, **optional, "name": spec.get("name"), "status": "draft"} + # Status is intentionally omitted so an update never changes it: + # re-importing over a published agent keeps it live (its API key and + # any active users are unaffected) instead of reverting it to draft. + # 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): return { "agent_id": str(target["agent_id"]), "action": "updated", + "status": target.get("status") or "draft", "slug": slug, "warnings": warnings, } # Row vanished between resolve and write — fall through to create. row = agents_repo.create(user, spec.get("name"), "draft", **{**authoritative, **optional}) - return {"agent_id": str(row["id"]), "action": "created", "slug": slug, "warnings": warnings} + return { + "agent_id": str(row["id"]), + "action": "created", + "status": "draft", + "slug": slug, + "warnings": warnings, + } # --------------------------------------------------------------------------- diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index dd3116be..9dbd8d2a 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -563,7 +563,7 @@ }, "importAgent": { "title": "Import agent from YAML", - "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.", + "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.", "dropzoneText": "Click to upload or drag and drop a .yaml file", "invalidFileType": "Only .yaml or .yml files are supported", "readError": "Could not read this agent file", @@ -584,6 +584,7 @@ "modelCustom": "{{name}} — custom model", "apiKeyPlaceholder": "API key (leave blank to skip this model)", "warningsTitle": "Agent imported as a draft. Some items need your attention:", + "warningsTitlePublished": "Your live agent was updated. Some items need your attention:", "cancel": "Cancel", "review": "Review", "import": "Import", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 914b40cb..8b2c1fa3 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -715,7 +715,7 @@ }, "importAgent": { "title": "Import agent from YAML", - "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.", + "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.", "dropzoneText": "Click to upload or drag and drop a .yaml file", "invalidFileType": "Only .yaml or .yml files are supported", "readError": "Could not read this agent file", @@ -736,6 +736,7 @@ "modelCustom": "{{name}} — custom model", "apiKeyPlaceholder": "API key (leave blank to skip this model)", "warningsTitle": "Agent imported as a draft. Some items need your attention:", + "warningsTitlePublished": "Your live agent was updated. Some items need your attention:", "cancel": "Cancel", "review": "Review", "import": "Import", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 389e1d8f..9df17de0 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -581,7 +581,7 @@ }, "importAgent": { "title": "Import agent from YAML", - "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.", + "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.", "dropzoneText": "Click to upload or drag and drop a .yaml file", "invalidFileType": "Only .yaml or .yml files are supported", "readError": "Could not read this agent file", @@ -602,6 +602,7 @@ "modelCustom": "{{name}} — custom model", "apiKeyPlaceholder": "API key (leave blank to skip this model)", "warningsTitle": "Agent imported as a draft. Some items need your attention:", + "warningsTitlePublished": "Your live agent was updated. Some items need your attention:", "cancel": "Cancel", "review": "Review", "import": "Import", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 5cd831b0..c1eaec51 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -581,7 +581,7 @@ }, "importAgent": { "title": "Import agent from YAML", - "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.", + "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.", "dropzoneText": "Click to upload or drag and drop a .yaml file", "invalidFileType": "Only .yaml or .yml files are supported", "readError": "Could not read this agent file", @@ -602,6 +602,7 @@ "modelCustom": "{{name}} — custom model", "apiKeyPlaceholder": "API key (leave blank to skip this model)", "warningsTitle": "Agent imported as a draft. Some items need your attention:", + "warningsTitlePublished": "Your live agent was updated. Some items need your attention:", "cancel": "Cancel", "review": "Review", "import": "Import", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 8260cb00..e31bb2bf 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -581,7 +581,7 @@ }, "importAgent": { "title": "Import agent from YAML", - "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.", + "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.", "dropzoneText": "Click to upload or drag and drop a .yaml file", "invalidFileType": "Only .yaml or .yml files are supported", "readError": "Could not read this agent file", @@ -602,6 +602,7 @@ "modelCustom": "{{name}} — custom model", "apiKeyPlaceholder": "API key (leave blank to skip this model)", "warningsTitle": "Agent imported as a draft. Some items need your attention:", + "warningsTitlePublished": "Your live agent was updated. Some items need your attention:", "cancel": "Cancel", "review": "Review", "import": "Import", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index eb18f217..c2d96e93 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -581,7 +581,7 @@ }, "importAgent": { "title": "Import agent from YAML", - "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.", + "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.", "dropzoneText": "Click to upload or drag and drop a .yaml file", "invalidFileType": "Only .yaml or .yml files are supported", "readError": "Could not read this agent file", @@ -602,6 +602,7 @@ "modelCustom": "{{name}} — custom model", "apiKeyPlaceholder": "API key (leave blank to skip this model)", "warningsTitle": "Agent imported as a draft. Some items need your attention:", + "warningsTitlePublished": "Your live agent was updated. Some items need your attention:", "cancel": "Cancel", "review": "Review", "import": "Import", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index a10d0b66..4217c7e3 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -581,7 +581,7 @@ }, "importAgent": { "title": "Import agent from YAML", - "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. The agent is always imported as a draft.", + "description": "Upload an agent YAML file. Sources and tools are matched to your account; anything missing can be mapped or created in the next step. A new agent is imported as a draft; re-importing updates the matching agent and keeps its current status.", "dropzoneText": "Click to upload or drag and drop a .yaml file", "invalidFileType": "Only .yaml or .yml files are supported", "readError": "Could not read this agent file", @@ -602,6 +602,7 @@ "modelCustom": "{{name}} — custom model", "apiKeyPlaceholder": "API key (leave blank to skip this model)", "warningsTitle": "Agent imported as a draft. Some items need your attention:", + "warningsTitlePublished": "Your live agent was updated. Some items need your attention:", "cancel": "Cancel", "review": "Review", "import": "Import", diff --git a/frontend/src/modals/ImportAgentModal.tsx b/frontend/src/modals/ImportAgentModal.tsx index bf008963..579c7f90 100644 --- a/frontend/src/modals/ImportAgentModal.tsx +++ b/frontend/src/modals/ImportAgentModal.tsx @@ -84,6 +84,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 reset = () => { @@ -97,6 +98,7 @@ export default function ImportAgentModal({ setToolSecrets({}); setModelKeys({}); setWarnings(null); + setImportedStatus(null); setGoToAgentId(null); }; @@ -189,6 +191,7 @@ export default function ImportAgentModal({ // Keep the modal open so the user sees what was skipped. setGoToAgentId(agentId); setWarnings(data.warnings as string[]); + setImportedStatus((data.status as string) || null); setPlan(null); return; } @@ -278,7 +281,9 @@ export default function ImportAgentModal({ {warnings ? (

- {t('modals.importAgent.warningsTitle')} + {importedStatus === 'published' + ? t('modals.importAgent.warningsTitlePublished') + : t('modals.importAgent.warningsTitle')}

    {warnings.map((w, i) => ( diff --git a/tests/api/test_agent_portability.py b/tests/api/test_agent_portability.py index 3c866abc..8ef90167 100644 --- a/tests/api/test_agent_portability.py +++ b/tests/api/test_agent_portability.py @@ -126,11 +126,43 @@ def test_round_trip_same_user_idempotent(pg_conn): assert r1["agent_id"] == r2["agent_id"] == str(agent["id"]) # matched by id assert len(PromptsRepository(pg_conn).list_for_user(user)) == 1 # no dup prompt imported = AgentsRepository(pg_conn).get(r1["agent_id"], user) - assert imported["status"] == "draft" + assert imported["status"] == "published" # update preserves the live status assert [str(s) for s in imported["extra_source_ids"]] == [str(src["id"])] assert imported["tools"] == [scheduler_id] # builtin passthrough +def test_update_preserves_published_status(pg_conn): + """Re-importing over a published agent must not revert it to draft. + + The agent's API key and any active users keep working; only the content + is synced. A new agent (no match) still lands as a draft. + """ + user = "u_pub" + agent = AgentsRepository(pg_conn).create( + user, + "Live Bot", + "published", + description="old", + chunks=2, + retriever="classic", + ) + slug = ensure_agent_slug(pg_conn, agent, user) + doc = { + "apiVersion": API_VERSION, + "kind": "Agent", + "metadata": {"id": str(agent["id"]), "slug": slug}, + "spec": {"name": "Live Bot", "description": "new", "retriever": "classic"}, + } + + result = apply_import(pg_conn, user, doc) + + assert result["action"] == "updated" + assert result["status"] == "published" # response reports the preserved status + updated = AgentsRepository(pg_conn).get(str(agent["id"]), user) + assert updated["status"] == "published" # stayed live + assert updated["description"] == "new" # content still synced + + def test_import_by_slug_idempotent(pg_conn): user = "u_slug_import" doc = _doc(name="Slug Bot", _slug="slug-bot", retriever="classic") @@ -154,6 +186,7 @@ def test_import_missing_source_drafts_and_warns(pg_conn): result = apply_import(pg_conn, user, doc) + assert result["status"] == "draft" # new agents are created as drafts assert any("Nonexistent KB" in w for w in result["warnings"]) agent = AgentsRepository(pg_conn).get(result["agent_id"], user) assert agent["status"] == "draft"