diff --git a/packages/coding-agent/src/tools/yield.ts b/packages/coding-agent/src/tools/yield.ts index f03b0dc7f..8b8049fc7 100644 --- a/packages/coding-agent/src/tools/yield.ts +++ b/packages/coding-agent/src/tools/yield.ts @@ -321,26 +321,27 @@ export class YieldTool implements AgentTool { const status = errorMessage !== undefined ? "aborted" : "success"; let schemaValidationOverridden = false; + // Unknown incremental labels are a hard contract mismatch with the closed caller + // schema. Reject before the last-turn short-circuit too: `type: ["findings"], result: {}` + // would otherwise be accepted as a typed last-turn incremental yield, then a sibling + // section's MAX_SCHEMA_RETRIES override flips schemaOverridden in finalization and the + // stale section rides along untouched. + if (status === "success" && isIncremental) { + const unknownLabels = this.#unknownIncrementalLabels(yieldType as string[]); + if (unknownLabels.length > 0) { + const validLabels = + this.#validateSection && this.#validateSection.size > 0 + ? formatYieldLabels([...this.#validateSection.keys()]) + : "none"; + throw new Error( + `Section ${formatYieldLabels(yieldType as string[])} uses unknown incremental yield label(s): ${formatYieldLabels(unknownLabels)}. Resubmit with one of the schema's labels: ${validLabels}.`, + ); + } + } if (status === "success" && !useLastTurn) { if (data === null) { throw new Error("data is required when yield indicates success"); } - // Unknown incremental labels are a hard contract mismatch with the closed - // caller schema, not a shape error a retry can fix. Reject them every time - // without touching the schema-retry counter so the override path never - // accepts a stale agent-native section name. - if (isIncremental) { - const unknownLabels = this.#unknownIncrementalLabels(yieldType as string[]); - if (unknownLabels.length > 0) { - const validLabels = - this.#validateSection && this.#validateSection.size > 0 - ? formatYieldLabels([...this.#validateSection.keys()]) - : "none"; - throw new Error( - `Section ${formatYieldLabels(yieldType as string[])} uses unknown incremental yield label(s): ${formatYieldLabels(unknownLabels)}. Resubmit with one of the schema's labels: ${validLabels}.`, - ); - } - } const sectionFailure = isIncremental ? this.#validateIncrementalSection(yieldType as string[], data) : this.#validate diff --git a/packages/coding-agent/test/tools/yield.test.ts b/packages/coding-agent/test/tools/yield.test.ts index 6363af92b..653bf96b0 100644 --- a/packages/coding-agent/test/tools/yield.test.ts +++ b/packages/coding-agent/test/tools/yield.test.ts @@ -282,6 +282,19 @@ describe("YieldTool", () => { ); } + // The last-turn short-circuit (`type: ["findings"], result: {}`) MUST also reject + // the unknown label. Otherwise the stale section silently accepts the last assistant + // text and rides along when a sibling section trips MAX_SCHEMA_RETRIES and + // schemaOverridden in finalization (issue #3927 follow-up review). + await expect( + tool.execute("call-native-reviewer-label-last-turn", { + type: ["findings"], + result: {}, + } as never), + ).rejects.toThrow( + /Section "findings" uses unknown incremental yield label\(s\): "findings"\. Resubmit with one of the schema's labels: "issue_key", "verdict", "blockers", "non_blocking_notes"\./, + ); + // Schema-retry budget intact: a separate, shape-only mismatch still fires the // first-attempt retry hint (`2 retry attempt(s) remain`), proving the unknown-label // path didn't burn the override.