From 6a6a2b865b2f4fa418f7c72f6ee50f2a9e1329a7 Mon Sep 17 00:00:00 2001
From: RockChinQ
Date: Tue, 25 Aug 2026 15:52:59 +0800
Subject: [PATCH] fix(web): make processor debugging reliable
---
src/langbot/pkg/agent/runner/errors.py | 16 +-
src/langbot/pkg/agent/runner/invoker.py | 10 +-
.../pkg/agent/runner/result_normalizer.py | 6 +-
.../pkg/api/http/controller/groups/agents.py | 37 ++++
.../pipeline/cntfilter/filters/banwords.py | 2 +-
src/langbot/pkg/utils/safe_regex.py | 5 +-
.../agent/test_orchestrator_integration.py | 17 +-
.../agent/test_result_normalizer.py | 7 +
.../unit_tests/api/test_agents_controller.py | 27 +++
tests/unit_tests/utils/test_safe_regex.py | 19 ++
.../app/home/agents/AgentDetailContent.tsx | 19 +-
.../agents/components/AgentCreateContent.tsx | 7 +-
.../agents/components/AgentDebugPanel.tsx | 152 ++++++++++++--
.../agents/components/AgentFormComponent.tsx | 192 ++++++++++++++----
.../components/home-sidebar/HomeSidebar.tsx | 11 +
web/src/app/home/layout.tsx | 2 +-
.../home/pipelines/PipelineDetailContent.tsx | 13 +-
.../components/debug-dialog/DebugDialog.tsx | 117 ++++++++---
.../pipeline-form/PipelineFormComponent.tsx | 98 ++++++---
web/src/components/ui/sidebar.tsx | 4 +-
web/src/i18n/locales/en-US.ts | 19 +-
web/src/i18n/locales/zh-Hans.ts | 17 +-
web/tests/e2e/fixtures/langbot-api.ts | 28 +++
.../e2e/processor-detail-workbench.spec.ts | 123 ++++++++++-
24 files changed, 788 insertions(+), 160 deletions(-)
diff --git a/src/langbot/pkg/agent/runner/errors.py b/src/langbot/pkg/agent/runner/errors.py
index 1755eff9f..bbf636fad 100644
--- a/src/langbot/pkg/agent/runner/errors.py
+++ b/src/langbot/pkg/agent/runner/errors.py
@@ -1,14 +1,17 @@
"""Agent runner errors."""
+
from __future__ import annotations
class AgentRunnerError(Exception):
"""Base error for agent runner operations."""
+
pass
class RunnerNotFoundError(AgentRunnerError):
"""Runner not found in registry."""
+
def __init__(self, runner_id: str):
self.runner_id = runner_id
super().__init__(f'Agent runner not found: {runner_id}')
@@ -16,6 +19,7 @@ class RunnerNotFoundError(AgentRunnerError):
class RunnerNotAuthorizedError(AgentRunnerError):
"""Runner not authorized for this binding."""
+
def __init__(self, runner_id: str, bound_plugins: list[str] | None):
self.runner_id = runner_id
self.bound_plugins = bound_plugins
@@ -24,6 +28,7 @@ class RunnerNotAuthorizedError(AgentRunnerError):
class RunnerProtocolError(AgentRunnerError):
"""Runner protocol version mismatch or invalid manifest."""
+
def __init__(self, runner_id: str, message: str):
self.runner_id = runner_id
super().__init__(f'Agent runner protocol error for {runner_id}: {message}')
@@ -31,7 +36,16 @@ class RunnerProtocolError(AgentRunnerError):
class RunnerExecutionError(AgentRunnerError):
"""Runner execution failed."""
- def __init__(self, runner_id: str, message: str, retryable: bool = False):
+
+ def __init__(
+ self,
+ runner_id: str,
+ message: str,
+ retryable: bool = False,
+ error_code: str | None = None,
+ ):
self.runner_id = runner_id
+ self.message = message
self.retryable = retryable
+ self.error_code = error_code
super().__init__(f'Agent runner {runner_id} execution failed: {message}')
diff --git a/src/langbot/pkg/agent/runner/invoker.py b/src/langbot/pkg/agent/runner/invoker.py
index 4f45747b6..89285bfb9 100644
--- a/src/langbot/pkg/agent/runner/invoker.py
+++ b/src/langbot/pkg/agent/runner/invoker.py
@@ -58,21 +58,21 @@ class AgentRunnerInvoker:
except asyncio.TimeoutError as e:
raise RunnerExecutionError(
descriptor.id,
- 'Runner timed out (code: runner.timeout)',
+ 'Runner timed out',
retryable=True,
+ error_code='runner.timeout',
) from e
except ActionCallTimeoutError as e:
raise RunnerExecutionError(
descriptor.id,
- f'{e} (code: runner.timeout)',
+ str(e),
retryable=True,
+ error_code='runner.timeout',
) from e
except RunnerExecutionError:
raise
except Exception as e:
- self.ap.logger.error(
- f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}'
- )
+ self.ap.logger.error(f'Runner {descriptor.id} unexpected error: {traceback.format_exc()}')
raise RunnerExecutionError(
descriptor.id,
str(e),
diff --git a/src/langbot/pkg/agent/runner/result_normalizer.py b/src/langbot/pkg/agent/runner/result_normalizer.py
index bb47c873a..d06cd1034 100644
--- a/src/langbot/pkg/agent/runner/result_normalizer.py
+++ b/src/langbot/pkg/agent/runner/result_normalizer.py
@@ -152,10 +152,14 @@ class AgentResultNormalizer:
error_msg = data.get('error', 'Unknown error')
error_code = data.get('code', 'unknown')
retryable = data.get('retryable', False)
+ normalized_error_code = str(error_code or '').strip()
raise RunnerExecutionError(
descriptor.id,
- f'{error_msg} (code: {error_code})',
+ str(error_msg),
retryable=retryable,
+ error_code=(
+ normalized_error_code if normalized_error_code and normalized_error_code != 'unknown' else None
+ ),
)
elif result_type == 'action.requested':
diff --git a/src/langbot/pkg/api/http/controller/groups/agents.py b/src/langbot/pkg/api/http/controller/groups/agents.py
index 0584d62a1..4722d9135 100644
--- a/src/langbot/pkg/api/http/controller/groups/agents.py
+++ b/src/langbot/pkg/api/http/controller/groups/agents.py
@@ -2,6 +2,13 @@ from __future__ import annotations
import quart
+from .....agent.runner.errors import (
+ AgentRunnerError,
+ RunnerExecutionError,
+ RunnerNotAuthorizedError,
+ RunnerNotFoundError,
+ RunnerProtocolError,
+)
from ...authz import Permission, require_permission
from ...context import RequestContext
from .. import group
@@ -63,6 +70,36 @@ class AgentsRouterGroup(group.RouterGroup):
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
+ except RunnerExecutionError as exc:
+ return self.http_status(
+ 422,
+ exc.error_code or 'runner_execution_failed',
+ exc.message,
+ )
+ except RunnerNotFoundError:
+ return self.http_status(
+ 409,
+ 'runner_not_found',
+ 'The configured Agent runner is unavailable',
+ )
+ except RunnerNotAuthorizedError:
+ return self.http_status(
+ 403,
+ 'runner_not_authorized',
+ 'The configured Agent runner is not authorized',
+ )
+ except RunnerProtocolError:
+ return self.http_status(
+ 502,
+ 'runner_protocol_error',
+ 'The Agent runner returned an invalid response',
+ )
+ except AgentRunnerError:
+ return self.http_status(
+ 502,
+ 'runner_error',
+ 'The Agent runner could not complete this test',
+ )
return self.success(data=result)
@self.route(
diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py
index 39796b33c..889e34ac7 100644
--- a/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py
+++ b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py
@@ -25,7 +25,7 @@ class BanWordFilter(filter_model.ContentFilter):
return entities.FilterResult(
level=entities.ResultLevel.BLOCK,
replacement='',
- user_notice='内容检查规则执行失败,请联系管理员',
+ user_notice='内容安全检查配置有误,请检查敏感词设置',
console_notice=f'Sensitive-word regex rejected: {exc}',
)
diff --git a/src/langbot/pkg/utils/safe_regex.py b/src/langbot/pkg/utils/safe_regex.py
index 32ae27843..24fa4cf49 100644
--- a/src/langbot/pkg/utils/safe_regex.py
+++ b/src/langbot/pkg/utils/safe_regex.py
@@ -7,7 +7,10 @@ from collections.abc import Sequence
import regex
-MAX_PATTERN_COUNT = 64
+# The bundled sensitive-word list already contains more than 64 entries. Keep
+# the deterministic cap, but leave enough room for the built-in defaults and
+# reasonable administrator customisation.
+MAX_PATTERN_COUNT = 256
MAX_PATTERN_CHARS = 1024
MAX_INPUT_CHARS = 1024 * 1024
MAX_REPLACEMENT_CHARS = 64
diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py
index e7db121bb..f8c5f06da 100644
--- a/tests/unit_tests/agent/test_orchestrator_integration.py
+++ b/tests/unit_tests/agent/test_orchestrator_integration.py
@@ -901,7 +901,7 @@ async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state):
[message async for message in orchestrator.run_from_query(query)]
assert exc_info.value.retryable is True
- assert 'runner.timeout' in str(exc_info.value)
+ assert exc_info.value.error_code == 'runner.timeout'
assert await get_session_registry().list_active_runs() == []
@@ -1012,6 +1012,7 @@ class TestQueryEntrySessionQueryId:
]
)
ap = FakeApplication(plugin_connector, db_engine)
+
async def build_resource_context(execution_query):
from langbot.pkg.provider.tools.loaders.mcp import (
_execution_context_from_query,
@@ -1025,9 +1026,7 @@ class TestQueryEntrySessionQueryId:
return 'Pinned documentation'
mcp_loader = types.SimpleNamespace(
- build_resource_context_for_query=AsyncMock(
- side_effect=build_resource_context
- )
+ build_resource_context_for_query=AsyncMock(side_effect=build_resource_context)
)
ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
@@ -1105,14 +1104,8 @@ class TestQueryEntrySessionQueryId:
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
assert event.input.text == 'hello'
assert event.input.contents[0].text == 'hello'
- assert (
- plugin_connector.contexts[0]['conversation']['workspace_id']
- == TEST_CONTEXT.workspace_uuid
- )
- assert (
- plugin_connector.contexts[0]['runtime']['metadata']['workspace_id']
- == TEST_CONTEXT.workspace_uuid
- )
+ assert plugin_connector.contexts[0]['conversation']['workspace_id'] == TEST_CONTEXT.workspace_uuid
+ assert plugin_connector.contexts[0]['runtime']['metadata']['workspace_id'] == TEST_CONTEXT.workspace_uuid
assert 'Pinned documentation' not in str(execution_query.user_message.content)
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
diff --git a/tests/unit_tests/agent/test_result_normalizer.py b/tests/unit_tests/agent/test_result_normalizer.py
index e276bd059..69a60ab39 100644
--- a/tests/unit_tests/agent/test_result_normalizer.py
+++ b/tests/unit_tests/agent/test_result_normalizer.py
@@ -1,4 +1,5 @@
"""Tests for agent runner result normalizer."""
+
from __future__ import annotations
import pytest
@@ -12,6 +13,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
class FakeApplication:
"""Fake Application for testing."""
+
def __init__(self):
class FakeLogger:
def __init__(self):
@@ -19,10 +21,13 @@ class FakeApplication:
def info(self, msg):
pass
+
def debug(self, msg):
pass
+
def warning(self, msg):
self.warnings.append(msg)
+
def error(self, msg):
pass
@@ -192,6 +197,7 @@ class TestNormalizeRunFailed:
assert exc_info.value.runner_id == 'plugin:langbot-team/LocalAgent/default'
assert exc_info.value.retryable is True
+ assert exc_info.value.error_code == 'upstream.timeout'
assert 'timeout' in str(exc_info.value)
@@ -290,6 +296,7 @@ class TestNormalizeNonMessageResults:
assert result is None
assert app.logger.warnings
+
class TestNormalizeInvalidResults:
"""Tests for handling invalid results."""
diff --git a/tests/unit_tests/api/test_agents_controller.py b/tests/unit_tests/api/test_agents_controller.py
index 224192c0a..7f0f111e8 100644
--- a/tests/unit_tests/api/test_agents_controller.py
+++ b/tests/unit_tests/api/test_agents_controller.py
@@ -9,6 +9,8 @@ from unittest.mock import ANY, AsyncMock
import pytest
import quart
+from langbot.pkg.agent.runner.errors import RunnerExecutionError
+
core_app_module = types.ModuleType('langbot.pkg.core.app')
core_app_module.Application = object
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
@@ -142,3 +144,28 @@ async def test_debug_agent_returns_bad_request_for_invalid_event():
'code': -1,
'msg': 'Invalid event_type',
}
+
+
+async def test_debug_agent_returns_actionable_runner_error():
+ agent_service = SimpleNamespace(
+ debug_agent=AsyncMock(
+ side_effect=RunnerExecutionError(
+ 'plugin:langbot-team/DifyAgent/default',
+ 'api-key is required',
+ error_code='dify.config_invalid',
+ )
+ ),
+ )
+ client = await _create_test_client(agent_service)
+
+ response = await client.post(
+ '/api/v1/agents/agent-1/debug',
+ json={'event_type': 'message.received', 'text': 'hello'},
+ headers={'Authorization': 'Bearer test-token'},
+ )
+
+ assert response.status_code == 422
+ assert await response.get_json() == {
+ 'code': 'dify.config_invalid',
+ 'msg': 'api-key is required',
+ }
diff --git a/tests/unit_tests/utils/test_safe_regex.py b/tests/unit_tests/utils/test_safe_regex.py
index 6f0f985f5..c19fb10c4 100644
--- a/tests/unit_tests/utils/test_safe_regex.py
+++ b/tests/unit_tests/utils/test_safe_regex.py
@@ -1,6 +1,8 @@
from __future__ import annotations
+import json
import threading
+from pathlib import Path
import pytest
@@ -53,6 +55,23 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
)
+@pytest.mark.asyncio
+async def test_bundled_sensitive_words_fit_within_pattern_limit():
+ config_path = Path(__file__).parents[3] / 'src/langbot/templates/metadata/sensitive-words.json'
+ config = json.loads(config_path.read_text())
+
+ assert len(config['words']) <= safe_regex.MAX_PATTERN_COUNT
+ found, masked = await safe_regex.mask_patterns(
+ config['words'],
+ '普通消息',
+ mask=config['mask'],
+ mask_word=config['mask_word'],
+ )
+
+ assert found is False
+ assert masked == '普通消息'
+
+
@pytest.mark.asyncio
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
found, masked = await safe_regex.mask_patterns(
diff --git a/web/src/app/home/agents/AgentDetailContent.tsx b/web/src/app/home/agents/AgentDetailContent.tsx
index 133f6459b..ec69b5700 100644
--- a/web/src/app/home/agents/AgentDetailContent.tsx
+++ b/web/src/app/home/agents/AgentDetailContent.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -10,6 +10,7 @@ import PipelineDetailContent from '@/app/home/pipelines/PipelineDetailContent';
import AgentCreateContent from './components/AgentCreateContent';
import AgentDebugPanel from './components/AgentDebugPanel';
import AgentFormComponent, {
+ AgentFormHandle,
AgentRunnerStatus,
} from './components/AgentFormComponent';
@@ -30,6 +31,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
const [runnerStatus, setRunnerStatus] = useState(
null,
);
+ const agentFormRef = useRef(null);
useEffect(() => {
if (isCreateMode) {
@@ -89,7 +91,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
return (
{
+ onFinish={(updatedAgent) => {
+ if (updatedAgent) {
+ setAgent((current) =>
+ current ? { ...current, ...updatedAgent } : current,
+ );
+ }
refreshPipelines();
}}
onDeleted={() => {
@@ -119,6 +127,11 @@ export default function AgentDetailContent({ id }: { id: string }) {
canOperate ? (
agentFormRef.current?.save() ?? false}
+ onOpenRunnerConfig={() =>
+ agentFormRef.current?.openSection('runner_config')
+ }
supportedEventPatterns={
agent.supported_event_patterns ??
agent.capability?.supported_event_patterns ?? ['*']
diff --git a/web/src/app/home/agents/components/AgentCreateContent.tsx b/web/src/app/home/agents/components/AgentCreateContent.tsx
index 654aaa041..d9cdd4962 100644
--- a/web/src/app/home/agents/components/AgentCreateContent.tsx
+++ b/web/src/app/home/agents/components/AgentCreateContent.tsx
@@ -51,9 +51,12 @@ export default function AgentCreateContent({
});
function handleKindChange(nextKind: AgentKind) {
+ const previousDefaultEmoji = kind === 'pipeline' ? '⚙️' : '🤖';
+ const nextDefaultEmoji = nextKind === 'pipeline' ? '⚙️' : '🤖';
setKind(nextKind);
- if (!form.getValues('emoji')) {
- form.setValue('emoji', nextKind === 'pipeline' ? '⚙️' : '🤖');
+ const currentEmoji = form.getValues('emoji');
+ if (!currentEmoji || currentEmoji === previousDefaultEmoji) {
+ form.setValue('emoji', nextDefaultEmoji);
}
}
diff --git a/web/src/app/home/agents/components/AgentDebugPanel.tsx b/web/src/app/home/agents/components/AgentDebugPanel.tsx
index efdf046e3..37b7b5570 100644
--- a/web/src/app/home/agents/components/AgentDebugPanel.tsx
+++ b/web/src/app/home/agents/components/AgentDebugPanel.tsx
@@ -1,7 +1,14 @@
-import { useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
-import { LoaderCircle, Play, RotateCcw } from 'lucide-react';
+import {
+ AlertCircle,
+ ChevronDown,
+ CircleHelp,
+ LoaderCircle,
+ Play,
+ RotateCcw,
+} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -15,10 +22,19 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@/components/ui/collapsible';
interface AgentDebugPanelProps {
agentId: string;
supportedEventPatterns?: string[];
+ beforeRun?: () => Promise;
+ hasUnsavedChanges?: boolean;
+ onOpenRunnerConfig?: () => void;
}
interface DebugEntry {
@@ -26,6 +42,8 @@ interface DebugEntry {
direction: 'input' | 'output' | 'error';
eventType: string;
text: string;
+ errorCode?: string;
+ detail?: string;
}
const EVENT_PRESETS = [
@@ -87,9 +105,17 @@ function createDebugSessionId(agentId: string) {
return `webui:${agentId}:${nonce}`;
}
+function matchesEventPattern(pattern: string, eventType: string) {
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
+ return new RegExp(`^${escaped.replaceAll('*', '.*')}$`).test(eventType);
+}
+
export default function AgentDebugPanel({
agentId,
supportedEventPatterns = ['*'],
+ beforeRun,
+ hasUnsavedChanges = false,
+ onOpenRunnerConfig,
}: AgentDebugPanelProps) {
const { t } = useTranslation();
const [preset, setPreset] = useState('message.received');
@@ -106,6 +132,22 @@ export default function AgentDebugPanel({
() => supportedEventPatterns.join(', '),
[supportedEventPatterns],
);
+ const availablePresets = useMemo(
+ () =>
+ EVENT_PRESETS.filter(
+ (item) =>
+ item.value === 'custom' ||
+ supportedEventPatterns.some((pattern) =>
+ matchesEventPattern(pattern, item.value),
+ ),
+ ),
+ [supportedEventPatterns],
+ );
+
+ useEffect(() => {
+ if (availablePresets.some((item) => item.value === preset)) return;
+ selectPreset(availablePresets[0]?.value ?? 'custom');
+ }, [availablePresets, preset]);
function selectPreset(value: string) {
setPreset(value);
@@ -129,6 +171,14 @@ export default function AgentDebugPanel({
toast.error(t('agents.debugInputRequired'));
return;
}
+ if (
+ !supportedEventPatterns.some((pattern) =>
+ matchesEventPattern(pattern, eventType),
+ )
+ ) {
+ toast.error(t('agents.debugUnsupportedEvent'));
+ return;
+ }
let eventData: Record;
try {
@@ -142,6 +192,12 @@ export default function AgentDebugPanel({
return;
}
+ setRunning(true);
+ if (hasUnsavedChanges && beforeRun && !(await beforeRun())) {
+ setRunning(false);
+ return;
+ }
+
const requestId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
setEntries((current) => [
...current,
@@ -152,7 +208,6 @@ export default function AgentDebugPanel({
text: inputText.trim() || JSON.stringify(eventData, null, 2),
},
]);
- setRunning(true);
try {
const result = await httpClient.debugAgent(agentId, {
event_type: eventType,
@@ -171,17 +226,41 @@ export default function AgentDebugPanel({
]);
if (isMessageEvent) setInputText('');
} catch (error) {
+ const errorCode =
+ typeof error === 'object' && error && 'code' in error
+ ? String((error as { code?: string }).code || '')
+ : '';
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: t('agents.debugRunFailed');
+ const isConfigError = errorCode.endsWith('.config_invalid');
+ const isExecutionError = errorCode === 'runner_execution_failed';
+ const isTimeout = errorCode === 'runner.timeout';
+ const friendlyMessage = isConfigError
+ ? t('agents.debugRunnerConfigInvalidDescription', {
+ message:
+ message === 'api-key is required'
+ ? t('agents.debugApiKeyRequired')
+ : message,
+ })
+ : isExecutionError
+ ? t('agents.debugRunnerExecutionFailedDescription')
+ : isTimeout
+ ? t('agents.debugRunnerTimeoutDescription')
+ : message || t('agents.debugRunFailed');
setEntries((current) => [
...current,
{
id: `error:${requestId}`,
direction: 'error',
eventType,
- text: message || t('agents.debugRunFailed'),
+ text: friendlyMessage,
+ errorCode,
+ detail:
+ isExecutionError || isTimeout
+ ? message || t('agents.debugRunFailed')
+ : undefined,
},
]);
} finally {
@@ -200,7 +279,7 @@ export default function AgentDebugPanel({
- {EVENT_PRESETS.map((item) => (
+ {availablePresets.map((item) => (
{t(item.labelKey)}
@@ -242,22 +321,30 @@ export default function AgentDebugPanel({
{entries.length === 0 ? (
-
- {t('agents.debugEmptyTranscript')}
-
+
+
+ {t('agents.debugEmptyTitle')}
+
+ {t('agents.debugEmptyTranscript')}
+
+
) : (
{entries.map((entry) => (
-
+ {entry.direction === 'error' &&
}
{entry.eventType}
@@ -271,7 +358,36 @@ export default function AgentDebugPanel({
{entry.text}
-
+ {entry.detail && (
+
+
+
+
+
+
+ {entry.detail}
+
+
+
+ )}
+ {(entry.errorCode?.endsWith('.config_invalid') ||
+ entry.errorCode === 'runner_execution_failed' ||
+ entry.errorCode === 'runner.timeout') &&
+ onOpenRunnerConfig && (
+
+ )}
+
))}
)}
@@ -322,7 +438,11 @@ export default function AgentDebugPanel({
) : (
)}
- {running ? t('agents.debugRunning') : t('agents.debugRun')}
+ {running
+ ? t('agents.debugRunning')
+ : hasUnsavedChanges
+ ? t('agents.debugSaveAndRun')
+ : t('agents.debugRun')}
diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx
index 5a28e1d7f..f1eda3ac1 100644
--- a/web/src/app/home/agents/components/AgentFormComponent.tsx
+++ b/web/src/app/home/agents/components/AgentFormComponent.tsx
@@ -1,4 +1,13 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ forwardRef,
+ type ForwardedRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -51,23 +60,62 @@ export interface AgentRunnerStatus {
interface AgentFormComponentProps {
agentId: string;
- onFinish: () => void;
+ onFinish: (agent?: Partial) => void;
onDeleted: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
onRunnerStatusChange?: (status: AgentRunnerStatus) => void;
}
-type AgentConfigSection = 'events' | 'runner' | 'runner_config' | 'basic';
+export type AgentConfigSection =
+ 'events' | 'runner' | 'runner_config' | 'basic';
-export default function AgentFormComponent({
- agentId,
- onFinish,
- onDeleted,
- onDirtyChange,
- onSavingChange,
- onRunnerStatusChange,
-}: AgentFormComponentProps) {
+export interface AgentFormHandle {
+ openSection: (section: AgentConfigSection) => void;
+ save: () => Promise;
+}
+
+function isRequiredRunnerValueMissing(value: unknown): boolean {
+ if (value === null || value === undefined) return true;
+ if (typeof value === 'string') return value.trim() === '';
+ if (Array.isArray(value)) return value.length === 0;
+ if (typeof value === 'object' && 'primary' in value) {
+ return !String((value as { primary?: unknown }).primary || '').trim();
+ }
+ return false;
+}
+
+function isRunnerFieldVisible(
+ field: PipelineConfigStage['config'][number],
+ values: Record,
+) {
+ if (!field.show_if || field.show_if.field.startsWith('__system.')) {
+ return true;
+ }
+ const dependentValue = values[field.show_if.field];
+ if (field.show_if.operator === 'eq') {
+ return dependentValue === field.show_if.value;
+ }
+ if (field.show_if.operator === 'neq') {
+ return dependentValue !== field.show_if.value;
+ }
+ return (
+ Array.isArray(field.show_if.value) &&
+ field.show_if.value.includes(dependentValue)
+ );
+}
+
+function AgentFormComponent(
+ {
+ agentId,
+ onFinish,
+ onDeleted,
+ onDirtyChange,
+ onSavingChange,
+ onRunnerStatusChange,
+ }: AgentFormComponentProps,
+ ref: ForwardedRef,
+) {
const { t } = useTranslation();
const [runnerConfigSchema, setRunnerConfigSchema] =
useState(null);
@@ -80,6 +128,7 @@ export default function AgentFormComponent({
const [activeSection, setActiveSection] =
useState('basic');
const isSavingRef = useRef(false);
+ const hasUnsavedChangesRef = useRef(false);
const formSchema = z.object({
basic: z.object({
@@ -116,6 +165,7 @@ export default function AgentFormComponent({
if (!savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
})();
+ hasUnsavedChangesRef.current = hasUnsavedChanges;
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
@@ -191,6 +241,24 @@ export default function AgentFormComponent({
const activeRunnerStage = runnerConfigSchema?.stages.find(
(stage) => stage.name === currentRunner,
);
+ const runnerConfigValues = form.watch('runner_config') as Record<
+ string,
+ Record
+ >;
+ const activeRunnerValues = useMemo(
+ () => runnerConfigValues?.[currentRunner] ?? {},
+ [currentRunner, runnerConfigValues],
+ );
+ const missingRunnerFields = useMemo(
+ () =>
+ (activeRunnerStage?.config ?? []).filter(
+ (field) =>
+ field.required &&
+ isRunnerFieldVisible(field, activeRunnerValues) &&
+ isRequiredRunnerValueMissing(activeRunnerValues[field.name]),
+ ),
+ [activeRunnerStage, activeRunnerValues],
+ );
const primarySections: Array<{
name: AgentConfigSection;
label: string;
@@ -270,6 +338,18 @@ export default function AgentFormComponent({
};
}
+ if (missingRunnerFields.length > 0) {
+ return {
+ label: t('agents.runnerConfigIncomplete'),
+ description: t('agents.runnerConfigIncompleteDescription', {
+ fields: missingRunnerFields
+ .map((field) => extractI18nObject(field.label))
+ .join(', '),
+ }),
+ tone: 'warning',
+ };
+ }
+
return {
label: t('agents.runnerReady'),
description: t('agents.runnerReadyDescription', {
@@ -283,6 +363,7 @@ export default function AgentFormComponent({
pluginStatusLoading,
pluginSystemStatus,
runnerOptions.length,
+ missingRunnerFields,
selectedRunnerOption,
t,
]);
@@ -369,45 +450,70 @@ export default function AgentFormComponent({
return patterns.length > 0 ? patterns : ['*'];
}
- function handleSubmit(values: FormValues) {
- if (isSavingRef.current) return;
- const submittedSnapshot = JSON.stringify(values);
- const runner = values.runner || {};
- const agent: Partial = {
- name: values.basic.name,
- description: values.basic.description ?? '',
- emoji: values.basic.emoji,
- enabled: values.basic.enabled ?? true,
- component_ref: (runner.id as string) || null,
- supported_event_patterns: normalizeEventPatterns(
- values.supported_event_patterns_text,
- ),
- config: {
- runner,
- runner_config: values.runner_config ?? {},
- },
- };
+ const saveValues = useCallback(
+ async (values: FormValues) => {
+ if (isSavingRef.current) return false;
+ const submittedSnapshot = JSON.stringify(values);
+ const runner = values.runner || {};
+ const agent: Partial = {
+ name: values.basic.name,
+ description: values.basic.description ?? '',
+ emoji: values.basic.emoji,
+ enabled: values.basic.enabled ?? true,
+ component_ref: (runner.id as string) || null,
+ supported_event_patterns: normalizeEventPatterns(
+ values.supported_event_patterns_text,
+ ),
+ config: {
+ runner,
+ runner_config: values.runner_config ?? {},
+ },
+ };
- isSavingRef.current = true;
- setIsSaving(true);
- onSavingChange?.(true);
- httpClient
- .updateAgent(agentId, agent)
- .then(() => {
+ isSavingRef.current = true;
+ setIsSaving(true);
+ onSavingChange?.(true);
+ try {
+ await httpClient.updateAgent(agentId, agent);
savedSnapshotRef.current = submittedSnapshot;
- onFinish();
+ onFinish(agent);
toast.success(t('agents.saveSuccess'));
- })
- .catch((err) => {
- toast.error(t('agents.saveError') + err.msg);
- })
- .finally(() => {
+ return true;
+ } catch (err) {
+ const message =
+ typeof err === 'object' && err && 'msg' in err
+ ? String((err as { msg?: string }).msg || '')
+ : '';
+ toast.error(t('agents.saveError') + message);
+ return false;
+ } finally {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
- });
+ }
+ },
+ [agentId, onFinish, onSavingChange, t],
+ );
+
+ function handleSubmit(values: FormValues) {
+ void saveValues(values);
}
+ useImperativeHandle(
+ ref,
+ () => ({
+ openSection: setActiveSection,
+ async save() {
+ if (!hasUnsavedChangesRef.current) return true;
+ if (isSavingRef.current) return false;
+ const valid = await form.trigger();
+ if (!valid) return false;
+ return (await saveValues(form.getValues())) ?? false;
+ },
+ }),
+ [form, saveValues],
+ );
+
function confirmDelete() {
httpClient
.deleteAgent(agentId)
@@ -672,3 +778,5 @@ export default function AgentFormComponent({
>
);
}
+
+export default forwardRef(AgentFormComponent);
diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx
index b6012a806..50450ddc1 100644
--- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx
+++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx
@@ -1752,6 +1752,17 @@ function findSidebarChildForPath(pathname: string): SidebarChildVO | undefined {
);
if (matchedChild) return matchedChild;
+ // Keep the legacy Pipeline URL usable after Pipelines and Agents were
+ // unified under the Processors section.
+ if (
+ pathname === '/home/pipelines' ||
+ pathname.startsWith('/home/pipelines/')
+ ) {
+ return sidebarConfigList.find(
+ (childConfig) => childConfig.id === 'pipelines',
+ );
+ }
+
if (
pathname === '/home/mcp' ||
pathname === '/home/skills' ||
diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx
index 625a90e98..29864873a 100644
--- a/web/src/app/home/layout.tsx
+++ b/web/src/app/home/layout.tsx
@@ -269,7 +269,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
-
+
diff --git a/web/src/app/home/pipelines/PipelineDetailContent.tsx b/web/src/app/home/pipelines/PipelineDetailContent.tsx
index 769703190..85d6a4524 100644
--- a/web/src/app/home/pipelines/PipelineDetailContent.tsx
+++ b/web/src/app/home/pipelines/PipelineDetailContent.tsx
@@ -1,7 +1,9 @@
-import { useState, useEffect } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
-import PipelineFormComponent from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
+import PipelineFormComponent, {
+ PipelineFormHandle,
+} from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
import DebugDialog from '@/app/home/pipelines/components/debug-dialog/DebugDialog';
import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab';
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
@@ -42,6 +44,8 @@ export default function PipelineDetailContent({
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
+ const pipelineFormRef = useRef
(null);
+ const pipeline = pipelines.find((item) => item.id === id);
function handleFinish() {
refreshPipelines();
@@ -96,7 +100,7 @@ export default function PipelineDetailContent({
return (
pipelineFormRef.current?.save() ?? false}
onConnectionStatusChange={setIsWebSocketConnected}
/>
) : undefined
diff --git a/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx b/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx
index 370bb079a..3cc973190 100644
--- a/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx
+++ b/web/src/app/home/pipelines/components/debug-dialog/DebugDialog.tsx
@@ -40,7 +40,13 @@ import {
Music,
Code,
AlignLeft,
+ RotateCcw,
} from 'lucide-react';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
interface DebugDialogProps {
open: boolean;
@@ -48,6 +54,8 @@ interface DebugDialogProps {
isEmbedded?: boolean;
compact?: boolean;
onConnectionStatusChange?: (isConnected: boolean) => void;
+ beforeSend?: () => Promise;
+ hasUnsavedChanges?: boolean;
}
function AuthenticatedMessageImage({
@@ -118,6 +126,8 @@ export default function DebugDialog({
isEmbedded = false,
compact = false,
onConnectionStatusChange,
+ beforeSend,
+ hasUnsavedChanges = false,
}: DebugDialogProps) {
const { t } = useTranslation();
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
@@ -177,7 +187,7 @@ export default function DebugDialog({
sessionType,
);
if (generation !== historyRequestGenerationRef.current) return;
- setMessages(response.messages);
+ setMessages(Array.isArray(response.messages) ? response.messages : []);
} catch (error) {
if (generation !== historyRequestGenerationRef.current) return;
console.error('Failed to load messages:', error);
@@ -186,6 +196,19 @@ export default function DebugDialog({
[sessionType],
);
+ const resetConversation = useCallback(async () => {
+ try {
+ await httpClient.resetWebSocketSession(selectedPipelineId, sessionType);
+ invalidateHistoryRequests();
+ setMessages([]);
+ setQuotedMessage(null);
+ toast.success(t('pipelines.debugDialog.resetSuccess'));
+ } catch (error) {
+ console.error('Failed to reset Debug Chat session:', error);
+ toast.error(t('pipelines.debugDialog.resetFailed'));
+ }
+ }, [invalidateHistoryRequests, selectedPipelineId, sessionType, t]);
+
// Initialize WebSocket connection
const initWebSocket = useCallback(
async (pipelineId: string) => {
@@ -435,6 +458,9 @@ export default function DebugDialog({
try {
setIsUploading(true);
+ if (hasUnsavedChanges && beforeSend && !(await beforeSend())) {
+ return;
+ }
const messageChain = [];
@@ -834,32 +860,65 @@ export default function DebugDialog({
compact && 'w-12 p-1.5 pl-1',
)}
>
-
-
+
+
+
+
+
+ {t('pipelines.debugDialog.privateChat')}
+
+
+
+
+
+
+
+ {t('pipelines.debugDialog.groupChat')}
+
+
+
+
+
+
+
+ {t('pipelines.debugDialog.reset')}
+
+
@@ -1120,7 +1179,9 @@ export default function DebugDialog({
) : (
<>
- {t('pipelines.debugDialog.send')}
+ {hasUnsavedChanges
+ ? t('pipelines.debugDialog.saveAndSend')
+ : t('pipelines.debugDialog.send')}
>
)}
diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
index d0d4c5c2c..a743321b0 100644
--- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
+++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
@@ -1,4 +1,11 @@
-import { useEffect, useRef, useState, useMemo } from 'react';
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api';
import {
@@ -51,17 +58,7 @@ import {
} from 'lucide-react';
import PipelineExtension from '@/app/home/pipelines/components/pipeline-extensions/PipelineExtension';
-export default function PipelineFormComponent({
- onFinish,
- onNewPipelineCreated,
- isEditMode,
- pipelineId,
- showButtons = true,
- onDeletePipeline,
- onCancel,
- onDirtyChange,
- onSavingChange,
-}: {
+interface PipelineFormComponentProps {
pipelineId?: string;
isEditMode: boolean;
disableForm: boolean;
@@ -72,7 +69,29 @@ export default function PipelineFormComponent({
onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
-}) {
+}
+
+export interface PipelineFormHandle {
+ save: () => Promise;
+}
+
+const PipelineFormComponent = forwardRef<
+ PipelineFormHandle,
+ PipelineFormComponentProps
+>(function PipelineFormComponent(
+ {
+ onFinish,
+ onNewPipelineCreated,
+ isEditMode,
+ pipelineId,
+ showButtons = true,
+ onDeletePipeline,
+ onCancel,
+ onDirtyChange,
+ onSavingChange,
+ },
+ ref,
+) {
const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
@@ -268,7 +287,7 @@ export default function PipelineFormComponent({
function handleFormSubmit(values: FormValues) {
if (isEditMode) {
- handleModify(values);
+ void handleModify(values);
} else {
handleCreate(values);
}
@@ -302,8 +321,8 @@ export default function PipelineFormComponent({
});
}
- function handleModify(values: FormValues) {
- if (isSavingRef.current) return;
+ async function handleModify(values: FormValues): Promise {
+ if (isSavingRef.current) return false;
const submittedSnapshot = JSON.stringify(values);
const realConfig = {
ai: values.ai,
@@ -327,23 +346,36 @@ export default function PipelineFormComponent({
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
- httpClient
- .updatePipeline(pipelineId || '', pipeline)
- .then(() => {
- savedSnapshotRef.current = submittedSnapshot;
- onFinish();
- toast.success(t('pipelines.saveSuccess'));
- })
- .catch((err) => {
- toast.error(t('pipelines.saveError') + err.msg);
- })
- .finally(() => {
- isSavingRef.current = false;
- setIsSaving(false);
- onSavingChange?.(false);
- });
+ try {
+ await httpClient.updatePipeline(pipelineId || '', pipeline);
+ savedSnapshotRef.current = submittedSnapshot;
+ onFinish();
+ toast.success(t('pipelines.saveSuccess'));
+ return true;
+ } catch (err) {
+ const message =
+ typeof err === 'object' && err && 'msg' in err
+ ? String((err as { msg?: string }).msg || '')
+ : '';
+ toast.error(t('pipelines.saveError') + message);
+ return false;
+ } finally {
+ isSavingRef.current = false;
+ setIsSaving(false);
+ onSavingChange?.(false);
+ }
}
+ useImperativeHandle(ref, () => ({
+ async save() {
+ if (!hasUnsavedChangesRef.current) return true;
+ if (isSavingRef.current || !isEditMode) return false;
+ const valid = await form.trigger();
+ if (!valid) return false;
+ return handleModify(form.getValues());
+ },
+ }));
+
// Called from DynamicFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
@@ -877,7 +909,9 @@ export default function PipelineFormComponent({
>
);
-}
+});
+
+export default PipelineFormComponent;
interface SectionItem {
label: string;
name: string;
diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx
index 95ae80ee2..e664817c3 100644
--- a/web/src/components/ui/sidebar.tsx
+++ b/web/src/components/ui/sidebar.tsx
@@ -207,7 +207,7 @@ function SidebarProvider({
} as React.CSSProperties
}
className={cn(
- 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-svh w-full overflow-hidden',
+ 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-svh min-h-0 w-full overflow-clip',
className,
)}
{...props}
@@ -566,7 +566,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
{
const configPanel = page.getByRole('region', { name: 'Configuration' });
await expect(debugPanel).toBeVisible();
await expect(configPanel).toBeVisible();
-
const debugBox = await debugPanel.boundingBox();
const configBox = await configPanel.boundingBox();
expect(debugBox).not.toBeNull();
@@ -28,9 +27,21 @@ test.describe('processor detail workbench', () => {
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
+ const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
+ await expect(appShell).toHaveCSS('overflow', 'clip');
+ await expect(sidebarInset).toHaveCSS('overflow', 'clip');
+ await appShell.evaluate((element) => {
+ element.scrollTop = 300;
+ });
+ await sidebarInset.evaluate((element) => {
+ element.scrollTop = 300;
+ });
await expect
.poll(() => appShell.evaluate((element) => element.scrollTop))
.toBe(0);
+ await expect
+ .poll(() => sidebarInset.evaluate((element) => element.scrollTop))
+ .toBe(0);
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
const flow = configPanel.getByRole('tablist');
@@ -66,10 +77,95 @@ test.describe('processor detail workbench', () => {
).toBeVisible();
});
+ test('agent saves edits before debugging and shows the real output', async ({
+ page,
+ }) => {
+ await installLangBotApiMocks(page, { authenticated: true });
+ const requests: string[] = [];
+ page.on('request', (request) => {
+ const path = new URL(request.url()).pathname;
+ if (
+ request.method() === 'PUT' &&
+ path === '/api/v1/agents/agent-workbench'
+ ) {
+ requests.push('save');
+ }
+ if (
+ request.method() === 'POST' &&
+ path === '/api/v1/agents/agent-workbench/debug'
+ ) {
+ requests.push('debug');
+ }
+ });
+
+ await page.goto('/home/agents?id=agent-workbench');
+ await page.getByLabel('Description').fill('Updated before debugging');
+ await page
+ .getByRole('textbox', { name: 'Conversation input' })
+ .fill('Hello');
+ await page.getByRole('button', { name: 'Save and run' }).click();
+
+ await expect(page.getByText('Mock Agent response')).toBeVisible();
+ expect(requests).toEqual(['save', 'debug']);
+ });
+
+ test('agent turns runner failures into an actionable message', async ({
+ page,
+ }) => {
+ await installLangBotApiMocks(page, { authenticated: true });
+ await page.route(
+ '**/api/v1/agents/agent-workbench/debug',
+ async (route) => {
+ await route.fulfill({
+ status: 422,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ code: 'dify.config_invalid',
+ msg: 'api-key is required',
+ }),
+ });
+ },
+ );
+
+ await page.goto('/home/agents?id=agent-workbench');
+ await page
+ .getByRole('textbox', { name: 'Conversation input' })
+ .fill('Hello');
+ await page.getByRole('button', { name: 'Run test' }).click();
+
+ await expect(
+ page.getByText(
+ 'The runner configuration is incomplete: API Key is missing',
+ ),
+ ).toBeVisible();
+ await expect(page.getByText('Internal server error')).toHaveCount(0);
+ await page
+ .getByRole('button', { name: 'Review runner configuration' })
+ .click();
+ await expect(
+ page.getByRole('tab', { name: 'Local Agent', exact: true }),
+ ).toHaveAttribute('data-state', 'active');
+ });
+
test('pipeline keeps debug chat left and exposes its main flow first', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
+ await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
+ ws.onMessage((raw) => {
+ const message = JSON.parse(String(raw));
+ if (message.type === 'authenticate') {
+ ws.send(
+ JSON.stringify({
+ type: 'connected',
+ connection_id: 'playwright-connection',
+ pipeline_uuid: 'pipeline-workbench',
+ session_type: 'person',
+ }),
+ );
+ }
+ });
+ });
await page.goto('/home/pipelines?id=pipeline-workbench');
@@ -77,6 +173,18 @@ test.describe('processor detail workbench', () => {
const configPanel = page.getByRole('region', { name: 'Configuration' });
await expect(debugPanel).toBeVisible();
await expect(configPanel).toBeVisible();
+ await expect(
+ debugPanel.getByRole('button', { name: 'Private Chat' }),
+ ).toBeVisible();
+ await expect(
+ debugPanel.getByRole('button', { name: 'Group Chat' }),
+ ).toBeVisible();
+ await debugPanel
+ .getByRole('button', { name: 'Reset Conversation' })
+ .click();
+ await expect(
+ page.getByText('Conversation reset successfully'),
+ ).toBeVisible();
const debugBox = await debugPanel.boundingBox();
const configBox = await configPanel.boundingBox();
@@ -86,19 +194,18 @@ test.describe('processor detail workbench', () => {
expect(configBox!.width).toBeGreaterThan(debugBox!.width);
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
+ const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
+ await expect(appShell).toHaveCSS('overflow', 'clip');
+ await expect(sidebarInset).toHaveCSS('overflow', 'clip');
await expect
.poll(() => appShell.evaluate((element) => element.scrollTop))
.toBe(0);
expect(debugBox!.y).toBeGreaterThanOrEqual(0);
const flow = configPanel.getByRole('tablist');
- await expect(flow.getByRole('tab').nth(0)).toContainText(
- 'Trigger Conditions',
- );
- await expect(flow.getByRole('tab').nth(1)).toContainText('AI Capabilities');
- await expect(flow.getByRole('tab').nth(2)).toContainText(
- 'Output Processing',
- );
+ await expect(flow.getByRole('tab').nth(0)).toContainText('Trigger');
+ await expect(flow.getByRole('tab').nth(1)).toContainText('AI');
+ await expect(flow.getByRole('tab').nth(2)).toContainText('Output');
await flow.getByRole('tab').nth(1).click();
await expect(