Compare commits

..

7 Commits

Author SHA1 Message Date
7c5ae57198 Merge branch 'fix/device-sso-error-display' into deploy/enterprise 2026-05-28 22:05:55 -07:00
c6c342486e fix(cli): handle null account on external-SSO device login
The poll-success contract sends account: null for the external_sso
subject type. login guarded only !== undefined and then read .id on
null, crashing the device-flow SSO login. Normalize null/undefined to
one path so the external_subject branch is taken.
2026-05-28 22:05:45 -07:00
93978fd0ab Merge branch 'fix/device-sso-error-display' into deploy/enterprise 2026-05-28 21:15:19 -07:00
7bc391a0fc fix(device): show sso_error as inline banner on code-entry page
Replace the standalone error_sso terminal view with an inline banner
derived directly from the sso_error query param on the code-entry
screen. The banner is pure-rendered from the URL (no effect, no extra
state), so it survives re-render/remount and the error is shown on the
main page instead of a separate view.
2026-05-28 21:14:49 -07:00
54471fa837 Merge fix/device-sso-error-display: display device-flow SSO error 2026-05-28 20:18:09 -07:00
1fd740cbbe fix(device): render sso_error via stable view so it actually displays
The prior fix set errMsg(ssoError) and router.replace(pathname) in the same
effect run. router.replace stripped the param and re-rendered, wiping the
just-set state and resetting view to code_entry — so the SSO error never
showed and the page silently bounced back to code entry.

Drive sso_error from a dedicated terminal error_sso view (matching the
existing error_* views), leave the non-sensitive param in the URL, and map
the code to friendly copy via a dispatch table. Add regression tests,
including one asserting router.replace is not called on mount.
2026-05-28 20:10:11 -07:00
4a2f90e7ec fix(device): display sso_error query param on /device page 2026-05-28 02:05:15 -07:00
25 changed files with 599 additions and 1791 deletions

View File

@ -1,17 +1,9 @@
from flask_restx import Resource
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.schema import 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
@ -19,40 +11,23 @@ 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 dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
),
return 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
@ -61,24 +36,18 @@ 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 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,
),
return 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
@ -86,29 +55,21 @@ 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 dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
return {"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 dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_workflow_candidates(app_id=app_model.id),
)
return 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
@ -118,21 +79,13 @@ 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 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),
)
return {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
return 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
@ -141,34 +94,26 @@ 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 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,
),
return 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 dump_response(
AgentAppComposerResponse,
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id),
)
return 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
@ -177,23 +122,17 @@ 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 dump_response(
AgentAppComposerResponse,
AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_model.id,
account_id=account.id,
payload=payload,
),
return 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
@ -201,20 +140,14 @@ class AgentAppComposerValidateApi(Resource):
def post(self, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
return {"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 dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
)
return AgentComposerService.get_agent_app_candidates(app_id=app_model.id)

View File

@ -4,18 +4,10 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import 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
@ -37,14 +29,6 @@ register_schema_models(
RosterAgentUpdatePayload,
RosterListQuery,
)
register_response_schema_models(
console_ns,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
def _agent_roster_service() -> AgentRosterService:
@ -53,23 +37,17 @@ 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 dump_response(
AgentRosterListResponse,
_agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
),
return _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
@ -79,49 +57,36 @@ 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 dump_response(
AgentRosterResponse,
service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id),
), 201
return 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 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,
),
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,
)
@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 dump_response(
AgentRosterResponse,
_agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id)),
)
return _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
@ -129,14 +94,10 @@ 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 dump_response(
AgentRosterResponse,
_agent_roster_service().update_roster_agent(
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
),
return _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
@ -149,31 +110,23 @@ 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 dump_response(
AgentConfigSnapshotListResponse,
{"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))},
)
return {"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 dump_response(
AgentConfigSnapshotDetailResponse,
_agent_roster_service().get_agent_version_detail(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
),
return _agent_roster_service().get_agent_version_detail(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
)

View File

@ -1,192 +0,0 @@
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,19 +343,11 @@ 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 | Schema |
| ---- | ----------- | ------ |
| 200 | Agent roster list | [AgentRosterListResponse](#agentrosterlistresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### POST
##### Parameters
@ -366,27 +358,18 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Agent created | [AgentRosterResponse](#agentrosterresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /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 | Schema |
| ---- | ----------- | ------ |
| 200 | Agent invite options | [AgentInviteOptionsResponse](#agentinviteoptionsresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /agents/{agent_id}
@ -401,7 +384,7 @@ Check if activation token is valid
| Code | Description |
| ---- | ----------- |
| 204 | Agent archived |
| 200 | Success |
#### GET
##### Parameters
@ -412,9 +395,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent detail | [AgentRosterResponse](#agentrosterresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### PATCH
##### Parameters
@ -426,9 +409,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent updated | [AgentRosterResponse](#agentrosterresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /agents/{agent_id}/versions
@ -441,9 +424,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent versions | [AgentConfigSnapshotListResponse](#agentconfigsnapshotlistresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /agents/{agent_id}/versions/{version_id}
@ -457,9 +440,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent version detail | [AgentConfigSnapshotDetailResponse](#agentconfigsnapshotdetailresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /all-workspaces
@ -995,9 +978,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer state | [AgentAppComposerResponse](#agentappcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### PUT
##### Parameters
@ -1009,9 +992,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer saved | [AgentAppComposerResponse](#agentappcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/agent-composer/candidates
@ -1024,9 +1007,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/agent-composer/validate
@ -1040,9 +1023,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/agent/logs
@ -3241,9 +3224,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer state | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### PUT
##### Parameters
@ -3256,9 +3239,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer saved | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates
@ -3272,9 +3255,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact
@ -3285,13 +3268,12 @@ Run draft workflow loop node
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| node_id | path | | Yes | string |
| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer impact | [AgentComposerImpactResponse](#agentcomposerimpactresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster
@ -3306,9 +3288,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer saved to roster | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate
@ -3323,9 +3305,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run
@ -10669,150 +10651,6 @@ 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.
@ -10827,35 +10665,6 @@ 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 |
@ -10865,27 +10674,6 @@ 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 |
@ -10899,50 +10687,6 @@ the current roster/workflow APIs scoped to Dify Agent.
| 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 |
@ -11077,22 +10821,6 @@ 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 |
@ -11800,12 +11528,6 @@ 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 |
@ -15647,32 +15369,6 @@ 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,90 +30,6 @@ 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")
@ -151,15 +67,14 @@ def test_roster_list_post_creates_agent_and_returns_detail(app, monkeypatch):
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_roster_agent_detail",
lambda _self, **kwargs: _agent_response(kwargs["agent_id"]),
lambda _self, **kwargs: {"id": kwargs["agent_id"], "tenant_id": kwargs["tenant_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"
assert result["agent_kind"] == "dify_agent"
assert result == {"id": "agent-1", "tenant_id": "tenant-1"}
def test_invite_options_get_parses_app_id(app, monkeypatch):
@ -167,14 +82,14 @@ def test_invite_options_get_parses_app_id(app, monkeypatch):
def list_invite_options(_self, **kwargs):
captured.update(kwargs)
return {"data": [], "page": kwargs["page"], "limit": kwargs["limit"], "total": 0, "has_more": False}
return {"data": []}
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": [], "page": 1, "limit": 10, "total": 0, "has_more": False}
assert result == {"data": []}
assert captured == {"tenant_id": "tenant-1", "page": 1, "limit": 10, "keyword": None, "app_id": "app-1"}
@ -185,12 +100,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: _agent_response(kwargs["agent_id"]),
lambda _self, **kwargs: {"id": kwargs["agent_id"]},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"update_roster_agent",
lambda _self, **kwargs: {**_agent_response(kwargs["agent_id"]), "description": kwargs["payload"].description},
lambda _self, **kwargs: {"id": kwargs["agent_id"], "description": kwargs["payload"].description},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
@ -200,29 +115,12 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"list_agent_versions",
lambda _self, **kwargs: [_version_response()],
lambda _self, **kwargs: [{"id": "version-1"}],
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_agent_version_detail",
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,
}
],
},
lambda _self, **kwargs: {"id": kwargs["version_id"], "agent_id": kwargs["agent_id"]},
)
assert _unwrap(AgentRosterDetailApi.get)(AgentRosterDetailApi(), agent_id)["id"] == agent_id
@ -230,10 +128,11 @@ 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"][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
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,
}
def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monkeypatch):
@ -246,52 +145,50 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monk
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_workflow_composer",
lambda **kwargs: _workflow_composer_response(node_id=kwargs["node_id"]),
lambda **kwargs: {"node_id": kwargs["node_id"]},
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_workflow_composer",
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
lambda **kwargs: {"saved": kwargs["payload"].save_strategy.value, "account_id": kwargs["account_id"]},
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_workflow_candidates",
lambda **kwargs: _candidates_response("workflow"),
lambda **kwargs: {"data": []},
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"calculate_impact",
lambda **kwargs: {
"current_snapshot_id": kwargs["current_snapshot_id"],
"workflow_node_count": 1,
"bindings": [],
},
lambda **kwargs: {"current_snapshot_id": kwargs["current_snapshot_id"], "workflow_node_count": 1},
)
workflow_state = _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1")
assert workflow_state["node_id"] == "node-1"
assert _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1") == {
"node_id": "node-1"
}
with app.test_request_context(json=payload):
saved_state = _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1")
assert saved_state["save_options"] == ["node_job_only"]
assert _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1") == {
"saved": "node_job_only",
"account_id": "account-1",
}
assert _unwrap(WorkflowAgentComposerValidateApi.post)(
WorkflowAgentComposerValidateApi(), app_model, "node-1"
) == {"result": "success", "errors": []}
assert (
_unwrap(WorkflowAgentComposerCandidatesApi.get)(WorkflowAgentComposerCandidatesApi(), app_model, "node-1")[
"variant"
]
== "workflow"
)
assert _unwrap(WorkflowAgentComposerCandidatesApi.get)(
WorkflowAgentComposerCandidatesApi(), app_model, "node-1"
) == {"data": []}
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"
)["save_options"] == ["node_job_only"]
assert (
_unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
WorkflowAgentComposerSaveToRosterApi(), app_model, "node-1"
)["saved"]
== "node_job_only"
)
def test_workflow_impact_returns_empty_without_version(app):
@ -315,26 +212,28 @@ def test_agent_app_composer_get_put_validate_and_candidates(app, monkeypatch):
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_agent_app_composer",
lambda **kwargs: _agent_app_composer_response(),
lambda **kwargs: {"loaded": True},
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_agent_app_composer",
lambda **kwargs: _agent_app_composer_response(),
lambda **kwargs: {"saved": kwargs["payload"].variant.value, "account_id": kwargs["account_id"]},
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_agent_app_candidates",
lambda **kwargs: _candidates_response("agent_app"),
lambda **kwargs: {"data": []},
)
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model) == {"loaded": True}
with app.test_request_context(json=payload):
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model) == {
"saved": "agent_app",
"account_id": "account-1",
}
assert _unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), app_model) == {
"result": "success",
"errors": [],
}
agent_app_candidates = _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model)
assert agent_app_candidates["variant"] == "agent_app"
assert _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model) == {"data": []}

View File

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

View File

@ -99,8 +99,9 @@ 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)
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 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`)
const ws = findDefaultWorkspace(s)
if (ws !== undefined)
w.write(` Workspace: ${ws.name}\n`)
@ -139,11 +140,12 @@ function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): Hos
token_id: s.token_id,
tokens: { bearer: s.token },
}
if (s.account !== undefined) {
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
const account = s.account ?? undefined
if (account !== undefined) {
bundle.account = { id: account.id, email: account.email, name: account.name }
}
if (s.subject_email !== undefined && s.subject_email !== ''
&& (s.account === undefined || s.account.id === '')) {
&& (account === undefined || 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,6 +356,9 @@ 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,9 +12,7 @@ import {
zGetAgentsByAgentIdVersionsByVersionIdResponse,
zGetAgentsByAgentIdVersionsPath,
zGetAgentsByAgentIdVersionsResponse,
zGetAgentsInviteOptionsQuery,
zGetAgentsInviteOptionsResponse,
zGetAgentsQuery,
zGetAgentsResponse,
zPatchAgentsByAgentIdBody,
zPatchAgentsByAgentIdPath,
@ -23,15 +21,22 @@ 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 = {
@ -61,8 +66,16 @@ 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',
@ -77,20 +90,35 @@ 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',
@ -100,8 +128,16 @@ 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',
@ -118,15 +154,22 @@ 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)
/**
@ -143,7 +186,6 @@ export const post = oc
method: 'POST',
operationId: 'postAgents',
path: '/agents',
successStatus: 201,
tags: ['console'],
})
.input(z.object({ body: zPostAgentsBody }))

View File

@ -4,14 +4,6 @@ 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
@ -22,38 +14,6 @@ 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
@ -62,22 +22,6 @@ 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
@ -100,63 +44,6 @@ 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
@ -237,13 +124,6 @@ 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 = {
@ -277,16 +157,14 @@ export type AgentSoulDifyToolCredentialRef = {
export type GetAgentsData = {
body?: never
path?: never
query?: {
keyword?: string
limit?: number
page?: number
}
query?: never
url: '/agents'
}
export type GetAgentsResponses = {
200: AgentRosterListResponse
200: {
[key: string]: unknown
}
}
export type GetAgentsResponse = GetAgentsResponses[keyof GetAgentsResponses]
@ -299,7 +177,9 @@ export type PostAgentsData = {
}
export type PostAgentsResponses = {
201: AgentRosterResponse
200: {
[key: string]: unknown
}
}
export type PostAgentsResponse = PostAgentsResponses[keyof PostAgentsResponses]
@ -307,17 +187,14 @@ export type PostAgentsResponse = PostAgentsResponses[keyof PostAgentsResponses]
export type GetAgentsInviteOptionsData = {
body?: never
path?: never
query?: {
app_id?: string
keyword?: string
limit?: number
page?: number
}
query?: never
url: '/agents/invite-options'
}
export type GetAgentsInviteOptionsResponses = {
200: AgentInviteOptionsResponse
200: {
[key: string]: unknown
}
}
export type GetAgentsInviteOptionsResponse
@ -333,8 +210,8 @@ export type DeleteAgentsByAgentIdData = {
}
export type DeleteAgentsByAgentIdResponses = {
204: {
[key: string]: never
200: {
[key: string]: unknown
}
}
@ -351,7 +228,9 @@ export type GetAgentsByAgentIdData = {
}
export type GetAgentsByAgentIdResponses = {
200: AgentRosterResponse
200: {
[key: string]: unknown
}
}
export type GetAgentsByAgentIdResponse
@ -367,7 +246,9 @@ export type PatchAgentsByAgentIdData = {
}
export type PatchAgentsByAgentIdResponses = {
200: AgentRosterResponse
200: {
[key: string]: unknown
}
}
export type PatchAgentsByAgentIdResponse
@ -383,7 +264,9 @@ export type GetAgentsByAgentIdVersionsData = {
}
export type GetAgentsByAgentIdVersionsResponses = {
200: AgentConfigSnapshotListResponse
200: {
[key: string]: unknown
}
}
export type GetAgentsByAgentIdVersionsResponse
@ -400,7 +283,9 @@ export type GetAgentsByAgentIdVersionsByVersionIdData = {
}
export type GetAgentsByAgentIdVersionsByVersionIdResponses = {
200: AgentConfigSnapshotDetailResponse
200: {
[key: string]: unknown
}
}
export type GetAgentsByAgentIdVersionsByVersionIdResponse

View File

@ -20,136 +20,6 @@ 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
*/
@ -208,34 +78,6 @@ 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
*/
@ -354,67 +196,39 @@ export const zRosterAgentCreatePayload = z.object({
})
/**
* AgentConfigSnapshotDetailResponse
* Success
*/
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 zGetAgentsResponse = z.record(z.string(), z.unknown())
export const zPostAgentsBody = zRosterAgentCreatePayload
/**
* Agent created
* Success
*/
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),
})
export const zPostAgentsResponse = z.record(z.string(), z.unknown())
/**
* Agent invite options
* Success
*/
export const zGetAgentsInviteOptionsResponse = zAgentInviteOptionsResponse
export const zGetAgentsInviteOptionsResponse = z.record(z.string(), z.unknown())
export const zDeleteAgentsByAgentIdPath = z.object({
agent_id: z.string(),
})
/**
* Agent archived
* Success
*/
export const zDeleteAgentsByAgentIdResponse = z.record(z.string(), z.never())
export const zDeleteAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
export const zGetAgentsByAgentIdPath = z.object({
agent_id: z.string(),
})
/**
* Agent detail
* Success
*/
export const zGetAgentsByAgentIdResponse = zAgentRosterResponse
export const zGetAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
export const zPatchAgentsByAgentIdBody = zRosterAgentUpdatePayload
@ -423,18 +237,18 @@ export const zPatchAgentsByAgentIdPath = z.object({
})
/**
* Agent updated
* Success
*/
export const zPatchAgentsByAgentIdResponse = zAgentRosterResponse
export const zPatchAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
export const zGetAgentsByAgentIdVersionsPath = z.object({
agent_id: z.string(),
})
/**
* Agent versions
* Success
*/
export const zGetAgentsByAgentIdVersionsResponse = zAgentConfigSnapshotListResponse
export const zGetAgentsByAgentIdVersionsResponse = z.record(z.string(), z.unknown())
export const zGetAgentsByAgentIdVersionsByVersionIdPath = z.object({
agent_id: z.string(),
@ -442,6 +256,6 @@ export const zGetAgentsByAgentIdVersionsByVersionIdPath = z.object({
})
/**
* Agent version detail
* Success
*/
export const zGetAgentsByAgentIdVersionsByVersionIdResponse = zAgentConfigSnapshotDetailResponse
export const zGetAgentsByAgentIdVersionsByVersionIdResponse = z.record(z.string(), z.unknown())

View File

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

View File

@ -167,14 +167,6 @@ 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
@ -188,18 +180,6 @@ 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
@ -756,28 +736,6 @@ 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
@ -1003,25 +961,6 @@ 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
@ -1042,13 +981,6 @@ 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'
@ -1071,6 +1003,13 @@ 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
@ -1078,38 +1017,6 @@ 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
@ -1398,39 +1305,6 @@ 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
@ -1479,10 +1353,6 @@ export type WorkflowOnlineUser = {
username: string
}
export type AgentScope = 'roster' | 'workflow_only'
export type AgentStatus = 'active' | 'archived'
export type AppVariableConfig = {
default?: unknown
name: string
@ -1563,9 +1433,19 @@ export type AgentSoulToolsConfig = {
dify_tools?: Array<AgentSoulDifyToolConfig>
}
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
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 DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
export type SimpleModelConfig = {
model_dict?: JsonValue
@ -1635,7 +1515,29 @@ export type WorkflowRunForArchivedLogResponse = {
triggered_from?: string | null
}
export type WorkflowAgentBindingType = 'inline_agent' | 'roster_agent'
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 DeclaredArrayItem = {
description?: string | null
@ -1664,29 +1566,7 @@ export type DeclaredOutputFileConfig = {
mime_types?: Array<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 DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
export type UserActionConfig = {
button_style?: ButtonStyle
@ -1696,6 +1576,12 @@ 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 = {
@ -1704,12 +1590,6 @@ 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 = {
@ -2182,7 +2062,9 @@ export type GetAppsByAppIdAgentComposerData = {
}
export type GetAppsByAppIdAgentComposerResponses = {
200: AgentAppComposerResponse
200: {
[key: string]: unknown
}
}
export type GetAppsByAppIdAgentComposerResponse
@ -2198,7 +2080,9 @@ export type PutAppsByAppIdAgentComposerData = {
}
export type PutAppsByAppIdAgentComposerResponses = {
200: AgentAppComposerResponse
200: {
[key: string]: unknown
}
}
export type PutAppsByAppIdAgentComposerResponse
@ -2214,7 +2098,9 @@ export type GetAppsByAppIdAgentComposerCandidatesData = {
}
export type GetAppsByAppIdAgentComposerCandidatesResponses = {
200: AgentComposerCandidatesResponse
200: {
[key: string]: unknown
}
}
export type GetAppsByAppIdAgentComposerCandidatesResponse
@ -2230,7 +2116,9 @@ export type PostAppsByAppIdAgentComposerValidateData = {
}
export type PostAppsByAppIdAgentComposerValidateResponses = {
200: AgentComposerValidateResponse
200: {
[key: string]: unknown
}
}
export type PostAppsByAppIdAgentComposerValidateResponse
@ -4627,7 +4515,9 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = {
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = {
200: WorkflowAgentComposerResponse
200: {
[key: string]: unknown
}
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
@ -4644,7 +4534,9 @@ export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = {
}
export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = {
200: WorkflowAgentComposerResponse
200: {
[key: string]: unknown
}
}
export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
@ -4661,14 +4553,16 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesData
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses = {
200: AgentComposerCandidatesResponse
200: {
[key: string]: unknown
}
}
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse
= GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses]
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData = {
body: ComposerSavePayload
body?: never
path: {
app_id: string
node_id: string
@ -4678,7 +4572,9 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData =
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses = {
200: AgentComposerImpactResponse
200: {
[key: string]: unknown
}
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse
@ -4695,7 +4591,9 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterD
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses = {
200: WorkflowAgentComposerResponse
200: {
[key: string]: unknown
}
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse
@ -4712,7 +4610,9 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateData
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses = {
200: AgentComposerValidateResponse
200: {
[key: string]: unknown
}
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse

View File

@ -77,14 +77,6 @@ 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
*/
@ -738,16 +730,12 @@ export const zSite = z.object({
})
/**
* AgentConfigSnapshotSummaryResponse
* ComposerBindingPayload
*/
export const zAgentConfigSnapshotSummaryResponse = z.object({
export const zComposerBindingPayload = 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(),
binding_type: z.enum(['inline_agent', 'roster_agent']),
current_snapshot_id: z.string().nullish(),
})
/**
@ -761,15 +749,6 @@ 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
*/
@ -783,24 +762,6 @@ 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
*/
@ -1274,33 +1235,6 @@ 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(),
@ -1541,32 +1475,6 @@ 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
*/
@ -1630,37 +1538,6 @@ 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
*/
@ -1848,63 +1725,6 @@ 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
*/
@ -1943,8 +1763,124 @@ 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
*
@ -2015,83 +1951,6 @@ 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
*/
@ -2108,25 +1967,6 @@ 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
*
@ -2572,9 +2412,9 @@ export const zGetAppsByAppIdAgentComposerPath = z.object({
})
/**
* Agent app composer state
* Success
*/
export const zGetAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse
export const zGetAppsByAppIdAgentComposerResponse = z.record(z.string(), z.unknown())
export const zPutAppsByAppIdAgentComposerBody = zComposerSavePayload
@ -2583,18 +2423,18 @@ export const zPutAppsByAppIdAgentComposerPath = z.object({
})
/**
* Agent app composer saved
* Success
*/
export const zPutAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse
export const zPutAppsByAppIdAgentComposerResponse = z.record(z.string(), z.unknown())
export const zGetAppsByAppIdAgentComposerCandidatesPath = z.object({
app_id: z.string(),
})
/**
* Agent app composer candidates
* Success
*/
export const zGetAppsByAppIdAgentComposerCandidatesResponse = zAgentComposerCandidatesResponse
export const zGetAppsByAppIdAgentComposerCandidatesResponse = z.record(z.string(), z.unknown())
export const zPostAppsByAppIdAgentComposerValidateBody = zComposerSavePayload
@ -2603,9 +2443,9 @@ export const zPostAppsByAppIdAgentComposerValidatePath = z.object({
})
/**
* Agent app composer validation result
* Success
*/
export const zPostAppsByAppIdAgentComposerValidateResponse = zAgentComposerValidateResponse
export const zPostAppsByAppIdAgentComposerValidateResponse = z.record(z.string(), z.unknown())
export const zGetAppsByAppIdAgentLogsPath = z.object({
app_id: z.string(),
@ -3883,10 +3723,12 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.obj
})
/**
* Workflow agent composer state
* Success
*/
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
= zWorkflowAgentComposerResponse
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = z.record(
z.string(),
z.unknown(),
)
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerBody = zComposerSavePayload
@ -3896,10 +3738,12 @@ export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.obj
})
/**
* Workflow agent composer saved
* Success
*/
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
= zWorkflowAgentComposerResponse
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = z.record(
z.string(),
z.unknown(),
)
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath = z.object({
app_id: z.string(),
@ -3907,13 +3751,12 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPa
})
/**
* Workflow agent composer candidates
* Success
*/
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse
= zAgentComposerCandidatesResponse
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody
= zComposerSavePayload
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = z.record(
z.string(),
z.unknown(),
)
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath = z.object({
app_id: z.string(),
@ -3921,10 +3764,12 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath
})
/**
* Workflow agent composer impact
* Success
*/
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse
= zAgentComposerImpactResponse
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse = z.record(
z.string(),
z.unknown(),
)
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody
= zComposerSavePayload
@ -3935,10 +3780,10 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRoste
})
/**
* Workflow agent composer saved to roster
* Success
*/
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse
= zWorkflowAgentComposerResponse
= z.record(z.string(), z.unknown())
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody
= zComposerSavePayload
@ -3949,10 +3794,12 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePat
})
/**
* Workflow agent composer validation result
* Success
*/
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse
= zAgentComposerValidateResponse
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse = z.record(
z.string(),
z.unknown(),
)
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({
app_id: z.string(),

View File

@ -1,4 +1,4 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { renderWithNuqs } from '@/test/nuqs-testing'
@ -13,11 +13,9 @@ 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,
usePathname: () => '/apps',
useSearchParams: () => mockSearchParams,
useSearchParams: () => new URLSearchParams(''),
}))
vi.mock('@/service/client', () => ({
@ -51,10 +49,12 @@ 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,20 +64,11 @@ 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', () => ({
@ -199,9 +190,9 @@ vi.mock('../app-card', () => ({
}))
vi.mock('../new-app-card', () => ({
default: (_props: { ref?: React.Ref<unknown> }) => {
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
},
}),
}))
vi.mock('../empty', () => ({
@ -239,7 +230,6 @@ 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 } },
})
@ -263,6 +253,7 @@ describe('List', () => {
mockServiceState.isLoading = false
mockServiceState.isFetchingNextPage = false
mockQueryState.category = 'all'
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockUseWorkflowOnlineUsers.mockClear()
@ -384,12 +375,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
@ -406,17 +397,6 @@ 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,7 +5,6 @@ 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 })
}
@ -19,11 +18,13 @@ 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')
})
@ -34,6 +35,7 @@ describe('useAppsQueryState', () => {
expect(result.current.query).toEqual({
category: AppModeEnum.WORKFLOW,
tagIDs: ['tag1', 'tag2'],
keywords: 'search term',
isCreatedByMe: true,
})
@ -115,6 +117,33 @@ 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, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { debounce, parseAsArrayOf, 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,6 +16,9 @@ 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),
}),
@ -35,6 +38,10 @@ export function useAppsQueryState() {
setQuery({ keywords })
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery({ tagIDs })
}, [setQuery])
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
setQuery({ isCreatedByMe })
}, [setQuery])
@ -43,6 +50,7 @@ export function useAppsQueryState() {
query,
setCategory,
setKeywords,
setTagIDs,
setIsCreatedByMe,
}), [query, setCategory, setKeywords, setIsCreatedByMe])
}), [query, setCategory, setKeywords, setTagIDs, setIsCreatedByMe])
}

View File

@ -15,7 +15,6 @@ 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'
@ -45,18 +44,15 @@ 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, keywords, isCreatedByMe },
query: { category, tagIDs, 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)
@ -75,16 +71,6 @@ 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,12 +3,6 @@ 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} />
@ -114,23 +108,6 @@ 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,7 +1,6 @@
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 = {
@ -25,20 +24,13 @@ 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">
{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" />}
<CardIcon
size="small"
src={getIconUrl(plugin.icon)}
/>
<div className="absolute -right-0.5 -bottom-0.5 z-10">
{statusIcon}
</div>

View File

@ -6,9 +6,10 @@ 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: () => null }),
useSearchParams: () => ({ get: (key: string) => mockSearchParams[key] ?? null }),
useRouter: () => ({ push: mockPush, replace: mockReplace }),
usePathname: () => '/device',
}))
@ -53,6 +54,12 @@ 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
@ -110,3 +117,41 @@ 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 } from './utils/error-copy'
import { classifyLookupError, ssoErrorCopy } from './utils/error-copy'
import { isValidUserCode } from './utils/user-code'
type View
@ -33,6 +33,7 @@ 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' })
@ -125,6 +126,12 @@ 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,6 +30,18 @@ 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,10 +18,6 @@ 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(),