From 444024c5b4452f52f3fbe2bc971597255f64175d Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:04:40 +0800 Subject: [PATCH] fix(agent): stabilize post-merge release paths --- .../e2e/wizard-onebot-agent-runtime.mjs | 25 +++- skills/skills.index.json | 8 +- .../cases/langrag-kb-retrieve.yaml | 4 + .../fixtures/complex-agent-task/verify.py | 9 +- skills/test/test_complex_agent_task_verify.py | 14 ++ src/langbot/pkg/agent/runner/orchestrator.py | 7 + .../pkg/agent/runner/query_entry_adapter.py | 4 +- src/langbot/pkg/provider/tools/loaders/mcp.py | 5 + .../agent/test_event_first_protocol.py | 17 +++ .../agent/test_orchestrator_integration.py | 24 ++- .../unit_tests/agent/test_resource_builder.py | 4 +- web/playwright.config.ts | 1 + web/src/app/infra/http/BackendClient.ts | 31 +++- web/tests/e2e/crud-smoke.spec.ts | 45 +++--- web/tests/e2e/fixtures/langbot-api.ts | 140 ++++++++++++++++-- web/tests/e2e/home-smoke.spec.ts | 6 +- .../e2e/pipeline-monitoring-turns.spec.ts | 2 +- web/tests/e2e/plugin-page-auth.spec.ts | 19 ++- web/tests/e2e/quota-create-actions.spec.ts | 11 +- 19 files changed, 319 insertions(+), 57 deletions(-) diff --git a/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs b/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs index de4932091..dc94efe31 100644 --- a/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs +++ b/skills/scripts/e2e/wizard-onebot-agent-runtime.mjs @@ -81,7 +81,8 @@ async function api(page, path, options = {}) { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", - "X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", + "X-Workspace-Id": + localStorage.getItem("langbot_active_workspace_uuid") || "", }, body: options.body === undefined ? undefined : JSON.stringify(options.body), @@ -205,9 +206,7 @@ try { result.visible_signals.push("bot-created", "adapter-enabled"); await page.getByRole("button", { name: /Next|下一步|次へ/ }).click(); - const localAgentTitle = page - .getByText(/^(Local Agent|本地 Agent)$/) - .first(); + const localAgentTitle = page.getByText(/^(Local Agent|本地 Agent)$/).first(); const localAgentCard = localAgentTitle.locator( 'xpath=ancestor::*[@data-slot="card"][1]', ); @@ -316,6 +315,24 @@ try { } catch (error) { if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; result.reason = result.reason || error.message; + if (browser?.page && token && botId) { + const [routeStatus, botLogs] = await Promise.all([ + api( + browser.page, + `/api/v1/platform/bots/${encodeURIComponent(botId)}/event-routes/status`, + ).catch(() => ({ status: 0, json: {} })), + api( + browser.page, + `/api/v1/platform/bots/${encodeURIComponent(botId)}/logs`, + { + method: "POST", + body: { from_index: -1, max_count: 50 }, + }, + ).catch(() => ({ status: 0, json: {} })), + ]); + result.runtime.failure_route_status = routeStatus.json.data || null; + result.runtime.failure_bot_logs = botLogs.json.data || null; + } if (browser?.page) await safeScreenshot(browser.page, paths.screenshot); } finally { if (browser?.page && token) { diff --git a/skills/skills.index.json b/skills/skills.index.json index d8290a6e8..16aa42801 100644 --- a/skills/skills.index.json +++ b/skills/skills.index.json @@ -1003,8 +1003,12 @@ "rag" ], "automation": "scripts/e2e/langrag-kb-retrieve.mjs", - "setup_automation": [], - "setup_provides_env": [], + "setup_automation": [ + "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" + ], + "setup_provides_env": [ + "LANGBOT_LOCAL_AGENT_RAG_KB_UUID" + ], "evidence_required": [ "ui", "screenshot", diff --git a/skills/skills/langbot-testing/cases/langrag-kb-retrieve.yaml b/skills/skills/langbot-testing/cases/langrag-kb-retrieve.yaml index 49fdec8ed..c82060239 100644 --- a/skills/skills/langbot-testing/cases/langrag-kb-retrieve.yaml +++ b/skills/skills/langbot-testing/cases/langrag-kb-retrieve.yaml @@ -25,6 +25,10 @@ automation_env: automation_env_any: - LANGBOT_LOCAL_AGENT_RAG_KB_UUID|LANGBOT_RAG_KB_UUID automation_expected_text: "azalea-cobalt-7421" +setup_automation: + - "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env" +setup_provides_env: + - LANGBOT_LOCAL_AGENT_RAG_KB_UUID preconditions: - "LangRAG is installed and initialized in the active LangBot instance." - "A working embedding model is available, preferably chroma-all-MiniLM-L6-v2 for local repeatability." diff --git a/skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py b/skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py index 5bd660237..8ca09e680 100644 --- a/skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py +++ b/skills/skills/langbot-testing/fixtures/complex-agent-task/verify.py @@ -8,13 +8,20 @@ from pathlib import Path def has_initial_failure_section(report: str) -> bool: folded = report.casefold() - return any( + if any( marker in folded for marker in ( "initial fail", "initially fail", "baseline fail", ) + ): + return True + + baseline_markers = ("baseline test", "before any edit", "before editing") + failure_markers = ("failing test", "failed test", "failures=", "errors=") + return any(marker in folded for marker in baseline_markers) and any( + marker in folded for marker in failure_markers ) diff --git a/skills/test/test_complex_agent_task_verify.py b/skills/test/test_complex_agent_task_verify.py index f748a7c1c..d5c5cab53 100644 --- a/skills/test/test_complex_agent_task_verify.py +++ b/skills/test/test_complex_agent_task_verify.py @@ -26,9 +26,23 @@ class ComplexAgentTaskVerifyTests(unittest.TestCase): def test_accepts_baseline_failure_heading(self) -> None: self.assertTrue(MODULE.has_initial_failure_section("## Baseline failing tests\n- test_price")) + def test_accepts_baseline_run_heading_with_failures_in_body(self) -> None: + report = """\ +## Baseline test run (before any edits) + +The suite reported FAILED (failures=2). Failing tests: + +- test_price +- test_inventory +""" + self.assertTrue(MODULE.has_initial_failure_section(report)) + def test_rejects_report_without_failure_section(self) -> None: self.assertFalse(MODULE.has_initial_failure_section("## Verification\nAll tests pass.")) + def test_rejects_passing_baseline_without_failure_evidence(self) -> None: + self.assertFalse(MODULE.has_initial_failure_section("## Baseline test run\nAll tests pass.")) + if __name__ == "__main__": unittest.main() diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index ca70d5dc3..47c8051c7 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -90,6 +90,13 @@ class AgentRunOrchestrator: execution_context = get_query_execution_context(execution_query) if not isinstance(execution_context, ExecutionContext): raise ValueError('Agent run requires a trusted ExecutionContext') + event_workspace_id = str(event.workspace_id or '').strip() + if event_workspace_id and event_workspace_id != execution_context.workspace_uuid: + raise ValueError('Agent event Workspace does not match its trusted ExecutionContext') + if not event_workspace_id: + event = event.model_copy( + update={'workspace_id': execution_context.workspace_uuid} + ) descriptor = await self.registry.get( execution_context, runner_id, diff --git a/src/langbot/pkg/agent/runner/query_entry_adapter.py b/src/langbot/pkg/agent/runner/query_entry_adapter.py index 50cbdd2c3..448453fbb 100644 --- a/src/langbot/pkg/agent/runner/query_entry_adapter.py +++ b/src/langbot/pkg/agent/runner/query_entry_adapter.py @@ -31,6 +31,7 @@ from .host_models import ( from .config_resolver import RunnerConfigResolver from .resource_policy import ResourcePolicyProjector from . import events as runner_events +from ...pipeline.pool import get_query_execution_context from ...provider.tools.toolmgr import TOOL_SOURCE_REFS_QUERY_KEY @@ -81,6 +82,7 @@ class QueryEntryAdapter: # Build raw ref raw_ref = cls._build_raw_ref(query) + execution_context = get_query_execution_context(query) return AgentEventEnvelope( event_id=event.event_id or str(query.query_id), @@ -89,7 +91,7 @@ class QueryEntryAdapter: source='host_adapter', source_event_type=event.source_event_type, bot_id=query.bot_uuid, - workspace_id=getattr(query, 'workspace_uuid', None), + workspace_id=execution_context.workspace_uuid, conversation_id=conversation.conversation_id, thread_id=conversation.thread_id, actor=actor, diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 75f8db255..2f3b61be7 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -1566,6 +1566,11 @@ def _execution_context_from_tenant(context: TenantContext) -> ExecutionContext: def _execution_context_from_query(query: pipeline_query.Query) -> ExecutionContext: + if isinstance(getattr(query, '_execution_context', None), ExecutionContext): + # Import lazily to keep the loader module independent during app boot. + from ....pipeline.pool import get_query_execution_context + + return get_query_execution_context(query) return _execution_context_from_tenant( ExecutionContext( instance_uuid=str(getattr(query, 'instance_uuid', '') or ''), diff --git a/tests/unit_tests/agent/test_event_first_protocol.py b/tests/unit_tests/agent/test_event_first_protocol.py index 8d3977fc1..626b40a1b 100644 --- a/tests/unit_tests/agent/test_event_first_protocol.py +++ b/tests/unit_tests/agent/test_event_first_protocol.py @@ -30,6 +30,7 @@ from langbot.pkg.agent.runner.binding_resolver import ( AgentBindingResolver, AgentBindingResolutionError, ) +from langbot.pkg.api.http.context import ExecutionContext class TestQueryToEventEnvelope: @@ -350,9 +351,17 @@ def mock_query(): """Create a mock query for testing.""" query = Mock() query.query_id = 123 + query.query_uuid = None query.workspace_uuid = 'workspace-test' query.bot_uuid = 'bot-uuid-123' query.pipeline_uuid = 'pipeline-uuid-456' + query._execution_context = ExecutionContext( + instance_uuid='instance-test', + workspace_uuid=query.workspace_uuid, + placement_generation=1, + bot_uuid=query.bot_uuid, + pipeline_uuid=query.pipeline_uuid, + ) query.launcher_type = Mock(value='person') query.launcher_id = 'launcher-123' query.sender_id = 'sender-123' @@ -399,9 +408,17 @@ def mock_query_no_session(): """Create a mock Query without session.""" query = Mock() query.query_id = 456 + query.query_uuid = None query.workspace_uuid = 'workspace-test' query.bot_uuid = 'bot-uuid-456' query.pipeline_uuid = 'pipeline-uuid-789' + query._execution_context = ExecutionContext( + instance_uuid='instance-test', + workspace_uuid=query.workspace_uuid, + placement_generation=1, + bot_uuid=query.bot_uuid, + pipeline_uuid=query.pipeline_uuid, + ) query.launcher_type = Mock(value='person') query.launcher_id = 'launcher-456' query.sender_id = 'sender-456' diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index 5a6d27ee8..e7db121bb 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -1012,8 +1012,22 @@ 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, + ) + + context = _execution_context_from_query(execution_query) + assert context.instance_uuid == TEST_CONTEXT.instance_uuid + assert context.workspace_uuid == TEST_CONTEXT.workspace_uuid + assert context.placement_generation == TEST_CONTEXT.placement_generation + assert context.bot_uuid == 'bot_001' + return 'Pinned documentation' + mcp_loader = types.SimpleNamespace( - build_resource_context_for_query=AsyncMock(return_value='Pinned documentation') + 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)) @@ -1091,6 +1105,14 @@ 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 '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_resource_builder.py b/tests/unit_tests/agent/test_resource_builder.py index b683a7419..b45e91413 100644 --- a/tests/unit_tests/agent/test_resource_builder.py +++ b/tests/unit_tests/agent/test_resource_builder.py @@ -64,7 +64,7 @@ def make_query( use_llm_model_uuid=None, use_funcs: list | None = None, ): - return SimpleNamespace( + query = SimpleNamespace( query_id=1, bot_uuid='bot_001', launcher_type='person', @@ -85,6 +85,8 @@ def make_query( use_funcs=use_funcs or [], pipeline_uuid='pipeline_001', ) + query._execution_context = TEST_CONTEXT + return query async def build_resources(app, query, descriptor): diff --git a/web/playwright.config.ts b/web/playwright.config.ts index e15c6ef9e..e12e499b2 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? undefined : 1, reporter: process.env.CI ? [['github'], ['list']] : 'list', use: { baseURL: 'http://127.0.0.1:4173', diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 6e09a71d0..b7aab12b6 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -788,11 +788,40 @@ export class BackendClient extends BaseHttpClient { name: string, filepath: string, ): Promise { - return this.getAuthenticatedObjectURL( + return this.getAuthenticatedPluginPageURL( `/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`, ); } + private async getAuthenticatedPluginPageURL(path: string): Promise { + const response = await this.instance.get(path, { + responseType: 'blob', + }); + if (!response.data.type.startsWith('text/html')) { + return URL.createObjectURL(response.data); + } + + const html = await response.data.text(); + const pageSdkPattern = + /]*\bsrc=(['"])\/api\/v1\/plugins\/_sdk\/page-sdk\.js\1[^>]*>\s*<\/script>/i; + if (!pageSdkPattern.test(html)) { + return URL.createObjectURL(response.data); + } + + const sdkResponse = await this.instance.get( + '/api/v1/plugins/_sdk/page-sdk.js', + { responseType: 'text' }, + ); + const inlineSdk = sdkResponse.data.replace(/<\/script/gi, '<\\/script'); + const hydratedHtml = html.replace( + pageSdkPattern, + ``, + ); + return URL.createObjectURL( + new Blob([hydratedHtml], { type: response.data.type }), + ); + } + public getAuthenticatedPluginIconURL( author: string, name: string, diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index b276fcf7e..aa385f593 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -15,6 +15,11 @@ async function submit(page: Page) { await page.getByRole('button', { name: /^Submit$/ }).click(); } +async function selectPlaywrightAdapter(page: Page) { + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Playwright Adapter' }).click(); +} + async function confirmDelete(page: Page) { await page .getByRole('dialog') @@ -83,22 +88,22 @@ test.describe('frontend CRUD smoke flows', () => { }); await page.goto('/home/bots?id=new'); + await selectPlaywrightAdapter(page); await page.locator('input[name="name"]').fill('Viewer Test Bot'); await page .locator('input[name="description"]') .fill('Proves monitoring is ordinary resource visibility.'); - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Playwright Adapter' }).click(); await submit(page); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); - await page.goto('/home/pipelines?id=new'); - await page.locator('input[name="basic.name"]').fill('Viewer Pipeline'); + await page.goto('/home/agents?id=new'); + await page.getByRole('button', { name: /^Pipeline/ }).click(); + await page.locator('input[name="name"]').fill('Viewer Pipeline'); await page - .locator('input[name="basic.description"]') + .locator('input[name="description"]') .fill('Viewer monitoring permission regression.'); await submit(page); - await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/); + await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/); workspace.membership.role = 'viewer'; workspace.permissions = ['member.view', 'resource.view', 'workspace.view']; @@ -110,7 +115,7 @@ test.describe('frontend CRUD smoke flows', () => { await page.getByRole('tab', { name: 'Logs' }).click(); await expect(page.getByText('No logs yet')).toBeVisible(); - await page.goto('/home/pipelines?id=pipeline-1'); + await page.goto('/home/agents?id=pipeline-1'); await expect(page.getByRole('tab', { name: 'Dashboard' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0); await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0); @@ -128,14 +133,13 @@ test.describe('frontend CRUD smoke flows', () => { await installLangBotApiMocks(page, { authenticated: true }); await page.goto('/home/bots?id=new'); + await selectPlaywrightAdapter(page); await expect(page.locator('input[name="name"]')).toBeVisible(); await page.locator('input[name="name"]').fill('Support Bot'); await page .locator('input[name="description"]') .fill('Answers customer support questions.'); - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Playwright Adapter' }).click(); await submit(page); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); @@ -336,9 +340,8 @@ test.describe('bot advanced flows', () => { // Create a bot first await page.goto('/home/bots?id=new'); + await selectPlaywrightAdapter(page); await page.locator('input[name="name"]').fill('Toggle Test Bot'); - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Playwright Adapter' }).click(); await submit(page); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); @@ -365,9 +368,8 @@ test.describe('bot advanced flows', () => { // Create a bot await page.goto('/home/bots?id=new'); + await selectPlaywrightAdapter(page); await page.locator('input[name="name"]').fill('Tab Test Bot'); - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Playwright Adapter' }).click(); await submit(page); // Verify we're on the Configuration tab @@ -400,12 +402,17 @@ test.describe('bot advanced flows', () => { // Create a bot await page.goto('/home/bots?id=new'); + await selectPlaywrightAdapter(page); await page.locator('input[name="name"]').fill('Clean Form Bot'); - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Playwright Adapter' }).click(); await submit(page); - // After creation, save button should be disabled (form is clean) + // Reload the persisted record so post-create initialization has completed. + await page.reload(); + await expect(page.locator('input[name="name"]')).toHaveValue( + 'Clean Form Bot', + ); + + // After loading, save button should be disabled (form is clean) const saveButton = page.getByRole('button', { name: /^Save$/ }); await expect(saveButton).toBeDisabled(); @@ -424,8 +431,7 @@ test.describe('bot advanced flows', () => { await page.goto('/home/bots?id=new'); // Select adapter but leave name empty - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Playwright Adapter' }).click(); + await selectPlaywrightAdapter(page); await submit(page); // Should show validation error for name (zod validation) @@ -690,9 +696,8 @@ test.describe('cross-resource flows', () => { // Create a bot await page.goto('/home/bots?id=new'); + await selectPlaywrightAdapter(page); await page.locator('input[name="name"]').fill('Bound Bot'); - await page.getByRole('combobox').click(); - await page.getByRole('option', { name: 'Playwright Adapter' }).click(); await submit(page); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index b60b3f19b..90ffbc35b 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -17,6 +17,7 @@ interface PipelineMock { description: string; config: JsonRecord; emoji: string; + kind: 'agent' | 'pipeline'; is_default: boolean; updated_at: string; } @@ -62,6 +63,7 @@ interface BotMock { adapter: string; adapter_config: JsonRecord; use_pipeline_uuid?: string; + event_bindings: unknown[]; pipeline_routing_rules: unknown[]; adapter_runtime_values: JsonRecord; updated_at: string; @@ -259,29 +261,52 @@ function makePipeline( data: JsonRecord, uuid = nextId(state, 'pipeline'), ): PipelineMock { + const kind = + data.kind === 'agent' || uuid.startsWith('agent-') ? 'agent' : 'pipeline'; + const runnerId = 'plugin:langbot-team/LocalAgent/default'; + const runnerConfig = { + model: { + primary: 'llm-valid', + fallbacks: [], + }, + 'enable-all-tools': false, + tools: ['unavailable_plugin_tool'], + }; + const defaultConfig = + kind === 'agent' + ? { + runner: { id: runnerId, 'expire-time': 0 }, + runner_config: { [runnerId]: runnerConfig }, + } + : { + ai: { + runner: { id: runnerId, 'expire-time': 0 }, + runner_config: { [runnerId]: runnerConfig }, + }, + trigger: {}, + safety: {}, + output: {}, + }; return { uuid, name: String(data.name || ''), description: String(data.description || ''), - config: (data.config as JsonRecord | undefined) || { - ai: {}, - trigger: {}, - safety: {}, - output: {}, - }, + config: (data.config as JsonRecord | undefined) || defaultConfig, emoji: String(data.emoji || '⚙️'), + kind, is_default: false, updated_at: now(), }; } function pipelineMetadata(withRunnerToolSelector = false) { + const runnerId = 'plugin:langbot-team/LocalAgent/default'; return { configs: [ { name: 'ai', label: { - en_US: 'AI Capabilities', + en_US: 'AI Feature', zh_Hans: 'AI 能力', }, stages: [ @@ -293,21 +318,20 @@ function pipelineMetadata(withRunnerToolSelector = false) { }, config: [ { - id: 'runner', - name: 'runner', + name: 'id', label: { en_US: 'Runner', zh_Hans: '运行器', }, type: 'select', required: true, - default: 'local-agent', + default: runnerId, options: [ { - name: 'local-agent', + name: runnerId, label: { - en_US: 'Built-in Agent', - zh_Hans: '内置 Agent', + en_US: 'Local Agent', + zh_Hans: '本地 Agent', }, }, ], @@ -315,10 +339,10 @@ function pipelineMetadata(withRunnerToolSelector = false) { ], }, { - name: 'local-agent', + name: runnerId, label: { - en_US: 'Built-in Agent', - zh_Hans: '内置 Agent', + en_US: 'Local Agent', + zh_Hans: '本地 Agent', }, config: [ { @@ -358,6 +382,25 @@ function pipelineMetadata(withRunnerToolSelector = false) { }; } +function agentMetadata(withRunnerToolSelector = false) { + const metadata = pipelineMetadata(withRunnerToolSelector); + return { + runner_config: metadata.configs[0], + kinds: [ + { + name: 'agent', + supported_event_patterns: ['*'], + message_only: false, + }, + { + name: 'pipeline', + supported_event_patterns: ['message.*'], + message_only: true, + }, + ], + }; +} + function providerModelList() { return { models: [ @@ -460,6 +503,7 @@ function makeBot( use_pipeline_uuid: data.use_pipeline_uuid ? String(data.use_pipeline_uuid) : undefined, + event_bindings: (data.event_bindings as unknown[] | undefined) || [], pipeline_routing_rules: (data.pipeline_routing_rules as unknown[] | undefined) || [], adapter_runtime_values: { @@ -629,6 +673,70 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) { return fulfillJson(route, { models: [] }); } + if (path === '/api/v1/tools') { + return fulfillJson(route, { + tools: [ + { + name: 'available_plugin_tool', + human_desc: 'Available plugin tool for frontend E2E tests.', + source: 'plugin', + source_id: 'qa/plugin-smoke', + source_name: 'qa/plugin-smoke', + }, + ], + }); + } + + if (path === '/api/v1/agents/_/metadata') { + return fulfillJson(route, agentMetadata(true)); + } + + if (path === '/api/v1/agents') { + if (method === 'POST') { + const agent = makePipeline(state, parseJsonBody(route)); + state.pipelines = [ + ...state.pipelines.filter((item) => item.uuid !== agent.uuid), + agent, + ]; + return fulfillJson(route, { uuid: agent.uuid, kind: agent.kind }); + } + + return fulfillJson(route, { agents: state.pipelines }); + } + + const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/); + if (agentMatch) { + const agentId = decodeURIComponent(agentMatch[1]); + + if (method === 'PUT') { + const agent = makePipeline(state, parseJsonBody(route), agentId); + state.pipelines = [ + ...state.pipelines.filter((item) => item.uuid !== agentId), + agent, + ]; + return fulfillJson(route, {}); + } + + if (method === 'DELETE') { + state.pipelines = state.pipelines.filter((item) => item.uuid !== agentId); + return fulfillJson(route, {}); + } + + const agent = state.pipelines.find((item) => item.uuid === agentId); + return fulfillJson(route, { + agent: + agent || + makePipeline( + state, + { + name: agentId, + kind: agentId.startsWith('agent-') ? 'agent' : 'pipeline', + }, + agentId, + ), + }); + } + if (path === '/api/v1/pipelines/_/metadata') { return fulfillJson(route, pipelineMetadata(state.withRunnerToolSelector)); } diff --git a/web/tests/e2e/home-smoke.spec.ts b/web/tests/e2e/home-smoke.spec.ts index 32623864b..789f6e3c1 100644 --- a/web/tests/e2e/home-smoke.spec.ts +++ b/web/tests/e2e/home-smoke.spec.ts @@ -9,9 +9,9 @@ const appRoutes = [ bodyText: 'Select a bot from the sidebar', }, { - path: '/home/pipelines', - heading: 'Pipelines', - bodyText: 'Select a pipeline from the sidebar', + path: '/home/agents', + heading: 'Processors', + bodyText: 'Select an Agent or Pipeline from the sidebar', }, { path: '/home/extensions', diff --git a/web/tests/e2e/pipeline-monitoring-turns.spec.ts b/web/tests/e2e/pipeline-monitoring-turns.spec.ts index 5ab4f9330..df182890b 100644 --- a/web/tests/e2e/pipeline-monitoring-turns.spec.ts +++ b/web/tests/e2e/pipeline-monitoring-turns.spec.ts @@ -162,7 +162,7 @@ test.describe('pipeline monitoring conversation turns', () => { monitoringData: monitoringData(), }); - await page.goto(`/home/pipelines?id=${pipeline.id}`); + await page.goto(`/home/agents?id=${pipeline.id}`); await page.getByRole('tab', { name: 'Dashboard' }).click(); await expect(page.getByText('2 conversation turns')).toBeVisible(); diff --git a/web/tests/e2e/plugin-page-auth.spec.ts b/web/tests/e2e/plugin-page-auth.spec.ts index fb90d2f18..a50f5eb07 100644 --- a/web/tests/e2e/plugin-page-auth.spec.ts +++ b/web/tests/e2e/plugin-page-auth.spec.ts @@ -62,6 +62,15 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({ }); let authenticatedAssetRequests = 0; + let pageSdkRequests = 0; + await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => { + pageSdkRequests += 1; + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.langbot = { onReady(callback) { callback(); } };', + }); + }); await page.route( '**/api/v1/plugins/langbot-team/LangRAG/authenticated-assets/**', async (route) => { @@ -69,7 +78,14 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({ await route.fulfill({ status: 200, contentType: 'text/html', - body: '

LangRAG Observability

', + body: `
+ + + `, }); }, ); @@ -84,5 +100,6 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({ .getByRole('heading', { name: 'LangRAG Observability' }), ).toBeVisible(); expect(authenticatedAssetRequests).toBeGreaterThan(0); + expect(pageSdkRequests).toBeGreaterThan(0); await expect(page.getByText('Loading...')).toHaveCount(0); }); diff --git a/web/tests/e2e/quota-create-actions.spec.ts b/web/tests/e2e/quota-create-actions.spec.ts index 3400b33cc..2d275c6e2 100644 --- a/web/tests/e2e/quota-create-actions.spec.ts +++ b/web/tests/e2e/quota-create-actions.spec.ts @@ -58,13 +58,14 @@ test('quota-reached create actions are disabled and explain the current limit', })), }), ); - await page.route('**/api/v1/pipelines**', (route) => + await page.route('**/api/v1/agents', (route) => fulfill(route, { - pipelines: Array.from({ length: 3 }, (_, index) => ({ + agents: Array.from({ length: 3 }, (_, index) => ({ uuid: `pipeline-${index}`, name: `Pipeline ${index + 1}`, description: '', emoji: '⚙️', + kind: 'pipeline', updated_at: new Date().toISOString(), })), }), @@ -103,8 +104,8 @@ test('quota-reached create actions are disabled and explain the current limit', name: 'Create Bots', exact: true, }); - const pipelineCreate = page.getByRole('button', { - name: 'Create Pipelines', + const processorCreate = page.getByRole('button', { + name: 'Create Processors', exact: true, }); const knowledgeCreate = page.getByRole('button', { @@ -117,7 +118,7 @@ test('quota-reached create actions are disabled and explain the current limit', }); await expect(botCreate).toBeDisabled(); - await expect(pipelineCreate).toBeDisabled(); + await expect(processorCreate).toBeDisabled(); await expect(knowledgeCreate).toBeDisabled(); await expect(addExtension).toBeEnabled();