diff --git a/nodes/src/nodes/tool_v0/IGlobal.py b/nodes/src/nodes/tool_v0/IGlobal.py new file mode 100644 index 000000000..3800eab55 --- /dev/null +++ b/nodes/src/nodes/tool_v0/IGlobal.py @@ -0,0 +1,64 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +v0 by Vercel tool node - global (shared) state. + +Reads the v0 API key from config and stores it for IInstance tool methods. +""" + +from __future__ import annotations + +from ai.common.config import Config +from rocketlib import IGlobalBase, OPEN_MODE, warning + + +class IGlobal(IGlobalBase): + """Global state for tool_v0.""" + + apikey: str = '' + + def beginGlobal(self) -> None: + if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: + return + + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + + self.apikey = str(cfg.get('apikey') or '').strip() + + if not self.apikey: + raise Exception('tool_v0: apikey is required') + + def validateConfig(self) -> None: + try: + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + apikey = str(cfg.get('apikey') or '').strip() + if not apikey: + warning('apikey is required') + except Exception as e: + warning(str(e)) + + def endGlobal(self) -> None: + self.apikey = '' diff --git a/nodes/src/nodes/tool_v0/IInstance.py b/nodes/src/nodes/tool_v0/IInstance.py new file mode 100644 index 000000000..2ba7f732c --- /dev/null +++ b/nodes/src/nodes/tool_v0/IInstance.py @@ -0,0 +1,276 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +v0 by Vercel tool node instance. + +Exposes ``generate_ui`` and ``refine_ui`` tools for generating React UI +components via Vercel's v0 generative UI API. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import httpx + +from rocketlib import IInstanceBase, tool_function, warning + +from .IGlobal import IGlobal + +# --------------------------------------------------------------------------- +# v0 API configuration +# --------------------------------------------------------------------------- + +V0_API_BASE = 'https://api.v0.dev/v1' +V0_GENERATE_ENDPOINT = f'{V0_API_BASE}/chat' +V0_REQUEST_TIMEOUT = 120 # seconds — generation can take a while + + +class IInstance(IInstanceBase): + IGlobal: IGlobal + + @tool_function( + input_schema={ + 'type': 'object', + 'required': ['prompt'], + 'properties': { + 'prompt': { + 'type': 'string', + 'description': 'A natural-language description of the UI component to generate.', + }, + 'model': { + 'type': 'string', + 'description': 'The v0 model to use (default: "v0-1.0-md").', + 'default': 'v0-1.0-md', + }, + }, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'success': {'type': 'boolean'}, + 'code': {'type': 'string', 'description': 'Generated React component code.'}, + 'message_id': {'type': 'string', 'description': 'v0 message ID for follow-up refinements.'}, + 'error': {'type': 'string', 'description': 'Error message on failure.'}, + }, + }, + description='Generate a React UI component from a natural-language description. Provide a detailed prompt describing the desired UI and receive production-ready React + Tailwind CSS code.', + ) + def generate_ui(self, args): + """Generate a React UI component from a text prompt.""" + args = _normalize_tool_input(args) + + prompt = args.get('prompt') + if not prompt: + return {'success': False, 'error': 'generate_ui requires a `prompt` parameter'} + + model = args.get('model') or 'v0-1.0-md' + + messages = [ + {'role': 'user', 'content': prompt}, + ] + + try: + response = self._call_v0_api(messages, model) + code, message_id = _extract_code(response) + except (httpx.HTTPError, ValueError, json.JSONDecodeError) as e: + return {'success': False, 'error': f'v0 API call failed: {e}'} + + if not code: + return { + 'success': False, + 'error': 'No code generated', + } + + return { + 'success': True, + 'code': code, + 'message_id': message_id, + } + + @tool_function( + input_schema={ + 'type': 'object', + 'required': ['prompt', 'message_id'], + 'properties': { + 'prompt': { + 'type': 'string', + 'description': 'Follow-up instructions describing how to change the component.', + }, + 'message_id': { + 'type': 'string', + 'description': 'The message_id returned from a previous generate_ui or refine_ui call.', + }, + 'prior_messages': { + 'type': 'array', + 'description': 'Prior conversation messages (user/assistant pairs) for stateless API fallback. Include the original prompt and response so the server has full context.', + 'items': { + 'type': 'object', + 'properties': { + 'role': {'type': 'string'}, + 'content': {'type': 'string'}, + }, + }, + }, + 'model': { + 'type': 'string', + 'description': 'The v0 model to use (default: "v0-1.0-md").', + 'default': 'v0-1.0-md', + }, + }, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'success': {'type': 'boolean'}, + 'code': {'type': 'string', 'description': 'Refined React component code.'}, + 'message_id': {'type': 'string', 'description': 'Updated message ID for further refinements.'}, + 'error': {'type': 'string', 'description': 'Error message on failure.'}, + }, + }, + description='Refine a previously generated UI component by providing follow-up instructions. Requires the message_id from a prior generate_ui call.', + ) + def refine_ui(self, args): + """Refine a previously generated UI component.""" + args = _normalize_tool_input(args) + + prompt = args.get('prompt') + if not prompt: + return {'success': False, 'error': 'refine_ui requires a `prompt` parameter'} + + message_id = args.get('message_id') + if not message_id: + return {'success': False, 'error': 'refine_ui requires a `message_id` from a prior generation'} + + model = args.get('model') or 'v0-1.0-md' + + # Build the messages array with prior history as a stateless fallback. + # The v0 /v1/chat endpoint may be stateful (server-side history keyed by + # parent_message_id) or stateless (standard OpenAI-compatible, requiring + # the full conversation in messages). We include both: the prior context + # in `messages` and `parent_message_id` as an extra parameter so the + # request works correctly regardless of the server's behaviour. + prior_messages: List[Dict[str, str]] = args.get('prior_messages') or [] + messages = [*prior_messages, {'role': 'user', 'content': prompt}] + + try: + response = self._call_v0_api(messages, model, parent_message_id=message_id) + code, new_message_id = _extract_code(response) + except (httpx.HTTPError, ValueError, json.JSONDecodeError) as e: + return {'success': False, 'error': f'v0 API call failed: {e}'} + + if not code: + return { + 'success': False, + 'error': 'No code generated', + } + + return { + 'success': True, + 'code': code, + 'message_id': new_message_id or message_id, + } + + def _call_v0_api(self, messages: List[Dict[str, str]], model: str, **extra: Any) -> Dict[str, Any]: + """Send a chat-style request to the v0 API and return the parsed response.""" + payload = { + 'model': model, + 'messages': messages, + 'stream': False, + **extra, + } + + headers = { + 'Authorization': f'Bearer {self.IGlobal.apikey}', + 'Content-Type': 'application/json', + } + + try: + with httpx.Client(timeout=V0_REQUEST_TIMEOUT) as client: + resp = client.post( + V0_GENERATE_ENDPOINT, + headers=headers, + json=payload, + ) + resp.raise_for_status() + try: + return resp.json() + except (json.JSONDecodeError, ValueError) as exc: + warning(f'v0 API returned non-JSON response: {exc}') + raise ValueError('v0 API returned non-JSON response') from exc + except httpx.HTTPStatusError as e: + warning(f'v0 API error: status={e.response.status_code}') + raise + except Exception as e: + warning(f'v0 API request failed: {e}') + raise + + +def _extract_code(response: Dict[str, Any]) -> tuple[str, str]: + """Extract generated code and message ID from the v0 API response.""" + message_id = '' + code = '' + + choices = response.get('choices') or [] + if choices: + message = choices[0].get('message') or {} + code = message.get('content') or '' + message_id = response.get('id') or '' + + return code, message_id + + +def _normalize_tool_input(input_obj: Any) -> Dict[str, Any]: + """Normalize whatever the engine/framework passes as tool input into a plain dict.""" + if input_obj is None: + return {} + + if hasattr(input_obj, 'model_dump') and callable(getattr(input_obj, 'model_dump')): + input_obj = input_obj.model_dump() + elif hasattr(input_obj, 'dict') and callable(getattr(input_obj, 'dict')): + input_obj = input_obj.dict() + + if isinstance(input_obj, str): + try: + parsed = json.loads(input_obj) + if isinstance(parsed, dict): + input_obj = parsed + except Exception: + pass + + if not isinstance(input_obj, dict): + warning(f'v0: unexpected input type {type(input_obj).__name__} (content redacted)') + return {} + + if 'input' in input_obj and isinstance(input_obj['input'], dict): + inner = input_obj['input'] + extras = {k: v for k, v in input_obj.items() if k != 'input'} + input_obj = {**inner, **extras} + + input_obj.pop('security_context', None) + + return input_obj diff --git a/nodes/src/nodes/tool_v0/__init__.py b/nodes/src/nodes/tool_v0/__init__.py new file mode 100644 index 000000000..d678f5ae5 --- /dev/null +++ b/nodes/src/nodes/tool_v0/__init__.py @@ -0,0 +1,35 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +from os.path import dirname, join, realpath +from depends import depends + +requirements = join(dirname(realpath(__file__)), 'requirements.txt') +depends(requirements) + +from .IGlobal import IGlobal +from .IInstance import IInstance + +__all__ = ['IGlobal', 'IInstance'] diff --git a/nodes/src/nodes/tool_v0/requirements.txt b/nodes/src/nodes/tool_v0/requirements.txt new file mode 100644 index 000000000..f7621d172 --- /dev/null +++ b/nodes/src/nodes/tool_v0/requirements.txt @@ -0,0 +1 @@ +httpx diff --git a/nodes/src/nodes/tool_v0/services.json b/nodes/src/nodes/tool_v0/services.json new file mode 100644 index 000000000..cd2995072 --- /dev/null +++ b/nodes/src/nodes/tool_v0/services.json @@ -0,0 +1,123 @@ +{ + // + // v0 by Vercel — Generative UI tool node + // + // Generates React + Tailwind CSS components from natural-language prompts + // using the Vercel v0 API. Exposes two tools: + // - generate_ui: create a component from a text description + // - refine_ui: iterate on a previous generation with follow-up instructions + // + + // + // Required: + // The displayable name of this node + // + "title": "v0 by Vercel", + // + // Required: + // The protocol is the endpoint protocol + // + "protocol": "tool_v0://", + // + // Required: + // Class type of the node - what it does + // + "classType": ["tool"], + // + // Required: + // Capabilities are flags that change the behavior of the underlying + // engine. "experimental" marks this node as not yet production-ready + // + "capabilities": ["invoke", "experimental"], + // + // Optional: + // Register is either filter, endpoint or ignored if not specified. If the + // type is specified, a factory is registered of that given type + // + "register": "filter", + // + // Optional: + // The node is the actual physical node to instantiate - if + // not specified, the protocol will be used + // + "node": "python", + // + // Optional: + // The path is the executable/script code - it is node dependent + // and is optional for most nodes + // + "path": "nodes.tool_v0", + // + // Required: + // The prefix map when added/removed when converting URLs <=> paths + // + "prefix": "v0", + // + // Optional: + // The icon to display in the UI for this node + // + "icon": "vercel.svg", + // + // Optional: + // Description of this driver + // + "description": ["A component that connects to Vercel's v0 API to generate React + Tailwind CSS UI ", "components from natural-language prompts. Supports generate_ui (create a new component ", "from a description) and refine_ui (iterate on a previous generation with follow-up ", "instructions). Experimental: API availability and behavior may change."], + // + // Optional: + // Rendering hints to the UI which indicate which fields of + // the configuration should be used to display information + // + "tile": ["Tool: v0 UI Generation"], + // + // Optional: + // As a pipe component, define what this pipe component takes + // and what it produces + // + "lanes": {}, + // + // Optional: + // Profile section are configuration options used by the driver itself + // + "preconfig": { + // Define the values that will be merged into any profile configuration + // specified, unless the profile is 'absolute' + "default": "default", + // Defines profiles used with the "profile": key + "profiles": { + "default": { + "title": "v0 by Vercel", + "apikey": "" + } + } + }, + // + // Optional: + // Local field definitions - these define fields only for the + // current service. You may specify them here, or directly + // in the shape + // + "fields": { + "tool_v0.apikey": { + "type": "string", + "title": "API Key", + "description": "Vercel v0 API key", + "default": "", + "secure": true, + "ui": { + "ui:widget": "ApiKeyWidget" + } + } + }, + // + // Required: + // Defines the fields (shape) of the service. Either source or target + // may be specified, or both, but at least one is required + // + "shape": [ + { + "section": "Pipe", + "title": "v0 by Vercel", + "properties": ["type", "tool_v0.apikey"] + } + ] +} diff --git a/nodes/test/test_tool_v0.py b/nodes/test/test_tool_v0.py new file mode 100644 index 000000000..d2e28d3ce --- /dev/null +++ b/nodes/test/test_tool_v0.py @@ -0,0 +1,284 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# ============================================================================= + +"""Unit tests for tool_v0 IInstance pure-logic helpers.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import types +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# rocketlib stub — must be installed before importing the module under test +# --------------------------------------------------------------------------- + +_WARNING_CALLS: list[str] = [] + + +def _reset_warnings() -> None: + _WARNING_CALLS.clear() + + +def _stub_warning(msg: str, *_a: object, **_k: object) -> None: + _WARNING_CALLS.append(msg) + + +def _install_rocketlib_stub() -> None: + rocketlib = types.ModuleType('rocketlib') + rocketlib.IInstanceBase = object + rocketlib.IGlobalBase = object + rocketlib.tool_function = lambda *_a, **_k: lambda fn: fn + rocketlib.warning = _stub_warning + rocketlib.OPEN_MODE = SimpleNamespace(CONFIG='config') + sys.modules.setdefault('rocketlib', rocketlib) + + ai_pkg = types.ModuleType('ai') + ai_pkg.__path__ = [] + ai_common = types.ModuleType('ai.common') + ai_common.__path__ = [] + ai_config = types.ModuleType('ai.common.config') + ai_config.Config = MagicMock() + sys.modules.setdefault('ai', ai_pkg) + sys.modules.setdefault('ai.common', ai_common) + sys.modules.setdefault('ai.common.config', ai_config) + + +_install_rocketlib_stub() + +# --------------------------------------------------------------------------- +# Load the module under test via importlib so we avoid package __init__ chains +# --------------------------------------------------------------------------- + +_NODES_ROOT = Path(__file__).resolve().parent.parent / 'src' / 'nodes' +_IINSTANCE_PATH = _NODES_ROOT / 'tool_v0' / 'IInstance.py' + + +def _load_iinstance(): + # The module uses `from .IGlobal import IGlobal` so it needs a parent package. + # Register a fake package 'tool_v0' and its IGlobal sub-module in sys.modules + # so the relative import resolves without a real filesystem package. + pkg_name = 'tool_v0' + pkg_stub = types.ModuleType(pkg_name) + pkg_stub.__path__ = [str(_NODES_ROOT / 'tool_v0')] + pkg_stub.__package__ = pkg_name + sys.modules[pkg_name] = pkg_stub + + iglobal_mod = types.ModuleType(f'{pkg_name}.IGlobal') + iglobal_mod.IGlobal = type('IGlobal', (), {}) + sys.modules[f'{pkg_name}.IGlobal'] = iglobal_mod + pkg_stub.IGlobal = iglobal_mod + + spec = importlib.util.spec_from_file_location( + f'{pkg_name}.IInstance', + _IINSTANCE_PATH, + submodule_search_locations=[], + ) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + mod.__package__ = pkg_name + sys.modules[f'{pkg_name}.IInstance'] = mod + spec.loader.exec_module(mod) + return mod + + +_mod = _load_iinstance() +# Force the loaded module's `warning` reference to our stub. Required because +# `from rocketlib import warning` binds the name at import time, so when the +# real rocketlib is already in sys.modules (e.g. on CI inside the engine +# pytest runner) our sys.modules stub is ignored and _mod.warning points at +# the engine's logger. Overriding the attribute on the loaded module patches +# the actual reference used by _normalize_tool_input. +_mod.warning = _stub_warning + +_normalize_tool_input = _mod._normalize_tool_input +_extract_code = _mod._extract_code +IInstance = _mod.IInstance + + +# --------------------------------------------------------------------------- +# Helper: build a minimal IInstance without calling __init__ +# --------------------------------------------------------------------------- + + +def _make_instance() -> IInstance: + inst = IInstance.__new__(IInstance) + iglobal = SimpleNamespace(apikey='test-key') + inst.IGlobal = iglobal + return inst + + +# ============================================================================= +# (a) _normalize_tool_input +# ============================================================================= + + +class TestNormalizeToolInput: + def setup_method(self): + _reset_warnings() + + def test_none_returns_empty_dict(self): + assert _normalize_tool_input(None) == {} + + def test_json_string_parsed_to_dict(self): + result = _normalize_tool_input(json.dumps({'prompt': 'hello'})) + assert result == {'prompt': 'hello'} + + def test_object_with_model_dump_converted(self): + obj = SimpleNamespace() + obj.model_dump = lambda: {'prompt': 'from_pydantic'} + result = _normalize_tool_input(obj) + assert result == {'prompt': 'from_pydantic'} + + def test_nested_input_key_flattened(self): + nested = {'input': {'prompt': 'hi', 'model': 'v0-1.0-md'}, 'extra': 'val'} + result = _normalize_tool_input(nested) + assert result['prompt'] == 'hi' + assert result['model'] == 'v0-1.0-md' + assert result['extra'] == 'val' + assert 'input' not in result + + def test_security_context_stripped(self): + data = {'prompt': 'test', 'security_context': {'token': 'secret'}} + result = _normalize_tool_input(data) + assert 'security_context' not in result + assert result['prompt'] == 'test' + + def test_non_dict_type_logs_type_name_only(self): + _normalize_tool_input(42) + assert len(_WARNING_CALLS) == 1 + assert 'int' in _WARNING_CALLS[0] + # Must not contain the actual value + assert '42' not in _WARNING_CALLS[0] + + def test_non_dict_list_logs_type_name_only(self): + _reset_warnings() + _normalize_tool_input(['secret_value_xyz', 'other_secret']) + assert len(_WARNING_CALLS) == 1 + assert 'list' in _WARNING_CALLS[0] + # Raw list contents must not appear in the warning + assert 'secret_value_xyz' not in _WARNING_CALLS[0] + assert 'other_secret' not in _WARNING_CALLS[0] + + def test_non_dict_returns_empty_dict(self): + result = _normalize_tool_input(12345) + assert result == {} + + +# ============================================================================= +# (b) _extract_code +# ============================================================================= + + +class TestExtractCode: + def test_empty_choices_returns_empty_strings(self): + code, msg_id = _extract_code({}) + assert code == '' + assert msg_id == '' + + def test_empty_choices_list_returns_empty_strings(self): + code, msg_id = _extract_code({'choices': []}) + assert code == '' + assert msg_id == '' + + def test_well_formed_response_extracts_code_and_id(self): + response = { + 'id': 'msg-abc123', + 'choices': [{'message': {'content': 'export default function App() {}', 'role': 'assistant'}}], + } + code, msg_id = _extract_code(response) + assert code == 'export default function App() {}' + assert msg_id == 'msg-abc123' + + def test_missing_message_field_returns_empty_strings(self): + response = { + 'id': 'msg-xyz', + 'choices': [{'finish_reason': 'stop'}], + } + code, msg_id = _extract_code(response) + assert code == '' + assert msg_id == 'msg-xyz' + + +# ============================================================================= +# (c) generate_ui +# ============================================================================= + + +class TestGenerateUi: + def test_missing_prompt_returns_error(self): + inst = _make_instance() + result = inst.generate_ui({}) + assert result['success'] is False + assert 'prompt' in result['error'] + + def test_http_status_error_returns_failure(self): + import httpx + + inst = _make_instance() + mock_response = MagicMock() + mock_response.status_code = 401 + err = httpx.HTTPStatusError('Unauthorized', request=MagicMock(), response=mock_response) + + with patch.object(inst, '_call_v0_api', side_effect=err): + result = inst.generate_ui({'prompt': 'make a button'}) + + assert result['success'] is False + assert 'error' in result + + def test_empty_choices_returns_no_code_generated(self): + inst = _make_instance() + with patch.object(inst, '_call_v0_api', return_value={'choices': [], 'id': 'x'}): + result = inst.generate_ui({'prompt': 'make a button'}) + + assert result['success'] is False + assert result['error'] == 'No code generated' + + +# ============================================================================= +# (d) refine_ui +# ============================================================================= + + +class TestRefineUi: + def test_missing_message_id_returns_error(self): + inst = _make_instance() + result = inst.refine_ui({'prompt': 'change color to blue'}) + assert result['success'] is False + assert 'message_id' in result['error'] + + def test_prior_messages_forwarded_into_call(self): + inst = _make_instance() + prior = [ + {'role': 'user', 'content': 'make a button'}, + {'role': 'assistant', 'content': 'export default function Btn() {}'}, + ] + good_response = { + 'id': 'msg-new', + 'choices': [{'message': {'content': 'export default function Btn2() {}'}}], + } + + with patch.object(inst, '_call_v0_api', return_value=good_response) as mock_call: + result = inst.refine_ui( + { + 'prompt': 'change color to blue', + 'message_id': 'msg-old', + 'prior_messages': prior, + } + ) + + assert result['success'] is True + _args, _kwargs = mock_call.call_args + messages_sent = _args[0] + # prior messages must appear at the start + assert messages_sent[:2] == prior + # new user turn must be appended + assert messages_sent[-1] == {'role': 'user', 'content': 'change color to blue'} + # parent_message_id passed as kwarg + assert _kwargs.get('parent_message_id') == 'msg-old'