fix(agent): stabilize post-merge release paths

This commit is contained in:
huanghuoguoguo
2026-08-04 17:04:40 +08:00
parent 5c5ed940a9
commit 444024c5b4
19 changed files with 319 additions and 57 deletions
@@ -81,7 +81,8 @@ async function api(page, path, options = {}) {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Workspace-Id": localStorage.getItem("langbot_active_workspace_uuid") || "", "X-Workspace-Id":
localStorage.getItem("langbot_active_workspace_uuid") || "",
}, },
body: body:
options.body === undefined ? undefined : JSON.stringify(options.body), options.body === undefined ? undefined : JSON.stringify(options.body),
@@ -205,9 +206,7 @@ try {
result.visible_signals.push("bot-created", "adapter-enabled"); result.visible_signals.push("bot-created", "adapter-enabled");
await page.getByRole("button", { name: /Next|下一步|次へ/ }).click(); await page.getByRole("button", { name: /Next|下一步|次へ/ }).click();
const localAgentTitle = page const localAgentTitle = page.getByText(/^(Local Agent|本地 Agent)$/).first();
.getByText(/^(Local Agent|本地 Agent)$/)
.first();
const localAgentCard = localAgentTitle.locator( const localAgentCard = localAgentTitle.locator(
'xpath=ancestor::*[@data-slot="card"][1]', 'xpath=ancestor::*[@data-slot="card"][1]',
); );
@@ -316,6 +315,24 @@ try {
} catch (error) { } catch (error) {
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail"; if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
result.reason = result.reason || error.message; 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); if (browser?.page) await safeScreenshot(browser.page, paths.screenshot);
} finally { } finally {
if (browser?.page && token) { if (browser?.page && token) {
+6 -2
View File
@@ -1003,8 +1003,12 @@
"rag" "rag"
], ],
"automation": "scripts/e2e/langrag-kb-retrieve.mjs", "automation": "scripts/e2e/langrag-kb-retrieve.mjs",
"setup_automation": [], "setup_automation": [
"setup_provides_env": [], "node:scripts/e2e/ensure-langrag-sentinel-kb.mjs --write-env"
],
"setup_provides_env": [
"LANGBOT_LOCAL_AGENT_RAG_KB_UUID"
],
"evidence_required": [ "evidence_required": [
"ui", "ui",
"screenshot", "screenshot",
@@ -25,6 +25,10 @@ automation_env:
automation_env_any: automation_env_any:
- LANGBOT_LOCAL_AGENT_RAG_KB_UUID|LANGBOT_RAG_KB_UUID - LANGBOT_LOCAL_AGENT_RAG_KB_UUID|LANGBOT_RAG_KB_UUID
automation_expected_text: "azalea-cobalt-7421" 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: preconditions:
- "LangRAG is installed and initialized in the active LangBot instance." - "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." - "A working embedding model is available, preferably chroma-all-MiniLM-L6-v2 for local repeatability."
@@ -8,13 +8,20 @@ from pathlib import Path
def has_initial_failure_section(report: str) -> bool: def has_initial_failure_section(report: str) -> bool:
folded = report.casefold() folded = report.casefold()
return any( if any(
marker in folded marker in folded
for marker in ( for marker in (
"initial fail", "initial fail",
"initially fail", "initially fail",
"baseline 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
) )
@@ -26,9 +26,23 @@ class ComplexAgentTaskVerifyTests(unittest.TestCase):
def test_accepts_baseline_failure_heading(self) -> None: def test_accepts_baseline_failure_heading(self) -> None:
self.assertTrue(MODULE.has_initial_failure_section("## Baseline failing tests\n- test_price")) 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: def test_rejects_report_without_failure_section(self) -> None:
self.assertFalse(MODULE.has_initial_failure_section("## Verification\nAll tests pass.")) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -90,6 +90,13 @@ class AgentRunOrchestrator:
execution_context = get_query_execution_context(execution_query) execution_context = get_query_execution_context(execution_query)
if not isinstance(execution_context, ExecutionContext): if not isinstance(execution_context, ExecutionContext):
raise ValueError('Agent run requires a trusted 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( descriptor = await self.registry.get(
execution_context, execution_context,
runner_id, runner_id,
@@ -31,6 +31,7 @@ from .host_models import (
from .config_resolver import RunnerConfigResolver from .config_resolver import RunnerConfigResolver
from .resource_policy import ResourcePolicyProjector from .resource_policy import ResourcePolicyProjector
from . import events as runner_events from . import events as runner_events
from ...pipeline.pool import get_query_execution_context
from ...provider.tools.toolmgr import TOOL_SOURCE_REFS_QUERY_KEY from ...provider.tools.toolmgr import TOOL_SOURCE_REFS_QUERY_KEY
@@ -81,6 +82,7 @@ class QueryEntryAdapter:
# Build raw ref # Build raw ref
raw_ref = cls._build_raw_ref(query) raw_ref = cls._build_raw_ref(query)
execution_context = get_query_execution_context(query)
return AgentEventEnvelope( return AgentEventEnvelope(
event_id=event.event_id or str(query.query_id), event_id=event.event_id or str(query.query_id),
@@ -89,7 +91,7 @@ class QueryEntryAdapter:
source='host_adapter', source='host_adapter',
source_event_type=event.source_event_type, source_event_type=event.source_event_type,
bot_id=query.bot_uuid, bot_id=query.bot_uuid,
workspace_id=getattr(query, 'workspace_uuid', None), workspace_id=execution_context.workspace_uuid,
conversation_id=conversation.conversation_id, conversation_id=conversation.conversation_id,
thread_id=conversation.thread_id, thread_id=conversation.thread_id,
actor=actor, actor=actor,
@@ -1566,6 +1566,11 @@ def _execution_context_from_tenant(context: TenantContext) -> ExecutionContext:
def _execution_context_from_query(query: pipeline_query.Query) -> 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( return _execution_context_from_tenant(
ExecutionContext( ExecutionContext(
instance_uuid=str(getattr(query, 'instance_uuid', '') or ''), instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
@@ -30,6 +30,7 @@ from langbot.pkg.agent.runner.binding_resolver import (
AgentBindingResolver, AgentBindingResolver,
AgentBindingResolutionError, AgentBindingResolutionError,
) )
from langbot.pkg.api.http.context import ExecutionContext
class TestQueryToEventEnvelope: class TestQueryToEventEnvelope:
@@ -350,9 +351,17 @@ def mock_query():
"""Create a mock query for testing.""" """Create a mock query for testing."""
query = Mock() query = Mock()
query.query_id = 123 query.query_id = 123
query.query_uuid = None
query.workspace_uuid = 'workspace-test' query.workspace_uuid = 'workspace-test'
query.bot_uuid = 'bot-uuid-123' query.bot_uuid = 'bot-uuid-123'
query.pipeline_uuid = 'pipeline-uuid-456' 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_type = Mock(value='person')
query.launcher_id = 'launcher-123' query.launcher_id = 'launcher-123'
query.sender_id = 'sender-123' query.sender_id = 'sender-123'
@@ -399,9 +408,17 @@ def mock_query_no_session():
"""Create a mock Query without session.""" """Create a mock Query without session."""
query = Mock() query = Mock()
query.query_id = 456 query.query_id = 456
query.query_uuid = None
query.workspace_uuid = 'workspace-test' query.workspace_uuid = 'workspace-test'
query.bot_uuid = 'bot-uuid-456' query.bot_uuid = 'bot-uuid-456'
query.pipeline_uuid = 'pipeline-uuid-789' 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_type = Mock(value='person')
query.launcher_id = 'launcher-456' query.launcher_id = 'launcher-456'
query.sender_id = 'sender-456' query.sender_id = 'sender-456'
@@ -1012,8 +1012,22 @@ class TestQueryEntrySessionQueryId:
] ]
) )
ap = FakeApplication(plugin_connector, db_engine) 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( 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) ap.tool_mgr = types.SimpleNamespace(mcp_tool_loader=mcp_loader)
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
@@ -1091,6 +1105,14 @@ class TestQueryEntrySessionQueryId:
assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text'] assert 'Pinned documentation' in plugin_connector.contexts[0]['input']['contents'][0]['text']
assert event.input.text == 'hello' assert event.input.text == 'hello'
assert event.input.contents[0].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) assert 'Pinned documentation' not in str(execution_query.user_message.content)
mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query) mcp_loader.build_resource_context_for_query.assert_awaited_once_with(execution_query)
@@ -64,7 +64,7 @@ def make_query(
use_llm_model_uuid=None, use_llm_model_uuid=None,
use_funcs: list | None = None, use_funcs: list | None = None,
): ):
return SimpleNamespace( query = SimpleNamespace(
query_id=1, query_id=1,
bot_uuid='bot_001', bot_uuid='bot_001',
launcher_type='person', launcher_type='person',
@@ -85,6 +85,8 @@ def make_query(
use_funcs=use_funcs or [], use_funcs=use_funcs or [],
pipeline_uuid='pipeline_001', pipeline_uuid='pipeline_001',
) )
query._execution_context = TEST_CONTEXT
return query
async def build_resources(app, query, descriptor): async def build_resources(app, query, descriptor):
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0, retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? undefined : 1,
reporter: process.env.CI ? [['github'], ['list']] : 'list', reporter: process.env.CI ? [['github'], ['list']] : 'list',
use: { use: {
baseURL: 'http://127.0.0.1:4173', baseURL: 'http://127.0.0.1:4173',
+30 -1
View File
@@ -788,11 +788,40 @@ export class BackendClient extends BaseHttpClient {
name: string, name: string,
filepath: string, filepath: string,
): Promise<string> { ): Promise<string> {
return this.getAuthenticatedObjectURL( return this.getAuthenticatedPluginPageURL(
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`, `/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
); );
} }
private async getAuthenticatedPluginPageURL(path: string): Promise<string> {
const response = await this.instance.get<Blob>(path, {
responseType: 'blob',
});
if (!response.data.type.startsWith('text/html')) {
return URL.createObjectURL(response.data);
}
const html = await response.data.text();
const pageSdkPattern =
/<script\b[^>]*\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<string>(
'/api/v1/plugins/_sdk/page-sdk.js',
{ responseType: 'text' },
);
const inlineSdk = sdkResponse.data.replace(/<\/script/gi, '<\\/script');
const hydratedHtml = html.replace(
pageSdkPattern,
`<script>${inlineSdk}</script>`,
);
return URL.createObjectURL(
new Blob([hydratedHtml], { type: response.data.type }),
);
}
public getAuthenticatedPluginIconURL( public getAuthenticatedPluginIconURL(
author: string, author: string,
name: string, name: string,
+25 -20
View File
@@ -15,6 +15,11 @@ async function submit(page: Page) {
await page.getByRole('button', { name: /^Submit$/ }).click(); 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) { async function confirmDelete(page: Page) {
await page await page
.getByRole('dialog') .getByRole('dialog')
@@ -83,22 +88,22 @@ test.describe('frontend CRUD smoke flows', () => {
}); });
await page.goto('/home/bots?id=new'); 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="name"]').fill('Viewer Test Bot');
await page await page
.locator('input[name="description"]') .locator('input[name="description"]')
.fill('Proves monitoring is ordinary resource visibility.'); .fill('Proves monitoring is ordinary resource visibility.');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page); await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await page.goto('/home/pipelines?id=new'); await page.goto('/home/agents?id=new');
await page.locator('input[name="basic.name"]').fill('Viewer Pipeline'); await page.getByRole('button', { name: /^Pipeline/ }).click();
await page.locator('input[name="name"]').fill('Viewer Pipeline');
await page await page
.locator('input[name="basic.description"]') .locator('input[name="description"]')
.fill('Viewer monitoring permission regression.'); .fill('Viewer monitoring permission regression.');
await submit(page); 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.membership.role = 'viewer';
workspace.permissions = ['member.view', 'resource.view', 'workspace.view']; 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 page.getByRole('tab', { name: 'Logs' }).click();
await expect(page.getByText('No logs yet')).toBeVisible(); 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: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0); await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^Save$/ })).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 installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/bots?id=new'); await page.goto('/home/bots?id=new');
await selectPlaywrightAdapter(page);
await expect(page.locator('input[name="name"]')).toBeVisible(); await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('Support Bot'); await page.locator('input[name="name"]').fill('Support Bot');
await page await page
.locator('input[name="description"]') .locator('input[name="description"]')
.fill('Answers customer support questions.'); .fill('Answers customer support questions.');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page); await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
@@ -336,9 +340,8 @@ test.describe('bot advanced flows', () => {
// Create a bot first // Create a bot first
await page.goto('/home/bots?id=new'); await page.goto('/home/bots?id=new');
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('Toggle Test Bot'); 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 submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
@@ -365,9 +368,8 @@ test.describe('bot advanced flows', () => {
// Create a bot // Create a bot
await page.goto('/home/bots?id=new'); await page.goto('/home/bots?id=new');
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('Tab Test Bot'); 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); await submit(page);
// Verify we're on the Configuration tab // Verify we're on the Configuration tab
@@ -400,12 +402,17 @@ test.describe('bot advanced flows', () => {
// Create a bot // Create a bot
await page.goto('/home/bots?id=new'); await page.goto('/home/bots?id=new');
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('Clean Form Bot'); 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); 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$/ }); const saveButton = page.getByRole('button', { name: /^Save$/ });
await expect(saveButton).toBeDisabled(); await expect(saveButton).toBeDisabled();
@@ -424,8 +431,7 @@ test.describe('bot advanced flows', () => {
await page.goto('/home/bots?id=new'); await page.goto('/home/bots?id=new');
// Select adapter but leave name empty // Select adapter but leave name empty
await page.getByRole('combobox').click(); await selectPlaywrightAdapter(page);
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page); await submit(page);
// Should show validation error for name (zod validation) // Should show validation error for name (zod validation)
@@ -690,9 +696,8 @@ test.describe('cross-resource flows', () => {
// Create a bot // Create a bot
await page.goto('/home/bots?id=new'); await page.goto('/home/bots?id=new');
await selectPlaywrightAdapter(page);
await page.locator('input[name="name"]').fill('Bound Bot'); 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 submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/); await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
+124 -16
View File
@@ -17,6 +17,7 @@ interface PipelineMock {
description: string; description: string;
config: JsonRecord; config: JsonRecord;
emoji: string; emoji: string;
kind: 'agent' | 'pipeline';
is_default: boolean; is_default: boolean;
updated_at: string; updated_at: string;
} }
@@ -62,6 +63,7 @@ interface BotMock {
adapter: string; adapter: string;
adapter_config: JsonRecord; adapter_config: JsonRecord;
use_pipeline_uuid?: string; use_pipeline_uuid?: string;
event_bindings: unknown[];
pipeline_routing_rules: unknown[]; pipeline_routing_rules: unknown[];
adapter_runtime_values: JsonRecord; adapter_runtime_values: JsonRecord;
updated_at: string; updated_at: string;
@@ -259,29 +261,52 @@ function makePipeline(
data: JsonRecord, data: JsonRecord,
uuid = nextId(state, 'pipeline'), uuid = nextId(state, 'pipeline'),
): PipelineMock { ): 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 { return {
uuid, uuid,
name: String(data.name || ''), name: String(data.name || ''),
description: String(data.description || ''), description: String(data.description || ''),
config: (data.config as JsonRecord | undefined) || { config: (data.config as JsonRecord | undefined) || defaultConfig,
ai: {},
trigger: {},
safety: {},
output: {},
},
emoji: String(data.emoji || '⚙️'), emoji: String(data.emoji || '⚙️'),
kind,
is_default: false, is_default: false,
updated_at: now(), updated_at: now(),
}; };
} }
function pipelineMetadata(withRunnerToolSelector = false) { function pipelineMetadata(withRunnerToolSelector = false) {
const runnerId = 'plugin:langbot-team/LocalAgent/default';
return { return {
configs: [ configs: [
{ {
name: 'ai', name: 'ai',
label: { label: {
en_US: 'AI Capabilities', en_US: 'AI Feature',
zh_Hans: 'AI 能力', zh_Hans: 'AI 能力',
}, },
stages: [ stages: [
@@ -293,21 +318,20 @@ function pipelineMetadata(withRunnerToolSelector = false) {
}, },
config: [ config: [
{ {
id: 'runner', name: 'id',
name: 'runner',
label: { label: {
en_US: 'Runner', en_US: 'Runner',
zh_Hans: '运行器', zh_Hans: '运行器',
}, },
type: 'select', type: 'select',
required: true, required: true,
default: 'local-agent', default: runnerId,
options: [ options: [
{ {
name: 'local-agent', name: runnerId,
label: { label: {
en_US: 'Built-in Agent', en_US: 'Local Agent',
zh_Hans: '内置 Agent', zh_Hans: '本地 Agent',
}, },
}, },
], ],
@@ -315,10 +339,10 @@ function pipelineMetadata(withRunnerToolSelector = false) {
], ],
}, },
{ {
name: 'local-agent', name: runnerId,
label: { label: {
en_US: 'Built-in Agent', en_US: 'Local Agent',
zh_Hans: '内置 Agent', zh_Hans: '本地 Agent',
}, },
config: [ 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() { function providerModelList() {
return { return {
models: [ models: [
@@ -460,6 +503,7 @@ function makeBot(
use_pipeline_uuid: data.use_pipeline_uuid use_pipeline_uuid: data.use_pipeline_uuid
? String(data.use_pipeline_uuid) ? String(data.use_pipeline_uuid)
: undefined, : undefined,
event_bindings: (data.event_bindings as unknown[] | undefined) || [],
pipeline_routing_rules: pipeline_routing_rules:
(data.pipeline_routing_rules as unknown[] | undefined) || [], (data.pipeline_routing_rules as unknown[] | undefined) || [],
adapter_runtime_values: { adapter_runtime_values: {
@@ -629,6 +673,70 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { models: [] }); 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') { if (path === '/api/v1/pipelines/_/metadata') {
return fulfillJson(route, pipelineMetadata(state.withRunnerToolSelector)); return fulfillJson(route, pipelineMetadata(state.withRunnerToolSelector));
} }
+3 -3
View File
@@ -9,9 +9,9 @@ const appRoutes = [
bodyText: 'Select a bot from the sidebar', bodyText: 'Select a bot from the sidebar',
}, },
{ {
path: '/home/pipelines', path: '/home/agents',
heading: 'Pipelines', heading: 'Processors',
bodyText: 'Select a pipeline from the sidebar', bodyText: 'Select an Agent or Pipeline from the sidebar',
}, },
{ {
path: '/home/extensions', path: '/home/extensions',
@@ -162,7 +162,7 @@ test.describe('pipeline monitoring conversation turns', () => {
monitoringData: monitoringData(), 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 page.getByRole('tab', { name: 'Dashboard' }).click();
await expect(page.getByText('2 conversation turns')).toBeVisible(); await expect(page.getByText('2 conversation turns')).toBeVisible();
+18 -1
View File
@@ -62,6 +62,15 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
}); });
let authenticatedAssetRequests = 0; 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( await page.route(
'**/api/v1/plugins/langbot-team/LangRAG/authenticated-assets/**', '**/api/v1/plugins/langbot-team/LangRAG/authenticated-assets/**',
async (route) => { async (route) => {
@@ -69,7 +78,14 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: 'text/html', contentType: 'text/html',
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>', body: `<!doctype html><html><body><main></main>
<script src="/api/v1/plugins/_sdk/page-sdk.js"></script>
<script>
langbot.onReady(() => {
document.querySelector('main').innerHTML = '<h1>LangRAG Observability</h1>';
});
</script>
</body></html>`,
}); });
}, },
); );
@@ -84,5 +100,6 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
.getByRole('heading', { name: 'LangRAG Observability' }), .getByRole('heading', { name: 'LangRAG Observability' }),
).toBeVisible(); ).toBeVisible();
expect(authenticatedAssetRequests).toBeGreaterThan(0); expect(authenticatedAssetRequests).toBeGreaterThan(0);
expect(pageSdkRequests).toBeGreaterThan(0);
await expect(page.getByText('Loading...')).toHaveCount(0); await expect(page.getByText('Loading...')).toHaveCount(0);
}); });
+6 -5
View File
@@ -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, { fulfill(route, {
pipelines: Array.from({ length: 3 }, (_, index) => ({ agents: Array.from({ length: 3 }, (_, index) => ({
uuid: `pipeline-${index}`, uuid: `pipeline-${index}`,
name: `Pipeline ${index + 1}`, name: `Pipeline ${index + 1}`,
description: '', description: '',
emoji: '⚙️', emoji: '⚙️',
kind: 'pipeline',
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
})), })),
}), }),
@@ -103,8 +104,8 @@ test('quota-reached create actions are disabled and explain the current limit',
name: 'Create Bots', name: 'Create Bots',
exact: true, exact: true,
}); });
const pipelineCreate = page.getByRole('button', { const processorCreate = page.getByRole('button', {
name: 'Create Pipelines', name: 'Create Processors',
exact: true, exact: true,
}); });
const knowledgeCreate = page.getByRole('button', { 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(botCreate).toBeDisabled();
await expect(pipelineCreate).toBeDisabled(); await expect(processorCreate).toBeDisabled();
await expect(knowledgeCreate).toBeDisabled(); await expect(knowledgeCreate).toBeDisabled();
await expect(addExtension).toBeEnabled(); await expect(addExtension).toBeEnabled();