feat(browser): 增加 sequence 组合块能力

新增单层 sequence 容器以组合现有 orchestration blocks,并补充 sequence 内失败定位能力。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
walker1211
2026-04-20 18:55:24 +08:00
co-authored by Claude Opus 4.7
parent 26baa019e3
commit 0038b063ac
2 changed files with 374 additions and 0 deletions
+39
View File
@@ -2438,6 +2438,9 @@ function formatOrchestrationSummary(session) {
`error: ${session.error || 'none'}`,
`status: ${session.status}`,
];
if (session.failedSequenceStepIndex !== null && session.failedSequenceStepIndex !== undefined) {
lines.push(`failedSequenceStepIndex: ${session.failedSequenceStepIndex}`);
}
if (session.errorDetails?.failedAssertionIndex !== undefined) {
lines.push(`failedAssertionIndex: ${session.errorDetails.failedAssertionIndex}`);
lines.push(`field: ${session.errorDetails.field}`);
@@ -2523,6 +2526,7 @@ const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([
'wait_for_then_press_key',
'run_replay_sequence',
'assert_query',
'sequence',
]);
const SUPPORTED_ASSERTION_OPERATORS = new Set(['equals', 'contains', 'gt', 'gte', 'lt', 'lte']);
@@ -2669,6 +2673,21 @@ function validateOrchestrationBlock(block) {
}
}
function requireSequenceSteps(block) {
const steps = Array.isArray(block.args?.steps) ? block.args.steps : null;
if (!steps || steps.length === 0) {
throw new Error('sequence steps must be a non-empty array');
}
return steps;
}
function validateSequenceStep(step) {
validateOrchestrationBlock(step);
if (step.type === 'sequence') {
throw new Error('sequence does not support nested sequence blocks');
}
}
function normalizeRecordedEvent(pageId, event) {
if (!event || typeof event !== 'object') {
return null;
@@ -5116,9 +5135,28 @@ async function executeOrchestrationBlock(session, block) {
return;
}
if (block.type === 'sequence') {
await executeSequenceSteps(session, block);
return;
}
throw new Error('unsupported orchestration block type');
}
async function executeSequenceSteps(session, block) {
const steps = requireSequenceSteps(block);
for (let index = 0; index < steps.length; index += 1) {
const step = steps[index];
validateSequenceStep(step);
try {
await executeOrchestrationBlock(session, step);
} catch (error) {
session.failedSequenceStepIndex = index;
throw error;
}
}
}
async function executeOrchestrationBlocks(session) {
for (let index = 0; index < session.blocks.length; index += 1) {
const block = session.blocks[index];
@@ -5154,6 +5192,7 @@ async function handleStartOrchestration(toolArgs) {
completedBlocks: 0,
currentBlockIndex: 0,
failedBlockIndex: null,
failedSequenceStepIndex: null,
error: null,
blocks,
};
@@ -6410,6 +6410,341 @@ describe('ccs-browser MCP server', () => {
expect(text).toContain('failedBlockIndex: 0');
});
it('runs a single-layer sequence with two successful blocks', async () => {
const pages: MockPageState[] = [
{
id: 'page-1',
title: 'Sequence Page',
currentUrl: 'https://example.com/sequence',
click: {
'#menu': {},
},
wait: {
selectorSnapshots: {
'#menu': [
[
{
exists: true,
rect: {
x: 20,
y: 30,
width: 100,
height: 40,
top: 30,
right: 120,
bottom: 70,
left: 20,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
],
],
},
},
query: {
'#menu': {
exists: true,
connected: true,
rect: {
x: 20,
y: 30,
width: 100,
height: 40,
top: 30,
right: 120,
bottom: 70,
left: 20,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
'#status': {
exists: true,
connected: true,
innerText: 'ready state',
textContent: 'ready state',
display: 'block',
visibility: 'visible',
opacity: '1',
rect: {
x: 20,
y: 80,
width: 100,
height: 40,
top: 80,
right: 120,
bottom: 120,
left: 20,
},
},
},
},
];
const responses = await runMcpRequests(pages, [
{
jsonrpc: '2.0',
id: 1401,
method: 'tools/call',
params: {
name: 'browser_start_orchestration',
arguments: {
blocks: [
createOrchestrationBlock({
type: 'sequence',
args: {
steps: [
createOrchestrationBlock({
type: 'wait_for_then_click',
args: {
wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 },
click: { selector: '#menu' },
},
}),
createOrchestrationBlock({
type: 'assert_query',
args: {
query: { selector: '#status', fields: ['innerText'] },
assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }],
},
}),
],
},
}),
],
},
},
},
]);
expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('status: completed');
expect(getResponseText(responses.find((message) => message.id === 1401))).toContain('completedBlocks: 1');
});
it('reports failedSequenceStepIndex when a sequence step fails', async () => {
const pages: MockPageState[] = [
{
id: 'page-1',
title: 'Sequence Failure Page',
currentUrl: 'https://example.com/sequence-failure',
click: {
'#menu': {},
},
wait: {
selectorSnapshots: {
'#menu': [
[
{
exists: true,
rect: {
x: 20,
y: 30,
width: 100,
height: 40,
top: 30,
right: 120,
bottom: 70,
left: 20,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
],
],
},
},
},
];
const responses = await runMcpRequests(pages, [
{
jsonrpc: '2.0',
id: 1402,
method: 'tools/call',
params: {
name: 'browser_start_orchestration',
arguments: {
blocks: [
createOrchestrationBlock({
type: 'sequence',
args: {
steps: [
createOrchestrationBlock({
type: 'wait_for_then_click',
args: {
wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 },
click: { selector: '#menu' },
},
}),
createOrchestrationBlock({
type: 'assert_query',
args: {
query: { selector: '#missing', fields: ['exists'] },
assertions: [{ field: 'exists', op: 'equals', value: true }],
},
}),
],
},
}),
],
},
},
},
{
jsonrpc: '2.0',
id: 1403,
method: 'tools/call',
params: { name: 'browser_get_orchestration', arguments: {} },
},
]);
const text = getResponseText(responses.find((message) => message.id === 1403));
expect(text).toContain('status: failed');
expect(text).toContain('failedBlockIndex: 0');
expect(text).toContain('failedSequenceStepIndex: 1');
});
it('rejects empty sequence steps and nested sequence blocks', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Sequence Validation Page', currentUrl: 'https://example.com/sequence-validation' }],
[
{
jsonrpc: '2.0',
id: 1404,
method: 'tools/call',
params: {
name: 'browser_start_orchestration',
arguments: {
blocks: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })],
},
},
},
{
jsonrpc: '2.0',
id: 1405,
method: 'tools/call',
params: {
name: 'browser_start_orchestration',
arguments: {
blocks: [
createOrchestrationBlock({
type: 'sequence',
args: {
steps: [createOrchestrationBlock({ type: 'sequence', args: { steps: [] } })],
},
}),
],
},
},
},
]
);
expect(getResponseText(responses.find((message) => message.id === 1404))).toContain('sequence steps must be a non-empty array');
expect(getResponseText(responses.find((message) => message.id === 1405))).toContain('sequence does not support nested sequence blocks');
});
it('runs a top-level block followed by a sequence in order', async () => {
const pages: MockPageState[] = [
{
id: 'page-1',
title: 'Mixed Sequence Page',
currentUrl: 'https://example.com/mixed-sequence',
click: {
'#menu': {},
},
wait: {
selectorSnapshots: {
'#menu': [
[
{
exists: true,
rect: {
x: 20,
y: 30,
width: 100,
height: 40,
top: 30,
right: 120,
bottom: 70,
left: 20,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
],
],
},
},
query: {
'#status': {
exists: true,
connected: true,
innerText: 'ready state',
textContent: 'ready state',
display: 'block',
visibility: 'visible',
opacity: '1',
rect: {
x: 20,
y: 80,
width: 100,
height: 40,
top: 80,
right: 120,
bottom: 120,
left: 20,
},
},
},
},
];
const responses = await runMcpRequests(pages, [
{
jsonrpc: '2.0',
id: 1406,
method: 'tools/call',
params: {
name: 'browser_start_orchestration',
arguments: {
blocks: [
createOrchestrationBlock({
type: 'assert_query',
args: {
query: { selector: '#status', fields: ['innerText'] },
assertions: [{ field: 'innerText', op: 'contains', value: 'ready' }],
},
}),
createOrchestrationBlock({
type: 'sequence',
args: {
steps: [
createOrchestrationBlock({
type: 'wait_for_then_click',
args: {
wait: { selector: '#menu', condition: { kind: 'visibility' }, timeoutMs: 1000 },
click: { selector: '#menu' },
},
}),
],
},
}),
],
},
},
},
]);
expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('status: completed');
expect(getResponseText(responses.find((message) => message.id === 1406))).toContain('completedBlocks: 2');
});
it('passes assert_query with multiple structured assertions', async () => {
const responses = await runMcpRequests(
[