diff --git a/changelog.d/fixes/12668-anthropic-native-handoff-system.md b/changelog.d/fixes/12668-anthropic-native-handoff-system.md new file mode 100644 index 0000000000..0bf6377ef0 --- /dev/null +++ b/changelog.d/fixes/12668-anthropic-native-handoff-system.md @@ -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)). diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 8d0964cb1c..50612683d5 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -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); + finalizeClaudeBodyConstraints(transformedBody as Record); } // Delegated Context Editing (opt-in): attach the clear_tool_uses strategy so diff --git a/open-sse/handlers/chatCore/claudeSystemRole.ts b/open-sse/handlers/chatCore/claudeSystemRole.ts index 106d521ad0..c977cfc8e0 100644 --- a/open-sse/handlers/chatCore/claudeSystemRole.ts +++ b/open-sse/handlers/chatCore/claudeSystemRole.ts @@ -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 | null | undefined)?.ttl; @@ -163,85 +167,3 @@ export function extractSystemRoleMessages(payload: Record): 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): void { - if (!Array.isArray(payload.messages) || payload.messages.length === 0) return; - const messages = payload.messages as Array>; - const isSystemRole = (role: unknown): boolean => - typeof role === "string" && - (role.toLowerCase() === "system" || role.toLowerCase() === "developer"); - const isEmptySystem = (m: Record): boolean => - m != null && - typeof m === "object" && - isSystemRole(m.role) && - Array.isArray(m.content) && - m.content.length === 0; - const isDirectiveOnly = (m: Record): 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), - ]; -} diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index c9a66c6380..652faed634 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -403,6 +403,7 @@ export { computeFingerprint } from "./claudeCodeFingerprint.ts"; export { obfuscateSensitiveWords, setSensitiveWords } from "./claudeCodeObfuscation.ts"; export { enforceThinkingTemperature, + finalizeClaudeBodyConstraints, disableThinkingIfToolChoiceForced, enforceCacheControlLimit, } from "./claudeCodeConstraints.ts"; diff --git a/open-sse/services/claudeCodeConstraints.ts b/open-sse/services/claudeCodeConstraints.ts index 094727e11b..6aaf9295fd 100644 --- a/open-sse/services/claudeCodeConstraints.ts +++ b/open-sse/services/claudeCodeConstraints.ts @@ -32,6 +32,148 @@ export function enforceThinkingTemperature(body: Record): void } } +/** Applies the final Anthropic wire-body invariants before serialization. */ +export function finalizeClaudeBodyConstraints(body: Record): 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): boolean { + return ( + message.output_config != null && + typeof message.output_config === "object" && + !Array.isArray(message.output_config) + ); +} + +function isEmptySystemMessage(message: unknown): message is Record { + if (message == null || typeof message !== "object") return false; + const candidate = message as Record; + 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): void { + if (!Array.isArray(payload.messages) || payload.messages.length === 0) return; + const messages = payload.messages as Array>; + 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> { + if (typeof content === "string" && content.length > 0) { + return [{ type: "text", text: content }]; + } + if (!Array.isArray(content)) return []; + const blocks: Array> = []; + for (const block of content) { + if (block == null || typeof block !== "object") continue; + const contentBlock = block as Record; + 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> +): Array> { + if (typeof existing === "string" && existing.length > 0) { + return [{ type: "text", text: existing }, ...extra]; + } + if (Array.isArray(existing)) { + return [...(existing as Array>), ...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): void { + if (!Array.isArray(payload.messages) || payload.messages.length === 0) return; + const messages = payload.messages as Array>; + + let runEnd = 0; + while (runEnd < messages.length && isSystemRole(messages[runEnd]?.role)) runEnd++; + if (runEnd === 0) return; + + const extraBlocks: Array> = []; + const directives: Array> = []; + 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): void { const toolChoice = body.tool_choice as Record | string | undefined; if (!toolChoice) return; diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index bd5720e94b..caded1d748 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -990,6 +990,7 @@ async function handleComboChatInner({ stickyWeightedLimit, getWeightedStepKeyForTarget, universalHandoffConfig, + sourceFormat, relayOptions, relayConfig, }; diff --git a/open-sse/services/combo/attemptLoopTypes.ts b/open-sse/services/combo/attemptLoopTypes.ts index 7fff71b3d5..184e357c99 100644 --- a/open-sse/services/combo/attemptLoopTypes.ts +++ b/open-sse/services/combo/attemptLoopTypes.ts @@ -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; }; diff --git a/open-sse/services/combo/executeTargetAttempt.ts b/open-sse/services/combo/executeTargetAttempt.ts index 1b2614f042..debe92d2d7 100644 --- a/open-sse/services/combo/executeTargetAttempt.ts +++ b/open-sse/services/combo/executeTargetAttempt.ts @@ -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 ); } } diff --git a/open-sse/services/contextHandoff.ts b/open-sse/services/contextHandoff.ts index c43b7f55c1..f0a625ec7a 100644 --- a/open-sse/services/contextHandoff.ts +++ b/open-sse/services/contextHandoff.ts @@ -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, + handoffContent: string +): Record { + 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, payload: HandoffPayload, - _relayMode?: "schema-locked" | "standard" + _relayMode?: "schema-locked" | "standard", + sourceFormat?: string | null ): Record { 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 { + try { + return getResponseText((await response.clone().json()) as Record); + } 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); - } 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 { 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, diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 26c7b42e0a..91e49d0d27 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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", diff --git a/tests/unit/claude-directive-final-dispatch.test.ts b/tests/unit/claude-directive-final-dispatch.test.ts new file mode 100644 index 0000000000..c1b3622c50 --- /dev/null +++ b/tests/unit/claude-directive-final-dispatch.test.ts @@ -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>; + output_config?: Record; + system?: string | Array>; +}; + +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): Promise { + 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" }); +}); diff --git a/tests/unit/context-handoff.test.ts b/tests/unit/context-handoff.test.ts index 4cc4efd5aa..68fc008ab9 100644 --- a/tests/unit/context-handoff.test.ts +++ b/tests/unit/context-handoff.test.ts @@ -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>; + + assert.strictEqual(injected.messages, body.messages); + assert.deepEqual(system[0], { type: "text", text: "Existing Claude prompt" }); + assert.match(String(system[1]?.text), //); + 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>)[0]?.role, "system"); + assert.equal(injected.system, undefined); +}); + test("injectHandoffIntoBody preserves Responses API shape for native Codex requests", () => { const payload = { sessionId: "sess-1", diff --git a/tests/unit/universal-handoff.test.ts b/tests/unit/universal-handoff.test.ts index addf4b633e..798ea86951 100644 --- a/tests/unit/universal-handoff.test.ts +++ b/tests/unit/universal-handoff.test.ts @@ -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>; + + assert.strictEqual(r.messages, body.messages); + assert.strictEqual(system[0], body.system[0]); + assert.match(String(system[1]?.text), //); + 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>)[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>; + + assert.strictEqual(r.messages, body.messages); + assert.match(String(system[0]?.text), //); +}); + test("injectUniversalHandoffBody Responses API with instructions", () => { const body = { input: "Hi", instructions: "Be nice" }; const r = injectUniversalHandoffBody(body, PREV, CURR, REASON, null);