Compare commits

..

5 Commits

Author SHA1 Message Date
52cdaa20d7 chore: remove tag stauts into comp 2026-05-29 14:53:03 +08:00
ac4e117a2a chore: unified plugin status icon position 2026-05-29 14:37:48 +08:00
418ee7398e fix: install failed plugin dose not show icon (#36811) 2026-05-29 06:07:43 +00:00
78f40c0d25 test: stabilize modal context pricing test (#36524) 2026-05-29 05:19:37 +00:00
2cc567c6a3 feat: add DTO for agent api (#36797)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-29 03:36:41 +00:00
25 changed files with 1791 additions and 599 deletions

View File

@ -1,9 +1,17 @@
from flask_restx import Resource
from controllers.common.schema import register_schema_models
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from fields.agent_fields import (
AgentAppComposerResponse,
AgentComposerCandidatesResponse,
AgentComposerImpactResponse,
AgentComposerValidateResponse,
WorkflowAgentComposerResponse,
)
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
@ -11,23 +19,40 @@ from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import ComposerSavePayload
register_schema_models(console_ns, ComposerSavePayload)
register_response_schema_models(
console_ns,
AgentAppComposerResponse,
AgentComposerCandidatesResponse,
AgentComposerImpactResponse,
AgentComposerValidateResponse,
WorkflowAgentComposerResponse,
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
class WorkflowAgentComposerApi(Resource):
@console_ns.response(
200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model: App, node_id: str):
_, tenant_id = current_account_with_tenant()
return AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
),
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Workflow agent composer saved", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -36,18 +61,24 @@ class WorkflowAgentComposerApi(Resource):
def put(self, app_model: App, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
),
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/validate")
class WorkflowAgentComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Workflow agent composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -55,21 +86,29 @@ class WorkflowAgentComposerValidateApi(Resource):
def post(self, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return {"result": "success", "errors": []}
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
class WorkflowAgentComposerCandidatesApi(Resource):
@console_ns.response(
200, "Workflow agent composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model: App, node_id: str):
return AgentComposerService.get_workflow_candidates(app_id=app_model.id)
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_workflow_candidates(app_id=app_model.id),
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/impact")
class WorkflowAgentComposerImpactApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(200, "Workflow agent composer impact", console_ns.models[AgentComposerImpactResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -79,13 +118,21 @@ class WorkflowAgentComposerImpactApi(Resource):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
if not current_snapshot_id:
return {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
return AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id)
return dump_response(
AgentComposerImpactResponse, {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
)
return dump_response(
AgentComposerImpactResponse,
AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id),
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/save-to-roster")
class WorkflowAgentComposerSaveToRosterApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Workflow agent composer saved to roster", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -94,26 +141,34 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
def post(self, app_model: App, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
),
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer")
class AgentAppComposerApi(Resource):
@console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App):
_, tenant_id = current_account_with_tenant()
return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id)
return dump_response(
AgentAppComposerResponse,
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id),
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(200, "Agent app composer saved", console_ns.models[AgentAppComposerResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -122,17 +177,23 @@ class AgentAppComposerApi(Resource):
def put(self, app_model: App):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_model.id,
account_id=account.id,
payload=payload,
return dump_response(
AgentAppComposerResponse,
AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_model.id,
account_id=account.id,
payload=payload,
),
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer/validate")
class AgentAppComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Agent app composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -140,14 +201,20 @@ class AgentAppComposerValidateApi(Resource):
def post(self, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return {"result": "success", "errors": []}
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
class AgentAppComposerCandidatesApi(Resource):
@console_ns.response(
200, "Agent app composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App):
return AgentComposerService.get_agent_app_candidates(app_id=app_model.id)
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
)

View File

@ -4,10 +4,18 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import register_schema_models
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from extensions.ext_database import db
from fields.agent_fields import (
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from services.agent.roster_service import AgentRosterService
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload, RosterListQuery
@ -29,6 +37,14 @@ register_schema_models(
RosterAgentUpdatePayload,
RosterListQuery,
)
register_response_schema_models(
console_ns,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
def _agent_roster_service() -> AgentRosterService:
@ -37,17 +53,23 @@ def _agent_roster_service() -> AgentRosterService:
@console_ns.route("/agents")
class AgentRosterListApi(Resource):
@console_ns.doc(params=query_params_from_model(RosterListQuery))
@console_ns.response(200, "Agent roster list", console_ns.models[AgentRosterListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
query = RosterListQuery.model_validate(request.args.to_dict(flat=True))
return _agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
return dump_response(
AgentRosterListResponse,
_agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
),
)
@console_ns.expect(console_ns.models[RosterAgentCreatePayload.__name__])
@console_ns.response(201, "Agent created", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -57,36 +79,49 @@ class AgentRosterListApi(Resource):
payload = RosterAgentCreatePayload.model_validate(console_ns.payload or {})
service = _agent_roster_service()
agent = service.create_roster_agent(tenant_id=tenant_id, account_id=account.id, payload=payload)
return service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id), 201
return dump_response(
AgentRosterResponse,
service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id),
), 201
@console_ns.route("/agents/invite-options")
class AgentInviteOptionsApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery))
@console_ns.response(200, "Agent invite options", console_ns.models[AgentInviteOptionsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
query = AgentInviteOptionsQuery.model_validate(request.args.to_dict(flat=True))
return _agent_roster_service().list_invite_options(
tenant_id=tenant_id,
page=query.page,
limit=query.limit,
keyword=query.keyword,
app_id=query.app_id,
return dump_response(
AgentInviteOptionsResponse,
_agent_roster_service().list_invite_options(
tenant_id=tenant_id,
page=query.page,
limit=query.limit,
keyword=query.keyword,
app_id=query.app_id,
),
)
@console_ns.route("/agents/<uuid:agent_id>")
class AgentRosterDetailApi(Resource):
@console_ns.response(200, "Agent detail", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id: UUID):
_, tenant_id = current_account_with_tenant()
return _agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id))
return dump_response(
AgentRosterResponse,
_agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id)),
)
@console_ns.expect(console_ns.models[RosterAgentUpdatePayload.__name__])
@console_ns.response(200, "Agent updated", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -94,10 +129,14 @@ class AgentRosterDetailApi(Resource):
def patch(self, agent_id: UUID):
account, tenant_id = current_account_with_tenant()
payload = RosterAgentUpdatePayload.model_validate(console_ns.payload or {})
return _agent_roster_service().update_roster_agent(
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
return dump_response(
AgentRosterResponse,
_agent_roster_service().update_roster_agent(
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
),
)
@console_ns.response(204, "Agent archived")
@setup_required
@login_required
@account_initialization_required
@ -110,23 +149,31 @@ class AgentRosterDetailApi(Resource):
@console_ns.route("/agents/<uuid:agent_id>/versions")
class AgentRosterVersionsApi(Resource):
@console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id: UUID):
_, tenant_id = current_account_with_tenant()
return {"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))}
return dump_response(
AgentConfigSnapshotListResponse,
{"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))},
)
@console_ns.route("/agents/<uuid:agent_id>/versions/<uuid:version_id>")
class AgentRosterVersionDetailApi(Resource):
@console_ns.response(200, "Agent version detail", console_ns.models[AgentConfigSnapshotDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id: UUID, version_id: UUID):
_, tenant_id = current_account_with_tenant()
return _agent_roster_service().get_agent_version_detail(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
return dump_response(
AgentConfigSnapshotDetailResponse,
_agent_roster_service().get_agent_version_detail(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
),
)

192
api/fields/agent_fields.py Normal file
View File

@ -0,0 +1,192 @@
from typing import Any, Literal
from pydantic import Field
from fields.base import ResponseModel
from models.agent import (
AgentConfigRevisionOperation,
AgentIconType,
AgentKind,
AgentScope,
AgentSource,
AgentStatus,
WorkflowAgentBindingType,
)
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
)
from services.entities.agent_entities import (
ComposerCandidateCapabilities,
ComposerSaveStrategy,
ComposerVariant,
)
class AgentConfigSnapshotSummaryResponse(ResponseModel):
id: str
agent_id: str | None = None
version: int
summary: str | None = None
version_note: str | None = None
created_by: str | None = None
created_at: str | None = None
class AgentRosterResponse(ResponseModel):
id: str
name: str
description: str
icon_type: AgentIconType | None = None
icon: str | None = None
icon_background: str | None = None
agent_kind: AgentKind
scope: AgentScope
source: AgentSource
app_id: str | None = None
workflow_id: str | None = None
workflow_node_id: str | None = None
active_config_snapshot_id: str | None = None
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
status: AgentStatus
created_by: str | None = None
updated_by: str | None = None
archived_by: str | None = None
archived_at: str | None = None
created_at: str | None = None
updated_at: str | None = None
class AgentInviteOptionResponse(AgentRosterResponse):
is_in_current_workflow: bool = False
in_current_workflow_count: int = 0
existing_node_ids: list[str] = Field(default_factory=list)
class AgentRosterListResponse(ResponseModel):
data: list[AgentRosterResponse]
page: int
limit: int
total: int
has_more: bool
class AgentInviteOptionsResponse(ResponseModel):
data: list[AgentInviteOptionResponse]
page: int
limit: int
total: int
has_more: bool
class AgentConfigRevisionResponse(ResponseModel):
id: str
previous_snapshot_id: str | None = None
current_snapshot_id: str
revision: int
operation: AgentConfigRevisionOperation
summary: str | None = None
version_note: str | None = None
created_by: str | None = None
created_at: str | None = None
class AgentConfigSnapshotDetailResponse(AgentConfigSnapshotSummaryResponse):
config_snapshot: AgentSoulConfig
revisions: list[AgentConfigRevisionResponse] = Field(default_factory=list)
class AgentConfigSnapshotListResponse(ResponseModel):
data: list[AgentConfigSnapshotSummaryResponse]
class AgentComposerAgentResponse(ResponseModel):
id: str
name: str
description: str
scope: AgentScope
status: AgentStatus
active_config_snapshot_id: str | None = None
class AgentComposerBindingResponse(ResponseModel):
id: str
binding_type: WorkflowAgentBindingType
agent_id: str | None = None
current_snapshot_id: str | None = None
workflow_id: str
node_id: str
class AgentComposerSoulLockResponse(ResponseModel):
locked: bool
can_unlock: bool = False
reason: str | None = None
class AgentComposerImpactBindingResponse(ResponseModel):
app_id: str
workflow_id: str
node_id: str
class AgentComposerImpactResponse(ResponseModel):
current_snapshot_id: str | None = None
workflow_node_count: int
bindings: list[AgentComposerImpactBindingResponse] = Field(default_factory=list)
class WorkflowAgentComposerResponse(ResponseModel):
variant: Literal[ComposerVariant.WORKFLOW]
agent: AgentComposerAgentResponse | None = None
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
binding: AgentComposerBindingResponse | None = None
soul_lock: AgentComposerSoulLockResponse
agent_soul: AgentSoulConfig
node_job: WorkflowNodeJobConfig
effective_declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
save_options: list[ComposerSaveStrategy]
impact_summary: AgentComposerImpactResponse | None = None
app_id: str | None = None
workflow_id: str | None = None
node_id: str | None = None
class AgentAppComposerResponse(ResponseModel):
variant: Literal[ComposerVariant.AGENT_APP]
agent: AgentComposerAgentResponse
active_config_snapshot: AgentConfigSnapshotSummaryResponse
agent_soul: AgentSoulConfig
save_options: list[ComposerSaveStrategy]
class AgentComposerValidateResponse(ResponseModel):
result: Literal["success"]
errors: list[str] = Field(default_factory=list)
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
previous_node_outputs: list[dict[str, Any]] = Field(default_factory=list)
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
human_contacts: list[dict[str, Any]] = Field(default_factory=list)
class AgentComposerSoulCandidatesResponse(ResponseModel):
skills_files: list[dict[str, Any]] = Field(default_factory=list)
dify_tools: list[dict[str, Any]] = Field(default_factory=list)
cli_tools: list[dict[str, Any]] = Field(default_factory=list)
knowledge_datasets: list[dict[str, Any]] = Field(default_factory=list)
human_contacts: list[dict[str, Any]] = Field(default_factory=list)
class AgentComposerCandidatesResponse(ResponseModel):
variant: ComposerVariant
allowed_node_job_candidates: AgentComposerNodeJobCandidatesResponse = Field(
default_factory=AgentComposerNodeJobCandidatesResponse
)
allowed_soul_candidates: AgentComposerSoulCandidatesResponse = Field(
default_factory=AgentComposerSoulCandidatesResponse
)
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)

View File

@ -343,11 +343,19 @@ Check if activation token is valid
### /agents
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | | No | string |
| limit | query | | No | integer |
| page | query | | No | integer |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent roster list | [AgentRosterListResponse](#agentrosterlistresponse) |
#### POST
##### Parameters
@ -358,18 +366,27 @@ Check if activation token is valid
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Agent created | [AgentRosterResponse](#agentrosterresponse) |
### /agents/invite-options
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | query | Workflow app id for in-current-workflow markers | No | string |
| keyword | query | | No | string |
| limit | query | | No | integer |
| page | query | | No | integer |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent invite options | [AgentInviteOptionsResponse](#agentinviteoptionsresponse) |
### /agents/{agent_id}
@ -384,7 +401,7 @@ Check if activation token is valid
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| 204 | Agent archived |
#### GET
##### Parameters
@ -395,9 +412,9 @@ Check if activation token is valid
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent detail | [AgentRosterResponse](#agentrosterresponse) |
#### PATCH
##### Parameters
@ -409,9 +426,9 @@ Check if activation token is valid
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent updated | [AgentRosterResponse](#agentrosterresponse) |
### /agents/{agent_id}/versions
@ -424,9 +441,9 @@ Check if activation token is valid
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent versions | [AgentConfigSnapshotListResponse](#agentconfigsnapshotlistresponse) |
### /agents/{agent_id}/versions/{version_id}
@ -440,9 +457,9 @@ Check if activation token is valid
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent version detail | [AgentConfigSnapshotDetailResponse](#agentconfigsnapshotdetailresponse) |
### /all-workspaces
@ -978,9 +995,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer state | [AgentAppComposerResponse](#agentappcomposerresponse) |
#### PUT
##### Parameters
@ -992,9 +1009,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer saved | [AgentAppComposerResponse](#agentappcomposerresponse) |
### /apps/{app_id}/agent-composer/candidates
@ -1007,9 +1024,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
### /apps/{app_id}/agent-composer/validate
@ -1023,9 +1040,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
### /apps/{app_id}/agent/logs
@ -3224,9 +3241,9 @@ Run draft workflow loop node
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer state | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
#### PUT
##### Parameters
@ -3239,9 +3256,9 @@ Run draft workflow loop node
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer saved | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates
@ -3255,9 +3272,9 @@ Run draft workflow loop node
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact
@ -3268,12 +3285,13 @@ Run draft workflow loop node
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| node_id | path | | Yes | string |
| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) |
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer impact | [AgentComposerImpactResponse](#agentcomposerimpactresponse) |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster
@ -3288,9 +3306,9 @@ Run draft workflow loop node
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer saved to roster | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate
@ -3305,9 +3323,9 @@ Run draft workflow loop node
##### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Success |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run
@ -10651,6 +10669,150 @@ Get banner list
| model_mode | string | Model mode | Yes |
| model_name | string | Model name | Yes |
#### AgentAppComposerResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | Yes |
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| variant | string | | Yes |
#### AgentComposerAgentResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot_id | string | | No |
| description | string | | Yes |
| id | string | | Yes |
| name | string | | Yes |
| scope | [AgentScope](#agentscope) | | Yes |
| status | [AgentStatus](#agentstatus) | | Yes |
#### AgentComposerBindingResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | No |
| binding_type | [WorkflowAgentBindingType](#workflowagentbindingtype) | | Yes |
| current_snapshot_id | string | | No |
| id | string | | Yes |
| node_id | string | | Yes |
| workflow_id | string | | Yes |
#### AgentComposerCandidatesResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| allowed_node_job_candidates | [AgentComposerNodeJobCandidatesResponse](#agentcomposernodejobcandidatesresponse) | | No |
| allowed_soul_candidates | [AgentComposerSoulCandidatesResponse](#agentcomposersoulcandidatesresponse) | | No |
| capabilities | [ComposerCandidateCapabilities](#composercandidatecapabilities) | | No |
| variant | [ComposerVariant](#composervariant) | | Yes |
#### AgentComposerImpactBindingResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | | Yes |
| node_id | string | | Yes |
| workflow_id | string | | Yes |
#### AgentComposerImpactResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| bindings | [ [AgentComposerImpactBindingResponse](#agentcomposerimpactbindingresponse) ] | | No |
| current_snapshot_id | string | | No |
| workflow_node_count | integer | | Yes |
#### AgentComposerNodeJobCandidatesResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| declare_output_types | [ [DeclaredOutputType](#declaredoutputtype) ] | | No |
| human_contacts | [ object ] | | No |
| previous_node_outputs | [ object ] | | No |
#### AgentComposerSoulCandidatesResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| cli_tools | [ object ] | | No |
| dify_tools | [ object ] | | No |
| human_contacts | [ object ] | | No |
| knowledge_datasets | [ object ] | | No |
| skills_files | [ object ] | | No |
#### AgentComposerSoulLockResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| can_unlock | boolean | | No |
| locked | boolean | | Yes |
| reason | string | | No |
#### AgentComposerValidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| errors | [ string ] | | No |
| result | string | | Yes |
#### AgentConfigRevisionOperation
Audit operation recorded for Agent Soul version/revision changes.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentConfigRevisionOperation | string | Audit operation recorded for Agent Soul version/revision changes. | |
#### AgentConfigRevisionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | string | | No |
| created_by | string | | No |
| current_snapshot_id | string | | Yes |
| id | string | | Yes |
| operation | [AgentConfigRevisionOperation](#agentconfigrevisionoperation) | | Yes |
| previous_snapshot_id | string | | No |
| revision | integer | | Yes |
| summary | string | | No |
| version_note | string | | No |
#### AgentConfigSnapshotDetailResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | No |
| config_snapshot | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| created_at | string | | No |
| created_by | string | | No |
| id | string | | Yes |
| revisions | [ [AgentConfigRevisionResponse](#agentconfigrevisionresponse) ] | | No |
| summary | string | | No |
| version | integer | | Yes |
| version_note | string | | No |
#### AgentConfigSnapshotListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) ] | | Yes |
#### AgentConfigSnapshotSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | No |
| created_at | string | | No |
| created_by | string | | No |
| id | string | | Yes |
| summary | string | | No |
| version | integer | | Yes |
| version_note | string | | No |
#### AgentIconType
Supported icon storage formats for Agent roster entries.
@ -10665,6 +10827,35 @@ Supported icon storage formats for Agent roster entries.
| ---- | ---- | ----------- | -------- |
| agent_id | string | | Yes |
#### AgentInviteOptionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| active_config_snapshot_id | string | | No |
| agent_kind | [AgentKind](#agentkind) | | Yes |
| app_id | string | | No |
| archived_at | string | | No |
| archived_by | string | | No |
| created_at | string | | No |
| created_by | string | | No |
| description | string | | Yes |
| existing_node_ids | [ string ] | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
| id | string | | Yes |
| in_current_workflow_count | integer | | No |
| is_in_current_workflow | boolean | | No |
| name | string | | Yes |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | Yes |
| status | [AgentStatus](#agentstatus) | | Yes |
| updated_at | string | | No |
| updated_by | string | | No |
| workflow_id | string | | No |
| workflow_node_id | string | | No |
#### AgentInviteOptionsQuery
| Name | Type | Description | Required |
@ -10674,6 +10865,27 @@ Supported icon storage formats for Agent roster entries.
| limit | integer | | No |
| page | integer | | No |
#### AgentInviteOptionsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AgentInviteOptionResponse](#agentinviteoptionresponse) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
| page | integer | | Yes |
| total | integer | | Yes |
#### AgentKind
Agent implementation family.
This leaves room for future non-Dify agent implementations while keeping
the current roster/workflow APIs scoped to Dify Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentKind | string | Agent implementation family. This leaves room for future non-Dify agent implementations while keeping the current roster/workflow APIs scoped to Dify Agent. | |
#### AgentKnowledgeQueryMode
| Name | Type | Description | Required |
@ -10687,6 +10899,50 @@ Supported icon storage formats for Agent roster entries.
| conversation_id | string | Conversation UUID | Yes |
| message_id | string | Message UUID | Yes |
#### AgentRosterListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AgentRosterResponse](#agentrosterresponse) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
| page | integer | | Yes |
| total | integer | | Yes |
#### AgentRosterResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| active_config_snapshot_id | string | | No |
| agent_kind | [AgentKind](#agentkind) | | Yes |
| app_id | string | | No |
| archived_at | string | | No |
| archived_by | string | | No |
| created_at | string | | No |
| created_by | string | | No |
| description | string | | Yes |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
| id | string | | Yes |
| name | string | | Yes |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | Yes |
| status | [AgentStatus](#agentstatus) | | Yes |
| updated_at | string | | No |
| updated_by | string | | No |
| workflow_id | string | | No |
| workflow_node_id | string | | No |
#### AgentScope
Visibility and lifecycle scope of an Agent record.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentScope | string | Visibility and lifecycle scope of an Agent record. | |
#### AgentSoulConfig
| Name | Type | Description | Required |
@ -10821,6 +11077,22 @@ Reference to model credentials resolved only at runtime.
| cli_tools | [ object ] | | No |
| dify_tools | [ [AgentSoulDifyToolConfig](#agentsouldifytoolconfig) ] | | No |
#### AgentSource
Origin that created or imported the Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentSource | string | Origin that created or imported the Agent. | |
#### AgentStatus
Soft lifecycle state for Agent records.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentStatus | string | Soft lifecycle state for Agent records. | |
#### AgentThought
| Name | Type | Description | Required |
@ -11528,6 +11800,12 @@ Button styles for user actions.
| binding_type | string | *Enum:* `"inline_agent"`, `"roster_agent"` | Yes |
| current_snapshot_id | string | | No |
#### ComposerCandidateCapabilities
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| human_roster_available | boolean | | No |
#### ComposerSavePayload
| Name | Type | Description | Required |
@ -15369,6 +15647,32 @@ in form definiton, or a variable while the workflow is running.
| embedding_provider_name | string | | Yes |
| vector_weight | number | | Yes |
#### WorkflowAgentBindingType
How a workflow node is bound to an Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| WorkflowAgentBindingType | string | How a workflow node is bound to an Agent. | |
#### WorkflowAgentComposerResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | No |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| app_id | string | | No |
| binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No |
| effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No |
| impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No |
| node_id | string | | No |
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| soul_lock | [AgentComposerSoulLockResponse](#agentcomposersoullockresponse) | | Yes |
| variant | string | | Yes |
| workflow_id | string | | No |
#### WorkflowAppLogPaginationResponse
| Name | Type | Description | Required |

View File

@ -30,6 +30,90 @@ def _unwrap(method):
return method
def _agent_response(agent_id: str = "agent-1") -> dict:
return {
"id": agent_id,
"name": "Analyst",
"description": "",
"icon_type": None,
"icon": None,
"icon_background": None,
"agent_kind": "dify_agent",
"scope": "roster",
"source": "agent_app",
"app_id": None,
"workflow_id": None,
"workflow_node_id": None,
"active_config_snapshot_id": "version-1",
"active_config_snapshot": _version_response(),
"status": "active",
"created_by": "account-1",
"updated_by": "account-1",
"archived_by": None,
"archived_at": None,
"created_at": None,
"updated_at": None,
}
def _version_response(version_id: str = "version-1") -> dict:
return {
"id": version_id,
"agent_id": "agent-1",
"version": 1,
"summary": None,
"version_note": None,
"created_by": "account-1",
"created_at": None,
}
def _workflow_composer_response(**overrides) -> dict:
response = {
"variant": "workflow",
"agent": None,
"active_config_snapshot": None,
"binding": None,
"soul_lock": {"locked": False, "can_unlock": False, "reason": "workflow_only_empty"},
"agent_soul": {},
"node_job": {},
"effective_declared_outputs": [],
"save_options": ["node_job_only"],
"impact_summary": None,
"app_id": "app-1",
"workflow_id": "workflow-1",
"node_id": "node-1",
}
response.update(overrides)
return response
def _agent_app_composer_response() -> dict:
return {
"variant": "agent_app",
"agent": {
"id": "agent-1",
"name": "Analyst",
"description": "",
"scope": "roster",
"status": "active",
"active_config_snapshot_id": "version-1",
},
"active_config_snapshot": _version_response(),
"agent_soul": {},
"save_options": ["save_to_current_version", "save_as_new_version"],
}
def _candidates_response(variant: str) -> dict:
return {
"variant": variant,
"allowed_node_job_candidates": {},
"allowed_soul_candidates": {},
"capabilities": {"human_roster_available": False},
}
@pytest.fixture
def account():
return SimpleNamespace(id="account-1")
@ -67,14 +151,15 @@ def test_roster_list_post_creates_agent_and_returns_detail(app, monkeypatch):
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_roster_agent_detail",
lambda _self, **kwargs: {"id": kwargs["agent_id"], "tenant_id": kwargs["tenant_id"]},
lambda _self, **kwargs: _agent_response(kwargs["agent_id"]),
)
with app.test_request_context(json={"name": "Analyst", "agent_soul": {"prompt": {"system_prompt": "x"}}}):
result, status = _unwrap(AgentRosterListApi.post)(AgentRosterListApi())
assert status == 201
assert result == {"id": "agent-1", "tenant_id": "tenant-1"}
assert result["id"] == "agent-1"
assert result["agent_kind"] == "dify_agent"
def test_invite_options_get_parses_app_id(app, monkeypatch):
@ -82,14 +167,14 @@ def test_invite_options_get_parses_app_id(app, monkeypatch):
def list_invite_options(_self, **kwargs):
captured.update(kwargs)
return {"data": []}
return {"data": [], "page": kwargs["page"], "limit": kwargs["limit"], "total": 0, "has_more": False}
monkeypatch.setattr(roster_controller.AgentRosterService, "list_invite_options", list_invite_options)
with app.test_request_context("/console/api/agents/invite-options?page=1&limit=10&app_id=app-1"):
result = _unwrap(AgentInviteOptionsApi.get)(AgentInviteOptionsApi())
assert result == {"data": []}
assert result == {"data": [], "page": 1, "limit": 10, "total": 0, "has_more": False}
assert captured == {"tenant_id": "tenant-1", "page": 1, "limit": 10, "keyword": None, "app_id": "app-1"}
@ -100,12 +185,12 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_roster_agent_detail",
lambda _self, **kwargs: {"id": kwargs["agent_id"]},
lambda _self, **kwargs: _agent_response(kwargs["agent_id"]),
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"update_roster_agent",
lambda _self, **kwargs: {"id": kwargs["agent_id"], "description": kwargs["payload"].description},
lambda _self, **kwargs: {**_agent_response(kwargs["agent_id"]), "description": kwargs["payload"].description},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
@ -115,12 +200,29 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"list_agent_versions",
lambda _self, **kwargs: [{"id": "version-1"}],
lambda _self, **kwargs: [_version_response()],
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_agent_version_detail",
lambda _self, **kwargs: {"id": kwargs["version_id"], "agent_id": kwargs["agent_id"]},
lambda _self, **kwargs: {
**_version_response(kwargs["version_id"]),
"agent_id": kwargs["agent_id"],
"config_snapshot": {},
"revisions": [
{
"id": "revision-1",
"previous_snapshot_id": None,
"current_snapshot_id": kwargs["version_id"],
"revision": 1,
"operation": "create_version",
"summary": None,
"version_note": None,
"created_by": "account-1",
"created_at": None,
}
],
},
)
assert _unwrap(AgentRosterDetailApi.get)(AgentRosterDetailApi(), agent_id)["id"] == agent_id
@ -128,11 +230,10 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
assert _unwrap(AgentRosterDetailApi.patch)(AgentRosterDetailApi(), agent_id)["description"] == "updated"
assert _unwrap(AgentRosterDetailApi.delete)(AgentRosterDetailApi(), agent_id) == ("", 204)
assert archived["account_id"] == "account-1"
assert _unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), agent_id) == {"data": [{"id": "version-1"}]}
assert _unwrap(AgentRosterVersionDetailApi.get)(AgentRosterVersionDetailApi(), agent_id, version_id) == {
"id": version_id,
"agent_id": agent_id,
}
assert _unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), agent_id)["data"][0]["id"] == "version-1"
version_detail = _unwrap(AgentRosterVersionDetailApi.get)(AgentRosterVersionDetailApi(), agent_id, version_id)
assert version_detail["id"] == version_id
assert version_detail["agent_id"] == agent_id
def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monkeypatch):
@ -145,50 +246,52 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monk
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_workflow_composer",
lambda **kwargs: {"node_id": kwargs["node_id"]},
lambda **kwargs: _workflow_composer_response(node_id=kwargs["node_id"]),
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_workflow_composer",
lambda **kwargs: {"saved": kwargs["payload"].save_strategy.value, "account_id": kwargs["account_id"]},
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_workflow_candidates",
lambda **kwargs: {"data": []},
lambda **kwargs: _candidates_response("workflow"),
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"calculate_impact",
lambda **kwargs: {"current_snapshot_id": kwargs["current_snapshot_id"], "workflow_node_count": 1},
lambda **kwargs: {
"current_snapshot_id": kwargs["current_snapshot_id"],
"workflow_node_count": 1,
"bindings": [],
},
)
assert _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1") == {
"node_id": "node-1"
}
workflow_state = _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1")
assert workflow_state["node_id"] == "node-1"
with app.test_request_context(json=payload):
assert _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1") == {
"saved": "node_job_only",
"account_id": "account-1",
}
saved_state = _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1")
assert saved_state["save_options"] == ["node_job_only"]
assert _unwrap(WorkflowAgentComposerValidateApi.post)(
WorkflowAgentComposerValidateApi(), app_model, "node-1"
) == {"result": "success", "errors": []}
assert _unwrap(WorkflowAgentComposerCandidatesApi.get)(
WorkflowAgentComposerCandidatesApi(), app_model, "node-1"
) == {"data": []}
assert (
_unwrap(WorkflowAgentComposerCandidatesApi.get)(WorkflowAgentComposerCandidatesApi(), app_model, "node-1")[
"variant"
]
== "workflow"
)
with app.test_request_context(json=payload):
assert _unwrap(WorkflowAgentComposerImpactApi.post)(WorkflowAgentComposerImpactApi(), app_model, "node-1") == {
"current_snapshot_id": "version-1",
"workflow_node_count": 1,
"bindings": [],
}
assert (
_unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
WorkflowAgentComposerSaveToRosterApi(), app_model, "node-1"
)["saved"]
== "node_job_only"
)
assert _unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
WorkflowAgentComposerSaveToRosterApi(), app_model, "node-1"
)["save_options"] == ["node_job_only"]
def test_workflow_impact_returns_empty_without_version(app):
@ -212,28 +315,26 @@ def test_agent_app_composer_get_put_validate_and_candidates(app, monkeypatch):
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_agent_app_composer",
lambda **kwargs: {"loaded": True},
lambda **kwargs: _agent_app_composer_response(),
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_agent_app_composer",
lambda **kwargs: {"saved": kwargs["payload"].variant.value, "account_id": kwargs["account_id"]},
lambda **kwargs: _agent_app_composer_response(),
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_agent_app_candidates",
lambda **kwargs: {"data": []},
lambda **kwargs: _candidates_response("agent_app"),
)
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model) == {"loaded": True}
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
with app.test_request_context(json=payload):
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model) == {
"saved": "agent_app",
"account_id": "account-1",
}
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
assert _unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), app_model) == {
"result": "success",
"errors": [],
}
assert _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model) == {"data": []}
agent_app_candidates = _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model)
assert agent_app_candidates["variant"] == "agent_app"

View File

@ -40,7 +40,7 @@ export type PollSuccess = {
subject_type?: string
subject_email?: string
subject_issuer?: string
account?: PollAccount | null
account?: PollAccount
workspaces?: readonly PollWorkspace[]
default_workspace_id?: string
token_id?: string

View File

@ -99,9 +99,8 @@ function renderCodePrompt(w: NodeJS.WritableStream, cs: ReturnType<typeof colorS
function renderLoggedIn(w: NodeJS.WritableStream, cs: ReturnType<typeof colorScheme>, host: string, s: PollSuccess): void {
const display = bareHost(host)
const account = s.account ?? undefined
if (account !== undefined && account.email !== '') {
w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(account.email)} (${account.name})\n`)
if (s.account !== undefined && s.account.email !== '') {
w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`)
const ws = findDefaultWorkspace(s)
if (ws !== undefined)
w.write(` Workspace: ${ws.name}\n`)
@ -140,12 +139,11 @@ function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): Hos
token_id: s.token_id,
tokens: { bearer: s.token },
}
const account = s.account ?? undefined
if (account !== undefined) {
bundle.account = { id: account.id, email: account.email, name: account.name }
if (s.account !== undefined) {
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
}
if (s.subject_email !== undefined && s.subject_email !== ''
&& (account === undefined || account.id === '')) {
&& (s.account === undefined || s.account.id === '')) {
bundle.external_subject = {
email: s.subject_email,
issuer: s.subject_issuer ?? '',

View File

@ -44,7 +44,7 @@ abstract class FileBasedStore implements Store {
flush(): void {
fs.mkdirSync(dirname(this.filePath), { recursive: true, mode: DIR_PERM })
// we don't handle A-B-A scenario,
// we don't handle A-B-A scenario,
// which is not likely to happen in cli
if (!this.dirty) {
return

View File

@ -356,9 +356,6 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
subject_type: 'external_sso',
subject_email: 'sso@dify.ai',
subject_issuer: 'https://issuer.example',
account: null,
workspaces: [],
default_workspace_id: null,
token_id: 'tok-sso-1',
})
}

View File

@ -12,7 +12,9 @@ import {
zGetAgentsByAgentIdVersionsByVersionIdResponse,
zGetAgentsByAgentIdVersionsPath,
zGetAgentsByAgentIdVersionsResponse,
zGetAgentsInviteOptionsQuery,
zGetAgentsInviteOptionsResponse,
zGetAgentsQuery,
zGetAgentsResponse,
zPatchAgentsByAgentIdBody,
zPatchAgentsByAgentIdPath,
@ -21,22 +23,15 @@ import {
zPostAgentsResponse,
} from './zod.gen'
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentsInviteOptions',
path: '/agents/invite-options',
tags: ['console'],
})
.input(z.object({ query: zGetAgentsInviteOptionsQuery.optional() }))
.output(zGetAgentsInviteOptionsResponse)
export const inviteOptions = {
@ -66,16 +61,8 @@ export const byVersionId = {
get: get2,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get3 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentsByAgentIdVersions',
@ -90,35 +77,20 @@ export const versions = {
byVersionId,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const delete_ = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'deleteAgentsByAgentId',
path: '/agents/{agent_id}',
successStatus: 204,
tags: ['console'],
})
.input(z.object({ params: zDeleteAgentsByAgentIdPath }))
.output(zDeleteAgentsByAgentIdResponse)
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get4 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentsByAgentId',
@ -128,16 +100,8 @@ export const get4 = oc
.input(z.object({ params: zGetAgentsByAgentIdPath }))
.output(zGetAgentsByAgentIdResponse)
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const patch = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'patchAgentsByAgentId',
@ -154,22 +118,15 @@ export const byAgentId = {
versions,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get5 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgents',
path: '/agents',
tags: ['console'],
})
.input(z.object({ query: zGetAgentsQuery.optional() }))
.output(zGetAgentsResponse)
/**
@ -186,6 +143,7 @@ export const post = oc
method: 'POST',
operationId: 'postAgents',
path: '/agents',
successStatus: 201,
tags: ['console'],
})
.input(z.object({ body: zPostAgentsBody }))

View File

@ -4,6 +4,14 @@ export type ClientOptions = {
baseUrl: `${string}://${string}/console/api` | (string & {})
}
export type AgentRosterListResponse = {
data: Array<AgentRosterResponse>
has_more: boolean
limit: number
page: number
total: number
}
export type RosterAgentCreatePayload = {
agent_soul?: AgentSoulConfig
description?: string
@ -14,6 +22,38 @@ export type RosterAgentCreatePayload = {
version_note?: string | null
}
export type AgentRosterResponse = {
active_config_snapshot?: AgentConfigSnapshotSummaryResponse
active_config_snapshot_id?: string | null
agent_kind: AgentKind
app_id?: string | null
archived_at?: string | null
archived_by?: string | null
created_at?: string | null
created_by?: string | null
description: string
icon?: string | null
icon_background?: string | null
icon_type?: AgentIconType
id: string
name: string
scope: AgentScope
source: AgentSource
status: AgentStatus
updated_at?: string | null
updated_by?: string | null
workflow_id?: string | null
workflow_node_id?: string | null
}
export type AgentInviteOptionsResponse = {
data: Array<AgentInviteOptionResponse>
has_more: boolean
limit: number
page: number
total: number
}
export type RosterAgentUpdatePayload = {
description?: string | null
icon?: string | null
@ -22,6 +62,22 @@ export type RosterAgentUpdatePayload = {
name?: string | null
}
export type AgentConfigSnapshotListResponse = {
data: Array<AgentConfigSnapshotSummaryResponse>
}
export type AgentConfigSnapshotDetailResponse = {
agent_id?: string | null
config_snapshot: AgentSoulConfig
created_at?: string | null
created_by?: string | null
id: string
revisions?: Array<AgentConfigRevisionResponse>
summary?: string | null
version: number
version_note?: string | null
}
export type AgentSoulConfig = {
app_features?: {
[key: string]: unknown
@ -44,6 +100,63 @@ export type AgentSoulConfig = {
export type AgentIconType = 'emoji' | 'image' | 'link'
export type AgentConfigSnapshotSummaryResponse = {
agent_id?: string | null
created_at?: string | null
created_by?: string | null
id: string
summary?: string | null
version: number
version_note?: string | null
}
export type AgentKind = 'dify_agent'
export type AgentScope = 'roster' | 'workflow_only'
export type AgentSource = 'agent_app' | 'imported' | 'system' | 'workflow'
export type AgentStatus = 'active' | 'archived'
export type AgentInviteOptionResponse = {
active_config_snapshot?: AgentConfigSnapshotSummaryResponse
active_config_snapshot_id?: string | null
agent_kind: AgentKind
app_id?: string | null
archived_at?: string | null
archived_by?: string | null
created_at?: string | null
created_by?: string | null
description: string
existing_node_ids?: Array<string>
icon?: string | null
icon_background?: string | null
icon_type?: AgentIconType
id: string
in_current_workflow_count?: number
is_in_current_workflow?: boolean
name: string
scope: AgentScope
source: AgentSource
status: AgentStatus
updated_at?: string | null
updated_by?: string | null
workflow_id?: string | null
workflow_node_id?: string | null
}
export type AgentConfigRevisionResponse = {
created_at?: string | null
created_by?: string | null
current_snapshot_id: string
id: string
operation: AgentConfigRevisionOperation
previous_snapshot_id?: string | null
revision: number
summary?: string | null
version_note?: string | null
}
export type AppVariableConfig = {
default?: unknown
name: string
@ -124,6 +237,13 @@ export type AgentSoulToolsConfig = {
dify_tools?: Array<AgentSoulDifyToolConfig>
}
export type AgentConfigRevisionOperation
= | 'create_version'
| 'save_current_version'
| 'save_new_agent'
| 'save_new_version'
| 'save_to_roster'
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
export type AgentSoulModelCredentialRef = {
@ -157,14 +277,16 @@ export type AgentSoulDifyToolCredentialRef = {
export type GetAgentsData = {
body?: never
path?: never
query?: never
query?: {
keyword?: string
limit?: number
page?: number
}
url: '/agents'
}
export type GetAgentsResponses = {
200: {
[key: string]: unknown
}
200: AgentRosterListResponse
}
export type GetAgentsResponse = GetAgentsResponses[keyof GetAgentsResponses]
@ -177,9 +299,7 @@ export type PostAgentsData = {
}
export type PostAgentsResponses = {
200: {
[key: string]: unknown
}
201: AgentRosterResponse
}
export type PostAgentsResponse = PostAgentsResponses[keyof PostAgentsResponses]
@ -187,14 +307,17 @@ export type PostAgentsResponse = PostAgentsResponses[keyof PostAgentsResponses]
export type GetAgentsInviteOptionsData = {
body?: never
path?: never
query?: never
query?: {
app_id?: string
keyword?: string
limit?: number
page?: number
}
url: '/agents/invite-options'
}
export type GetAgentsInviteOptionsResponses = {
200: {
[key: string]: unknown
}
200: AgentInviteOptionsResponse
}
export type GetAgentsInviteOptionsResponse
@ -210,8 +333,8 @@ export type DeleteAgentsByAgentIdData = {
}
export type DeleteAgentsByAgentIdResponses = {
200: {
[key: string]: unknown
204: {
[key: string]: never
}
}
@ -228,9 +351,7 @@ export type GetAgentsByAgentIdData = {
}
export type GetAgentsByAgentIdResponses = {
200: {
[key: string]: unknown
}
200: AgentRosterResponse
}
export type GetAgentsByAgentIdResponse
@ -246,9 +367,7 @@ export type PatchAgentsByAgentIdData = {
}
export type PatchAgentsByAgentIdResponses = {
200: {
[key: string]: unknown
}
200: AgentRosterResponse
}
export type PatchAgentsByAgentIdResponse
@ -264,9 +383,7 @@ export type GetAgentsByAgentIdVersionsData = {
}
export type GetAgentsByAgentIdVersionsResponses = {
200: {
[key: string]: unknown
}
200: AgentConfigSnapshotListResponse
}
export type GetAgentsByAgentIdVersionsResponse
@ -283,9 +400,7 @@ export type GetAgentsByAgentIdVersionsByVersionIdData = {
}
export type GetAgentsByAgentIdVersionsByVersionIdResponses = {
200: {
[key: string]: unknown
}
200: AgentConfigSnapshotDetailResponse
}
export type GetAgentsByAgentIdVersionsByVersionIdResponse

View File

@ -20,6 +20,136 @@ export const zRosterAgentUpdatePayload = z.object({
name: z.string().min(1).max(255).nullish(),
})
/**
* AgentConfigSnapshotSummaryResponse
*/
export const zAgentConfigSnapshotSummaryResponse = z.object({
agent_id: z.string().nullish(),
created_at: z.string().nullish(),
created_by: z.string().nullish(),
id: z.string(),
summary: z.string().nullish(),
version: z.int(),
version_note: z.string().nullish(),
})
/**
* AgentConfigSnapshotListResponse
*/
export const zAgentConfigSnapshotListResponse = z.object({
data: z.array(zAgentConfigSnapshotSummaryResponse),
})
/**
* AgentKind
*
* Agent implementation family.
*
* This leaves room for future non-Dify agent implementations while keeping
* the current roster/workflow APIs scoped to Dify Agent.
*/
export const zAgentKind = z.enum(['dify_agent'])
/**
* AgentScope
*
* Visibility and lifecycle scope of an Agent record.
*/
export const zAgentScope = z.enum(['roster', 'workflow_only'])
/**
* AgentSource
*
* Origin that created or imported the Agent.
*/
export const zAgentSource = z.enum(['agent_app', 'imported', 'system', 'workflow'])
/**
* AgentStatus
*
* Soft lifecycle state for Agent records.
*/
export const zAgentStatus = z.enum(['active', 'archived'])
/**
* AgentRosterResponse
*/
export const zAgentRosterResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(),
active_config_snapshot_id: z.string().nullish(),
agent_kind: zAgentKind,
app_id: z.string().nullish(),
archived_at: z.string().nullish(),
archived_by: z.string().nullish(),
created_at: z.string().nullish(),
created_by: z.string().nullish(),
description: z.string(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: zAgentIconType.optional(),
id: z.string(),
name: z.string(),
scope: zAgentScope,
source: zAgentSource,
status: zAgentStatus,
updated_at: z.string().nullish(),
updated_by: z.string().nullish(),
workflow_id: z.string().nullish(),
workflow_node_id: z.string().nullish(),
})
/**
* AgentRosterListResponse
*/
export const zAgentRosterListResponse = z.object({
data: z.array(zAgentRosterResponse),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* AgentInviteOptionResponse
*/
export const zAgentInviteOptionResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(),
active_config_snapshot_id: z.string().nullish(),
agent_kind: zAgentKind,
app_id: z.string().nullish(),
archived_at: z.string().nullish(),
archived_by: z.string().nullish(),
created_at: z.string().nullish(),
created_by: z.string().nullish(),
description: z.string(),
existing_node_ids: z.array(z.string()).optional(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: zAgentIconType.optional(),
id: z.string(),
in_current_workflow_count: z.int().optional().default(0),
is_in_current_workflow: z.boolean().optional().default(false),
name: z.string(),
scope: zAgentScope,
source: zAgentSource,
status: zAgentStatus,
updated_at: z.string().nullish(),
updated_by: z.string().nullish(),
workflow_id: z.string().nullish(),
workflow_node_id: z.string().nullish(),
})
/**
* AgentInviteOptionsResponse
*/
export const zAgentInviteOptionsResponse = z.object({
data: z.array(zAgentInviteOptionResponse),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* AppVariableConfig
*/
@ -78,6 +208,34 @@ export const zAgentSoulSkillsFilesConfig = z.object({
skills: z.array(z.record(z.string(), z.unknown())).optional(),
})
/**
* AgentConfigRevisionOperation
*
* Audit operation recorded for Agent Soul version/revision changes.
*/
export const zAgentConfigRevisionOperation = z.enum([
'create_version',
'save_current_version',
'save_new_agent',
'save_new_version',
'save_to_roster',
])
/**
* AgentConfigRevisionResponse
*/
export const zAgentConfigRevisionResponse = z.object({
created_at: z.string().nullish(),
created_by: z.string().nullish(),
current_snapshot_id: z.string(),
id: z.string(),
operation: zAgentConfigRevisionOperation,
previous_snapshot_id: z.string().nullish(),
revision: z.int(),
summary: z.string().nullish(),
version_note: z.string().nullish(),
})
/**
* AgentKnowledgeQueryMode
*/
@ -196,39 +354,67 @@ export const zRosterAgentCreatePayload = z.object({
})
/**
* Success
* AgentConfigSnapshotDetailResponse
*/
export const zGetAgentsResponse = z.record(z.string(), z.unknown())
export const zAgentConfigSnapshotDetailResponse = z.object({
agent_id: z.string().nullish(),
config_snapshot: zAgentSoulConfig,
created_at: z.string().nullish(),
created_by: z.string().nullish(),
id: z.string(),
revisions: z.array(zAgentConfigRevisionResponse).optional(),
summary: z.string().nullish(),
version: z.int(),
version_note: z.string().nullish(),
})
export const zGetAgentsQuery = z.object({
keyword: z.string().optional(),
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* Agent roster list
*/
export const zGetAgentsResponse = zAgentRosterListResponse
export const zPostAgentsBody = zRosterAgentCreatePayload
/**
* Success
* Agent created
*/
export const zPostAgentsResponse = z.record(z.string(), z.unknown())
export const zPostAgentsResponse = zAgentRosterResponse
export const zGetAgentsInviteOptionsQuery = z.object({
app_id: z.string().optional(),
keyword: z.string().optional(),
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* Success
* Agent invite options
*/
export const zGetAgentsInviteOptionsResponse = z.record(z.string(), z.unknown())
export const zGetAgentsInviteOptionsResponse = zAgentInviteOptionsResponse
export const zDeleteAgentsByAgentIdPath = z.object({
agent_id: z.string(),
})
/**
* Success
* Agent archived
*/
export const zDeleteAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
export const zDeleteAgentsByAgentIdResponse = z.record(z.string(), z.never())
export const zGetAgentsByAgentIdPath = z.object({
agent_id: z.string(),
})
/**
* Success
* Agent detail
*/
export const zGetAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
export const zGetAgentsByAgentIdResponse = zAgentRosterResponse
export const zPatchAgentsByAgentIdBody = zRosterAgentUpdatePayload
@ -237,18 +423,18 @@ export const zPatchAgentsByAgentIdPath = z.object({
})
/**
* Success
* Agent updated
*/
export const zPatchAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
export const zPatchAgentsByAgentIdResponse = zAgentRosterResponse
export const zGetAgentsByAgentIdVersionsPath = z.object({
agent_id: z.string(),
})
/**
* Success
* Agent versions
*/
export const zGetAgentsByAgentIdVersionsResponse = z.record(z.string(), z.unknown())
export const zGetAgentsByAgentIdVersionsResponse = zAgentConfigSnapshotListResponse
export const zGetAgentsByAgentIdVersionsByVersionIdPath = z.object({
agent_id: z.string(),
@ -256,6 +442,6 @@ export const zGetAgentsByAgentIdVersionsByVersionIdPath = z.object({
})
/**
* Success
* Agent version detail
*/
export const zGetAgentsByAgentIdVersionsByVersionIdResponse = z.record(z.string(), z.unknown())
export const zGetAgentsByAgentIdVersionsByVersionIdResponse = zAgentConfigSnapshotDetailResponse

View File

@ -352,6 +352,7 @@ import {
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody,
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath,
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody,
@ -3516,7 +3517,12 @@ export const post47 = oc
path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact',
tags: ['console'],
})
.input(z.object({ params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath }))
.input(
z.object({
body: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody,
params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath,
}),
)
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse)
export const impact = {

View File

@ -167,6 +167,14 @@ export type AdvancedChatWorkflowRunPayload = {
query?: string
}
export type AgentAppComposerResponse = {
active_config_snapshot: AgentConfigSnapshotSummaryResponse
agent: AgentComposerAgentResponse
agent_soul: AgentSoulConfig
save_options: Array<ComposerSaveStrategy>
variant: string
}
export type ComposerSavePayload = {
agent_soul?: AgentSoulConfig
binding?: ComposerBindingPayload
@ -180,6 +188,18 @@ export type ComposerSavePayload = {
version_note?: string | null
}
export type AgentComposerCandidatesResponse = {
allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse
allowed_soul_candidates?: AgentComposerSoulCandidatesResponse
capabilities?: ComposerCandidateCapabilities
variant: ComposerVariant
}
export type AgentComposerValidateResponse = {
errors?: Array<string>
result: string
}
export type AnnotationReplyPayload = {
embedding_model_name: string
embedding_provider_name: string
@ -736,6 +756,28 @@ export type HumanInputDeliveryTestPayload = {
}
}
export type WorkflowAgentComposerResponse = {
active_config_snapshot?: AgentConfigSnapshotSummaryResponse
agent?: AgentComposerAgentResponse
agent_soul: AgentSoulConfig
app_id?: string | null
binding?: AgentComposerBindingResponse
effective_declared_outputs?: Array<DeclaredOutputConfig>
impact_summary?: AgentComposerImpactResponse
node_id?: string | null
node_job: WorkflowNodeJobConfig
save_options: Array<ComposerSaveStrategy>
soul_lock: AgentComposerSoulLockResponse
variant: string
workflow_id?: string | null
}
export type AgentComposerImpactResponse = {
bindings?: Array<AgentComposerImpactBindingResponse>
current_snapshot_id?: string | null
workflow_node_count: number
}
export type WorkflowRunNodeExecutionResponse = {
created_at?: number | null
created_by_account?: SimpleAccount
@ -961,6 +1003,25 @@ export type AdvancedChatWorkflowRunForListResponse = {
version?: string | null
}
export type AgentConfigSnapshotSummaryResponse = {
agent_id?: string | null
created_at?: string | null
created_by?: string | null
id: string
summary?: string | null
version: number
version_note?: string | null
}
export type AgentComposerAgentResponse = {
active_config_snapshot_id?: string | null
description: string
id: string
name: string
scope: AgentScope
status: AgentStatus
}
export type AgentSoulConfig = {
app_features?: {
[key: string]: unknown
@ -981,6 +1042,13 @@ export type AgentSoulConfig = {
tools?: AgentSoulToolsConfig
}
export type ComposerSaveStrategy
= | 'node_job_only'
| 'save_as_new_agent'
| 'save_as_new_version'
| 'save_to_current_version'
| 'save_to_roster'
export type ComposerBindingPayload = {
agent_id?: string | null
binding_type: 'inline_agent' | 'roster_agent'
@ -1003,13 +1071,6 @@ export type WorkflowNodeJobConfig = {
workflow_prompt?: string
}
export type ComposerSaveStrategy
= | 'node_job_only'
| 'save_as_new_agent'
| 'save_as_new_version'
| 'save_to_current_version'
| 'save_to_roster'
export type ComposerSoulLockPayload = {
locked?: boolean
unlocked_from_version_id?: string | null
@ -1017,6 +1078,38 @@ export type ComposerSoulLockPayload = {
export type ComposerVariant = 'agent_app' | 'workflow'
export type AgentComposerNodeJobCandidatesResponse = {
declare_output_types?: Array<DeclaredOutputType>
human_contacts?: Array<{
[key: string]: unknown
}>
previous_node_outputs?: Array<{
[key: string]: unknown
}>
}
export type AgentComposerSoulCandidatesResponse = {
cli_tools?: Array<{
[key: string]: unknown
}>
dify_tools?: Array<{
[key: string]: unknown
}>
human_contacts?: Array<{
[key: string]: unknown
}>
knowledge_datasets?: Array<{
[key: string]: unknown
}>
skills_files?: Array<{
[key: string]: unknown
}>
}
export type ComposerCandidateCapabilities = {
human_roster_available?: boolean
}
export type AnnotationHitHistory = {
annotation_content?: string | null
annotation_question?: string | null
@ -1305,6 +1398,39 @@ export type PipelineVariableResponse = {
variable: string
}
export type AgentComposerBindingResponse = {
agent_id?: string | null
binding_type: WorkflowAgentBindingType
current_snapshot_id?: string | null
id: string
node_id: string
workflow_id: string
}
export type DeclaredOutputConfig = {
array_item?: DeclaredArrayItem
check?: DeclaredOutputCheckConfig
description?: string | null
failure_strategy?: DeclaredOutputFailureStrategy
file?: DeclaredOutputFileConfig
id?: string | null
name: string
required?: boolean
type: DeclaredOutputType
}
export type AgentComposerSoulLockResponse = {
can_unlock?: boolean
locked: boolean
reason?: string | null
}
export type AgentComposerImpactBindingResponse = {
app_id: string
node_id: string
workflow_id: string
}
export type WorkflowDraftVariableWithoutValue = {
description?: string
edited?: boolean
@ -1353,6 +1479,10 @@ export type WorkflowOnlineUser = {
username: string
}
export type AgentScope = 'roster' | 'workflow_only'
export type AgentStatus = 'active' | 'archived'
export type AppVariableConfig = {
default?: unknown
name: string
@ -1433,20 +1563,10 @@ export type AgentSoulToolsConfig = {
dify_tools?: Array<AgentSoulDifyToolConfig>
}
export type DeclaredOutputConfig = {
array_item?: DeclaredArrayItem
check?: DeclaredOutputCheckConfig
description?: string | null
failure_strategy?: DeclaredOutputFailureStrategy
file?: DeclaredOutputFileConfig
id?: string | null
name: string
required?: boolean
type: DeclaredOutputType
}
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
export type SimpleModelConfig = {
model_dict?: JsonValue
pre_prompt?: string | null
@ -1515,29 +1635,7 @@ export type WorkflowRunForArchivedLogResponse = {
triggered_from?: string | null
}
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
export type AgentSoulModelCredentialRef = {
id?: string | null
provider?: string | null
type: string
}
export type AgentSoulDifyToolConfig = {
credential_ref?: AgentSoulDifyToolCredentialRef
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
description?: string | null
enabled?: boolean
name?: string | null
plugin_id?: string | null
provider?: string | null
provider_id?: string | null
provider_type?: string
runtime_parameters?: {
[key: string]: unknown
}
tool_name: string
}
export type WorkflowAgentBindingType = 'inline_agent' | 'roster_agent'
export type DeclaredArrayItem = {
description?: string | null
@ -1566,7 +1664,29 @@ export type DeclaredOutputFileConfig = {
mime_types?: Array<string>
}
export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
export type AgentSoulModelCredentialRef = {
id?: string | null
provider?: string | null
type: string
}
export type AgentSoulDifyToolConfig = {
credential_ref?: AgentSoulDifyToolCredentialRef
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
description?: string | null
enabled?: boolean
name?: string | null
plugin_id?: string | null
provider?: string | null
provider_id?: string | null
provider_type?: string
runtime_parameters?: {
[key: string]: unknown
}
tool_name: string
}
export type UserActionConfig = {
button_style?: ButtonStyle
@ -1576,12 +1696,6 @@ export type UserActionConfig = {
export type FormInputConfig = unknown
export type AgentSoulDifyToolCredentialRef = {
id?: string | null
provider?: string | null
type?: 'provider' | 'tool'
}
export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop'
export type DeclaredOutputRetryConfig = {
@ -1590,6 +1704,12 @@ export type DeclaredOutputRetryConfig = {
retry_interval_ms?: number
}
export type AgentSoulDifyToolCredentialRef = {
id?: string | null
provider?: string | null
type?: 'provider' | 'tool'
}
export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary'
export type ParagraphInputConfig = {
@ -2062,9 +2182,7 @@ export type GetAppsByAppIdAgentComposerData = {
}
export type GetAppsByAppIdAgentComposerResponses = {
200: {
[key: string]: unknown
}
200: AgentAppComposerResponse
}
export type GetAppsByAppIdAgentComposerResponse
@ -2080,9 +2198,7 @@ export type PutAppsByAppIdAgentComposerData = {
}
export type PutAppsByAppIdAgentComposerResponses = {
200: {
[key: string]: unknown
}
200: AgentAppComposerResponse
}
export type PutAppsByAppIdAgentComposerResponse
@ -2098,9 +2214,7 @@ export type GetAppsByAppIdAgentComposerCandidatesData = {
}
export type GetAppsByAppIdAgentComposerCandidatesResponses = {
200: {
[key: string]: unknown
}
200: AgentComposerCandidatesResponse
}
export type GetAppsByAppIdAgentComposerCandidatesResponse
@ -2116,9 +2230,7 @@ export type PostAppsByAppIdAgentComposerValidateData = {
}
export type PostAppsByAppIdAgentComposerValidateResponses = {
200: {
[key: string]: unknown
}
200: AgentComposerValidateResponse
}
export type PostAppsByAppIdAgentComposerValidateResponse
@ -4515,9 +4627,7 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = {
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = {
200: {
[key: string]: unknown
}
200: WorkflowAgentComposerResponse
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
@ -4534,9 +4644,7 @@ export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = {
}
export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = {
200: {
[key: string]: unknown
}
200: WorkflowAgentComposerResponse
}
export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
@ -4553,16 +4661,14 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesData
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses = {
200: {
[key: string]: unknown
}
200: AgentComposerCandidatesResponse
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse
= GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses]
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData = {
body?: never
body: ComposerSavePayload
path: {
app_id: string
node_id: string
@ -4572,9 +4678,7 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData =
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses = {
200: {
[key: string]: unknown
}
200: AgentComposerImpactResponse
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse
@ -4591,9 +4695,7 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterD
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses = {
200: {
[key: string]: unknown
}
200: WorkflowAgentComposerResponse
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse
@ -4610,9 +4712,7 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateData
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses = {
200: {
[key: string]: unknown
}
200: AgentComposerValidateResponse
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse

View File

@ -77,6 +77,14 @@ export const zAdvancedChatWorkflowRunPayload = z.object({
query: z.string().optional().default(''),
})
/**
* AgentComposerValidateResponse
*/
export const zAgentComposerValidateResponse = z.object({
errors: z.array(z.string()).optional(),
result: z.string(),
})
/**
* AnnotationReplyPayload
*/
@ -730,12 +738,16 @@ export const zSite = z.object({
})
/**
* ComposerBindingPayload
* AgentConfigSnapshotSummaryResponse
*/
export const zComposerBindingPayload = z.object({
export const zAgentConfigSnapshotSummaryResponse = z.object({
agent_id: z.string().nullish(),
binding_type: z.enum(['inline_agent', 'roster_agent']),
current_snapshot_id: z.string().nullish(),
created_at: z.string().nullish(),
created_by: z.string().nullish(),
id: z.string(),
summary: z.string().nullish(),
version: z.int(),
version_note: z.string().nullish(),
})
/**
@ -749,6 +761,15 @@ export const zComposerSaveStrategy = z.enum([
'save_to_roster',
])
/**
* ComposerBindingPayload
*/
export const zComposerBindingPayload = z.object({
agent_id: z.string().nullish(),
binding_type: z.enum(['inline_agent', 'roster_agent']),
current_snapshot_id: z.string().nullish(),
})
/**
* ComposerSoulLockPayload
*/
@ -762,6 +783,24 @@ export const zComposerSoulLockPayload = z.object({
*/
export const zComposerVariant = z.enum(['agent_app', 'workflow'])
/**
* AgentComposerSoulCandidatesResponse
*/
export const zAgentComposerSoulCandidatesResponse = z.object({
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
dify_tools: z.array(z.record(z.string(), z.unknown())).optional(),
human_contacts: z.array(z.record(z.string(), z.unknown())).optional(),
knowledge_datasets: z.array(z.record(z.string(), z.unknown())).optional(),
skills_files: z.array(z.record(z.string(), z.unknown())).optional(),
})
/**
* ComposerCandidateCapabilities
*/
export const zComposerCandidateCapabilities = z.object({
human_roster_available: z.boolean().optional().default(false),
})
/**
* AnnotationHitHistory
*/
@ -1235,6 +1274,33 @@ export const zWorkflowPaginationResponse = z.object({
page: z.int(),
})
/**
* AgentComposerSoulLockResponse
*/
export const zAgentComposerSoulLockResponse = z.object({
can_unlock: z.boolean().optional().default(false),
locked: z.boolean(),
reason: z.string().nullish(),
})
/**
* AgentComposerImpactBindingResponse
*/
export const zAgentComposerImpactBindingResponse = z.object({
app_id: z.string(),
node_id: z.string(),
workflow_id: z.string(),
})
/**
* AgentComposerImpactResponse
*/
export const zAgentComposerImpactResponse = z.object({
bindings: z.array(zAgentComposerImpactBindingResponse).optional(),
current_snapshot_id: z.string().nullish(),
workflow_node_count: z.int(),
})
export const zWorkflowDraftVariableWithoutValue = z.object({
description: z.string().optional(),
edited: z.boolean().optional(),
@ -1475,6 +1541,32 @@ export const zWorkflowOnlineUsersResponse = z.object({
data: z.array(zWorkflowOnlineUsersByApp),
})
/**
* AgentScope
*
* Visibility and lifecycle scope of an Agent record.
*/
export const zAgentScope = z.enum(['roster', 'workflow_only'])
/**
* AgentStatus
*
* Soft lifecycle state for Agent records.
*/
export const zAgentStatus = z.enum(['active', 'archived'])
/**
* AgentComposerAgentResponse
*/
export const zAgentComposerAgentResponse = z.object({
active_config_snapshot_id: z.string().nullish(),
description: z.string(),
id: z.string(),
name: z.string(),
scope: zAgentScope,
status: zAgentStatus,
})
/**
* AppVariableConfig
*/
@ -1538,6 +1630,37 @@ export const zAgentSoulSkillsFilesConfig = z.object({
*/
export const zWorkflowNodeJobMode = z.enum(['let_agent_figure_it_out', 'tell_agent_what_to_do'])
/**
* DeclaredOutputType
*/
export const zDeclaredOutputType = z.enum([
'array',
'boolean',
'file',
'number',
'object',
'string',
])
/**
* AgentComposerNodeJobCandidatesResponse
*/
export const zAgentComposerNodeJobCandidatesResponse = z.object({
declare_output_types: z.array(zDeclaredOutputType).optional(),
human_contacts: z.array(z.record(z.string(), z.unknown())).optional(),
previous_node_outputs: z.array(z.record(z.string(), z.unknown())).optional(),
})
/**
* AgentComposerCandidatesResponse
*/
export const zAgentComposerCandidatesResponse = z.object({
allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(),
allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(),
capabilities: zComposerCandidateCapabilities.optional(),
variant: zComposerVariant,
})
/**
* SimpleModelConfig
*/
@ -1725,6 +1848,63 @@ export const zWorkflowArchivedLogPaginationResponse = z.object({
total: z.int(),
})
/**
* WorkflowAgentBindingType
*
* How a workflow node is bound to an Agent.
*/
export const zWorkflowAgentBindingType = z.enum(['inline_agent', 'roster_agent'])
/**
* AgentComposerBindingResponse
*/
export const zAgentComposerBindingResponse = z.object({
agent_id: z.string().nullish(),
binding_type: zWorkflowAgentBindingType,
current_snapshot_id: z.string().nullish(),
id: z.string(),
node_id: z.string(),
workflow_id: z.string(),
})
/**
* DeclaredArrayItem
*
* Per-item shape for an ``array``-typed declared output.
*
* PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays
* are rejected so the runtime type checker and JSON Schema stay easy to reason
* about. Stage 4 §4.2.
*/
export const zDeclaredArrayItem = z.object({
description: z.string().nullish(),
type: zDeclaredOutputType,
})
/**
* DeclaredOutputCheckConfig
*
* File-output content check via a model-based comparison against a benchmark file.
*
* Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3.
*/
export const zDeclaredOutputCheckConfig = z.object({
benchmark_file_ref: z.record(z.string(), z.unknown()).nullish(),
enabled: z.boolean().optional().default(false),
model_ref: z.record(z.string(), z.unknown()).nullish(),
prompt: z.string().nullish(),
})
/**
* DeclaredOutputFileConfig
*
* File-type output metadata. Both lists empty means "any file accepted".
*/
export const zDeclaredOutputFileConfig = z.object({
extensions: z.array(z.string()).optional(),
mime_types: z.array(z.string()).optional(),
})
/**
* AgentKnowledgeQueryMode
*/
@ -1763,124 +1943,8 @@ export const zAgentSoulModelConfig = z.object({
plugin_id: z.string().min(1).max(255),
})
/**
* DeclaredOutputCheckConfig
*
* File-output content check via a model-based comparison against a benchmark file.
*
* Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3.
*/
export const zDeclaredOutputCheckConfig = z.object({
benchmark_file_ref: z.record(z.string(), z.unknown()).nullish(),
enabled: z.boolean().optional().default(false),
model_ref: z.record(z.string(), z.unknown()).nullish(),
prompt: z.string().nullish(),
})
/**
* DeclaredOutputFileConfig
*
* File-type output metadata. Both lists empty means "any file accepted".
*/
export const zDeclaredOutputFileConfig = z.object({
extensions: z.array(z.string()).optional(),
mime_types: z.array(z.string()).optional(),
})
/**
* DeclaredOutputType
*/
export const zDeclaredOutputType = z.enum([
'array',
'boolean',
'file',
'number',
'object',
'string',
])
/**
* DeclaredArrayItem
*
* Per-item shape for an ``array``-typed declared output.
*
* PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays
* are rejected so the runtime type checker and JSON Schema stay easy to reason
* about. Stage 4 §4.2.
*/
export const zDeclaredArrayItem = z.object({
description: z.string().nullish(),
type: zDeclaredOutputType,
})
export const zFormInputConfig = z.unknown()
/**
* AgentSoulDifyToolCredentialRef
*
* Reference to a stored Dify Plugin Tool credential.
*
* Secret values are resolved only at runtime. The legacy ``credential_id``
* field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so
* old Agent tool payloads can be read while new payloads stay explicit.
*/
export const zAgentSoulDifyToolCredentialRef = z.object({
id: z.string().max(255).nullish(),
provider: z.string().max(255).nullish(),
type: z.enum(['provider', 'tool']).optional().default('tool'),
})
/**
* AgentSoulDifyToolConfig
*
* One Dify Plugin Tool configured on Agent Soul.
*
* The API backend prepares this persisted product shape into
* ``DifyPluginToolConfig`` before sending a run request to Agent backend.
* ``provider_id`` keeps compatibility with existing Agent tool config payloads;
* new callers should send ``plugin_id`` + ``provider`` when available.
*/
export const zAgentSoulDifyToolConfig = z.object({
credential_ref: zAgentSoulDifyToolCredentialRef.optional(),
credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'),
description: z.string().nullish(),
enabled: z.boolean().optional().default(true),
name: z.string().max(255).nullish(),
plugin_id: z.string().max(255).nullish(),
provider: z.string().max(255).nullish(),
provider_id: z.string().max(255).nullish(),
provider_type: z.string().optional().default('plugin'),
runtime_parameters: z.record(z.string(), z.unknown()).optional(),
tool_name: z.string().min(1).max(255),
})
/**
* AgentSoulToolsConfig
*/
export const zAgentSoulToolsConfig = z.object({
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
})
/**
* AgentSoulConfig
*/
export const zAgentSoulConfig = z.object({
app_features: z.record(z.string(), z.unknown()).optional(),
app_variables: z.array(zAppVariableConfig).optional(),
env: zAgentSoulEnvConfig.optional(),
human: zAgentSoulHumanConfig.optional(),
knowledge: zAgentSoulKnowledgeConfig.optional(),
memory: zAgentSoulMemoryConfig.optional(),
misc_legacy: z.record(z.string(), z.unknown()).optional(),
model: zAgentSoulModelConfig.optional(),
prompt: zAgentSoulPromptConfig.optional(),
sandbox: zAgentSoulSandboxConfig.optional(),
schema_version: z.int().optional().default(1),
skills_files: zAgentSoulSkillsFilesConfig.optional(),
tools: zAgentSoulToolsConfig.optional(),
})
/**
* OutputErrorStrategy
*
@ -1951,6 +2015,83 @@ export const zWorkflowNodeJobConfig = z.object({
workflow_prompt: z.string().optional().default(''),
})
/**
* AgentSoulDifyToolCredentialRef
*
* Reference to a stored Dify Plugin Tool credential.
*
* Secret values are resolved only at runtime. The legacy ``credential_id``
* field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so
* old Agent tool payloads can be read while new payloads stay explicit.
*/
export const zAgentSoulDifyToolCredentialRef = z.object({
id: z.string().max(255).nullish(),
provider: z.string().max(255).nullish(),
type: z.enum(['provider', 'tool']).optional().default('tool'),
})
/**
* AgentSoulDifyToolConfig
*
* One Dify Plugin Tool configured on Agent Soul.
*
* The API backend prepares this persisted product shape into
* ``DifyPluginToolConfig`` before sending a run request to Agent backend.
* ``provider_id`` keeps compatibility with existing Agent tool config payloads;
* new callers should send ``plugin_id`` + ``provider`` when available.
*/
export const zAgentSoulDifyToolConfig = z.object({
credential_ref: zAgentSoulDifyToolCredentialRef.optional(),
credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'),
description: z.string().nullish(),
enabled: z.boolean().optional().default(true),
name: z.string().max(255).nullish(),
plugin_id: z.string().max(255).nullish(),
provider: z.string().max(255).nullish(),
provider_id: z.string().max(255).nullish(),
provider_type: z.string().optional().default('plugin'),
runtime_parameters: z.record(z.string(), z.unknown()).optional(),
tool_name: z.string().min(1).max(255),
})
/**
* AgentSoulToolsConfig
*/
export const zAgentSoulToolsConfig = z.object({
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
})
/**
* AgentSoulConfig
*/
export const zAgentSoulConfig = z.object({
app_features: z.record(z.string(), z.unknown()).optional(),
app_variables: z.array(zAppVariableConfig).optional(),
env: zAgentSoulEnvConfig.optional(),
human: zAgentSoulHumanConfig.optional(),
knowledge: zAgentSoulKnowledgeConfig.optional(),
memory: zAgentSoulMemoryConfig.optional(),
misc_legacy: z.record(z.string(), z.unknown()).optional(),
model: zAgentSoulModelConfig.optional(),
prompt: zAgentSoulPromptConfig.optional(),
sandbox: zAgentSoulSandboxConfig.optional(),
schema_version: z.int().optional().default(1),
skills_files: zAgentSoulSkillsFilesConfig.optional(),
tools: zAgentSoulToolsConfig.optional(),
})
/**
* AgentAppComposerResponse
*/
export const zAgentAppComposerResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse,
agent: zAgentComposerAgentResponse,
agent_soul: zAgentSoulConfig,
save_options: z.array(zComposerSaveStrategy),
variant: z.string(),
})
/**
* ComposerSavePayload
*/
@ -1967,6 +2108,25 @@ export const zComposerSavePayload = z.object({
version_note: z.string().nullish(),
})
/**
* WorkflowAgentComposerResponse
*/
export const zWorkflowAgentComposerResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(),
agent: zAgentComposerAgentResponse.optional(),
agent_soul: zAgentSoulConfig,
app_id: z.string().nullish(),
binding: zAgentComposerBindingResponse.optional(),
effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(),
impact_summary: zAgentComposerImpactResponse.optional(),
node_id: z.string().nullish(),
node_job: zWorkflowNodeJobConfig,
save_options: z.array(zComposerSaveStrategy),
soul_lock: zAgentComposerSoulLockResponse,
variant: z.string(),
workflow_id: z.string().nullish(),
})
/**
* ButtonStyle
*
@ -2412,9 +2572,9 @@ export const zGetAppsByAppIdAgentComposerPath = z.object({
})
/**
* Success
* Agent app composer state
*/
export const zGetAppsByAppIdAgentComposerResponse = z.record(z.string(), z.unknown())
export const zGetAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse
export const zPutAppsByAppIdAgentComposerBody = zComposerSavePayload
@ -2423,18 +2583,18 @@ export const zPutAppsByAppIdAgentComposerPath = z.object({
})
/**
* Success
* Agent app composer saved
*/
export const zPutAppsByAppIdAgentComposerResponse = z.record(z.string(), z.unknown())
export const zPutAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse
export const zGetAppsByAppIdAgentComposerCandidatesPath = z.object({
app_id: z.string(),
})
/**
* Success
* Agent app composer candidates
*/
export const zGetAppsByAppIdAgentComposerCandidatesResponse = z.record(z.string(), z.unknown())
export const zGetAppsByAppIdAgentComposerCandidatesResponse = zAgentComposerCandidatesResponse
export const zPostAppsByAppIdAgentComposerValidateBody = zComposerSavePayload
@ -2443,9 +2603,9 @@ export const zPostAppsByAppIdAgentComposerValidatePath = z.object({
})
/**
* Success
* Agent app composer validation result
*/
export const zPostAppsByAppIdAgentComposerValidateResponse = z.record(z.string(), z.unknown())
export const zPostAppsByAppIdAgentComposerValidateResponse = zAgentComposerValidateResponse
export const zGetAppsByAppIdAgentLogsPath = z.object({
app_id: z.string(),
@ -3723,12 +3883,10 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.obj
})
/**
* Success
* Workflow agent composer state
*/
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = z.record(
z.string(),
z.unknown(),
)
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
= zWorkflowAgentComposerResponse
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerBody = zComposerSavePayload
@ -3738,12 +3896,10 @@ export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.obj
})
/**
* Success
* Workflow agent composer saved
*/
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = z.record(
z.string(),
z.unknown(),
)
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
= zWorkflowAgentComposerResponse
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath = z.object({
app_id: z.string(),
@ -3751,12 +3907,13 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPa
})
/**
* Success
* Workflow agent composer candidates
*/
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = z.record(
z.string(),
z.unknown(),
)
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse
= zAgentComposerCandidatesResponse
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody
= zComposerSavePayload
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath = z.object({
app_id: z.string(),
@ -3764,12 +3921,10 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath
})
/**
* Success
* Workflow agent composer impact
*/
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse = z.record(
z.string(),
z.unknown(),
)
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse
= zAgentComposerImpactResponse
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody
= zComposerSavePayload
@ -3780,10 +3935,10 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRoste
})
/**
* Success
* Workflow agent composer saved to roster
*/
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse
= z.record(z.string(), z.unknown())
= zWorkflowAgentComposerResponse
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody
= zComposerSavePayload
@ -3794,12 +3949,10 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePat
})
/**
* Success
* Workflow agent composer validation result
*/
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse = z.record(
z.string(),
z.unknown(),
)
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse
= zAgentComposerValidateResponse
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({
app_id: z.string(),

View File

@ -1,4 +1,4 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { renderWithNuqs } from '@/test/nuqs-testing'
@ -13,9 +13,11 @@ const mockUseWorkflowOnlineUsers = vi.hoisted(() => vi.fn((_options: unknown) =>
const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
let mockSearchParams = new URLSearchParams('')
vi.mock('@/next/navigation', () => ({
useRouter: () => mockRouter,
useSearchParams: () => new URLSearchParams(''),
usePathname: () => '/apps',
useSearchParams: () => mockSearchParams,
}))
vi.mock('@/service/client', () => ({
@ -49,12 +51,10 @@ vi.mock('@/context/app-context', () => ({
}))
const mockSetKeywords = vi.fn()
const mockSetTagIDs = vi.fn()
const mockSetIsCreatedByMe = vi.fn()
const mockSetCategory = vi.fn()
const mockQueryState = {
category: 'all',
tagIDs: [] as string[],
keywords: '',
isCreatedByMe: false,
}
@ -64,11 +64,20 @@ vi.mock('../hooks/use-apps-query-state', () => ({
query: mockQueryState,
setCategory: mockSetCategory,
setKeywords: mockSetKeywords,
setTagIDs: mockSetTagIDs,
setIsCreatedByMe: mockSetIsCreatedByMe,
}),
}))
vi.mock('@/features/tag-management/components/tag-filter', () => ({
TagFilter: ({ value, onChange, onOpenTagManagement }: { value: string[], onChange: (value: string[]) => void, onOpenTagManagement: () => void }) => (
<div>
<button type="button" onClick={() => onChange(['tag-1'])}>common.tag.placeholder</button>
<span data-testid="tag-filter-value">{value.join(',')}</span>
<button type="button" onClick={onOpenTagManagement}>Manage tags</button>
</div>
),
}))
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('../hooks/use-dsl-drag-drop', () => ({
@ -190,9 +199,9 @@ vi.mock('../app-card', () => ({
}))
vi.mock('../new-app-card', () => ({
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
default: (_props: { ref?: React.Ref<unknown> }) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
}),
},
}))
vi.mock('../empty', () => ({
@ -230,6 +239,7 @@ beforeAll(() => {
// Render helper wrapping with shared nuqs testing helper plus a seeded
// systemFeatures cache so List can resolve its useSuspenseQuery.
const renderList = (searchParams = '') => {
mockSearchParams = new URLSearchParams(searchParams)
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
@ -253,7 +263,6 @@ describe('List', () => {
mockServiceState.isLoading = false
mockServiceState.isFetchingNextPage = false
mockQueryState.category = 'all'
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockUseWorkflowOnlineUsers.mockClear()
@ -375,12 +384,12 @@ describe('List', () => {
describe('App List Query', () => {
it('should build paged query input from active filters', () => {
mockQueryState.tagIDs = ['tag-1']
mockQueryState.keywords = 'sales'
mockQueryState.isCreatedByMe = true
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
fireEvent.click(screen.getByText('common.tag.placeholder'))
const options = mockAppListInfiniteOptions.mock.calls.at(-1)?.[0] as AppListInfiniteOptions
@ -397,6 +406,17 @@ describe('List', () => {
expect(options.getNextPageParam({ has_more: true, page: 2 })).toBe(3)
expect(options.getNextPageParam({ has_more: false, page: 2 })).toBeUndefined()
})
it('should remove legacy tagIDs from URL while preserving other filters', async () => {
renderList('?category=workflow&tagIDs=tag-1;tag-2&keywords=sales&isCreatedByMe=true')
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith(
'/apps?category=workflow&keywords=sales&isCreatedByMe=true',
{ scroll: false },
)
})
})
})
describe('Tag Filter', () => {

View File

@ -5,6 +5,7 @@ import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../../constants'
import { useAppsQueryState } from '../use-apps-query-state'
const renderWithAdapter = (searchParams = '') => {
// eslint-disable-next-line react/use-state -- renderHook executes a custom hook, not React.useState
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
}
@ -18,13 +19,11 @@ describe('useAppsQueryState', () => {
expect(result.current.query).toEqual({
category: 'all',
tagIDs: [],
keywords: '',
isCreatedByMe: false,
})
expect(typeof result.current.setCategory).toBe('function')
expect(typeof result.current.setKeywords).toBe('function')
expect(typeof result.current.setTagIDs).toBe('function')
expect(typeof result.current.setIsCreatedByMe).toBe('function')
})
@ -35,7 +34,6 @@ describe('useAppsQueryState', () => {
expect(result.current.query).toEqual({
category: AppModeEnum.WORKFLOW,
tagIDs: ['tag1', 'tag2'],
keywords: 'search term',
isCreatedByMe: true,
})
@ -117,33 +115,6 @@ describe('useAppsQueryState', () => {
}
})
it('should update tag filter URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()
act(() => {
result.current.setTagIDs(['tag1', 'tag2'])
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.tagIDs).toEqual(['tag1', 'tag2'])
expect(update.searchParams.get('tagIDs')).toBe('tag1;tag2')
expect(update.options.history).toBe('push')
})
it('should remove tagIDs from URL when empty', async () => {
const { result, onUrlUpdate } = renderWithAdapter('?tagIDs=tag1;tag2')
act(() => {
result.current.setTagIDs([])
})
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls.at(-1)![0]
expect(result.current.query.tagIDs).toEqual([])
expect(update.searchParams.has('tagIDs')).toBe(false)
})
it('should update created-by-me URL state', async () => {
const { result, onUrlUpdate } = renderWithAdapter()

View File

@ -1,4 +1,4 @@
import { debounce, parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { debounce, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { AppModes } from '@/types/app'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
@ -16,9 +16,6 @@ const appListQueryParsers = {
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' }),
tagIDs: parseAsArrayOf(parseAsString, ';')
.withDefault([])
.withOptions({ history: 'push' }),
keywords: parseAsString.withDefault('').withOptions({
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
}),
@ -38,10 +35,6 @@ export function useAppsQueryState() {
setQuery({ keywords })
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery({ tagIDs })
}, [setQuery])
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
setQuery({ isCreatedByMe })
}, [setQuery])
@ -50,7 +43,6 @@ export function useAppsQueryState() {
query,
setCategory,
setKeywords,
setTagIDs,
setIsCreatedByMe,
}), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe])
}), [query, setCategory, setKeywords, setIsCreatedByMe])
}

View File

@ -15,6 +15,7 @@ import { useAppContext } from '@/context/app-context'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
@ -44,15 +45,18 @@ const List: FC<Props> = ({
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const searchParams = useSearchParams()
const pathname = usePathname()
const { replace } = useRouter()
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
query: { category, tagIDs, keywords, isCreatedByMe },
query: { category, keywords, isCreatedByMe },
setCategory,
setKeywords,
setTagIDs,
setIsCreatedByMe,
} = useAppsQueryState()
const [tagIDs, setTagIDs] = useState<string[]>([])
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
@ -71,6 +75,16 @@ const List: FC<Props> = ({
enabled: isCurrentWorkspaceEditor,
})
useEffect(() => {
if (!searchParams.has('tagIDs'))
return
const params = new URLSearchParams(searchParams.toString())
params.delete('tagIDs')
const query = params.toString()
replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
}, [pathname, replace, searchParams])
const appListQuery = useMemo<AppListQuery>(() => ({
page: 1,
limit: 30,

View File

@ -3,6 +3,12 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
import PluginItem from '../plugin-item'
vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({
MagicBox: ({ className }: { className?: string }) => (
<svg data-testid="magic-box-icon" className={className} />
),
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src, size }: { src: string, size: string }) => (
<div data-testid="card-icon" data-src={src} data-size={size} />
@ -108,6 +114,23 @@ describe('PluginItem', () => {
expect(cardIcon).toHaveAttribute('data-src', 'https://example.com/icons/my-icon.svg')
expect(cardIcon).toHaveAttribute('data-size', 'small')
})
it('should show default tool icon when plugin icon is empty', () => {
const { container } = render(
<PluginItem
plugin={createPlugin({ icon: '' })}
getIconUrl={mockGetIconUrl}
language="en_US"
statusIcon={<span data-testid="status-icon" />}
statusText="status"
/>,
)
expect(mockGetIconUrl).not.toHaveBeenCalled()
expect(screen.queryByTestId('card-icon')).not.toBeInTheDocument()
expect(container.querySelector('[data-testid="magic-box-icon"]')).toHaveClass('size-8', 'text-text-tertiary')
expect(screen.getByTestId('status-icon').parentElement).toHaveClass('absolute', '-bottom-0.5', '-right-0.5', 'z-10')
})
})
describe('Props', () => {

View File

@ -1,6 +1,7 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
type PluginItemProps = {
@ -24,13 +25,20 @@ const PluginItem: FC<PluginItemProps> = ({
action,
onClear,
}) => {
const hasPluginIcon = !!plugin.icon
return (
<div className="group/item flex gap-1 rounded-lg p-2 hover:bg-state-base-hover">
<div className="relative shrink-0 self-start">
<CardIcon
size="small"
src={getIconUrl(plugin.icon)}
/>
{hasPluginIcon
? (
<CardIcon
size="small"
src={getIconUrl(plugin.icon)}
/>
)
// eslint-disable-next-line hyoban/prefer-tailwind-icons -- Reuse the same MagicBox component as the marketplace install button.
: <MagicBox className="size-8 text-text-tertiary" />}
<div className="absolute -right-0.5 -bottom-0.5 z-10">
{statusIcon}
</div>

View File

@ -6,10 +6,9 @@ import DevicePage from '../page'
const mockPush = vi.fn()
const mockReplace = vi.fn()
const mockDeviceLookup = vi.fn()
let mockSearchParams: Record<string, string | null> = {}
vi.mock('@/next/navigation', () => ({
useSearchParams: () => ({ get: (key: string) => mockSearchParams[key] ?? null }),
useSearchParams: () => ({ get: () => null }),
useRouter: () => ({ push: mockPush, replace: mockReplace }),
usePathname: () => '/device',
}))
@ -54,12 +53,6 @@ let MockDeviceFlowError: MockDeviceFlowErrorCtor
beforeEach(async () => {
vi.clearAllMocks()
mockSearchParams = {}
// router.replace(pathname) in the real app drops the query string; mirror
// that so useSearchParams reflects the cleared URL on the next render.
mockReplace.mockImplementation(() => {
mockSearchParams = {}
})
mockUseQuery.mockReturnValue({ data: undefined, isError: false } as ReturnType<typeof useQuery>)
const mod = await import('@/service/device-flow') as { DeviceFlowError: MockDeviceFlowErrorCtor }
MockDeviceFlowError = mod.DeviceFlowError
@ -117,41 +110,3 @@ describe('error_lookup_failed terminal state', () => {
expect(screen.queryByText('Could not verify the code')).not.toBeInTheDocument()
})
})
describe('sso_error inline banner on the code-entry page', () => {
const SSO_BANNER_COPY = /identity is linked to a Dify account/i
it('shows the error banner with friendly copy when sso_error is present', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
expect(await screen.findByText(SSO_BANNER_COPY)).toBeInTheDocument()
})
it('keeps the code-entry screen visible (error on main page, not a separate view)', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText(SSO_BANNER_COPY)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Continue/i })).toBeInTheDocument()
})
it('does not surface the raw backend error code', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText(SSO_BANNER_COPY)
expect(screen.queryByText('email_belongs_to_dify_account')).not.toBeInTheDocument()
})
it('does not scrub the param on mount (regression: error was wiped by router.replace)', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText(SSO_BANNER_COPY)
expect(mockReplace).not.toHaveBeenCalled()
})
it('shows no banner when sso_error is absent', () => {
render(<DevicePage />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.queryByText(SSO_BANNER_COPY)).not.toBeInTheDocument()
})
})

View File

@ -14,7 +14,7 @@ import AuthorizeAccount from './components/authorize-account'
import AuthorizeSSO from './components/authorize-sso'
import Chooser from './components/chooser'
import CodeInput from './components/code-input'
import { classifyLookupError, ssoErrorCopy } from './utils/error-copy'
import { classifyLookupError } from './utils/error-copy'
import { isValidUserCode } from './utils/user-code'
type View
@ -33,7 +33,6 @@ export default function DevicePage() {
const pathname = usePathname()
const urlUserCode = (searchParams.get('user_code') || '').trim().toUpperCase()
const ssoVerified = searchParams.get('sso_verified') === '1'
const ssoError = searchParams.get('sso_error') || ''
const [typed, setTyped] = useState('')
const [view, setView] = useState<View>({ kind: 'code_entry' })
@ -126,12 +125,6 @@ export default function DevicePage() {
<>
{view.kind === 'code_entry' && (
<div className="flex flex-col gap-5">
{ssoError && (
<div className="flex items-start gap-2 rounded-lg bg-state-destructive-hover p-3">
<span className="mt-0.5 i-ri-close-circle-line h-4 w-4 shrink-0 text-util-colors-red-red-600" />
<p className="text-sm text-text-destructive">{ssoErrorCopy(ssoError)}</p>
</div>
)}
<div>
<h1 className="text-2xl font-semibold text-text-primary">Authorize Dify CLI</h1>
<p className="mt-2 text-sm text-text-secondary">

View File

@ -30,18 +30,6 @@ export function approveErrorCopy(err: unknown): string {
return DEFAULT_MESSAGE
}
// SSO-branch failures arrive as a `sso_error` query param set by the backend
// (oauth_device_sso sso-complete) when it redirects back to /device.
const SSO_ERROR_COPY: Record<string, string> = {
email_belongs_to_dify_account: 'This identity is linked to a Dify account. Use “Sign in with Dify account” instead.',
}
const DEFAULT_SSO_ERROR_MESSAGE = 'Single sign-on could not be completed. Try again.'
export function ssoErrorCopy(code: string): string {
return SSO_ERROR_COPY[code] ?? DEFAULT_SSO_ERROR_MESSAGE
}
export type LookupOutcome = 'expired' | 'rate_limited' | 'failed'
export function classifyLookupError(err: unknown): LookupOutcome {

View File

@ -18,6 +18,10 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(() => new URLSearchParams()),
}))
vi.mock('@/app/components/billing/pricing', () => ({
default: () => <div>billing.plansCommon.mostPopular</div>,
}))
const mockUseProviderContext = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),