From f700c56172d79f1222690d7a1ccfb9d6cc4c53bf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:22:17 +0000 Subject: [PATCH 1/3] test(openai-chat): declare role acceptance in suites that assert the forwarded role (#5334 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5334 made the developer wire role tri-state: an undeclared destination folds it to system. Two suites asserting role:"developer" on the Chat wire were missed because they are about tool-result repair ordering and document parts, not role selection — declare the destination, per the convention the change established. Verified: both files fail on dev@600075d2 with system-for-developer wire roles and pass with the declaration. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts | 4 ++++ tests/responses/chat-inline-document-bytes.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index 41a61c21023..075e3c33186 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -12,6 +12,10 @@ const provider: OcxProviderConfig = { baseUrl: "https://example.test/v1", apiKey: "sk-test", authMode: "key", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; this suite is about tool-result repair ordering, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; interface ChatMsg { diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts index dd88fa05788..a718c1c376a 100644 --- a/tests/responses/chat-inline-document-bytes.test.ts +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -28,6 +28,10 @@ const chatProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; the document test asserts the role a turn keeps, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; const anthropicProvider = { adapter: "anthropic", From 4e22463e91a23ed4a4ef6437376883300d0556a6 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 21 Sep 2026 09:43:09 +0900 Subject: [PATCH 2/3] fix(combos): retry single targets only after cooldown --- src/server/responses/core-combo.ts | 2 ++ tests/server/server-combo-failover-e2e.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 7b92529e710..d8eb234294e 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -745,6 +745,7 @@ export async function executeComboResponses( code: failure.upstreamCode, message: failure.classificationText, }); + const failedTargetCooled = isComboTargetInCooldown(comboId, pick.target, failureNow); // Same target selector as the exclusionary pick below, minus `exclude`: the only // difference is deliberate and is the whole point of the single-target retry. const retryAfterCooldown = () => @@ -775,6 +776,7 @@ export async function executeComboResponses( && combo.targets.length === 1 && combo.waitForCooldownMs > 0 && comboTargetsDispatched <= 1 + && failedTargetCooled && !options.abortSignal?.aborted ) { pick = await retryAfterCooldown(); diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index f4904cb38dd..00c04d7e258 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -2249,6 +2249,19 @@ describe("server combo failover 030 activation matrix", () => { expect(hits).toBe(1); }); + test("single-target wait does not retry a request-local refusal", async () => { + let hits = 0; + const upstream = serve(() => { + hits += 1; + return Response.json({ error: { type: "invalid_request_error", message: "Unsupported parameter: user" } }, { status: 400 }); + }); + const response = await post(comboConfig({ a: provider("openai-responses", baseUrl(upstream), "key-a") }, [ + { provider: "a", model: "m1" }, + ], { cooldownMs: 50, waitForCooldownMs: 500 }), { user: "synthetic-client" }); + expect(response.status).toBe(400); + expect(hits).toBe(1); + }); + test("a past Retry-After date remains immediate through response consumption", async () => { const now = Date.parse("2026-07-18T00:00:00.000Z"); const failure = await consumeComboFailure(Response.json({ error: { message: "rate limited" } }, { From 5d00b3abd1fdd8f357f41b788b60354b55c8edb4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:44:11 +0000 Subject: [PATCH 3/3] fix(combos): gate single-target retry on this failure's cooldown scope A concurrent request can cool the shared target while this request's own failure recorded no cooldown (scope "none"), so isComboTargetInCooldown alone was enough to arm the exclusion-free retry and replay a refused request. Require a cooldown-producing scope alongside the shared-state check, and cover the interleaving with a synchronized two-request test. Co-Authored-By: Epinephrine --- src/server/responses/core-combo.ts | 14 ++++-- .../server/server-combo-failover-e2e.test.ts | 45 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index d8eb234294e..4962bf64d50 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -732,20 +732,26 @@ export async function executeComboResponses( ); const failureNow = Date.now(); const attemptedTargets = pick.attempted; + const failureCooldownScope = comboFailureCooldownScope(failure.response.status, failure.classificationText, { + code: failure.upstreamCode, + }); const nextPick = advanceComboAfterFailure(config, pick, { retryAfter: failure.retryAfter, resetAt: failure.resetAt, cooldownMs: combo.cooldownMs, now: failureNow, - cooldownScope: comboFailureCooldownScope(failure.response.status, failure.classificationText, { - code: failure.upstreamCode, - }), + cooldownScope: failureCooldownScope, eligible: targetEligible, status: failure.response.status, code: failure.upstreamCode, message: failure.classificationText, }); - const failedTargetCooled = isComboTargetInCooldown(comboId, pick.target, failureNow); + // Cooldown state is shared by every request using this target, so a concurrent failure + // can put it in cooldown while THIS failure recorded none. Scope "none" means the + // refusal described this request's shape rather than the target's health — only a + // cooldown this failure produced itself may arm the single-target retry below. + const failedTargetCooled = failureCooldownScope !== "none" + && isComboTargetInCooldown(comboId, pick.target, failureNow); // Same target selector as the exclusionary pick below, minus `exclude`: the only // difference is deliberate and is the whole point of the single-target retry. const retryAfterCooldown = () => diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index 00c04d7e258..46124c2dc0a 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -2262,6 +2262,51 @@ describe("server combo failover 030 activation matrix", () => { expect(hits).toBe(1); }); + test("a concurrent cooldown does not retry a request-local refusal", async () => { + // Two requests share one target: the first stays in flight on a gate while the second + // fails hot and writes the SHARED cooldown. The first request's own failure records no + // cooldown (scope "none"), so the foreign entry alone must not arm the retry gate — + // it would wait out the sibling's cooldown and replay the refused request. + let markHeld!: () => void; + let releaseHeld!: () => void; + const heldRequest = new Promise(resolve => { markHeld = resolve; }); + const gate = new Promise(resolve => { releaseHeld = resolve; }); + let hits = 0; + const upstream = serve(async () => { + hits += 1; + if (hits === 1) { + markHeld(); + await gate; + return Response.json({ error: { type: "invalid_request_error", message: "Unsupported parameter: user" } }, { status: 400 }); + } + // 429 rather than 5xx so the failure reaches the combo layer directly: + // fetchWithTransientRetry would absorb a 503 before it could cool the target. + return hits === 2 + ? Response.json({ error: { message: "rate limited" } }, { status: 429 }) + : chatSuccess("single target recovered", "m1"); + }); + const config = comboConfig({ a: provider("openai-responses", baseUrl(upstream), "key-a") }, [ + { provider: "a", model: "m1" }, + ], { cooldownMs: 500, waitForCooldownMs: 2_000 }); + + const refused = post(config, { user: "synthetic-client" }); + await heldRequest; + const cooling = post(config); + const target = { provider: "a", model: "m1" }; + const deadline = Date.now() + 5_000; + while (!isComboTargetInCooldown("free", target)) { + if (Date.now() > deadline) throw new Error("sibling request never cooled the target"); + await Bun.sleep(5); + } + releaseHeld(); + const [refusal, cooled] = await Promise.all([refused, cooling]); + expect(refusal.status).toBe(400); + expect(cooled.status).toBe(200); + // The cooling request hits twice (failure, then its own post-cooldown retry); the + // refused request must hit exactly once. + expect(hits).toBe(3); + }); + test("a past Retry-After date remains immediate through response consumption", async () => { const now = Date.parse("2026-07-18T00:00:00.000Z"); const failure = await consumeComboFailure(Response.json({ error: { message: "rate limited" } }, {