diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c1ce136ca4..0d15efb521 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1732,6 +1732,8 @@ export interface HandleResponsesOptions { callerDirectAuth?: CallerDirectAuth | null; /** Internal recursion guard; callers outside this module must not set it. */ comboAttempt?: boolean; + /** Internal handoff: this combo was selected by shadow-call interception. */ + shadowCallIntercepted?: boolean; /** Internal combo handoff for one parent-validated continuation snapshot. */ comboReplaySnapshot?: { sourceBody: unknown; @@ -3288,6 +3290,7 @@ async function handleResponsesInner( } // Compaction may send the last client-visible bare model after a combo switch. // Configured selectors take precedence; otherwise recall before combo dispatch (#3891). + let shadowCallIntercepted = false; if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) { const rawModel = (body as { model?: unknown }).model; const rawInput = (body as { input?: unknown }).input; @@ -3317,6 +3320,7 @@ async function handleResponsesInner( && isShadowSourceModel(rawShadowModel, shadowIntercept.sourceModels)) { const shadowComboId = resolveComboId(config, shadowIntercept.model); if (shadowComboId && Object.hasOwn(config.combos ?? {}, shadowComboId)) { + shadowCallIntercepted = true; (body as Record).model = shadowIntercept.model; // Same rule as the late intercept site: record the operator-configured prefix that // matched, never the caller's raw model string. Matching is by prefix, so the raw @@ -3332,6 +3336,9 @@ async function handleResponsesInner( options.onRequestBodyRead?.(); return handleComboResponses(req, body, comboId, config, logCtx, { ...options, + // Concrete combo child selectors no longer match the shadow source model. Carry the + // interception decision explicitly so provider-specific helper isolation still applies. + shadowCallIntercepted, // The original request body was accepted above. Combo children are synthetic // replays and must not repeat the caller-owned timeout transition. onRequestBodyRead: undefined, @@ -3438,6 +3445,7 @@ async function handleResponsesInner( } } if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId; + if (options.shadowCallIntercepted === true) parsed._cursorIsolateConversation = true; } catch (err) { if (isTranslatorBudgetExceededError(err)) { return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { diff --git a/tests/responses/responses-shadow-intercept.test.ts b/tests/responses/responses-shadow-intercept.test.ts index feafd404df..114e981f52 100644 --- a/tests/responses/responses-shadow-intercept.test.ts +++ b/tests/responses/responses-shadow-intercept.test.ts @@ -4,7 +4,7 @@ * default follows modern clients, while sourceModels keeps an escape hatch. */ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync} from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses, isShadowSourceModel } from "../../src/server/responses"; @@ -14,6 +14,7 @@ import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; const originalFetch = globalThis.fetch; @@ -295,6 +296,23 @@ function chatOk(text: string): Response { } describe("a combo shadow-call target enters the failover loop (#4129)", () => { + test("carries helper conversation isolation into concrete combo children", () => { + const core = readFileSync(repoPath("src/server/responses/core.ts"), "utf8"); + const comboDispatch = core.slice( + core.indexOf("const comboId = !options.comboAttempt"), + core.indexOf("let unreadableEncryptedAgentTask"), + ); + const parsedHandoff = core.slice( + core.indexOf("if (cursorClientThreadId) parsed._cursorClientThreadId"), + core.indexOf("} catch (err)", core.indexOf("if (cursorClientThreadId) parsed._cursorClientThreadId")), + ); + + expect(comboDispatch).toContain("shadowCallIntercepted,"); + expect(parsedHandoff).toContain( + "if (options.shadowCallIntercepted === true) parsed._cursorIsolateConversation = true;", + ); + }); + test("a helper call rewritten to a combo hops past a 429 to the second target", async () => { const urls: string[] = []; const logCtx: RequestLogContext = { model: "", provider: "" };