fix(cli): use boolean sentinel for structured_output submission

Closes 1 #3589 review thread (Copilot, posted 3 times against the
same branch).

The `structuredSubmission !== undefined` sentinel collapsed two
distinct states into one value: "no submission yet" and "submission
recorded with undefined args". The latter is reachable under a
permissive empty schema (`{}`) since `BaseDeclarativeTool.validateToolParams`
would have already accepted the call regardless of arg shape, and
some content-generator adapters may surface a no-arg model call as
`args: undefined`. In that case the run would have fallen through to
the normal continuation loop instead of terminating, breaking the
single-shot contract.

Track submission via a separate `hasStructuredSubmission` boolean.
The recorded value of `structuredSubmission` (which lands in
`structured_result`) is preserved verbatim — including `undefined` —
so structured_result reflects exactly what the model submitted.

Test: new 'terminates even when structured_output args are undefined'
pins the contract; the boolean lets us assert the early-return path
runs even though the recorded value is itself undefined.
This commit is contained in:
wenshao 2026-05-08 13:47:48 +08:00
parent c24c1e5f75
commit 21c48e96ca
2 changed files with 61 additions and 3 deletions

View file

@ -2519,6 +2519,59 @@ describe('runNonInteractive', () => {
);
});
it('terminates even when structured_output args are undefined under an empty schema', async () => {
// Pin the boolean-sentinel contract: the previous
// `structuredSubmission !== undefined` check broke if the model
// called structured_output with args missing/undefined (which can
// happen under a permissive `{}` schema, since validateToolParams
// would have already accepted the call). The run must still
// terminate on the first successful structured_output call.
setupMetricsMock();
vi.mocked(mockConfig.getJsonSchema).mockReturnValue({});
mockGeminiClient.sendMessageStream.mockReturnValueOnce(
createStreamFromEvents([
{
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'so-undef',
name: ToolNames.STRUCTURED_OUTPUT,
args: undefined as unknown as Record<string, unknown>,
isClientInitiated: false,
prompt_id: 'p-structured-undef',
},
},
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } },
},
]),
);
mockCoreExecuteToolCall.mockResolvedValue({
responseParts: [{ text: 'Structured output accepted.' }],
});
const adapter = makeMockAdapter();
await runNonInteractive(
mockConfig,
mockSettings,
'go',
'p-structured-undef',
{ adapter },
);
// Single turn — boolean sentinel kicked us out even though the args
// value itself is undefined.
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
expect(adapter.emitResult).toHaveBeenCalledTimes(1);
expect(adapter.emitResult).toHaveBeenCalledWith(
expect.objectContaining({
isError: false,
structuredResult: undefined,
}),
);
});
it('sets process.exitCode=1 and writes stderr when model emits text under --json-schema', async () => {
setupMetricsMock();
vi.mocked(mockConfig.getJsonSchema).mockReturnValue({

View file

@ -485,8 +485,12 @@ export async function runNonInteractive(
const toolResponseParts: Part[] = [];
// When --json-schema is active, the first successful call to the
// synthetic structured_output tool terminates the session with the
// submitted args as the structured result.
// submitted args as the structured result. A separate boolean
// tracks whether a submission happened, since `args` itself may
// legitimately be undefined or any falsy value (an empty schema
// `{}` accepts any payload, including no fields at all).
let structuredSubmission: unknown = undefined;
let hasStructuredSubmission = false;
for (const requestInfo of toolCallRequests) {
const finalRequestInfo = requestInfo;
@ -546,9 +550,10 @@ export async function runNonInteractive(
if (
finalRequestInfo.name === ToolNames.STRUCTURED_OUTPUT &&
!toolResponse.error &&
structuredSubmission === undefined
!hasStructuredSubmission
) {
structuredSubmission = finalRequestInfo.args;
hasStructuredSubmission = true;
}
if (toolResponse.responseParts) {
@ -562,7 +567,7 @@ export async function runNonInteractive(
modelOverride = toolResponse.modelOverride;
}
}
if (structuredSubmission !== undefined) {
if (hasStructuredSubmission) {
// 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.