Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/route-input-usage-prefill-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"agent-bundle": patch
---

Route catalog and MCP prefill correctness fixes from post-merge review: CLI
usage summaries mark repeatable named options with the same ` ...` operand
suffix the generated help prints; optional booleans without a schema default
keep an unset state (a three-state omitted/true/false control) instead of
submitting an explicit `false` the handler can observe; and a stale
Routes-page prefill naming a tool the server no longer advertises surfaces a
missing-tool notice instead of silently attaching the prepared arguments to
the first advertised tool.
8 changes: 8 additions & 0 deletions packages/workbench/src/mcp/mcp-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,14 @@
color: #a8b8ca;
}

.mcp-page-missing-tool {
background: #3a1f24;
border-left: 3px solid #f28b82;
color: #ffd9d4;
margin: 0 0 0.75rem;
padding: 0.55rem 0.75rem;
}

@media (max-width: 42rem) {
.mcp-page-heading,
.mcp-page-catalog li,
Expand Down
11 changes: 10 additions & 1 deletion packages/workbench/src/mcp/mcp-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1240,7 +1240,13 @@ export const McpPage = (props: McpPageProps) => {
const prompts = catalogItems(model.catalogs.prompts, 'Prompt');
const resources = catalogItems(model.catalogs.resources, 'Resource');
const resourceTemplates = catalogItems(model.catalogs.resourceTemplates, 'Resource template');
const selectedTool = tools.find((item) => item.name === toolName) ?? tools[0];
const matchedTool = tools.find((item) => item.name === toolName);
const selectedTool = matchedTool ?? (initialToolPrefill === undefined && toolName === '' ? tools[0] : undefined);
const missingToolName = initialToolPrefill !== undefined
&& model.phase === 'ready'
&& !tools.some((item) => item.name === initialToolPrefill.toolName)
? initialToolPrefill.toolName
: undefined;
const selectedPrompt = prompts.find((item) => item.name === promptName) ?? prompts[0];
const active = Object.values(model.activeRequests);
const controls = mcpPageSessionControls(model.phase, pendingActions, onResetSession !== undefined, serverCatalogState);
Expand Down Expand Up @@ -1428,6 +1434,9 @@ export const McpPage = (props: McpPageProps) => {
<strong>Tool call prefilled from Routes</strong>
<p>{initialToolPrefill.serverName} · {initialToolPrefill.toolName}</p>
<pre><code>{display(initialToolPrefill.arguments)}</code></pre>
{missingToolName === undefined ? undefined : <p className="mcp-page-missing-tool">
The server no longer advertises the &quot;{missingToolName}&quot; tool. The prepared arguments were not applied to another tool.
</p>}
<p>Open the session and use the existing call control when you are ready. Nothing runs automatically.</p>
</aside>}
<div className="mcp-page-catalog-grid">
Expand Down
33 changes: 24 additions & 9 deletions packages/workbench/src/routes/routes-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,24 +213,35 @@ export const routeCatalogHasKind = (catalog: RouteCatalog, kind: RouteManifestKi
export const routeCatalogServerCount = (catalog: RouteCatalog): number =>
catalog.servers.length;

const defaultDraftValue = (schema: RouteInputPropertySchema): RouteInputDraftValue => {
/**
* An optional boolean without a schema default stays out of the draft: a
* `false` initialization would submit `{ key: false }` where the author's
* handler observes an omitted property, defeating optional semantics.
*/
const defaultDraftValue = (
schema: RouteInputPropertySchema,
required: boolean,
): RouteInputDraftValue | undefined => {
if (schema.default !== undefined) {
if (Array.isArray(schema.default)) {
return Object.freeze(schema.default.map((value) => typeof value === 'boolean' ? value : String(value)));
}
return typeof schema.default === 'boolean' ? schema.default : String(schema.default);
}
if (schema.type === 'boolean') return false;
if (schema.type === 'boolean') return required ? false : undefined;
if (schema.type === 'array') return Object.freeze([]);
return '';
};

export const createRouteInputDraft = (schema: RouteInputSchema): RouteInputDraft => Object.freeze(
Object.fromEntries(Object.keys(schema.properties).sort().map((key) => [
key,
defaultDraftValue(schema.properties[key]!),
])),
);
export const createRouteInputDraft = (schema: RouteInputSchema): RouteInputDraft => {
const required = new Set(schema.required ?? []);
return Object.freeze(Object.fromEntries(
Object.keys(schema.properties).sort().flatMap((key) => {
const value = defaultDraftValue(schema.properties[key]!, required.has(key));
return value === undefined ? [] : [[key, value] as const];
}),
));
};

export const routeInputLabel = (key: string): string => {
const words = key
Expand Down Expand Up @@ -357,7 +368,11 @@ export const cliCommandUsage = (command: RouteManifestCliCommand): string => {
: `[${option.option}${option.repeated ? '...' : ''}]`);
const flags = command.options.filter((option) => option.positional === undefined)
.map((option) => {
const value = option.kind === 'boolean' ? `--${option.option}` : `--${option.option} ${cliOperand(option)}`;
// Booleans are flags and the grammar rejects boolean arrays, so only
// value-carrying options can repeat; ` ...` mirrors cli-entry help rows.
const value = option.kind === 'boolean'
? `--${option.option}`
: `--${option.option} ${cliOperand(option)}${option.repeated ? ' ...' : ''}`;
return option.required ? value : `[${value}]`;
});
return [...command.path, ...positionals, ...flags].join(' ');
Expand Down
33 changes: 27 additions & 6 deletions packages/workbench/src/routes/routes-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,26 @@ const scalarControl = (
routeId: string,
key: string,
schema: Exclude<RouteInputPropertySchema, { readonly type: 'array' }>,
required: boolean,
value: RouteInputDraftValue | undefined,
setValue: (value: RouteInputDraftValue) => void,
setValue: (value: RouteInputDraftValue | undefined) => void,
): React.ReactNode => {
const id = editorId(routeId, key);
switch (schema.type) {
case 'boolean':
return <input checked={value === true} id={id} onChange={(event) => setValue(event.currentTarget.checked)} type="checkbox" />;
// A checkbox cannot express "unset", so an optional boolean without a
// schema default keeps a third omitted state instead of submitting false.
return required || schema.default !== undefined
? <input checked={value === true} id={id} onChange={(event) => setValue(event.currentTarget.checked)} type="checkbox" />
: <select
id={id}
onChange={(event) => setValue(event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true')}
value={value === true ? 'true' : value === false ? 'false' : ''}
>
<option value="">(omitted)</option>
<option value="true">true</option>
<option value="false">false</option>
</select>;
case 'number':
return <input id={id} onChange={(event) => setValue(event.currentTarget.value)} type="number" value={typeof value === 'string' ? value : ''} />;
case 'string':
Expand Down Expand Up @@ -123,8 +136,14 @@ const RouteInputEditor = ({ entry, group, onOpenMcp }: {
? undefined
: cliCommandInvocation(entry.command, validated.arguments));
};
const setValue = (key: string, value: RouteInputDraftValue): void => {
const next = Object.freeze({ ...draft, [key]: value });
const setValue = (key: string, value: RouteInputDraftValue | undefined): void => {
const entries = { ...draft };
if (value === undefined) {
delete entries[key];
} else {
entries[key] = value;
}
const next = Object.freeze(entries);
setDraft(next);
commitValidation(next);
};
Expand Down Expand Up @@ -181,7 +200,7 @@ const RouteInputEditor = ({ entry, group, onOpenMcp }: {
if (property.type !== 'array') {
return <div className="route-input-field" key={key}>
<label htmlFor={editorId(entry.id, key)}>{label}{required ? ' (required)' : ''}
{scalarControl(entry.id, key, property, draft[key], (value) => setValue(key, value))}
{scalarControl(entry.id, key, property, required, draft[key], (value) => setValue(key, value))}
</label>
{property.description === undefined ? undefined : <p>{property.description}</p>}
{error === undefined ? undefined : <span className="route-input-error" role="alert">{error}</span>}
Expand All @@ -192,7 +211,9 @@ const RouteInputEditor = ({ entry, group, onOpenMcp }: {
<legend>{label}{required ? ' (required)' : ''}</legend>
{property.description === undefined ? undefined : <p>{property.description}</p>}
{values.map((value, index) => <div className="route-input-array-row" key={`${key}-${String(index)}`}>
{scalarControl(entry.id, `${key}-${String(index)}`, property.items, value, (nextValue) => {
{/* Array rows exist only after "Add", so items are always set. */}
{scalarControl(entry.id, `${key}-${String(index)}`, property.items, true, value, (nextValue) => {
if (nextValue === undefined) return;
const next = [...values];
next[index] = nextValue as boolean | string;
setValue(key, Object.freeze(next));
Expand Down
4 changes: 2 additions & 2 deletions packages/workbench/tests/examples-real.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,10 +606,10 @@ e2e('renders the flagship compiled route catalog by server and kind in real Chro
await expect(inventoryTool.getByLabel('Source (required)')).toBeVisible({ timeout: browserTimeout });
await expect(inventoryTool.getByLabel('Report')).toBeVisible({ timeout: browserTimeout });
await expect(inventoryTool.getByLabel('Strict')).toBeVisible({ timeout: browserTimeout });
await expect(inventoryTool.getByLabel('Strict')).toHaveValue('', { timeout: browserTimeout });
await inventoryTool.getByRole('button', { name: 'Validate input' }).click();
await expect(inventoryTool.getByRole('alert')).toHaveText('Source is required.', { timeout: browserTimeout });
await inventoryTool.getByLabel('Source (required)').fill('/tmp/audiobooks');
await inventoryTool.getByLabel('Strict').check();
await expect(inventoryTool.getByRole('button', { name: 'Open in MCP session' })).toBeEnabled({ timeout: browserTimeout });
await inventoryTool.getByRole('button', { name: 'Open in MCP session' }).click();
await waitForSettledWorkbench(page);
Expand All @@ -618,7 +618,7 @@ e2e('renders the flagship compiled route catalog by server and kind in real Chro
const prefill = page.getByRole('status').filter({ hasText: 'Tool call prefilled from Routes' });
await expect(prefill).toContainText('inventory_sources', { timeout: browserTimeout });
await expect(prefill).toContainText('"source": "/tmp/audiobooks"', { timeout: browserTimeout });
await expect(prefill).toContainText('"strict": true', { timeout: browserTimeout });
await expect(prefill).not.toContainText('"strict"', { timeout: browserTimeout });
await expect(page.getByRole('button', { name: 'Open MCP session' })).toBeEnabled({ timeout: browserTimeout });
await page.getByRole('link', { name: 'Routes', exact: true }).click();
await waitForSettledWorkbench(page);
Expand Down
27 changes: 27 additions & 0 deletions packages/workbench/tests/mcp-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,33 @@ describe('MCP page', () => {
expect(markup).toContain('weather');
expect(markup).toContain('Berlin');
expect(markup).toContain('Call weather');
expect(markup).not.toContain('no longer advertises');
expect(pageController.history).toHaveLength(1);
});

it('rejects a stale Routes-page tool prefill without selecting another tool', () => {
const pageController = controller();
const readyPageController: McpPageController = {
...pageController,
model: { ...model, phase: 'ready' } as McpBrowserSessionModel,
};
const markup = renderToStaticMarkup(createElement(McpPage, {
controller: readyPageController,
epochOptions: ['epoch-1'],
initialBinding: { epochId: 'epoch-1', serverName: 'weather', target: 'codex' },
initialToolPrefill: {
arguments: { city: 'Berlin' },
serverName: 'weather',
toolName: 'retired_weather',
},
serverOptions: [{ name: 'weather', target: 'codex' }],
targetOptions: ['codex'],
}));

expect(markup).toContain('The server no longer advertises the &quot;retired_weather&quot; tool.');
expect(markup).not.toContain('id="mcp-tool-arguments"');
expect(markup).not.toContain('Call weather');
expect(markup).not.toContain('Call clock');
expect(pageController.history).toHaveLength(1);
});

Expand Down
48 changes: 46 additions & 2 deletions packages/workbench/tests/routes-model.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, it } from '@rstest/core';

import type { RouteManifest } from '../../agent-bundle/src/contracts/routes.ts';
import type { RouteInputSchema, RouteManifest } from '../../agent-bundle/src/contracts/routes.ts';
import {
cliCommandInvocation,
cliCommandUsage,
Expand Down Expand Up @@ -219,6 +219,37 @@ it('prefills projected defaults and validates typed route input before invoke',
});
});

it('preserves optional boolean omission while validating explicit and required values', () => {
const schema: RouteInputSchema = {
additionalProperties: false,
properties: {
defaulted: { default: true, type: 'boolean' },
enabled: { type: 'boolean' },
strict: { type: 'boolean' },
},
required: ['enabled'],
type: 'object',
};
const draft = createRouteInputDraft(schema);

expect(draft).toEqual({ defaulted: true, enabled: false });
expect(validateRouteInput(schema, draft)).toEqual({
arguments: { defaulted: true, enabled: false },
errors: {},
});
expect(validateRouteInput(schema, { ...draft, strict: true })).toEqual({
arguments: { defaulted: true, enabled: false, strict: true },
errors: {},
});
expect(validateRouteInput(schema, { ...draft, strict: false })).toEqual({
arguments: { defaulted: true, enabled: false, strict: false },
errors: {},
});
expect(validateRouteInput(schema, { defaulted: true })).toEqual({
errors: { enabled: 'Enabled must be true or false.' },
});
});

it('validates the raw JSON fallback without inventing a schema', () => {
expect(validateRawRouteInput('{')).toEqual({ error: 'Enter a valid JSON object.' });
expect(validateRawRouteInput('[]')).toEqual({ error: 'Arguments must be a JSON object.' });
Expand All @@ -242,7 +273,7 @@ it('formats CLI usage and a shell-copyable invocation from validated input', ()
};

expect(cliCommandUsage(command)).toBe(
'library audit <input-file> [--format <text|json>] [--tag <string>] [--verbose]',
'library audit <input-file> [--format <text|json>] [--tag <string> ...] [--verbose]',
);
expect(cliCommandInvocation(command, {
format: 'json',
Expand All @@ -252,6 +283,19 @@ it('formats CLI usage and a shell-copyable invocation from validated input', ()
})).toBe("library audit '/Audio Books' --format json --tag fiction --tag history --verbose");
});

it('marks required and optional repeated named flags in CLI usage', () => {
expect(cliCommandUsage({
aliases: [],
exitCode: 'zero',
options: [
{ key: 'source', kind: 'string', option: 'source', repeated: true, required: true },
{ key: 'tag', kind: 'string', option: 'tag', repeated: true, required: false },
],
path: ['library', 'import'],
routeId: 'cli:library/import',
})).toBe('library import --source <string> ... [--tag <string> ...]');
});

it('maps a tool route server id and final route segment into an MCP prefill', () => {
const catalog = routeCatalogFor(manifest);
const group = catalog.groups[0]!;
Expand Down
4 changes: 4 additions & 0 deletions packages/workbench/tests/routes-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ const manifest: RouteManifest = {
additionalProperties: false,
properties: {
count: { default: 2, description: 'Repeat count.', type: 'number' },
enabled: { default: true, type: 'boolean' },
format: { enum: ['text', 'json'], type: 'string' },
strict: { type: 'boolean' },
tags: { items: { type: 'string' }, type: 'array' },
},
required: ['format'],
Expand Down Expand Up @@ -145,8 +147,10 @@ it('renders generated fields, descriptions, defaults, required markers, and a ga
expect(markup).toContain('Generated input editor');
expect(markup).toContain('Repeat count.');
expect(markup).toContain('value="2"');
expect(markup).toMatch(/Enabled<input[^>]*type="checkbox"[^>]*checked=""/u);
expect(markup).toContain('Format (required)');
expect(markup).toContain('<option value="json">json</option>');
expect(markup).toMatch(/Strict<select[^>]*><option value=""[^>]*>\(omitted\)<\/option><option value="true">true<\/option><option value="false">false<\/option><\/select>/u);
expect(markup).toContain('Add Tags item');
expect(markup).toContain('Full schema validation runs during execution.');
expect(markup).toContain('Open in MCP session');
Expand Down
Loading