fix: address Copilot follow-up review on --json-schema scaffolding

Five small but real findings flagged on the latest pass:

1. core/src/index.ts re-exported `SyntheticOutputTool` via `export type`,
   but it's a runtime class — that erased it from the emitted JS and
   would break value imports. Split into a value `export { ... }` and a
   `export type { StructuredOutputParams }`.

2. The structured-output success path returned without flushing
   `localQueue` notifications or finalising one-shot monitors. If a
   background task had already emitted `task_started`, exiting here
   could drop its paired `task_notification` and leave SDK consumers
   with unpaired lifecycle events. Mirror the regular terminal path's
   `flushQueuedNotificationsToSdk` + `finalizeOneShotMonitors` calls
   before `emitResult`.

3. `schemaRootAcceptsObject` ignored the `not` keyword, so
   `{not:{type:"object"}}` (which forbids every object value) slipped
   through. Add a best-effort `not` check that rejects when
   `not.type` directly excludes object. Deeper negated patterns still
   fall through to Ajv at runtime.

4. `compileStrict`'s JSDoc claimed it errored on "Ajv versions we can't
   support", but the function doesn't actually check Ajv versions. Reword
   to "malformed or uses unsupported draft/features for our Ajv
   configuration" so the contract matches the implementation.

5. The pre-scan suppressed sibling tool calls but only synthesised
   tool_result events for them on the retry path — the success path
   left those tool_use blocks unpaired in the emitted JSONL/stream-json
   event log. Move the synthesis after the for loop so it runs for both
   the success break and the validation-failure fall-through; the event
   log is now consistent regardless of which path the run takes.

Tests cover the new \`not\`-rejection paths, the success-path tool_result
synthesis, and the existing retry-pairing test still passes against the
restructured emit ordering.
This commit is contained in:
wenshao 2026-05-05 01:00:53 +08:00
parent 0583ca8647
commit 62038527cc
6 changed files with 158 additions and 60 deletions

View file

@ -231,6 +231,34 @@ function schemaRootAcceptsObject(schema: Record<string, unknown>): boolean {
if (!everyBranchAcceptsObject) return false;
}
// Best-effort `not` handling: when `not` directly forbids object via its
// own `type` keyword (e.g. `{not:{type:"object"}}` or
// `{not:{type:["object","null"]}}`), the schema can never be satisfied
// by an object — reject. We don't try to do full satisfiability analysis
// for arbitrary `not` schemas (e.g. `not:{const:"foo"}` is fine, but
// `not:{anyOf:[{type:"object"},…]}` would also reject objects); those
// fall through to Ajv at runtime.
const notSchema = schema['not'];
if (
typeof notSchema === 'object' &&
notSchema !== null &&
!Array.isArray(notSchema)
) {
const notType = (notSchema as Record<string, unknown>)['type'];
if (notType !== undefined) {
const types = Array.isArray(notType) ? notType : [notType];
// If `not.type` *only* lists types that include "object", every
// object value matches `not.type`, so the negation rejects every
// object. The simplest sound check: if "object" is in the list at
// all and there's no sibling positive constraint that would allow
// an object some other way, reject. We've already validated the
// sibling type / anyOf / oneOf / allOf above — at this point we
// know they at least permit objects, so a `not.type` containing
// "object" still excludes them.
if (types.includes('object')) return false;
}
}
// No narrowing at the root — lenient default, treated as object-compatible.
return true;
}

View file

@ -181,4 +181,22 @@ describe('resolveJsonSchemaArg', () => {
);
expect(schema).toBeDefined();
});
it('rejects a root `not` that directly forbids object', () => {
// `not:{type:"object"}` excludes every object value, so the schema is
// unsatisfiable for tool-call args. Best-effort check — only inspects
// `not.type`; deeper negated patterns fall through to Ajv at runtime.
expect(() => resolveJsonSchemaArg('{"not":{"type":"object"}}')).toThrow(
/must accept object-typed values/,
);
expect(() =>
resolveJsonSchemaArg('{"not":{"type":["object","null"]}}'),
).toThrow(/must accept object-typed values/);
});
it('accepts a root `not` whose negated type does not exclude object', () => {
// `not:{type:"string"}` only forbids strings — objects are still fine.
const schema = resolveJsonSchemaArg('{"not":{"type":"string"}}');
expect(schema).toBeDefined();
});
});

View file

@ -2421,6 +2421,25 @@ describe('runNonInteractive', () => {
});
describe('--json-schema structured output', () => {
// Helper: walk an emitted event and extract the first tool_use_id when
// it represents a tool_result block. Returns undefined for any other
// event shape.
const extractToolResultId = (event: unknown): string | undefined => {
if (typeof event !== 'object' || event === null) return undefined;
const e = event as {
type?: unknown;
message?: { content?: unknown };
};
if (e.type !== 'user') return undefined;
const content = e.message?.content;
if (!Array.isArray(content) || content.length === 0) return undefined;
const block = content[0] as { type?: unknown; tool_use_id?: unknown };
if (block?.type !== 'tool_result') return undefined;
return typeof block.tool_use_id === 'string'
? block.tool_use_id
: undefined;
};
it('stops executing remaining tool calls from the same turn once structured_output succeeds', async () => {
(mockConfig.getJsonSchema as Mock).mockReturnValue({
type: 'object',
@ -2506,22 +2525,23 @@ describe('runNonInteractive', () => {
// torn down before we emit the terminal result.
expect(abortAllSpy).toHaveBeenCalledTimes(1);
// The emitted result must carry the submitted args under `result` as
// the JSON-stringified payload (the headless JSON formatter encodes
// the structured submission so SDK consumers always see a string here,
// matching how text-mode `result` is also a string).
const result = writes
const events = writes
.join('')
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line))
.flat()
.find(
(m: unknown) =>
typeof m === 'object' &&
m !== null &&
(m as { type?: string }).type === 'result',
);
.flat();
// The emitted result must carry the submitted args under `result` as
// the JSON-stringified payload (the headless JSON formatter encodes
// the structured submission so SDK consumers always see a string here,
// matching how text-mode `result` is also a string).
const result = events.find(
(m: unknown) =>
typeof m === 'object' &&
m !== null &&
(m as { type?: string }).type === 'result',
);
expect(result).toBeDefined();
expect(result.is_error).toBe(false);
expect(typeof result.result).toBe('string');
@ -2529,6 +2549,14 @@ describe('runNonInteractive', () => {
// The raw object is also exposed under `structured_result` for SDK
// consumers that don't want to re-parse the stringified payload.
expect(result.structured_result).toEqual(structuredArgs);
// The suppressed trailing tool_use must have a synthesised
// tool_result so the event log pairs every tool_use with a
// tool_result, even on the success path.
const trailingToolResult = events.find(
(m: unknown) => extractToolResultId(m) === 'tool-trailing',
);
expect(trailingToolResult).toBeDefined();
});
it('skips side-effecting tool calls that precede structured_output in the same turn', async () => {
@ -2611,21 +2639,29 @@ describe('runNonInteractive', () => {
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
expect(abortAllSpy).toHaveBeenCalledTimes(1);
const result = writes
const events = writes
.join('')
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line))
.flat()
.find(
(m: unknown) =>
typeof m === 'object' &&
m !== null &&
(m as { type?: string }).type === 'result',
);
.flat();
const result = events.find(
(m: unknown) =>
typeof m === 'object' &&
m !== null &&
(m as { type?: string }).type === 'result',
);
expect(result).toBeDefined();
expect(result.is_error).toBe(false);
expect(result.structured_result).toEqual(structuredArgs);
// The suppressed leading tool_use must have a synthesised
// tool_result event so the event log pairs every tool_use with a
// tool_result on the success path.
const leadingToolResult = events.find(
(m: unknown) => extractToolResultId(m) === 'tool-leading',
);
expect(leadingToolResult).toBeDefined();
});
it('keeps the session running when structured_output args fail validation so the model can retry', async () => {

View file

@ -489,12 +489,12 @@ export async function runNonInteractive(
// If --json-schema is active and the model emitted a
// `structured_output` call in the same assistant turn as other
// tools, the structured call is the terminal contract — pre-scan
// the batch and execute ONLY the first `structured_output`,
// suppressing both leading and trailing tool calls. Without this,
// tools, the structured call is the terminal contract — execute
// ONLY the first `structured_output` and synthesise a "skipped"
// tool_result for every other call in the batch. Without this,
// a batch like `[write_file(...), structured_output(...)]` would
// run the side-effecting tool before the run is accepted, and an
// invalid structured_output mid-batch (`[structured_output(bad),
// run the side-effecting tool before the run is accepted, and
// an invalid structured_output mid-batch (`[structured_output(bad),
// write_file(...)]`) would still fall through to the trailing
// tool before the retry turn.
let requestsToExecute = toolCallRequests;
@ -583,22 +583,57 @@ export async function runNonInteractive(
!toolResponse.error
) {
// Honour the "first valid call ends the session" contract.
// The pre-scan above already filtered the batch to the first
// structured_output, so any other tool_use blocks the model
// emitted in this turn lack a matching tool_result — which is
// consistent with how other terminal paths (max-turns,
// cancellation) leave the stream. The break is after the
// responseParts/modelOverride capture above so future changes
// to SyntheticOutputTool can't silently drop those signals.
// The break is after the responseParts/modelOverride capture
// above so future changes to SyntheticOutputTool can't
// silently drop those signals.
structuredSubmission = finalRequestInfo.args;
break;
}
}
// Synthesise tool_result events + retry parts for any sibling
// tool calls suppressed by the pre-scan. Runs for both the
// success and retry paths so the emitted event log pairs every
// tool_use the model produced with a tool_result, AND the
// retry-turn payload (when reached) doesn't leave Anthropic /
// OpenAI staring at unpaired tool_use blocks.
if (suppressedCalls.length > 0) {
const suppressedOutput =
'Skipped: structured_output was also requested in this turn and takes precedence as the terminal output contract. Re-issue this call in a separate turn if needed.';
for (const call of suppressedCalls) {
const responseParts: Part[] = [
{
functionResponse: {
id: call.callId,
name: call.name,
response: { output: suppressedOutput },
},
},
];
adapter.emitToolResult(call, {
callId: call.callId,
responseParts,
resultDisplay: suppressedOutput,
error: undefined,
errorType: undefined,
});
toolResponseParts.push(...responseParts);
}
}
if (structuredSubmission !== undefined) {
// Abort any in-flight background agents so they don't race the
// terminal emitResult; structured-output mode is a single-shot
// contract and the caller expects a deterministic shutdown.
registry.abortAll();
// Match the regular terminal path's holdback: drain queued
// task notifications to the SDK and finalise one-shot
// monitors before emitResult, so consumers always see a
// task_notification paired with every task_started and don't
// lose monitor lifecycle events when structured-output mode
// exits early.
flushQueuedNotificationsToSdk(localQueue);
finalizeOneShotMonitors();
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
const stats =
@ -616,26 +651,6 @@ export async function runNonInteractive(
});
return 0;
}
// Retry path: structured_output either wasn't called or its args
// failed validation. If the pre-scan suppressed sibling tool calls
// from this turn, the prior assistant message still contained
// their tool_use blocks — synthesize matching tool_result blocks
// now so the next-turn payload pairs every tool_use with a
// tool_result. Anthropic and OpenAI both reject batches with
// unpaired tool_use entries, which would otherwise turn the
// retry path into an opaque provider-side error.
for (const call of suppressedCalls) {
toolResponseParts.push({
functionResponse: {
id: call.callId,
name: call.name,
response: {
output:
'Skipped: structured_output was also requested in this turn and takes precedence as the terminal output contract. Re-issue this call in a separate turn if needed.',
},
},
});
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
// Drain-turns count toward getMaxSessionTurns() for symmetry with the main

View file

@ -111,10 +111,8 @@ export type {
ExitPlanModeTool,
ExitPlanModeParams,
} from './tools/exitPlanMode.js';
export type {
SyntheticOutputTool,
StructuredOutputParams,
} from './tools/syntheticOutput.js';
export { SyntheticOutputTool } from './tools/syntheticOutput.js';
export type { StructuredOutputParams } from './tools/syntheticOutput.js';
export type { GlobTool, GlobToolParams, GlobPath } from './tools/glob.js';
export type { GrepTool, GrepToolParams } from './tools/grep.js';
export type { LSTool, LSToolParams, FileEntry } from './tools/ls.js';

View file

@ -66,10 +66,13 @@ function getValidator(schema: AnySchema): Ajv {
export class SchemaValidator {
/**
* Strictly compiles a schema. Returns an error message if the schema is
* malformed or uses an Ajv version we can't support. Returns null on
* success. Unlike {@link validate}, this does NOT silently skip on
* compile failure callers (e.g. the CLI's `--json-schema` parser) need
* to surface invalid schemas instead of letting them no-op at runtime.
* malformed or uses unsupported draft/features for our Ajv configuration
* (see {@link getValidator} `$schema` selects between draft-07 and
* draft-2020-12; anything else falls through to draft-07's compiler).
* Returns null on success. Unlike {@link validate}, this does NOT
* silently skip on compile failure callers (e.g. the CLI's
* `--json-schema` parser) need to surface invalid schemas instead of
* letting them no-op at runtime.
*/
static compileStrict(schema: unknown): string | null {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {