mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 21:06:18 +08:00
fix(dify): merge HITL resume streaming into in-tree plugin
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user