diff --git a/comfy_cli/cql/engine.py b/comfy_cli/cql/engine.py index c1c825b9..9f16cb70 100644 --- a/comfy_cli/cql/engine.py +++ b/comfy_cli/cql/engine.py @@ -792,7 +792,24 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: # case surfaces here instead of as a cryptic server reject. autogrow_ports = {p.name: p for p in m.inputs if p.is_autogrow} autogrow_seen: set[str] = set() + # Dynamic combos are checked up front so the generic loop below can + # exempt STALE dynamic sub-keys (left over from a previous + # selection): the server ignores unknown sub-keys, so a hard edge + # check on one would false-error; the unknown_input warning from + # _check_dynamic_combos already covers it. Sub-keys under an + # unresolved selection keep the old generic checks. + dyn_port_names = {p.name for p in m.inputs if p.is_dynamic_combo} + dyn_errors, dyn_warnings, dyn_valid_keys, dyn_unresolved = _check_dynamic_combos( + node_id, class_type, m, node_data + ) for input_name, value in (node_data.get("inputs") or {}).items(): + if ( + "." in input_name + and input_name.split(".", 1)[0] in dyn_port_names + and input_name not in dyn_valid_keys + and not any(input_name.startswith(prefix) for prefix in dyn_unresolved) + ): + continue if autogrow_ports and "." in input_name: base = input_name.split(".", 1)[0] if base in autogrow_ports: @@ -909,7 +926,6 @@ def validate_workflow(self, workflow: dict[str, Any]) -> dict[str, Any]: errors.extend(_check_autogrow_required(node_id, autogrow_ports, autogrow_seen, node_data)) errors.extend(_check_required_present(node_id, m, node_data)) - dyn_errors, dyn_warnings = _check_dynamic_combos(node_id, class_type, m, node_data) errors.extend(dyn_errors) warnings.extend(dyn_warnings) @@ -1065,6 +1081,18 @@ def morphism_to_dict(self, m: Morphism) -> dict[str, Any]: # Autogrow inputs wire as one slot key per connection; # surface that here so `nodes show` is self-documenting. **({"autogrow": True, "wire_as": p.autogrow_slot_example()} if p.is_autogrow else {}), + # Dynamic combos: expose the valid selection keys so agents + # can discover them. `choices` stays [] — selection keys are + # not flat-value enum choices (see _is_scalar_choice). + **( + { + "selection_keys": [ + k for o in _dynamic_combo_options(p.raw_spec) if isinstance(k := o.get("key"), str) + ] + } + if p.is_dynamic_combo + else {} + ), } for p in m.inputs ], @@ -1203,7 +1231,9 @@ def _dynamic_combo_options(spec: Any) -> list[dict]: return [o for o in (options_meta.get("options") or []) if isinstance(o, dict)] -def _check_dynamic_combos(node_id: str, class_type: str, m: Morphism, node_data: dict) -> tuple[list[dict], list[dict]]: +def _check_dynamic_combos( + node_id: str, class_type: str, m: Morphism, node_data: dict +) -> tuple[list[dict], list[dict], set[str], set[str]]: """Validate every dynamic-combo input against the option its selector names. Mirrors the server: ``DynamicCombo._expand_schema_for_dynamic`` @@ -1219,17 +1249,65 @@ def _check_dynamic_combos(node_id: str, class_type: str, m: Morphism, node_data: selector. Without this walk a workflow that omits or mistypes a sub-input validates clean and is then rejected at ``/prompt`` — validation giving false confidence right before a paid run. + + Also flags present dotted keys that match no sub-input of the resolved + selection — a warning, not an error, because the server ignores extra + keys. Returns ``(errors, warnings, valid_keys, unresolved)`` — the caller + uses ``valid_keys``/``unresolved`` to exempt stale (server-ignored) + dynamic sub-keys from the generic edge checks, which would otherwise + hard-error on e.g. a dangling link left over from a previous selection. """ errors: list[dict] = [] warnings: list[dict] = [] present = node_data.get("inputs") or {} - for port in m.inputs: - if not port.is_dynamic_combo: - continue - e, w = _check_dynamic_combo_input(node_id, class_type, port.name, port.raw_spec, port.required, present) + dynamic_ports = [p for p in m.inputs if p.is_dynamic_combo] + if not dynamic_ports: + return errors, warnings, set(), set() + + valid_keys: set[str] = set() + unresolved: set[str] = set() + resolved: dict[str, Any] = {} + for port in dynamic_ports: + e, w, v, u = _check_dynamic_combo_input( + node_id, class_type, port.name, port.raw_spec, port.required, present, resolved + ) errors.extend(e) warnings.extend(w) - return errors, warnings + valid_keys |= v + unresolved |= u + + # Unknown dotted keys under a RESOLVED selection → warning. Under an + # unresolved prefix (selection absent/invalid/link-valued) the sub-keys + # can't be judged — the primary error already covers it, so don't pile on. + dyn_port_names = {p.name for p in dynamic_ports} + for key in present: + if "." not in key or key in valid_keys: + continue + base = key.split(".", 1)[0] + if base not in dyn_port_names: + continue + if any(key.startswith(prefix) for prefix in unresolved): + continue + # Attribute the stray key to the DEEPEST resolved combo prefix, so a + # stray `model.mode.bogus` under a resolved `model.mode` names + # `model.mode`'s selection (and lists ITS sub-keys), not `model`'s. + anchor = max((n for n in resolved if key.startswith(f"{n}.")), key=len, default=base) + selection = resolved.get(anchor, present.get(anchor)) + known = sorted(k for k in valid_keys if k.startswith(f"{anchor}.")) + warnings.append( + { + "node_id": node_id, + "field": key, + "code": "unknown_input", + "message": ( + f"input {key!r} matches no sub-input of {anchor}={selection!r} — the server will ignore it" + ), + "hint": f"valid sub-keys for this selection: {', '.join(known)}" + if known + else f"selection {selection!r} takes no sub-inputs", + } + ) + return errors, warnings, valid_keys, unresolved def _check_dynamic_combo_input( @@ -1239,9 +1317,19 @@ def _check_dynamic_combo_input( spec: Any, required: bool, present: dict, + resolved: dict[str, Any], depth: int = 0, -) -> tuple[list[dict], list[dict]]: - """One dynamic-combo input: resolve its selected option, check its sub-inputs.""" +) -> tuple[list[dict], list[dict], set[str], set[str]]: + """One dynamic-combo input: resolve its selected option, check its sub-inputs. + + Returns ``(errors, warnings, valid_keys, unresolved)`` — ``valid_keys`` is + every dotted key this (and any nested, resolved) selection accepts; + ``unresolved`` is the set of ``"."`` prefixes whose sub-keys can't + be judged, so the caller skips unknown-key warnings there. ``resolved`` + (mutated) records ``name -> selected key`` for every combo level that DID + resolve, so the caller can attribute a stray key to the deepest resolved + prefix. + """ errors: list[dict] = [] warnings: list[dict] = [] options = _dynamic_combo_options(spec) @@ -1276,14 +1364,14 @@ def _check_dynamic_combo_input( "valid_options": keys, } ) - return errors, warnings + return errors, warnings, set(), {f"{name}."} selected = present[name] if isinstance(selected, list) and len(selected) == 2: # Wired as a link: which option expands is only known at execution time, # so there is no static sub-input set to check. The edge itself is # already validated by the driver loop. - return errors, warnings + return errors, warnings, set(), {f"{name}."} option = next((o for o in options if o.get("key") == selected), None) if option is None: @@ -1311,25 +1399,32 @@ def _check_dynamic_combo_input( "valid_options": keys, } ) - return errors, warnings + return errors, warnings, set(), {f"{name}."} + resolved[name] = selected if depth >= _MAX_DYNAMIC_COMBO_DEPTH: - return errors, warnings # pathological nesting — the converter stops here too + return errors, warnings, set(), set() # pathological nesting — the converter stops here too + valid_keys: set[str] = set() + nested_unresolved: set[str] = set() sub_def = option.get("inputs") if not isinstance(sub_def, dict): - return errors, warnings + return errors, warnings, valid_keys, nested_unresolved for section, sub_required in (("required", True), ("optional", False)): section_def = sub_def.get(section) if not isinstance(section_def, dict): continue for sub_name, sub_spec in section_def.items(): - e, w = _check_dynamic_combo_sub( - node_id, class_type, f"{name}.{sub_name}", sub_spec, sub_required, present, depth + dotted = f"{name}.{sub_name}" + valid_keys.add(dotted) + e, w, v, u = _check_dynamic_combo_sub( + node_id, class_type, dotted, sub_spec, sub_required, present, resolved, depth ) errors.extend(e) warnings.extend(w) - return errors, warnings + valid_keys |= v + nested_unresolved |= u + return errors, warnings, valid_keys, nested_unresolved def _check_dynamic_combo_sub( @@ -1339,8 +1434,9 @@ def _check_dynamic_combo_sub( sub_spec: Any, sub_required: bool, present: dict, + resolved: dict[str, Any], depth: int, -) -> tuple[list[dict], list[dict]]: +) -> tuple[list[dict], list[dict], set[str], set[str]]: """Presence + shape + catalog checks for one expanded sub-input. The sub-input spec is a plain ``INPUT_TYPES`` entry, so it goes through the @@ -1360,55 +1456,72 @@ def _check_dynamic_combo_sub( if port.is_dynamic_combo: # Nested dynamic combo: its own selector/presence rules apply one level down. - return _check_dynamic_combo_input(node_id, class_type, dotted, sub_spec, sub_required, present, depth + 1) + return _check_dynamic_combo_input( + node_id, class_type, dotted, sub_spec, sub_required, present, resolved, depth + 1 + ) if port.is_autogrow: # An autogrow sub-input wires as `.` keys and routinely # declares `min: 0` even inside the `required` section (Seedream's # `model.images`), so absence is NOT a server reject — the converter # emits no key at all for a zero-slot autogrow. Nothing to presence- or - # shape-check here. - return [], [] + # shape-check here. Any slot keys actually present are accepted + # wholesale (not counted, not edge-checked here) so they don't + # surface as unknown_input noise; the generic driver loop still + # edge-checks whichever slot keys ARE present. + slot_prefix = f"{dotted}." + return [], [], {k for k in present if k.startswith(slot_prefix)}, set() if dotted not in present: if not sub_required: - return [], [] - return [ - { - "node_id": node_id, - "field": dotted, - "code": "required_input_missing", - "message": ( - f"required input {dotted!r} is missing — the server will reject this node (required_input_missing)" - ), - "hint": f"add {dotted!r} to inputs" - + ( - f" (e.g. a {port.type} value)" - if not port.is_link - else f" (wire a {port.type} link: [, ])" - ), - } - ], [] + return [], [], set(), set() + return ( + [ + { + "node_id": node_id, + "field": dotted, + "code": "required_input_missing", + "message": ( + f"required input {dotted!r} is missing — the server will reject this node (required_input_missing)" + ), + "hint": f"add {dotted!r} to inputs" + + ( + f" (e.g. a {port.type} value)" + if not port.is_link + else f" (wire a {port.type} link: [, ])" + ), + } + ], + [], + set(), + set(), + ) value = present[dotted] if isinstance(value, list) and len(value) == 2: # A wired sub-input — the driver loop already ran the dangling-edge and # output-index checks on this same key. - return [], [] + return [], [], set(), set() shape_err = port.validate_shape(value) if shape_err: - return [ - { - "node_id": node_id, - "field": dotted, - "code": "shape_mismatch", - "message": shape_err, - "hint": f"expected {port.type}; check the value type", - } - ], [] + return ( + [ + { + "node_id": node_id, + "field": dotted, + "code": "shape_mismatch", + "message": shape_err, + "hint": f"expected {port.type}; check the value type", + } + ], + [], + set(), + set(), + ) - return _validate_catalog_value(node_id, class_type, dotted, port, value) + errs, warns = _validate_catalog_value(node_id, class_type, dotted, port, value) + return errs, warns, set(), set() def _check_autogrow_required( diff --git a/tests/comfy_cli/cql/test_engine.py b/tests/comfy_cli/cql/test_engine.py index 3b3d2915..e406e288 100644 --- a/tests/comfy_cli/cql/test_engine.py +++ b/tests/comfy_cli/cql/test_engine.py @@ -1670,3 +1670,420 @@ def test_load_from_target_refuses_non_loopback_local_host(): with pytest.raises(LoadError, match="non-loopback"): _load_from_target(mode="local", host="example.com", port=8188) + + +# =========================================================================== +# TestDynamicComboInputs — BE-3358: selection-key enum + dotted sub-inputs +# =========================================================================== + + +@pytest.fixture +def graph_dynamic() -> Graph: + """Graph built from the synthetic BE-3349-shaped dynamic-combo fixture: + a COMFY_DYNAMICCOMBO_V3 `model` input with two options carrying different + required sub-inputs (INT with min/max, an enum), one of which nests a + second dynamic combo (`model.mode` → `model.mode.budget`).""" + import json + from pathlib import Path + + fixture = Path(__file__).parent.parent / "fixtures" / "dynamic_combo_object_info.json" + return Graph.from_object_info(json.loads(fixture.read_text())) + + +class TestDynamicComboInputs: + """COMFY_DYNAMICCOMBO_V3 inputs (ClaudeNode.model, …): the flat value must + be a known selection key, and the selected option's required sub-inputs + must be present as dotted keys — mirroring the server's + _expand_schema_for_dynamic + required_input_missing checks.""" + + def _node(self, inputs: dict) -> dict: + return {"1": {"class_type": "ClaudeNode", "inputs": inputs}} + + def test_valid_selection_with_all_sub_keys(self, graph_dynamic: Graph): + wf = self._node( + { + "prompt": "hi", + "model": "Opus 4.6", + "model.max_tokens": 800, + "model.mode": "fast", + } + ) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is True, result["errors"] + assert result["warnings"] == [] + + def test_missing_required_sub_key_errors(self, graph_dynamic: Graph): + """BE-3349 repro 1: {"model": "Opus 4.6"} with no sub-keys.""" + wf = self._node({"prompt": "hi", "model": "Opus 4.6"}) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is False + missing = [e for e in result["errors"] if e["code"] == "required_input_missing"] + assert {e["field"] for e in missing} == {"model.max_tokens", "model.mode"} + + def test_invalid_selection_key_errors(self, graph_dynamic: Graph): + """BE-3349 repro 2: unknown selection is a hard unknown_enum_value + carrying the full valid_options list.""" + wf = self._node({"prompt": "hi", "model": "NotARealModel", "model.bogus_key": 5}) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is False + err = next(e for e in result["errors"] if e["code"] == "unknown_enum_value") + assert err["field"] == "model" + assert err["valid_options"] == ["Opus 4.6", "Haiku 4.5"] + # Sub-keys of an unknown selection can't be judged — no pile-on warning. + assert "unknown_input" not in [w["code"] for w in result["warnings"]] + + def test_garbage_dotted_key_warns(self, graph_dynamic: Graph): + wf = self._node( + { + "prompt": "hi", + "model": "Opus 4.6", + "model.max_tokens": 800, + "model.mode": "fast", + "model.bogus_key": 5, + } + ) + result = graph_dynamic.validate_workflow(wf) + # Extra keys are ignored by the server → warning, not error. + assert result["valid"] is True, result["errors"] + warn = next(w for w in result["warnings"] if w["code"] == "unknown_input") + assert warn["field"] == "model.bogus_key" + assert "model.max_tokens" in warn["hint"] + + def test_out_of_range_sub_value_errors(self, graph_dynamic: Graph): + wf = self._node( + { + "prompt": "hi", + "model": "Opus 4.6", + "model.max_tokens": 999999, + "model.mode": "fast", + } + ) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is False + err = next(e for e in result["errors"] if e["code"] == "above_max") + assert err["field"] == "model.max_tokens" + + def test_sub_value_shape_mismatch_errors(self, graph_dynamic: Graph): + wf = self._node( + { + "prompt": "hi", + "model": "Opus 4.6", + "model.max_tokens": "lots", + "model.mode": "fast", + } + ) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is False + err = next(e for e in result["errors"] if e["code"] == "shape_mismatch") + assert err["field"] == "model.max_tokens" + assert "model.max_tokens" in err["message"] + + def test_enum_sub_input_membership_checked(self, graph_dynamic: Graph): + """A COMBO sub-input of the selected option gets the same hard enum + check as a top-level combo.""" + wf = self._node( + { + "prompt": "hi", + "model": "Haiku 4.5", + "model.max_tokens": 100, + "model.style": "florid", + } + ) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is False + err = next(e for e in result["errors"] if e["code"] == "unknown_enum_value") + assert err["field"] == "model.style" + assert err["valid_options"] == ["concise", "detailed"] + + def test_required_dynamic_port_absent_errors(self, graph_dynamic: Graph): + wf = self._node({"prompt": "hi"}) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is False + err = next(e for e in result["errors"] if e["code"] == "required_input_missing") + assert err["field"] == "model" + assert "Opus 4.6" in err["hint"] + + def test_nested_selection_missing_required_sub_key(self, graph_dynamic: Graph): + """Nested dynamic combo: mode=thinking requires model.mode.budget.""" + wf = self._node( + { + "prompt": "hi", + "model": "Opus 4.6", + "model.max_tokens": 800, + "model.mode": "thinking", + } + ) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is False + missing = [e for e in result["errors"] if e["code"] == "required_input_missing"] + assert {e["field"] for e in missing} == {"model.mode.budget"} + + def test_nested_selection_valid_with_budget(self, graph_dynamic: Graph): + wf = self._node( + { + "prompt": "hi", + "model": "Opus 4.6", + "model.max_tokens": 800, + "model.mode": "thinking", + "model.mode.budget": 2048, + } + ) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is True, result["errors"] + assert result["warnings"] == [] + + def test_nested_invalid_selection_errors_without_pile_on(self, graph_dynamic: Graph): + wf = self._node( + { + "prompt": "hi", + "model": "Opus 4.6", + "model.max_tokens": 800, + "model.mode": "warp", + "model.mode.budget": 2048, + } + ) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is False + err = next(e for e in result["errors"] if e["code"] == "unknown_enum_value") + assert err["field"] == "model.mode" + assert err["valid_options"] == ["fast", "thinking"] + # model.mode.budget sits under the unresolved selection — no warning. + assert result["warnings"] == [] + + def test_optional_sub_input_absent_is_fine_but_range_checked_when_present(self, graph_dynamic: Graph): + wf = self._node( + { + "prompt": "hi", + "model": "Opus 4.6", + "model.max_tokens": 800, + "model.mode": "fast", + "model.temperature": 3.5, + } + ) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is False + err = next(e for e in result["errors"] if e["code"] == "above_max") + assert err["field"] == "model.temperature" + + def test_link_valued_selection_is_skipped(self, graph_dynamic: Graph): + """A 2-list selection value is a link — treated as a no-op (no crash, + no selection error), matching the pre-BE-3358 behavior.""" + wf = { + "0": { + "class_type": "ClaudeNode", + "inputs": {"prompt": "src", "model": "Haiku 4.5", "model.max_tokens": 1, "model.style": "concise"}, + }, + "1": {"class_type": "ClaudeNode", "inputs": {"prompt": "hi", "model": ["0", 0]}}, + } + result = graph_dynamic.validate_workflow(wf) + codes = [e["code"] for e in result["errors"]] + assert "unknown_enum_value" not in codes + assert "required_input_missing" not in codes + + def test_malformed_options_are_skipped(self): + """Non-dict options and options missing key/inputs parse to nothing — + validation degrades to the old lenient behavior instead of crashing.""" + info = { + "Foo": { + "input": { + "required": { + "shape": [ + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + "not-a-dict", + {"key": 42, "inputs": {"required": {}}}, + {"key": "no-inputs"}, + {"key": "square", "inputs": "not-a-dict"}, + ] + }, + ] + } + }, + "input_order": {"required": ["shape"]}, + "output": [], + "output_name": [], + "output_node": True, + "display_name": "Foo", + "python_module": "nodes", + } + } + g = Graph.from_object_info(info) + result = g.validate_workflow({"1": {"class_type": "Foo", "inputs": {"shape": "anything"}}}) + assert isinstance(result["valid"], bool) # must not raise + + def test_describe_exposes_selection_keys(self, graph_dynamic: Graph): + desc = graph_dynamic.morphism_to_dict(graph_dynamic.node("ClaudeNode")) + model = next(i for i in desc["inputs"] if i["name"] == "model") + assert model["selection_keys"] == ["Opus 4.6", "Haiku 4.5"] + # enum_values contract untouched: selection keys are not flat choices. + assert model["choices"] == [] + prompt = next(i for i in desc["inputs"] if i["name"] == "prompt") + assert "selection_keys" not in prompt + + # -- Cursor-review hardening (PR #573 panel findings) ------------------- + + def test_stale_link_valued_sub_key_no_hard_edge_error(self, graph_dynamic: Graph): + """A stale dynamic sub-key left over from a previous selection is + IGNORED by the server even when link-valued — the generic edge checks + must not hard-error (dangling_edge) on it; it only warns.""" + wf = self._node( + { + "prompt": "hi", + "model": "Opus 4.6", + "model.max_tokens": 800, + "model.mode": "fast", + # stale key from a previous selection, pointing at a node that + # no longer exists + "model.old_image": ["99", 0], + } + ) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is True, result["errors"] + codes = [e["code"] for e in result["errors"]] + assert "dangling_edge" not in codes + warn = next(w for w in result["warnings"] if w["code"] == "unknown_input") + assert warn["field"] == "model.old_image" + + def test_deep_stray_key_attributed_to_nested_level(self, graph_dynamic: Graph): + """A stray key under a RESOLVED nested combo is attributed to that + level (model.mode='fast'), not the top-level model selection.""" + wf = self._node( + { + "prompt": "hi", + "model": "Opus 4.6", + "model.max_tokens": 800, + "model.mode": "fast", + "model.mode.bogus": 1, + } + ) + result = graph_dynamic.validate_workflow(wf) + assert result["valid"] is True, result["errors"] + warn = next(w for w in result["warnings"] if w["code"] == "unknown_input") + assert warn["field"] == "model.mode.bogus" + assert "model.mode='fast'" in warn["message"] + assert "'fast' takes no sub-inputs" in warn["hint"] + + def test_required_missing_hint_truncates_many_selection_keys(self): + """A dynamic combo with hundreds of options must not dump them all + into the required_input_missing hint — first 8, then a count.""" + options = [{"key": f"ckpt-{i:03d}", "inputs": {"required": {}, "optional": {}}} for i in range(30)] + info = { + "Loader": { + "input": {"required": {"model": ["COMFY_DYNAMICCOMBO_V3", {"options": options}]}}, + "input_order": {"required": ["model"]}, + "output": [], + "output_name": [], + "output_node": True, + "display_name": "Loader", + "python_module": "nodes", + } + } + g = Graph.from_object_info(info) + result = g.validate_workflow({"1": {"class_type": "Loader", "inputs": {}}}) + err = next(e for e in result["errors"] if e["code"] == "required_input_missing") + hint = err["hint"] + assert "ckpt-007" in hint + assert "ckpt-008" not in hint + assert "and 22 more" in hint + + def test_deeply_nested_dynamic_options_degrade_without_recursion_error(self): + """A hostile object_info with pathologically nested dynamic combos + (deeper than _MAX_SUBGRAPH_DEPTH) parses leniently instead of + crashing from_object_info with a RecursionError.""" + spec = ["COMFY_DYNAMICCOMBO_V3", {"options": [{"key": "leaf", "inputs": {"required": {}}}]}] + for _ in range(200): + spec = [ + "COMFY_DYNAMICCOMBO_V3", + {"options": [{"key": "deeper", "inputs": {"required": {"next": spec}}}]}, + ] + info = { + "Nest": { + "input": {"required": {"root": spec}}, + "input_order": {"required": ["root"]}, + "output": [], + "output_name": [], + "output_node": True, + "display_name": "Nest", + "python_module": "nodes", + } + } + g = Graph.from_object_info(info) # must not raise + result = g.validate_workflow({"1": {"class_type": "Nest", "inputs": {"root": "deeper"}}}) + assert isinstance(result["valid"], bool) # deep recursion is bounded, not a crash + + +class TestDynamicComboAutogrowSub: + """Autogrow sub-inputs carried by a dynamic-combo option (e.g. an option + whose schema declares `images` as COMFY_AUTOGROW_V3): slot keys wire as + `model.images.image0`, … — mirroring the top-level autogrow path.""" + + INFO = { + "Src": { + "input": {"required": {}}, + "input_order": {"required": []}, + "output": ["IMAGE"], + "output_name": ["image"], + "display_name": "Src", + "python_module": "nodes", + }, + "Batch": { + "input": { + "required": { + "model": [ + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + { + "key": "multi", + "inputs": {"required": {"images": ["COMFY_AUTOGROW_V3", {}]}, "optional": {}}, + }, + {"key": "none", "inputs": {"required": {}, "optional": {}}}, + ] + }, + ] + } + }, + "input_order": {"required": ["model"]}, + "output": [], + "output_name": [], + "output_node": True, + "display_name": "Batch", + "python_module": "nodes", + }, + } + + def _graph(self) -> Graph: + return Graph.from_object_info(self.INFO) + + def test_wired_slot_keys_are_valid(self): + wf = { + "0": {"class_type": "Src", "inputs": {}}, + "1": { + "class_type": "Batch", + "inputs": {"model": "multi", "model.images.image0": ["0", 0], "model.images.image1": ["0", 0]}, + }, + } + result = self._graph().validate_workflow(wf) + assert result["valid"] is True, result["errors"] + # Slot keys must NOT surface as unknown_input noise. + assert [w for w in result["warnings"] if w["code"] == "unknown_input"] == [] + + def test_autogrow_sub_with_no_slots_is_lenient(self): + """Unlike a TOP-LEVEL autogrow input, a required autogrow sub-input + nested inside a dynamic-combo option is NOT slot-count checked here: + real schemas (Seedream's `model.images`) routinely declare `required` + with an effective `min: 0`, so the converter legitimately emits zero + slot keys. See TestValidateDynamicCombo.test_converted_seedream_workflow_is_valid.""" + wf = {"1": {"class_type": "Batch", "inputs": {"model": "multi"}}} + result = self._graph().validate_workflow(wf) + assert result["valid"] is True, result["errors"] + + def test_slot_edges_still_checked(self): + """Valid slot keys keep the generic edge checks — a slot pointing at a + missing node is still a dangling_edge error.""" + wf = {"1": {"class_type": "Batch", "inputs": {"model": "multi", "model.images.image0": ["99", 0]}}} + result = self._graph().validate_workflow(wf) + assert result["valid"] is False + err = next(e for e in result["errors"] if e["code"] == "dangling_edge") + assert err["field"] == "model.images.image0" diff --git a/tests/comfy_cli/fixtures/dynamic_combo_object_info.json b/tests/comfy_cli/fixtures/dynamic_combo_object_info.json new file mode 100644 index 00000000..92dcf1f2 --- /dev/null +++ b/tests/comfy_cli/fixtures/dynamic_combo_object_info.json @@ -0,0 +1,66 @@ +{ + "ClaudeNode": { + "input": { + "required": { + "prompt": ["STRING", {"multiline": true, "default": ""}], + "model": [ + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + { + "key": "Opus 4.6", + "inputs": { + "required": { + "max_tokens": ["INT", {"default": 1024, "min": 1, "max": 32000}], + "mode": [ + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + { + "key": "fast", + "inputs": {"required": {}, "optional": {}} + }, + { + "key": "thinking", + "inputs": { + "required": { + "budget": ["INT", {"default": 1024, "min": 1, "max": 8000}] + }, + "optional": {} + } + } + ] + } + ] + }, + "optional": { + "temperature": ["FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0}] + } + } + }, + { + "key": "Haiku 4.5", + "inputs": { + "required": { + "max_tokens": ["INT", {"default": 512, "min": 1, "max": 8192}], + "style": [["concise", "detailed"]] + }, + "optional": {} + } + } + ] + } + ] + } + }, + "input_order": {"required": ["prompt", "model"]}, + "output": ["STRING"], + "output_name": ["text"], + "category": "api/text", + "display_name": "Claude", + "description": "Synthetic BE-3349-shaped dynamic-combo node for validator tests.", + "output_node": true, + "api_node": true, + "python_module": "nodes" + } +}