mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 12:52:25 +03:00
fix(sse): prevent Anthropic 400s for Claude-native handoffs (#12668)
* fix(sse): prevent Anthropic 400s for Claude-native handoffs * docs(changelog): add fragment for Claude-native handoff 400 fix * refactor(sse): satisfy file-size and complexity ratchets Keep the Claude wire-body guard while staying under the frozen per-file line baselines and the complexity ratchets measured against release/v3.8.51. Extract the final constraint coordinator, split system-message normalization into focused helpers, and isolate handoff response parsing. Reflow the universal-handoff explanation to absorb the added source-format argument without growing the frozen file.
This commit is contained in:
committed by
GitHub
parent
dd70dbdaa0
commit
c97f61b2ac
@@ -0,0 +1 @@
|
||||
- **fix(sse):** Claude-native context handoffs now land in Anthropic's top-level `system` parameter instead of a leading `role: "system"` message, and the final Claude executor dispatch hoists any remaining leading prompt system/developer messages and relocates directive-only `output_config` envelopes away from `messages[0]`, preventing the `messages.0: use the top-level 'system' parameter` HTTP 400 on model switches ([#12668](https://github.com/diegosouzapw/OmniRoute/pull/12668)).
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
appendAnthropicBetaHeader,
|
||||
CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA,
|
||||
CONTEXT_1M_BETA_HEADER,
|
||||
enforceThinkingTemperature,
|
||||
finalizeClaudeBodyConstraints,
|
||||
modelHasNativeContext1m,
|
||||
modelSupportsContext1mBeta,
|
||||
} from "../services/claudeCodeCompatible.ts";
|
||||
@@ -1368,7 +1368,7 @@ export class BaseExecutor {
|
||||
// routing mode (grouped/raw/combo) and the native passthrough share,
|
||||
// before fingerprinting and CCH signing serialize the body.
|
||||
if (this.provider === "claude" || usesClaudeCodeProtocol) {
|
||||
enforceThinkingTemperature(transformedBody as Record<string, unknown>);
|
||||
finalizeClaudeBodyConstraints(transformedBody as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// Delegated Context Editing (opt-in): attach the clear_tool_uses strategy so
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
|
||||
export type HoistedCacheBoundary = "moved" | "kept" | "dropped";
|
||||
|
||||
// Re-exported from its canonical home in claudeCodeConstraints.ts so existing
|
||||
// importers of this module keep working.
|
||||
export { relocateDirectiveOnlyMessages } from "../../services/claudeCodeConstraints.ts";
|
||||
|
||||
/** Effective cache TTL of a `cache_control` value; Anthropic defaults to 5m when `ttl` is absent. */
|
||||
function effectiveTtl(marker: unknown): string {
|
||||
const ttl = (marker as Record<string, unknown> | null | undefined)?.ttl;
|
||||
@@ -163,85 +167,3 @@ export function extractSystemRoleMessages(payload: Record<string, unknown>): voi
|
||||
}
|
||||
payload.messages = messages.filter((m) => !isSystemRole(m.role));
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a directive-only system message (empty content array + message-level
|
||||
* `output_config`, the shape Claude Code clients emit) off `messages[0]`.
|
||||
*
|
||||
* Anthropic treats `messages[0]` as the initial system prompt position and
|
||||
* rejects the directive-only form there ("use the top-level 'system' parameter
|
||||
* for the initial system prompt"), while accepting it at any other position.
|
||||
* The mid-conversation-system passthrough (provider `claude` + 1M-context beta
|
||||
* models) deliberately keeps system-role messages inside `messages[]`, so a
|
||||
* directive that arrived first would go upstream unchanged and 400. Relocate it
|
||||
* past the first real turn instead; when the conversation has no real turn at
|
||||
* all, fold the `output_config` into the top-level parameter (which wins when
|
||||
* already present) and drop the now-empty message.
|
||||
*/
|
||||
export function relocateDirectiveOnlyMessages(payload: Record<string, unknown>): void {
|
||||
if (!Array.isArray(payload.messages) || payload.messages.length === 0) return;
|
||||
const messages = payload.messages as Array<Record<string, unknown>>;
|
||||
const isSystemRole = (role: unknown): boolean =>
|
||||
typeof role === "string" &&
|
||||
(role.toLowerCase() === "system" || role.toLowerCase() === "developer");
|
||||
const isEmptySystem = (m: Record<string, unknown>): boolean =>
|
||||
m != null &&
|
||||
typeof m === "object" &&
|
||||
isSystemRole(m.role) &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.length === 0;
|
||||
const isDirectiveOnly = (m: Record<string, unknown>): boolean =>
|
||||
isEmptySystem(m) &&
|
||||
m.output_config != null &&
|
||||
typeof m.output_config === "object" &&
|
||||
!Array.isArray(m.output_config);
|
||||
|
||||
if (!isEmptySystem(messages[0])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect the whole leading run of empty system messages so consecutive
|
||||
// directives are all relocated in one pass (handling only messages[0] would
|
||||
// leave the second directive at the rejected position).
|
||||
let runEnd = 0;
|
||||
while (runEnd < messages.length && isEmptySystem(messages[runEnd])) {
|
||||
runEnd++;
|
||||
}
|
||||
const lead = messages.slice(0, runEnd);
|
||||
const directives = lead.filter(isDirectiveOnly);
|
||||
|
||||
// First real (user/assistant) turn after the run. System messages with text
|
||||
// content are not safe insertion anchors — keep walking past them, and past
|
||||
// any non-object entries a malformed body may carry.
|
||||
let insertAfter = -1;
|
||||
for (let i = runEnd; i < messages.length; i++) {
|
||||
const candidate = messages[i];
|
||||
if (
|
||||
candidate != null &&
|
||||
typeof candidate === "object" &&
|
||||
!isSystemRole(candidate.role)
|
||||
) {
|
||||
insertAfter = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (insertAfter === -1) {
|
||||
// No real turn to relocate after: fold the first directive's
|
||||
// output_config into the top-level parameter (an explicit top-level value
|
||||
// wins) and drop the whole run.
|
||||
if (payload.output_config == null && directives.length > 0) {
|
||||
payload.output_config = directives[0].output_config;
|
||||
}
|
||||
payload.messages = messages.slice(runEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
// Move the directives (in order) past the first real turn; plain empty
|
||||
// system messages carry nothing and are dropped.
|
||||
payload.messages = [
|
||||
...messages.slice(runEnd, insertAfter + 1),
|
||||
...directives,
|
||||
...messages.slice(insertAfter + 1),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -403,6 +403,7 @@ export { computeFingerprint } from "./claudeCodeFingerprint.ts";
|
||||
export { obfuscateSensitiveWords, setSensitiveWords } from "./claudeCodeObfuscation.ts";
|
||||
export {
|
||||
enforceThinkingTemperature,
|
||||
finalizeClaudeBodyConstraints,
|
||||
disableThinkingIfToolChoiceForced,
|
||||
enforceCacheControlLimit,
|
||||
} from "./claudeCodeConstraints.ts";
|
||||
|
||||
@@ -32,6 +32,148 @@ export function enforceThinkingTemperature(body: Record<string, unknown>): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies the final Anthropic wire-body invariants before serialization. */
|
||||
export function finalizeClaudeBodyConstraints(body: Record<string, unknown>): void {
|
||||
hoistLeadingSystemMessages(body);
|
||||
relocateDirectiveOnlyMessages(body);
|
||||
enforceThinkingTemperature(body);
|
||||
}
|
||||
|
||||
function isSystemRole(role: unknown): boolean {
|
||||
return (
|
||||
typeof role === "string" &&
|
||||
(role.toLowerCase() === "system" || role.toLowerCase() === "developer")
|
||||
);
|
||||
}
|
||||
|
||||
function hasOutputConfig(message: Record<string, unknown>): boolean {
|
||||
return (
|
||||
message.output_config != null &&
|
||||
typeof message.output_config === "object" &&
|
||||
!Array.isArray(message.output_config)
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptySystemMessage(message: unknown): message is Record<string, unknown> {
|
||||
if (message == null || typeof message !== "object") return false;
|
||||
const candidate = message as Record<string, unknown>;
|
||||
return (
|
||||
isSystemRole(candidate.role) &&
|
||||
Array.isArray(candidate.content) &&
|
||||
candidate.content.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
function isDirectiveOnlyMessage(message: unknown): boolean {
|
||||
return isEmptySystemMessage(message) && hasOutputConfig(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a directive-only system message (empty content array + message-level
|
||||
* `output_config`) off `messages[0]`, which Anthropic reserves for the initial
|
||||
* system-prompt position. Legitimate directives already later in the conversation
|
||||
* stay untouched.
|
||||
*/
|
||||
export function relocateDirectiveOnlyMessages(payload: Record<string, unknown>): void {
|
||||
if (!Array.isArray(payload.messages) || payload.messages.length === 0) return;
|
||||
const messages = payload.messages as Array<Record<string, unknown>>;
|
||||
if (!isEmptySystemMessage(messages[0])) return;
|
||||
|
||||
let runEnd = 0;
|
||||
while (runEnd < messages.length && isEmptySystemMessage(messages[runEnd])) runEnd++;
|
||||
const directives = messages.slice(0, runEnd).filter(isDirectiveOnlyMessage);
|
||||
|
||||
let insertAfter = -1;
|
||||
for (let i = runEnd; i < messages.length; i++) {
|
||||
const candidate = messages[i];
|
||||
if (candidate != null && typeof candidate === "object" && !isSystemRole(candidate.role)) {
|
||||
insertAfter = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (insertAfter === -1) {
|
||||
if (payload.output_config == null && directives.length > 0) {
|
||||
payload.output_config = directives[0].output_config;
|
||||
}
|
||||
payload.messages = messages.slice(runEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
payload.messages = [
|
||||
...messages.slice(runEnd, insertAfter + 1),
|
||||
...directives,
|
||||
...messages.slice(insertAfter + 1),
|
||||
];
|
||||
}
|
||||
|
||||
/** Extracts non-empty text blocks from string or array message content. */
|
||||
function textBlocksFromContent(content: unknown): Array<Record<string, unknown>> {
|
||||
if (typeof content === "string" && content.length > 0) {
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
if (!Array.isArray(content)) return [];
|
||||
const blocks: Array<Record<string, unknown>> = [];
|
||||
for (const block of content) {
|
||||
if (block == null || typeof block !== "object") continue;
|
||||
const contentBlock = block as Record<string, unknown>;
|
||||
if (
|
||||
contentBlock.type === "text" &&
|
||||
typeof contentBlock.text === "string" &&
|
||||
contentBlock.text.length > 0
|
||||
) {
|
||||
blocks.push({ ...contentBlock });
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Merges hoisted blocks into the existing top-level system value. */
|
||||
function mergeSystemBlocks(
|
||||
existing: unknown,
|
||||
extra: Array<Record<string, unknown>>
|
||||
): Array<Record<string, unknown>> {
|
||||
if (typeof existing === "string" && existing.length > 0) {
|
||||
return [{ type: "text", text: existing }, ...extra];
|
||||
}
|
||||
if (Array.isArray(existing)) {
|
||||
return [...(existing as Array<Record<string, unknown>>), ...extra];
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoists only the initial system/developer run into Anthropic's top-level `system` field.
|
||||
* Directive-only entries remain in `messages` for the positional relocation pass, and
|
||||
* mid-conversation system entries remain untouched for the context-1m beta path.
|
||||
*/
|
||||
export function hoistLeadingSystemMessages(payload: Record<string, unknown>): void {
|
||||
if (!Array.isArray(payload.messages) || payload.messages.length === 0) return;
|
||||
const messages = payload.messages as Array<Record<string, unknown>>;
|
||||
|
||||
let runEnd = 0;
|
||||
while (runEnd < messages.length && isSystemRole(messages[runEnd]?.role)) runEnd++;
|
||||
if (runEnd === 0) return;
|
||||
|
||||
const extraBlocks: Array<Record<string, unknown>> = [];
|
||||
const directives: Array<Record<string, unknown>> = [];
|
||||
for (const message of messages.slice(0, runEnd)) {
|
||||
if (isDirectiveOnlyMessage(message)) {
|
||||
directives.push(message);
|
||||
continue;
|
||||
}
|
||||
extraBlocks.push(...textBlocksFromContent(message.content));
|
||||
if (payload.output_config == null && hasOutputConfig(message)) {
|
||||
payload.output_config = message.output_config;
|
||||
}
|
||||
}
|
||||
|
||||
if (extraBlocks.length > 0) {
|
||||
payload.system = mergeSystemBlocks(payload.system, extraBlocks);
|
||||
}
|
||||
payload.messages = [...directives, ...messages.slice(runEnd)];
|
||||
}
|
||||
|
||||
export function disableThinkingIfToolChoiceForced(body: Record<string, unknown>): void {
|
||||
const toolChoice = body.tool_choice as Record<string, unknown> | string | undefined;
|
||||
if (!toolChoice) return;
|
||||
|
||||
@@ -990,6 +990,7 @@ async function handleComboChatInner({
|
||||
stickyWeightedLimit,
|
||||
getWeightedStepKeyForTarget,
|
||||
universalHandoffConfig,
|
||||
sourceFormat,
|
||||
relayOptions,
|
||||
relayConfig,
|
||||
};
|
||||
|
||||
@@ -103,6 +103,7 @@ export type AttemptLoopDeps = {
|
||||
stickyWeightedLimit?: number;
|
||||
getWeightedStepKeyForTarget?: (target: ResolvedComboTarget) => string | null;
|
||||
universalHandoffConfig?: UniversalHandoffConfig;
|
||||
sourceFormat?: string | null;
|
||||
relayOptions?: { sessionId?: string | null } | null;
|
||||
relayConfig?: ContextRelayConfig | null;
|
||||
};
|
||||
|
||||
@@ -299,14 +299,13 @@ export async function executeTargetAttempt(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
// Universal handoff: inject existing handoff if model changed. i === 0
|
||||
// only: a fallback target (i > 0) serves the SAME client request the
|
||||
// failed primary target would have served, with the original messages
|
||||
// already intact -- there's nothing to hand off, since the client never
|
||||
// saw the earlier target fail. Injecting a handoff note there replaces
|
||||
// real context with a context-free note, which weaker fallback models
|
||||
// have been observed treating as license to fabricate content instead
|
||||
// of just answering the actual request (#12227 follow-up).
|
||||
// Universal handoff: inject on model change only when i === 0. A fallback
|
||||
// target (i > 0) serves the SAME client request the failed primary target
|
||||
// would have served, with the original messages already intact -- there is
|
||||
// nothing to hand off, since the client never saw the earlier target fail.
|
||||
// Injecting a handoff note there replaces real context with a context-free
|
||||
// note, which weaker fallback models have been observed treating as license
|
||||
// to fabricate content instead of answering the request (#12227 follow-up).
|
||||
if (
|
||||
i === 0 &&
|
||||
universalHandoffConfig.enabled &&
|
||||
@@ -322,7 +321,8 @@ export async function executeTargetAttempt(opts: {
|
||||
modelStr,
|
||||
`Model routing: ${lastModel} → ${modelStr}`,
|
||||
existingHandoff,
|
||||
universalHandoffConfig.relayMode
|
||||
universalHandoffConfig.relayMode,
|
||||
deps.sourceFormat
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,10 +532,39 @@ You are continuing a conversation that was transferred from another account due
|
||||
The context above contains a concise summary of the prior work. Continue seamlessly from where the session left off.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a handoff text block to Anthropic's top-level `system` parameter.
|
||||
*
|
||||
* Anthropic's Messages API rejects a non-empty `role: "system"` entry at
|
||||
* `messages[0]` ("use the top-level 'system' parameter for the initial system
|
||||
* prompt"), so a handoff injected into a Claude-native body must never become a
|
||||
* leading system message. An existing string system prompt is preserved as the
|
||||
* first block so prompt order is unchanged; `messages` is left untouched.
|
||||
*/
|
||||
function injectClaudeSystemHandoff(
|
||||
body: Record<string, unknown>,
|
||||
handoffContent: string
|
||||
): Record<string, unknown> {
|
||||
const handoffBlock = { type: "text", text: handoffContent };
|
||||
const existingSystem = body.system;
|
||||
let system: unknown[];
|
||||
|
||||
if (Array.isArray(existingSystem)) {
|
||||
system = [...existingSystem, handoffBlock];
|
||||
} else if (typeof existingSystem === "string" && existingSystem.length > 0) {
|
||||
system = [{ type: "text", text: existingSystem }, handoffBlock];
|
||||
} else {
|
||||
system = [handoffBlock];
|
||||
}
|
||||
|
||||
return { ...body, system };
|
||||
}
|
||||
|
||||
export function injectHandoffIntoBody(
|
||||
body: Record<string, unknown>,
|
||||
payload: HandoffPayload,
|
||||
_relayMode?: "schema-locked" | "standard"
|
||||
_relayMode?: "schema-locked" | "standard",
|
||||
sourceFormat?: string | null
|
||||
): Record<string, unknown> {
|
||||
const handoffContent = buildHandoffSystemMessage(payload);
|
||||
const isResponsesRequest =
|
||||
@@ -562,6 +591,10 @@ export function injectHandoffIntoBody(
|
||||
return nextBody;
|
||||
}
|
||||
|
||||
if (sourceFormat === "claude") {
|
||||
return injectClaudeSystemHandoff(body, handoffContent);
|
||||
}
|
||||
|
||||
const handoffMessage = {
|
||||
role: "system",
|
||||
content: handoffContent,
|
||||
@@ -721,6 +754,18 @@ function logUniversalHandoffOutcome(
|
||||
console.warn(`[universal-handoff] ${outcome} (combo=${comboName}): ${detail}`);
|
||||
}
|
||||
|
||||
/** Reads the summary model's reply as text, tolerating non-JSON bodies. */
|
||||
async function readUniversalHandoffResponse(response: Response): Promise<string> {
|
||||
try {
|
||||
return getResponseText((await response.clone().json()) as Record<string, unknown>);
|
||||
} catch {
|
||||
return await response
|
||||
.clone()
|
||||
.text()
|
||||
.catch(() => "");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a universal handoff summary for any model/provider switch.
|
||||
*/
|
||||
@@ -778,17 +823,13 @@ async function generateUniversalHandoffAsync(options: {
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
let content = "";
|
||||
try {
|
||||
content = getResponseText((await response.clone().json()) as Record<string, unknown>);
|
||||
} catch {
|
||||
content = await response.clone().text().catch(() => "");
|
||||
}
|
||||
const content = await readUniversalHandoffResponse(response);
|
||||
|
||||
const parsed = parseHandoffJSON(content);
|
||||
if (!parsed) {
|
||||
const preview = JSON.stringify(content.slice(0, 200));
|
||||
logUniversalHandoffOutcome("unparseable", options.comboName, `model=${summaryModel} contentPreview=${preview}`);
|
||||
const detail = `model=${summaryModel} contentPreview=${preview}`;
|
||||
logUniversalHandoffOutcome("unparseable", options.comboName, detail);
|
||||
return "unparseable";
|
||||
}
|
||||
|
||||
@@ -875,7 +916,8 @@ export function injectUniversalHandoffBody(
|
||||
currModel: string,
|
||||
reason: string,
|
||||
existingPayload?: HandoffPayload | null,
|
||||
_relayMode?: "schema-locked" | "standard"
|
||||
_relayMode?: "schema-locked" | "standard",
|
||||
sourceFormat?: string | null
|
||||
): Record<string, unknown> {
|
||||
const handoffContent = buildUniversalHandoffSystemMessage(
|
||||
prevModel,
|
||||
@@ -906,6 +948,10 @@ export function injectUniversalHandoffBody(
|
||||
return nextBody;
|
||||
}
|
||||
|
||||
if (sourceFormat === "claude") {
|
||||
return injectClaudeSystemHandoff(body, handoffContent);
|
||||
}
|
||||
|
||||
const handoffMessage = {
|
||||
role: "system",
|
||||
content: handoffContent,
|
||||
|
||||
@@ -1896,7 +1896,7 @@ async function handleSingleModelChat(
|
||||
if (handoff && handoff.fromAccount !== credentials.connectionId) {
|
||||
// Inject only after a real account switch. The combo loop itself cannot
|
||||
// reliably detect this because account selection happens inside auth.
|
||||
requestBody = injectHandoffIntoBody(requestBody, handoff);
|
||||
requestBody = injectHandoffIntoBody(requestBody, handoff, undefined, sourceFormat);
|
||||
injectedHandoff = handoff;
|
||||
log.info(
|
||||
"CONTEXT_RELAY",
|
||||
|
||||
175
tests/unit/claude-directive-final-dispatch.test.ts
Normal file
175
tests/unit/claude-directive-final-dispatch.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-directive-dispatch-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { DefaultExecutor } = await import("../../open-sse/executors/default.ts");
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
type CapturedBody = {
|
||||
messages?: Array<Record<string, unknown>>;
|
||||
output_config?: Record<string, unknown>;
|
||||
system?: string | Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function captureFetchBodies(): { bodies: CapturedBody[]; restore: () => void } {
|
||||
const bodies: CapturedBody[] = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (_url: unknown, init: { body?: unknown } = {}) => {
|
||||
bodies.push(JSON.parse(String(init.body ?? "{}")) as CapturedBody);
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
function restore(): void {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
|
||||
return { bodies, restore };
|
||||
}
|
||||
|
||||
async function executeClaude(body: Record<string, unknown>): Promise<Response> {
|
||||
return new DefaultExecutor("claude").execute({
|
||||
model: "claude-opus-5",
|
||||
body,
|
||||
stream: false,
|
||||
credentials: { accessToken: "sk-ant-oat-test-token" },
|
||||
clientHeaders: { "x-app": "cli" },
|
||||
});
|
||||
}
|
||||
|
||||
test("final Claude dispatch hoists a leading non-empty system message", async () => {
|
||||
const { bodies, restore } = captureFetchBodies();
|
||||
|
||||
try {
|
||||
await executeClaude({
|
||||
model: "claude-opus-5",
|
||||
max_tokens: 64,
|
||||
system: [{ type: "text", text: "Existing top-level prompt." }],
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Look up a value",
|
||||
input_schema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Escaped initial prompt.",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }] },
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
const captured = bodies.at(-1);
|
||||
assert.ok(captured, "fetch did not capture a request body");
|
||||
assert.equal(captured.messages?.[0]?.role, "user");
|
||||
assert.equal(
|
||||
captured.messages?.some((message) =>
|
||||
["system", "developer"].includes(String(message.role).toLowerCase())
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.ok(Array.isArray(captured.system), "system must be serialized as content blocks");
|
||||
assert.deepEqual(
|
||||
captured.system.slice(-2),
|
||||
[
|
||||
{ type: "text", text: "Existing top-level prompt." },
|
||||
{
|
||||
type: "text",
|
||||
text: "Escaped initial prompt.",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
},
|
||||
],
|
||||
"existing and hoisted system prompts must retain their order and cache boundary"
|
||||
);
|
||||
});
|
||||
|
||||
test("final Claude dispatch relocates leading directive-only messages", async () => {
|
||||
const { bodies, restore } = captureFetchBodies();
|
||||
const firstDirective = {
|
||||
role: "system",
|
||||
content: [],
|
||||
output_config: { effort: "medium" },
|
||||
};
|
||||
const laterDirective = {
|
||||
role: "system",
|
||||
content: [],
|
||||
output_config: { effort: "low" },
|
||||
};
|
||||
|
||||
try {
|
||||
await executeClaude({
|
||||
model: "claude-opus-5",
|
||||
max_tokens: 64,
|
||||
messages: [
|
||||
firstDirective,
|
||||
{ role: "developer", content: [] },
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "hi" },
|
||||
laterDirective,
|
||||
{ role: "user", content: "continue" },
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
const messages = bodies[0]?.messages;
|
||||
assert.ok(messages, "fetch did not capture a messages array");
|
||||
assert.equal(messages[0]?.role, "user");
|
||||
assert.equal(messages[0]?.content, "hello");
|
||||
assert.deepEqual(messages[1], firstDirective);
|
||||
assert.equal(
|
||||
messages.some((message) => message.role === "developer"),
|
||||
false
|
||||
);
|
||||
assert.deepEqual(messages[3], laterDirective, "valid later directive must retain its position");
|
||||
});
|
||||
|
||||
test("final Claude dispatch folds a sole directive without overriding top-level output_config", async () => {
|
||||
const { bodies, restore } = captureFetchBodies();
|
||||
|
||||
try {
|
||||
await executeClaude({
|
||||
model: "claude-opus-5",
|
||||
max_tokens: 64,
|
||||
output_config: { effort: "high" },
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [],
|
||||
output_config: { effort: "medium" },
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.deepEqual(bodies[0]?.messages, []);
|
||||
assert.deepEqual(bodies[0]?.output_config, { effort: "high" });
|
||||
});
|
||||
@@ -69,6 +69,62 @@ test("buildHandoffSystemMessage and injectHandoffIntoBody preserve existing hist
|
||||
assert.equal(body.messages.length, 2);
|
||||
});
|
||||
|
||||
test("injectHandoffIntoBody appends Claude-native handoff to top-level system", () => {
|
||||
const payload = {
|
||||
sessionId: "sess-1",
|
||||
comboName: "relay-combo",
|
||||
fromAccount: "conn-a",
|
||||
summary: "Keep the Claude-native request shape",
|
||||
keyDecisions: ["keep system prompts top-level"],
|
||||
taskProgress: "Continue the request",
|
||||
activeEntities: ["contextHandoff.ts"],
|
||||
messageCount: 8,
|
||||
model: "claude/claude-opus-5",
|
||||
warningThresholdPct: 0.85,
|
||||
generatedAt: "2099-04-08T12:00:00.000Z",
|
||||
expiresAt: "2099-04-08T17:00:00.000Z",
|
||||
};
|
||||
const body = {
|
||||
system: "Existing Claude prompt",
|
||||
max_tokens: 64,
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Continue" }] }],
|
||||
};
|
||||
|
||||
const injected = contextHandoff.injectHandoffIntoBody(body, payload, undefined, "claude");
|
||||
const system = injected.system as Array<Record<string, unknown>>;
|
||||
|
||||
assert.strictEqual(injected.messages, body.messages);
|
||||
assert.deepEqual(system[0], { type: "text", text: "Existing Claude prompt" });
|
||||
assert.match(String(system[1]?.text), /<context_handoff>/);
|
||||
assert.equal(system[1]?.type, "text");
|
||||
});
|
||||
|
||||
test("injectHandoffIntoBody keeps OpenAI system fields on the message-based path", () => {
|
||||
const payload = {
|
||||
sessionId: "sess-1",
|
||||
comboName: "relay-combo",
|
||||
fromAccount: "conn-a",
|
||||
summary: "Keep the OpenAI request shape",
|
||||
keyDecisions: [],
|
||||
taskProgress: "Continue",
|
||||
activeEntities: [],
|
||||
messageCount: 1,
|
||||
model: "openai/gpt-5",
|
||||
warningThresholdPct: 0.85,
|
||||
generatedAt: "2099-04-08T12:00:00.000Z",
|
||||
expiresAt: "2099-04-08T17:00:00.000Z",
|
||||
};
|
||||
const body = {
|
||||
system: undefined,
|
||||
messages: [{ role: "user", content: "Continue" }],
|
||||
};
|
||||
|
||||
const injected = contextHandoff.injectHandoffIntoBody(body, payload, undefined, "openai");
|
||||
|
||||
assert.equal((injected.messages as Array<Record<string, unknown>>)[0]?.role, "system");
|
||||
assert.equal(injected.system, undefined);
|
||||
});
|
||||
|
||||
test("injectHandoffIntoBody preserves Responses API shape for native Codex requests", () => {
|
||||
const payload = {
|
||||
sessionId: "sess-1",
|
||||
|
||||
@@ -314,6 +314,49 @@ test("injectUniversalHandoffBody preserves original system message", () => {
|
||||
assert.strictEqual(r.messages[1].content, "Be helpful");
|
||||
});
|
||||
|
||||
test("injectUniversalHandoffBody appends Claude-native handoff to top-level system", () => {
|
||||
const body = {
|
||||
model: CURR,
|
||||
system: [{ type: "text", text: "Existing Claude prompt" }],
|
||||
max_tokens: 64,
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
};
|
||||
|
||||
const r = injectUniversalHandoffBody(body, PREV, CURR, REASON, null, undefined, "claude");
|
||||
const system = r.system as Array<Record<string, unknown>>;
|
||||
|
||||
assert.strictEqual(r.messages, body.messages);
|
||||
assert.strictEqual(system[0], body.system[0]);
|
||||
assert.match(String(system[1]?.text), /<context_handoff>/);
|
||||
assert.equal(system[1]?.type, "text");
|
||||
});
|
||||
|
||||
test("injectUniversalHandoffBody keeps OpenAI bodies with top-level system on messages path", () => {
|
||||
const body = {
|
||||
system: null,
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
};
|
||||
|
||||
const r = injectUniversalHandoffBody(body, PREV, CURR, REASON, null, undefined, "openai");
|
||||
|
||||
assert.equal((r.messages as Array<Record<string, unknown>>)[0]?.role, "system");
|
||||
assert.equal(r.system, null);
|
||||
});
|
||||
|
||||
test("injectUniversalHandoffBody creates Claude top-level system when absent", () => {
|
||||
const body = {
|
||||
model: CURR,
|
||||
max_tokens: 64,
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
};
|
||||
|
||||
const r = injectUniversalHandoffBody(body, PREV, CURR, REASON, null, undefined, "claude");
|
||||
const system = r.system as Array<Record<string, unknown>>;
|
||||
|
||||
assert.strictEqual(r.messages, body.messages);
|
||||
assert.match(String(system[0]?.text), /<context_handoff>/);
|
||||
});
|
||||
|
||||
test("injectUniversalHandoffBody Responses API with instructions", () => {
|
||||
const body = { input: "Hi", instructions: "Be nice" };
|
||||
const r = injectUniversalHandoffBody(body, PREV, CURR, REASON, null);
|
||||
|
||||
Reference in New Issue
Block a user