mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-17 12:21:10 +00:00
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:
parent
c24c1e5f75
commit
21c48e96ca
2 changed files with 61 additions and 3 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue