From 7e71b1c1eec5cd649b284080b3bfe17006013769 Mon Sep 17 00:00:00 2001 From: 2301_80247084 <3301767269@qq.com> Date: Tue, 11 Aug 2026 01:07:11 +0800 Subject: [PATCH] fix(end-node): normalize non-string plugin content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin node may feed the End node a non-string payload (dict / list / number). Content.content was typed Optional[str], so such payloads failed Pydantic validation before reaching the converter — the second dialogue round could not load (#1282). - Widen Content.content to Optional[Any] (only used by End-node inputs). - end_convert now stringifies non-string content for response_template so the dumped configs stay JSON-serializable. Closes #1282. Co-Authored-By: AtomCode (deepseek-v4-flash) --- .../core/manager/convertor/components/end.py | 7 +- backend/openjiuwen_studio/schemas/node.py | 5 +- .../tests/test_end_node_nonstring_content.py | 79 +++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_end_node_nonstring_content.py diff --git a/backend/openjiuwen_studio/core/manager/convertor/components/end.py b/backend/openjiuwen_studio/core/manager/convertor/components/end.py index f6da49cbf..79a970df6 100644 --- a/backend/openjiuwen_studio/core/manager/convertor/components/end.py +++ b/backend/openjiuwen_studio/core/manager/convertor/components/end.py @@ -53,9 +53,14 @@ def end_convert(node: Node) -> dsl.Component: response_template="", ) else: + # A plugin node may emit a non-string payload (dict / list / number). + # Normalize it to a string so the End node's response_template stays + # serializable and the second dialogue round loads correctly (#1282). + raw_content = content.content + response_template = raw_content if isinstance(raw_content, str) else str(raw_content or "") configs = dsl.EndConfig( stream_output=inputs.streaming, - response_template=content.content, + response_template=response_template, ) end_node = dsl.Component( diff --git a/backend/openjiuwen_studio/schemas/node.py b/backend/openjiuwen_studio/schemas/node.py index 0ff3a7bb7..4487d60d7 100644 --- a/backend/openjiuwen_studio/schemas/node.py +++ b/backend/openjiuwen_studio/schemas/node.py @@ -87,7 +87,10 @@ class LoopParam(BaseType): class Content(BaseType): - content: Optional[str] = Field("", alias="content") + # ``content`` may carry a non-string payload (dict / list / number) when a + # plugin node feeds the End node directly; consumers normalize it to a + # string, so keep the field permissive (#1282). + content: Optional[Any] = Field("", alias="content") streaming: Optional[bool] = Field(False, alias="streaming") diff --git a/backend/tests/test_end_node_nonstring_content.py b/backend/tests/test_end_node_nonstring_content.py new file mode 100644 index 000000000..693a5499d --- /dev/null +++ b/backend/tests/test_end_node_nonstring_content.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""Unit tests for End node conversion with non-string content (issue #1282). + +A plugin node may emit a non-string payload (dict / list / number) into the +End node's ``content``. ``end_convert`` must normalize it to a string so the +``response_template`` stays serializable and the second dialogue round loads. +""" + +import pytest + +from openjiuwen_studio.core.manager.convertor.components.end import end_convert +from openjiuwen_studio.core.common.dsl import ComponentType +from openjiuwen_studio.schemas.node import ( + Content, + Inputs, + Meta, + Node, + NodeData, + NodePosition, +) + + +def _make_end_node(content_value) -> Node: + """Build an End node whose content carries the given payload.""" + return Node( + id="end-1", + meta=Meta(position=NodePosition(x=0, y=0), node_id="end-1", name="End", type="end"), + data=NodeData( + title="End", + inputs=Inputs( + input_parameters={}, + content=Content(content=content_value), + streaming=False, + ), + ), + ) + + +def test_end_convert_accepts_string_content(): + node = _make_end_node("hello world") + component = end_convert(node) + assert component.type == ComponentType.COMPONENT_TYPE_END + assert component.configs["response_template"] == "hello world" + + +def test_end_convert_normalizes_dict_content_to_string(): + node = _make_end_node({"key": "value"}) + component = end_convert(node) + assert component.type == ComponentType.COMPONENT_TYPE_END + template = component.configs["response_template"] + assert isinstance(template, str) + assert "key" in template + + +def test_end_convert_normalizes_number_content_to_string(): + node = _make_end_node(12345) + component = end_convert(node) + template = component.configs["response_template"] + assert isinstance(template, str) + assert template == "12345" + + +def test_end_convert_handles_none_content_gracefully(): + node = _make_end_node(None) + component = end_convert(node) + assert component.configs["response_template"] == "" + + +def test_end_convert_keeps_serializable_configs_for_non_string_content(): + # Regression guard: the dumped configs must be JSON-serializable even when + # the raw payload was a dict (the second dialogue round re-reads these). + import json + + node = _make_end_node({"a": [1, 2, 3]}) + component = end_convert(node) + json.dumps(component.configs) # must not raise + assert isinstance(component.configs["response_template"], str)