fix(agent-v2): complete console API contract schemas (#37210)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh
2026-06-09 04:40:32 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent a80bba2c35
commit bbdf3d7634
12 changed files with 625 additions and 280 deletions
@@ -30,6 +30,7 @@ from uuid import UUID
from flask import Response
from flask_restx import Resource
from controllers.common.schema import register_response_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, setup_required
@@ -38,8 +39,13 @@ from libs.login import login_required
from models import App, AppMode
from services.workflow import inspector_events
from services.workflow.node_output_inspector_service import (
CheckResultView,
NodeOutputInspectorError,
NodeOutputInspectorService,
NodeOutputsView,
NodeOutputView,
OutputPreviewView,
WorkflowRunSnapshotView,
)
logger = logging.getLogger(__name__)
@@ -54,6 +60,15 @@ _HEARTBEAT_EVERY_TICKS = 15
# many ticks (= seconds).
_STREAM_HARD_TIMEOUT_TICKS = 1800 # 30 min
register_response_schema_models(
console_ns,
CheckResultView,
NodeOutputView,
NodeOutputsView,
WorkflowRunSnapshotView,
OutputPreviewView,
)
def _service() -> NodeOutputInspectorService:
"""One-line factory so tests can monkeypatch a stub if needed."""
@@ -124,6 +139,7 @@ class WorkflowDraftRunNodeOutputsApi(Resource):
@console_ns.doc("get_workflow_draft_run_node_outputs")
@console_ns.doc(description="Snapshot of every node's declared outputs for a draft workflow run.")
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
@console_ns.response(200, "Workflow run node outputs", console_ns.models[WorkflowRunSnapshotView.__name__])
@console_ns.response(404, "Workflow run not found")
@setup_required
@login_required
@@ -146,6 +162,7 @@ class WorkflowDraftRunNodeOutputDetailApi(Resource):
"node_id": "Node ID inside the workflow graph",
}
)
@console_ns.response(200, "Workflow run node output detail", console_ns.models[NodeOutputsView.__name__])
@console_ns.response(404, "Workflow run / node not found")
@setup_required
@login_required
@@ -171,6 +188,7 @@ class WorkflowDraftRunNodeOutputPreviewApi(Resource):
"output_name": "Declared output name as exposed by Composer",
}
)
@console_ns.response(200, "Workflow run node output preview", console_ns.models[OutputPreviewView.__name__])
@console_ns.response(404, "Workflow run / node / output not found")
@setup_required
@login_required
@@ -309,6 +327,7 @@ class WorkflowDraftRunNodeOutputEventsApi(Resource):
@console_ns.doc("stream_workflow_draft_run_node_output_events")
@console_ns.doc(description="Server-Sent Events stream of inspector deltas for a draft workflow run.")
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
@console_ns.response(200, "Workflow run node output event stream")
@console_ns.response(404, "Workflow run not found")
@setup_required
@login_required
@@ -338,6 +357,7 @@ class WorkflowPublishedRunNodeOutputsApi(Resource):
@console_ns.doc("get_workflow_published_run_node_outputs")
@console_ns.doc(description="Snapshot of every node's declared outputs for a published workflow run.")
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
@console_ns.response(200, "Workflow run node outputs", console_ns.models[WorkflowRunSnapshotView.__name__])
@console_ns.response(404, "Workflow run not found")
@setup_required
@login_required
@@ -360,6 +380,7 @@ class WorkflowPublishedRunNodeOutputDetailApi(Resource):
"node_id": "Node ID inside the workflow graph",
}
)
@console_ns.response(200, "Workflow run node output detail", console_ns.models[NodeOutputsView.__name__])
@console_ns.response(404, "Workflow run / node not found")
@setup_required
@login_required
@@ -386,6 +407,7 @@ class WorkflowPublishedRunNodeOutputPreviewApi(Resource):
"output_name": "Declared output name as exposed by Composer",
}
)
@console_ns.response(200, "Workflow run node output preview", console_ns.models[OutputPreviewView.__name__])
@console_ns.response(404, "Workflow run / node / output not found")
@setup_required
@login_required
@@ -402,6 +424,7 @@ class WorkflowPublishedRunNodeOutputEventsApi(Resource):
@console_ns.doc("stream_workflow_published_run_node_output_events")
@console_ns.doc(description="Server-Sent Events stream of inspector deltas for a published workflow run.")
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
@console_ns.response(200, "Workflow run node output event stream")
@console_ns.response(404, "Workflow run not found")
@setup_required
@login_required
@@ -265,7 +265,9 @@ class WorkflowAgentNodeValidator:
binding: WorkflowAgentNodeBinding,
node_job: WorkflowNodeJobConfig,
) -> None:
forbidden_paths = cls._find_locked_agent_soul_paths(node_job.metadata.model_dump(mode="python"))
forbidden_paths = cls._find_locked_agent_soul_paths(
node_job.metadata.model_dump(mode="python", exclude_none=True)
)
if forbidden_paths:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} cannot override locked Agent Soul fields: "
+48 -8
View File
@@ -73,7 +73,7 @@ RuntimeParameterValue = JsonPrimitive | list[str] | list[int] | list[float] | li
class AgentFlexibleConfig(BaseModel):
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra="allow", json_schema_extra={"x-dify-opaque": True})
def get(self, key: str, default: Any = None) -> Any:
return self.model_dump(mode="python").get(key, default)
@@ -87,10 +87,15 @@ class AgentFlexibleConfig(BaseModel):
class AgentFileRefConfig(AgentFlexibleConfig):
id: str | None = Field(default=None, max_length=255)
file_id: str | None = Field(default=None, max_length=255)
upload_file_id: str | None = Field(default=None, max_length=255)
reference: str | None = Field(default=None, max_length=255)
tenant_id: str | None = Field(default=None, max_length=255)
name: str | None = Field(default=None, max_length=255)
type: str | None = Field(default=None, max_length=64)
transfer_method: str | None = Field(default=None, max_length=64)
url: str | None = None
remote_url: str | None = None
class AgentSkillRefConfig(AgentFlexibleConfig):
@@ -101,17 +106,36 @@ class AgentSkillRefConfig(AgentFlexibleConfig):
path: str | None = None
class AgentPermissionConfig(BaseModel):
model_config = ConfigDict(extra="ignore")
allowed: bool | None = None
status: str | None = Field(default=None, max_length=64)
state: str | None = Field(default=None, max_length=64)
class AgentCliToolConfig(AgentFlexibleConfig):
enabled: bool = True
name: str | None = Field(default=None, max_length=255)
tool_name: str | None = Field(default=None, max_length=255)
label: str | None = Field(default=None, max_length=255)
description: str | None = None
command: str | None = None
invoke_metadata: dict[str, Any] = Field(default_factory=dict)
install_commands: list[str] = Field(default_factory=list)
install_command: str | None = None
install: str | None = None
setup_command: str | None = None
invoke_metadata: dict[str, Any] = Field(default_factory=dict, json_schema_extra={"x-dify-opaque": True})
pre_authorized: bool | None = None
authorization_status: AgentCliToolAuthorizationStatus | None = None
permission: dict[str, Any] = Field(default_factory=dict)
permission: AgentPermissionConfig | None = None
dangerous: bool = False
dangerous_command: bool = False
requires_confirmation: bool = False
dangerous_acknowledged: bool = False
dangerous_accepted: bool = False
risk_accepted: bool = False
approved: bool = False
risk_level: AgentCliToolRiskLevel | None = None
@@ -132,8 +156,12 @@ class AgentHumanContactConfig(AgentFlexibleConfig):
id: str | None = Field(default=None, max_length=255)
contact_id: str | None = Field(default=None, max_length=255)
human_id: str | None = Field(default=None, max_length=255)
tenant_id: str | None = Field(default=None, max_length=255)
name: str | None = Field(default=None, max_length=255)
email: str | None = Field(default=None, max_length=255)
channel: str | None = Field(default=None, max_length=64)
method: str | None = Field(default=None, max_length=64)
contact_method: str | None = Field(default=None, max_length=64)
class AgentHumanToolConfig(AgentFlexibleConfig):
@@ -144,17 +172,27 @@ class AgentHumanToolConfig(AgentFlexibleConfig):
class AgentEnvVariableConfig(AgentFlexibleConfig):
name: str | None = Field(default=None, max_length=255)
key: str | None = Field(default=None, max_length=255)
env_name: str | None = Field(default=None, max_length=255)
variable: str | None = Field(default=None, max_length=255)
type: str | None = Field(default=None, max_length=64)
value: RuntimeParameterValue = None
default: RuntimeParameterValue = None
required: bool = False
class AgentSecretRefConfig(AgentFlexibleConfig):
name: str | None = Field(default=None, max_length=255)
key: str | None = Field(default=None, max_length=255)
env_name: str | None = Field(default=None, max_length=255)
variable: str | None = Field(default=None, max_length=255)
type: str | None = Field(default=None, max_length=64)
id: str | None = Field(default=None, max_length=255)
ref: str | None = Field(default=None, max_length=255)
credential_id: str | None = Field(default=None, max_length=255)
provider_credential_id: str | None = Field(default=None, max_length=255)
provider: str | None = Field(default=None, max_length=255)
permission: dict[str, Any] = Field(default_factory=dict)
permission: AgentPermissionConfig | None = None
permission_status: str | None = Field(default=None, max_length=64)
@@ -162,6 +200,7 @@ class AgentSandboxProviderConfig(AgentFlexibleConfig):
image: str | None = None
working_dir: str | None = None
env: list[AgentEnvVariableConfig] = Field(default_factory=list)
cpu: int | None = Field(default=None, ge=1)
class AgentMemoryArtifactConfig(AgentFlexibleConfig):
@@ -176,7 +215,7 @@ class AgentModelResponseFormatConfig(AgentFlexibleConfig):
class AgentSoulModelSettings(BaseModel):
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra="ignore")
temperature: float | None = None
top_p: float | None = None
@@ -241,9 +280,10 @@ class WorkflowPreviousNodeOutputRef(AgentFlexibleConfig):
class WorkflowNodeJobMetadata(BaseModel):
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra="ignore")
file_refs: list[AgentFileRefConfig] | None = None
agent_soul: dict[str, Any] | None = Field(default=None, json_schema_extra={"x-dify-opaque": True})
class AgentSoulPromptConfig(BaseModel):
@@ -391,7 +431,7 @@ class AppVariableConfig(BaseModel):
name: str = Field(min_length=1, max_length=255)
type: str = Field(min_length=1, max_length=64)
required: bool = False
default: Any = None
default: Any = Field(default=None, json_schema_extra={"x-dify-opaque": True})
class AgentSoulConfig(BaseModel):
@@ -490,7 +530,7 @@ class DeclaredOutputFailureStrategy(BaseModel):
# When ``on_failure == DEFAULT_VALUE`` this value replaces the failed output. The
# value's shape must match the owning ``DeclaredOutputConfig.type``; that match is
# enforced at ``DeclaredOutputConfig`` level so the strategy stays type-agnostic.
default_value: Any = None
default_value: Any = Field(default=None, json_schema_extra={"x-dify-opaque": True})
@model_validator(mode="after")
def _require_default_value_when_default_strategy(self) -> DeclaredOutputFailureStrategy:
+135 -20
View File
@@ -3724,9 +3724,10 @@ Snapshot of every node's declared outputs for a draft workflow run.
##### Responses
| Code | Description |
| ---- | ----------- |
| 404 | Workflow run not found |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow run node outputs | [WorkflowRunSnapshotView](#workflowrunsnapshotview) |
| 404 | Workflow run not found | |
### /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/events
@@ -3746,6 +3747,7 @@ Server-Sent Events stream of inspector deltas for a draft workflow run.
| Code | Description |
| ---- | ----------- |
| 200 | Workflow run node output event stream |
| 404 | Workflow run not found |
### /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}
@@ -3765,9 +3767,10 @@ One node's declared outputs for a draft workflow run.
##### Responses
| Code | Description |
| ---- | ----------- |
| 404 | Workflow run / node not found |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow run node output detail | [NodeOutputsView](#nodeoutputsview) |
| 404 | Workflow run / node not found | |
### /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview
@@ -3787,9 +3790,10 @@ Full value for one declared output, including signed download URL for files.
##### Responses
| Code | Description |
| ---- | ----------- |
| 404 | Workflow run / node / output not found |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow run node output preview | [OutputPreviewView](#outputpreviewview) |
| 404 | Workflow run / node / output not found | |
### /apps/{app_id}/workflows/draft/system-variables
@@ -4044,9 +4048,10 @@ Snapshot of every node's declared outputs for a published workflow run.
##### Responses
| Code | Description |
| ---- | ----------- |
| 404 | Workflow run not found |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow run node outputs | [WorkflowRunSnapshotView](#workflowrunsnapshotview) |
| 404 | Workflow run not found | |
### /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/events
@@ -4066,6 +4071,7 @@ Server-Sent Events stream of inspector deltas for a published workflow run.
| Code | Description |
| ---- | ----------- |
| 200 | Workflow run node output event stream |
| 404 | Workflow run not found |
### /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/{node_id}
@@ -4085,9 +4091,10 @@ One node's declared outputs for a published workflow run.
##### Responses
| Code | Description |
| ---- | ----------- |
| 404 | Workflow run / node not found |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow run node output detail | [NodeOutputsView](#nodeoutputsview) |
| 404 | Workflow run / node not found | |
### /apps/{app_id}/workflows/published/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview
@@ -4107,9 +4114,10 @@ Full value for one declared output of a published run.
##### Responses
| Code | Description |
| ---- | ----------- |
| 404 | Workflow run / node / output not found |
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow run node output preview | [OutputPreviewView](#outputpreviewview) |
| 404 | Workflow run / node / output not found | |
### /apps/{app_id}/workflows/triggers/webhook
@@ -11835,17 +11843,28 @@ composer/publish validators and skipped by runtime request builders.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| approved | boolean | | No |
| authorization_status | [AgentCliToolAuthorizationStatus](#agentclitoolauthorizationstatus) | | No |
| command | string | | No |
| dangerous | boolean | | No |
| dangerous_accepted | boolean | | No |
| dangerous_acknowledged | boolean | | No |
| dangerous_command | boolean | | No |
| description | string | | No |
| enabled | boolean | | No |
| install | string | | No |
| install_command | string | | No |
| install_commands | [ string ] | | No |
| invoke_metadata | object | | No |
| label | string | | No |
| name | string | | No |
| permission | object | | No |
| permission | [AgentPermissionConfig](#agentpermissionconfig) | | No |
| pre_authorized | boolean | | No |
| requires_confirmation | boolean | | No |
| risk_accepted | boolean | | No |
| risk_level | [AgentCliToolRiskLevel](#agentclitoolrisklevel) | | No |
| setup_command | string | | No |
| tool_name | string | | No |
#### AgentCliToolRiskLevel
@@ -12004,10 +12023,14 @@ Audit operation recorded for Agent Soul version/revision changes.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| default | string<br>integer<br>number<br>boolean<br>[ string ]<br>[ integer ]<br>[ number ]<br>[ boolean ] | | No |
| env_name | string | | No |
| key | string | | No |
| name | string | | No |
| required | boolean | | No |
| type | string | | No |
| value | string<br>integer<br>number<br>boolean<br>[ string ]<br>[ integer ]<br>[ number ]<br>[ boolean ] | | No |
| variable | string | | No |
#### AgentFeatureToggleConfig
@@ -12019,21 +12042,30 @@ Audit operation recorded for Agent Soul version/revision changes.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| file_id | string | | No |
| id | string | | No |
| name | string | | No |
| reference | string | | No |
| remote_url | string | | No |
| tenant_id | string | | No |
| transfer_method | string | | No |
| type | string | | No |
| upload_file_id | string | | No |
| url | string | | No |
#### AgentHumanContactConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| channel | string | | No |
| contact_id | string | | No |
| contact_method | string | | No |
| email | string | | No |
| human_id | string | | No |
| id | string | | No |
| method | string | | No |
| name | string | | No |
| tenant_id | string | | No |
#### AgentHumanToolConfig
@@ -12177,6 +12209,14 @@ the current roster/workflow APIs scoped to Dify Agent.
| keywords | string | | No |
| outputs_config | [AgentModerationIOConfig](#agentmoderationioconfig) | | No |
#### AgentPermissionConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| allowed | boolean | | No |
| state | string | | No |
| status | string | | No |
#### AgentReferencingWorkflowResponse
| Name | Type | Description | Required |
@@ -12233,6 +12273,7 @@ the current roster/workflow APIs scoped to Dify Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| cpu | integer | | No |
| env | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No |
| image | string | | No |
| working_dir | string | | No |
@@ -12249,12 +12290,18 @@ Visibility and lifecycle scope of an Agent record.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| credential_id | string | | No |
| env_name | string | | No |
| id | string | | No |
| key | string | | No |
| name | string | | No |
| permission | object | | No |
| permission | [AgentPermissionConfig](#agentpermissionconfig) | | No |
| permission_status | string | | No |
| provider | string | | No |
| provider_credential_id | string | | No |
| ref | string | | No |
| type | string | | No |
| variable | string | | No |
#### AgentSensitiveWordAvoidanceFeatureConfig
@@ -13093,6 +13140,15 @@ Button styles for user actions.
| ---- | ---- | ----------- | -------- |
| email | string | | Yes |
#### CheckResultView
``type_check`` / ``output_check`` per-output summary block.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| passed | boolean | | Yes |
| reason | string | | No |
#### ChildChunkBatchUpdatePayload
| Name | Type | Description | Required |
@@ -15535,6 +15591,38 @@ Enum class for model type.
| ---- | ---- | ----------- | -------- |
| node_id | string | | Yes |
#### NodeOutputStatus
Lifecycle status of a single declared output within a run.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| NodeOutputStatus | string | Lifecycle status of a single declared output within a run. | |
#### NodeOutputView
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| name | string | | Yes |
| output_check | [CheckResultView](#checkresultview) | | No |
| retried | integer | | No |
| status | [NodeOutputStatus](#nodeoutputstatus) | | Yes |
| type | [DeclaredOutputType](#declaredoutputtype) | | No |
| type_check | [CheckResultView](#checkresultview) | | No |
| value_preview | | | No |
#### NodeOutputsView
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| node_completed_at | dateTime | | No |
| node_display_name | string | | Yes |
| node_id | string | | Yes |
| node_kind | string | | Yes |
| node_started_at | dateTime | | No |
| node_status | [NodeStatus](#nodestatus) | | Yes |
| outputs | [ [NodeOutputView](#nodeoutputview) ] | | No |
#### NodeRunPayload
| Name | Type | Description | Required |
@@ -15547,6 +15635,14 @@ Enum class for model type.
| ---- | ---- | ----------- | -------- |
| inputs | object | | Yes |
#### NodeStatus
Coarse node-level status used by Inspector to pick a banner.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| NodeStatus | string | Coarse node-level status used by Inspector to pick a banner. | |
#### NotionEstimatePayload
| Name | Type | Description | Required |
@@ -15637,6 +15733,16 @@ output check fails and any configured retry attempts have been exhausted.
| ---- | ---- | ----------- | -------- |
| OutputErrorStrategy | string | Per-output failure handling strategy. Mirrors ``graphon.ErrorStrategy`` but scoped to a single declared output of a Workflow Agent Node. The runtime applies the strategy after type check or output check fails and any configured retry attempts have been exhausted. | |
#### OutputPreviewView
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| node_id | string | | Yes |
| output_name | string | | Yes |
| status | [NodeOutputStatus](#nodeoutputstatus) | | Yes |
| type | [DeclaredOutputType](#declaredoutputtype) | | No |
| value | | | No |
#### OwnerTransferCheckPayload
| Name | Type | Description | Required |
@@ -17821,6 +17927,7 @@ can reuse its existing handler.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_soul | object | | No |
| file_refs | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No |
#### WorkflowNodeJobMode
@@ -18078,6 +18185,14 @@ Query parameters for workflow runs.
| files | [ ] | | No |
| inputs | object | | Yes |
#### WorkflowRunSnapshotView
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| node_outputs | [ [NodeOutputsView](#nodeoutputsview) ] | | No |
| workflow_run_id | string | | Yes |
| workflow_run_status | [WorkflowExecutionStatus](#workflowexecutionstatus) | | Yes |
#### WorkflowStatisticQuery
| Name | Type | Description | Required |
@@ -115,7 +115,7 @@ class NodeOutputView(BaseModel):
name: str
type: DeclaredOutputType | None = None
status: NodeOutputStatus
value_preview: Any = None
value_preview: Any = Field(default=None, json_schema_extra={"x-dify-opaque": True})
type_check: CheckResultView | None = None
output_check: CheckResultView | None = None
retried: int = 0
@@ -148,7 +148,7 @@ class OutputPreviewView(BaseModel):
output_name: str
type: DeclaredOutputType | None = None
status: NodeOutputStatus
value: Any = None # full value (with signed URL for file refs)
value: Any = Field(default=None, json_schema_extra={"x-dify-opaque": True})
class NodeOutputInspectorError(Exception):
@@ -44,6 +44,28 @@ class CapturingCredentialsProvider:
return {"api_key": "secret-key"}
def test_agent_soul_round_trip_preserves_existing_app_feature_fields():
config = AgentSoulConfig.model_validate(
{
"app_features": {
"file_upload": {
"enabled": True,
"allowed_file_upload_methods": ["local_file"],
},
"annotation_reply": {"enabled": True},
"more_like_this": {"enabled": False},
}
}
)
dumped = config.model_dump(mode="json")
assert dumped["app_features"]["file_upload"]["enabled"] is True
assert dumped["app_features"]["file_upload"]["allowed_file_upload_methods"] == ["local_file"]
assert dumped["app_features"]["annotation_reply"] == {"enabled": True}
assert dumped["app_features"]["more_like_this"] == {"enabled": False}
class FakePluginToolsBuilder:
def __init__(self) -> None:
# Capture the runtime invocation source so tests can assert it was