fix(web): refuse a raw-JSON tool argument the schema would retype (#2171)

The Tools and Apps tabs' "Edit as JSON" editor sent its draft through the
string-to-declared-type conversion `callTool` applies for the widget form, so
`{"count":"01"}` against a numeric schema left as `{"count":1}` — the editor
showed one payload and the wire carried another.

Both JSON-authoring surfaces now refuse such a draft and name the value to
rewrite. Edit-and-replay already did; this makes the Tools and Apps forms
agree with it, which is the consistency #2171's acceptance asks for.

`coercedArgumentNames` and `coercedArgumentsError` move into core/json so the
two surfaces share one predicate and one sentence — the check runs the real
conversion and compares rather than restating its rules, so it cannot drift
from what would actually be sent. Enforcement is opt-in
(`SchemaFormProps.enforceToolArgumentTypes`): an elicitation renders through
the same form and its values are never converted, so enforcing there would
refuse a draft for a reason that is not true of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-27 22:57:29 -04:00
commit 9516299d8d

View file

@ -294,12 +294,32 @@ function suppliedMatchesConst(value: string, constValue: unknown): boolean {
export function convertToolParameters(
tool: Tool,
params: Record<string, string>,
): Record<string, JsonValue> {
return convertParametersForSchema(tool.inputSchema, params);
}
/**
* {@link convertToolParameters} against a bare `inputSchema` rather than a
* `Tool`.
*
* Split out because the two callers hold different things: the client has the
* `Tool`, while a form holds only the schema it is rendering (which is that
* same object, structurally narrowed). Both must reach the SAME conversion —
* one of them decides what goes on the wire and the other decides whether to
* let the user send it, so a second implementation would let them disagree
* about which values are convertible.
*/
export function convertParametersForSchema(
inputSchema: unknown,
params: Record<string, string>,
): Record<string, JsonValue> {
const result: Record<string, JsonValue> = {};
// A property's schema can live on a root composition branch rather than on
// the root itself (#2123); see `coercionProperties` for how the branch is
// identified when it does.
const { base, branches } = resolveRootUnion(tool.inputSchema ?? {});
const { base, branches } = resolveRootUnion(
(inputSchema ?? {}) as RootUnionSchema,
);
const properties = coercionProperties(base, branches, params);
for (const [key, value] of Object.entries(params)) {
const declared = properties[key];
@ -338,6 +358,62 @@ export function convertToolParameters(
return result;
}
/**
* The argument names a `tools/call` would silently retype, per the tool's
* schema — empty when every value is already the type the schema declares.
*
* Runs the conversion the client runs and compares, rather than restating its
* rules: {@link convertParametersForSchema} is the function on the other side,
* so asking it is the only way a caller cannot drift from what is actually
* sent.
*
* Only string-valued arguments can be retyped, because that is all the
* conversion looks at — a JSON draft that already writes `2` as a number is
* passed through untouched, and is not reported here.
*
* Used by the two places a payload is authored as JSON rather than through
* widgets — the Tools/Apps form's "Edit as JSON" switch and the
* Edit-and-replay modal — each of which refuses a draft this returns anything
* for, so what the editor shows is what the wire carries (#2171).
*/
export function coercedArgumentNames(
inputSchema: unknown,
args: Record<string, unknown>,
): string[] {
const stringArgs: Record<string, string> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string") stringArgs[key] = value;
}
if (Object.keys(stringArgs).length === 0) return [];
const converted = convertParametersForSchema(inputSchema, stringArgs);
// `Object.keys` of the *supplied* names, so an argument the schema does not
// declare (which the conversion passes through) cannot be reported.
return Object.keys(stringArgs).filter(
(key) => converted[key] !== stringArgs[key],
);
}
/**
* The refusal {@link coercedArgumentNames} justifies, as one sentence.
*
* Shared for the same reason the check is: the Tools/Apps raw-JSON editor and
* the Edit-and-replay modal refuse the same drafts, so they must not word it
* two different ways. `toolName` is included when the caller knows which tool
* the draft targets — the replay modal does, because its `name` is editable
* and the schema is looked up from it; a form is already rendering one tool
* and would only be repeating itself.
*/
export function coercedArgumentsError(
names: string[],
toolName?: string,
): string {
const quoted = names.map((name) => `\`${name}\``).join(", ");
const whose = toolName
? `the type ${toolName}'s schema declares`
: "the type the schema declares";
return `${quoted} would be converted to ${whose} — write the value with that type instead`;
}
/**
* Convert prompt arguments (JsonValue) to strings for prompt API
*/