Merge remote-tracking branch 'origin/main' into feat/agent-v2

This commit is contained in:
yyh 2026-06-23 20:45:59 +08:00
commit 673d84073b
No known key found for this signature in database
92 changed files with 2676 additions and 350 deletions

View File

@ -28,9 +28,9 @@ from libs.login import login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import ComposerSavePayload
from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload
register_schema_models(console_ns, ComposerSavePayload)
register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload)
register_response_schema_models(
console_ns,
AgentAppComposerResponse,
@ -91,6 +91,38 @@ class WorkflowAgentComposerApi(Resource):
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/copy-from-roster")
class WorkflowAgentComposerCopyFromRosterApi(Resource):
@console_ns.expect(console_ns.models[WorkflowComposerCopyFromRosterPayload.__name__])
@console_ns.response(
200,
"Workflow roster agent copied to inline agent",
console_ns.models[WorkflowAgentComposerResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, account_id: str, app_model: App, node_id: str):
payload = WorkflowComposerCopyFromRosterPayload.model_validate(console_ns.payload or {})
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.copy_workflow_composer_from_roster(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account_id,
source_agent_id=payload.source_agent_id,
source_snapshot_id=payload.source_snapshot_id,
idempotency_key=payload.idempotency_key,
),
)
@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__])
@ -104,7 +136,7 @@ class WorkflowAgentComposerValidateApi(Resource):
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
ComposerConfigValidator.validate_publish_payload(payload)
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
@ -239,7 +271,7 @@ class AgentComposerValidateApi(Resource):
def post(self, tenant_id: str, agent_id: UUID):
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
ComposerConfigValidator.validate_publish_payload(payload)
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,

View File

@ -27,7 +27,11 @@ from controllers.console.wraps import with_current_user
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from fields.conversation_fields import ResultResponse
from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem, SuggestedQuestionsResponse
from fields.message_fields import (
ExploreMessageInfiniteScrollPagination,
ExploreMessageListItem,
SuggestedQuestionsResponse,
)
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from models import Account
@ -56,7 +60,7 @@ register_schema_models(console_ns, MessageListQuery, MessageFeedbackPayload, Mor
register_response_schema_models(
console_ns,
GeneratedAppResponse,
MessageInfiniteScrollPagination,
ExploreMessageInfiniteScrollPagination,
ResultResponse,
SuggestedQuestionsResponse,
)
@ -68,7 +72,7 @@ register_response_schema_models(
)
class MessageListApi(InstalledAppResource):
@console_ns.doc(params=query_params_from_model(MessageListQuery))
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPagination.__name__])
@console_ns.response(200, "Success", console_ns.models[ExploreMessageInfiniteScrollPagination.__name__])
@with_current_user
def get(self, current_user: Account, installed_app: InstalledApp):
app_model = installed_app.app
@ -88,9 +92,9 @@ class MessageListApi(InstalledAppResource):
str(args.first_id) if args.first_id else None,
args.limit,
)
adapter = TypeAdapter(MessageListItem)
adapter = TypeAdapter(ExploreMessageListItem)
items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data]
return MessageInfiniteScrollPagination(
return ExploreMessageInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=items,

View File

@ -41,6 +41,7 @@ from core.app.entities.queue_entities import (
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
QueuePingEvent,
QueueReasoningChunkEvent,
QueueRetrieverResourcesEvent,
QueueStopEvent,
QueueTextChunkEvent,
@ -62,6 +63,7 @@ from core.app.entities.task_entities import (
MessageAudioStreamResponse,
MessageEndStreamResponse,
PingStreamResponse,
ReasoningChunkStreamResponse,
StreamResponse,
WorkflowPauseStreamResponse,
WorkflowTaskState,
@ -473,6 +475,17 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
self._workflow_response_converter.fetch_files_from_node_outputs(event.outputs or {})
)
# Collect terminal reasoning (separated mode) per LLM node id for persistence. This is the
# authoritative source (outputs.reasoning_content), decoupled from the live delta stream.
# Accumulate across iteration/loop passes (same node_id) to match the live stream, which
# appends every pass under the same key — overwriting would keep only the last pass.
if event.node_type == BuiltinNodeTypes.LLM:
reasoning_content = (event.outputs or {}).get("reasoning_content")
if isinstance(reasoning_content, str) and reasoning_content:
self._task_state.metadata.reasoning[event.node_id] = (
self._task_state.metadata.reasoning.get(event.node_id, "") + reasoning_content
)
node_finish_resp = self._workflow_response_converter.workflow_node_finish_to_stream_response(
event=event,
task_id=self._application_generate_entity.task_id,
@ -535,6 +548,27 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
answer=delta_text, message_id=self._message_id, from_variable_selector=event.from_variable_selector
)
def _handle_reasoning_chunk_event(
self, event: QueueReasoningChunkEvent, **kwargs
) -> Generator[StreamResponse, None, None]:
"""Handle out-of-band reasoning chunk events.
Pure emit: reasoning is streamed on its own channel and never written to the
answer. The terminal marker (is_final) may carry an empty reasoning string, in
which case it is still forwarded as the "thinking finished" signal.
"""
if not event.reasoning and not event.is_final:
return
yield ReasoningChunkStreamResponse(
task_id=self._application_generate_entity.task_id,
data=ReasoningChunkStreamResponse.Data(
message_id=self._message_id,
reasoning=event.reasoning,
node_id=event.from_node_id,
is_final=event.is_final,
),
)
def _handle_iteration_start_event(
self, event: QueueIterationStartEvent, **kwargs
) -> Generator[StreamResponse, None, None]:
@ -872,6 +906,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
QueuePingEvent: self._handle_ping_event,
QueueErrorEvent: self._handle_error_event,
QueueTextChunkEvent: self._handle_text_chunk_event,
QueueReasoningChunkEvent: self._handle_reasoning_chunk_event,
# Workflow events
QueueWorkflowStartedEvent: self._handle_workflow_started_event,
QueueWorkflowSucceededEvent: self._handle_workflow_succeeded_event,

View File

@ -24,6 +24,7 @@ from core.app.entities.queue_entities import (
QueueNodeRetryEvent,
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
QueueReasoningChunkEvent,
QueueRetrieverResourcesEvent,
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
@ -74,6 +75,7 @@ from graphon.graph_events import (
NodeRunLoopNextEvent,
NodeRunLoopStartedEvent,
NodeRunLoopSucceededEvent,
NodeRunReasoningChunkEvent,
NodeRunRetrieverResourceEvent,
NodeRunRetryEvent,
NodeRunStartedEvent,
@ -576,6 +578,16 @@ class WorkflowBasedAppRunner:
in_loop_id=event.in_loop_id,
)
)
case NodeRunReasoningChunkEvent():
self._publish_event(
QueueReasoningChunkEvent(
reasoning=event.chunk,
from_node_id=event.node_id,
is_final=event.is_final,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
)
)
case NodeRunRetrieverResourceEvent():
self._publish_event(
QueueRetrieverResourcesEvent(

View File

@ -40,6 +40,7 @@ class QueueEvent(StrEnum):
NODE_FAILED = "node_failed"
NODE_EXCEPTION = "node_exception"
RETRIEVER_RESOURCES = "retriever_resources"
REASONING_CHUNK = "reasoning_chunk"
ANNOTATION_REPLY = "annotation_reply"
AGENT_THOUGHT = "agent_thought"
MESSAGE_FILE = "message_file"
@ -197,6 +198,26 @@ class QueueTextChunkEvent(AppQueueEvent):
"""loop id if node is in loop"""
class QueueReasoningChunkEvent(AppQueueEvent):
"""
QueueReasoningChunkEvent entity
Out-of-band reasoning (chain-of-thought) delta from an LLM node in "separated"
mode. It never touches the answer; it is emitted on a dedicated channel.
"""
event: QueueEvent = QueueEvent.REASONING_CHUNK
reasoning: str
from_node_id: str | None = None
"""id of the LLM node that produced this reasoning"""
is_final: bool = False
"""marks the terminal reasoning chunk for the node run (may carry empty reasoning)"""
in_iteration_id: str | None = None
"""iteration id if node is in iteration"""
in_loop_id: str | None = None
"""loop id if node is in loop"""
class QueueAgentMessageEvent(AppQueueEvent):
"""
QueueMessageEvent entity

View File

@ -27,6 +27,9 @@ class TaskStateMetadata(BaseModel):
annotation_reply: AnnotationReply | None = None
retriever_resources: Sequence[RetrievalSourceMetadata] = Field(default_factory=list)
usage: LLMUsage | None = None
reasoning: dict[str, str] = Field(default_factory=dict)
"""reasoning_content per LLM node id (separated mode), accumulated across iteration/loop
passes for that node; persisted to message_metadata"""
class TaskState(BaseModel):
@ -85,6 +88,7 @@ class StreamEvent(StrEnum):
LOOP_COMPLETED = "loop_completed"
TEXT_CHUNK = "text_chunk"
TEXT_REPLACE = "text_replace"
REASONING_CHUNK = "reasoning_chunk"
AGENT_LOG = "agent_log"
HUMAN_INPUT_REQUIRED = "human_input_required"
HUMAN_INPUT_FORM_FILLED = "human_input_form_filled"
@ -726,6 +730,28 @@ class TextChunkStreamResponse(StreamResponse):
data: Data
class ReasoningChunkStreamResponse(StreamResponse):
"""
ReasoningChunkStreamResponse entity
Out-of-band reasoning (chain-of-thought) delta, parallel to text_chunk. Only
emitted in "separated" mode; the answer/message stream stays free of <think>.
"""
class Data(BaseModel):
"""
Data entity
"""
message_id: str
reasoning: str
node_id: str | None = None
is_final: bool = False
event: StreamEvent = StreamEvent.REASONING_CHUNK
data: Data
class TextReplaceStreamResponse(StreamResponse):
"""
TextReplaceStreamResponse entity

View File

@ -75,7 +75,7 @@ from services.agent_drive_service import AgentDriveService, decode_drive_mention
from .output_failure_orchestrator import retry_idempotency_key
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids
from .runtime_feature_manifest import build_runtime_feature_manifest
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
_DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation")

View File

@ -124,6 +124,7 @@ else
exec python -m app
else
exec gunicorn \
--no-control-socket \
--bind "${DIFY_BIND_ADDRESS:-0.0.0.0}:${DIFY_PORT:-5001}" \
--workers ${SERVER_WORKER_AMOUNT:-1} \
--worker-class ${SERVER_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} \

View File

@ -77,6 +77,13 @@ class WebMessageListItem(MessageListItem):
)
class ExploreMessageListItem(MessageListItem):
metadata: JSONValueType | None = Field(
default=None,
validation_alias="message_metadata_dict",
)
class MessageInfiniteScrollPagination(ResponseModel):
limit: int
has_more: bool
@ -89,6 +96,12 @@ class WebMessageInfiniteScrollPagination(ResponseModel):
data: list[WebMessageListItem]
class ExploreMessageInfiniteScrollPagination(ResponseModel):
limit: int
has_more: bool
data: list[ExploreMessageListItem]
class SavedMessageItem(ResponseModel):
id: str
inputs: dict[str, JSONValueType]

View File

@ -13,7 +13,7 @@ def valid_password(password):
if re.match(pattern, password) is not None:
return password
raise ValueError("Password must contain letters and numbers, and the length must be greater than 8.")
raise ValueError("Password must contain letters and numbers, and the length must be at least 8 characters.")
def hash_password(password_str, salt_byte):

View File

@ -3807,6 +3807,26 @@ Submit human input form preview for workflow
| ---- | ----------- | ------ |
| 200 | Workflow agent composer candidates | **application/json**: [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse)<br> |
### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string (uuid) |
| node_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [WorkflowComposerCopyFromRosterPayload](#workflowcomposercopyfromrosterpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow roster agent copied to inline agent | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)<br> |
### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact
#### Parameters
@ -6574,7 +6594,7 @@ Request body:
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [MessageInfiniteScrollPagination](#messageinfinitescrollpagination)<br> |
| 200 | Success | **application/json**: [ExploreMessageInfiniteScrollPagination](#exploremessageinfinitescrollpagination)<br> |
### [POST] /installed-apps/{installed_app_id}/messages/{message_id}/feedbacks
#### Parameters
@ -14472,9 +14492,14 @@ Button styles for user actions.
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | No |
| binding | [ComposerBindingPayload](#composerbindingpayload) | | No |
| client_revision_id | string | | No |
| description | string | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
| idempotency_key | string | | No |
| new_agent_name | string | | No |
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | No |
| role | string | | No |
| save_strategy | [ComposerSaveStrategy](#composersavestrategy) | | Yes |
| soul_lock | [ComposerSoulLockPayload](#composersoullockpayload) | | No |
| variant | [ComposerVariant](#composervariant) | | Yes |
@ -16083,6 +16108,34 @@ Request payload for bulk downloading documents as a zip archive.
| ---- | ---- | ----------- | -------- |
| tool_icons | object | | No |
#### ExploreMessageInfiniteScrollPagination
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [ExploreMessageListItem](#exploremessagelistitem) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
#### ExploreMessageListItem
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes |
| answer | string | | Yes |
| conversation_id | string | | Yes |
| created_at | integer | | No |
| error | string | | No |
| extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | Yes |
| feedback | [SimpleFeedback](#simplefeedback) | | No |
| id | string | | Yes |
| inputs | object | | Yes |
| message_files | [ [MessageFile](#messagefile) ] | | Yes |
| metadata | [JSONValueType](#jsonvaluetype) | | No |
| parent_message_id | string | | No |
| query | string | | Yes |
| retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes |
| status | string | | Yes |
#### ExternalApiTemplateListQuery
| Name | Type | Description | Required |
@ -17157,14 +17210,6 @@ Enum class for large language model mode.
| upload_file_id | string | | No |
| url | string | | No |
#### MessageInfiniteScrollPagination
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [MessageListItem](#messagelistitem) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
#### MessageInfiniteScrollPaginationResponse
| Name | Type | Description | Required |
@ -17173,25 +17218,6 @@ Enum class for large language model mode.
| has_more | boolean | | Yes |
| limit | integer | | Yes |
#### MessageListItem
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes |
| answer | string | | Yes |
| conversation_id | string | | Yes |
| created_at | integer | | No |
| error | string | | No |
| extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | Yes |
| feedback | [SimpleFeedback](#simplefeedback) | | No |
| id | string | | Yes |
| inputs | object | | Yes |
| message_files | [ [MessageFile](#messagefile) ] | | Yes |
| parent_message_id | string | | No |
| query | string | | Yes |
| retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes |
| status | string | | Yes |
#### MessageListQuery
| Name | Type | Description | Required |
@ -20646,6 +20672,14 @@ How a workflow node is bound to an Agent.
| position_x | number | Comment X position | No |
| position_y | number | Comment Y position | No |
#### WorkflowComposerCopyFromRosterPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| idempotency_key | string | | No |
| source_agent_id | string | | Yes |
| source_snapshot_id | string | | No |
#### WorkflowConversationVariableResponse
| Name | Type | Description | Required |

View File

@ -44,7 +44,7 @@ dependencies = [
"resend>=2.27.0,<3.0.0",
# Emerging: newer and fast-moving, use compatible pins
"fastopenapi[flask]==0.7.0",
"graphon==0.5.2",
"graphon==0.5.3",
"httpx-sse==0.4.3",
"json-repair==0.59.4",
]

View File

@ -208,6 +208,7 @@ def _ref_entry(
"inferred": inferred,
}
def _capped(values: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
if len(values) > MAX_CANDIDATES_PER_LIST:
return values[:MAX_CANDIDATES_PER_LIST], True

View File

@ -4,6 +4,7 @@ from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.sql.elements import ColumnElement
from extensions.ext_database import db
from libs.helper import to_timestamp
@ -13,6 +14,7 @@ from models.agent import (
AgentConfigRevisionOperation,
AgentConfigSnapshot,
AgentDriveFile,
AgentIconType,
AgentKind,
AgentScope,
AgentSource,
@ -20,9 +22,7 @@ from models.agent import (
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import (
DeclaredOutputConfig,
)
from models.agent_config_entities import DeclaredOutputConfig
from models.agent_config_entities import (
effective_declared_outputs as _effective_declared_outputs,
)
@ -32,6 +32,7 @@ from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import (
AgentNameConflictError,
AgentNotFoundError,
AgentVersionConflictError,
AgentVersionNotFoundError,
InvalidComposerConfigError,
)
@ -51,6 +52,13 @@ from services.entities.agent_entities import (
# WorkflowAgentNodeBinding.workflow_version tag for the draft workflow row.
# Mirrors Workflow.version when it is "draft" (see models/workflow.py).
_DRAFT_WORKFLOW_VERSION = "draft"
_PUBLISH_SAVE_STRATEGIES = frozenset(
{
ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
ComposerSaveStrategy.SAVE_AS_NEW_AGENT,
ComposerSaveStrategy.SAVE_TO_ROSTER,
}
)
logger = logging.getLogger(__name__)
@ -76,6 +84,13 @@ def _backfill_cli_tool_ids(agent_soul: AgentSoulConfig | None) -> None:
seen_ids.add(minted)
def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> None:
if payload.save_strategy in _PUBLISH_SAVE_STRATEGIES:
ComposerConfigValidator.validate_publish_payload(payload)
return
ComposerConfigValidator.validate_draft_save_payload(payload)
class AgentComposerService:
@classmethod
def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> dict[str, Any]:
@ -105,7 +120,7 @@ class AgentComposerService:
raise ValueError("Workflow composer endpoint only accepts workflow variant")
_backfill_cli_tool_ids(payload.agent_soul)
ComposerConfigValidator.validate_save_payload(payload)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
@ -164,6 +179,86 @@ class AgentComposerService:
)
return state
@classmethod
def copy_workflow_composer_from_roster(
cls,
*,
tenant_id: str,
app_id: str,
node_id: str,
account_id: str,
source_agent_id: str,
source_snapshot_id: str | None = None,
idempotency_key: str | None = None,
) -> dict[str, Any]:
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
binding = cls._require_binding(
cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
)
if binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT and idempotency_key:
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
version = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id if agent else None,
version_id=binding.current_snapshot_id,
)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
if binding.binding_type != WorkflowAgentBindingType.ROSTER_AGENT:
raise InvalidComposerConfigError("Workflow agent node must be bound to a roster agent.")
if binding.agent_id != source_agent_id:
raise InvalidComposerConfigError("Source agent does not match the current workflow node binding.")
source_agent = cls._require_agent(tenant_id=tenant_id, agent_id=source_agent_id)
if source_agent.scope != AgentScope.ROSTER or source_agent.status != AgentStatus.ACTIVE:
raise InvalidComposerConfigError("Source agent must be an active roster agent.")
source_version = cls._require_version(
tenant_id=tenant_id,
agent_id=source_agent.id,
version_id=source_agent.active_config_snapshot_id,
)
if source_snapshot_id and source_snapshot_id != source_version.id:
raise AgentVersionConflictError()
agent_soul = AgentSoulConfig.model_validate(source_version.config_snapshot_dict)
inline_agent = cls._create_workflow_only_agent(
tenant_id=tenant_id,
app_id=app_id,
workflow_id=workflow.id,
node_id=node_id,
account_id=account_id,
agent_soul=agent_soul,
name=source_agent.name,
description=source_agent.description,
role=source_agent.role,
icon_type=source_agent.icon_type,
icon=source_agent.icon,
icon_background=source_agent.icon_background,
)
cls._copy_agent_drive_rows(
tenant_id=tenant_id,
source_agent_id=source_agent.id,
target_agent_id=inline_agent.id,
account_id=account_id,
agent_soul=agent_soul,
node_job=WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict),
)
binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT
binding.agent_id = inline_agent.id
binding.current_snapshot_id = inline_agent.active_config_snapshot_id
binding.updated_by = account_id
db.session.flush()
db.session.commit()
version = cls._require_version(
tenant_id=tenant_id,
agent_id=inline_agent.id,
version_id=inline_agent.active_config_snapshot_id,
)
return cls._serialize_workflow_state(binding=binding, agent=inline_agent, version=version)
@classmethod
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
agent = db.session.scalar(
@ -200,7 +295,7 @@ class AgentComposerService:
if payload.variant != ComposerVariant.AGENT_APP:
raise ValueError("Agent App composer endpoint only accepts agent_app variant")
_backfill_cli_tool_ids(payload.agent_soul)
ComposerConfigValidator.validate_save_payload(payload)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
if payload.agent_soul is None:
raise ValueError("agent_soul is required")
@ -832,6 +927,11 @@ class AgentComposerService:
tenant_id=tenant_id,
account_id=account_id,
name=agent_name,
description=payload.description or "",
role=payload.role or "",
icon_type=payload.icon_type,
icon=payload.icon,
icon_background=payload.icon_background,
agent_soul=payload.agent_soul,
operation=AgentConfigRevisionOperation.SAVE_NEW_AGENT,
version_note=payload.version_note,
@ -877,6 +977,13 @@ class AgentComposerService:
tenant_id=tenant_id,
account_id=account_id,
name=agent_name,
description=payload.description if payload.description is not None else source_agent.description,
role=payload.role if payload.role is not None else source_agent.role,
icon_type=payload.icon_type if payload.icon_type is not None else source_agent.icon_type,
icon=payload.icon if payload.icon is not None else source_agent.icon,
icon_background=payload.icon_background
if payload.icon_background is not None
else source_agent.icon_background,
agent_soul=agent_soul,
operation=AgentConfigRevisionOperation.SAVE_TO_ROSTER,
version_note=payload.version_note,
@ -899,11 +1006,21 @@ class AgentComposerService:
node_id: str,
account_id: str,
agent_soul: AgentSoulConfig,
name: str | None = None,
description: str = "",
role: str = "",
icon_type: Any | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> Agent:
agent = Agent(
tenant_id=tenant_id,
name=f"Workflow Agent {node_id}",
description="",
name=name or f"Workflow Agent {node_id}",
description=description,
role=role,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
@ -928,6 +1045,98 @@ class AgentComposerService:
agent.active_config_has_model = agent_soul_has_model(agent_soul)
return agent
@classmethod
def _copy_agent_drive_rows(
cls,
*,
tenant_id: str,
source_agent_id: str,
target_agent_id: str,
account_id: str,
agent_soul: AgentSoulConfig,
node_job: WorkflowNodeJobConfig | None = None,
) -> None:
exact_keys, prefixes = cls._drive_copy_scopes_from_agent_configs(agent_soul=agent_soul, node_job=node_job)
predicates: list[ColumnElement[bool]] = []
if exact_keys:
predicates.append(AgentDriveFile.key.in_(sorted(exact_keys)))
predicates.extend(AgentDriveFile.key.startswith(prefix) for prefix in sorted(prefixes))
if not predicates:
return
source_rows = list(
db.session.scalars(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == source_agent_id,
or_(*predicates),
)
).all()
)
if not source_rows:
return
existing_target_keys = set(
db.session.scalars(
select(AgentDriveFile.key).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == target_agent_id,
AgentDriveFile.key.in_([row.key for row in source_rows]),
)
).all()
)
for row in source_rows:
if row.key in existing_target_keys:
continue
db.session.add(
AgentDriveFile(
tenant_id=tenant_id,
agent_id=target_agent_id,
key=row.key,
file_kind=row.file_kind,
file_id=row.file_id,
value_owned_by_drive=row.value_owned_by_drive,
is_skill=row.is_skill,
skill_metadata=row.skill_metadata,
size=row.size,
hash=row.hash,
mime_type=row.mime_type,
created_by=account_id,
)
)
@staticmethod
def _drive_copy_scopes_from_agent_configs(
*, agent_soul: AgentSoulConfig, node_job: WorkflowNodeJobConfig | None = None
) -> tuple[set[str], set[str]]:
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
from services.agent_drive_service import decode_drive_mention_ref
exact_keys: set[str] = set()
prefixes: set[str] = set()
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
continue
drive_key = decode_drive_mention_ref(mention.ref_id)
if not drive_key:
continue
if mention.kind == MentionKind.SKILL and "/" in drive_key:
prefixes.add(f"{drive_key.rsplit('/', 1)[0]}/")
else:
exact_keys.add(drive_key)
if node_job is not None:
for file_ref in node_job.metadata.file_refs or []:
if file_ref.drive_key:
exact_keys.add(file_ref.drive_key)
for output in node_job.declared_outputs:
benchmark_ref = output.check.benchmark_file_ref if output.check and output.check.enabled else None
if benchmark_ref and benchmark_ref.drive_key:
exact_keys.add(benchmark_ref.drive_key)
return exact_keys, prefixes
@classmethod
def _create_roster_agent_for_composer(
cls,
@ -938,11 +1147,20 @@ class AgentComposerService:
agent_soul: AgentSoulConfig,
operation: AgentConfigRevisionOperation,
version_note: str | None,
description: str = "",
role: str = "",
icon_type: AgentIconType | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> Agent:
agent = Agent(
tenant_id=tenant_id,
name=name,
description="",
description=description,
role=role,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.WORKFLOW,

View File

@ -50,7 +50,7 @@ _DANGEROUS_ACK_KEYS = (
class ComposerConfigValidator:
@classmethod
def validate_save_payload(cls, payload: ComposerSavePayload) -> None:
def validate_draft_save_payload(cls, payload: ComposerSavePayload) -> None:
if (
payload.variant == ComposerVariant.WORKFLOW
and payload.soul_lock.locked
@ -59,6 +59,13 @@ class ComposerConfigValidator:
):
raise AgentSoulLockedError()
@classmethod
def validate_save_payload(cls, payload: ComposerSavePayload) -> None:
cls.validate_publish_payload(payload)
@classmethod
def validate_publish_payload(cls, payload: ComposerSavePayload) -> None:
cls.validate_draft_save_payload(payload)
if payload.agent_soul is not None:
cls.validate_agent_soul(payload.agent_soul)
if payload.node_job is not None:

View File

@ -17,6 +17,10 @@ class AgentArchivedError(Conflict):
description = "Archived agent cannot be modified."
class AgentVersionConflictError(Conflict):
description = "Agent config version changed. Please reload and try again."
class AgentSoulLockedError(BadRequest):
description = "Agent Soul is locked for this workflow node."

View File

@ -8,17 +8,25 @@ from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator
from models.agent import (
Agent,
AgentConfigSnapshot,
AgentDriveFile,
AgentScope,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import DeclaredOutputConfig, WorkflowNodeJobConfig
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, WorkflowNodeJobConfig
from models.workflow import Workflow
from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import (
ComposerSavePayload,
ComposerSaveStrategy,
ComposerSoulLockPayload,
ComposerVariant,
)
class WorkflowAgentPublishService:
@ -67,11 +75,131 @@ class WorkflowAgentPublishService:
@classmethod
def validate_agent_nodes_for_publish(cls, *, session: Session, draft_workflow: Workflow) -> None:
WorkflowAgentNodeValidator.validate_published_workflow(session=session, workflow=draft_workflow)
cls._validate_composer_configs_for_publish(session=session, draft_workflow=draft_workflow)
@classmethod
def validate_agent_nodes_for_draft_sync(cls, *, session: Session, draft_workflow: Workflow) -> None:
WorkflowAgentNodeValidator.validate_draft_workflow(session=session, workflow=draft_workflow)
@classmethod
def _validate_composer_configs_for_publish(cls, *, session: Session, draft_workflow: Workflow) -> None:
node_ids = {
node_id for node_id, _node_data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict)
}
if not node_ids:
return
bindings = session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id,
WorkflowAgentNodeBinding.app_id == draft_workflow.app_id,
WorkflowAgentNodeBinding.workflow_id == draft_workflow.id,
WorkflowAgentNodeBinding.workflow_version == draft_workflow.version,
WorkflowAgentNodeBinding.node_id.in_(node_ids),
)
).all()
for binding in bindings:
cls._validate_binding_composer_config_for_publish(session=session, binding=binding)
@classmethod
def _validate_binding_composer_config_for_publish(
cls,
*,
session: Session,
binding: WorkflowAgentNodeBinding,
) -> None:
if not binding.agent_id:
return
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == binding.tenant_id,
Agent.id == binding.agent_id,
)
.limit(1)
)
if agent is None:
return
snapshot_id = (
agent.active_config_snapshot_id
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else binding.current_snapshot_id
)
if snapshot_id is None:
return
snapshot = session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == binding.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == snapshot_id,
)
.limit(1)
)
if snapshot is None:
return
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
payload = ComposerSavePayload.model_construct(
variant=ComposerVariant.WORKFLOW,
save_strategy=ComposerSaveStrategy.NODE_JOB_ONLY,
soul_lock=ComposerSoulLockPayload(locked=False),
agent_soul=agent_soul,
node_job=node_job,
)
ComposerConfigValidator.validate_publish_payload(payload)
# ENG-623 §4.4: drive-backed refs must point at real drive rows before
# publishing. This stays out of composer save so autosave/save-draft can
# persist incomplete refs and surface them as non-blocking findings.
cls._require_drive_refs_resolved_for_publish(session=session, binding=binding, agent_soul=agent_soul)
@classmethod
def _require_drive_refs_resolved_for_publish(
cls,
*,
session: Session,
binding: WorkflowAgentNodeBinding,
agent_soul: AgentSoulConfig,
) -> None:
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
from services.agent_drive_service import decode_drive_mention_ref
wanted_keys: dict[str, tuple[str, str]] = {}
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
continue
drive_key = decode_drive_mention_ref(mention.ref_id)
if not drive_key:
continue
code = "skill_ref_dangling" if mention.kind == MentionKind.SKILL else "file_ref_dangling"
wanted_keys[drive_key] = (code, mention.label or drive_key)
if not wanted_keys or not binding.agent_id:
return
existing_keys = set(
session.scalars(
select(AgentDriveFile.key).where(
AgentDriveFile.tenant_id == binding.tenant_id,
AgentDriveFile.agent_id == binding.agent_id,
AgentDriveFile.key.in_(sorted(wanted_keys)),
)
).all()
)
messages: list[str] = []
for key, (code, display) in wanted_keys.items():
if key in existing_keys:
continue
kind = "skill" if code == "skill_ref_dangling" else "file"
messages.append(f"{code}: {kind} '{display}' has no drive entry for key '{key}'.")
if messages:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has invalid Agent Soul drive refs: {'; '.join(messages)}"
)
@classmethod
def sync_agent_bindings_for_draft(
cls,

View File

@ -183,7 +183,11 @@ class EnterpriseRequest(BaseRequest):
if account_id:
inner_headers[INNER_ACCOUNT_ID_HEADER] = account_id
if not cls.base_url.startswith("http") or not cls.base_url.startswith("https") or not cls.base_url:
if (
not cls.rbac_base_url.startswith("http")
and not cls.rbac_base_url.startswith("https")
and not cls.rbac_base_url
):
raise ValueError("ENTERPRISE_RBAC_API_URL is required when RBAC_ENABLED=true")
url = f"{cls.rbac_base_url}{endpoint}"

View File

@ -534,6 +534,31 @@ def _legacy_role_permission_keys(role: TenantAccountRole) -> list[str]:
)
def _legacy_member_roles_response(
tenant_id: str, member_account_id: str, role: TenantAccountRole | str | None
) -> MemberRolesResponse:
if not role:
return MemberRolesResponse(account_id=member_account_id, roles=[])
tenant_role = TenantAccountRole(role)
role_value = tenant_role.value
return MemberRolesResponse(
account_id=member_account_id,
roles=[
RBACRole(
id=role_value,
name=role_value,
description="",
is_builtin=True,
type="",
permission_keys=_legacy_role_permission_keys(tenant_role),
role_tag="owner" if tenant_role == TenantAccountRole.OWNER else role_value,
tenant_id=tenant_id,
)
],
)
def _legacy_my_permissions(tenant_id: str, account_id: str | None) -> MyPermissionsResponse:
if not account_id:
return MyPermissionsResponse()
@ -1582,23 +1607,7 @@ class RBACService:
TenantAccountJoin.account_id == member_account_id,
)
)
return MemberRolesResponse(
account_id=member_account_id,
roles=[
RBACRole(
id=role,
name=role,
description="",
is_builtin=True,
type="",
permission_keys=_legacy_role_permission_keys(role),
role_tag="owner" if role == "owner" else role,
tenant_id=tenant_id,
)
]
if role
else [],
)
return _legacy_member_roles_response(tenant_id, member_account_id, role)
@staticmethod
def batch_get(
@ -1629,6 +1638,36 @@ class RBACService:
member_account_id: str,
role_ids: list[str],
) -> MemberRolesResponse:
if not dify_config.RBAC_ENABLED:
if len(role_ids) != 1:
raise ValueError("Legacy workspace member role update requires exactly one role.")
tenant_role = TenantAccountRole(role_ids[0])
with session_factory.create_session() as session:
target_member_join = session.scalar(
select(TenantAccountJoin).where(
TenantAccountJoin.tenant_id == tenant_id,
TenantAccountJoin.account_id == member_account_id,
)
)
if not target_member_join:
raise ValueError("Member not in tenant.")
if tenant_role == TenantAccountRole.OWNER:
current_owner_join = session.scalar(
select(TenantAccountJoin).where(
TenantAccountJoin.tenant_id == tenant_id,
TenantAccountJoin.role == TenantAccountRole.OWNER,
)
)
if current_owner_join and current_owner_join.account_id != member_account_id:
current_owner_join.role = TenantAccountRole.ADMIN
target_member_join.role = tenant_role
session.commit()
return _legacy_member_roles_response(tenant_id, member_account_id, tenant_role)
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/members/rbac-roles",

View File

@ -42,6 +42,11 @@ class ComposerSavePayload(BaseModel):
idempotency_key: str | None = None
client_revision_id: str | None = None
new_agent_name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
role: str | None = Field(default=None, max_length=255)
icon_type: AgentIconType | None = None
icon: str | None = Field(default=None, max_length=255)
icon_background: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_variant_sections(self) -> "ComposerSavePayload":
@ -58,6 +63,12 @@ class ComposerSavePayload(BaseModel):
return self
class WorkflowComposerCopyFromRosterPayload(BaseModel):
source_agent_id: str = Field(min_length=1, max_length=255)
source_snapshot_id: str | None = Field(default=None, max_length=255)
idempotency_key: str | None = Field(default=None, max_length=255)
class RosterAgentCreatePayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
mode: Literal["agent"] = "agent"

View File

@ -15,6 +15,7 @@ from controllers.console.agent.composer import (
AgentComposerValidateApi,
WorkflowAgentComposerApi,
WorkflowAgentComposerCandidatesApi,
WorkflowAgentComposerCopyFromRosterApi,
WorkflowAgentComposerImpactApi,
WorkflowAgentComposerSaveToRosterApi,
WorkflowAgentComposerValidateApi,
@ -972,7 +973,7 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
"save_workflow_composer",
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None
)
@ -1017,6 +1018,58 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
)["save_options"] == ["node_job_only"]
def test_workflow_composer_copy_from_roster(app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str) -> None:
app_model = SimpleNamespace(id="app-1")
captured: dict[str, object] = {}
def fake_copy_from_roster(**kwargs):
captured.update(kwargs)
return _workflow_composer_response(
binding={
"id": "binding-1",
"binding_type": "inline_agent",
"agent_id": "inline-agent-1",
"current_snapshot_id": "inline-version-1",
"workflow_id": "workflow-1",
"node_id": kwargs["node_id"],
},
agent={
"id": "inline-agent-1",
"name": "Nadia",
"description": "",
"scope": "workflow_only",
"status": "active",
},
active_config_snapshot={"id": "inline-version-1", "version": 1},
)
monkeypatch.setattr(
composer_controller.AgentComposerService, "copy_workflow_composer_from_roster", fake_copy_from_roster
)
with app.test_request_context(
json={
"source_agent_id": "roster-agent-1",
"source_snapshot_id": "roster-version-1",
"idempotency_key": "copy-1",
}
):
result = unwrap(WorkflowAgentComposerCopyFromRosterApi.post)(
WorkflowAgentComposerCopyFromRosterApi(), "tenant-1", account_id, app_model, "node-1"
)
assert result["binding"]["binding_type"] == "inline_agent"
assert captured == {
"tenant_id": "tenant-1",
"app_id": "app-1",
"node_id": "node-1",
"account_id": account_id,
"source_agent_id": "roster-agent-1",
"source_snapshot_id": "roster-version-1",
"idempotency_key": "copy-1",
}
def test_workflow_impact_returns_empty_without_version(app: Flask) -> None:
payload = {"variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value}
@ -1067,7 +1120,7 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
"save_agent_app_composer",
save_agent_app_composer,
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"collect_validation_findings",

View File

@ -1,4 +1,4 @@
from core.app.entities.queue_entities import QueueStopEvent
from core.app.entities.queue_entities import QueueEvent, QueueReasoningChunkEvent, QueueStopEvent
class TestQueueEntities:
@ -10,3 +10,17 @@ class TestQueueEntities:
event = QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL)
event.stopped_by = "unknown"
assert event.get_stop_reason() == "Stopped by unknown reason."
def test_reasoning_chunk_event_defaults(self):
event = QueueReasoningChunkEvent(reasoning="thinking", from_node_id="llm")
assert event.event == QueueEvent.REASONING_CHUNK
assert event.reasoning == "thinking"
assert event.from_node_id == "llm"
assert event.is_final is False
assert event.in_iteration_id is None
assert event.in_loop_id is None
def test_reasoning_chunk_event_terminal_marker_allows_empty_reasoning(self):
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm", is_final=True)
assert event.reasoning == ""
assert event.is_final is True

View File

@ -1,10 +1,15 @@
import json
from core.app.entities.task_entities import (
NodeFinishStreamResponse,
NodeRetryStreamResponse,
NodeStartStreamResponse,
ReasoningChunkStreamResponse,
StreamEvent,
TaskStateMetadata,
)
from graphon.enums import WorkflowNodeExecutionStatus
from graphon.model_runtime.utils.encoders import jsonable_encoder
class TestTaskEntities:
@ -76,3 +81,41 @@ class TestTaskEntities:
assert payload["event"] == StreamEvent.NODE_RETRY.value
assert payload["data"]["retry_index"] == 2
assert payload["data"]["outputs"] is None
def test_reasoning_chunk_stream_response_shape(self):
response = ReasoningChunkStreamResponse(
task_id="task-1",
data=ReasoningChunkStreamResponse.Data(
message_id="msg-1",
reasoning="let me think",
node_id="llm",
is_final=False,
),
)
payload = response.model_dump()
assert payload["event"] == StreamEvent.REASONING_CHUNK
assert payload["task_id"] == "task-1"
assert payload["data"]["message_id"] == "msg-1"
assert payload["data"]["reasoning"] == "let me think"
assert payload["data"]["node_id"] == "llm"
assert payload["data"]["is_final"] is False
def test_task_state_metadata_reasoning_round_trips(self):
# The persistence path serializes the whole metadata to message_metadata via
# model_dump -> jsonable_encoder -> json.dumps, then reads back with json.loads.
metadata = TaskStateMetadata()
metadata.reasoning["llm"] = "first"
metadata.reasoning["llm2"] = "second"
serialized = json.dumps(jsonable_encoder(metadata.model_dump()))
restored = json.loads(serialized)
assert restored["reasoning"] == {"llm": "first", "llm2": "second"}
def test_task_state_metadata_reasoning_defaults_empty(self):
# Old rows / runs without reasoning serialize to an empty dict, never null.
metadata = TaskStateMetadata()
restored = json.loads(json.dumps(jsonable_encoder(metadata.model_dump())))
assert restored["reasoning"] == {}

View File

@ -47,7 +47,12 @@ from graphon.model_runtime.entities.model_entities import (
ParameterType,
)
from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory
from graphon.node_events import ModelInvokeCompletedEvent, RunRetrieverResourceEvent, StreamChunkEvent
from graphon.node_events import (
ModelInvokeCompletedEvent,
RunRetrieverResourceEvent,
StreamChunkEvent,
StreamReasoningEvent,
)
from graphon.nodes.base.entities import VariableSelector
from graphon.nodes.llm import llm_utils
from graphon.nodes.llm.entities import (
@ -1576,9 +1581,13 @@ def test_handle_invoke_result_streaming_collects_text_metrics_and_structured_out
assert events[0] == first_chunk
assert events[1] == StreamChunkEvent(selector=["node-1", "text"], chunk="answer", is_final=False)
assert events[1] == StreamReasoningEvent(selector=["node-1", "reasoning_content"], chunk="plan", is_final=False)
completed = events[2]
assert events[2] == StreamChunkEvent(selector=["node-1", "text"], chunk="answer", is_final=False)
assert events[3] == StreamReasoningEvent(selector=["node-1", "reasoning_content"], chunk="", is_final=True)
completed = events[4]
assert isinstance(completed, ModelInvokeCompletedEvent)
assert completed.text == "answer"
assert completed.reasoning_content == "plan"

View File

@ -0,0 +1,36 @@
from fields.message_fields import ExploreMessageListItem, MessageListItem
def _base_kwargs():
return {
"id": "m1",
"conversation_id": "c1",
"inputs": {},
"query": "hi",
"answer": "answer",
"retriever_resources": [],
"agent_thoughts": [],
"message_files": [],
"status": "normal",
"extra_contents": [],
}
class TestExploreMessageListItem:
def test_exposes_metadata_for_history_rehydration(self):
# The Explore/installed-app surface must surface message_metadata (incl. reasoning)
# so the chat-with-history client can rehydrate the thinking panel on reload.
item = ExploreMessageListItem(**_base_kwargs(), metadata={"reasoning": {"llm": "thinking..."}})
payload = item.model_dump(mode="json")
assert payload["metadata"] == {"reasoning": {"llm": "thinking..."}}
def test_metadata_defaults_to_none(self):
item = ExploreMessageListItem(**_base_kwargs())
assert item.model_dump(mode="json")["metadata"] is None
def test_base_message_list_item_has_no_metadata(self):
# Guard the public service-API contract: the base item must not leak metadata.
payload = MessageListItem(**_base_kwargs()).model_dump(mode="json")
assert "metadata" not in payload

View File

@ -35,6 +35,13 @@ class TestValidPassword:
with pytest.raises(ValueError):
valid_password("")
def test_should_reject_password_shorter_than_minimum_length(self):
"""A 7-character password with letters and numbers is rejected for length."""
with pytest.raises(ValueError) as exc_info:
valid_password("abc1234")
assert "at least 8" in str(exc_info.value)
class TestPasswordHashing:
"""Test password hashing and comparison"""

View File

@ -4,7 +4,7 @@ from pydantic import ValidationError
from models.agent_config_entities import AgentKnowledgeQueryMode, AgentSoulModelConfig, DeclaredOutputType
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import AgentSoulLockedError, PlaintextSecretNotAllowedError
from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError
from services.entities.agent_entities import (
AgentSoulConfig,
ComposerSavePayload,
@ -65,6 +65,24 @@ def test_locked_workflow_node_job_only_allows_inline_soul_payload():
ComposerConfigValidator.validate_save_payload(payload)
def test_draft_save_payload_skips_publish_only_agent_soul_validation():
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.AGENT_APP,
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
"agent_soul": {
"prompt": {"system_prompt": "no human reference yet"},
"human": {"contacts": [{"id": "human-1", "name": "Reviewer"}]},
"env": {"variables": [{"name": "bad-name"}]},
},
}
)
ComposerConfigValidator.validate_draft_save_payload(payload)
with pytest.raises(InvalidComposerConfigError):
ComposerConfigValidator.validate_publish_payload(payload)
def test_agent_app_soul_allows_app_features_and_variables():
payload = ComposerSavePayload.model_validate(
{
@ -88,6 +106,28 @@ def test_agent_app_soul_allows_app_features_and_variables():
assert payload.agent_soul.app_variables[0].name == "company_name"
def test_composer_save_payload_accepts_new_roster_metadata():
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.WORKFLOW,
"save_strategy": ComposerSaveStrategy.SAVE_TO_ROSTER,
"new_agent_name": "Research Agent",
"description": "Finds relevant sources.",
"role": "Research Assistant",
"icon_type": "emoji",
"icon": "search",
"icon_background": "#E0F2FE",
}
)
assert payload.new_agent_name == "Research Agent"
assert payload.description == "Finds relevant sources."
assert payload.role == "Research Assistant"
assert payload.icon_type == "emoji"
assert payload.icon == "search"
assert payload.icon_background == "#E0F2FE"
def test_knowledge_query_mode_uses_stable_backend_enums():
config = AgentSoulConfig.model_validate(
{

View File

@ -4,11 +4,13 @@ from types import SimpleNamespace
import pytest
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError
from models.agent import (
Agent,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
AgentDebugConversation,
AgentDriveFile,
AgentKind,
AgentScope,
AgentSource,
@ -30,7 +32,7 @@ from services.agent import composer_service, roster_service
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import InvalidComposerConfigError
from services.agent.errors import AgentVersionConflictError, InvalidComposerConfigError
from services.agent.roster_service import AgentRosterService
from services.agent.workflow_publish_service import WorkflowAgentPublishService
from services.app_service import AppListParams, AppService
@ -168,7 +170,7 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy,
calls = []
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None)
monkeypatch.setattr(
@ -221,12 +223,52 @@ def test_save_workflow_composer_rejects_agent_app_variant():
)
def _duplicate_env_secret_payload(strategy: ComposerSaveStrategy) -> ComposerSavePayload:
return ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.AGENT_APP.value,
"save_strategy": strategy.value,
"agent_soul": {
"prompt": {"system_prompt": "x"},
"env": {
"variables": [{"name": "TOKEN", "value": "plain"}],
"secret_refs": [{"name": "TOKEN", "value": "credential-1"}],
},
},
}
)
@pytest.mark.parametrize(
"strategy",
[
ComposerSaveStrategy.NODE_JOB_ONLY,
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
],
)
def test_draft_save_strategies_skip_publish_validation(strategy: ComposerSaveStrategy):
composer_service._validate_composer_payload_for_strategy(_duplicate_env_secret_payload(strategy))
@pytest.mark.parametrize(
"strategy",
[
ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
ComposerSaveStrategy.SAVE_AS_NEW_AGENT,
ComposerSaveStrategy.SAVE_TO_ROSTER,
],
)
def test_publish_save_strategies_run_publish_validation(strategy: ComposerSaveStrategy):
with pytest.raises(InvalidComposerConfigError, match="duplicate env/secret name 'TOKEN'"):
composer_service._validate_composer_payload_for_strategy(_duplicate_env_secret_payload(strategy))
def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest.MonkeyPatch):
fake_session = FakeSession(scalar=[None])
created_version = SimpleNamespace(id="version-1")
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
monkeypatch.setattr(AgentComposerService, "_create_config_version", lambda **kwargs: created_version)
monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True})
payload = ComposerSavePayload.model_validate(
@ -256,7 +298,7 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch: pytest.Mon
updated = {}
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: SimpleNamespace(id="version-1"))
monkeypatch.setattr(
AgentComposerService,
@ -374,9 +416,28 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
fake_session = FakeSession()
monkeypatch.setattr(composer_service.db, "session", fake_session)
workflow_agent = SimpleNamespace(id="inline-agent-1", active_config_snapshot_id="inline-version-1")
roster_agent = SimpleNamespace(id="roster-agent-1", active_config_snapshot_id="roster-version-1", name="Roster")
roster_agent = SimpleNamespace(
id="roster-agent-1",
active_config_snapshot_id="roster-version-1",
name="Roster",
description="Source description",
role="Source role",
icon_type="emoji",
icon="source",
icon_background="#FFFFFF",
)
create_roster_calls = []
monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent)
monkeypatch.setattr(AgentComposerService, "_create_roster_agent_for_composer", lambda **kwargs: roster_agent)
def fake_create_roster_agent_for_composer(**kwargs):
create_roster_calls.append(kwargs)
return roster_agent
monkeypatch.setattr(
AgentComposerService,
"_create_roster_agent_for_composer",
fake_create_roster_agent_for_composer,
)
monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent)
monkeypatch.setattr(
AgentComposerService,
@ -402,6 +463,11 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
"agent_soul": {"prompt": {"system_prompt": "new"}},
"node_job": {"workflow_prompt": "use prior output"},
"new_agent_name": "Copied Agent",
"description": "Copied description",
"role": "Copied role",
"icon_type": "emoji",
"icon": "copied",
"icon_background": "#E0F2FE",
}
)
existing_binding = WorkflowAgentNodeBinding(agent_id="inline-agent-1", current_snapshot_id="inline-version-1")
@ -459,6 +525,14 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
assert new_agent_binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
assert save_to_roster_binding.agent_id == "roster-agent-1"
assert new_version_binding.current_snapshot_id == "new-version-1"
assert create_roster_calls[0]["description"] == "Copied description"
assert create_roster_calls[0]["role"] == "Copied role"
assert create_roster_calls[0]["icon"] == "copied"
assert create_roster_calls[0]["icon_background"] == "#E0F2FE"
assert create_roster_calls[1]["description"] == "Copied description"
assert create_roster_calls[1]["role"] == "Copied role"
assert create_roster_calls[1]["icon"] == "copied"
assert create_roster_calls[1]["icon_background"] == "#E0F2FE"
def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch):
@ -674,6 +748,428 @@ def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypat
)
def test_copy_workflow_composer_from_roster_creates_inline_agent_and_preserves_node_job(
monkeypatch: pytest.MonkeyPatch,
):
fake_session = FakeSession()
monkeypatch.setattr(composer_service.db, "session", fake_session)
workflow = SimpleNamespace(id="workflow-1")
node_job = WorkflowNodeJobConfig(workflow_prompt="keep this node task")
binding = WorkflowAgentNodeBinding(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version="draft",
node_id="node-1",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id="roster-agent-1",
current_snapshot_id="old-roster-version",
node_job_config=node_job,
)
roster_agent = Agent(
id="roster-agent-1",
tenant_id="tenant-1",
name="Nadia",
description="Clarification Drafter",
role="Clarifies tenders",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="roster-version-2",
)
source_version = AgentConfigSnapshot(
id="roster-version-2",
tenant_id="tenant-1",
agent_id="roster-agent-1",
version=2,
config_snapshot='{"prompt":{"system_prompt":"copy me"}}',
)
inline_agent = Agent(
id="inline-agent-1",
tenant_id="tenant-1",
name="Nadia",
description="Clarification Drafter",
role="Clarifies tenders",
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="inline-version-1",
)
captured: dict[str, object] = {}
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: workflow)
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding)
monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent)
monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: source_version)
def fake_create_workflow_only_agent(**kwargs):
captured["create"] = kwargs
return inline_agent
def fake_copy_drive_rows(**kwargs):
captured["drive"] = kwargs
monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent)
monkeypatch.setattr(AgentComposerService, "_copy_agent_drive_rows", fake_copy_drive_rows)
monkeypatch.setattr(
AgentComposerService,
"_serialize_workflow_state",
lambda **kwargs: {
"binding": {
"binding_type": kwargs["binding"].binding_type.value,
"agent_id": kwargs["binding"].agent_id,
"current_snapshot_id": kwargs["binding"].current_snapshot_id,
},
"node_job": kwargs["binding"].node_job_config_dict,
},
)
state = AgentComposerService.copy_workflow_composer_from_roster(
tenant_id="tenant-1",
app_id="app-1",
node_id="node-1",
account_id="account-1",
source_agent_id="roster-agent-1",
source_snapshot_id="roster-version-2",
)
assert state["binding"]["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value
assert state["binding"]["agent_id"] == "inline-agent-1"
assert state["node_job"]["workflow_prompt"] == "keep this node task"
assert binding.node_job_config is node_job
create_kwargs = captured["create"]
assert create_kwargs["agent_soul"].prompt.system_prompt == "copy me"
assert create_kwargs["name"] == "Nadia"
assert create_kwargs["role"] == "Clarifies tenders"
drive_kwargs = captured["drive"]
assert drive_kwargs["source_agent_id"] == "roster-agent-1"
assert drive_kwargs["target_agent_id"] == "inline-agent-1"
assert fake_session.commits == 1
def test_copy_workflow_composer_from_roster_rejects_stale_source_snapshot(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
monkeypatch.setattr(
AgentComposerService,
"_get_workflow_binding",
lambda **kwargs: WorkflowAgentNodeBinding(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version="draft",
node_id="node-1",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id="roster-agent-1",
current_snapshot_id="roster-version-1",
node_job_config=WorkflowNodeJobConfig(),
),
)
roster_agent = Agent(
id="roster-agent-1",
tenant_id="tenant-1",
name="Nadia",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="roster-version-2",
)
source_version = AgentConfigSnapshot(
id="roster-version-2",
tenant_id="tenant-1",
agent_id="roster-agent-1",
version=2,
config_snapshot='{"prompt":{"system_prompt":"copy me"}}',
)
monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent)
monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: source_version)
with pytest.raises(AgentVersionConflictError):
AgentComposerService.copy_workflow_composer_from_roster(
tenant_id="tenant-1",
app_id="app-1",
node_id="node-1",
account_id="account-1",
source_agent_id="roster-agent-1",
source_snapshot_id="roster-version-1",
)
def test_copy_workflow_composer_from_roster_is_idempotent_when_already_inline(monkeypatch: pytest.MonkeyPatch):
inline_binding = WorkflowAgentNodeBinding(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version="draft",
node_id="node-1",
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id="inline-agent-1",
current_snapshot_id="inline-version-1",
)
inline_agent = Agent(
id="inline-agent-1",
tenant_id="tenant-1",
name="Inline",
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="inline-version-1",
)
inline_version = AgentConfigSnapshot(
id="inline-version-1",
tenant_id="tenant-1",
agent_id="inline-agent-1",
version=1,
config_snapshot='{"prompt":{"system_prompt":"inline"}}',
)
monkeypatch.setattr(composer_service.db, "session", FakeSession())
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: inline_binding)
monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: inline_agent)
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: inline_version)
monkeypatch.setattr(
AgentComposerService,
"_serialize_workflow_state",
lambda **kwargs: {"binding_type": kwargs["binding"].binding_type.value},
)
state = AgentComposerService.copy_workflow_composer_from_roster(
tenant_id="tenant-1",
app_id="app-1",
node_id="node-1",
account_id="account-1",
source_agent_id="roster-agent-1",
idempotency_key="same-click",
)
assert state == {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value}
@pytest.mark.parametrize(
("binding_agent_id", "binding_type", "source_scope", "source_status", "expected_message"),
[
(
"roster-agent-1",
WorkflowAgentBindingType.INLINE_AGENT,
AgentScope.ROSTER,
AgentStatus.ACTIVE,
"must be bound to a roster agent",
),
(
"other-agent",
WorkflowAgentBindingType.ROSTER_AGENT,
AgentScope.ROSTER,
AgentStatus.ACTIVE,
"does not match",
),
(
"roster-agent-1",
WorkflowAgentBindingType.ROSTER_AGENT,
AgentScope.WORKFLOW_ONLY,
AgentStatus.ACTIVE,
"must be an active roster agent",
),
(
"roster-agent-1",
WorkflowAgentBindingType.ROSTER_AGENT,
AgentScope.ROSTER,
AgentStatus.ARCHIVED,
"must be an active roster agent",
),
],
)
def test_copy_workflow_composer_from_roster_rejects_invalid_source_binding(
monkeypatch: pytest.MonkeyPatch,
binding_agent_id: str,
binding_type: WorkflowAgentBindingType,
source_scope: AgentScope,
source_status: AgentStatus,
expected_message: str,
):
binding = WorkflowAgentNodeBinding(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version="draft",
node_id="node-1",
binding_type=binding_type,
agent_id=binding_agent_id,
current_snapshot_id="version-1",
node_job_config=WorkflowNodeJobConfig(),
)
source_agent = Agent(
id="roster-agent-1",
tenant_id="tenant-1",
name="Source",
scope=source_scope,
source=AgentSource.AGENT_APP,
status=source_status,
active_config_snapshot_id="version-1",
)
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding)
monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: source_agent)
with pytest.raises(InvalidComposerConfigError, match=expected_message):
AgentComposerService.copy_workflow_composer_from_roster(
tenant_id="tenant-1",
app_id="app-1",
node_id="node-1",
account_id="account-1",
source_agent_id="roster-agent-1",
)
def test_copy_agent_drive_rows_copies_skill_prefix_and_files(monkeypatch: pytest.MonkeyPatch):
skill_row = AgentDriveFile(
tenant_id="tenant-1",
agent_id="roster-agent-1",
key="tender-analyzer/SKILL.md",
file_kind="tool_file",
file_id="tool-file-1",
value_owned_by_drive=True,
is_skill=True,
skill_metadata='{"name":"Tender Analyzer"}',
size=10,
mime_type="text/markdown",
)
script_row = AgentDriveFile(
tenant_id="tenant-1",
agent_id="roster-agent-1",
key="tender-analyzer/scripts/run.sh",
file_kind="tool_file",
file_id="tool-file-2",
value_owned_by_drive=True,
size=20,
mime_type="text/x-shellscript",
)
file_row = AgentDriveFile(
tenant_id="tenant-1",
agent_id="roster-agent-1",
key="files/qna.pdf",
file_kind="upload_file",
file_id="upload-file-1",
value_owned_by_drive=False,
size=30,
mime_type="application/pdf",
)
fake_session = FakeSession(scalars=[[skill_row, script_row, file_row], []])
monkeypatch.setattr(composer_service.db, "session", fake_session)
agent_soul = AgentSoulConfig.model_validate(
{
"prompt": {
"system_prompt": "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]",
},
}
)
node_job = WorkflowNodeJobConfig.model_validate(
{"metadata": {"file_refs": [{"name": "qna.pdf", "drive_key": "files/qna.pdf"}]}}
)
AgentComposerService._copy_agent_drive_rows(
tenant_id="tenant-1",
source_agent_id="roster-agent-1",
target_agent_id="inline-agent-1",
account_id="account-1",
agent_soul=agent_soul,
node_job=node_job,
)
copied = [row for row in fake_session.added if isinstance(row, AgentDriveFile)]
assert [row.key for row in copied] == [
"tender-analyzer/SKILL.md",
"tender-analyzer/scripts/run.sh",
"files/qna.pdf",
]
assert {row.agent_id for row in copied} == {"inline-agent-1"}
assert copied[0].file_id == "tool-file-1"
assert copied[0].is_skill is True
assert copied[2].value_owned_by_drive is False
def test_copy_agent_drive_rows_skips_when_no_referenced_drive_keys(monkeypatch: pytest.MonkeyPatch):
fake_session = FakeSession()
monkeypatch.setattr(composer_service.db, "session", fake_session)
agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "No drive mentions."}})
AgentComposerService._copy_agent_drive_rows(
tenant_id="tenant-1",
source_agent_id="roster-agent-1",
target_agent_id="inline-agent-1",
account_id="account-1",
agent_soul=agent_soul,
)
assert fake_session.added == []
def test_copy_agent_drive_rows_skips_existing_target_keys(monkeypatch: pytest.MonkeyPatch):
source_row = AgentDriveFile(
tenant_id="tenant-1",
agent_id="roster-agent-1",
key="files/qna.pdf",
file_kind="upload_file",
file_id="upload-file-1",
value_owned_by_drive=False,
size=30,
mime_type="application/pdf",
)
fake_session = FakeSession(scalars=[[source_row], ["files/qna.pdf"]])
monkeypatch.setattr(composer_service.db, "session", fake_session)
agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "[§file:files/qna.pdf:qna.pdf§]"}})
AgentComposerService._copy_agent_drive_rows(
tenant_id="tenant-1",
source_agent_id="roster-agent-1",
target_agent_id="inline-agent-1",
account_id="account-1",
agent_soul=agent_soul,
)
assert [row for row in fake_session.added if isinstance(row, AgentDriveFile)] == []
def test_drive_copy_scopes_include_declared_output_benchmark_files():
agent_soul = AgentSoulConfig.model_validate(
{
"prompt": {
"system_prompt": (
"[§file:files/source.pdf:source.pdf§] "
"[§knowledge:dataset-1:Docs§] "
"[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]"
)
},
}
)
node_job = WorkflowNodeJobConfig.model_validate(
{
"declared_outputs": [
{
"name": "qna_report",
"type": "file",
"check": {
"enabled": True,
"prompt": "Compare the generated file with the benchmark.",
"benchmark_file_ref": {"name": "expected.pdf", "drive_key": "files/expected.pdf"},
},
},
{
"name": "summary",
"type": "string",
"check": {"enabled": False, "benchmark_file_ref": {"drive_key": "files/ignored.pdf"}},
},
],
}
)
exact_keys, prefixes = AgentComposerService._drive_copy_scopes_from_agent_configs(
agent_soul=agent_soul,
node_job=node_job,
)
assert exact_keys == {"files/source.pdf", "files/expected.pdf"}
assert prefixes == {"tender-analyzer/"}
def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytest.MonkeyPatch):
fake_session = FakeSession()
monkeypatch.setattr(composer_service.db, "session", fake_session)
@ -2060,6 +2556,97 @@ class TestListWorkflowsReferencingAppAgent:
class TestWorkflowAgentDraftBindingSync:
def _agent_workflow(self) -> Workflow:
return 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"}}],
"edges": [],
}
),
)
def _agent_binding(self) -> WorkflowAgentNodeBinding:
return WorkflowAgentNodeBinding(
id="binding-1",
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_version=Workflow.VERSION_DRAFT,
node_id="agent-node",
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
agent_id="agent-1",
current_snapshot_id="snapshot-1",
node_job_config=WorkflowNodeJobConfig(),
)
def _publish_agent(self) -> Agent:
return Agent(
id="agent-1",
tenant_id="tenant-1",
name="Iris",
status=AgentStatus.ACTIVE,
active_config_snapshot_id="snapshot-1",
)
def _snapshot(self, agent_soul: AgentSoulConfig) -> AgentConfigSnapshot:
return AgentConfigSnapshot(
id="snapshot-1",
tenant_id="tenant-1",
agent_id="agent-1",
version=1,
config_snapshot=agent_soul,
)
def test_publish_validation_rejects_agent_soul_publish_only_errors(self):
binding = self._agent_binding()
agent_soul = AgentSoulConfig.model_validate(
{
"model": {
"plugin_id": "langgenius/openai/openai",
"model_provider": "openai",
"model": "gpt-4o",
},
"prompt": {"system_prompt": "no human reference yet"},
"human": {"contacts": [{"id": "human-1", "name": "Reviewer"}]},
}
)
agent = self._publish_agent()
snapshot = self._snapshot(agent_soul)
session = FakeSession(scalar=[binding, agent, snapshot, agent, snapshot], scalars=[[binding]])
with pytest.raises(InvalidComposerConfigError, match="human_involvement_not_referenced"):
WorkflowAgentPublishService.validate_agent_nodes_for_publish(
session=session,
draft_workflow=self._agent_workflow(),
)
def test_publish_validation_rejects_dangling_agent_soul_drive_refs(self):
binding = self._agent_binding()
agent_soul = AgentSoulConfig.model_validate(
{
"model": {
"plugin_id": "langgenius/openai/openai",
"model_provider": "openai",
"model": "gpt-4o",
},
"prompt": {"system_prompt": "Use [§skill:research%2FSKILL.md:Research§]."},
}
)
agent = self._publish_agent()
snapshot = self._snapshot(agent_soul)
session = FakeSession(scalar=[binding, agent, snapshot, agent, snapshot], scalars=[[binding], []])
with pytest.raises(WorkflowAgentNodeValidationError, match="skill_ref_dangling"):
WorkflowAgentPublishService.validate_agent_nodes_for_publish(
session=session,
draft_workflow=self._agent_workflow(),
)
def test_projects_binding_declared_outputs_to_draft_graph_response(self):
workflow = Workflow(
id="workflow-1",

View File

@ -745,15 +745,54 @@ class TestMemberRoles:
def test_replace(self, mock_send: MagicMock):
mock_send.return_value = {"account_id": "acct-2", "roles": []}
svc.RBACService.MemberRoles.replace(
"tenant-1", "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"]
)
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True):
svc.RBACService.MemberRoles.replace(
"tenant-1", "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"]
)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/members/rbac-roles"
assert call.params == {"account_id": "acct-2"}
assert call.json == {"role_ids": ["workspace.owner", "workspace.editor"]}
def test_replace_updates_legacy_join_role_when_rbac_disabled(self, mock_send: MagicMock):
session = MagicMock()
session.__enter__.return_value = session
target_join = SimpleNamespace(role=svc.TenantAccountRole.NORMAL, account_id="acct-2")
session.scalar.return_value = target_join
with (
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
patch(f"{MODULE}.session_factory.create_session", return_value=session),
):
out = svc.RBACService.MemberRoles.replace("tenant-1", "acct-1", "acct-2", role_ids=["editor"])
mock_send.assert_not_called()
session.commit.assert_called_once()
assert target_join.role == svc.TenantAccountRole.EDITOR
assert out.account_id == "acct-2"
assert out.roles[0].id == "editor"
assert "app.acl.preview" in out.roles[0].permission_keys
def test_replace_legacy_owner_demotes_current_owner_when_rbac_disabled(self, mock_send: MagicMock):
session = MagicMock()
session.__enter__.return_value = session
target_join = SimpleNamespace(role=svc.TenantAccountRole.NORMAL, account_id="acct-2")
owner_join = SimpleNamespace(role=svc.TenantAccountRole.OWNER, account_id="acct-owner")
session.scalar.side_effect = [target_join, owner_join]
with (
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
patch(f"{MODULE}.session_factory.create_session", return_value=session),
):
out = svc.RBACService.MemberRoles.replace("tenant-1", "acct-1", "acct-2", role_ids=["owner"])
mock_send.assert_not_called()
session.commit.assert_called_once()
assert target_join.role == svc.TenantAccountRole.OWNER
assert owner_join.role == svc.TenantAccountRole.ADMIN
assert out.roles[0].id == "owner"
def test_batch_get(self, mock_send: MagicMock):
mock_send.return_value = {
"acct-2": [

8
api/uv.lock generated
View File

@ -1636,7 +1636,7 @@ requires-dist = [
{ name = "gmpy2", specifier = ">=2.3.0,<3.0.0" },
{ name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" },
{ name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" },
{ name = "graphon", specifier = "==0.5.2" },
{ name = "graphon", specifier = "==0.5.3" },
{ name = "gunicorn", specifier = ">=26.0.0,<27.0.0" },
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
{ name = "httpx-sse", specifier = "==0.4.3" },
@ -2987,7 +2987,7 @@ httpx = [
[[package]]
name = "graphon"
version = "0.5.2"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
@ -3008,9 +3008,9 @@ dependencies = [
{ name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] },
{ name = "webvtt-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/16/f183da187414c335be67f52f6a1b7c2a33bf0b1d5090eda7e6c92d42d94a/graphon-0.5.2.tar.gz", hash = "sha256:d66a9edcd883766bd50e94f84a691c92ce536ea60e721552089e83ac8e94bf68", size = 269773, upload-time = "2026-06-16T04:06:22.074Z" }
sdist = { url = "https://files.pythonhosted.org/packages/50/02/75c8cc2f946c8b6debe4f71a8a0f41a69cd499073368a8735ca424c6551f/graphon-0.5.3.tar.gz", hash = "sha256:eaa87d5e664acdf14c80e38afce6bc0f14644961de7ce7b059266fe61bc30e0b", size = 271204, upload-time = "2026-06-23T08:13:32.46Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/e6/36a3981cd44e7a40a7cd7d374e26f01e02dd49410c5fbbd7df248750d5fb/graphon-0.5.2-py3-none-any.whl", hash = "sha256:11f89399e67ed1ddd2ce1c336accd9c4ad5b8fe2741f9167e6085af0b325cd14", size = 381908, upload-time = "2026-06-16T04:06:20.453Z" },
{ url = "https://files.pythonhosted.org/packages/84/fb/616f8ecbd184af57dca8380877b149198d944f4a6658cceb353ae02ace92/graphon-0.5.3-py3-none-any.whl", hash = "sha256:a7f070d1e5eef13d25b97cce6d23675b228c1d38f3c656e3dcacaa6be9ccada4", size = 383359, upload-time = "2026-06-23T08:13:31.075Z" },
]
[[package]]

View File

@ -18,9 +18,11 @@ side-effecting ``on_context_resume`` attempt fails after issuing shellctl jobs,
Agenton still exits ``resource_context()`` but never transitions the layer to
``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run,
so the enter hook itself must perform best-effort business compensation before
re-raising the failure. Agent Stub env injection uses shellctl's native per-run
``env`` argument for user-visible ``shell.run`` and for trusted server-owned
fixed scripts executed through ``run_remote_script()``.
re-raising the failure. Agent Soul shell env is injected into user-visible
commands and CLI bootstrap commands without persisting a workspace env file.
Agent Stub env injection uses shellctl's native per-run ``env`` argument for
user-visible ``shell.run`` and for trusted server-owned fixed scripts executed
through ``run_remote_script()``.
"""
from __future__ import annotations
@ -475,7 +477,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
try:
client = self._require_client()
result = await client.run(
_wrap_user_script(script),
_wrap_user_script(script, self.config),
cwd=self._require_workspace_cwd(),
env=self._build_user_shell_run_env(),
timeout=timeout,
@ -536,9 +538,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
and optional Agent Stub env injection.
Unlike model-visible ``shell.run``, this server-owned boundary does not
source ``.dify/env.sh``. That file is user-controlled shell config, so
sourcing it here would let sandbox code clobber trusted Agent Stub env
values before ``dify-agent file upload`` executes.
inject Agent Soul shell env. Keeping the user-controlled shell env out
of this path prevents sandbox code from clobbering trusted Agent Stub
env values before ``dify-agent file upload`` executes.
"""
env = None
if inject_agent_stub_env:
@ -833,16 +835,18 @@ def _workspace_cwd(session_id: str) -> str:
def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
"""Return the workspace bootstrap script for env + CLI tool declarations."""
has_bootstrap = bool(config.env or config.secret_refs or config.cli_tools or config.sandbox is not None)
if not has_bootstrap:
"""Return the workspace bootstrap script for CLI tool declarations."""
install_commands = [command for tool in config.cli_tools for command in tool.install_commands]
if not install_commands:
return ""
lines: list[str] = [
"set -eu",
'mkdir -p ".dify"',
"cat > \".dify/env.sh\" <<'DIFY_ENV_EOF'",
]
lines: list[str] = ["set -eu", *_shell_config_export_lines(config), *install_commands]
return "\n".join(lines)
def _shell_config_export_lines(config: DifyShellLayerConfig) -> list[str]:
"""Return ephemeral Agent Soul shell exports for one shellctl command."""
lines: list[str] = []
for env_var in config.env:
lines.append(f"export {env_var.name}={_shquote(env_var.value)}")
for secret_ref in config.secret_refs:
@ -860,32 +864,15 @@ def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
if config.sandbox.config:
sandbox_config = json.dumps(config.sandbox.config, ensure_ascii=True, sort_keys=True)
lines.append(f"export DIFY_SANDBOX_CONFIG_JSON={_shquote(sandbox_config)}")
lines.extend(
[
"DIFY_ENV_EOF",
'chmod 600 ".dify/env.sh"',
'. ".dify/env.sh"',
]
)
for tool in config.cli_tools:
for command in tool.install_commands:
lines.append(command)
return "\n".join(lines)
return lines
def _wrap_user_script(script: str) -> str:
"""Source Agent Soul env before executing a model-requested shell command."""
# TODO: refactor
return "\n".join(
[
'if [ -f ".dify/env.sh" ]; then',
" set -a",
' . ".dify/env.sh"',
" set +a",
"fi",
script,
]
)
def _wrap_user_script(script: str, config: DifyShellLayerConfig) -> str:
"""Inject Agent Soul env before executing a model-requested shell command."""
lines = _shell_config_export_lines(config)
if not lines:
return script
return "\n".join([*lines, script])
def _workspace_mkdir_script(*, session_id: str) -> str:

View File

@ -3,6 +3,7 @@ from collections.abc import Callable, Mapping
import secrets
import time
from dataclasses import dataclass
from typing import cast
import pytest
@ -454,7 +455,6 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
assert 'export GITHUB_TOKEN="${GITHUB_TOKEN:-}"' in script
assert "export DIFY_SANDBOX_PROVIDER='independent'" in script
assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script
assert '. ".dify/env.sh"' in script
assert "apt-get install -y ripgrep" in script
return _job_result("bootstrap-job", status=JobStatusName.EXITED, done=True, exit_code=0)
@ -489,10 +489,60 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
assert layer.runtime_state.job_ids == ["mkdir-job", "bootstrap-job"]
def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(time, "time", lambda: 0xABC12)
def token_hex(_nbytes: int) -> str:
return "ff"
monkeypatch.setattr(secrets, "token_hex", token_hex)
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
del timeout
assert env is None
if cwd is None:
return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0)
assert cwd == "~/workspace/abc12ff"
assert "export PROJECT_NAME='demo project'" in script
assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script
assert "export DIFY_SANDBOX_PROVIDER='independent'" in script
assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script
assert script.endswith("\npwd")
return _job_result("user-job", status=JobStatusName.EXITED, done=True, exit_code=0)
client = FakeShellctlClient(run_handler=run_handler)
layer = _shell_layer(
client_factory=lambda _entrypoint: client,
config=DifyShellLayerConfig(
env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project")],
secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="secret-1")],
sandbox=DifyShellSandboxConfig(provider="independent", config={"cpu": 2}),
),
)
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
await layer.on_context_create()
run_result = cast(
Mapping[str, object],
await tools["shell_run"].function_schema.call(
{"script": "pwd"},
None, # pyright: ignore[reportArgumentType]
),
)
assert run_result["job_id"] == "user-job"
asyncio.run(scenario())
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ff"]
assert layer.runtime_state.job_ids == ["mkdir-job", "user-job"]
def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
assert script.endswith("\npwd")
assert '. ".dify/env.sh"' in script
assert script == "pwd"
assert cwd == "~/workspace/abc12ff"
assert env is None
assert timeout == 2.5
@ -608,8 +658,7 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -
def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
del cwd, timeout
if script.endswith("\npwd"):
assert '. ".dify/env.sh"' in script
if script == "pwd":
assert env is not None
return _job_result("user-job", status=JobStatusName.EXITED, done=True, exit_code=0)
assert env is None
@ -639,8 +688,8 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() ->
asyncio.run(scenario())
user_run_call = next(call for call in client.run_calls if call.script.endswith("\npwd"))
internal_run_calls = [call for call in client.run_calls if not call.script.endswith("\npwd")]
user_run_call = next(call for call in client.run_calls if call.script == "pwd")
internal_run_calls = [call for call in client.run_calls if call.script != "pwd"]
assert user_run_call.env == {
AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub",

View File

@ -2133,9 +2133,6 @@
}
},
"web/app/components/base/markdown-blocks/think-block.tsx": {
"react/set-state-in-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}

View File

@ -134,9 +134,14 @@ export type ComposerSavePayload = {
agent_soul?: AgentSoulConfig | null
binding?: ComposerBindingPayload | null
client_revision_id?: string | null
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: AgentIconType | null
idempotency_key?: string | null
new_agent_name?: string | null
node_job?: WorkflowNodeJobConfig | null
role?: string | null
save_strategy: ComposerSaveStrategy
soul_lock?: ComposerSoulLockPayload
variant: ComposerVariant
@ -536,6 +541,8 @@ export type ComposerBindingPayload = {
current_snapshot_id?: string | null
}
export type AgentIconType = 'emoji' | 'image' | 'link'
export type WorkflowNodeJobConfig = {
declared_outputs?: Array<DeclaredOutputConfig>
human_contacts?: Array<AgentHumanContactConfig>
@ -876,8 +883,6 @@ export type LlmMode = 'chat' | 'completion'
export type AgentKind = 'dify_agent'
export type AgentIconType = 'emoji' | 'image' | 'link'
export type AgentPublishedReferenceResponse = {
app_icon?: string | null
app_icon_background?: string | null

View File

@ -282,6 +282,13 @@ export const zComposerBindingPayload = z.object({
current_snapshot_id: z.string().nullish(),
})
/**
* AgentIconType
*
* Supported icon storage formats for Agent roster entries.
*/
export const zAgentIconType = z.enum(['emoji', 'image', 'link'])
/**
* ComposerSoulLockPayload
*/
@ -830,13 +837,6 @@ export const zAgentAppDetailWithSite = z.object({
*/
export const zAgentKind = z.enum(['dify_agent'])
/**
* AgentIconType
*
* Supported icon storage formats for Agent roster entries.
*/
export const zAgentIconType = z.enum(['emoji', 'image', 'link'])
/**
* AgentPublishedReferenceResponse
*/
@ -1854,6 +1854,13 @@ export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']
/**
* AgentKnowledgeQueryConfig
*
* Per-set query policy for Agent v2 knowledge retrieval.
*
* Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
* legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
* set owns its own query policy, so ``user_query`` must carry an explicit
* ``value`` while ``generated_query`` leaves that value empty.
*/
export const zAgentKnowledgeQueryConfig = z.object({
mode: zAgentKnowledgeQueryMode,
@ -1879,6 +1886,12 @@ export const zAgentKnowledgeWeightedScoreConfig = z.object({
/**
* AgentKnowledgeRetrievalConfig
*
* Per-set retrieval policy for Agent v2 knowledge retrieval.
*
* Retrieval settings now live on each knowledge set instead of one shared
* flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
* ``single`` retrieval with a required model config.
*/
export const zAgentKnowledgeRetrievalConfig = z.object({
mode: z.enum(['multiple', 'single']),
@ -1967,6 +1980,12 @@ export const zAgentKnowledgeMetadataConditions = z.object({
/**
* AgentKnowledgeMetadataFilteringConfig
*
* Per-set metadata filtering policy.
*
* The Python attribute uses ``metadata_model_config`` for clarity because the
* model belongs to metadata filtering specifically, while the external API and
* generated schema keep the historical ``model_config`` field name via alias.
*/
export const zAgentKnowledgeMetadataFilteringConfig = z.object({
conditions: zAgentKnowledgeMetadataConditions.nullish(),
@ -1976,6 +1995,13 @@ export const zAgentKnowledgeMetadataFilteringConfig = z.object({
/**
* AgentKnowledgeSetConfig
*
* One explicit knowledge set in Agent v2.
*
* ``knowledge.sets`` replaces the old flat knowledge config. Each set owns
* its datasets plus query, retrieval, and metadata policies. An individual
* set must contain at least one dataset id even though the overall knowledge
* section may be empty, which is how callers express "no knowledge layer".
*/
export const zAgentKnowledgeSetConfig = z.object({
datasets: z.array(zAgentKnowledgeDatasetConfig),
@ -1989,6 +2015,14 @@ export const zAgentKnowledgeSetConfig = z.object({
/**
* AgentSoulKnowledgeConfig
*
* Top-level Agent v2 knowledge config.
*
* Agent v2 models knowledge as explicit sets instead of one flat
* ``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
* list means no knowledge layer should be emitted at runtime, while set-name
* uniqueness stays case-insensitive because runtime selection addresses sets
* by name.
*/
export const zAgentSoulKnowledgeConfig = z.object({
sets: z.array(zAgentKnowledgeSetConfig).optional(),
@ -2031,9 +2065,14 @@ export const zComposerSavePayload = z.object({
agent_soul: zAgentSoulConfig.nullish(),
binding: zComposerBindingPayload.nullish(),
client_revision_id: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().max(255).nullish(),
icon_background: z.string().max(255).nullish(),
icon_type: zAgentIconType.nullish(),
idempotency_key: z.string().nullish(),
new_agent_name: z.string().min(1).max(255).nullish(),
node_job: zWorkflowNodeJobConfig.nullish(),
role: z.string().max(255).nullish(),
save_strategy: zComposerSaveStrategy,
soul_lock: zComposerSoulLockPayload.optional(),
variant: zComposerVariant,

View File

@ -392,6 +392,9 @@ import {
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody,
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath,
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath,
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse,
@ -3479,6 +3482,26 @@ export const candidates = {
}
export const post51 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRoster',
path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster',
tags: ['console'],
})
.input(
z.object({
body: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody,
params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath,
}),
)
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse)
export const copyFromRoster = {
post: post51,
}
export const post52 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -3495,10 +3518,10 @@ export const post51 = oc
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse)
export const impact = {
post: post51,
post: post52,
}
export const post52 = oc
export const post53 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -3515,10 +3538,10 @@ export const post52 = oc
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse)
export const saveToRoster = {
post: post52,
post: post53,
}
export const post53 = oc
export const post54 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -3535,7 +3558,7 @@ export const post53 = oc
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse)
export const validate = {
post: post53,
post: post54,
}
export const get62 = oc
@ -3569,6 +3592,7 @@ export const agentComposer = {
get: get62,
put: put4,
candidates,
copyFromRoster,
impact,
saveToRoster,
validate,
@ -3598,7 +3622,7 @@ export const lastRun = {
*
* Run draft workflow node
*/
export const post54 = oc
export const post55 = oc
.route({
description: 'Run draft workflow node',
inputStructure: 'detailed',
@ -3617,7 +3641,7 @@ export const post54 = oc
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse)
export const run8 = {
post: post54,
post: post55,
}
/**
@ -3625,7 +3649,7 @@ export const run8 = {
*
* Poll for trigger events and execute single node when event arrives
*/
export const post55 = oc
export const post56 = oc
.route({
description: 'Poll for trigger events and execute single node when event arrives',
inputStructure: 'detailed',
@ -3639,7 +3663,7 @@ export const post55 = oc
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse)
export const run9 = {
post: post55,
post: post56,
}
export const trigger = {
@ -3699,7 +3723,7 @@ export const nodes7 = {
*
* Run draft workflow
*/
export const post56 = oc
export const post57 = oc
.route({
description: 'Run draft workflow',
inputStructure: 'detailed',
@ -3718,7 +3742,7 @@ export const post56 = oc
.output(zPostAppsByAppIdWorkflowsDraftRunResponse)
export const run10 = {
post: post56,
post: post57,
}
/**
@ -3840,7 +3864,7 @@ export const systemVariables = {
*
* Poll for trigger events and execute full workflow when event arrives
*/
export const post57 = oc
export const post58 = oc
.route({
description: 'Poll for trigger events and execute full workflow when event arrives',
inputStructure: 'detailed',
@ -3859,7 +3883,7 @@ export const post57 = oc
.output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse)
export const run11 = {
post: post57,
post: post58,
}
/**
@ -3867,7 +3891,7 @@ export const run11 = {
*
* Full workflow debug when the start node is a trigger
*/
export const post58 = oc
export const post59 = oc
.route({
description: 'Full workflow debug when the start node is a trigger',
inputStructure: 'detailed',
@ -3886,7 +3910,7 @@ export const post58 = oc
.output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse)
export const runAll = {
post: post58,
post: post59,
}
export const trigger2 = {
@ -4039,7 +4063,7 @@ export const get72 = oc
*
* Sync draft workflow configuration
*/
export const post59 = oc
export const post60 = oc
.route({
description: 'Sync draft workflow configuration',
inputStructure: 'detailed',
@ -4059,7 +4083,7 @@ export const post59 = oc
export const draft2 = {
get: get72,
post: post59,
post: post60,
conversationVariables: conversationVariables2,
environmentVariables,
features,
@ -4095,7 +4119,7 @@ export const get73 = oc
/**
* Publish workflow
*/
export const post60 = oc
export const post61 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -4114,7 +4138,7 @@ export const post60 = oc
export const publish = {
get: get73,
post: post60,
post: post61,
}
/**
@ -4251,7 +4275,7 @@ export const triggers2 = {
/**
* Restore a published workflow version into the draft workflow
*/
export const post61 = oc
export const post62 = oc
.route({
description: 'Restore a published workflow version into the draft workflow',
inputStructure: 'detailed',
@ -4264,7 +4288,7 @@ export const post61 = oc
.output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse)
export const restore = {
post: post61,
post: post62,
}
/**
@ -4489,7 +4513,7 @@ export const get81 = oc
*
* Create a new API key for an app
*/
export const post62 = oc
export const post63 = oc
.route({
description: 'Create a new API key for an app',
inputStructure: 'detailed',
@ -4505,7 +4529,7 @@ export const post62 = oc
export const apiKeys = {
get: get81,
post: post62,
post: post63,
byApiKeyId,
}
@ -4563,7 +4587,7 @@ export const get83 = oc
*
* Create a new application
*/
export const post63 = oc
export const post64 = oc
.route({
description: 'Create a new application',
inputStructure: 'detailed',
@ -4579,7 +4603,7 @@ export const post63 = oc
export const apps = {
get: get83,
post: post63,
post: post64,
imports,
starred,
workflows,

View File

@ -986,9 +986,14 @@ export type ComposerSavePayload = {
agent_soul?: AgentSoulConfig | null
binding?: ComposerBindingPayload | null
client_revision_id?: string | null
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: AgentIconType | null
idempotency_key?: string | null
new_agent_name?: string | null
node_job?: WorkflowNodeJobConfig | null
role?: string | null
save_strategy: ComposerSaveStrategy
soul_lock?: ComposerSoulLockPayload
variant: ComposerVariant
@ -1003,6 +1008,12 @@ export type AgentComposerCandidatesResponse = {
variant: ComposerVariant
}
export type WorkflowComposerCopyFromRosterPayload = {
idempotency_key?: string | null
source_agent_id: string
source_snapshot_id?: string | null
}
export type AgentComposerImpactResponse = {
bindings?: Array<AgentComposerImpactBindingResponse>
current_snapshot_id?: string | null
@ -1873,6 +1884,8 @@ export type ComposerBindingPayload = {
current_snapshot_id?: string | null
}
export type AgentIconType = 'emoji' | 'image' | 'link'
export type ComposerSoulLockPayload = {
locked?: boolean
unlocked_from_version_id?: string | null
@ -5505,6 +5518,23 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResp
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse
= GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses]
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterData = {
body: WorkflowComposerCopyFromRosterPayload
path: {
app_id: string
node_id: string
}
query?: never
url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster'
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses = {
200: WorkflowAgentComposerResponse
}
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse
= PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses]
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData = {
body: ComposerSavePayload
path: {

View File

@ -642,6 +642,15 @@ export const zHumanInputDeliveryTestPayload = z.object({
*/
export const zEmptyObjectResponse = z.record(z.string(), z.unknown())
/**
* WorkflowComposerCopyFromRosterPayload
*/
export const zWorkflowComposerCopyFromRosterPayload = z.object({
idempotency_key: z.string().max(255).nullish(),
source_agent_id: z.string().min(1).max(255),
source_snapshot_id: z.string().max(255).nullish(),
})
/**
* DraftWorkflowNodeRunPayload
*/
@ -1835,6 +1844,13 @@ export const zComposerBindingPayload = z.object({
current_snapshot_id: z.string().nullish(),
})
/**
* AgentIconType
*
* Supported icon storage formats for Agent roster entries.
*/
export const zAgentIconType = z.enum(['emoji', 'image', 'link'])
/**
* ComposerSoulLockPayload
*/
@ -3305,6 +3321,13 @@ export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']
/**
* AgentKnowledgeQueryConfig
*
* Per-set query policy for Agent v2 knowledge retrieval.
*
* Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
* legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
* set owns its own query policy, so ``user_query`` must carry an explicit
* ``value`` while ``generated_query`` leaves that value empty.
*/
export const zAgentKnowledgeQueryConfig = z.object({
mode: zAgentKnowledgeQueryMode,
@ -3330,6 +3353,12 @@ export const zAgentKnowledgeWeightedScoreConfig = z.object({
/**
* AgentKnowledgeRetrievalConfig
*
* Per-set retrieval policy for Agent v2 knowledge retrieval.
*
* Retrieval settings now live on each knowledge set instead of one shared
* flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
* ``single`` retrieval with a required model config.
*/
export const zAgentKnowledgeRetrievalConfig = z.object({
mode: z.enum(['multiple', 'single']),
@ -3501,6 +3530,12 @@ export const zAgentKnowledgeMetadataConditions = z.object({
/**
* AgentKnowledgeMetadataFilteringConfig
*
* Per-set metadata filtering policy.
*
* The Python attribute uses ``metadata_model_config`` for clarity because the
* model belongs to metadata filtering specifically, while the external API and
* generated schema keep the historical ``model_config`` field name via alias.
*/
export const zAgentKnowledgeMetadataFilteringConfig = z.object({
conditions: zAgentKnowledgeMetadataConditions.nullish(),
@ -3510,6 +3545,13 @@ export const zAgentKnowledgeMetadataFilteringConfig = z.object({
/**
* AgentKnowledgeSetConfig
*
* One explicit knowledge set in Agent v2.
*
* ``knowledge.sets`` replaces the old flat knowledge config. Each set owns
* its datasets plus query, retrieval, and metadata policies. An individual
* set must contain at least one dataset id even though the overall knowledge
* section may be empty, which is how callers express "no knowledge layer".
*/
export const zAgentKnowledgeSetConfig = z.object({
datasets: z.array(zAgentKnowledgeDatasetConfig),
@ -3523,6 +3565,14 @@ export const zAgentKnowledgeSetConfig = z.object({
/**
* AgentSoulKnowledgeConfig
*
* Top-level Agent v2 knowledge config.
*
* Agent v2 models knowledge as explicit sets instead of one flat
* ``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
* list means no knowledge layer should be emitted at runtime, while set-name
* uniqueness stays case-insensitive because runtime selection addresses sets
* by name.
*/
export const zAgentSoulKnowledgeConfig = z.object({
sets: z.array(zAgentKnowledgeSetConfig).optional(),
@ -3573,9 +3623,14 @@ export const zComposerSavePayload = z.object({
agent_soul: zAgentSoulConfig.nullish(),
binding: zComposerBindingPayload.nullish(),
client_revision_id: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().max(255).nullish(),
icon_background: z.string().max(255).nullish(),
icon_type: zAgentIconType.nullish(),
idempotency_key: z.string().nullish(),
new_agent_name: z.string().min(1).max(255).nullish(),
node_job: zWorkflowNodeJobConfig.nullish(),
role: z.string().max(255).nullish(),
save_strategy: zComposerSaveStrategy,
soul_lock: zComposerSoulLockPayload.optional(),
variant: zComposerVariant,
@ -5458,6 +5513,20 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPa
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse
= zAgentComposerCandidatesResponse
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody
= zWorkflowComposerCopyFromRosterPayload
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath = z.object({
app_id: z.uuid(),
node_id: z.string(),
})
/**
* Workflow roster agent copied to inline agent
*/
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse
= zWorkflowAgentComposerResponse
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody
= zComposerSavePayload

View File

@ -98,8 +98,8 @@ export type ResultResponse = {
result: string
}
export type MessageInfiniteScrollPagination = {
data: Array<MessageListItem>
export type ExploreMessageInfiniteScrollPagination = {
data: Array<ExploreMessageListItem>
has_more: boolean
limit: number
}
@ -187,7 +187,7 @@ export type JsonValue
| Array<unknown>
| null
export type MessageListItem = {
export type ExploreMessageListItem = {
agent_thoughts: Array<AgentThought>
answer: string
conversation_id: string
@ -200,6 +200,7 @@ export type MessageListItem = {
[key: string]: JsonValueType
}
message_files: Array<MessageFile>
metadata?: JsonValueType | null
parent_message_id?: string | null
query: string
retriever_resources: Array<RetrieverResource>
@ -644,7 +645,7 @@ export type GetInstalledAppsByInstalledAppIdMessagesData = {
}
export type GetInstalledAppsByInstalledAppIdMessagesResponses = {
200: MessageInfiniteScrollPagination
200: ExploreMessageInfiniteScrollPagination
}
export type GetInstalledAppsByInstalledAppIdMessagesResponse

View File

@ -503,9 +503,9 @@ export const zHumanInputContent = z.object({
})
/**
* MessageListItem
* ExploreMessageListItem
*/
export const zMessageListItem = z.object({
export const zExploreMessageListItem = z.object({
agent_thoughts: z.array(zAgentThought),
answer: z.string(),
conversation_id: z.string(),
@ -516,6 +516,7 @@ export const zMessageListItem = z.object({
id: z.string(),
inputs: z.record(z.string(), zJsonValueType),
message_files: z.array(zMessageFile),
metadata: zJsonValueType.nullish(),
parent_message_id: z.string().nullish(),
query: z.string(),
retriever_resources: z.array(zRetrieverResource),
@ -523,10 +524,10 @@ export const zMessageListItem = z.object({
})
/**
* MessageInfiniteScrollPagination
* ExploreMessageInfiniteScrollPagination
*/
export const zMessageInfiniteScrollPagination = z.object({
data: z.array(zMessageListItem),
export const zExploreMessageInfiniteScrollPagination = z.object({
data: z.array(zExploreMessageListItem),
has_more: z.boolean(),
limit: z.int(),
})
@ -693,7 +694,8 @@ export const zGetInstalledAppsByInstalledAppIdMessagesQuery = z.object({
/**
* Success
*/
export const zGetInstalledAppsByInstalledAppIdMessagesResponse = zMessageInfiniteScrollPagination
export const zGetInstalledAppsByInstalledAppIdMessagesResponse
= zExploreMessageInfiniteScrollPagination
export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksBody
= zMessageFeedbackPayload

View File

@ -1,13 +1,11 @@
import type { ReactNode } from 'react'
import { DeployDrawer } from '@/features/deployments/deploy-drawer'
import { DeploymentsRouteStateHydrator } from '@/features/deployments/route-state-hydrator'
export default function DeploymentsLayout({ children }: {
children: ReactNode
}) {
return (
<>
<DeploymentsRouteStateHydrator />
{children}
<DeployDrawer />
</>

View File

@ -7,6 +7,7 @@ import Zendesk from '@/app/components/base/zendesk'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { GotoAnything } from '@/app/components/goto-anything'
import MainNavLayout from '@/app/components/main-nav/layout'
import { NextRouteStateBridge } from '@/app/components/next-route-state'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import WorkflowGeneratorMount from '@/app/components/workflow/workflow-generator/mount'
@ -26,24 +27,26 @@ export default async function Layout({ children }: { children: ReactNode }) {
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout>
<RoleRouteGuard>
{children}
</RoleRouteGuard>
</MainNavLayout>
<InSiteMessageNotification />
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<NextRouteStateBridge>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout>
<RoleRouteGuard>
{children}
</RoleRouteGuard>
</MainNavLayout>
<InSiteMessageNotification />
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</NextRouteStateBridge>
</CommonLayoutHydrationBoundary>
<Zendesk />
</>

View File

@ -67,6 +67,8 @@ function getFormattedChatList(messages: any[]) {
feedback: item.feedback,
isAnswer: true,
citation: item.retriever_resources,
reasoningContent: item.metadata?.reasoning,
reasoningFinished: true,
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))),
parentMessageId: `question-${item.id}`,
humanInputFormDataList,

View File

@ -68,6 +68,7 @@ type HookCallbacks = {
onWorkflowPaused: (workflowPaused: Record<string, unknown>) => void
onTTSChunk: (messageId: string, audio: string) => void
onTTSEnd: (messageId: string, audio: string) => void
onReasoning: (chunk: { data: { message_id?: string, reasoning: string, node_id?: string, is_final?: boolean } }) => void
}
type UseChatFormSettings = NonNullable<Parameters<typeof useChat>[1]>
@ -2410,4 +2411,98 @@ describe('useChat', () => {
})
expect(result.current.chatList[1]!.annotation?.id).toBe('')
})
describe('reasoning (separated mode)', () => {
it('accumulates reasoning deltas per node and marks finished on is_final (handleSend)', () => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'hi' }, {})
})
act(() => {
callbacks.onData('answer', true, { messageId: 'm-1', conversationId: 'c-1', taskId: 't-1' })
})
act(() => {
callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'let me ', node_id: 'llm' } })
callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'think', node_id: 'llm' } })
})
const responseItem = result.current.chatList[1]!
expect(responseItem.reasoningContent).toEqual({ llm: 'let me think' })
expect(responseItem.reasoningFinished).toBeUndefined()
// answer stays clean — reasoning never leaks into content
expect(responseItem.content).toBe('answer')
act(() => {
callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: '', node_id: 'llm', is_final: true } })
})
expect(result.current.chatList[1]!.reasoningContent).toEqual({ llm: 'let me think' })
expect(result.current.chatList[1]!.reasoningFinished).toBe(true)
})
it('keys reasoning by node and falls back to "_" when node_id is absent (handleSend)', () => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'hi' }, {})
})
act(() => {
callbacks.onData('answer', true, { messageId: 'm-1', conversationId: 'c-1', taskId: 't-1' })
})
act(() => {
callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'a', node_id: 'llm-1' } })
callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'b', node_id: 'llm-2' } })
callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'c' } })
})
expect(result.current.chatList[1]!.reasoningContent).toEqual({ 'llm-1': 'a', 'llm-2': 'b', '_': 'c' })
})
it('accumulates reasoning onto an existing answer node on resume (handleResume / sseGet)', () => {
let callbacks: HookCallbacks
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const prevChatTree = [{
id: 'q-1',
content: 'query',
isAnswer: false,
children: [{
id: 'm-1',
content: 'initial',
isAnswer: true,
message_files: [],
siblingIndex: 0,
}],
}]
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[]))
act(() => {
result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true })
})
act(() => {
callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'resumed ', node_id: 'llm' } })
callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'thought', node_id: 'llm', is_final: true } })
})
const responseItem = result.current.chatList.find(item => item.id === 'm-1')!
expect(responseItem.reasoningContent).toEqual({ llm: 'resumed thought' })
expect(responseItem.reasoningFinished).toBe(true)
})
})
})

View File

@ -150,6 +150,92 @@ describe('Answer Component', () => {
})
})
// Reasoning panel slot (separated-mode chain-of-thought) in both layouts.
// The panel body renders through the async dynamic Markdown, so assertions
// target the synchronously-rendered "Thinking…/Thought" summary label.
describe('Reasoning Panel', () => {
it('should render the reasoning panel in the normal layout while thinking', () => {
render(
<Answer
{...defaultProps}
responding={true}
item={{
...defaultProps.item,
// Thinking ⇒ the answer has not started yet, so content must be empty.
content: '',
reasoningContent: { llm: 'deep thought' },
} as unknown as ChatItem}
/>,
)
expect(screen.getByText(/chat\.thinking/)).toBeInTheDocument()
})
it('should render the reasoning panel in the thought state once finished', () => {
render(
<Answer
{...defaultProps}
item={{
...defaultProps.item,
reasoningContent: { llm: 'recalled reasoning' },
reasoningFinished: true,
} as unknown as ChatItem}
/>,
)
expect(screen.getByText(/chat\.thought/)).toBeInTheDocument()
})
it('should render the reasoning panel within the human-input layout', () => {
render(
<Answer
{...defaultProps}
item={{
...defaultProps.item,
reasoningContent: { llm: 'human-input reasoning' },
humanInputFormDataList: [{ id: 'form1' }],
} as unknown as ChatItem}
/>,
)
// hasHumanInputs is true, so this can only come from the human-input slot
expect(screen.getByText(/chat\.(thinking|thought)/)).toBeInTheDocument()
})
it('should render the reasoning panel in the human-input layout when the answer is empty (history reload)', () => {
// Regression: the human-input slot outer guard must include hasReasoning, otherwise a
// rehydrated message with forms + reasoning but an empty answer drops the panel entirely.
render(
<Answer
{...defaultProps}
item={{
...defaultProps.item,
content: '',
reasoningContent: { llm: 'reload reasoning' },
reasoningFinished: true,
humanInputFilledFormDataList: [{ id: 'form1' }],
} as unknown as ChatItem}
/>,
)
expect(screen.getByText(/chat\.thought/)).toBeInTheDocument()
})
it('should not render the reasoning panel when reasoningContent is absent', () => {
render(<Answer {...defaultProps} />)
expect(screen.queryByText(/chat\.(thinking|thought)/)).not.toBeInTheDocument()
})
it('should not render the reasoning panel for an empty reasoningContent map (rehydrated, no reasoning)', () => {
render(
<Answer
{...defaultProps}
item={{
...defaultProps.item,
reasoningContent: {},
} as unknown as ChatItem}
/>,
)
expect(screen.queryByText(/chat\.(thinking|thought)/)).not.toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should handle switch sibling', () => {
const mockSwitchSibling = vi.fn()

View File

@ -0,0 +1,89 @@
import { act, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ReasoningPanel from '../reasoning-panel'
// Mock react-i18next so the reused chat.thinking/chat.thought labels resolve.
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'chat.thinking': 'Thinking...',
'chat.thought': 'Thought',
}
return translations[key] || key
},
}),
}))
// Mock the heavy Markdown renderer to a simple passthrough.
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content }: { content: string }) => <div data-testid="reasoning-markdown">{content}</div>,
}))
describe('ReasoningPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('renders nothing when there is no reasoning text', () => {
const { container } = render(<ReasoningPanel content={{}} done={false} />)
expect(container).toBeEmptyDOMElement()
})
it('shows the thinking state while not done', () => {
render(<ReasoningPanel content={{ llm: 'let me think' }} done={false} />)
expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument()
expect(screen.getByText('let me think')).toBeInTheDocument()
})
it('shows the thought state once done (answer started / terminal / history)', () => {
render(<ReasoningPanel content={{ llm: 'done thinking' }} done />)
expect(screen.getByText(/Thought/)).toBeInTheDocument()
})
it('counts elapsed time up while thinking', () => {
render(<ReasoningPanel content={{ llm: 'thinking' }} done={false} />)
expect(screen.getByText(/\(0\.0s\)/)).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(500)
})
expect(screen.getByText(/\(0\.5s\)/)).toBeInTheDocument()
})
it('freezes the timer once done (latched), even if it flips back', () => {
const { rerender } = render(<ReasoningPanel content={{ llm: 'thinking' }} done={false} />)
act(() => {
vi.advanceTimersByTime(700)
})
// Answer starts → done latches; timer must stop at 0.7s.
rerender(<ReasoningPanel content={{ llm: 'thinking' }} done />)
act(() => {
vi.advanceTimersByTime(1000)
})
expect(screen.getByText(/Thought\(0\.7s\)/)).toBeInTheDocument()
})
it('concatenates reasoning from multiple LLM nodes', () => {
render(<ReasoningPanel content={{ llm1: 'first', llm2: 'second' }} done={false} />)
expect(screen.getByTestId('reasoning-markdown')).toHaveTextContent('first second')
})
it('reflects in-place mutation of the same content object (streaming)', () => {
// The live stream mutates the same reasoningContent object under a stable reference,
// then re-renders. The panel must reflect the appended delta, not a stale snapshot.
const content: Record<string, string> = { llm: 'first' }
const { rerender } = render(<ReasoningPanel content={content} done={false} />)
expect(screen.getByTestId('reasoning-markdown')).toHaveTextContent('first')
content.llm = 'first second'
rerender(<ReasoningPanel content={content} done={false} />)
expect(screen.getByTestId('reasoning-markdown')).toHaveTextContent('first second')
})
})

View File

@ -24,6 +24,7 @@ import HumanInputFilledFormList from './human-input-filled-form-list'
import HumanInputFormList from './human-input-form-list'
import More from './more'
import Operation from './operation'
import ReasoningPanel from './reasoning-panel'
import SuggestedQuestions from './suggested-questions'
import WorkflowProcessItem from './workflow-process'
@ -74,6 +75,9 @@ const Answer: FC<AnswerProps> = ({
} = item
const hasAgentThoughts = !!agent_thoughts?.length
const hasHumanInputs = !!humanInputFormDataList?.length || !!humanInputFilledFormDataList?.length
// Truthy only when there is real reasoning text. Rehydrated messages carry an empty
// `{}` (the field is always persisted), and `!!{}` would otherwise be truthy.
const hasReasoning = !!item.reasoningContent && Object.values(item.reasoningContent).some(Boolean)
const [containerWidth, setContainerWidth] = useState(0)
const [contentWidth, setContentWidth] = useState(0)
@ -140,6 +144,15 @@ const Answer: FC<AnswerProps> = ({
}, [switchSibling, item.prevSibling, item.nextSibling])
const contentIsEmpty = typeof content === 'string' && content.trim() === ''
// Reasoning is "done" — freeze the elapsed timer and collapse the panel — as soon as ANY of:
// ① the answer has begun streaming (first text delta): the only signal that fires
// mid-node, so it drives the normal think→answer handoff;
// ② the reasoning stream's terminal marker arrived (a reasoning node that finishes
// before a separate answer node starts);
// ③ the response is no longer active — explicitly false, not merely absent (history / abnormal end).
// graphon's is_final (on BOTH the text and reasoning channels) is a node-terminal marker
// that trails the whole answer, so it can't drive ①; the answer-started signal must.
const reasoningDone = !contentIsEmpty || !!item.reasoningFinished || responding === false
return (
<div className="mb-2 flex last:mb-0">
@ -223,7 +236,7 @@ const Answer: FC<AnswerProps> = ({
)}
{/* Block 2: Response Content (when human inputs exist) */}
{hasHumanInputs && (responding || !contentIsEmpty || hasAgentThoughts) && (
{hasHumanInputs && (responding || !contentIsEmpty || hasAgentThoughts || hasReasoning) && (
<div className={cn('group relative mt-2 pr-10', chatAnswerContainerInner)}>
<div className="absolute -top-2 left-6 h-3 w-0.5 bg-chat-answer-human-input-form-divider-bg" />
<div
@ -245,7 +258,15 @@ const Answer: FC<AnswerProps> = ({
)
}
{
responding && contentIsEmpty && !hasAgentThoughts && (
hasReasoning && (
<ReasoningPanel
content={item.reasoningContent ?? {}}
done={reasoningDone}
/>
)
}
{
responding && contentIsEmpty && !hasAgentThoughts && !hasReasoning && (
<div className="flex h-5 w-6 items-center justify-center">
<LoadingAnim type="text" />
</div>
@ -351,7 +372,15 @@ const Answer: FC<AnswerProps> = ({
)
}
{
responding && contentIsEmpty && !hasAgentThoughts && (
hasReasoning && (
<ReasoningPanel
content={item.reasoningContent ?? {}}
done={reasoningDone}
/>
)
}
{
responding && contentIsEmpty && !hasAgentThoughts && !hasReasoning && (
<div className="flex h-5 w-6 items-center justify-center">
<LoadingAnim type="text" />
</div>

View File

@ -0,0 +1,31 @@
import type { FC } from 'react'
import { Markdown } from '@/app/components/base/markdown'
import ThinkingDetails from '@/app/components/base/markdown-blocks/thinking-details'
import { useElapsedTimer } from '@/app/components/base/markdown-blocks/use-elapsed-timer'
type ReasoningPanelProps = {
// reasoning (chain-of-thought) deltas accumulated per LLM node id
content: Record<string, string>
// true once reasoning is over (answer started / terminal marker / response ended);
// latches the elapsed timer and collapses the panel. Computed by the caller.
done: boolean
}
const ReasoningPanel: FC<ReasoningPanelProps> = ({ content, done }) => {
// First version renders one panel for the run; multiple LLM nodes are concatenated.
// Computed inline (not memoized): the live stream mutates `content` in place under a
// stable reference, so a [content]-keyed memo would never see new deltas.
const text = Object.values(content).filter(Boolean).join('\n\n')
const { elapsedTime, isComplete } = useElapsedTimer(done)
if (!text)
return null
return (
<ThinkingDetails className="my-2" isComplete={isComplete} elapsedTime={elapsedTime}>
<Markdown content={text} />
</ThinkingDetails>
)
}
export default ReasoningPanel

View File

@ -12,6 +12,7 @@ import type {
IOnDataMoreInfo,
IOtherOptions,
} from '@/service/base'
import type { ReasoningChunkResponse } from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast'
import { uniqBy } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
@ -283,6 +284,17 @@ export const useChat = (
if (taskId)
taskIdRef.current = taskId
},
onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => {
const { message_id, reasoning, node_id, is_final } = reasoningData
updateChatTreeNode(message_id, (responseItem) => {
const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {})
const key = node_id || '_'
if (reasoning)
reasoningContent[key] = (reasoningContent[key] || '') + reasoning
if (is_final)
responseItem.reasoningFinished = true
})
},
async onCompleted(hasError?: boolean) {
handleResponding(false)
@ -757,6 +769,22 @@ export const useChat = (
parentId: data.parent_message_id,
})
},
onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => {
const { reasoning, node_id, is_final } = reasoningData
const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {})
const key = node_id || '_'
if (reasoning)
reasoningContent[key] = (reasoningContent[key] || '') + reasoning
if (is_final)
responseItem.reasoningFinished = true
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
},
async onCompleted(hasError?: boolean) {
handleResponding(false)

View File

@ -113,6 +113,9 @@ export type IChatItem = {
suggestedQuestions?: string[]
log?: { role: string, text: string, files?: FileEntity[] }[]
agent_thoughts?: ThoughtItem[]
// for LLM reasoning (chain-of-thought) in "separated" mode, keyed by LLM node id
reasoningContent?: Record<string, string>
reasoningFinished?: boolean
message_files?: FileEntity[]
workflow_run_id?: string
// for agent log

View File

@ -39,6 +39,8 @@ function getFormattedChatList(messages: any[]) {
feedback: item.feedback,
isAnswer: true,
citation: item.retriever_resources,
reasoningContent: item.metadata?.reasoning,
reasoningFinished: true,
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))),
parentMessageId: `question-${item.id}`,
})

View File

@ -1,8 +1,7 @@
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useChatContext } from '../chat/chat/context'
import ThinkingDetails from './thinking-details'
import { useElapsedTimer } from './use-elapsed-timer'
const hasEndThink = (children: any): boolean => {
if (typeof children === 'string')
@ -40,34 +39,9 @@ const removeEndThink = (children: any): any => {
const useThinkTimer = (children: any) => {
const { isResponding } = useChatContext()
const endThinkDetected = hasEndThink(children)
const [startTime] = useState(() => Date.now())
const [elapsedTime, setElapsedTime] = useState(0)
const [isComplete, setIsComplete] = useState(() => endThinkDetected)
const timerRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (isComplete)
return
timerRef.current = setInterval(() => {
setElapsedTime(Math.floor((Date.now() - startTime) / 100) / 10)
}, 100)
return () => {
if (timerRef.current)
clearInterval(timerRef.current)
}
}, [startTime, isComplete])
useEffect(() => {
// Stop timer when:
// 1. Content has [ENDTHINKFLAG] marker (normal completion)
// 2. isResponding is not true (false = user clicked stop, undefined = historical conversation)
if (endThinkDetected || !isResponding)
setIsComplete(true)
}, [endThinkDetected, isResponding])
return { elapsedTime, isComplete }
// Stop when the marker arrives (normal completion) or the response is no longer
// active (false = user stopped, undefined = historical conversation).
return useElapsedTimer(endThinkDetected || !isResponding)
}
type ThinkBlockProps = React.ComponentProps<'details'> & {
@ -77,41 +51,22 @@ type ThinkBlockProps = React.ComponentProps<'details'> & {
const ThinkBlock = ({ children, ...props }: ThinkBlockProps) => {
const { elapsedTime, isComplete } = useThinkTimer(children)
const displayContent = removeEndThink(children)
const { t } = useTranslation()
const { 'data-think': isThink = false, className, open, ...rest } = props
if (!isThink)
return (<details {...props}>{children}</details>)
return (
<details
<ThinkingDetails
{...rest}
data-think={isThink}
className={cn('group', className)}
open={isComplete ? open : true}
className={className}
open={open}
isComplete={isComplete}
elapsedTime={elapsedTime}
>
<summary className="flex cursor-pointer list-none items-center pl-2 font-bold whitespace-nowrap text-text-secondary select-none">
<div className="flex shrink-0 items-center">
<svg
className="mr-2 size-3 transition-transform duration-500 group-open:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
{isComplete ? `${t('chat.thought', { ns: 'common' })}(${elapsedTime.toFixed(1)}s)` : `${t('chat.thinking', { ns: 'common' })}(${elapsedTime.toFixed(1)}s)`}
</div>
</summary>
<div className="ml-2 border-l border-components-panel-border bg-components-panel-bg-alt p-3 text-text-secondary">
{displayContent}
</div>
</details>
{displayContent}
</ThinkingDetails>
)
}

View File

@ -0,0 +1,49 @@
import type { ComponentProps } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type ThinkingDetailsProps = ComponentProps<'details'> & {
isComplete: boolean
elapsedTime: number
}
/**
* Presentational collapsible "thinking" shell: the chevron summary with the
* "Thinking…/Thought (Xs)" label and the bordered content body. Driver-agnostic
* callers compute `isComplete`/`elapsedTime` and pass the body as children.
*/
const ThinkingDetails = ({ isComplete, elapsedTime, className, open, children, ...rest }: ThinkingDetailsProps) => {
const { t } = useTranslation()
return (
<details
{...rest}
className={cn('group', className)}
open={isComplete ? open : true}
>
<summary className="flex cursor-pointer list-none items-center pl-2 font-bold whitespace-nowrap text-text-secondary select-none">
<div className="flex shrink-0 items-center">
<svg
className="mr-2 size-3 transition-transform duration-500 group-open:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
{isComplete ? `${t('chat.thought', { ns: 'common' })}(${elapsedTime.toFixed(1)}s)` : `${t('chat.thinking', { ns: 'common' })}(${elapsedTime.toFixed(1)}s)`}
</div>
</summary>
<div className="ml-2 border-l border-components-panel-border bg-components-panel-bg-alt p-3 text-text-secondary">
{children}
</div>
</details>
)
}
export default ThinkingDetails

View File

@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from 'react'
/**
* Elapsed-time timer shared by the tagged-markdown `ThinkBlock` and the
* stream-driven `ReasoningPanel`. Counts up every 100ms until `complete`
* latches true, then freezes. Initializing complete=true (e.g. historical
* conversations) never starts the timer.
*/
export const useElapsedTimer = (complete: boolean) => {
const [startTime] = useState(() => Date.now())
const [elapsedTime, setElapsedTime] = useState(0)
// Latch completion so a transient flip back to "not complete" never restarts the timer.
const completedRef = useRef(complete)
if (complete)
completedRef.current = true
const isComplete = completedRef.current
const timerRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (isComplete)
return
timerRef.current = setInterval(() => {
setElapsedTime(Math.floor((Date.now() - startTime) / 100) / 10)
}, 100)
return () => {
if (timerRef.current)
clearInterval(timerRef.current)
}
}, [startTime, isComplete])
return { elapsedTime, isComplete }
}

View File

@ -0,0 +1,51 @@
import { atom } from 'jotai'
export type NextRouteParams = Record<string, string | string[]>
type NextRouteState = {
pathname: string
params: NextRouteParams
}
// Mirrors Next router state. NextRouteStateBridge force-hydrates this atom on
// render so feature atoms can read route state without calling router hooks.
const nextRouteStateAtom = atom<NextRouteState>({
pathname: '',
params: {},
})
function normalizedParamEntries(params: NextRouteParams) {
return Object.keys(params)
.sort()
.map((key) => {
const value = params[key]
return [key, Array.isArray(value) ? [...value] : value] as const
})
}
function normalizeNextRouteParams(params: NextRouteParams): NextRouteParams {
return Object.fromEntries(normalizedParamEntries(params)) as NextRouteParams
}
function routeParamsKey(params: NextRouteParams) {
return JSON.stringify(normalizedParamEntries(params))
}
export const nextPathnameAtom = atom(get => get(nextRouteStateAtom).pathname)
export const nextParamsAtom = atom(get => get(nextRouteStateAtom).params)
export const setNextRouteStateAtom = atom(null, (get, set, routeState: NextRouteState) => {
const nextParams = normalizeNextRouteParams(routeState.params)
const currentRouteState = get(nextRouteStateAtom)
if (
currentRouteState.pathname !== routeState.pathname
|| routeParamsKey(currentRouteState.params) !== routeParamsKey(nextParams)
) {
set(nextRouteStateAtom, {
pathname: routeState.pathname,
params: nextParams,
})
}
})

View File

@ -0,0 +1,25 @@
'use client'
import type { ReactNode } from 'react'
import type { NextRouteParams } from './atoms'
import { useHydrateAtoms } from 'jotai/utils'
import { useParams, usePathname } from '@/next/navigation'
import {
setNextRouteStateAtom,
} from './atoms'
export function NextRouteStateBridge({ children }: {
children: ReactNode
}) {
const pathname = usePathname()
const params = useParams<NextRouteParams>()
useHydrateAtoms([
[setNextRouteStateAtom, {
pathname,
params,
}],
] as const, { dangerouslyForceHydrate: true })
return children
}

View File

@ -39,6 +39,8 @@ function getFormattedChatList(messages: any[]) {
feedback: item.feedback,
isAnswer: true,
citation: item.metadata?.retriever_resources,
reasoningContent: item.metadata?.reasoning,
reasoningFinished: true,
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))),
workflow_run_id: item.workflow_run_id,
parentMessageId: `question-${item.id}`,

View File

@ -295,6 +295,35 @@ describe('useChat handleResume', () => {
})
})
describe('onReasoning', () => {
it('should accumulate reasoning per node onto the resumed answer', async () => {
const { result } = await setupResumeWithTree()
act(() => {
capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: 'resumed ', node_id: 'llm' } })
capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: 'thought', node_id: 'llm', is_final: true } })
})
const answer = result.current.chatList.find(item => item.id === 'msg-resume')
expect(answer!.reasoningContent).toEqual({ llm: 'resumed thought' })
expect(answer!.reasoningFinished).toBe(true)
})
it('should ignore empty reasoning and fall back to "_" when node_id is absent', async () => {
const { result } = await setupResumeWithTree()
act(() => {
capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: '' } })
capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: 'a' } })
capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: 'b' } })
})
const answer = result.current.chatList.find(item => item.id === 'msg-resume')
expect(answer!.reasoningContent).toEqual({ _: 'ab' })
expect(answer!.reasoningFinished).toBeUndefined()
})
})
describe('onCompleted', () => {
it('should set isResponding to false', async () => {
const { result } = await setupResumeWithTree()

View File

@ -216,6 +216,50 @@ describe('useChat handleSend SSE callbacks', () => {
})
})
describe('onReasoning', () => {
const findAnswer = (result: any) =>
result.current.chatList.find((item: any) => item.isAnswer && !item.isOpeningStatement)
it('should accumulate reasoning per node without leaking into content', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'let me ', node_id: 'llm' } })
capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'think', node_id: 'llm' } })
})
const answer = findAnswer(result)
expect(answer!.reasoningContent).toEqual({ llm: 'let me think' })
expect(answer!.reasoningFinished).toBeUndefined()
expect(answer!.content).toBe('')
})
it('should key reasoning by node and fall back to "_" when node_id is absent', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'a', node_id: 'llm-1' } })
capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'b', node_id: 'llm-2' } })
capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'c' } })
})
expect(findAnswer(result)!.reasoningContent).toEqual({ 'llm-1': 'a', 'llm-2': 'b', '_': 'c' })
})
it('should ignore empty reasoning and mark finished when is_final is set', () => {
const { result } = setupAndSend()
act(() => {
capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'done', node_id: 'llm' } })
capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: '', node_id: 'llm', is_final: true } })
})
const answer = findAnswer(result)
expect(answer!.reasoningContent).toEqual({ llm: 'done' })
expect(answer!.reasoningFinished).toBe(true)
})
})
describe('onCompleted', () => {
it('should set isResponding to false', async () => {
const { result } = setupAndSend()

View File

@ -7,6 +7,7 @@ import type {
} from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { IOtherOptions } from '@/service/base'
import type { ReasoningChunkResponse } from '@/types/workflow'
import { toast } from '@langgenius/dify-ui/toast'
import { uniqBy } from 'es-toolkit/compat'
import { produce, setAutoFreeze } from 'immer'
@ -365,6 +366,22 @@ export const useChat = (
parentId: params.parent_message_id,
})
},
onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => {
const { reasoning, node_id, is_final } = reasoningData
const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {})
const key = node_id || '_'
if (reasoning)
reasoningContent[key] = (reasoningContent[key] || '') + reasoning
if (is_final)
responseItem.reasoningFinished = true
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
async onCompleted(hasError?: boolean, errorMessage?: string) {
const { workflowRunningData } = workflowStore.getState()
handleResponding(false)
@ -711,6 +728,18 @@ export const useChat = (
if (taskId)
taskIdRef.current = taskId
},
onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => {
const { message_id, reasoning, node_id, is_final } = reasoningData
updateChatTreeNode(message_id, (responseItem) => {
const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {})
const key = node_id || '_'
if (reasoning)
reasoningContent[key] = (reasoningContent[key] || '') + reasoning
if (is_final)
responseItem.reasoningFinished = true
})
},
async onCompleted(hasError?: boolean) {
const { workflowRunningData } = workflowStore.getState()
handleResponding(false)

View File

@ -43,9 +43,11 @@ function CreateReleaseCloseButton() {
export function CreateReleaseDialogContent() {
return (
<ScopeProvider atoms={[createReleaseFormAtom]}>
<CreateReleaseDialogSurface />
</ScopeProvider>
<DialogContent className="top-[18dvh] w-140 max-w-[calc(100vw-32px)] translate-y-0 overflow-hidden p-0">
<ScopeProvider atoms={[createReleaseFormAtom]} name="CreateReleaseForm">
<CreateReleaseDialogSurface />
</ScopeProvider>
</DialogContent>
)
}
@ -75,7 +77,7 @@ function CreateReleaseDialogSurface() {
}
return (
<DialogContent className="top-[18dvh] w-140 max-w-[calc(100vw-32px)] translate-y-0 overflow-hidden p-0">
<>
<CreateReleaseCloseButton />
<form
noValidate
@ -105,6 +107,6 @@ function CreateReleaseDialogSurface() {
<CreateReleaseActions />
</form>
</DialogContent>
</>
)
}

View File

@ -2,7 +2,7 @@ import type { Getter } from 'jotai'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
type QueryOptions = {
enabled?: boolean
@ -65,6 +65,13 @@ async function loadState() {
return await import('../state')
}
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/overview`,
params: { appInstanceId },
})
}
describe('deployment detail state', () => {
it('should disable detail queries with skipToken until a route app instance exists', async () => {
const state = await loadState()
@ -92,7 +99,7 @@ describe('deployment detail state', () => {
const state = await loadState()
const store = createStore()
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
setDeploymentRoute(store)
store.set(state.deploymentSourceAppIdAtom, 'source-app-1')
expect(store.get(state.deploymentDetailAppInstanceQueryAtom)).toMatchObject({

View File

@ -2,7 +2,7 @@ import type { Getter } from 'jotai'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
type QueryOptions = {
enabled?: boolean
@ -57,6 +57,13 @@ async function loadState() {
return await import('../state')
}
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/overview`,
params: { appInstanceId },
})
}
describe('versions tab state', () => {
it('should gate release history and menu queries until route and menu state are ready', async () => {
const state = await loadState()
@ -79,7 +86,7 @@ describe('versions tab state', () => {
it('should build release history input from the current page', async () => {
const state = await loadState()
const store = createStore()
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
setDeploymentRoute(store)
store.set(state.setReleaseHistoryCurrentPageAtom, -1)
expect(store.get(state.releaseHistoryCurrentPageAtom)).toBe(0)
@ -99,7 +106,7 @@ describe('versions tab state', () => {
it('should scope deploy menu queries to the open release id', async () => {
const state = await loadState()
const store = createStore()
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
setDeploymentRoute(store)
store.set(state.setDeployReleaseMenuOpenAtom, {
releaseId: 'release-1',

View File

@ -1,37 +1,8 @@
'use client'
import type { ReactNode } from 'react'
import { ScopeProvider } from 'jotai-scope'
import { useQueryState } from 'nuqs'
import {
deploymentsListEnvironmentIdAtom,
deploymentsListKeywordsAtom,
envFilterQueryState,
keywordsQueryState,
} from './state'
import { DeploymentsListStateBoundary } from './state'
import { DeploymentsListShell } from './ui/shell'
function DeploymentsListStateBoundary({ children }: {
children: ReactNode
}) {
const [envFilter] = useQueryState('env', envFilterQueryState)
const [keywords] = useQueryState('keywords', keywordsQueryState)
const stateKey = `${envFilter ?? 'all'}:${keywords}`
return (
<ScopeProvider
key={stateKey}
atoms={[
[deploymentsListEnvironmentIdAtom, envFilter],
[deploymentsListKeywordsAtom, keywords],
]}
name="DeploymentsList"
>
{children}
</ScopeProvider>
)
}
export function DeploymentsList() {
return (
<DeploymentsListStateBoundary>

View File

@ -2,10 +2,12 @@
import type { ListAppInstanceSummariesResponse } from '@dify/contracts/enterprise/types.gen'
import type { InfiniteData, QueryKey } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { keepPreviousData } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { parseAsString } from 'nuqs'
import { useHydrateAtoms } from 'jotai/utils'
import { parseAsString, useQueryState } from 'nuqs'
import { consoleQuery } from '@/service/client'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../../shared/domain/pagination'
import { deploymentStatusPollingInterval } from '../../shared/domain/runtime-status'
@ -13,8 +15,24 @@ import { deploymentStatusPollingInterval } from '../../shared/domain/runtime-sta
export const envFilterQueryState = parseAsString.withOptions({ history: 'push' })
export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' })
export const deploymentsListKeywordsAtom = atom('')
export const deploymentsListEnvironmentIdAtom = atom<string | null>(null)
// Mirrors nuqs URL state. DeploymentsListStateBoundary force-hydrates these
// atoms on render so query atoms can read URL filters through Jotai.
const deploymentsListKeywordsAtom = atom('')
const deploymentsListEnvironmentIdAtom = atom<string | null>(null)
export function DeploymentsListStateBoundary({ children }: {
children: ReactNode
}) {
const [envFilter] = useQueryState('env', envFilterQueryState)
const [keywords] = useQueryState('keywords', keywordsQueryState)
useHydrateAtoms([
[deploymentsListEnvironmentIdAtom, envFilter],
[deploymentsListKeywordsAtom, keywords],
] as const, { dangerouslyForceHydrate: true })
return children
}
function listDeploymentStatusPollingInterval(data?: InfiniteData<ListAppInstanceSummariesResponse>) {
const rows = data?.pages?.flatMap(page =>

View File

@ -6,10 +6,7 @@ import type {
import type { Getter } from 'jotai'
import { atom, createStore } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
deploymentRouteAppInstanceIdAtom,
deploymentsRouteActiveAtom,
} from '../../route-state'
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
type QueryOptions = {
enabled?: boolean
@ -120,6 +117,13 @@ function setListAppInstances(appInstances: AppInstance[]) {
})
}
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/overview`,
params: { appInstanceId },
})
}
describe('deployments nav state', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -137,8 +141,7 @@ describe('deployments nav state', () => {
it('should append the current route item when it is missing from the list query', async () => {
const state = await loadState()
const store = createStore()
store.set(deploymentsRouteActiveAtom, true)
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
setDeploymentRoute(store)
setListAppInstances([
appInstance({
id: 'app-instance-2',
@ -172,8 +175,7 @@ describe('deployments nav state', () => {
it('should use the route id as a fallback current item name', async () => {
const state = await loadState()
const store = createStore()
store.set(deploymentsRouteActiveAtom, true)
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
setDeploymentRoute(store)
setListAppInstances([])
expect(store.get(state.deploymentsNavItemsAtom)).toMatchObject([

View File

@ -1,35 +0,0 @@
'use client'
import { useSetAtom } from 'jotai'
import { useHydrateAtoms } from 'jotai/react/utils'
import { useEffect } from 'react'
import { useParams } from '@/next/navigation'
import {
deploymentRouteAppInstanceIdAtom,
deploymentsRouteActiveAtom,
} from './route-state'
function routeAppInstanceId(params?: { appInstanceId?: string | string[] }) {
return typeof params?.appInstanceId === 'string' ? params.appInstanceId : undefined
}
export function DeploymentsRouteStateHydrator() {
const params = useParams<{ appInstanceId?: string | string[] }>()
const appInstanceId = routeAppInstanceId(params)
const setDeploymentsRouteActive = useSetAtom(deploymentsRouteActiveAtom)
const setRouteAppInstanceId = useSetAtom(deploymentRouteAppInstanceIdAtom)
useHydrateAtoms([
[deploymentsRouteActiveAtom, true],
[deploymentRouteAppInstanceIdAtom, appInstanceId],
] as const, { dangerouslyForceHydrate: true })
useEffect(() => {
return () => {
setDeploymentsRouteActive(false)
setRouteAppInstanceId(undefined)
}
}, [setDeploymentsRouteActive, setRouteAppInstanceId])
return null
}

View File

@ -1,6 +1,19 @@
'use client'
import { atom } from 'jotai'
import {
nextParamsAtom,
nextPathnameAtom,
} from '@/app/components/next-route-state/atoms'
export const deploymentsRouteActiveAtom = atom(false)
export const deploymentRouteAppInstanceIdAtom = atom<string | undefined>(undefined)
function isDeploymentsRoute(pathname: string) {
return pathname === '/deployments' || pathname.startsWith('/deployments/')
}
export const deploymentsRouteActiveAtom = atom(get => isDeploymentsRoute(get(nextPathnameAtom)))
export const deploymentRouteAppInstanceIdAtom = atom((get) => {
const appInstanceId = get(nextParamsAtom).appInstanceId
return typeof appInstanceId === 'string' ? appInstanceId : undefined
})

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "حذف {{name}}؟",
"roster.deleteFailed": "فشل حذف الوكيل.",
"roster.deleteSuccess": "تم حذف الوكيل.",
"roster.duplicateDialog.description": "أنشئ نسخة من {{name}} وخصّص هويته في Roster.",
"roster.duplicateDialog.title": "نسخ الوكيل",
"roster.duplicateForm.changeIcon": "تغيير أيقونة نسخة {{name}}",
"roster.duplicateSuccess": "تم نسخ الوكيل.",
"roster.editAgent": "تعديل {{name}}",
"roster.editDialog.description": "حدّث اسم Roster والوصف والدور لهذا الوكيل.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "{{name}} löschen?",
"roster.deleteFailed": "Agent konnte nicht gelöscht werden.",
"roster.deleteSuccess": "Agent gelöscht.",
"roster.duplicateDialog.description": "Erstellen Sie eine Kopie von {{name}} und passen Sie ihre Roster-Identität an.",
"roster.duplicateDialog.title": "Agent duplizieren",
"roster.duplicateForm.changeIcon": "Symbol des Duplikats für {{name}} ändern",
"roster.duplicateSuccess": "Agent dupliziert.",
"roster.editAgent": "{{name}} bearbeiten",
"roster.editDialog.description": "Aktualisieren Sie Roster-Name, Beschreibung und Rolle dieses Agenten.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "¿Eliminar {{name}}?",
"roster.deleteFailed": "Error al eliminar el agente.",
"roster.deleteSuccess": "Agente eliminado.",
"roster.duplicateDialog.description": "Crea una copia de {{name}} y personaliza su identidad en el roster.",
"roster.duplicateDialog.title": "Duplicar agente",
"roster.duplicateForm.changeIcon": "Cambiar icono del duplicado de {{name}}",
"roster.duplicateSuccess": "Agente duplicado.",
"roster.editAgent": "Editar {{name}}",
"roster.editDialog.description": "Actualiza el nombre, la descripción y el rol del roster para este agente.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "حذف {{name}}؟",
"roster.deleteFailed": "حذف عامل ناموفق بود.",
"roster.deleteSuccess": "عامل حذف شد.",
"roster.duplicateDialog.description": "یک کپی از {{name}} ایجاد کنید و هویت آن در Roster را سفارشی کنید.",
"roster.duplicateDialog.title": "تکثیر عامل",
"roster.duplicateForm.changeIcon": "تغییر آیکون کپی برای {{name}}",
"roster.duplicateSuccess": "عامل تکثیر شد.",
"roster.editAgent": "ویرایش {{name}}",
"roster.editDialog.description": "نام، توضیحات و نقش Roster این عامل را به‌روزرسانی کنید.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "Supprimer {{name}} ?",
"roster.deleteFailed": "Échec de la suppression de lagent.",
"roster.deleteSuccess": "Agent supprimé.",
"roster.duplicateDialog.description": "Créez une copie de {{name}} et personnalisez son identité dans le roster.",
"roster.duplicateDialog.title": "Dupliquer lagent",
"roster.duplicateForm.changeIcon": "Changer licône du duplicata pour {{name}}",
"roster.duplicateSuccess": "Agent dupliqué.",
"roster.editAgent": "Modifier {{name}}",
"roster.editDialog.description": "Mettez à jour le nom, la description et le rôle de cet agent dans le roster.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "{{name}} हटाएँ?",
"roster.deleteFailed": "एजेंट हटाने में विफल।",
"roster.deleteSuccess": "एजेंट हटा दिया गया।",
"roster.duplicateDialog.description": "{{name}} की एक प्रति बनाएँ और उसकी Roster पहचान को अनुकूलित करें।",
"roster.duplicateDialog.title": "एजेंट डुप्लिकेट करें",
"roster.duplicateForm.changeIcon": "{{name}} के लिए डुप्लिकेट आइकन बदलें",
"roster.duplicateSuccess": "एजेंट डुप्लिकेट किया गया।",
"roster.editAgent": "{{name}} संपादित करें",
"roster.editDialog.description": "इस एजेंट का Roster नाम, विवरण और भूमिका अपडेट करें।",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "Hapus {{name}}?",
"roster.deleteFailed": "Gagal menghapus agen.",
"roster.deleteSuccess": "Agen dihapus.",
"roster.duplicateDialog.description": "Buat salinan {{name}} dan sesuaikan identitas Roster-nya.",
"roster.duplicateDialog.title": "Duplikat agen",
"roster.duplicateForm.changeIcon": "Ubah ikon duplikat untuk {{name}}",
"roster.duplicateSuccess": "Agen diduplikasi.",
"roster.editAgent": "Edit {{name}}",
"roster.editDialog.description": "Perbarui nama, deskripsi, dan peran Roster untuk agen ini.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "Eliminare {{name}}?",
"roster.deleteFailed": "Impossibile eliminare lagente.",
"roster.deleteSuccess": "Agente eliminato.",
"roster.duplicateDialog.description": "Crea una copia di {{name}} e personalizza la sua identità nel roster.",
"roster.duplicateDialog.title": "Duplica agente",
"roster.duplicateForm.changeIcon": "Cambia icona del duplicato per {{name}}",
"roster.duplicateSuccess": "Agente duplicato.",
"roster.editAgent": "Modifica {{name}}",
"roster.editDialog.description": "Aggiorna il nome, la descrizione e il ruolo del roster per questo agente.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "{{name}} を削除しますか?",
"roster.deleteFailed": "エージェントの削除に失敗しました。",
"roster.deleteSuccess": "エージェントを削除しました。",
"roster.duplicateDialog.description": "{{name}} のコピーを作成し、その Roster での識別情報をカスタマイズします。",
"roster.duplicateDialog.title": "エージェントを複製",
"roster.duplicateForm.changeIcon": "{{name}} の複製アイコンを変更",
"roster.duplicateSuccess": "エージェントを複製しました。",
"roster.editAgent": "{{name}} を編集",
"roster.editDialog.description": "このエージェントの Roster 名、説明、ロールを更新します。",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "{{name}}을(를) 삭제하시겠습니까?",
"roster.deleteFailed": "에이전트 삭제에 실패했습니다.",
"roster.deleteSuccess": "에이전트가 삭제되었습니다.",
"roster.duplicateDialog.description": "{{name}} 의 사본을 만들고 Roster 신원을 사용자 지정합니다.",
"roster.duplicateDialog.title": "에이전트 복제",
"roster.duplicateForm.changeIcon": "{{name}} 의 복제본 아이콘 변경",
"roster.duplicateSuccess": "에이전트가 복제되었습니다.",
"roster.editAgent": "{{name}} 편집",
"roster.editDialog.description": "이 에이전트의 Roster 이름, 설명 및 역할을 업데이트합니다.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "{{name}} verwijderen?",
"roster.deleteFailed": "Verwijderen van agent mislukt.",
"roster.deleteSuccess": "Agent verwijderd.",
"roster.duplicateDialog.description": "Maak een kopie van {{name}} en pas de roster-identiteit aan.",
"roster.duplicateDialog.title": "Agent dupliceren",
"roster.duplicateForm.changeIcon": "Duplicaatpictogram voor {{name}} wijzigen",
"roster.duplicateSuccess": "Agent gedupliceerd.",
"roster.editAgent": "{{name}} bewerken",
"roster.editDialog.description": "Werk de rosternaam, beschrijving en rol van deze agent bij.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "Usunąć {{name}}?",
"roster.deleteFailed": "Nie udało się usunąć agenta.",
"roster.deleteSuccess": "Agent usunięty.",
"roster.duplicateDialog.description": "Utwórz kopię {{name}} i dostosuj jego tożsamość w Roster.",
"roster.duplicateDialog.title": "Duplikuj agenta",
"roster.duplicateForm.changeIcon": "Zmień ikonę duplikatu dla {{name}}",
"roster.duplicateSuccess": "Agent zduplikowany.",
"roster.editAgent": "Edytuj {{name}}",
"roster.editDialog.description": "Zaktualizuj nazwę w Roster, opis i rolę tego agenta.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "Excluir {{name}}?",
"roster.deleteFailed": "Falha ao excluir o agente.",
"roster.deleteSuccess": "Agente excluído.",
"roster.duplicateDialog.description": "Crie uma cópia de {{name}} e personalize sua identidade no roster.",
"roster.duplicateDialog.title": "Duplicar agente",
"roster.duplicateForm.changeIcon": "Alterar ícone da duplicata de {{name}}",
"roster.duplicateSuccess": "Agente duplicado.",
"roster.editAgent": "Editar {{name}}",
"roster.editDialog.description": "Atualize o nome, a descrição e a função deste agente no roster.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "Ștergeți {{name}}?",
"roster.deleteFailed": "Ștergerea agentului a eșuat.",
"roster.deleteSuccess": "Agent șters.",
"roster.duplicateDialog.description": "Creați o copie a {{name}} și personalizați identitatea sa în roster.",
"roster.duplicateDialog.title": "Duplică agentul",
"roster.duplicateForm.changeIcon": "Schimbă pictograma duplicatului pentru {{name}}",
"roster.duplicateSuccess": "Agent duplicat.",
"roster.editAgent": "Editați {{name}}",
"roster.editDialog.description": "Actualizați numele, descrierea și rolul din roster pentru acest agent.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "Удалить {{name}}?",
"roster.deleteFailed": "Не удалось удалить агента.",
"roster.deleteSuccess": "Агент удалён.",
"roster.duplicateDialog.description": "Создайте копию {{name}} и настройте его идентичность в Roster.",
"roster.duplicateDialog.title": "Дублировать агента",
"roster.duplicateForm.changeIcon": "Сменить иконку копии для {{name}}",
"roster.duplicateSuccess": "Агент продублирован.",
"roster.editAgent": "Редактировать {{name}}",
"roster.editDialog.description": "Обновите имя в Roster, описание и роль этого агента.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "Izbrišem {{name}}?",
"roster.deleteFailed": "Agenta ni bilo mogoče izbrisati.",
"roster.deleteSuccess": "Agent izbrisan.",
"roster.duplicateDialog.description": "Ustvarite kopijo agenta {{name}} in prilagodite njegovo identiteto v Roster.",
"roster.duplicateDialog.title": "Podvoji agenta",
"roster.duplicateForm.changeIcon": "Spremeni ikono kopije za {{name}}",
"roster.duplicateSuccess": "Agent podvojen.",
"roster.editAgent": "Uredi {{name}}",
"roster.editDialog.description": "Posodobite ime v Roster, opis in vlogo za tega agenta.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "ลบ {{name}} หรือไม่",
"roster.deleteFailed": "ลบตัวแทนไม่สำเร็จ",
"roster.deleteSuccess": "ลบตัวแทนแล้ว",
"roster.duplicateDialog.description": "สร้างสำเนาของ {{name}} และปรับแต่งข้อมูลระบุตัวตนใน Roster",
"roster.duplicateDialog.title": "ทำสำเนาตัวแทน",
"roster.duplicateForm.changeIcon": "เปลี่ยนไอคอนสำเนาสำหรับ {{name}}",
"roster.duplicateSuccess": "ทำสำเนาตัวแทนแล้ว",
"roster.editAgent": "แก้ไข {{name}}",
"roster.editDialog.description": "อัปเดตชื่อ คำอธิบาย และบทบาทใน Roster สำหรับตัวแทนนี้",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "{{name}} silinsin mi?",
"roster.deleteFailed": "Ajan silinemedi.",
"roster.deleteSuccess": "Ajan silindi.",
"roster.duplicateDialog.description": "{{name}} ajanının bir kopyasını oluşturun ve Roster kimliğini özelleştirin.",
"roster.duplicateDialog.title": "Ajanı çoğalt",
"roster.duplicateForm.changeIcon": "{{name}} için kopya simgesini değiştir",
"roster.duplicateSuccess": "Ajan çoğaltıldı.",
"roster.editAgent": "{{name}} düzenle",
"roster.editDialog.description": "Bu ajan için Roster adı, açıklaması ve rolünü güncelleyin.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "Видалити {{name}}?",
"roster.deleteFailed": "Не вдалося видалити агента.",
"roster.deleteSuccess": "Агента видалено.",
"roster.duplicateDialog.description": "Створіть копію {{name}} та налаштуйте його ідентичність у Roster.",
"roster.duplicateDialog.title": "Дублювати агента",
"roster.duplicateForm.changeIcon": "Змінити іконку копії для {{name}}",
"roster.duplicateSuccess": "Агента продубльовано.",
"roster.editAgent": "Редагувати {{name}}",
"roster.editDialog.description": "Оновіть ім'я в Roster, опис і роль для цього агента.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "Xóa {{name}}?",
"roster.deleteFailed": "Xóa tác nhân thất bại.",
"roster.deleteSuccess": "Đã xóa tác nhân.",
"roster.duplicateDialog.description": "Tạo một bản sao của {{name}} và tùy chỉnh danh tính của nó trong Roster.",
"roster.duplicateDialog.title": "Nhân bản tác nhân",
"roster.duplicateForm.changeIcon": "Đổi biểu tượng bản sao cho {{name}}",
"roster.duplicateSuccess": "Đã nhân bản tác nhân.",
"roster.editAgent": "Chỉnh sửa {{name}}",
"roster.editDialog.description": "Cập nhật tên, mô tả và vai trò Roster cho tác nhân này.",

View File

@ -353,6 +353,9 @@
"roster.deleteDialog.title": "刪除 {{name}}",
"roster.deleteFailed": "智能體刪除失敗。",
"roster.deleteSuccess": "智能體已刪除。",
"roster.duplicateDialog.description": "建立 {{name}} 的副本,並自訂它在 Roster 中的身份資訊。",
"roster.duplicateDialog.title": "複製智能體",
"roster.duplicateForm.changeIcon": "更換 {{name}} 副本的圖示",
"roster.duplicateSuccess": "智能體已複製。",
"roster.editAgent": "編輯 {{name}}",
"roster.editDialog.description": "更新此智能體在 Roster 中的名稱、描述和角色。",

View File

@ -217,6 +217,57 @@ describe('handleStream', () => {
expect(onCompleted).toHaveBeenCalled()
})
it('should dispatch reasoning_chunk events to onReasoning', async () => {
// Arrange
const onData = vi.fn()
const onCompleted = vi.fn()
const onReasoning = vi.fn()
const reasoningEvent = {
event: 'reasoning_chunk',
task_id: 'task-1',
data: { message_id: 'm-1', reasoning: 'let me think', node_id: 'llm', is_final: false },
}
const mockReader = {
read: vi.fn()
.mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode(`data: ${JSON.stringify(reasoningEvent)}\n`),
})
.mockResolvedValueOnce({
done: true,
value: undefined,
}),
}
const mockResponse = {
ok: true,
body: {
getReader: () => mockReader,
},
} as unknown as Response
// onReasoning is the last positional handler; fill the unused intervening slots.
const interveningNoops = Array.from({ length: 29 }, () => undefined)
// Act
;(handleStream as (...args: unknown[]) => void)(
mockResponse,
onData,
onCompleted,
...interveningNoops,
onReasoning,
)
// Wait for the stream to be processed
await new Promise(resolve => setTimeout(resolve, 50))
// Assert - the full event object is forwarded to onReasoning, answer stays untouched
expect(onReasoning).toHaveBeenCalledWith(reasoningEvent)
expect(onData).not.toHaveBeenCalled()
})
it('should throw error when response is not ok', () => {
// Arrange
const onData = vi.fn()

View File

@ -21,6 +21,7 @@ import type {
NodeStartedResponse,
ParallelBranchFinishedResponse,
ParallelBranchStartedResponse,
ReasoningChunkResponse,
TextChunkResponse,
TextReplaceResponse,
WorkflowFinishedResponse,
@ -66,6 +67,7 @@ type IOnIterationFinished = (workflowFinished: IterationFinishedResponse) => voi
type IOnParallelBranchStarted = (parallelBranchStarted: ParallelBranchStartedResponse) => void
type IOnParallelBranchFinished = (parallelBranchFinished: ParallelBranchFinishedResponse) => void
type IOnTextChunk = (textChunk: TextChunkResponse) => void
type IOnReasoning = (reasoningChunk: ReasoningChunkResponse) => void
type IOnTTSChunk = (messageId: string, audioStr: string, audioType?: string) => void
type IOnTTSEnd = (messageId: string, audioStr: string, audioType?: string) => void
type IOnTextReplace = (textReplace: TextReplaceResponse) => void
@ -95,6 +97,7 @@ export type IOtherOptions = {
request?: Request
onData?: IOnData // for stream
onReasoning?: IOnReasoning
onThought?: IOnThought
onFile?: IOnFile
onMessageEnd?: IOnMessageEnd
@ -223,6 +226,7 @@ export const handleStream = (
onDataSourceNodeProcessing?: IOnDataSourceNodeProcessing,
onDataSourceNodeCompleted?: IOnDataSourceNodeCompleted,
onDataSourceNodeError?: IOnDataSourceNodeError,
onReasoning?: IOnReasoning,
) => {
if (!response.ok)
throw new Error('Network response was not ok')
@ -340,6 +344,9 @@ export const handleStream = (
else if (bufferObj.event === 'text_chunk') {
onTextChunk?.(bufferObj as TextChunkResponse)
}
else if (bufferObj.event === 'reasoning_chunk') {
onReasoning?.(bufferObj as ReasoningChunkResponse)
}
else if (bufferObj.event === 'text_replace') {
onTextReplace?.(bufferObj as TextReplaceResponse)
}
@ -461,6 +468,7 @@ export const ssePost = async (
const {
isPublicAPI = false,
onData,
onReasoning,
onCompleted,
onThought,
onFile,
@ -599,6 +607,7 @@ export const ssePost = async (
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
onReasoning,
)
})
.catch((e) => {
@ -616,6 +625,7 @@ export const sseGet = async (
const {
isPublicAPI = false,
onData,
onReasoning,
onCompleted,
onThought,
onFile,
@ -747,6 +757,7 @@ export const sseGet = async (
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
onReasoning,
)
})
.catch((e) => {

View File

@ -309,6 +309,17 @@ export type TextChunkResponse = {
}
}
export type ReasoningChunkResponse = {
task_id: string
event: string
data: {
message_id: string
reasoning: string
node_id?: string
is_final?: boolean
}
}
export type TextReplaceResponse = {
task_id: string
workflow_run_id: string