fix: support agent duplicate role and skill file preview (#37788)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-06-23 14:35:26 +08:00 committed by GitHub
parent 7fc8eed716
commit a3309cd857
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 96 additions and 18 deletions

View File

@ -14,7 +14,6 @@ from controllers.console.app.app import (
)
from controllers.console.app.app import (
AppListQuery,
CopyAppPayload,
_normalize_app_list_query_args,
)
from controllers.console.app.app import (
@ -110,6 +109,25 @@ class AgentAppUpdatePayload(GenericUpdateAppPayload):
return role
class AgentAppCopyPayload(BaseModel):
name: str | None = Field(default=None, description="Name for the copied agent")
description: str | None = Field(default=None, description="Description for the copied agent", max_length=400)
role: str | None = Field(default=None, description="Role for the copied agent", max_length=255)
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("role")
@classmethod
def validate_role(cls, value: str | None) -> str | None:
if value is None:
return None
role = value.strip()
if not role:
raise ValueError("Agent role is required when provided.")
return role
class AgentApiStatusPayload(BaseModel):
enable_api: bool = Field(..., description="Enable or disable Agent service API")
@ -242,8 +260,8 @@ register_schema_models(
console_ns,
AgentAppCreatePayload,
AgentAppUpdatePayload,
AgentAppCopyPayload,
AgentApiStatusPayload,
CopyAppPayload,
AgentInviteOptionsQuery,
AgentLogsQuery,
AgentStatisticsQuery,
@ -567,7 +585,7 @@ class AgentDebugConversationRefreshApi(Resource):
@console_ns.route("/agent/<uuid:agent_id>/copy")
class AgentAppCopyApi(Resource):
@console_ns.expect(console_ns.models[CopyAppPayload.__name__])
@console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__])
@console_ns.response(201, "Agent app copied successfully", console_ns.models[AgentAppDetailWithSite.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@ -578,13 +596,14 @@ class AgentAppCopyApi(Resource):
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
args = CopyAppPayload.model_validate(console_ns.payload or {})
args = AgentAppCopyPayload.model_validate(console_ns.payload or {})
copied_app = _agent_roster_service().duplicate_agent_app(
tenant_id=tenant_id,
agent_id=str(agent_id),
account=current_user,
name=args.name,
description=args.description,
role=args.role,
icon_type=args.icon_type,
icon=args.icon,
icon_background=args.icon_background,

View File

@ -592,7 +592,7 @@ Stop a running Agent App chat message generation
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [CopyAppPayload](#copyapppayload)<br> |
| Yes | **application/json**: [AgentAppCopyPayload](#agentappcopypayload)<br> |
#### Responses
@ -12157,6 +12157,17 @@ Default namespace
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
| variant | string | | Yes |
#### AgentAppCopyPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | Description for the copied agent | No |
| icon | string | Icon | No |
| icon_background | string | Icon background color | No |
| icon_type | [IconType](#icontype) | Icon type | No |
| name | string | Name for the copied agent | No |
| role | string | Role for the copied agent | No |
#### AgentAppCreatePayload
| Name | Type | Description | Required |

View File

@ -633,6 +633,7 @@ class AgentRosterService:
account: Any,
name: str | None = None,
description: str | None = None,
role: str | None = None,
icon_type: Any = None,
icon: str | None = None,
icon_background: str | None = None,
@ -644,6 +645,7 @@ class AgentRosterService:
copied_name = name or self._next_duplicate_agent_name(tenant_id=tenant_id, base_name=source_app.name)
copied_description = description if description is not None else source_app.description
copied_role = role if role is not None else source_agent.role or ""
copied_icon_type = icon_type if icon_type is not None else source_app.icon_type
copied_icon = icon if icon is not None else source_app.icon
copied_icon_background = icon_background if icon_background is not None else source_app.icon_background
@ -654,7 +656,7 @@ class AgentRosterService:
name=copied_name,
description=copied_description,
mode="agent",
agent_role=source_agent.role or "",
agent_role=copied_role,
icon_type=self._normalize_app_icon_type(copied_icon_type),
icon=copied_icon,
icon_background=copied_icon_background,

View File

@ -17,6 +17,8 @@ normalization.
from __future__ import annotations
import mimetypes
import posixpath
import re
from typing import Any
@ -62,7 +64,8 @@ class SkillStandardizeService:
skill_md_bytes = self._package.read_member_bytes(content=content, member_path=manifest.entry_path)
slug = slugify_skill_name(manifest.name)
# Two drive-owned ToolFiles: canonical SKILL.md + the full archive.
# Drive-owned files: canonical SKILL.md, every inspectable archive file,
# and the full archive for future restore/export.
md_tool_file = self._tool_files.create_file_by_raw(
user_id=user_id,
tenant_id=tenant_id,
@ -82,6 +85,30 @@ class SkillStandardizeService:
skill_md_key = f"{slug}/{_SKILL_MD_NAME}"
archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}"
member_items: list[DriveCommitItem] = []
for member_path in sorted(set(manifest.files)):
member_key = f"{slug}/{member_path}"
if member_key in {skill_md_key, archive_key}:
continue
member_bytes = self._package.read_member_bytes(content=content, member_path=member_path)
mimetype = mimetypes.guess_type(member_path)[0] or "application/octet-stream"
member_tool_file = self._tool_files.create_file_by_raw(
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=member_bytes,
mimetype=mimetype,
filename=posixpath.basename(member_path),
)
member_items.append(
DriveCommitItem(
key=member_key,
file_ref=DriveFileRef(kind="tool_file", id=member_tool_file.id),
value_owned_by_drive=True,
)
)
self._drive.commit(
tenant_id=tenant_id,
user_id=user_id,
@ -103,6 +130,7 @@ class SkillStandardizeService:
file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id),
value_owned_by_drive=True,
),
*member_items,
],
)

View File

@ -464,6 +464,7 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
json={
"name": "Iris copy",
"description": "Copied",
"role": "Copied role",
"icon_type": "emoji",
"icon": "sparkles",
"icon_background": "#fff",
@ -479,6 +480,7 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
"account": current_user,
"name": "Iris copy",
"description": "Copied",
"role": "Copied role",
"icon_type": "emoji",
"icon": "sparkles",
"icon_background": "#fff",

View File

@ -1883,8 +1883,11 @@ class TestAgentAppBackingAgent:
monkeypatch.setattr(service, "_copy_agent_active_snapshot", lambda **_: None)
monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_: "Iris copy")
captured: dict[str, object] = {}
class FakeAppService:
def create_app(self, tenant_id: str, params, account: object) -> object:
captured["params"] = params
return target_app
access_mode_updates = []
@ -1910,9 +1913,11 @@ class TestAgentAppBackingAgent:
tenant_id="tenant-1",
agent_id="source-agent",
account=SimpleNamespace(id="account-1"),
role="Custom Analyst",
)
assert duplicated is target_app
assert captured["params"].agent_role == "Custom Analyst"
assert access_mode_updates == [("target-app", "private")]
def test_duplicate_agent_app_falls_back_to_public_access_mode(self, monkeypatch: pytest.MonkeyPatch):

View File

@ -32,13 +32,14 @@ def test_slugify_skill_name():
assert slugify_skill_name("") == "skill"
def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members():
content = _zip({"SKILL.md": _SKILL_MD, "scripts/run.py": b"print('x')\n"})
tool_files = MagicMock()
tool_files.create_file_by_raw.side_effect = [
SimpleNamespace(id="md-tool-file"),
SimpleNamespace(id="zip-tool-file"),
SimpleNamespace(id="script-tool-file"),
]
drive = MagicMock()
drive.commit.return_value = []
@ -52,25 +53,33 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
agent_id="agent-1",
)
# Two ToolFiles: SKILL.md (markdown) + full archive (zip).
assert tool_files.create_file_by_raw.call_count == 2
md_call, zip_call = tool_files.create_file_by_raw.call_args_list
# ToolFiles: SKILL.md, full archive, and each inspectable package member.
assert tool_files.create_file_by_raw.call_count == 3
md_call, zip_call, script_call = tool_files.create_file_by_raw.call_args_list
assert md_call.kwargs["mimetype"] == "text/markdown"
assert md_call.kwargs["file_binary"] == _SKILL_MD
assert zip_call.kwargs["mimetype"] == "application/zip"
assert zip_call.kwargs["file_binary"] == content
assert script_call.kwargs["mimetype"] in {"text/x-python", "text/plain", "application/octet-stream"}
assert script_call.kwargs["file_binary"] == b"print('x')\n"
assert script_call.kwargs["filename"] == "run.py"
# Committed as drive-owned with the standardized keys.
commit_kwargs = drive.commit.call_args.kwargs
assert commit_kwargs["agent_id"] == "agent-1"
items = commit_kwargs["items"]
assert [item.key for item in items] == ["pdf-toolkit/SKILL.md", "pdf-toolkit/.DIFY-SKILL-FULL.zip"]
assert [item.key for item in items] == [
"pdf-toolkit/SKILL.md",
"pdf-toolkit/.DIFY-SKILL-FULL.zip",
"pdf-toolkit/scripts/run.py",
]
assert all(item.value_owned_by_drive for item in items)
assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file"]
assert [item.file_ref.id for item in items] == ["md-tool-file", "zip-tool-file", "script-tool-file"]
assert items[0].is_skill is True
assert items[0].skill_metadata.name == "PDF Toolkit"
assert items[0].skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
assert items[1].is_skill is False
assert items[2].is_skill is False
# The returned skill ref carries stable drive paths + file ids.
skill = result["skill"]

View File

@ -158,12 +158,13 @@ export type AgentComposerValidateResponse = {
warnings?: Array<ComposerValidationWarningResponse>
}
export type CopyAppPayload = {
export type AgentAppCopyPayload = {
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: IconType | null
name?: string | null
role?: string | null
}
export type AgentDebugConversationRefreshResponse = {
@ -1952,7 +1953,7 @@ export type PostAgentByAgentIdComposerValidateResponse
= PostAgentByAgentIdComposerValidateResponses[keyof PostAgentByAgentIdComposerValidateResponses]
export type PostAgentByAgentIdCopyData = {
body: CopyAppPayload
body: AgentAppCopyPayload
path: {
agent_id: string
}

View File

@ -170,14 +170,15 @@ export const zAgentAppUpdatePayload = z.object({
})
/**
* CopyAppPayload
* AgentAppCopyPayload
*/
export const zCopyAppPayload = z.object({
export const zAgentAppCopyPayload = z.object({
description: z.string().max(400).nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: zIconType.nullish(),
name: z.string().nullish(),
role: z.string().max(255).nullish(),
})
/**
@ -2433,7 +2434,7 @@ export const zPostAgentByAgentIdComposerValidatePath = z.object({
*/
export const zPostAgentByAgentIdComposerValidateResponse = zAgentComposerValidateResponse
export const zPostAgentByAgentIdCopyBody = zCopyAppPayload
export const zPostAgentByAgentIdCopyBody = zAgentAppCopyPayload
export const zPostAgentByAgentIdCopyPath = z.object({
agent_id: z.uuid(),