Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>).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
Expand All @@ -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,
Expand Down Expand Up @@ -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", {
Expand Down
20 changes: 19 additions & 1 deletion tests/responses/responses-shadow-intercept.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;

Expand Down Expand Up @@ -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: "" };
Expand Down
Loading