fix(web): make processor debugging reliable

This commit is contained in:
RockChinQ
2026-08-25 15:52:59 +08:00
parent 781d8a9ac8
commit 6a6a2b865b
24 changed files with 788 additions and 160 deletions
+15 -1
View File
@@ -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}')
+5 -5
View File
@@ -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),
@@ -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':
@@ -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(
@@ -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}',
)
+4 -1
View File
@@ -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
@@ -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)
@@ -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."""
@@ -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',
}
+19
View File
@@ -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(
+16 -3
View File
@@ -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<AgentRunnerStatus | null>(
null,
);
const agentFormRef = useRef<AgentFormHandle>(null);
useEffect(() => {
if (isCreateMode) {
@@ -89,7 +91,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
return (
<ProcessorDetailWorkbench
key={id}
title={t('agents.editAgent')}
title={`${agent.emoji || '🤖'} ${agent.name}`}
status={runnerStatus}
saveLabel={t('common.save')}
saveFormId="agent-form"
@@ -100,8 +102,14 @@ export default function AgentDetailContent({ id }: { id: string }) {
configContent={
<fieldset className="contents" disabled={!canManage}>
<AgentFormComponent
ref={agentFormRef}
agentId={id}
onFinish={() => {
onFinish={(updatedAgent) => {
if (updatedAgent) {
setAgent((current) =>
current ? { ...current, ...updatedAgent } : current,
);
}
refreshPipelines();
}}
onDeleted={() => {
@@ -119,6 +127,11 @@ export default function AgentDetailContent({ id }: { id: string }) {
canOperate ? (
<AgentDebugPanel
agentId={id}
hasUnsavedChanges={formDirty}
beforeRun={async () => agentFormRef.current?.save() ?? false}
onOpenRunnerConfig={() =>
agentFormRef.current?.openSection('runner_config')
}
supportedEventPatterns={
agent.supported_event_patterns ??
agent.capability?.supported_event_patterns ?? ['*']
@@ -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);
}
}
@@ -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<boolean>;
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<string, unknown>;
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({
<SelectValue />
</SelectTrigger>
<SelectContent>
{EVENT_PRESETS.map((item) => (
{availablePresets.map((item) => (
<SelectItem key={item.value} value={item.value}>
{t(item.labelKey)}
</SelectItem>
@@ -242,22 +321,30 @@ export default function AgentDebugPanel({
</p>
</div>
{entries.length === 0 ? (
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t('agents.debugEmptyTranscript')}
</div>
<Alert className="my-4 bg-muted/20">
<CircleHelp className="size-4" />
<AlertTitle>{t('agents.debugEmptyTitle')}</AlertTitle>
<AlertDescription>
{t('agents.debugEmptyTranscript')}
</AlertDescription>
</Alert>
) : (
<div className="space-y-3">
{entries.map((entry) => (
<div
<Alert
key={entry.id}
className={`rounded-lg border p-3 ${
variant={
entry.direction === 'error' ? 'destructive' : 'default'
}
className={
entry.direction === 'output'
? 'border-primary/20 bg-primary/5'
: entry.direction === 'error'
? 'border-destructive/30 bg-destructive/5'
: 'bg-muted/40'
}`}
: entry.direction === 'input'
? 'bg-muted/40'
: undefined
}
>
{entry.direction === 'error' && <AlertCircle />}
<div className="mb-2 flex items-center justify-between gap-2">
<Badge variant="outline">{entry.eventType}</Badge>
<span className="text-xs text-muted-foreground">
@@ -271,7 +358,36 @@ export default function AgentDebugPanel({
<pre className="min-w-0 whitespace-pre-wrap break-words font-sans text-sm leading-relaxed">
{entry.text}
</pre>
</div>
{entry.detail && (
<Collapsible className="mt-3">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="sm">
{t('agents.debugErrorDetails')}
<ChevronDown className="size-3.5" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
{entry.detail}
</pre>
</CollapsibleContent>
</Collapsible>
)}
{(entry.errorCode?.endsWith('.config_invalid') ||
entry.errorCode === 'runner_execution_failed' ||
entry.errorCode === 'runner.timeout') &&
onOpenRunnerConfig && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={onOpenRunnerConfig}
>
{t('agents.debugReviewRunnerConfig')}
</Button>
)}
</Alert>
))}
</div>
)}
@@ -322,7 +438,11 @@ export default function AgentDebugPanel({
) : (
<Play className="size-4" />
)}
{running ? t('agents.debugRunning') : t('agents.debugRun')}
{running
? t('agents.debugRunning')
: hasUnsavedChanges
? t('agents.debugSaveAndRun')
: t('agents.debugRun')}
</Button>
</div>
</div>
@@ -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<Agent>) => 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<boolean>;
}
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<string, unknown>,
) {
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<AgentFormHandle>,
) {
const { t } = useTranslation();
const [runnerConfigSchema, setRunnerConfigSchema] =
useState<PipelineConfigTab | null>(null);
@@ -80,6 +128,7 @@ export default function AgentFormComponent({
const [activeSection, setActiveSection] =
useState<AgentConfigSection>('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<string, unknown>
>;
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<Agent> = {
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<Agent> = {
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);
@@ -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' ||
+1 -1
View File
@@ -269,7 +269,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
</div>
</header>
<main className="flex-1 overflow-hidden min-w-0 px-4 pb-4 pt-0">
<main className="min-h-0 min-w-0 flex-1 overflow-clip px-4 pb-4 pt-0">
<div
className={`mx-auto h-full w-full min-w-0 ${HOME_CONTENT_MAX_WIDTH}`}
>
@@ -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<PipelineFormHandle>(null);
const pipeline = pipelines.find((item) => item.id === id);
function handleFinish() {
refreshPipelines();
@@ -96,7 +100,7 @@ export default function PipelineDetailContent({
return (
<ProcessorDetailWorkbench
key={id}
title={t('pipelines.editPipeline')}
title={`${pipeline?.emoji || '⚙️'} ${pipeline?.name || t('pipelines.editPipeline')}`}
saveLabel={t('common.save')}
saveFormId="pipeline-form"
canSave={canManage}
@@ -106,6 +110,7 @@ export default function PipelineDetailContent({
configContent={
<fieldset className="contents" disabled={!canManage}>
<PipelineFormComponent
ref={pipelineFormRef}
pipelineId={id}
isEditMode={true}
disableForm={!canManage}
@@ -130,6 +135,8 @@ export default function PipelineDetailContent({
pipelineId={id}
isEmbedded={true}
compact={true}
hasUnsavedChanges={formDirty}
beforeSend={async () => pipelineFormRef.current?.save() ?? false}
onConnectionStatusChange={setIsWebSocketConnected}
/>
) : undefined
@@ -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<boolean>;
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',
)}
>
<Button
variant="ghost"
size="icon"
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'person'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
onClick={() => setSessionType('person')}
>
<User className="size-5" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'group'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
onClick={() => setSessionType('group')}
>
<Users className="size-5" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t('pipelines.debugDialog.privateChat')}
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'person'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
onClick={() => setSessionType('person')}
>
<User className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('pipelines.debugDialog.privateChat')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t('pipelines.debugDialog.groupChat')}
className={cn(
'w-10 h-10 justify-center rounded-md transition-none border-0 shadow-none',
sessionType === 'group'
? 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
onClick={() => setSessionType('group')}
>
<Users className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('pipelines.debugDialog.groupChat')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('pipelines.debugDialog.reset')}
className="w-10 h-10 justify-center rounded-md text-muted-foreground"
onClick={() => void resetConversation()}
>
<RotateCcw className="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t('pipelines.debugDialog.reset')}
</TooltipContent>
</Tooltip>
</div>
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
@@ -1120,7 +1179,9 @@ export default function DebugDialog({
) : (
<>
<Send className="size-4" />
{t('pipelines.debugDialog.send')}
{hasUnsavedChanges
? t('pipelines.debugDialog.saveAndSend')
: t('pipelines.debugDialog.send')}
</>
)}
</Button>
@@ -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<boolean>;
}
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<boolean> {
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({
</Dialog>
</>
);
}
});
export default PipelineFormComponent;
interface SectionItem {
label: string;
name: string;
+2 -2
View File
@@ -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'>) {
<main
data-slot="sidebar-inset"
className={cn(
'bg-background relative flex w-full flex-1 flex-col min-w-0',
'bg-background relative flex min-h-0 w-full flex-1 flex-col overflow-clip min-w-0',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
'dark:md:peer-data-[variant=inset]:border dark:md:peer-data-[variant=inset]:border-sidebar-border',
className,
+18 -1
View File
@@ -727,6 +727,8 @@ const enUS = {
selectedRunnerUnavailableDescription:
'{{runner}} is not currently registered. Select another runner or restore its extension.',
noRunnerSelected: 'No runner selected',
runnerConfigIncomplete: 'Runner configuration incomplete',
runnerConfigIncompleteDescription: 'Complete required fields: {{fields}}',
runnerReady: 'Runner ready',
runnerReadyDescription:
'{{runner}} is registered and the plugin runtime is connected.',
@@ -749,18 +751,32 @@ const enUS = {
debugEventPayload: 'Event payload (JSON)',
debugSupportedEvents: 'Agent supports',
debugRun: 'Run test',
debugSaveAndRun: 'Save and run',
debugRunning: 'Running',
debugTranscript: 'Debug transcript',
debugTranscriptDescription:
'Inputs and Agent outputs from the current debug session.',
debugEmptyTitle: 'Verify how this Agent behaves',
debugEmptyTranscript:
'Choose an event and run a test to see the result here.',
'Choose an event, enter test content, then select “Run test”. Results stay on this page.',
debugAgentOutput: 'Agent output',
debugTestInput: 'Test input',
debugNoTextOutput: 'The run completed without textual output.',
debugEventTypeRequired: 'Enter an event type',
debugInputRequired: 'Enter a conversation input',
debugInvalidPayload: 'The event payload must be a valid JSON object',
debugUnsupportedEvent:
'This event is outside the Agents bindable event range',
debugRunnerConfigInvalidDescription:
'The runner configuration is incomplete: {{message}}',
debugRunnerExecutionFailedDescription:
'This run failed. Check the selected model and runner configuration, then try again.',
debugRunnerTimeoutDescription:
'The run timed out. Try again later or adjust the runner timeout.',
debugApiKeyRequired: 'API Key is missing',
debugOpenRunnerConfig: 'Open runner configuration',
debugReviewRunnerConfig: 'Review runner configuration',
debugErrorDetails: 'View error details',
debugRunFailed: 'Agent debug run failed',
},
plugins: {
@@ -1324,6 +1340,7 @@ const enUS = {
privateChat: 'Private Chat',
groupChat: 'Group Chat',
send: 'Send',
saveAndSend: 'Save and send',
reset: 'Reset Conversation',
inputPlaceholder: 'Send {{type}} message...',
noMessages: 'No messages',
+16 -1
View File
@@ -696,6 +696,8 @@ const zhHans = {
selectedRunnerUnavailableDescription:
'{{runner}} 当前未注册。请选择其他运行器,或恢复对应扩展。',
noRunnerSelected: '尚未选择运行器',
runnerConfigIncomplete: '运行器配置待完善',
runnerConfigIncompleteDescription: '请填写必填项:{{fields}}',
runnerReady: '运行器已就绪',
runnerReadyDescription: '{{runner}} 已注册,插件运行时连接正常。',
debugTab: '事件调试',
@@ -716,16 +718,28 @@ const zhHans = {
debugEventPayload: '事件载荷(JSON',
debugSupportedEvents: 'Agent 支持',
debugRun: '运行测试',
debugSaveAndRun: '保存并运行',
debugRunning: '运行中',
debugTranscript: '调试记录',
debugTranscriptDescription: '当前调试会话中的输入与 Agent 输出。',
debugEmptyTranscript: '选择事件并运行测试后,结果会显示在这里。',
debugEmptyTitle: '在这里验证 Agent 的实际效果',
debugEmptyTranscript:
'选择事件类型,填写测试内容,然后点击“运行测试”。结果只会显示在这里。',
debugAgentOutput: 'Agent 输出',
debugTestInput: '测试输入',
debugNoTextOutput: '运行完成,但没有产生文本输出。',
debugEventTypeRequired: '请输入事件类型',
debugInputRequired: '请输入对话内容',
debugInvalidPayload: '事件载荷必须是有效的 JSON 对象',
debugUnsupportedEvent: '这个事件不在当前 Agent 的可绑定事件范围内',
debugRunnerConfigInvalidDescription: '运行器配置不完整:{{message}}',
debugRunnerExecutionFailedDescription:
'本次运行失败。请检查所选模型和运行器配置后重试。',
debugRunnerTimeoutDescription: '运行超时。请稍后重试或调整运行器超时时间。',
debugApiKeyRequired: 'API Key 未填写',
debugOpenRunnerConfig: '前往运行器配置',
debugReviewRunnerConfig: '检查运行器配置',
debugErrorDetails: '查看详细错误',
debugRunFailed: 'Agent 调试运行失败',
},
plugins: {
@@ -1267,6 +1281,7 @@ const zhHans = {
privateChat: '私聊',
groupChat: '群聊',
send: '发送',
saveAndSend: '保存并发送',
reset: '重置对话',
inputPlaceholder: '发送 {{type}} 消息...',
noMessages: '暂无消息',
+28
View File
@@ -706,6 +706,24 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { agents: state.pipelines });
}
const agentDebugMatch = path.match(/^\/api\/v1\/agents\/([^/]+)\/debug$/);
if (agentDebugMatch) {
const payload = parseJsonBody(route);
return fulfillJson(route, {
event_id: nextId(state, 'event'),
event_type: String(payload.event_type || 'message.received'),
conversation_id: String(payload.conversation_id || 'debug-session'),
final_text: 'Mock Agent response',
outputs: [
{
kind: 'message',
role: 'assistant',
text: 'Mock Agent response',
},
],
});
}
const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/);
if (agentMatch) {
const agentId = decodeURIComponent(agentMatch[1]);
@@ -756,6 +774,16 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { pipelines: state.pipelines });
}
if (
/^\/api\/v1\/pipelines\/[^/]+\/ws\/messages\/(person|group)$/.test(path)
) {
return fulfillJson(route, { messages: [] });
}
if (/^\/api\/v1\/pipelines\/[^/]+\/ws\/reset\/(person|group)$/.test(path)) {
return fulfillJson(route, { message: 'reset' });
}
const pipelineMatch = path.match(/^\/api\/v1\/pipelines\/([^/]+)$/);
if (pipelineMatch) {
const pipelineId = decodeURIComponent(pipelineMatch[1]);
@@ -19,7 +19,6 @@ test.describe('processor detail workbench', () => {
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(