Merge branch 'main' into feat/vici-dial

This commit is contained in:
Abhishek Kumar 2026-07-20 21:22:26 +05:30
commit 9458fdb67e
27 changed files with 2118 additions and 157 deletions

View file

@ -696,8 +696,8 @@ async def update_campaign(
@router.get("/{campaign_id}/runs")
async def get_campaign_runs(
campaign_id: int,
page: int = 1,
limit: int = 50,
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
limit: int = Query(50, ge=1, le=100, description="Number of items per page"),
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
sort_by: Optional[str] = Query(
None, description="Field to sort by (e.g., 'duration', 'created_at')"

View file

@ -1,5 +1,6 @@
"""API routes for managing tools."""
import time
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
@ -22,6 +23,8 @@ from api.schemas.tool import (
ToolDefinition,
ToolParameter,
ToolResponse,
ToolTestRequest,
ToolTestResponse,
TransferCallConfig,
TransferCallToolDefinition,
UpdateToolRequest,
@ -39,6 +42,10 @@ from api.services.tool_management import (
from api.services.tool_management import (
populate_discovered_tools as _populate_discovered_tools,
)
from api.services.workflow.tools.custom_tool import (
execute_http_tool,
serialize_query_params,
)
router = APIRouter(prefix="/tools")
@ -57,6 +64,8 @@ __all__ = [
"ToolDefinition",
"ToolParameter",
"ToolResponse",
"ToolTestRequest",
"ToolTestResponse",
"TransferCallConfig",
"TransferCallToolDefinition",
"UpdateToolRequest",
@ -196,6 +205,149 @@ async def refresh_mcp_tools(
raise HTTPException(status_code=e.status_code, detail=e.message) from e
@router.post("/{tool_uuid}/test")
async def test_tool(
tool_uuid: str,
request: ToolTestRequest,
user: UserModel = Depends(get_user),
) -> ToolTestResponse:
"""Execute an HTTP API tool with sample LLM and preset parameters."""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
tool = await db_client.get_tool_by_uuid(
tool_uuid, user.selected_organization_id, include_archived=True
)
if not tool:
raise HTTPException(status_code=404, detail="Tool not found")
if tool.category != ToolCategory.HTTP_API.value:
raise HTTPException(status_code=400, detail="Only HTTP API tools can be tested")
tool_config = (
tool.definition.get("config", {}) if isinstance(tool.definition, dict) else {}
)
configured_method = tool_config.get("method", "?")
configured_url = tool_config.get("url", "?")
started_at = time.perf_counter()
result = await execute_http_tool(
tool,
request.llm_params,
preset_params=request.preset_params,
organization_id=user.selected_organization_id,
include_request_headers=True,
)
duration_ms = max(0, round((time.perf_counter() - started_at) * 1000))
status = result.get("status", "error")
status_code = result.get("status_code")
if status_code is not None and status_code >= 400:
status = "error"
hint = _hint_for_status_code(status_code, configured_method)
# Preset values take precedence over model-supplied values, matching live
# execution after configured preset templates have been resolved.
resolved_arguments = {**request.llm_params, **request.preset_params}
# Mirror execute_http_tool's own branch: POST/PUT/PATCH send the
# resolved arguments as a JSON body; GET/DELETE send them as query
# params. Never both.
request_body = None
request_params = None
if configured_method in ("POST", "PUT", "PATCH"):
request_body = resolved_arguments # keep {} so preview matches wire request
elif resolved_arguments:
request_params = serialize_query_params(resolved_arguments)
return ToolTestResponse(
status=status,
status_code=status_code,
data=result.get("data"),
error=result.get("error"),
duration_ms=duration_ms,
hint=hint,
request_method=configured_method,
request_url=configured_url,
request_headers=result.get("request_headers", {}),
request_body=request_body,
request_params=request_params,
)
def _hint_for_status_code(
status_code: Optional[int], configured_method: str
) -> Optional[str]:
"""Human-readable explanation for a status code a misconfigured tool
is likely to hit. Returns None for 2xx and any code not covered."""
if status_code == 400:
return (
"HTTP 400 Bad Request — the server rejected the request payload. "
"Verify the arguments/body match what this endpoint expects."
)
if status_code == 401:
return (
"HTTP 401 Unauthorized — the request wasn't authenticated. Check "
"the credential configured on the Authentication tab is present "
"and valid."
)
if status_code == 403:
return (
"HTTP 403 Forbidden — authenticated, but the configured "
"credential doesn't have permission for this endpoint/action."
)
if status_code == 404:
return (
f"HTTP 404 Not Found — verify the endpoint URL is correct and "
f"that {configured_method} is a valid method for it."
)
if status_code == 405:
return (
f"HTTP 405 Method Not Allowed — the endpoint rejected the "
f"configured method ({configured_method}). Verify the API expects "
f"{configured_method} for this URL."
)
if status_code == 408:
return (
"HTTP 408 Request Timeout — the endpoint didn't respond in time. "
"Check the endpoint is reachable, or increase Timeout (ms) if it's "
"just slow."
)
if status_code == 409:
return (
"HTTP 409 Conflict — the endpoint rejected the request due to a "
"conflicting resource state (e.g. duplicate create). Not "
"necessarily a configuration problem."
)
if status_code == 415:
return (
"HTTP 415 Unsupported Media Type — check the Content-Type header "
"matches the format this endpoint expects for the body."
)
if status_code == 422:
return (
"HTTP 422 Unprocessable Entity — the request was well-formed but "
"the payload's structure or field types don't match what this "
"endpoint expects. Compare your arguments against the API's "
"documented schema."
)
if status_code == 429:
return (
"HTTP 429 Too Many Requests — the endpoint is rate-limiting. Wait "
"and retry; not a configuration problem."
)
if status_code is not None and 500 <= status_code < 600:
return (
f"HTTP {status_code} — the endpoint itself errored. This is "
"likely an issue on the API's side, not your tool configuration."
)
return None
@router.put("/{tool_uuid}")
async def update_tool(
tool_uuid: str,

View file

@ -1407,8 +1407,8 @@ class WorkflowRunsResponse(BaseModel):
@router.get("/{workflow_id}/runs")
async def get_workflow_runs(
workflow_id: int,
page: int = 1,
limit: int = 50,
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
limit: int = Query(50, ge=1, le=100, description="Number of items per page"),
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
sort_by: Optional[str] = Query(
None, description="Field to sort by (e.g., 'duration', 'created_at')"

View file

@ -561,3 +561,32 @@ class McpRefreshResponse(BaseModel):
tool_uuid: str
discovered_tools: list = Field(default_factory=list)
error: Optional[str] = None
class ToolTestRequest(BaseModel):
"""Request body for testing an HTTP API tool outside a live call."""
llm_params: Dict[str, Any] = Field(
default_factory=dict,
description="Values for parameters normally supplied by the model.",
)
preset_params: Dict[str, Any] = Field(
default_factory=dict,
description="Resolved values for parameters normally supplied from presets.",
)
class ToolTestResponse(BaseModel):
"""Result of testing an HTTP API tool."""
status: str
status_code: Optional[int] = None
data: Optional[Any] = None
error: Optional[str] = None
hint: Optional[str] = None
request_method: str
request_url: str
request_headers: Dict[str, str] = Field(default_factory=dict)
request_body: Optional[Dict[str, Any]] = None
request_params: Optional[Dict[str, Any]] = None
duration_ms: int

View file

@ -584,6 +584,20 @@ class SarvamLLMConfiguration(BaseLLMConfiguration):
OPENAI_REALTIME_MODELS = ["gpt-realtime-2"]
# ISO 639-1 codes accepted by the Realtime API's input_audio_transcription.
# Not exhaustive — the field allows custom input.
OPENAI_REALTIME_LANGUAGES = [
"en",
"es",
"pt",
"fr",
"de",
"it",
"hi",
"ja",
"ko",
"zh",
]
OPENAI_REALTIME_VOICES = [
"alloy",
"ash",
@ -618,6 +632,17 @@ class OpenAIRealtimeLLMConfiguration(BaseLLMConfiguration):
"allow_custom_input": True,
},
)
language: str | None = Field(
default=None,
description=(
"ISO 639-1 language code for input audio transcription (e.g. 'pt', 'es'). "
"Improves transcription accuracy and latency. Leave unset to auto-detect."
),
json_schema_extra={
"examples": OPENAI_REALTIME_LANGUAGES,
"allow_custom_input": True,
},
)
GROK_REALTIME_MODELS = ["grok-voice-think-fast-1.0"]

View file

@ -52,16 +52,6 @@ class AudioConfig:
)
self.pipeline_sample_rate = 16000
# Log configuration for auditing
logger.info(
f"AudioConfig initialized: "
f"transport_in={self.transport_in_sample_rate}Hz, "
f"transport_out={self.transport_out_sample_rate}Hz, "
f"vad={self.vad_sample_rate}Hz, "
f"pipeline={self.pipeline_sample_rate}Hz, "
f"buffer={self.buffer_size_seconds}s"
)
@property
def buffer_size_bytes(self) -> int:
"""Calculate buffer size in bytes based on pipeline sample rate."""

View file

@ -95,6 +95,9 @@ from pipecat.turns.user_start import (
MinWordsUserTurnStartStrategy,
ProvisionalVADUserTurnStartStrategy,
)
from pipecat.turns.user_start.transcription_user_turn_start_strategy import (
TranscriptionUserTurnStartStrategy,
)
from pipecat.turns.user_start.vad_user_turn_start_strategy import (
VADUserTurnStartStrategy,
)
@ -160,9 +163,10 @@ def _create_non_realtime_user_turn_start_strategies(
if turn_start_strategy == "provisional_vad":
return [
TranscriptionUserTurnStartStrategy(),
ProvisionalVADUserTurnStartStrategy(
pause_secs=_resolve_provisional_vad_pause_secs(run_configs)
)
),
]
if uses_external_turns:
@ -172,7 +176,7 @@ def _create_non_realtime_user_turn_start_strategies(
# confirms a real turn.
return [ExternalUserTurnStartStrategy(enable_interruptions=True)]
return [VADUserTurnStartStrategy()]
return [TranscriptionUserTurnStartStrategy(), VADUserTurnStartStrategy()]
def _create_non_realtime_user_turn_stop_strategies(

View file

@ -227,7 +227,6 @@ def create_stt_service(
# Other models than flux
# Use language from user config, defaulting to "multi" for multilingual support
language = getattr(user_config.stt, "language", None) or "multi"
logger.debug(f"Using DeepGram Model - {user_config.stt.model}")
return DeepgramSTTService(
api_key=user_config.stt.api_key,
settings=DeepgramSTTSettings(
@ -1009,6 +1008,13 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"):
SessionProperties,
)
# Pin the transcription language when configured. Without it the model
# auto-detects per utterance, which misfires on short/noisy telephony
# audio (e.g. Portuguese transcribed as English or Chinese).
transcription_kwargs = {}
if language:
transcription_kwargs["language"] = language
return DograhOpenAIRealtimeLLMService(
api_key=api_key,
settings=DograhOpenAIRealtimeLLMService.Settings(
@ -1016,7 +1022,9 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"):
session_properties=SessionProperties(
audio=AudioConfiguration(
input=AudioInput(
transcription=InputAudioTranscription(),
transcription=InputAudioTranscription(
**transcription_kwargs
),
),
output=AudioOutput(
voice=voice or "alloy",

View file

@ -8,6 +8,7 @@ import httpx
from loguru import logger
from api.db import db_client
from api.services.configuration.masking import mask_key
from api.utils.credential_auth import build_auth_header
from api.utils.template_renderer import render_template
@ -21,6 +22,19 @@ TYPE_MAP = {
}
def serialize_query_params(arguments: Dict[str, Any]) -> Dict[str, Any]:
"""JSON-stringify dict/list values so they're safe to pass as query params.
httpx (and query strings in general) only support primitive param values.
Object/array-typed tool arguments must be serialized before going out as
GET/DELETE query params, otherwise httpx raises a TypeError.
"""
return {
k: json.dumps(v) if isinstance(v, (dict, list)) else v
for k, v in arguments.items()
}
def tool_to_function_schema(tool: Any) -> Dict[str, Any]:
"""Convert a ToolModel to an LLM function schema.
@ -224,7 +238,9 @@ async def execute_http_tool(
arguments: Dict[str, Any],
call_context_vars: Optional[Dict[str, Any]] = None,
gathered_context_vars: Optional[Dict[str, Any]] = None,
preset_params: Optional[Dict[str, Any]] = None,
organization_id: Optional[int] = None,
include_request_headers: bool = False,
) -> Dict[str, Any]:
"""Execute an HTTP API tool.
@ -233,7 +249,11 @@ async def execute_http_tool(
arguments: Arguments passed by the LLM (parameter name -> value)
call_context_vars: Initial context variables available at runtime
gathered_context_vars: Variables extracted during the conversation
preset_params: Pre-resolved preset parameter values. Used by the test
endpoint; live calls omit this so configured templates are resolved.
organization_id: Organization ID for credential lookup
include_request_headers: Include a client-safe header preview in the result.
Headers supplied by a stored credential are masked.
Returns:
Result dict with response data or error
@ -248,7 +268,9 @@ async def execute_http_tool(
# Get headers from config
headers = dict(config.get("headers", {}) or {})
# Add auth header if credential is configured
# Add auth header if credential is configured. Keep track of which headers
# came from the credential so only those values are masked in test previews.
credential_headers: Dict[str, str] = {}
credential_uuid = config.get("credential_uuid")
if credential_uuid and organization_id:
try:
@ -256,8 +278,8 @@ async def execute_http_tool(
credential_uuid, organization_id
)
if credential:
auth_header = build_auth_header(credential)
headers.update(auth_header)
credential_headers = build_auth_header(credential)
headers.update(credential_headers)
logger.debug(f"Applied credential '{credential.name}' to tool request")
else:
logger.warning(
@ -266,17 +288,31 @@ async def execute_http_tool(
except Exception as e:
logger.error(f"Failed to fetch credential for tool '{tool.name}': {e}")
request_headers: Dict[str, str] = {}
if include_request_headers:
request_headers = {str(name): str(value) for name, value in headers.items()}
for header_name, header_value in credential_headers.items():
request_headers[header_name] = mask_key(str(header_value))
def build_result(result: Dict[str, Any]) -> Dict[str, Any]:
if include_request_headers:
return {**result, "request_headers": request_headers}
return result
# Get timeout
timeout_ms = config.get("timeout_ms", 5000)
timeout_seconds = timeout_ms / 1000
try:
preset_arguments = _resolve_preset_parameters(
config, call_context_vars, gathered_context_vars
)
except ValueError as e:
logger.error(f"Custom tool '{tool.name}' preset parameter error: {e}")
return {"status": "error", "error": str(e)}
if preset_params is None:
try:
preset_arguments = _resolve_preset_parameters(
config, call_context_vars, gathered_context_vars
)
except ValueError as e:
logger.error(f"Custom tool '{tool.name}' preset parameter error: {e}")
return build_result({"status": "error", "error": str(e)})
else:
preset_arguments = dict(preset_params)
resolved_arguments = {**(arguments or {}), **preset_arguments}
@ -286,7 +322,7 @@ async def execute_http_tool(
if method in ("POST", "PUT", "PATCH"):
body = resolved_arguments
elif method in ("GET", "DELETE") and resolved_arguments:
params = resolved_arguments
params = serialize_query_params(resolved_arguments)
logger.info(
f"Executing custom tool '{tool.name}' ({tool.tool_uuid}): {method} {url}"
@ -322,23 +358,29 @@ async def execute_http_tool(
logger.debug(
f"Custom tool '{tool.name}' completed with status {response.status_code}"
)
return result
return build_result(result)
except httpx.TimeoutException:
logger.error(f"Custom tool '{tool.name}' timed out after {timeout_seconds}s")
return {
"status": "error",
"error": f"Request timed out after {timeout_seconds} seconds",
}
return build_result(
{
"status": "error",
"error": f"Request timed out after {timeout_seconds} seconds",
}
)
except httpx.RequestError as e:
logger.error(f"Custom tool '{tool.name}' request failed: {e}")
return {
"status": "error",
"error": f"Request failed: {str(e)}",
}
return build_result(
{
"status": "error",
"error": f"Request failed: {str(e)}",
}
)
except Exception as e:
logger.error(f"Custom tool '{tool.name}' execution failed: {e}")
return {
"status": "error",
"error": f"Tool execution failed: {str(e)}",
}
return build_result(
{
"status": "error",
"error": f"Tool execution failed: {str(e)}",
}
)

View file

@ -30,6 +30,7 @@ from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.services.llm_service import FunctionCallParams
from api.enums import WorkflowRunMode
from api.services.configuration.masking import mask_key
from api.services.workflow.pipecat_engine_custom_tools import get_function_schema
from api.services.workflow.tools.custom_tool import (
_coerce_parameter_value,
@ -430,6 +431,7 @@ class TestExecuteHttpTool:
assert result["status"] == "success"
assert result["status_code"] == 201
assert result["data"]["id"] == 123
assert "request_headers" not in result
@pytest.mark.asyncio
async def test_post_request_sends_nested_json_body(self):
@ -546,6 +548,55 @@ class TestExecuteHttpTool:
}
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_post_request_accepts_pre_resolved_preset_params(self):
"""The test endpoint can supply preset values without call context."""
tool = MockToolModel(
tool_uuid="test-uuid-preset-override",
name="Create Lead",
description="Create a lead with caller context",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "POST",
"url": "https://api.example.com/leads",
"timeout_ms": 5000,
"preset_parameters": [
{
"name": "phone_number",
"type": "string",
"value_template": "{{initial_context.phone_number}}",
"required": True,
}
],
},
},
)
with patch(
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
) as mock_client_class:
mock_client = AsyncMock()
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {"id": 123}
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await execute_http_tool(
tool,
{"name": "John"},
preset_params={"phone_number": "+14155550123"},
)
assert mock_client.request.call_args.kwargs["json"] == {
"name": "John",
"phone_number": "+14155550123",
}
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_missing_required_preset_parameter_returns_error(self):
"""Test that required preset parameters fail before the HTTP request."""
@ -619,6 +670,52 @@ class TestExecuteHttpTool:
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_get_request_serializes_object_and_array_query_params(self):
"""Object/array-typed arguments must be JSON-stringified for GET query
params httpx raises a TypeError if a dict/list is passed as-is."""
tool = MockToolModel(
tool_uuid="test-uuid",
name="Search Users",
description="Search for users",
category="http_api",
definition={
"schema_version": 1,
"type": "http_api",
"config": {
"method": "GET",
"url": "https://api.example.com/users/search",
"timeout_ms": 5000,
},
},
)
arguments = {
"source": "voice",
"metadata": {"campaign": "spring"},
"tags": ["a", "b"],
}
with patch(
"api.services.workflow.tools.custom_tool.httpx.AsyncClient"
) as mock_client_class:
mock_client = AsyncMock()
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"users": []}
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await execute_http_tool(tool, arguments)
call_kwargs = mock_client.request.call_args.kwargs
assert call_kwargs["params"] == {
"source": "voice",
"metadata": '{"campaign": "spring"}',
"tags": '["a", "b"]',
}
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_get_request_without_arguments_preserves_url_query_params(self):
"""Empty runtime args should not override query params already in the URL."""
@ -762,11 +859,17 @@ class TestExecuteHttpTool:
mock_client.request.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
await execute_http_tool(tool, {"data": "test"})
result = await execute_http_tool(
tool, {"data": "test"}, include_request_headers=True
)
call_kwargs = mock_client.request.call_args.kwargs
assert call_kwargs["headers"]["X-API-Key"] == "secret-key"
assert call_kwargs["headers"]["X-Custom-Header"] == "custom-value"
assert result["request_headers"] == {
"X-API-Key": "secret-key",
"X-Custom-Header": "custom-value",
}
@pytest.mark.asyncio
async def test_request_includes_auth_header_from_credential(self):
@ -807,7 +910,12 @@ class TestExecuteHttpTool:
with patch("api.services.workflow.tools.custom_tool.db_client") as mock_db:
mock_db.get_credential_by_uuid = AsyncMock(return_value=mock_credential)
await execute_http_tool(tool, {"data": "test"}, organization_id=1)
result = await execute_http_tool(
tool,
{"data": "test"},
organization_id=1,
include_request_headers=True,
)
# Verify credential was fetched
mock_db.get_credential_by_uuid.assert_called_once_with(
@ -819,6 +927,12 @@ class TestExecuteHttpTool:
assert (
call_kwargs["headers"]["Authorization"] == "Bearer my-secret-token"
)
assert result["request_headers"]["Authorization"] == mask_key(
"Bearer my-secret-token"
)
assert (
"my-secret-token" not in result["request_headers"]["Authorization"]
)
@pytest.mark.asyncio
async def test_no_credential_lookup_without_organization_id(self):

View file

@ -26,9 +26,15 @@ from api.routes.tool import (
CreateToolRequest,
McpToolConfig,
McpToolDefinition,
ToolTestRequest,
ToolTestResponse,
UpdateToolRequest,
_populate_discovered_tools,
refresh_mcp_tools,
router,
)
from api.routes.tool import (
test_tool as call_test_tool_route,
)
from api.services.workflow.tools.mcp_tool import (
validate_mcp_definition,
@ -420,6 +426,314 @@ def _mcp_tool_model(org_id=1):
return t
def _http_tool_model(method="GET"):
t = MagicMock()
t.tool_uuid = "tu-http"
t.name = "Mock HTTP"
t.category = "http_api"
t.definition = {
"schema_version": 1,
"type": "http_api",
"config": {"method": method, "url": "https://example.com/search"},
}
return t
@pytest.mark.asyncio
async def test_tool_executes_http_api_tool_with_llm_and_preset_params(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model()
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
executor = AsyncMock(
return_value={
"status": "success",
"status_code": 200,
"data": {"ok": True},
}
)
monkeypatch.setattr(tool_route, "execute_http_tool", executor)
resp = await call_test_tool_route(
"tu-http",
request=ToolTestRequest(
llm_params={"query": "cart"},
preset_params={
"customer_id": "c_123",
"sentiment": "cooperative",
},
),
user=_fake_user(),
)
assert resp.status == "success"
assert resp.status_code == 200
assert resp.data == {"ok": True}
assert resp.error is None
executor.assert_awaited_once_with(
tool,
{"query": "cart"},
preset_params={
"customer_id": "c_123",
"sentiment": "cooperative",
},
organization_id=1,
include_request_headers=True,
)
assert resp.hint is None
assert resp.request_method == "GET"
assert resp.request_url == "https://example.com/search"
assert resp.request_headers == {}
assert resp.request_body is None
assert resp.request_params == {
"query": "cart",
"customer_id": "c_123",
"sentiment": "cooperative",
}
@pytest.mark.asyncio
async def test_tool_test_sets_request_body_for_post_method(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={"status": "success", "status_code": 200, "data": {"id": 1}}
),
)
resp = await call_test_tool_route(
"tu-http",
request=ToolTestRequest(llm_params={"name": "Ada"}),
user=_fake_user(),
)
assert resp.request_method == "POST"
assert resp.request_body == {"name": "Ada"}
assert resp.request_params is None
@pytest.mark.asyncio
async def test_tool_test_returns_masked_effective_request_headers(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={
"status": "success",
"status_code": 200,
"data": {"ok": True},
"request_headers": {
"X-Tenant": "acme",
"Authorization": "****************oken",
},
}
),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
assert resp.request_headers == {
"X-Tenant": "acme",
"Authorization": "****************oken",
}
@pytest.mark.asyncio
async def test_tool_test_request_body_includes_resolved_preset_parameters(
monkeypatch,
):
"""The Request preview includes direct preset values alongside LLM values."""
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
tool.definition["config"]["preset_parameters"] = [
{
"name": "source",
"type": "string",
"value_template": "{{initial_context.metadata.channel}}",
"required": True,
}
]
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={"status": "success", "status_code": 200, "data": {"ok": True}}
),
)
resp = await call_test_tool_route(
"tu-http",
request=ToolTestRequest(
llm_params={"name": "Ada"},
preset_params={"source": "web_widget"},
),
user=_fake_user(),
)
assert resp.request_body == {"name": "Ada", "source": "web_widget"}
@pytest.mark.asyncio
async def test_tool_test_no_arguments_post_shows_empty_body(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(return_value={"status": "success", "status_code": 200, "data": None}),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
# POST with no arguments sends json={} over the wire; preview must show {}
# so callers can distinguish an absent body from an empty one.
assert resp.request_body == {}
assert resp.request_params is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"status_code,expected_snippet",
[
(400, "HTTP 400 Bad Request"),
(401, "HTTP 401 Unauthorized"),
(403, "HTTP 403 Forbidden"),
(404, "HTTP 404 Not Found"),
(405, "HTTP 405 Method Not Allowed"),
(408, "HTTP 408 Request Timeout"),
(409, "HTTP 409 Conflict"),
(415, "HTTP 415 Unsupported Media Type"),
(422, "HTTP 422 Unprocessable Entity"),
(429, "HTTP 429 Too Many Requests"),
(500, "HTTP 500"),
(503, "HTTP 503"),
],
)
async def test_tool_test_hint_for_status_code(
monkeypatch, status_code, expected_snippet
):
import api.routes.tool as tool_route
tool = _http_tool_model(method="POST")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={
"status": "error",
"status_code": status_code,
"error": "boom",
}
),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(llm_params={"a": 1}), user=_fake_user()
)
assert resp.hint is not None
assert resp.hint.startswith(expected_snippet)
@pytest.mark.asyncio
async def test_tool_test_no_hint_on_success(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="GET")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(return_value={"status": "success", "status_code": 200, "data": {}}),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
assert resp.hint is None
@pytest.mark.asyncio
async def test_tool_test_no_hint_for_uncovered_status_code(monkeypatch):
import api.routes.tool as tool_route
tool = _http_tool_model(method="GET")
monkeypatch.setattr(
tool_route.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool)
)
monkeypatch.setattr(
tool_route,
"execute_http_tool",
AsyncMock(
return_value={"status": "error", "status_code": 418, "error": "teapot"}
),
)
resp = await call_test_tool_route(
"tu-http", request=ToolTestRequest(), user=_fake_user()
)
assert resp.hint is None
def test_tool_test_route_is_registered():
assert any(
route.path == "/tools/{tool_uuid}/test" and "POST" in route.methods
for route in router.routes
)
@pytest.mark.asyncio
async def test_tool_rejects_non_http_api_tool(monkeypatch):
import api.routes.tool as tool_route
monkeypatch.setattr(
tool_route.db_client,
"get_tool_by_uuid",
AsyncMock(return_value=_mcp_tool_model()),
)
with pytest.raises(HTTPException) as ei:
await call_test_tool_route(
"tu-mcp", request=ToolTestRequest(), user=_fake_user()
)
assert ei.value.status_code == 400
@pytest.mark.asyncio
async def test_refresh_success(monkeypatch):
import api.services.tool_management as tool_svc
@ -484,3 +798,41 @@ async def test_refresh_not_found_is_404(monkeypatch):
with pytest.raises(HTTPException) as ei:
await refresh_mcp_tools("nope", user=_fake_user())
assert ei.value.status_code == 404
def test_tool_test_response_has_hint_and_request_fields():
"""ToolTestResponse must carry hint + request_method/url/body/params
so the frontend can show what was sent and why it may have failed."""
resp = ToolTestResponse(
status="error",
status_code=405,
data=None,
error="Method Not Allowed",
duration_ms=12,
hint="HTTP 405 Method Not Allowed — the endpoint rejected the configured method (POST).",
request_method="POST",
request_url="https://example.com/thing",
request_headers={"Authorization": "********oken"},
request_body={"a": 1},
request_params=None,
)
assert resp.hint.startswith("HTTP 405")
assert resp.request_method == "POST"
assert resp.request_url == "https://example.com/thing"
assert resp.request_headers == {"Authorization": "********oken"}
assert resp.request_body == {"a": 1}
assert resp.request_params is None
def test_tool_test_response_request_fields_default_to_none_or_required():
"""hint/request_body/request_params are optional; request_method/url are required."""
resp = ToolTestResponse(
status="success",
duration_ms=5,
request_method="GET",
request_url="https://example.com/thing",
)
assert resp.hint is None
assert resp.request_headers == {}
assert resp.request_body is None
assert resp.request_params is None

View file

@ -0,0 +1,40 @@
"""Pagination bounds for the workflow-run and campaign-run list endpoints.
Regression for issue #553: `limit=0` raised an unhandled ZeroDivisionError
(HTTP 500) in the `total_pages` computation, and negative `limit`/`page`
produced nonsensical pagination. Both endpoints now validate the params
(`limit` in [1, 100], `page` >= 1), matching the sibling list endpoints.
"""
import pytest
async def _make_user(db_session, slug: str):
user, _ = await db_session.get_or_create_user_by_provider_id(f"{slug}_user")
org, _ = await db_session.get_or_create_organization_by_provider_id(
f"{slug}_org", user.id
)
await db_session.update_user_selected_organization(user.id, org.id)
return await db_session.get_user_by_id(user.id)
@pytest.mark.parametrize(
"path",
[
"/api/v1/workflow/1/runs",
"/api/v1/campaign/1/runs",
],
)
@pytest.mark.parametrize("query", ["limit=0", "limit=-5", "limit=101", "page=0"])
async def test_run_list_rejects_out_of_range_pagination(
test_client_factory, db_session, path, query
):
"""Out-of-range limit/page is a 422 validation error, never a 500."""
user = await _make_user(db_session, "paginate_bounds")
async with test_client_factory(user) as client:
response = await client.get(f"{path}?{query}")
assert response.status_code == 422, (
f"{path}?{query} expected 422, got {response.status_code}: {response.text}"
)

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,540 @@
"use client";
import { AlertTriangle, Loader2, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { testToolApiV1ToolsToolUuidTestPost } from "@/client/sdk.gen";
import type { ToolTestResponse } from "@/client/types.gen";
import type { HttpMethod, PresetToolParameter, ToolParameter } from "@/components/http";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import {
generateSampleValue,
isUnsafeHttpMethod,
parseTestParameterValues,
} from "./helpers";
type HttpToolTestDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
toolUuid: string;
httpMethod: HttpMethod;
url: string;
parameters: ToolParameter[];
presetParameters: PresetToolParameter[];
};
type TestParameter = ToolParameter | PresetToolParameter;
type ParameterSource = "llm" | "preset";
type JsonEditTarget = {
source: ParameterSource;
name: string;
};
type ParameterFieldsProps = {
idPrefix: string;
parameters: TestParameter[];
values: Record<string, string>;
onValueChange: (name: string, value: string) => void;
onEditJson: (name: string) => void;
};
function defaultInputValue(parameter: TestParameter): string {
if (
"valueTemplate" in parameter &&
parameter.valueTemplate &&
!parameter.valueTemplate.includes("{{")
) {
return parameter.valueTemplate;
}
switch (parameter.type) {
case "number":
return "0";
case "boolean":
return "true";
case "object":
return "{}";
case "array":
return "[]";
default:
return "";
}
}
function seedMissingValues(
previous: Record<string, string>,
parameters: TestParameter[]
): Record<string, string> {
const next = { ...previous };
let changed = false;
for (const parameter of parameters) {
if (!parameter.name || parameter.name in next) continue;
next[parameter.name] = defaultInputValue(parameter);
changed = true;
}
return changed ? next : previous;
}
function ParameterFields({
idPrefix,
parameters,
values,
onValueChange,
onEditJson,
}: ParameterFieldsProps) {
if (parameters.length === 0) {
return <p className="text-sm text-muted-foreground">No parameters configured.</p>;
}
return (
<div className="space-y-4">
{parameters.map((parameter) => {
const inputId = `${idPrefix}-${parameter.name}`;
const value = values[parameter.name] ?? defaultInputValue(parameter);
return (
<div key={parameter.name} className="space-y-1.5">
<div className="flex items-center gap-2">
<Label htmlFor={inputId} className="text-sm font-mono">
{parameter.name}
</Label>
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{parameter.type}
</span>
{parameter.required && <span className="text-xs text-destructive">required</span>}
</div>
{"description" in parameter && parameter.description && (
<p className="text-xs text-muted-foreground">{parameter.description}</p>
)}
{"valueTemplate" in parameter && parameter.valueTemplate && (
<p className="break-all text-xs text-muted-foreground">
Configured preset: <code>{parameter.valueTemplate}</code>
</p>
)}
{parameter.type === "boolean" ? (
<select
id={inputId}
value={value}
onChange={(event) => onValueChange(parameter.name, event.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : parameter.type === "number" ? (
<Input
id={inputId}
type="number"
value={value}
onChange={(event) => onValueChange(parameter.name, event.target.value)}
/>
) : parameter.type === "object" || parameter.type === "array" ? (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onEditJson(parameter.name)}
className="h-9 flex-1 truncate rounded-md border border-input bg-background px-3 py-1 text-left font-mono text-sm shadow-sm hover:bg-accent"
>
{!value || value === (parameter.type === "array" ? "[]" : "{}") ? (
<span className="text-muted-foreground">Empty</span>
) : (
value
)}
</button>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => onEditJson(parameter.name)}
aria-label={`Edit ${parameter.name}`}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
) : (
<Input
id={inputId}
value={value}
onChange={(event) => onValueChange(parameter.name, event.target.value)}
placeholder={`Enter ${parameter.name}`}
/>
)}
</div>
);
})}
</div>
);
}
export function HttpToolTestDialog({
open,
onOpenChange,
toolUuid,
httpMethod,
url,
parameters,
presetParameters,
}: HttpToolTestDialogProps) {
const { getAccessToken } = useAuth();
const [llmParamValues, setLlmParamValues] = useState<Record<string, string>>({});
const [presetParamValues, setPresetParamValues] = useState<Record<string, string>>({});
const [result, setResult] = useState<ToolTestResponse | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [jsonEditTarget, setJsonEditTarget] = useState<JsonEditTarget | null>(null);
const [jsonEditDraft, setJsonEditDraft] = useState("");
const [jsonEditError, setJsonEditError] = useState<string | null>(null);
useEffect(() => {
setLlmParamValues((previous) => seedMissingValues(previous, parameters));
}, [parameters]);
useEffect(() => {
setPresetParamValues((previous) => seedMissingValues(previous, presetParameters));
}, [presetParameters]);
const handleFillSampleValues = () => {
setLlmParamValues((previous) => {
const next = { ...previous };
for (const parameter of parameters) {
next[parameter.name] = generateSampleValue(parameter.type);
}
return next;
});
setPresetParamValues((previous) => {
const next = { ...previous };
for (const parameter of presetParameters) {
next[parameter.name] = generateSampleValue(parameter.type);
}
return next;
});
};
const closeJsonEditDialog = () => {
setJsonEditTarget(null);
setJsonEditDraft("");
setJsonEditError(null);
};
const openJsonEditDialog = (source: ParameterSource, parameterName: string) => {
const values = source === "llm" ? llmParamValues : presetParamValues;
const current = values[parameterName] ?? "";
let draft = current;
try {
draft = JSON.stringify(JSON.parse(current), null, 2);
} catch {
// Keep incomplete or invalid JSON as-is. Validation is triggered
// only when the user explicitly chooses Save or Format JSON.
}
setJsonEditTarget({ source, name: parameterName });
setJsonEditDraft(draft);
setJsonEditError(null);
};
const handleJsonEditDraftChange = (value: string) => {
setJsonEditDraft(value);
setJsonEditError(null);
};
const handleFormatJson = () => {
try {
const parsed = JSON.parse(jsonEditDraft);
setJsonEditDraft(JSON.stringify(parsed, null, 2));
setJsonEditError(null);
} catch (caughtError) {
setJsonEditError(caughtError instanceof Error ? caughtError.message : "Invalid JSON");
}
};
const handleSaveJsonEdit = () => {
if (jsonEditTarget === null) return;
const target = jsonEditTarget;
try {
const parsed = JSON.parse(jsonEditDraft);
const setValues = target.source === "llm" ? setLlmParamValues : setPresetParamValues;
setValues((previous) => ({ ...previous, [target.name]: JSON.stringify(parsed) }));
closeJsonEditDialog();
} catch (caughtError) {
setJsonEditError(caughtError instanceof Error ? caughtError.message : "Invalid JSON");
}
};
const handleTestTool = async () => {
try {
setIsTesting(true);
setTestError(null);
setResult(null);
const llmParams = parseTestParameterValues(parameters, llmParamValues);
const presetParams = parseTestParameterValues(presetParameters, presetParamValues);
const accessToken = await getAccessToken();
const response = await testToolApiV1ToolsToolUuidTestPost({
path: { tool_uuid: toolUuid },
headers: { Authorization: `Bearer ${accessToken}` },
body: {
llm_params: llmParams,
preset_params: presetParams,
},
});
if (response.error) {
setTestError(detailFromError(response.error, "Failed to test tool"));
return;
}
if (response.data) setResult(response.data);
} catch (caughtError) {
setTestError(caughtError instanceof Error ? caughtError.message : "Failed to test tool");
} finally {
setIsTesting(false);
}
};
const isSuccess =
result?.status === "success" &&
(result.status_code == null || (result.status_code >= 200 && result.status_code < 300));
const resultBadgeLabel = isSuccess ? "success" : result?.status === "success" ? "failed" : "error";
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-3xl grid-rows-[auto_minmax(0,1fr)]">
<DialogHeader>
<DialogTitle>Test Tool</DialogTitle>
<DialogDescription>
Run the saved configuration against the real endpoint.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 overflow-y-auto pr-1">
<div className="rounded-lg border bg-muted/40 p-4">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Request
</p>
<div className="flex items-start gap-3">
<span className="rounded bg-foreground px-2 py-1 font-mono text-xs font-semibold text-background">
{httpMethod}
</span>
<code className="break-all pt-0.5 text-sm">{url}</code>
</div>
</div>
{isUnsafeHttpMethod(httpMethod) && (
<div
role="alert"
className="flex gap-3 rounded-lg border border-amber-300 bg-amber-50 p-4 text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200"
>
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">This performs a real external request</p>
<p className="text-sm">
Testing sends an actual {httpMethod} request to this endpoint. Any configured
credential is used for the request, and the operation may modify external data.
</p>
</div>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Parameters</p>
<p className="text-xs text-muted-foreground">
Supply the values that would normally come from the model and configured presets.
</p>
</div>
{(parameters.length > 0 || presetParameters.length > 0) && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFillSampleValues}
>
Fill sample values
</Button>
)}
</div>
</div>
<div className="space-y-3 border-t pt-4">
<div>
<p className="text-sm font-medium">LLM Parameters</p>
<p className="text-xs text-muted-foreground">
Values the model would provide at call time.
</p>
</div>
<ParameterFields
idPrefix="llm-param"
parameters={parameters}
values={llmParamValues}
onValueChange={(name, value) =>
setLlmParamValues((previous) => ({ ...previous, [name]: value }))
}
onEditJson={(name) => openJsonEditDialog("llm", name)}
/>
</div>
<div className="space-y-3 border-t pt-4">
<div>
<p className="text-sm font-medium">Preset Parameters</p>
<p className="text-xs text-muted-foreground">
Resolved values that Dograh would normally derive from each configured preset.
</p>
</div>
<ParameterFields
idPrefix="preset-param"
parameters={presetParameters}
values={presetParamValues}
onValueChange={(name, value) =>
setPresetParamValues((previous) => ({ ...previous, [name]: value }))
}
onEditJson={(name) => openJsonEditDialog("preset", name)}
/>
</div>
<div className="flex justify-end">
<Button onClick={handleTestTool} disabled={isTesting}>
{isTesting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
"Test Tool"
)}
</Button>
</div>
{testError && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{testError}
</div>
)}
{result && (
<div className="space-y-3 border-t pt-4">
{(result.request_method || result.request_url) && (
<div className="space-y-1 overflow-auto rounded-lg bg-muted p-3 font-mono text-xs">
<p className="font-medium text-foreground">
{result.request_method} {result.request_url}
</p>
{result.request_headers && Object.keys(result.request_headers).length > 0 && (
<pre className="whitespace-pre-wrap">
Headers: {JSON.stringify(result.request_headers, null, 2)}
</pre>
)}
{result.request_body != null && (
<pre className="whitespace-pre-wrap">
Body: {JSON.stringify(result.request_body, null, 2)}
</pre>
)}
{result.request_params != null && (
<pre className="whitespace-pre-wrap">
Query:{" "}
{Object.entries(result.request_params)
.map(([key, value]) => `${key}=${value}`)
.join(" ")}
</pre>
)}
</div>
)}
<div className="flex items-center gap-3">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
isSuccess
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
}`}
>
<span>{isSuccess ? "✓" : "✗"}</span>
{resultBadgeLabel}
</span>
{result.status_code != null && (
<span className="text-sm text-muted-foreground">HTTP {result.status_code}</span>
)}
{result.duration_ms !== undefined && (
<span className="rounded bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground">
{result.duration_ms}ms
</span>
)}
</div>
{result.hint && (
<div className="rounded border border-amber-200 bg-amber-100 p-3 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-900/20 dark:text-amber-400">
{result.hint}
</div>
)}
{result.error && (
<div className="rounded border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{result.error}
</div>
)}
{result.data != null && (
<div className="max-h-80 overflow-auto rounded-lg bg-muted p-4 font-mono text-sm">
<pre>{JSON.stringify(result.data, null, 2)}</pre>
</div>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
<Dialog
open={jsonEditTarget !== null}
onOpenChange={(isOpen) => {
if (!isOpen) closeJsonEditDialog();
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit {jsonEditTarget?.name}</DialogTitle>
<DialogDescription>
Edit the JSON value sent for this parameter when testing.
</DialogDescription>
</DialogHeader>
<textarea
value={jsonEditDraft}
onChange={(event) => handleJsonEditDraftChange(event.target.value)}
rows={12}
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-sm shadow-sm"
spellCheck={false}
/>
{jsonEditError && <p className="text-sm text-destructive">{jsonEditError}</p>}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleFormatJson}
disabled={jsonEditDraft.length === 0}
>
Format JSON
</Button>
<Button type="button" variant="outline" onClick={closeJsonEditDialog}>
Cancel
</Button>
<Button type="button" onClick={handleSaveJsonEdit}>
Save
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
buildHttpToolTestSnapshot,
generateSampleValue,
type HttpToolTestSnapshotFields,
isUnsafeHttpMethod,
parseTestParameterValues,
} from "./helpers";
describe("generateSampleValue", () => {
it.each([
["string", "sample_text"],
["number", "5"],
["boolean", "true"],
["array", "[]"],
["object", "{}"],
] as const)("returns %s sample input", (type, expected) => {
expect(generateSampleValue(type)).toBe(expected);
});
});
describe("isUnsafeHttpMethod", () => {
it("treats GET as safe", () => {
expect(isUnsafeHttpMethod("GET")).toBe(false);
});
it.each(["POST", "PUT", "PATCH", "DELETE"] as const)("treats %s as unsafe", (method) => {
expect(isUnsafeHttpMethod(method)).toBe(true);
});
});
describe("parseTestParameterValues", () => {
it("converts input strings to configured parameter types", () => {
expect(
parseTestParameterValues(
[
{ name: "query", type: "string", required: true },
{ name: "limit", type: "number", required: true },
{ name: "enabled", type: "boolean", required: true },
{ name: "filters", type: "object", required: true },
{ name: "tags", type: "array", required: true },
],
{
query: "cart",
limit: "5",
enabled: "false",
filters: '{"status":"open"}',
tags: '["new"]',
}
)
).toEqual({
query: "cart",
limit: 5,
enabled: false,
filters: { status: "open" },
tags: ["new"],
});
});
it("reports the parameter containing invalid JSON", () => {
expect(() =>
parseTestParameterValues(
[{ name: "filters", type: "object", required: true }],
{ filters: "{" }
)
).toThrow("filters: invalid JSON");
});
it("rejects missing required values", () => {
expect(() =>
parseTestParameterValues(
[{ name: "customer_id", type: "string", required: true }],
{ customer_id: "" }
)
).toThrow("customer_id: value is required");
});
});
describe("buildHttpToolTestSnapshot", () => {
const base: HttpToolTestSnapshotFields = {
name: "Search API",
description: "Search for records",
httpMethod: "GET",
url: "https://api.example.com",
credentialUuid: "",
headers: [],
parameters: [],
presetParameters: [],
timeoutMs: 5000,
customMessage: "",
customMessageType: "text",
customMessageRecordingId: "",
};
it("produces identical output for identical fields", () => {
expect(buildHttpToolTestSnapshot(base)).toBe(buildHttpToolTestSnapshot({ ...base }));
});
it("changes when a saved HTTP field changes", () => {
expect(buildHttpToolTestSnapshot(base)).not.toBe(
buildHttpToolTestSnapshot({ ...base, url: "https://api.example.com/v2" })
);
expect(buildHttpToolTestSnapshot(base)).not.toBe(
buildHttpToolTestSnapshot({
...base,
parameters: [{ name: "q", type: "string", description: "", required: true }],
})
);
expect(buildHttpToolTestSnapshot(base)).not.toBe(
buildHttpToolTestSnapshot({ ...base, name: "Updated Search API" })
);
expect(buildHttpToolTestSnapshot(base)).not.toBe(
buildHttpToolTestSnapshot({ ...base, description: "Updated description" })
);
expect(buildHttpToolTestSnapshot(base)).not.toBe(
buildHttpToolTestSnapshot({ ...base, customMessage: "Done" })
);
});
});

View file

@ -0,0 +1,80 @@
import type { HttpMethod, KeyValueItem, ParameterType, PresetToolParameter, ToolParameter } from "@/components/http";
const TYPE_SAMPLE_VALUES: Record<ParameterType, string> = {
string: "sample_text",
number: "5",
boolean: "true",
array: "[]",
object: "{}",
};
/**
* Type-based sample value used to pre-fill test-dialog inputs. Parameters do
* not currently include a schema, so objects and arrays can only be seeded
* with valid empty JSON containers.
*/
export function generateSampleValue(type: ParameterType): string {
return TYPE_SAMPLE_VALUES[type];
}
/** Whether testing this method may change state on the external service. */
export function isUnsafeHttpMethod(method: HttpMethod): boolean {
return method !== "GET";
}
export function parseTestParameterValues(
parameters: Array<Pick<ToolParameter, "name" | "type" | "required">>,
values: Record<string, string>
): Record<string, unknown> {
const parsedValues: Record<string, unknown> = {};
for (const parameter of parameters) {
const rawValue = values[parameter.name];
if (rawValue === undefined || rawValue === "") {
if (parameter.required) throw new Error(`${parameter.name}: value is required`);
continue;
}
if (parameter.type === "number") {
parsedValues[parameter.name] = Number(rawValue);
} else if (parameter.type === "boolean") {
parsedValues[parameter.name] = rawValue === "true";
} else if (parameter.type === "object" || parameter.type === "array") {
try {
parsedValues[parameter.name] = JSON.parse(rawValue);
} catch {
throw new Error(`${parameter.name}: invalid JSON`);
}
} else {
parsedValues[parameter.name] = rawValue;
}
}
return parsedValues;
}
export type HttpToolTestSnapshotFields = {
name: string;
description: string;
httpMethod: HttpMethod;
url: string;
credentialUuid: string;
headers: KeyValueItem[];
parameters: ToolParameter[];
presetParameters: PresetToolParameter[];
timeoutMs: number;
customMessage: string;
customMessageType: "text" | "audio";
customMessageRecordingId: string;
};
/**
* Canonical string for HTTP API fields that affect the saved configuration
* used by Test Tool.
*/
export function buildHttpToolTestSnapshot(fields: HttpToolTestSnapshotFields): string {
const normalizedHeaders = Object.fromEntries(
fields.headers.filter((header) => header.key).map((header) => [header.key, header.value])
);
return JSON.stringify({ ...fields, headers: normalizedHeaders });
}

View file

@ -0,0 +1,8 @@
export {
buildHttpToolTestSnapshot,
generateSampleValue,
type HttpToolTestSnapshotFields,
isUnsafeHttpMethod,
parseTestParameterValues,
} from "./helpers";
export { HttpToolTestDialog } from "./HttpToolTestDialog";

View file

@ -1,4 +1,5 @@
export { BuiltinToolConfig, type BuiltinToolConfigProps } from "./BuiltinToolConfig";
export { EndCallToolConfig, type EndCallToolConfigProps } from "./EndCallToolConfig";
export { buildHttpToolTestSnapshot, HttpToolTestDialog } from "./http-tool-test";
export { HttpApiToolConfig, type HttpApiToolConfigProps } from "./HttpApiToolConfig";
export { TransferCallToolConfig, type TransferCallToolConfigProps } from "./TransferCallToolConfig";

View file

@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Code, ExternalLink, Loader2, Save } from "lucide-react";
import { ArrowLeft, Code, ExternalLink, FlaskConical, Loader2, Save } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
@ -38,6 +38,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TOOL_DOCUMENTATION_URLS } from "@/constants/documentation";
import { useOrgConfig } from "@/context/OrgConfigContext";
import { detailFromError } from "@/lib/apiError";
@ -56,7 +57,14 @@ import {
type ToolCategory,
type TransferDestinationSource,
} from "../config";
import { BuiltinToolConfig, EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
import {
buildHttpToolTestSnapshot,
BuiltinToolConfig,
EndCallToolConfig,
HttpApiToolConfig,
HttpToolTestDialog,
TransferCallToolConfig,
} from "./components";
function normalizeParameterType(value: string | null | undefined): ParameterType {
switch (value) {
@ -87,6 +95,8 @@ export default function ToolDetailPage() {
const [error, setError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const [showCodeDialog, setShowCodeDialog] = useState(false);
const [showTestDialog, setShowTestDialog] = useState(false);
const [savedHttpTestSnapshot, setSavedHttpTestSnapshot] = useState<string | null>(null);
// Common form state
const [name, setName] = useState("");
@ -279,52 +289,73 @@ export default function ToolDetailPage() {
// Populate HTTP API specific fields
const config = tool.definition?.config as HttpApiToolDefinition["config"] | undefined;
if (config) {
setHttpMethod((config.method as HttpMethod) || "POST");
setUrl(config.url || "");
setCredentialUuid(config.credential_uuid || "");
setTimeoutMs(config.timeout_ms || 5000);
setCustomMessage(config.customMessage || "");
setCustomMessageType(config.customMessageType || "text");
setCustomMessageRecordingId(config.customMessageRecordingId || "");
const loadedHttpMethod = (config.method as HttpMethod) || "POST";
const loadedUrl = config.url || "";
const loadedCredentialUuid = config.credential_uuid || "";
const loadedTimeoutMs = config.timeout_ms || 5000;
const loadedCustomMessage = config.customMessage || "";
const loadedCustomMessageType = config.customMessageType || "text";
const loadedCustomMessageRecordingId = config.customMessageRecordingId || "";
setHttpMethod(loadedHttpMethod);
setUrl(loadedUrl);
setCredentialUuid(loadedCredentialUuid);
setTimeoutMs(loadedTimeoutMs);
setCustomMessage(loadedCustomMessage);
setCustomMessageType(loadedCustomMessageType);
setCustomMessageRecordingId(loadedCustomMessageRecordingId);
// Convert headers object to array
if (config.headers) {
setHeaders(
Object.entries(config.headers).map(([key, value]) => ({
key,
value: value as string,
}))
);
} else {
setHeaders([]);
}
const loadedHeaders = config.headers
? Object.entries(config.headers).map(([key, value]) => ({
key,
value: value as string,
}))
: [];
setHeaders(loadedHeaders);
// Load parameters
let loadedParameters: ToolParameter[] = [];
if (config.parameters && Array.isArray(config.parameters)) {
setParameters(
config.parameters.map((p) => ({
name: p.name || "",
type: normalizeParameterType(p.type),
description: p.description || "",
required: p.required ?? true,
}))
);
loadedParameters = config.parameters.map((p) => ({
name: p.name || "",
type: normalizeParameterType(p.type),
description: p.description || "",
required: p.required ?? true,
}));
setParameters(loadedParameters);
} else {
setParameters([]);
}
let loadedPresetParameters: PresetToolParameter[] = [];
if (config.preset_parameters && Array.isArray(config.preset_parameters)) {
setPresetParameters(
config.preset_parameters.map((p) => ({
name: p.name || "",
type: normalizeParameterType(p.type),
valueTemplate: p.value_template || "",
required: p.required ?? true,
}))
);
loadedPresetParameters = config.preset_parameters.map((p) => ({
name: p.name || "",
type: normalizeParameterType(p.type),
valueTemplate: p.value_template || "",
required: p.required ?? true,
}));
setPresetParameters(loadedPresetParameters);
} else {
setPresetParameters([]);
}
setSavedHttpTestSnapshot(
buildHttpToolTestSnapshot({
name: tool.name,
description: tool.description || "",
httpMethod: loadedHttpMethod,
url: loadedUrl,
credentialUuid: loadedCredentialUuid,
headers: loadedHeaders,
parameters: loadedParameters,
presetParameters: loadedPresetParameters,
timeoutMs: loadedTimeoutMs,
customMessage: loadedCustomMessage,
customMessageType: loadedCustomMessageType,
customMessageRecordingId: loadedCustomMessageRecordingId,
})
);
}
}
};
@ -443,6 +474,11 @@ export default function ToolDetailPage() {
setError("All parameters must have a name");
return;
}
const paramNames = parameters.map((p) => p.name.trim()).filter(Boolean);
if (new Set(paramNames).size !== paramNames.length) {
setError("Parameter names must be unique");
return;
}
const invalidPresetParams = presetParameters.filter(
(p) => !p.name.trim() || !p.valueTemplate.trim()
@ -628,6 +664,24 @@ export default function ToolDetailPage() {
setTool(response.data);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
if (tool.category === "http_api") {
setSavedHttpTestSnapshot(
buildHttpToolTestSnapshot({
name,
description,
httpMethod,
url,
credentialUuid,
headers,
parameters,
presetParameters,
timeoutMs,
customMessage,
customMessageType,
customMessageRecordingId,
})
);
}
}
} catch (err) {
setError("Failed to save tool");
@ -729,6 +783,24 @@ const data = await response.json();`;
const isTransferCallTool = tool.category === "transfer_call";
const isBuiltinTool = tool.category === "calculator";
const isMcpTool = tool.category === "mcp";
const isHttpApiTool = tool.category === "http_api";
const hasUnsavedHttpChanges =
isHttpApiTool &&
(savedHttpTestSnapshot === null ||
buildHttpToolTestSnapshot({
name,
description,
httpMethod,
url,
credentialUuid,
headers,
parameters,
presetParameters,
timeoutMs,
customMessage,
customMessageType,
customMessageRecordingId,
}) !== savedHttpTestSnapshot);
const categoryConfig = getCategoryConfig(tool.category as ToolCategory);
return (
@ -764,7 +836,7 @@ const data = await response.json();`;
</div>
</div>
<div className="flex items-center gap-2">
{!isEndCallTool && !isTransferCallTool && !isBuiltinTool && !isMcpTool && (
{isHttpApiTool && (
<Button
variant="outline"
onClick={() => setShowCodeDialog(true)}
@ -958,6 +1030,18 @@ const data = await response.json();`;
/>
)}
{isHttpApiTool && (
<HttpToolTestDialog
open={showTestDialog}
onOpenChange={setShowTestDialog}
toolUuid={toolUuid}
httpMethod={httpMethod}
url={url}
parameters={parameters}
presetParameters={presetParameters}
/>
)}
{error && (
<div className="mt-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
{error}
@ -970,7 +1054,34 @@ const data = await response.json();`;
</div>
)}
<div className="flex justify-end mt-6">
<div className="flex justify-end gap-2 mt-6">
{isHttpApiTool && (
hasUnsavedHttpChanges ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" tabIndex={0}>
<Button type="button" variant="outline" disabled>
<FlaskConical className="w-4 h-4 mr-2" />
Test Tool
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Save the tool before testing.
</TooltipContent>
</Tooltip>
) : (
<Button
type="button"
variant="outline"
onClick={() => setShowTestDialog(true)}
disabled={isSaving}
>
<FlaskConical className="w-4 h-4 mr-2" />
Test Tool
</Button>
)
)}
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
@ -1002,6 +1113,7 @@ const data = await response.json();`;
</div>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -117,6 +117,7 @@ function RenderWorkflow({
onConnect,
onEdgesChange,
onNodesChange,
onDelete,
} = useWorkflowState({
initialWorkflowName,
workflowId,
@ -514,6 +515,7 @@ function RenderWorkflow({
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onDelete={onDelete}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={isViewingHistoricalVersion ? undefined : onConnect}

View file

@ -136,6 +136,7 @@ export const useWorkflowState = ({
templateContextVariables,
workflowConfigurations,
initializeWorkflow,
commitDeletion,
setNodes,
setEdges,
setWorkflowName,
@ -493,6 +494,10 @@ export const useWorkflowState = ({
[setNodes],
);
const onDelete = useCallback(() => {
commitDeletion();
}, [commitDeletion]);
const onRun = async (mode: string) => {
if (!user?.id) return;
const workflowRunName = `WR-${getRandomId()}`;
@ -638,6 +643,7 @@ export const useWorkflowState = ({
onConnect,
onEdgesChange,
onNodesChange,
onDelete,
onRun,
saveTemplateContextVariables,
saveWorkflowConfigurations,

View file

@ -661,6 +661,11 @@ function GeneralSection({
</Select>
<p className="text-xs text-muted-foreground">
{selectedTurnStartStrategy?.description}
{turnStartStrategy === "provisional_vad" && (
<span className="ml-2 inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
Experimental
</span>
)}
</p>
</div>
{turnStartStrategy === "min_words" && (

View file

@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { FlowEdge, FlowNode } from '@/components/flow/types';
import { useWorkflowStore } from './workflowStore';
const createNode = (id: string, x = 0): FlowNode => ({
id,
type: 'agentNode',
position: { x, y: 0 },
data: { name: id },
});
const createEdge = (id: string, source: string, target: string): FlowEdge => ({
id,
source,
target,
data: { condition: 'always', label: id },
});
const nodeIds = () => useWorkflowStore.getState().nodes.map((node) => node.id);
describe('workflow history', () => {
beforeEach(() => {
useWorkflowStore.getState().clearStore();
useWorkflowStore.getState().initializeWorkflow(1, 'Initial', [], []);
});
it('keeps every committed node state available to undo and redo', () => {
const store = useWorkflowStore.getState();
store.addNode(createNode('a'));
store.addNode(createNode('b'));
store.undo();
expect(nodeIds()).toEqual(['a']);
store.undo();
expect(nodeIds()).toEqual([]);
store.redo();
store.redo();
expect(nodeIds()).toEqual(['a', 'b']);
});
it('undoes a node and connected-edge deletion in one step', () => {
const firstNode = createNode('a');
const secondNode = createNode('b');
const edge = createEdge('a-b', 'a', 'b');
useWorkflowStore.getState().initializeWorkflow(
1,
'Initial',
[firstNode, secondNode],
[edge]
);
// React Flow emits connected-edge removals before node removals, then onDelete.
useWorkflowStore.getState().setEdges(
[],
[{ id: edge.id, type: 'remove' }]
);
useWorkflowStore.getState().setNodes(
[secondNode],
[{ id: firstNode.id, type: 'remove' }]
);
useWorkflowStore.getState().commitDeletion();
expect(nodeIds()).toEqual(['b']);
expect(useWorkflowStore.getState().edges).toEqual([]);
expect(useWorkflowStore.getState().history).toHaveLength(2);
useWorkflowStore.getState().undo();
expect(nodeIds()).toEqual(['a', 'b']);
expect(useWorkflowStore.getState().edges).toEqual([edge]);
useWorkflowStore.getState().redo();
expect(nodeIds()).toEqual(['b']);
expect(useWorkflowStore.getState().edges).toEqual([]);
});
it('coalesces active dragging into one committed final position', () => {
const initialNode = createNode('a');
useWorkflowStore.getState().initializeWorkflow(1, 'Initial', [initialNode], []);
for (const x of [10, 20]) {
useWorkflowStore.getState().setNodes(
[createNode('a', x)],
[{ id: 'a', type: 'position', position: { x, y: 0 }, dragging: true }]
);
}
useWorkflowStore.getState().setNodes(
[createNode('a', 30)],
[{ id: 'a', type: 'position', position: { x: 30, y: 0 }, dragging: false }]
);
expect(useWorkflowStore.getState().history).toHaveLength(2);
useWorkflowStore.getState().undo();
expect(useWorkflowStore.getState().nodes[0].position.x).toBe(0);
useWorkflowStore.getState().redo();
expect(useWorkflowStore.getState().nodes[0].position.x).toBe(30);
});
it('truncates redo history after a new edit', () => {
useWorkflowStore.getState().addNode(createNode('a'));
useWorkflowStore.getState().addNode(createNode('b'));
useWorkflowStore.getState().undo();
useWorkflowStore.getState().addNode(createNode('c'));
expect(nodeIds()).toEqual(['a', 'c']);
expect(useWorkflowStore.getState().canRedo()).toBe(false);
useWorkflowStore.getState().undo();
expect(nodeIds()).toEqual(['a']);
useWorkflowStore.getState().redo();
expect(nodeIds()).toEqual(['a', 'c']);
});
it('keeps the current state at the end of the bounded history', () => {
for (let index = 1; index <= 55; index += 1) {
useWorkflowStore.getState().setWorkflowName(`Edit ${index}`);
}
expect(useWorkflowStore.getState().history).toHaveLength(50);
expect(useWorkflowStore.getState().historyIndex).toBe(49);
let undoCount = 0;
while (useWorkflowStore.getState().canUndo()) {
useWorkflowStore.getState().undo();
undoCount += 1;
}
expect(undoCount).toBe(49);
expect(useWorkflowStore.getState().workflowName).toBe('Edit 6');
while (useWorkflowStore.getState().canRedo()) {
useWorkflowStore.getState().redo();
}
expect(useWorkflowStore.getState().workflowName).toBe('Edit 55');
});
it('does not track selection-only changes', () => {
const initialNode = createNode('a');
useWorkflowStore.getState().initializeWorkflow(1, 'Initial', [initialNode], []);
useWorkflowStore.getState().setNodes(
[{ ...initialNode, selected: true }],
[{ id: 'a', type: 'select', selected: true }]
);
expect(useWorkflowStore.getState().nodes[0].selected).toBe(true);
expect(useWorkflowStore.getState().history).toHaveLength(1);
expect(useWorkflowStore.getState().isDirty).toBe(false);
});
});

View file

@ -54,7 +54,7 @@ interface WorkflowActions {
) => void;
// History management
pushToHistory: () => void;
commitDeletion: () => void;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
@ -99,6 +99,30 @@ type WorkflowStore = WorkflowState & WorkflowActions;
const MAX_HISTORY_SIZE = 50;
const commitHistory = (
state: Pick<WorkflowState, 'history' | 'historyIndex'>,
snapshot: HistoryState
): Pick<
WorkflowState,
'nodes' | 'edges' | 'workflowName' | 'history' | 'historyIndex' | 'isDirty'
> => {
const history = [
...state.history.slice(0, state.historyIndex + 1),
snapshot,
];
if (history.length > MAX_HISTORY_SIZE) {
history.shift();
}
return {
...snapshot,
history,
historyIndex: history.length - 1,
isDirty: true,
};
};
// Create the store
export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
// Initial state
@ -134,27 +158,14 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
});
},
pushToHistory: () => {
const state = get();
const currentState: HistoryState = {
nodes: state.nodes,
edges: state.edges,
workflowName: state.workflowName,
};
// Remove any forward history if we're not at the end
const newHistory = state.history.slice(0, state.historyIndex + 1);
newHistory.push(currentState);
// Limit history size
if (newHistory.length > MAX_HISTORY_SIZE) {
newHistory.shift();
}
set({
history: newHistory,
historyIndex: newHistory.length - 1,
});
commitDeletion: () => {
set((state) =>
commitHistory(state, {
nodes: state.nodes,
edges: state.edges,
workflowName: state.workflowName,
})
);
},
undo: () => {
@ -200,10 +211,10 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
setNodes: (nodes, changes) => {
// Determine whether to push to history and set isDirty based on change types
if (changes && changes.length > 0) {
// Check for add/remove changes (always push to history)
const hasAddRemoveChanges = changes.some(change =>
change.type === 'add' || change.type === 'remove'
);
const hasAddChanges = changes.some(change => change.type === 'add');
// React Flow emits edge and node removals separately. They are committed
// together from onDelete after both live-state updates have completed.
const hasRemoveChanges = changes.some(change => change.type === 'remove');
// Check for position changes - only push to history when drag ENDS (dragging: false)
// but still mark as dirty during dragging
@ -214,11 +225,16 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
change.type === 'position' && change.dragging === true
);
if (hasAddRemoveChanges || hasDragEndChanges) {
get().pushToHistory();
set({ nodes, isDirty: true });
} else if (isActiveDragging) {
// During active dragging, update nodes but don't push to history
if (hasAddChanges || hasDragEndChanges) {
set((state) =>
commitHistory(state, {
nodes,
edges: state.edges,
workflowName: state.workflowName,
})
);
} else if (hasRemoveChanges || isActiveDragging) {
// During active dragging or deletion, update nodes before committing.
set({ nodes, isDirty: true });
} else {
// For selection changes or dimension updates, don't push to history or set dirty
@ -231,49 +247,62 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
},
addNode: (node) => {
const state = get();
get().pushToHistory();
set({
nodes: [...state.nodes, node],
isDirty: true
set((state) => {
const nodes = [...state.nodes, node];
return commitHistory(state, {
nodes,
edges: state.edges,
workflowName: state.workflowName,
});
});
},
updateNode: (nodeId, updates) => {
const state = get();
get().pushToHistory();
set({
nodes: state.nodes.map((node) =>
set((state) => {
const nodes = state.nodes.map((node) =>
node.id === nodeId ? { ...node, ...updates } : node
),
isDirty: true,
);
return commitHistory(state, {
nodes,
edges: state.edges,
workflowName: state.workflowName,
});
});
},
deleteNode: (nodeId) => {
const state = get();
get().pushToHistory();
set({
nodes: state.nodes.filter((node) => node.id !== nodeId),
edges: state.edges.filter(
set((state) => {
const nodes = state.nodes.filter((node) => node.id !== nodeId);
const edges = state.edges.filter(
(edge) => edge.source !== nodeId && edge.target !== nodeId
),
isDirty: true,
);
return commitHistory(state, {
nodes,
edges,
workflowName: state.workflowName,
});
});
},
setEdges: (edges, changes) => {
// Determine whether to push to history and set isDirty based on change types
if (changes && changes.length > 0) {
// Check if any changes are user-initiated (not just selections)
const hasDirtyChanges = changes.some(change =>
const hasImmediateHistoryChanges = changes.some(change =>
change.type === 'add' ||
change.type === 'remove' ||
change.type === 'replace'
);
const hasRemoveChanges = changes.some(change => change.type === 'remove');
if (hasDirtyChanges) {
get().pushToHistory();
if (hasImmediateHistoryChanges) {
set((state) =>
commitHistory(state, {
nodes: state.nodes,
edges,
workflowName: state.workflowName,
})
);
} else if (hasRemoveChanges) {
// React Flow calls onDelete after all edge and node removals.
set({ edges, isDirty: true });
} else {
// For selection changes, don't push to history
@ -286,37 +315,48 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
},
addEdge: (edge) => {
const state = get();
get().pushToHistory();
set({
edges: [...state.edges, edge],
isDirty: true
set((state) => {
const edges = [...state.edges, edge];
return commitHistory(state, {
nodes: state.nodes,
edges,
workflowName: state.workflowName,
});
});
},
updateEdge: (edgeId, updates) => {
const state = get();
get().pushToHistory();
set({
edges: state.edges.map((edge) =>
set((state) => {
const edges = state.edges.map((edge) =>
edge.id === edgeId ? { ...edge, ...updates } : edge
),
isDirty: true,
);
return commitHistory(state, {
nodes: state.nodes,
edges,
workflowName: state.workflowName,
});
});
},
deleteEdge: (edgeId) => {
const state = get();
get().pushToHistory();
set({
edges: state.edges.filter((edge) => edge.id !== edgeId),
isDirty: true,
set((state) => {
const edges = state.edges.filter((edge) => edge.id !== edgeId);
return commitHistory(state, {
nodes: state.nodes,
edges,
workflowName: state.workflowName,
});
});
},
setWorkflowName: (workflowName) => {
get().pushToHistory();
set({ workflowName, isDirty: true });
set((state) =>
commitHistory(state, {
nodes: state.nodes,
edges: state.edges,
workflowName,
})
);
},
setTemplateContextVariables: (templateContextVariables) => {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6040,6 +6040,88 @@ export type ToolResponse = {
created_by?: CreatedByResponse | null;
};
/**
* ToolTestRequest
*
* Request body for testing an HTTP API tool outside a live call.
*/
export type ToolTestRequest = {
/**
* Llm Params
*
* Values for parameters normally supplied by the model.
*/
llm_params?: {
[key: string]: unknown;
};
/**
* Preset Params
*
* Resolved values for parameters normally supplied from presets.
*/
preset_params?: {
[key: string]: unknown;
};
};
/**
* ToolTestResponse
*
* Result of testing an HTTP API tool.
*/
export type ToolTestResponse = {
/**
* Status
*/
status: string;
/**
* Status Code
*/
status_code?: number | null;
/**
* Data
*/
data?: unknown | null;
/**
* Error
*/
error?: string | null;
/**
* Hint
*/
hint?: string | null;
/**
* Request Method
*/
request_method: string;
/**
* Request Url
*/
request_url: string;
/**
* Request Headers
*/
request_headers?: {
[key: string]: string;
};
/**
* Request Body
*/
request_body?: {
[key: string]: unknown;
} | null;
/**
* Request Params
*/
request_params?: {
[key: string]: unknown;
} | null;
/**
* Duration Ms
*/
duration_ms: number;
};
/**
* TransferCallConfig
*
@ -10856,6 +10938,50 @@ export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses = {
export type RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponse = RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses[keyof RefreshMcpToolsApiV1ToolsToolUuidMcpRefreshPostResponses];
export type TestToolApiV1ToolsToolUuidTestPostData = {
body: ToolTestRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Tool Uuid
*/
tool_uuid: string;
};
query?: never;
url: '/api/v1/tools/{tool_uuid}/test';
};
export type TestToolApiV1ToolsToolUuidTestPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type TestToolApiV1ToolsToolUuidTestPostError = TestToolApiV1ToolsToolUuidTestPostErrors[keyof TestToolApiV1ToolsToolUuidTestPostErrors];
export type TestToolApiV1ToolsToolUuidTestPostResponses = {
/**
* Successful Response
*/
200: ToolTestResponse;
};
export type TestToolApiV1ToolsToolUuidTestPostResponse = TestToolApiV1ToolsToolUuidTestPostResponses[keyof TestToolApiV1ToolsToolUuidTestPostResponses];
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostData = {
body?: never;
headers?: {