diff --git a/plugins/Runner/dify-agent/README.md b/plugins/Runner/dify-agent/README.md index fad5ed7b5..486d906f2 100644 --- a/plugins/Runner/dify-agent/README.md +++ b/plugins/Runner/dify-agent/README.md @@ -54,10 +54,11 @@ events, which are used only when no explicit answer was produced. ## Workflow Human Input -When Dify emits `workflow_paused` with a `human_input_required` reason, the -runner asks LangBot to deliver a structured interaction. A validated -`interaction.submitted` event resumes Dify through -`/form/human_input/{form_token}` and `/workflow/{workflow_run_id}/events`. +When Dify emits `workflow_paused` with a `human_input_required` reason, or a +direct `human_input_required` event, the runner asks LangBot to deliver a +structured interaction. A validated `interaction.submitted` event resumes Dify +through `/form/human_input/{form_token}`. The submission response may itself +stream events; otherwise the runner reads `/workflow/{workflow_run_id}/events`. Workflows that pause again produce a new interaction and can be resumed again. Forms are emitted as atomic steps: the runner collects one field at a time and then emits the action buttons. This matches LangBot delivery adapters that diff --git a/plugins/Runner/dify-agent/components/runner/default.py b/plugins/Runner/dify-agent/components/runner/default.py index ddb946be1..1fd8276e9 100644 --- a/plugins/Runner/dify-agent/components/runner/default.py +++ b/plugins/Runner/dify-agent/components/runner/default.py @@ -247,17 +247,68 @@ def _usage_from_payload(payload: typing.Any) -> dict[str, typing.Any] | None: return normalized or None +def _mapping_value(mapping: typing.Any, *keys: str) -> typing.Any: + """Return a protocol value using explicit aliases with case-insensitive matching.""" + if not isinstance(mapping, dict): + return None + for key in keys: + if key in mapping: + return mapping[key] + lowered = {str(key).lower(): value for key, value in mapping.items()} + for key in keys: + if key.lower() in lowered: + return lowered[key.lower()] + return None + + +def _protocol_name(value: typing.Any) -> str: + if not isinstance(value, str): + return "" + return re.sub(r"[-\s]+", "_", value.strip().lower()) + + +def _workflow_run_id(event: dict[str, typing.Any], fallback: str = "") -> str: + data = event.get("data") if isinstance(event.get("data"), dict) else {} + value = _mapping_value(data, "workflow_run_id", "workflowRunId") + if value in (None, ""): + value = _mapping_value(event, "workflow_run_id", "workflowRunId") + return str(value or fallback).strip() + + +def _human_input_reason(event: dict[str, typing.Any]) -> dict[str, typing.Any] | None: + data = event.get("data") if isinstance(event.get("data"), dict) else {} + if _protocol_name(event.get("event")) == "human_input_required": + return data + + raw_reasons = _mapping_value(data, "reasons", "pause_reasons", "pauseReasons") + if isinstance(raw_reasons, dict): + reasons = [raw_reasons] + elif isinstance(raw_reasons, list): + reasons = raw_reasons + else: + raw_reason = _mapping_value(data, "reason", "pause_reason", "pauseReason") + reasons = [raw_reason] if isinstance(raw_reason, dict) else [] + + for reason in reasons: + if not isinstance(reason, dict): + continue + reason_type = _mapping_value(reason, "TYPE", "type", "reason_type", "reasonType") + if _protocol_name(reason_type) == "human_input_required": + return reason + return None + + def _form_field_name(field: dict[str, typing.Any]) -> str: return str( - field.get("output_variable_name") or field.get("variable") or field.get("name") or field.get("id") or "" + _mapping_value(field, "output_variable_name", "outputVariableName", "variable", "name", "id") or "" ).strip() def _form_field_options(field: dict[str, typing.Any]) -> list[str]: - source = field.get("option_source") - raw_options = source.get("value") if isinstance(source, dict) else None + source = _mapping_value(field, "option_source", "optionSource") + raw_options = _mapping_value(source, "value") if isinstance(source, dict) else None if raw_options is None: - raw_options = field.get("options") + raw_options = _mapping_value(field, "options") if isinstance(raw_options, str): raw_options = [line.strip() for line in raw_options.splitlines() if line.strip()] if not isinstance(raw_options, list): @@ -266,7 +317,7 @@ def _form_field_options(field: dict[str, typing.Any]) -> list[str]: options: list[str] = [] for option in raw_options: if isinstance(option, dict): - value = option.get("value") or option.get("label") or option.get("name") + value = _mapping_value(option, "value", "label", "name") else: value = option if value is not None and str(value).strip(): @@ -275,9 +326,9 @@ def _form_field_options(field: dict[str, typing.Any]) -> list[str]: def _form_field_default(field: dict[str, typing.Any]) -> typing.Any: - default = field.get("default") - if isinstance(default, dict) and default.get("type") == "constant": - return default.get("value") + default = _mapping_value(field, "default", "default_value", "defaultValue") + if isinstance(default, dict) and _protocol_name(_mapping_value(default, "type")) == "constant": + return _mapping_value(default, "value") return default @@ -725,7 +776,7 @@ class DefaultRunner(Runner): workflow_run_id: str, user: str, ) -> tuple[InteractionRequest, dict[str, typing.Any]]: - form_token = str(reason.get("form_token") or "").strip() + form_token = str(_mapping_value(reason, "form_token", "formToken") or "").strip() if not form_token or not workflow_run_id: raise DifyAPIError( "Dify human-input event is missing continuation identifiers", @@ -735,9 +786,10 @@ class DefaultRunner(Runner): interaction_id = f"dify-{uuid.uuid4().hex}" fields: list[InteractionField] = [] field_map: dict[str, str] = {} - resolved_defaults = reason.get("resolved_default_values") + resolved_defaults = _mapping_value(reason, "resolved_default_values", "resolvedDefaultValues") default_inputs = dict(resolved_defaults) if isinstance(resolved_defaults, dict) else {} - raw_fields = reason.get("inputs") if isinstance(reason.get("inputs"), list) else [] + reason_inputs = _mapping_value(reason, "inputs", "fields") + raw_fields = reason_inputs if isinstance(reason_inputs, list) else [] for index, raw_field in enumerate(raw_fields): if not isinstance(raw_field, dict): continue @@ -746,7 +798,7 @@ class DefaultRunner(Runner): continue field_id = f"field_{index + 1}" field_map[field_id] = provider_name - provider_type = str(raw_field.get("type") or "text").lower() + provider_type = _protocol_name(_mapping_value(raw_field, "type") or "text") field_type = { "paragraph": "textarea", "textarea": "textarea", @@ -755,7 +807,7 @@ class DefaultRunner(Runner): "checkbox": "boolean", "boolean": "boolean", "file": "file", - "file-list": "file", + "file_list": "file", }.get(provider_type, "text") option_values = _form_field_options(raw_field) if field_type == "select" else [] if field_type == "select" and not option_values: @@ -766,22 +818,23 @@ class DefaultRunner(Runner): fields.append( InteractionField( id=field_id, - label=str(raw_field.get("label") or raw_field.get("title") or provider_name), + label=str(_mapping_value(raw_field, "label", "title") or provider_name), type=field_type, - required=bool(raw_field.get("required", True)), + required=_to_bool(_mapping_value(raw_field, "required", "is_required", "isRequired"), True), options=[InteractionOption(value=value, label=value) for value in option_values], - placeholder=raw_field.get("placeholder"), + placeholder=_mapping_value(raw_field, "placeholder"), default=default, ) ) actions: list[InteractionAction] = [] action_map: dict[str, str] = {} - raw_actions = reason.get("actions") if isinstance(reason.get("actions"), list) else [] + reason_actions = _mapping_value(reason, "actions", "user_actions", "userActions") + raw_actions = reason_actions if isinstance(reason_actions, list) else [] for index, raw_action in enumerate(raw_actions): if not isinstance(raw_action, dict): continue - provider_action_id = str(raw_action.get("id") or "").strip() + provider_action_id = str(_mapping_value(raw_action, "id", "action_id", "actionId") or "").strip() if not provider_action_id: continue action_id = f"action_{index + 1}" @@ -789,15 +842,15 @@ class DefaultRunner(Runner): actions.append( InteractionAction( id=action_id, - label=str(raw_action.get("title") or raw_action.get("label") or provider_action_id), + label=str(_mapping_value(raw_action, "title", "label", "text") or provider_action_id), ) ) if not actions: action_map["continue"] = "" actions.append(InteractionAction(id="continue", label="Continue", style="primary")) - node_title = str(reason.get("node_title") or "Human input required") - form_content = str(reason.get("form_content") or "") + node_title = str(_mapping_value(reason, "node_title", "nodeTitle", "title") or "Human input required") + form_content = str(_mapping_value(reason, "form_content", "formContent", "content") or "") provider_names = [field_map[field.id] for field in fields if field.id in field_map] provider_descriptions, action_description = _form_step_descriptions(form_content, provider_names) field_descriptions = { @@ -846,13 +899,8 @@ class DefaultRunner(Runner): event: dict[str, typing.Any], user: str, ) -> RunnerResult | None: - data = event.get("data") if isinstance(event.get("data"), dict) else {} - workflow_run_id = str(data.get("workflow_run_id") or "").strip() - reasons = data.get("reasons") if isinstance(data.get("reasons"), list) else [] - reason = next( - (item for item in reasons if isinstance(item, dict) and item.get("TYPE") == "human_input_required"), - None, - ) + workflow_run_id = _workflow_run_id(event) + reason = _human_input_reason(event) if reason is None: return None request, continuation = self._build_interaction_request(reason, workflow_run_id, user) @@ -985,11 +1033,11 @@ class DefaultRunner(Runner): "node_finished", "workflow_finished", "workflow_paused", + "human_input_required", }: mode = "workflow" if event_type == "workflow_started": - data = event.get("data") if isinstance(event.get("data"), dict) else {} - workflow_run_id = str(data.get("workflow_run_id") or workflow_run_id) + workflow_run_id = _workflow_run_id(event, workflow_run_id) if event_type == "error": raise DifyAPIError( @@ -1011,9 +1059,10 @@ class DefaultRunner(Runner): has_response = True yield RunnerResult.message_delta(ctx.run_id, MessageChunk(role="assistant", content=content)) - elif mode == "workflow" and event_type == "workflow_paused": + elif mode == "workflow" and event_type in {"workflow_paused", "human_input_required"}: data = event.get("data") if isinstance(event.get("data"), dict) else {} - if workflow_run_id and not data.get("workflow_run_id"): + workflow_run_id = _workflow_run_id(event, workflow_run_id) + if workflow_run_id and not _workflow_run_id(event): event = dict(event) event["data"] = {**data, "workflow_run_id": workflow_run_id} if pending_content: @@ -1224,8 +1273,7 @@ class DefaultRunner(Runner): usage = _usage_from_payload(event) or usage if event_type == "workflow_started": - data = event.get("data") if isinstance(event.get("data"), dict) else {} - workflow_run_id = str(data.get("workflow_run_id") or workflow_run_id) + workflow_run_id = _workflow_run_id(event, workflow_run_id) if event_type == "error": raise DifyAPIError( @@ -1267,9 +1315,10 @@ class DefaultRunner(Runner): MessageChunk(role="assistant", content=content, is_final=True), ) - elif event_type == "workflow_paused": + elif event_type in {"workflow_paused", "human_input_required"}: data = event.get("data") if isinstance(event.get("data"), dict) else {} - if workflow_run_id and not data.get("workflow_run_id"): + workflow_run_id = _workflow_run_id(event, workflow_run_id) + if workflow_run_id and not _workflow_run_id(event): event = dict(event) event["data"] = {**data, "workflow_run_id": workflow_run_id} if pending_content: @@ -1403,7 +1452,10 @@ class DefaultRunner(Runner): await self._store_interaction_continuation(ctx, continuation) pending_content = "" + streamed_content = "" + text_event_count = 0 has_response = False + final_response = False terminal = False usage: dict[str, typing.Any] | None = None async for event in client.workflow_submit( @@ -1426,30 +1478,49 @@ class DefaultRunner(Runner): if event_type != "text_chunk" else str(event.get("data", {}).get("text") or "") ) - if len(pending_content) + len(chunk) > 1024 * 1024: + if event_type != "text_chunk" and pending_content and chunk.startswith(pending_content): + next_content = chunk + else: + next_content = pending_content + chunk + if len(next_content) > 1024 * 1024: raise DifyAPIError("Dify response exceeds the runtime limit", code="dify.response_limit") - pending_content += chunk + pending_content = next_content + text_event_count += 1 + if text_event_count % 8 == 0: + content, _ = process_thinking_content(pending_content, remove_think) + if content and content != streamed_content: + streamed_content = content + has_response = True + yield RunnerResult.message_delta( + ctx.run_id, + MessageChunk(role="assistant", content=content, is_final=False), + ) elif event_type == "node_finished": data = event.get("data", {}) if data.get("node_type") == "answer": answer = extract_text_from_output(data.get("outputs", {}).get("answer")) if answer: content, _ = process_thinking_content(answer, remove_think) - has_response = True - yield RunnerResult.message_delta( - ctx.run_id, - MessageChunk(role="assistant", content=content, is_final=True), - ) - elif event_type == "workflow_paused": + final_response = bool(content) + if content and content != streamed_content: + has_response = True + streamed_content = content + yield RunnerResult.message_delta( + ctx.run_id, + MessageChunk(role="assistant", content=content, is_final=True), + ) + elif event_type in {"workflow_paused", "human_input_required"}: if pending_content: content, _ = process_thinking_content(pending_content, remove_think) - if content: + if content and content != streamed_content: + has_response = True + streamed_content = content yield RunnerResult.message_delta( ctx.run_id, MessageChunk(role="assistant", content=content, is_final=True), ) data = event.get("data") if isinstance(event.get("data"), dict) else {} - if not data.get("workflow_run_id"): + if not _workflow_run_id(event): event = dict(event) event["data"] = { **data, @@ -1475,26 +1546,29 @@ class DefaultRunner(Runner): summary = extract_text_from_output(data.get("outputs", {}).get("summary", "")) if summary: content, _ = process_thinking_content(summary, remove_think) - has_response = True - yield RunnerResult.message_delta( - ctx.run_id, - MessageChunk(role="assistant", content=content, is_final=True), - ) + final_response = bool(content) + if content and content != streamed_content: + has_response = True + streamed_content = content + yield RunnerResult.message_delta( + ctx.run_id, + MessageChunk(role="assistant", content=content, is_final=True), + ) terminal = True - if pending_content and not has_response: - content, _ = process_thinking_content(pending_content, remove_think) - if content: - has_response = True - yield RunnerResult.message_delta( - ctx.run_id, - MessageChunk(role="assistant", content=content, is_final=True), - ) if not terminal: raise DifyAPIError( "Dify workflow resume ended before a terminal event", code="dify.response_invalid", ) + if pending_content and not final_response: + content, _ = process_thinking_content(pending_content, remove_think) + if content and content != streamed_content: + has_response = True + yield RunnerResult.message_delta( + ctx.run_id, + MessageChunk(role="assistant", content=content, is_final=True), + ) if not has_response: raise DifyAPIError( "Dify workflow returned no response after human input", diff --git a/plugins/Runner/dify-agent/pkg/dify_client.py b/plugins/Runner/dify-agent/pkg/dify_client.py index cff68b758..905db3f05 100644 --- a/plugins/Runner/dify-agent/pkg/dify_client.py +++ b/plugins/Runner/dify-agent/pkg/dify_client.py @@ -208,18 +208,25 @@ class AsyncDifyClient: timeout=self.timeout, trust_env=False, ) as client: - response = await limited_post( - client, + async with client.stream( + "POST", f"/form/human_input/{form_token}", - DifyAPIError, headers=headers, json={"inputs": inputs, "user": user, "action": action}, - ) - if not response.is_success: - raise DifyAPIError( - f"Dify API error: {response.status_code} - {response.text[:200]}", - code="dify.http_error", - ) + ) as response: + if not response.is_success: + body = await limited_body(response, DifyAPIError) + raise DifyAPIError( + f"Dify API error: {response.status_code} - {body.decode('utf-8', errors='replace')[:200]}", + code="dify.http_error", + ) + + content_type = response.headers.get("content-type", "").lower() + if content_type.split(";", 1)[0].strip() == "text/event-stream": + async for event in _iter_sse_json(response): + yield event + return + await limited_body(response, DifyAPIError) async with client.stream( "GET", diff --git a/plugins/Runner/dify-agent/pkg/http_limits.py b/plugins/Runner/dify-agent/pkg/http_limits.py index fb3984c37..670998262 100644 --- a/plugins/Runner/dify-agent/pkg/http_limits.py +++ b/plugins/Runner/dify-agent/pkg/http_limits.py @@ -13,7 +13,7 @@ MAX_STREAM_BYTES = 16 * 1024 * 1024 async def limited_bytes(response, error): total = 0 - async for chunk in response.aiter_bytes(chunk_size=8192): + async for chunk in response.aiter_bytes(): total += len(chunk) if total > MAX_STREAM_BYTES: raise error("Provider stream exceeds the runtime limit") diff --git a/plugins/Runner/dify-agent/tests/test_resume_streaming.py b/plugins/Runner/dify-agent/tests/test_resume_streaming.py new file mode 100644 index 000000000..142ea8cf5 --- /dev/null +++ b/plugins/Runner/dify-agent/tests/test_resume_streaming.py @@ -0,0 +1,61 @@ +"""Streaming and deduplication for resumed Dify workflows.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from components.runner.default import DefaultRunner + + +async def _resume(events): + continuation = { + "phase": "submit", + "field_map": {}, + "action_map": {"approve": "approve"}, + "default_inputs": {}, + "form_token": "form", + "workflow_run_id": "workflow", + "user": "user", + } + runner = SimpleNamespace( + _load_interaction_continuation=AsyncMock(return_value=continuation), + _store_interaction_continuation=AsyncMock(), + _delete_interaction_continuation=AsyncMock(), + ) + + class Client: + async def workflow_submit(self, **kwargs): + for event in events: + yield event + + submission = SimpleNamespace(interaction_id="interaction", action_id="approve", values={}) + result = [ + item + async for item in DefaultRunner._resume_workflow_once( + runner, SimpleNamespace(run_id="run"), Client(), submission, False + ) + ] + return [item.data["chunk"] for item in result if item.type == "message.delta"], result + + +@pytest.mark.asyncio +async def test_resume_emits_intermediate_snapshots_without_repeating_final_answer(): + events = [{"event": "text_chunk", "data": {"text": char}} for char in "abcdefghij"] + events += [ + {"event": "node_finished", "data": {"node_type": "answer", "outputs": {"answer": "abcdefghij"}}}, + {"event": "workflow_finished", "data": {"outputs": {"summary": "abcdefghij"}}}, + ] + chunks, results = await _resume(events) + assert [(chunk["content"], chunk["is_final"]) for chunk in chunks] == [ + ("abcdefgh", False), + ("abcdefghij", True), + ] + assert results[-1].type == "run.completed" + + +@pytest.mark.asyncio +async def test_resume_accepts_cumulative_message_snapshots(): + events = [{"event": "message", "answer": "abcdefgh"[:length]} for length in range(1, 9)] + events.append({"event": "workflow_finished", "data": {"outputs": {}}}) + chunks, _ = await _resume(events) + assert [chunk["content"] for chunk in chunks] == ["abcdefgh"] diff --git a/plugins/Runner/tests/test_traditional_runner_protocol_fixes.py b/plugins/Runner/tests/test_traditional_runner_protocol_fixes.py index eb5f6d6e0..9fb3efbae 100644 --- a/plugins/Runner/tests/test_traditional_runner_protocol_fixes.py +++ b/plugins/Runner/tests/test_traditional_runner_protocol_fixes.py @@ -252,6 +252,87 @@ def test_dify_workflow_pause_requests_host_interaction_and_hides_provider_tokens assert all(_type(result) != "run.completed" for result in results) +def test_dify_current_human_input_event_requests_host_interaction() -> None: + module = _load_runner_module("dify-agent") + runner = object.__new__(module.DefaultRunner) + storage = _FakePluginStorage() + runner.get_run_api = lambda ctx: storage + + class FakeClient: + async def workflow_run(self, *, inputs, user, files): + yield { + "event": "human_input_required", + "workflow_run_id": "workflow-private", + "data": { + "form_token": "form-private", + "node_title": "Manual review", + "form_content": "Choose a priority\n{{#$output.priority#}}", + "inputs": [ + { + "output_variable_name": "priority", + "type": "select", + "option_source": {"type": "constant", "value": ["high", "low"]}, + } + ], + "actions": [{"id": "approve-private", "title": "Approve"}], + }, + } + + results = asyncio.run(_collect_async(runner._run_workflow(_ctx(), FakeClient(), {}, "hello", "user_1", [], False))) + + assert _type(results[-1]) == "action.requested" + assert all(_type(result) != "run.completed" for result in results) + request = results[-1].data["payload"] + assert request["title"] == "Manual review" + assert request["fields"][0]["options"][0]["value"] == "high" + assert "form-private" not in str(request) + assert "workflow-private" not in str(request) + continuation = json.loads(next(iter(storage.values.values()))) + assert continuation["form_token"] == "form-private" + assert continuation["workflow_run_id"] == "workflow-private" + + +def test_dify_pause_accepts_single_reason_and_camel_case_fields() -> None: + module = _load_runner_module("dify-agent") + runner = object.__new__(module.DefaultRunner) + storage = _FakePluginStorage() + runner.get_run_api = lambda ctx: storage + event = { + "event": "workflow_paused", + "data": { + "workflowRunId": "workflow-private", + "reason": { + "type": "human-input-required", + "formToken": "form-private", + "nodeTitle": "Manual review", + "formContent": "Choose a priority\n{{#$output.priority#}}", + "resolvedDefaultValues": {"priority": "low"}, + "fields": [ + { + "outputVariableName": "priority", + "type": "select", + "optionSource": {"type": "constant", "value": [{"label": "High", "value": "high"}]}, + } + ], + "userActions": [{"actionId": "approve-private", "label": "Approve"}], + }, + }, + } + + result = asyncio.run(runner._interaction_result_for_pause(_ctx(), event, "user_1")) + + assert result is not None + request = result.data["payload"] + assert request["title"] == "Manual review" + assert request["fields"][0]["default"] == "low" + assert request["fields"][0]["options"][0]["value"] == "high" + assert request["fields"][0]["options"][0]["label"] == "high" + continuation = json.loads(next(iter(storage.values.values()))) + assert continuation["action_map"] == {"action_1": "approve-private"} + assert "form-private" not in str(request) + assert "workflow-private" not in str(request) + + def test_dify_interaction_resume_submits_mapped_values_and_clears_continuation() -> None: module = _load_runner_module("dify-agent") runner = object.__new__(module.DefaultRunner) @@ -558,6 +639,77 @@ def test_dify_client_submits_form_then_streams_resumed_events(monkeypatch) -> No assert event_body.consumed and event_body.closed +def test_dify_client_consumes_sse_returned_by_form_submission(monkeypatch) -> None: + module = _load_runner_module("dify-agent") + client_module = sys.modules["pkg.dify_client"] + real_async_client = httpx.AsyncClient + calls: list[httpx.Request] = [] + expected_event = {"event": "workflow_finished", "data": {"outputs": {"summary": "done"}}} + + def handle_request(request): + calls.append(request) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream; charset=utf-8"}, + content=b"data: " + json.dumps(expected_event).encode() + b"\n\n", + ) + + transport = httpx.MockTransport(handle_request) + monkeypatch.setattr( + client_module.httpx, + "AsyncClient", + lambda **kwargs: real_async_client(transport=transport, **kwargs), + ) + client = module.AsyncDifyClient(api_key="key", base_url="https://dify.example/v1") + + events = asyncio.run( + _collect_async( + client.workflow_submit( + form_token="form-token", + workflow_run_id="workflow-run", + inputs={"priority": "high"}, + user="user-1", + action="approve", + ) + ) + ) + + assert events == [expected_event] + assert [(request.method, request.url.path) for request in calls] == [("POST", "/v1/form/human_input/form-token")] + + +def test_dify_client_yields_submission_sse_before_stream_ends(monkeypatch) -> None: + module = _load_runner_module("dify-agent") + client_module = sys.modules["pkg.dify_client"] + real_async_client = httpx.AsyncClient + first_event = {"event": "text_chunk", "data": {"text": "ready"}} + + class FirstEventStream(httpx.AsyncByteStream): + async def __aiter__(self): + yield b"data: " + json.dumps(first_event).encode() + b"\n\n" + raise AssertionError("submission response was buffered before the first event") + + def handle_request(request): + assert request.method == "POST" + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=FirstEventStream()) + + monkeypatch.setattr( + client_module.httpx, + "AsyncClient", + lambda **kwargs: real_async_client(transport=httpx.MockTransport(handle_request), **kwargs), + ) + client = module.AsyncDifyClient(api_key="key", base_url="https://dify.example/v1") + + async def read_first(): + stream = client.workflow_submit("form-token", "workflow-run", {}, "user-1", "approve") + try: + return await anext(stream) + finally: + await stream.aclose() + + assert asyncio.run(read_first()) == first_event + + def test_dify_text_image_upload_failure_is_input_error() -> None: module = _load_runner_module("dify-agent")