mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 19:52:50 +03:00
fix(sse): relocate directive-only messages off messages[0] (#10457)
The upstream Messages API rejects directive-style messages (empty content array with a message-level output_config) when they sit at messages[0] — the initial system prompt position — while accepting the form at any other position. Measured in production: 122x 400 on the offical-claude combo in one hour. The mid-conversation-system passthrough (official provider + 1M-context beta models) keeps system-role messages inside messages[], so a directive that arrived first went upstream unchanged. relocateDirectiveOnlyMessages() moves the whole leading run of empty system messages: directive-only ones past the first real turn, plain empties dropped. extractSystemRoleMessages() now folds a directive's output_config into the top-level parameter instead of silently discarding it. Signed-off-by: Minxi Hou <houminxi@gmail.com>
This commit is contained in:
@@ -7,8 +7,8 @@ import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
|
||||
import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts";
|
||||
import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts";
|
||||
import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts";
|
||||
import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
|
||||
export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
|
||||
import { extractSystemRoleMessages, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts";
|
||||
export { extractSystemRoleMessages, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts";
|
||||
import { checkIdempotencyCache } from "./chatCore/idempotency.ts";
|
||||
import { checkSemanticCache } from "./chatCore/semanticCache.ts";
|
||||
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
|
||||
@@ -2132,6 +2132,12 @@ export async function handleChatCore({
|
||||
!shouldUseMidConversationSystem(translatedBody, effectiveModel)
|
||||
) {
|
||||
extractSystemRoleMessages(translatedBody);
|
||||
} else {
|
||||
// The mid-conversation-system path keeps system-role messages inside
|
||||
// messages[], but a directive-only message (content: [] +
|
||||
// output_config) at messages[0] is rejected by Anthropic. Move it past
|
||||
// the first real turn; Anthropic accepts the form at any other position.
|
||||
relocateDirectiveOnlyMessages(translatedBody);
|
||||
}
|
||||
if (Array.isArray(translatedBody.messages)) {
|
||||
translatedBody.messages = splitMisplacedToolResults(
|
||||
|
||||
@@ -135,6 +135,21 @@ export function extractSystemRoleMessages(payload: Record<string, unknown>): voi
|
||||
}
|
||||
}
|
||||
}
|
||||
// Directive payload (message-level output_config, as emitted by Claude
|
||||
// Code clients): the message itself is lifted away, so fold its output
|
||||
// configuration into the top-level parameter instead of silently dropping
|
||||
// it — whatever shape the content had. An explicit top-level output_config
|
||||
// wins, and among several directive messages the first one wins.
|
||||
if (payload.output_config == null) {
|
||||
const directive = sm as Record<string, unknown>;
|
||||
if (
|
||||
directive.output_config != null &&
|
||||
typeof directive.output_config === "object" &&
|
||||
!Array.isArray(directive.output_config)
|
||||
) {
|
||||
payload.output_config = directive.output_config;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extraBlocks.length > 0) {
|
||||
const existingSystem = payload.system;
|
||||
@@ -148,3 +163,85 @@ 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),
|
||||
];
|
||||
}
|
||||
|
||||
120
tests/unit/claude-directive-midconv-passthrough.test.ts
Normal file
120
tests/unit/claude-directive-midconv-passthrough.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// @ts-nocheck
|
||||
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-midconv-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function noopLog() {
|
||||
return {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushAsyncSideEffects() {
|
||||
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
await flushAsyncSideEffects();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("claude mid-conversation-system passthrough relocates a directive-only messages[0]", async () => {
|
||||
let captured = null;
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
captured = {
|
||||
url: String(url),
|
||||
method: init.method ?? "GET",
|
||||
headers: new Headers(init.headers),
|
||||
body: JSON.parse(String(init.body || "{}")),
|
||||
};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "msg_test",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-opus-5",
|
||||
content: [{ type: "text", text: "OK" }],
|
||||
usage: { input_tokens: 4, output_tokens: 1 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const body = {
|
||||
model: "claude-opus-5",
|
||||
max_tokens: 64,
|
||||
system: [{ type: "text", text: "You are Claude." }],
|
||||
tools: [{ name: "Bash", description: "Run a command", input_schema: { type: "object" } }],
|
||||
messages: [
|
||||
{ role: "system", content: [], output_config: { effort: "medium" } },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const result = await handleChatCore({
|
||||
body: structuredClone(body),
|
||||
modelInfo: { provider: "claude", model: "claude-opus-5", extendedContext: false },
|
||||
credentials: { apiKey: "test-claude-key", providerSpecificData: {} },
|
||||
log: noopLog(),
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/messages",
|
||||
body: structuredClone(body),
|
||||
headers: new Headers({
|
||||
accept: "application/json",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "claude-code/2.1.154",
|
||||
}),
|
||||
},
|
||||
userAgent: "claude-code/2.1.154",
|
||||
});
|
||||
|
||||
assert.ok(captured, "fetch was not called");
|
||||
assert.ok(captured.url.startsWith("https://api.anthropic.com/v1/messages"));
|
||||
assert.equal(captured.method, "POST");
|
||||
assert.ok(captured.headers.get("x-api-key"), "x-api-key header missing");
|
||||
assert.ok(captured.headers.get("anthropic-version"), "anthropic-version header missing");
|
||||
assert.equal(result.success, true);
|
||||
// The directive-only message must not sit at messages[0] when it reaches upstream.
|
||||
const upstreamMessages = captured.body.messages;
|
||||
assert.equal(upstreamMessages[0].role, "user");
|
||||
assert.equal(upstreamMessages[1].role, "system");
|
||||
assert.deepEqual(upstreamMessages[1].output_config, { effort: "medium" });
|
||||
// The relocation must not disturb anything else the client sent.
|
||||
assert.deepEqual(upstreamMessages[1].content, []);
|
||||
assert.equal(upstreamMessages[0].content, "hello");
|
||||
// The claude identity layer prepends its own blocks; assert the client's
|
||||
// block survived rather than an exact count.
|
||||
assert.ok(
|
||||
captured.body.system.some(
|
||||
(block) => block.type === "text" && block.text === "You are Claude."
|
||||
)
|
||||
);
|
||||
assert.equal(captured.body.tools.length, 1);
|
||||
// The directive stays message-level; the top level (if set) is the base
|
||||
// executor's own default injection, not the hoisted directive value.
|
||||
assert.notDeepEqual(captured.body.output_config, { effort: "medium" });
|
||||
});
|
||||
291
tests/unit/claude-directive-only-relocation.test.ts
Normal file
291
tests/unit/claude-directive-only-relocation.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
extractSystemRoleMessages,
|
||||
relocateDirectiveOnlyMessages,
|
||||
} from "../../open-sse/handlers/chatCore.ts";
|
||||
|
||||
// Claude Code 2.1.154+ clients send directives as system-role messages with an
|
||||
// empty content array and a message-level output_config. Anthropic rejects the
|
||||
// directive-only form when it lands at messages[0] (the initial system prompt
|
||||
// position) while accepting it at any other position. Upstream error text:
|
||||
// messages.0: use the top-level 'system' parameter for the initial system
|
||||
// prompt; the directive-only form (content: [] with output_config) is
|
||||
// accepted at any position
|
||||
// Measured in production: 122x 400 in one hour on the offical-claude combo.
|
||||
|
||||
test("relocateDirectiveOnlyMessages moves a directive-only messages[0] past the first real turn", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: [], output_config: { effort: "high" } },
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "assistant", content: "hi" },
|
||||
],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 3);
|
||||
assert.equal(payload.messages[0].role, "user");
|
||||
assert.equal(payload.messages[1].role, "system");
|
||||
assert.deepEqual(payload.messages[1].output_config, { effort: "high" });
|
||||
assert.equal(payload.messages[2].role, "assistant");
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages skips consecutive system messages to find the real turn", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: [], output_config: { effort: "high" } },
|
||||
{ role: "system", content: "mid-conversation context" },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 3);
|
||||
assert.equal(payload.messages[0].role, "system");
|
||||
assert.equal(payload.messages[0].content, "mid-conversation context");
|
||||
assert.equal(payload.messages[1].role, "user");
|
||||
assert.equal(payload.messages[2].role, "system");
|
||||
assert.deepEqual(payload.messages[2].output_config, { effort: "high" });
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages drops an empty system message without output_config at messages[0]", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: [] },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 1);
|
||||
assert.equal(payload.messages[0].role, "user");
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages folds output_config to top level when no real turn exists", () => {
|
||||
const payload = {
|
||||
messages: [{ role: "system", content: [], output_config: { effort: "xhigh" } }],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 0);
|
||||
assert.deepEqual(payload.output_config, { effort: "xhigh" });
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages keeps an existing top-level output_config untouched", () => {
|
||||
const payload = {
|
||||
output_config: { effort: "low" },
|
||||
messages: [{ role: "system", content: [], output_config: { effort: "xhigh" } }],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 0);
|
||||
assert.deepEqual(payload.output_config, { effort: "low" });
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages is a no-op for a normal user first message", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "system", content: [], output_config: { effort: "high" } },
|
||||
],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 2);
|
||||
assert.equal(payload.messages[0].role, "user");
|
||||
assert.equal(payload.messages[1].role, "system");
|
||||
assert.equal(payload.messages[1].content.length, 0);
|
||||
assert.deepEqual(payload.messages[1].output_config, { effort: "high" });
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages relocates a directive after an empty system message", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: [] },
|
||||
{ role: "system", content: [], output_config: { effort: "high" } },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 2);
|
||||
assert.equal(payload.messages[0].role, "user");
|
||||
assert.equal(payload.messages[1].role, "system");
|
||||
assert.deepEqual(payload.messages[1].output_config, { effort: "high" });
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages relocates consecutive directives in order", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: [], output_config: { effort: "high" } },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 3);
|
||||
assert.equal(payload.messages[0].role, "user");
|
||||
assert.equal(payload.messages[1].role, "system");
|
||||
assert.deepEqual(payload.messages[1].output_config, { effort: "high" });
|
||||
assert.equal(payload.messages[2].role, "system");
|
||||
assert.deepEqual(payload.messages[2].output_config, { effort: "low" });
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages walks past a text system message to find the anchor", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: [], output_config: { effort: "high" } },
|
||||
{ role: "system", content: "real system prompt" },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 3);
|
||||
assert.equal(payload.messages[0].content, "real system prompt");
|
||||
assert.equal(payload.messages[1].role, "user");
|
||||
assert.equal(payload.messages[2].role, "system");
|
||||
assert.deepEqual(payload.messages[2].output_config, { effort: "high" });
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages is a no-op for a system message with text content", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: "real system prompt" },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 2);
|
||||
assert.equal(payload.messages[0].content, "real system prompt");
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages handles a non-array messages field", () => {
|
||||
const payload = { messages: "not-an-array" };
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages, "not-an-array");
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages handles an empty messages array", () => {
|
||||
const payload = { messages: [] };
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 0);
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages handles developer-role directives too", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "developer", content: [], output_config: { format: { type: "json_schema" } } },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 2);
|
||||
assert.equal(payload.messages[0].role, "user");
|
||||
assert.equal(payload.messages[1].role, "developer");
|
||||
assert.deepEqual(payload.messages[1].output_config, {
|
||||
format: { type: "json_schema" },
|
||||
});
|
||||
});
|
||||
|
||||
test("extractSystemRoleMessages preserves the output_config of directive-only messages", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: "Memory context: foo" },
|
||||
{ role: "system", content: [], output_config: { effort: "high" } },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
extractSystemRoleMessages(payload);
|
||||
assert.equal(payload.messages.length, 1);
|
||||
assert.equal(payload.messages[0].role, "user");
|
||||
assert.deepEqual(payload.system, [{ type: "text", text: "Memory context: foo" }]);
|
||||
assert.deepEqual(payload.output_config, { effort: "high" });
|
||||
});
|
||||
|
||||
test("extractSystemRoleMessages keeps an existing top-level output_config", () => {
|
||||
const payload = {
|
||||
output_config: { effort: "low" },
|
||||
messages: [
|
||||
{ role: "system", content: [], output_config: { effort: "high" } },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
extractSystemRoleMessages(payload);
|
||||
assert.equal(payload.messages.length, 1);
|
||||
assert.deepEqual(payload.output_config, { effort: "low" });
|
||||
});
|
||||
|
||||
test("extractSystemRoleMessages folds output_config even when the message also has text", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "Text + directive" }],
|
||||
output_config: { effort: "high" },
|
||||
},
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
extractSystemRoleMessages(payload);
|
||||
assert.equal(payload.messages.length, 1);
|
||||
assert.deepEqual(payload.system, [{ type: "text", text: "Text + directive" }]);
|
||||
assert.deepEqual(payload.output_config, { effort: "high" });
|
||||
});
|
||||
|
||||
test("extractSystemRoleMessages keeps the first directive output_config among several", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: [], output_config: { effort: "high" } },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
extractSystemRoleMessages(payload);
|
||||
assert.equal(payload.messages.length, 1);
|
||||
assert.deepEqual(payload.output_config, { effort: "high" });
|
||||
});
|
||||
|
||||
test("extractSystemRoleMessages folds output_config for string-content messages too", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: "String content", output_config: { effort: "high" } },
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
extractSystemRoleMessages(payload);
|
||||
assert.equal(payload.messages.length, 1);
|
||||
assert.deepEqual(payload.system, [{ type: "text", text: "String content" }]);
|
||||
assert.deepEqual(payload.output_config, { effort: "high" });
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages does not throw on a null first message", () => {
|
||||
const payload = {
|
||||
messages: [null, { role: "user", content: "hello" }],
|
||||
};
|
||||
assert.doesNotThrow(() => relocateDirectiveOnlyMessages(payload));
|
||||
assert.equal(payload.messages.length, 2);
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages does not throw on a null anchor candidate", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: [], output_config: { effort: "high" } },
|
||||
null,
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
assert.doesNotThrow(() => relocateDirectiveOnlyMessages(payload));
|
||||
// The null entry stays where it was; the directive lands after the real turn.
|
||||
assert.equal(payload.messages.length, 3);
|
||||
assert.equal(payload.messages[0], null);
|
||||
assert.equal(payload.messages[1].role, "user");
|
||||
assert.equal(payload.messages[2].role, "system");
|
||||
assert.deepEqual(payload.messages[2].output_config, { effort: "high" });
|
||||
});
|
||||
|
||||
test("relocateDirectiveOnlyMessages drops plain empties but keeps text system messages with no real turn", () => {
|
||||
const payload = {
|
||||
messages: [
|
||||
{ role: "system", content: [] },
|
||||
{ role: "system", content: "keep me" },
|
||||
],
|
||||
};
|
||||
relocateDirectiveOnlyMessages(payload);
|
||||
assert.equal(payload.messages.length, 1);
|
||||
assert.equal(payload.messages[0].content, "keep me");
|
||||
assert.equal(payload.output_config, undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user