mirror of
https://github.com/langgenius/dify.git
synced 2026-06-26 23:01:11 +08:00
Merge remote-tracking branch 'origin/main' into feat/agent-v2
This commit is contained in:
commit
673d84073b
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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} \
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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."
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"] == {}
|
||||
|
||||
@ -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"
|
||||
|
||||
36
api/tests/unit_tests/fields/test_message_fields.py
Normal file
36
api/tests/unit_tests/fields/test_message_fields.py
Normal 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
|
||||
@ -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"""
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
8
api/uv.lock
generated
@ -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]]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
31
web/app/components/base/chat/chat/answer/reasoning-panel.tsx
Normal file
31
web/app/components/base/chat/chat/answer/reasoning-panel.tsx
Normal 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
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}`,
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
49
web/app/components/base/markdown-blocks/thinking-details.tsx
Normal file
49
web/app/components/base/markdown-blocks/thinking-details.tsx
Normal 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
|
||||
34
web/app/components/base/markdown-blocks/use-elapsed-timer.ts
Normal file
34
web/app/components/base/markdown-blocks/use-elapsed-timer.ts
Normal 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 }
|
||||
}
|
||||
51
web/app/components/next-route-state/atoms.ts
Normal file
51
web/app/components/next-route-state/atoms.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
})
|
||||
25
web/app/components/next-route-state/index.tsx
Normal file
25
web/app/components/next-route-state/index.tsx
Normal 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
|
||||
}
|
||||
@ -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}`,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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 والوصف والدور لهذا الوكيل.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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 این عامل را بهروزرسانی کنید.",
|
||||
|
||||
@ -353,6 +353,9 @@
|
||||
"roster.deleteDialog.title": "Supprimer {{name}} ?",
|
||||
"roster.deleteFailed": "Échec de la suppression de l’agent.",
|
||||
"roster.deleteSuccess": "Agent supprimé.",
|
||||
"roster.duplicateDialog.description": "Créez une copie de {{name}} et personnalisez son identité dans le roster.",
|
||||
"roster.duplicateDialog.title": "Dupliquer l’agent",
|
||||
"roster.duplicateForm.changeIcon": "Changer l’icô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.",
|
||||
|
||||
@ -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 नाम, विवरण और भूमिका अपडेट करें।",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -353,6 +353,9 @@
|
||||
"roster.deleteDialog.title": "Eliminare {{name}}?",
|
||||
"roster.deleteFailed": "Impossibile eliminare l’agente.",
|
||||
"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.",
|
||||
|
||||
@ -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 名、説明、ロールを更新します。",
|
||||
|
||||
@ -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 이름, 설명 및 역할을 업데이트합니다.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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, описание и роль этого агента.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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 สำหรับตัวแทนนี้",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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, опис і роль для цього агента.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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 中的名稱、描述和角色。",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user