fix(agent): support agent-id chat and inline draft bindings (#37483)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123
2026-06-16 06:04:30 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 30506f7221
commit b4e3a9095b
10 changed files with 1853 additions and 241 deletions
+95 -49
View File
@@ -1,5 +1,6 @@
import logging
from typing import Any, Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
@@ -10,6 +11,7 @@ import services
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.error import (
AppUnavailableError,
CompletionRequestError,
@@ -23,6 +25,7 @@ from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
with_current_user_id,
)
@@ -186,51 +189,27 @@ class ChatMessageApi(Resource):
@edit_permission_required
@with_current_user
def post(self, current_user: Account, app_model: App):
raw_payload = console_ns.payload or {}
args_model = ChatMessagePayload.model_validate(raw_payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
return _create_chat_message(current_user=current_user, app_model=app_model)
streaming = _resolve_debugger_chat_streaming(
app_mode=AppMode.value_of(app_model.mode),
response_mode=args_model.response_mode,
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
args["response_mode"] = "streaming"
args["auto_generate_name"] = False
external_trace_id = get_external_trace_id(request)
if external_trace_id:
args["external_trace_id"] = external_trace_id
try:
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
)
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
class AgentChatMessageApi(Resource):
@console_ns.doc("create_agent_chat_message")
@console_ns.doc(description="Generate an Agent App chat message for debugging")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
@console_ns.response(200, "Chat message generated successfully", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(404, "Agent or conversation not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _create_chat_message(current_user=current_user, app_model=app_model)
@console_ns.route("/apps/<uuid:app_id>/chat-messages/<string:task_id>/stop")
@@ -245,12 +224,79 @@ class ChatMessageStopApi(Resource):
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@with_current_user_id
def post(self, current_user_id: str, app_model: App, task_id: str):
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.DEBUGGER,
user_id=current_user_id,
app_mode=AppMode.value_of(app_model.mode),
@console_ns.route("/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop")
class AgentChatMessageStopApi(Resource):
@console_ns.doc("stop_agent_chat_message")
@console_ns.doc(description="Stop a running Agent App chat message generation")
@console_ns.doc(params={"agent_id": "Agent ID", "task_id": "Task ID to stop"})
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
def _create_chat_message(*, current_user: Account, app_model: App):
raw_payload = console_ns.payload or {}
args_model = ChatMessagePayload.model_validate(raw_payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
streaming = _resolve_debugger_chat_streaming(
app_mode=AppMode.value_of(app_model.mode),
response_mode=args_model.response_mode,
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
args["response_mode"] = "streaming"
args["auto_generate_name"] = False
external_trace_id = get_external_trace_id(request)
if external_trace_id:
args["external_trace_id"] = external_trace_id
try:
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
)
return {"result": "success"}, 200
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str):
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.DEBUGGER,
user_id=current_user_id,
app_mode=AppMode.value_of(app_model.mode),
)
return {"result": "success"}, 200
+216 -125
View File
@@ -13,6 +13,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes
from controllers.common.fields import SimpleResultResponse, TextFileResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.error import (
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
@@ -25,6 +26,7 @@ from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from core.app.entities.app_invoke_entities import InvokeFrom
@@ -183,67 +185,25 @@ class ChatMessageListApi(Resource):
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@edit_permission_required
def get(self, app_model: App):
args = ChatMessagesQuery.model_validate(request.args.to_dict())
return _list_chat_messages(app_model=app_model)
conversation = db.session.scalar(
select(Conversation)
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
.limit(1)
)
if not conversation:
raise NotFound("Conversation Not Exists.")
if args.first_id:
first_message = db.session.scalar(
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
)
if not first_message:
raise NotFound("First message not found")
history_messages = db.session.scalars(
select(Message)
.where(
Message.conversation_id == conversation.id,
Message.created_at < first_message.created_at,
Message.id != first_message.id,
)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
else:
history_messages = db.session.scalars(
select(Message)
.where(Message.conversation_id == conversation.id)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
# Initialize has_more based on whether we have a full page
if len(history_messages) == args.limit:
current_page_first_message = history_messages[-1]
# Check if there are more messages before the current page
has_more = db.session.scalar(
select(
exists().where(
Message.conversation_id == conversation.id,
Message.created_at < current_page_first_message.created_at,
Message.id != current_page_first_message.id,
)
)
)
else:
# If we don't have a full page, there are no more messages
has_more = False
history_messages = list(reversed(history_messages))
attach_message_extra_contents(history_messages)
return MessageInfiniteScrollPaginationResponse.model_validate(
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
class AgentChatMessageListApi(Resource):
@console_ns.doc("list_agent_chat_messages")
@console_ns.doc(description="Get Agent App chat messages for a conversation with pagination")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.doc(params=query_params_from_model(ChatMessagesQuery))
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__])
@console_ns.response(404, "Agent or conversation not found")
@login_required
@account_initialization_required
@setup_required
@edit_permission_required
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _list_chat_messages(app_model=app_model)
@console_ns.route("/apps/<uuid:app_id>/feedbacks")
@@ -261,44 +221,25 @@ class MessageFeedbackApi(Resource):
@account_initialization_required
@with_current_user
def post(self, current_user: Account, app_model: App):
args = MessageFeedbackPayload.model_validate(console_ns.payload)
return _update_message_feedback(current_user=current_user, app_model=app_model)
message_id = str(args.message_id)
message = db.session.scalar(
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
)
if not message:
raise NotFound("Message Not Exists.")
feedback = message.admin_feedback
if not args.rating and feedback:
db.session.delete(feedback)
elif args.rating and feedback:
feedback.rating = FeedbackRating(args.rating)
feedback.content = args.content
elif not args.rating and not feedback:
raise ValueError("rating cannot be None when feedback not exists")
else:
rating_value = args.rating
if rating_value is None:
raise ValueError("rating is required to create feedback")
feedback = MessageFeedback(
app_id=app_model.id,
conversation_id=message.conversation_id,
message_id=message.id,
rating=FeedbackRating(rating_value),
content=args.content,
from_source=FeedbackFromSource.ADMIN,
from_account_id=current_user.id,
)
db.session.add(feedback)
db.session.commit()
return {"result": "success"}
@console_ns.route("/agent/<uuid:agent_id>/feedbacks")
class AgentMessageFeedbackApi(Resource):
@console_ns.doc("create_agent_message_feedback")
@console_ns.doc(description="Create or update Agent App message feedback")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
@console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "Agent or message not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _update_message_feedback(current_user=current_user, app_model=app_model)
@console_ns.route("/apps/<uuid:app_id>/annotations/count")
@@ -340,31 +281,28 @@ class MessageSuggestedQuestionApi(Resource):
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@with_current_user
def get(self, current_user: Account, app_model: App, message_id: UUID):
message_id_str = str(message_id)
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
)
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
raise NotFound("Conversation not found")
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
return {"data": questions}
@console_ns.route("/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions")
class AgentMessageSuggestedQuestionApi(Resource):
@console_ns.doc("get_agent_message_suggested_questions")
@console_ns.doc(description="Get suggested questions for an Agent App message")
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
@console_ns.response(
200,
"Suggested questions retrieved successfully",
console_ns.models[SuggestedQuestionsResponse.__name__],
)
@console_ns.response(404, "Agent, message, or conversation not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
@console_ns.route("/apps/<uuid:app_id>/feedbacks/export")
@@ -423,14 +361,167 @@ class MessageApi(Resource):
@login_required
@account_initialization_required
def get(self, app_model: App, message_id: UUID):
message_id_str = str(message_id)
return _get_message_detail(app_model=app_model, message_id=message_id)
message = db.session.scalar(
select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1)
@console_ns.route("/agent/<uuid:agent_id>/messages/<uuid:message_id>")
class AgentMessageApi(Resource):
@console_ns.doc("get_agent_message")
@console_ns.doc(description="Get Agent App message details by ID")
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
@console_ns.response(200, "Message retrieved successfully", console_ns.models[MessageDetailResponse.__name__])
@console_ns.response(404, "Agent or message not found")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _get_message_detail(app_model=app_model, message_id=message_id)
def _list_chat_messages(*, app_model: App):
args = ChatMessagesQuery.model_validate(request.args.to_dict())
conversation = db.session.scalar(
select(Conversation)
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
.limit(1)
)
if not conversation:
raise NotFound("Conversation Not Exists.")
if args.first_id:
first_message = db.session.scalar(
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
)
if not message:
raise NotFound("Message Not Exists.")
if not first_message:
raise NotFound("First message not found")
attach_message_extra_contents([message])
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json")
history_messages = db.session.scalars(
select(Message)
.where(
Message.conversation_id == conversation.id,
Message.created_at < first_message.created_at,
Message.id != first_message.id,
)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
else:
history_messages = db.session.scalars(
select(Message)
.where(Message.conversation_id == conversation.id)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
# Initialize has_more based on whether we have a full page
if len(history_messages) == args.limit:
current_page_first_message = history_messages[-1]
# Check if there are more messages before the current page
has_more = db.session.scalar(
select(
exists().where(
Message.conversation_id == conversation.id,
Message.created_at < current_page_first_message.created_at,
Message.id != current_page_first_message.id,
)
)
)
else:
# If we don't have a full page, there are no more messages
has_more = False
history_messages = list(reversed(history_messages))
attach_message_extra_contents(history_messages)
return MessageInfiniteScrollPaginationResponse.model_validate(
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
from_attributes=True,
).model_dump(mode="json")
def _update_message_feedback(*, current_user: Account, app_model: App):
args = MessageFeedbackPayload.model_validate(console_ns.payload)
message_id = str(args.message_id)
message = db.session.scalar(
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
)
if not message:
raise NotFound("Message Not Exists.")
feedback = message.admin_feedback
if not args.rating and feedback:
db.session.delete(feedback)
elif args.rating and feedback:
feedback.rating = FeedbackRating(args.rating)
feedback.content = args.content
elif not args.rating and not feedback:
raise ValueError("rating cannot be None when feedback not exists")
else:
rating_value = args.rating
if rating_value is None:
raise ValueError("rating is required to create feedback")
feedback = MessageFeedback(
app_id=app_model.id,
conversation_id=message.conversation_id,
message_id=message.id,
rating=FeedbackRating(rating_value),
content=args.content,
from_source=FeedbackFromSource.ADMIN,
from_account_id=current_user.id,
)
db.session.add(feedback)
db.session.commit()
return {"result": "success"}
def _get_message_suggested_questions(*, current_user: Account, app_model: App, message_id: UUID):
message_id_str = str(message_id)
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
)
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
raise NotFound("Conversation not found")
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
return {"data": questions}
def _get_message_detail(*, app_model: App, message_id: UUID):
message_id_str = str(message_id)
message = db.session.scalar(
select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1)
)
if not message:
raise NotFound("Message Not Exists.")
attach_message_extra_contents([message])
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json")
+91
View File
@@ -392,6 +392,58 @@ Check if activation token is valid
| 400 | Invalid request parameters | |
| 403 | Insufficient permissions | |
### [GET] /agent/{agent_id}/chat-messages
Get Agent App chat messages for a conversation with pagination
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string |
| conversation_id | query | Conversation ID | Yes | string |
| first_id | query | First message ID for pagination | No | string |
| limit | query | Number of messages to return (1-100) | No | integer, <br>**Default:** 20 |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [MessageInfiniteScrollPaginationResponse](#messageinfinitescrollpaginationresponse)<br> |
| 404 | Agent or conversation not found | |
### [GET] /agent/{agent_id}/chat-messages/{message_id}/suggested-questions
Get suggested questions for an Agent App message
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string |
| message_id | path | Message ID | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Suggested questions retrieved successfully | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)<br> |
| 404 | Agent, message, or conversation not found | |
### [POST] /agent/{agent_id}/chat-messages/{task_id}/stop
Stop a running Agent App chat message generation
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string |
| task_id | path | Task ID to stop | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
### [GET] /agent/{agent_id}/composer
#### Parameters
@@ -527,6 +579,28 @@ Update an Agent App's presentation features (opener, follow-up, citations, ...)
| 400 | Invalid configuration | |
| 404 | Agent not found | |
### [POST] /agent/{agent_id}/feedbacks
Create or update Agent App message feedback
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [MessageFeedbackPayload](#messagefeedbackpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Feedback updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
| 404 | Agent or message not found | |
### [DELETE] /agent/{agent_id}/files
Delete one Agent App drive file by key
@@ -564,6 +638,23 @@ Commit an uploaded file into the Agent App drive under files/<name>
| ---- | ----------- | ------ |
| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)<br> |
### [GET] /agent/{agent_id}/messages/{message_id}
Get Agent App message details by ID
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string |
| message_id | path | Message ID | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Message retrieved successfully | **application/json**: [MessageDetailResponse](#messagedetailresponse)<br> |
| 404 | Agent or message not found | |
### [GET] /agent/{agent_id}/referencing-workflows
List workflow apps that reference this Agent App's bound Agent (read-only)
+123 -22
View File
@@ -9,7 +9,14 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from models.agent import Agent, AgentScope, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.agent import (
Agent,
AgentConfigSnapshot,
AgentScope,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import DeclaredOutputConfig, WorkflowNodeJobConfig
from models.workflow import Workflow
@@ -66,7 +73,7 @@ class WorkflowAgentPublishService:
WorkflowAgentNodeValidator.validate_draft_workflow(session=session, workflow=draft_workflow)
@classmethod
def sync_roster_agent_bindings_for_draft(
def sync_agent_bindings_for_draft(
cls,
*,
session: Session,
@@ -96,7 +103,7 @@ class WorkflowAgentPublishService:
continue
if not isinstance(binding_payload, Mapping):
raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.")
cls._sync_roster_agent_binding_for_node(
cls._sync_agent_binding_for_node(
session=session,
draft_workflow=draft_workflow,
node_id=node_id,
@@ -108,7 +115,21 @@ class WorkflowAgentPublishService:
session.flush()
@classmethod
def _sync_roster_agent_binding_for_node(
def sync_roster_agent_bindings_for_draft(
cls,
*,
session: Session,
draft_workflow: Workflow,
account_id: str,
) -> None:
cls.sync_agent_bindings_for_draft(
session=session,
draft_workflow=draft_workflow,
account_id=account_id,
)
@classmethod
def _sync_agent_binding_for_node(
cls,
*,
session: Session,
@@ -120,26 +141,33 @@ class WorkflowAgentPublishService:
account_id: str,
) -> None:
binding_type = node_binding.get("binding_type")
if binding_type != WorkflowAgentBindingType.ROSTER_AGENT.value:
raise ValueError(f"Workflow Agent node {node_id} only supports roster_agent graph binding.")
agent_id = node_binding.get("agent_id")
if not isinstance(agent_id, str) or not agent_id:
raise ValueError(f"Workflow Agent node {node_id} roster_agent binding requires agent_id.")
raise ValueError(f"Workflow Agent node {node_id} agent binding requires agent_id.")
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == draft_workflow.tenant_id,
Agent.id == agent_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
if binding_type == WorkflowAgentBindingType.ROSTER_AGENT.value:
agent, current_snapshot_id = cls._resolve_roster_agent_graph_binding(
session=session,
draft_workflow=draft_workflow,
node_id=node_id,
agent_id=agent_id,
)
.limit(1)
)
if agent is None:
raise ValueError(f"Workflow Agent node {node_id} references an unavailable roster agent.")
if not agent.active_config_snapshot_id:
raise ValueError(f"Workflow Agent node {node_id} roster agent has no active config snapshot.")
resolved_binding_type = WorkflowAgentBindingType.ROSTER_AGENT
elif binding_type == WorkflowAgentBindingType.INLINE_AGENT.value:
raw_current_snapshot_id = node_binding.get("current_snapshot_id")
if not isinstance(raw_current_snapshot_id, str) or not raw_current_snapshot_id:
raise ValueError(f"Workflow Agent node {node_id} inline_agent binding requires current_snapshot_id.")
current_snapshot_id = raw_current_snapshot_id
agent = cls._resolve_inline_agent_graph_binding(
session=session,
draft_workflow=draft_workflow,
node_id=node_id,
agent_id=agent_id,
current_snapshot_id=current_snapshot_id,
)
resolved_binding_type = WorkflowAgentBindingType.INLINE_AGENT
else:
raise ValueError(f"Workflow Agent node {node_id} has unsupported agent_binding type.")
binding = existing_binding
node_job_config = cls._node_job_config_from_node_data(
@@ -160,11 +188,84 @@ class WorkflowAgentPublishService:
else:
binding.node_job_config = node_job_config
binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT
binding.binding_type = resolved_binding_type
binding.agent_id = agent.id
binding.current_snapshot_id = agent.active_config_snapshot_id
binding.current_snapshot_id = current_snapshot_id
binding.updated_by = account_id
@classmethod
def _resolve_roster_agent_graph_binding(
cls,
*,
session: Session,
draft_workflow: Workflow,
node_id: str,
agent_id: str,
) -> tuple[Agent, str]:
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == draft_workflow.tenant_id,
Agent.id == agent_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None:
raise ValueError(f"Workflow Agent node {node_id} references an unavailable roster agent.")
if agent.scope != AgentScope.ROSTER:
raise ValueError(f"Workflow Agent node {node_id} roster_agent binding must reference a roster agent.")
if not agent.active_config_snapshot_id:
raise ValueError(f"Workflow Agent node {node_id} roster agent has no active config snapshot.")
return agent, agent.active_config_snapshot_id
@classmethod
def _resolve_inline_agent_graph_binding(
cls,
*,
session: Session,
draft_workflow: Workflow,
node_id: str,
agent_id: str,
current_snapshot_id: str,
) -> Agent:
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == draft_workflow.tenant_id,
Agent.id == agent_id,
Agent.scope == AgentScope.WORKFLOW_ONLY,
Agent.app_id == draft_workflow.app_id,
Agent.workflow_id == draft_workflow.id,
Agent.workflow_node_id == node_id,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None:
raise ValueError(f"Workflow Agent node {node_id} references an unavailable inline agent.")
if (
agent.scope != AgentScope.WORKFLOW_ONLY
or agent.app_id != draft_workflow.app_id
or agent.workflow_id != draft_workflow.id
or agent.workflow_node_id != node_id
):
raise ValueError(f"Workflow Agent node {node_id} inline_agent binding does not belong to this node.")
snapshot = session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == draft_workflow.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == current_snapshot_id,
)
.limit(1)
)
if snapshot is None or snapshot.agent_id != agent.id:
raise ValueError(f"Workflow Agent node {node_id} references a missing inline agent config snapshot.")
return agent
@classmethod
def _node_job_config_from_node_data(
cls,
+1 -1
View File
@@ -323,7 +323,7 @@ class WorkflowService:
from services.agent.workflow_publish_service import WorkflowAgentPublishService
db.session.flush()
WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft(
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=cast(Session, db.session),
draft_workflow=workflow,
account_id=account.id,
@@ -4,6 +4,7 @@ from typing import Any, cast
import pytest
from flask import Flask
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.console import console_ns
from controllers.console.agent import composer as composer_controller
@@ -25,6 +26,15 @@ from controllers.console.agent.roster import (
AgentRosterVersionDetailApi,
AgentRosterVersionsApi,
)
from controllers.console.app import completion as completion_controller
from controllers.console.app import message as message_controller
from controllers.console.app.completion import AgentChatMessageApi, AgentChatMessageStopApi
from controllers.console.app.message import (
AgentChatMessageListApi,
AgentMessageApi,
AgentMessageFeedbackApi,
AgentMessageSuggestedQuestionApi,
)
from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant
@@ -132,6 +142,11 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/agent/<uuid:agent_id>/sandbox/files",
"/agent/<uuid:agent_id>/skills/upload",
"/agent/<uuid:agent_id>/files",
"/agent/<uuid:agent_id>/chat-messages",
"/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop",
"/agent/<uuid:agent_id>/feedbacks",
"/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions",
"/agent/<uuid:agent_id>/messages/<uuid:message_id>",
"/agent/invite-options",
):
assert route in paths
@@ -471,6 +486,272 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
assert cast(dict[str, object], captured["candidates"])["app_id"] == "app-1"
def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = SimpleNamespace(id="app-1", mode="agent")
captured: dict[str, object] = {}
def resolve_agent_app_model(**kwargs: object) -> object:
captured["resolve"] = kwargs
return app_model
def create_chat_message(**kwargs: object) -> dict[str, object]:
captured["create"] = kwargs
return {"result": "generated"}
def stop_chat_message(**kwargs: object) -> tuple[dict[str, object], int]:
captured["stop"] = kwargs
return {"result": "success"}, 200
monkeypatch.setattr(completion_controller, "resolve_agent_app_model", resolve_agent_app_model)
monkeypatch.setattr(completion_controller, "_create_chat_message", create_chat_message)
monkeypatch.setattr(completion_controller, "_stop_chat_message", stop_chat_message)
with app.test_request_context(json={"inputs": {}, "query": "hello"}):
assert unwrap(AgentChatMessageApi.post)(
AgentChatMessageApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id
) == {"result": "generated"}
assert cast(dict[str, object], captured["resolve"]) == {"tenant_id": "tenant-1", "agent_id": agent_id}
create_call = cast(dict[str, object], captured["create"])
assert create_call["app_model"] is app_model
assert cast(SimpleNamespace, create_call["current_user"]).id == account_id
assert unwrap(AgentChatMessageStopApi.post)(
AgentChatMessageStopApi(), "tenant-1", account_id, agent_id, "task-1"
) == ({"result": "success"}, 200)
stop_call = cast(dict[str, object], captured["stop"])
assert stop_call == {"current_user_id": account_id, "app_model": app_model, "task_id": "task-1"}
def test_agent_chat_helper_forces_agent_streaming_and_external_trace(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
) -> None:
app_model = SimpleNamespace(id="app-1", mode="agent")
current_user = SimpleNamespace(id=account_id)
captured: dict[str, object] = {}
def generate(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return {"answer": "ok"}
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate)
monkeypatch.setattr(
completion_controller.helper,
"compact_generate_response",
lambda response: {"response": response},
)
with app.test_request_context(
json={"inputs": {}, "query": "hello", "response_mode": "streaming"},
headers={"X-Trace-Id": "trace-1"},
):
result = completion_controller._create_chat_message(current_user=current_user, app_model=app_model)
assert result == {"response": {"answer": "ok"}}
assert captured["app_model"] is app_model
assert captured["user"] is current_user
assert captured["streaming"] is True
args = cast(dict[str, object], captured["args"])
assert args["response_mode"] == "streaming"
assert args["auto_generate_name"] is False
assert args["external_trace_id"] == "trace-1"
@pytest.mark.parametrize(
("error", "expected"),
[
(completion_controller.services.errors.conversation.ConversationNotExistsError(), NotFound),
(
completion_controller.services.errors.conversation.ConversationCompletedError(),
completion_controller.ConversationCompletedError,
),
(
completion_controller.services.errors.app_model_config.AppModelConfigBrokenError(),
completion_controller.AppUnavailableError,
),
(
completion_controller.ProviderTokenNotInitError("not initialized"),
completion_controller.ProviderNotInitializeError,
),
(completion_controller.QuotaExceededError(), completion_controller.ProviderQuotaExceededError),
(
completion_controller.ModelCurrentlyNotSupportError(),
completion_controller.ProviderModelCurrentlyNotSupportError,
),
(completion_controller.InvokeRateLimitError("rate limited"), completion_controller.InvokeRateLimitHttpError),
(completion_controller.InvokeError("invoke failed"), completion_controller.CompletionRequestError),
(RuntimeError("unexpected"), InternalServerError),
],
)
def test_agent_chat_helper_maps_generation_errors(
app: Flask,
monkeypatch: pytest.MonkeyPatch,
error: Exception,
expected: type[Exception],
) -> None:
app_model = SimpleNamespace(id="app-1", mode="chat")
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", lambda **_: (_ for _ in ()).throw(error))
with app.test_request_context(json={"inputs": {}, "query": "hello"}):
with pytest.raises(expected):
completion_controller._create_chat_message(
current_user=SimpleNamespace(id="account-1"),
app_model=app_model,
)
def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
message_id = "00000000-0000-0000-0000-000000000002"
app_model = SimpleNamespace(id="app-1")
current_user = SimpleNamespace(id="account-1")
captured: dict[str, object] = {}
def resolve_agent_app_model(**kwargs: object) -> object:
captured["resolve"] = kwargs
return app_model
def list_chat_messages(**kwargs: object) -> dict[str, object]:
captured["list"] = kwargs
return {"data": []}
def update_message_feedback(**kwargs: object) -> dict[str, object]:
captured["feedback"] = kwargs
return {"result": "success"}
def get_message_suggested_questions(**kwargs: object) -> dict[str, object]:
captured["suggested"] = kwargs
return {"data": ["next"]}
def get_message_detail(**kwargs: object) -> dict[str, object]:
captured["detail"] = kwargs
return {"id": message_id}
monkeypatch.setattr(message_controller, "resolve_agent_app_model", resolve_agent_app_model)
monkeypatch.setattr(message_controller, "_list_chat_messages", list_chat_messages)
monkeypatch.setattr(message_controller, "_update_message_feedback", update_message_feedback)
monkeypatch.setattr(message_controller, "_get_message_suggested_questions", get_message_suggested_questions)
monkeypatch.setattr(message_controller, "_get_message_detail", get_message_detail)
assert unwrap(AgentChatMessageListApi.get)(AgentChatMessageListApi(), "tenant-1", agent_id) == {"data": []}
assert cast(dict[str, object], captured["list"])["app_model"] is app_model
with app.test_request_context(json={"message_id": message_id, "rating": "like"}):
assert unwrap(AgentMessageFeedbackApi.post)(AgentMessageFeedbackApi(), "tenant-1", current_user, agent_id) == {
"result": "success"
}
feedback_call = cast(dict[str, object], captured["feedback"])
assert feedback_call["app_model"] is app_model
assert feedback_call["current_user"] is current_user
assert unwrap(AgentMessageSuggestedQuestionApi.get)(
AgentMessageSuggestedQuestionApi(), "tenant-1", current_user, agent_id, message_id
) == {"data": ["next"]}
suggested_call = cast(dict[str, object], captured["suggested"])
assert suggested_call["app_model"] is app_model
assert suggested_call["current_user"] is current_user
assert suggested_call["message_id"] == message_id
assert unwrap(AgentMessageApi.get)(AgentMessageApi(), "tenant-1", agent_id, message_id) == {"id": message_id}
detail_call = cast(dict[str, object], captured["detail"])
assert detail_call == {"app_model": app_model, "message_id": message_id}
def test_list_chat_messages_supports_first_id_pagination(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
conversation_id = "00000000-0000-0000-0000-000000000010"
first_message_id = "00000000-0000-0000-0000-000000000011"
older_message_id = "00000000-0000-0000-0000-000000000012"
conversation = SimpleNamespace(id=conversation_id)
first_message = SimpleNamespace(id=first_message_id, created_at=2)
older_message = SimpleNamespace(id=older_message_id, created_at=1)
scalar_values = iter([conversation, first_message, True])
scalars_result = SimpleNamespace(all=lambda: [older_message])
session = SimpleNamespace(
scalar=lambda _stmt: next(scalar_values),
scalars=lambda _stmt: scalars_result,
)
class FakeMessagePaginationResponse:
@classmethod
def model_validate(cls, pagination: object, from_attributes: bool = False) -> object:
return SimpleNamespace(
model_dump=lambda mode: {
"data": [item.id for item in pagination.data],
"limit": pagination.limit,
"has_more": pagination.has_more,
}
)
monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session))
monkeypatch.setattr(message_controller, "attach_message_extra_contents", lambda messages: None)
monkeypatch.setattr(message_controller, "MessageInfiniteScrollPaginationResponse", FakeMessagePaginationResponse)
with app.test_request_context(
"/console/api/agent/agent-1/chat-messages"
f"?conversation_id={conversation_id}&first_id={first_message_id}&limit=1"
):
result = message_controller._list_chat_messages(app_model=SimpleNamespace(id="app-1"))
assert result == {"data": [older_message_id], "limit": 1, "has_more": True}
def test_update_message_feedback_rejects_empty_rating_without_existing_feedback(
app: Flask, monkeypatch: pytest.MonkeyPatch
) -> None:
message_id = "00000000-0000-0000-0000-000000000002"
message = SimpleNamespace(id=message_id, app_id="app-1", admin_feedback=None)
session = SimpleNamespace(scalar=lambda _stmt: message)
monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session))
with app.test_request_context(json={"message_id": message_id, "rating": None}):
with pytest.raises(ValueError, match="rating cannot be None"):
message_controller._update_message_feedback(
current_user=SimpleNamespace(id="account-1"),
app_model=SimpleNamespace(id="app-1"),
)
@pytest.mark.parametrize(
("error", "expected"),
[
(message_controller.MessageNotExistsError(), NotFound),
(message_controller.ConversationNotExistsError(), NotFound),
(
message_controller.ProviderTokenNotInitError("not initialized"),
message_controller.ProviderNotInitializeError,
),
(message_controller.QuotaExceededError(), message_controller.ProviderQuotaExceededError),
(message_controller.ModelCurrentlyNotSupportError(), message_controller.ProviderModelCurrentlyNotSupportError),
(message_controller.InvokeError("invoke failed"), message_controller.CompletionRequestError),
(
message_controller.SuggestedQuestionsAfterAnswerDisabledError(),
message_controller.AppSuggestedQuestionsAfterAnswerDisabledError,
),
(RuntimeError("unexpected"), InternalServerError),
],
)
def test_get_message_suggested_questions_maps_service_errors(
monkeypatch: pytest.MonkeyPatch,
error: Exception,
expected: type[Exception],
) -> None:
monkeypatch.setattr(
message_controller.MessageService,
"get_suggested_questions_after_answer",
lambda **_: (_ for _ in ()).throw(error),
)
with pytest.raises(expected):
message_controller._get_message_suggested_questions(
current_user=SimpleNamespace(id="account-1"),
app_model=SimpleNamespace(id="app-1"),
message_id="00000000-0000-0000-0000-000000000002",
)
def test_dify_tool_candidate_response_keeps_granularity_fields():
"""Both selection granularities must survive the fields-layer model —
the frontend needs granularity/tools_count to render the Tools menu."""
@@ -1261,6 +1261,225 @@ class TestWorkflowAgentDraftBindingSync:
],
).model_dump(mode="json")
def test_creates_inline_binding_from_agent_node_graph(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": "agent-node",
"data": {
"type": "agent",
"version": "2",
"agent_task": "Use the current node context.",
"agent_binding": {
"binding_type": "inline_agent",
"agent_id": "inline-agent-1",
"current_snapshot_id": "inline-snapshot-1",
},
},
}
]
}
),
)
agent = Agent(
id="inline-agent-1",
tenant_id="tenant-1",
name="Workflow Agent agent-node",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
app_id="app-1",
workflow_id="workflow-1",
workflow_node_id="agent-node",
status=AgentStatus.ACTIVE,
active_config_snapshot_id="inline-snapshot-1",
)
snapshot = AgentConfigSnapshot(
id="inline-snapshot-1",
tenant_id="tenant-1",
agent_id="inline-agent-1",
version=1,
config_snapshot=AgentSoulConfig(),
)
session = FakeSession(scalar=[agent, snapshot], scalars=[[]])
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=session,
draft_workflow=workflow,
account_id="account-1",
)
binding = next(item for item in session.added if isinstance(item, WorkflowAgentNodeBinding))
assert binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT
assert binding.agent_id == "inline-agent-1"
assert binding.current_snapshot_id == "inline-snapshot-1"
assert binding.node_job_config_dict == WorkflowNodeJobConfig(
workflow_prompt="Use the current node context.",
).model_dump(mode="json")
def test_rejects_inline_binding_for_agent_owned_by_another_node(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": "agent-node",
"data": {
"type": "agent",
"version": "2",
"agent_binding": {
"binding_type": "inline_agent",
"agent_id": "inline-agent-1",
"current_snapshot_id": "inline-snapshot-1",
},
},
}
]
}
),
)
agent = Agent(
id="inline-agent-1",
tenant_id="tenant-1",
name="Workflow Agent other-node",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
app_id="app-1",
workflow_id="workflow-1",
workflow_node_id="other-node",
status=AgentStatus.ACTIVE,
active_config_snapshot_id="inline-snapshot-1",
)
session = FakeSession(scalar=[agent], scalars=[[]])
with pytest.raises(ValueError, match="inline_agent binding does not belong to this node"):
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=session,
draft_workflow=workflow,
account_id="account-1",
)
def test_rejects_agent_node_graph_binding_with_unsupported_type(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": "agent-node",
"data": {
"type": "agent",
"version": "2",
"agent_binding": {
"binding_type": "unknown",
"agent_id": "agent-1",
},
},
}
]
}
),
)
with pytest.raises(ValueError, match="unsupported agent_binding type"):
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=FakeSession(scalars=[[]]),
draft_workflow=workflow,
account_id="account-1",
)
def test_rejects_inline_binding_without_current_snapshot_id(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": "agent-node",
"data": {
"type": "agent",
"version": "2",
"agent_binding": {
"binding_type": "inline_agent",
"agent_id": "inline-agent-1",
},
},
}
]
}
),
)
with pytest.raises(ValueError, match="inline_agent binding requires current_snapshot_id"):
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=FakeSession(scalars=[[]]),
draft_workflow=workflow,
account_id="account-1",
)
def test_rejects_inline_binding_with_missing_snapshot(self):
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
version=Workflow.VERSION_DRAFT,
graph=json.dumps(
{
"nodes": [
{
"id": "agent-node",
"data": {
"type": "agent",
"version": "2",
"agent_binding": {
"binding_type": "inline_agent",
"agent_id": "inline-agent-1",
"current_snapshot_id": "missing-snapshot",
},
},
}
]
}
),
)
agent = Agent(
id="inline-agent-1",
tenant_id="tenant-1",
name="Workflow Agent agent-node",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
app_id="app-1",
workflow_id="workflow-1",
workflow_node_id="agent-node",
status=AgentStatus.ACTIVE,
active_config_snapshot_id="inline-snapshot-1",
)
with pytest.raises(ValueError, match="missing inline agent config snapshot"):
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
session=FakeSession(scalar=[agent, None], scalars=[[]]),
draft_workflow=workflow,
account_id="account-1",
)
def test_updates_existing_roster_binding_prompt_from_agent_node_graph(self):
workflow = Workflow(
id="workflow-1",