mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(ai): add developer message role
This commit is contained in:
parent
4c1861033b
commit
8c61663f30
32 changed files with 596 additions and 46 deletions
|
|
@ -31,7 +31,11 @@ export type { QueueMode } from "./types.ts";
|
|||
|
||||
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||
return messages.filter(
|
||||
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
(message) =>
|
||||
message.role === "user" ||
|
||||
message.role === "developer" ||
|
||||
message.role === "assistant" ||
|
||||
message.role === "toolResult",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -225,6 +225,8 @@ export function estimateTokens(message: AgentMessage): number {
|
|||
let chars = 0;
|
||||
|
||||
switch (message.role) {
|
||||
case "developer":
|
||||
return Math.ceil(message.content.length / 4);
|
||||
case "user": {
|
||||
chars = estimateTextAndImageContentChars(
|
||||
(message as { content: string | Array<{ type: string; text?: string }> }).content,
|
||||
|
|
@ -275,6 +277,7 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end
|
|||
case "branchSummary":
|
||||
case "compactionSummary":
|
||||
case "user":
|
||||
case "developer":
|
||||
case "assistant":
|
||||
cutPoints.push(i);
|
||||
break;
|
||||
|
|
@ -311,7 +314,7 @@ export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: numb
|
|||
}
|
||||
if (entry.type === "message") {
|
||||
const role = entry.message.role;
|
||||
if (role === "user" || role === "bashExecution") {
|
||||
if (role === "user" || role === "developer" || role === "bashExecution") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
|
@ -370,13 +373,14 @@ export function findCutPoint(
|
|||
cutIndex--;
|
||||
}
|
||||
const cutEntry = entries[cutIndex];
|
||||
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
|
||||
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
||||
const isTurnStartMessage =
|
||||
cutEntry.type === "message" && (cutEntry.message.role === "user" || cutEntry.message.role === "developer");
|
||||
const turnStartIndex = isTurnStartMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
||||
|
||||
return {
|
||||
firstKeptEntryIndex: cutIndex,
|
||||
turnStartIndex,
|
||||
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
|
||||
isSplitTurn: !isTurnStartMessage && turnStartIndex !== -1,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,9 @@ export function serializeConversation(messages: Message[]): string {
|
|||
const parts: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
if (msg.role === "developer") {
|
||||
if (msg.content) parts.push(`[Developer]: ${msg.content}`);
|
||||
} else if (msg.role === "user") {
|
||||
const content =
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|||
timestamp: m.timestamp,
|
||||
};
|
||||
case "user":
|
||||
case "developer":
|
||||
case "assistant":
|
||||
case "toolResult":
|
||||
return m;
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|||
/**
|
||||
* Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
|
||||
*
|
||||
* Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage
|
||||
* Each AgentMessage must be converted to a UserMessage, DeveloperMessage, AssistantMessage, or ToolResultMessage
|
||||
* that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications,
|
||||
* status messages) should be filtered out.
|
||||
*
|
||||
|
|
@ -419,7 +419,7 @@ export type AgentEvent =
|
|||
// Turn lifecycle - a turn is one assistant response + any tool calls/results
|
||||
| { type: "turn_start" }
|
||||
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
|
||||
// Message lifecycle - emitted for user, assistant, and toolResult messages
|
||||
// Message lifecycle - emitted for user, developer, assistant, and toolResult messages
|
||||
| { type: "message_start"; message: AgentMessage }
|
||||
// Only emitted for assistant messages during streaming
|
||||
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
serializeConversation,
|
||||
shouldCompact,
|
||||
} from "../../src/harness/compaction/compaction.ts";
|
||||
import { convertToLlm } from "../../src/harness/messages.ts";
|
||||
import { buildSessionContext } from "../../src/harness/session/session.ts";
|
||||
import type {
|
||||
BranchSummaryEntry,
|
||||
|
|
@ -62,6 +63,14 @@ function createUserMessage(text: string): AgentMessage {
|
|||
};
|
||||
}
|
||||
|
||||
function createDeveloperMessage(text: string): AgentMessage {
|
||||
return {
|
||||
role: "developer",
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantMessage(text: string, usage = createMockUsage(100, 50)): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
|
|
@ -163,6 +172,11 @@ describe("harness compaction", () => {
|
|||
expect(shouldCompact(95000, 100000, { ...settings, enabled: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("retains developer messages when converting harness context", () => {
|
||||
const developer = createDeveloperMessage("Follow the project policy.");
|
||||
expect(convertToLlm([developer])).toEqual([developer]);
|
||||
});
|
||||
|
||||
it("finds a cut point based on token differences", () => {
|
||||
const entries: SessionTreeEntry[] = [];
|
||||
let parentId: string | null = null;
|
||||
|
|
@ -214,6 +228,17 @@ describe("harness compaction", () => {
|
|||
const result = findCutPoint([thinking, branchSummary, customMessage], 0, 3, 1);
|
||||
expect(result.firstKeptEntryIndex).toBe(0);
|
||||
|
||||
const developerUser = createMessageEntry(createUserMessage("user"));
|
||||
const developerAssistant = createMessageEntry(createAssistantMessage("assistant"), developerUser.id);
|
||||
const developer = createMessageEntry(createDeveloperMessage("developer instruction"), developerAssistant.id);
|
||||
const developerEntries = [developerUser, developerAssistant, developer];
|
||||
expect(findTurnStartIndex(developerEntries, 2, 0)).toBe(2);
|
||||
expect(findCutPoint(developerEntries, 0, 3, 1)).toEqual({
|
||||
firstKeptEntryIndex: 2,
|
||||
turnStartIndex: -1,
|
||||
isSplitTurn: false,
|
||||
});
|
||||
|
||||
const toolResult = createMessageEntry({
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
|
|
@ -285,6 +310,7 @@ describe("harness compaction", () => {
|
|||
};
|
||||
|
||||
expect(estimateTokens({ role: "user", content: "plain user", timestamp: Date.now() })).toBeGreaterThan(0);
|
||||
expect(estimateTokens(createDeveloperMessage("substantial developer instruction"))).toBeGreaterThan(0);
|
||||
expect(estimateTokens(assistantWithThinkingAndTool)).toBeGreaterThan(0);
|
||||
expect(estimateTokens(customString)).toBeGreaterThan(0);
|
||||
expect(estimateTokens(toolResultWithImage)).toBeGreaterThan(1000);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
AnthropicMessagesCompat,
|
||||
Api,
|
||||
KnownProvider,
|
||||
MistralConversationsCompat,
|
||||
Model,
|
||||
OpenAICompletionsCompat,
|
||||
OpenAIResponsesCompat,
|
||||
|
|
@ -210,6 +211,53 @@ const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([
|
|||
"gpt-5.6-terra",
|
||||
"gpt-5.6-luna",
|
||||
]);
|
||||
const VERIFIED_OPENROUTER_DEVELOPER_ROLE_MODELS = new Set([
|
||||
"openai/gpt-oss-20b",
|
||||
"openai/gpt-oss-120b",
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
"anthropic/claude-sonnet-5",
|
||||
]);
|
||||
const VERIFIED_RESPONSES_DEVELOPER_ROLE_MODELS = new Set([
|
||||
"openai-codex:gpt-5.3-codex-spark",
|
||||
"openai-codex:gpt-5.4",
|
||||
"openai-codex:gpt-5.4-mini",
|
||||
"openai-codex:gpt-5.5",
|
||||
"openai-codex:gpt-5.6-sol",
|
||||
"openai-codex:gpt-5.6-terra",
|
||||
"github-copilot:gpt-5-mini",
|
||||
"github-copilot:gpt-5.3-codex",
|
||||
"github-copilot:gpt-5.4",
|
||||
"github-copilot:gpt-5.4-mini",
|
||||
"github-copilot:gpt-5.5",
|
||||
"opencode:gpt-5.4",
|
||||
"opencode:gpt-5.4-mini",
|
||||
]);
|
||||
const VERIFIED_MISTRAL_DEVELOPER_ROLE_MODELS = new Set([
|
||||
"codestral-latest",
|
||||
"devstral-2512",
|
||||
"devstral-latest",
|
||||
"devstral-medium-latest",
|
||||
"labs-devstral-small-2512",
|
||||
"magistral-medium-latest",
|
||||
"ministral-3b-latest",
|
||||
"ministral-8b-latest",
|
||||
"mistral-large-2512",
|
||||
"mistral-large-latest",
|
||||
"mistral-medium-2505",
|
||||
"mistral-medium-2508",
|
||||
"mistral-medium-2604",
|
||||
"mistral-medium-3.5",
|
||||
"mistral-medium-latest",
|
||||
"mistral-small-2506",
|
||||
"mistral-small-2603",
|
||||
"mistral-small-latest",
|
||||
"open-mistral-7b",
|
||||
"open-mistral-nemo",
|
||||
"open-mixtral-8x22b",
|
||||
"open-mixtral-8x7b",
|
||||
"pixtral-12b",
|
||||
]);
|
||||
const OPENAI_LONG_CONTEXT_INPUT_THRESHOLD = 272000;
|
||||
const OPENAI_SHORT_CONTEXT_CAPPED_MODEL_IDS = new Set([
|
||||
"gpt-5.4",
|
||||
|
|
@ -355,6 +403,17 @@ function isAnthropicTemperatureUnsupportedModel(modelId: string): boolean {
|
|||
return id.includes("opus-4-7") || id.includes("opus-4.7") || id.includes("opus-4-8") || id.includes("opus-4.8");
|
||||
}
|
||||
|
||||
function supportsAnthropicDeveloperRole(modelId: string): boolean {
|
||||
const id = modelId.toLowerCase();
|
||||
return (
|
||||
id.includes("opus-4-8") ||
|
||||
id.includes("opus-4.8") ||
|
||||
id.includes("fable-5") ||
|
||||
id.includes("sonnet-5") ||
|
||||
id.includes("sonnet.5")
|
||||
);
|
||||
}
|
||||
|
||||
const OPENAI_COMPLETIONS_DEFAULT_COMPAT = {
|
||||
supportsStore: true,
|
||||
supportsDeveloperRole: true,
|
||||
|
|
@ -398,6 +457,7 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open
|
|||
provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz");
|
||||
const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot.");
|
||||
const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
|
||||
const isOfficialOpenAI = provider === "openai" || baseUrl.includes("api.openai.com");
|
||||
const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com");
|
||||
const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com");
|
||||
const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com");
|
||||
|
|
@ -426,13 +486,12 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open
|
|||
|
||||
const isGrok = provider === "xai" || baseUrl.includes("api.x.ai");
|
||||
const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com");
|
||||
const isOpenRouterDeveloperRoleModel =
|
||||
isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/"));
|
||||
const isOpenRouterDeveloperRoleModel = isOpenRouter && VERIFIED_OPENROUTER_DEVELOPER_ROLE_MODELS.has(model.id);
|
||||
const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined;
|
||||
|
||||
return {
|
||||
supportsStore: !isNonStandard,
|
||||
supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter),
|
||||
supportsDeveloperRole: isOpenRouterDeveloperRoleModel || isOfficialOpenAI,
|
||||
supportsReasoningEffort:
|
||||
!isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing,
|
||||
supportsUsageInStreaming: true,
|
||||
|
|
@ -508,6 +567,33 @@ function applyOpenAIToolSearchMetadata(model: Model<Api>): void {
|
|||
};
|
||||
}
|
||||
|
||||
function applyDeveloperRoleMetadata(model: Model<Api>): void {
|
||||
if (
|
||||
model.api === "openai-responses" ||
|
||||
model.api === "openai-codex-responses" ||
|
||||
model.api === "azure-openai-responses"
|
||||
) {
|
||||
const supported =
|
||||
model.provider === "openai" ||
|
||||
model.provider === "azure-openai-responses" ||
|
||||
VERIFIED_RESPONSES_DEVELOPER_ROLE_MODELS.has(`${model.provider}:${model.id}`);
|
||||
if (supported) {
|
||||
model.compat = {
|
||||
...(model.compat as OpenAIResponsesCompat | undefined),
|
||||
supportsDeveloperRole: true,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.api === "mistral-conversations" && VERIFIED_MISTRAL_DEVELOPER_ROLE_MODELS.has(model.id)) {
|
||||
model.compat = {
|
||||
...(model.compat as MistralConversationsCompat | undefined),
|
||||
supportsDeveloperRole: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isGemini3ProModel(modelId: string): boolean {
|
||||
return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase());
|
||||
}
|
||||
|
|
@ -577,6 +663,13 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
|
|||
if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) {
|
||||
mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true });
|
||||
}
|
||||
if (
|
||||
model.api === "anthropic-messages" &&
|
||||
model.provider === "anthropic" &&
|
||||
supportsAnthropicDeveloperRole(model.id)
|
||||
) {
|
||||
mergeAnthropicMessagesCompat(model, { supportsDeveloperRole: true });
|
||||
}
|
||||
if (model.api === "anthropic-messages" && isAnthropicTemperatureUnsupportedModel(model.id)) {
|
||||
mergeAnthropicMessagesCompat(model, { supportsTemperature: false });
|
||||
}
|
||||
|
|
@ -2205,6 +2298,7 @@ async function generateModels() {
|
|||
applyThinkingLevelMetadata(model);
|
||||
applyOpenAICompletionsCompatMetadata(model);
|
||||
applyOpenAIToolSearchMetadata(model);
|
||||
applyDeveloperRoleMetadata(model);
|
||||
}
|
||||
|
||||
// Group by provider and deduplicate by model ID
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts"
|
|||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
|
||||
import { developerMessageToUserMessage, downgradeDeveloperMessages } from "./developer-messages.ts";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
|
||||
import { adjustMaxTokensForThinking, buildBaseOptions, clampMaxTokensToContext } from "./simple-options.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
|
@ -172,6 +173,7 @@ function getAnthropicCompat(
|
|||
model: Model<"anthropic-messages">,
|
||||
): Required<Omit<AnthropicMessagesCompat, "forceAdaptiveThinking">> {
|
||||
return {
|
||||
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? false,
|
||||
supportsEagerToolInputStreaming: model.compat?.supportsEagerToolInputStreaming ?? true,
|
||||
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
|
||||
sendSessionAffinityHeaders: model.compat?.sendSessionAffinityHeaders ?? false,
|
||||
|
|
@ -923,7 +925,17 @@ function buildParams(
|
|||
): MessageCreateParamsStreaming {
|
||||
const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env);
|
||||
const compat = getAnthropicCompat(model);
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
const anthropicMessages = compat.supportsDeveloperRole
|
||||
? context.messages.map((message, index): Message => {
|
||||
if (message.role !== "developer") return message;
|
||||
const previousRole = context.messages[index - 1]?.role;
|
||||
const nextRole = context.messages[index + 1]?.role;
|
||||
const hasValidPreviousRole = previousRole === "user" || previousRole === "toolResult";
|
||||
const hasValidNextRole = nextRole === undefined || nextRole === "assistant";
|
||||
return hasValidPreviousRole && hasValidNextRole ? message : developerMessageToUserMessage(message);
|
||||
})
|
||||
: downgradeDeveloperMessages(context.messages);
|
||||
const transformedMessages = transformMessages(anthropicMessages, model, normalizeToolCallId);
|
||||
const normalizeToolName = isOAuthToken ? toClaudeCodeName : (name: string) => name;
|
||||
const toolPlacement = splitDeferredTools(
|
||||
{ ...context, messages: transformedMessages },
|
||||
|
|
@ -946,7 +958,7 @@ function buildParams(
|
|||
compat.allowEmptySignature,
|
||||
deferredToolNames,
|
||||
normalizeToolName,
|
||||
),
|
||||
) as MessageParam[],
|
||||
max_tokens: options?.maxTokens ?? model.maxTokens,
|
||||
stream: true,
|
||||
};
|
||||
|
|
@ -1136,6 +1148,11 @@ function convertMessages(
|
|||
content: filteredBlocks,
|
||||
});
|
||||
}
|
||||
} else if (msg.role === "developer") {
|
||||
params.push({
|
||||
role: "system",
|
||||
content: sanitizeSurrogates(msg.content),
|
||||
} as unknown as MessageParam);
|
||||
} else if (msg.role === "assistant") {
|
||||
const blocks: ContentBlockParam[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import { parseStreamingJson } from "../utils/json-parse.ts";
|
|||
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { downgradeDeveloperMessages } from "./developer-messages.ts";
|
||||
import {
|
||||
adjustMaxTokensForThinking,
|
||||
buildBaseOptions,
|
||||
|
|
@ -743,7 +744,11 @@ function convertMessages(
|
|||
env?: ProviderEnv,
|
||||
): Message[] {
|
||||
const result: Message[] = [];
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
const transformedMessages = transformMessages(
|
||||
downgradeDeveloperMessages(context.messages),
|
||||
model,
|
||||
normalizeToolCallId,
|
||||
);
|
||||
|
||||
for (let i = 0; i < transformedMessages.length; i++) {
|
||||
const m = transformedMessages[i];
|
||||
|
|
|
|||
21
packages/ai/src/api/developer-messages.ts
Normal file
21
packages/ai/src/api/developer-messages.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { DeveloperMessage, Message, UserMessage } from "../types.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
|
||||
export const SYNTHETIC_DEVELOPER_MESSAGE_PREFIX = `<developer_message>
|
||||
The following instruction was provided by the application developer and applies from this point forward:
|
||||
|
||||
`;
|
||||
export const SYNTHETIC_DEVELOPER_MESSAGE_SUFFIX = `
|
||||
</developer_message>`;
|
||||
|
||||
export function developerMessageToUserMessage(message: DeveloperMessage): UserMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: `${SYNTHETIC_DEVELOPER_MESSAGE_PREFIX}${sanitizeSurrogates(message.content)}${SYNTHETIC_DEVELOPER_MESSAGE_SUFFIX}`,
|
||||
timestamp: message.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function downgradeDeveloperMessages(messages: Message[]): Message[] {
|
||||
return messages.map((message) => (message.role === "developer" ? developerMessageToUserMessage(message) : message));
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
|
||||
import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { downgradeDeveloperMessages } from "./developer-messages.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
type GoogleApiType = "google-generative-ai" | "google-vertex";
|
||||
|
|
@ -95,7 +96,11 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
|||
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
|
||||
};
|
||||
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
const transformedMessages = transformMessages(
|
||||
downgradeDeveloperMessages(context.messages),
|
||||
model,
|
||||
normalizeToolCallId,
|
||||
);
|
||||
|
||||
for (const msg of transformedMessages) {
|
||||
if (msg.role === "user") {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
|||
import { shortHash } from "../utils/hash.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { downgradeDeveloperMessages } from "./developer-messages.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
|
|
@ -68,7 +69,11 @@ export const stream: StreamFunction<"mistral-conversations", MistralOptions> = (
|
|||
});
|
||||
|
||||
const normalizeMistralToolCallId = createMistralToolCallIdNormalizer();
|
||||
const transformedMessages = transformMessages(context.messages, model, (id) => normalizeMistralToolCallId(id));
|
||||
const transformedMessages = transformMessages(
|
||||
model.compat?.supportsDeveloperRole ? context.messages : downgradeDeveloperMessages(context.messages),
|
||||
model,
|
||||
(id) => normalizeMistralToolCallId(id),
|
||||
);
|
||||
|
||||
let payload = buildChatPayload(model, context, transformedMessages, options);
|
||||
const nextPayload = await options?.onPayload?.(payload, model);
|
||||
|
|
@ -514,6 +519,11 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl
|
|||
const result: ChatCompletionStreamRequestMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "developer") {
|
||||
result.push({ role: "system", content: sanitizeSurrogates(msg.content) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
result.push({ role: "user", content: sanitizeSurrogates(msg.content) });
|
||||
|
|
|
|||
|
|
@ -38,11 +38,20 @@ import { headersToRecord } from "../utils/headers.ts";
|
|||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { downgradeDeveloperMessages } from "./developer-messages.ts";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
const OPENROUTER_DEVELOPER_ROLE_MODELS = new Set([
|
||||
"openai/gpt-oss-20b",
|
||||
"openai/gpt-oss-120b",
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
"anthropic/claude-sonnet-5",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if conversation messages contain tool calls or tool results.
|
||||
* This is needed because Anthropic (via proxy) requires the tools param
|
||||
|
|
@ -867,7 +876,11 @@ export function convertMessages(
|
|||
return id;
|
||||
};
|
||||
|
||||
const transformedMessages = transformMessages(context.messages, model, (id) => normalizeToolCallId(id));
|
||||
const transformedMessages = transformMessages(
|
||||
compat.supportsDeveloperRole ? context.messages : downgradeDeveloperMessages(context.messages),
|
||||
model,
|
||||
(id) => normalizeToolCallId(id),
|
||||
);
|
||||
|
||||
if (context.systemPrompt) {
|
||||
const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
|
||||
|
|
@ -888,7 +901,12 @@ export function convertMessages(
|
|||
});
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
if (msg.role === "developer") {
|
||||
params.push({
|
||||
role: "developer",
|
||||
content: sanitizeSurrogates(msg.content),
|
||||
});
|
||||
} else if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
params.push({
|
||||
role: "user",
|
||||
|
|
@ -1190,6 +1208,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
|
|||
provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz");
|
||||
const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot.");
|
||||
const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
|
||||
const isOfficialOpenAI = provider === "openai" || baseUrl.includes("api.openai.com");
|
||||
const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com");
|
||||
const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com");
|
||||
const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com");
|
||||
|
|
@ -1217,13 +1236,12 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
|
|||
|
||||
const isGrok = provider === "xai" || baseUrl.includes("api.x.ai");
|
||||
const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com");
|
||||
const isOpenRouterDeveloperRoleModel =
|
||||
isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/"));
|
||||
const isOpenRouterDeveloperRoleModel = isOpenRouter && OPENROUTER_DEVELOPER_ROLE_MODELS.has(model.id);
|
||||
const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined;
|
||||
|
||||
return {
|
||||
supportsStore: !isNonStandard,
|
||||
supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter),
|
||||
supportsDeveloperRole: isOpenRouterDeveloperRoleModel || isOfficialOpenAI,
|
||||
supportsReasoningEffort:
|
||||
!isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing,
|
||||
supportsUsageInStreaming: true,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import type { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
|||
import { shortHash } from "../utils/hash.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { downgradeDeveloperMessages } from "./developer-messages.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -127,12 +128,16 @@ export function convertResponsesMessages<TApi extends Api>(
|
|||
return `${normalizedCallId}|${normalizedItemId}`;
|
||||
};
|
||||
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
|
||||
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
|
||||
const supportsDeveloperRole = compat?.supportsDeveloperRole === true;
|
||||
const transformedMessages = transformMessages(
|
||||
supportsDeveloperRole ? context.messages : downgradeDeveloperMessages(context.messages),
|
||||
model,
|
||||
normalizeToolCallId,
|
||||
);
|
||||
const includeSystemPrompt = options?.includeSystemPrompt ?? true;
|
||||
if (includeSystemPrompt && context.systemPrompt) {
|
||||
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
|
||||
const role = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
|
||||
const role = model.reasoning && supportsDeveloperRole ? "developer" : "system";
|
||||
messages.push({
|
||||
role,
|
||||
content: sanitizeSurrogates(context.systemPrompt),
|
||||
|
|
@ -141,7 +146,12 @@ export function convertResponsesMessages<TApi extends Api>(
|
|||
|
||||
let msgIndex = 0;
|
||||
for (const msg of transformedMessages) {
|
||||
if (msg.role === "user") {
|
||||
if (msg.role === "developer") {
|
||||
messages.push({
|
||||
role: "developer",
|
||||
content: sanitizeSurrogates(msg.content),
|
||||
});
|
||||
} else if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
messages.push({
|
||||
role: "user",
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEn
|
|||
|
||||
function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCompat> {
|
||||
return {
|
||||
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
|
||||
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? false,
|
||||
sendSessionIdHeader: model.compat?.sendSessionIdHeader ?? true,
|
||||
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
|
||||
supportsToolSearch: model.compat?.supportsToolSearch ?? false,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,10 @@ export function transformMessages<TApi extends Api>(
|
|||
const toolCallIdMap = new Map<string, string>();
|
||||
// Normalize null/undefined content from untyped callers (custom tools, hand-built
|
||||
// histories, old session files) so downstream code can rely on the type contract.
|
||||
const normalizedMessages = messages.map((msg) => (msg.content == null ? { ...msg, content: [] } : msg));
|
||||
const normalizedMessages: Message[] = messages.map((msg) => {
|
||||
if (msg.content != null) return msg;
|
||||
return msg.role === "developer" ? { ...msg, content: "" } : { ...msg, content: [] };
|
||||
});
|
||||
const imageAwareMessages = downgradeUnsupportedImages(normalizedMessages, model);
|
||||
|
||||
// First pass: transform messages (unsupported image downgrade, thinking blocks, tool call ID normalization)
|
||||
|
|
|
|||
|
|
@ -181,6 +181,9 @@ function messageToText(message: Message): string {
|
|||
if (message.role === "user") {
|
||||
return contentToText(message.content);
|
||||
}
|
||||
if (message.role === "developer") {
|
||||
return message.content;
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
return assistantContentToText(message.content);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -380,6 +380,13 @@ export interface UserMessage {
|
|||
timestamp: number; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
/** Application-provided instructions that take effect at this point in the conversation. */
|
||||
export interface DeveloperMessage {
|
||||
role: "developer";
|
||||
content: string;
|
||||
timestamp: number; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
export interface AssistantMessage {
|
||||
role: "assistant";
|
||||
content: (TextContent | ThinkingContent | ToolCall)[];
|
||||
|
|
@ -411,7 +418,7 @@ export interface ToolResultMessage<TDetails = any> {
|
|||
timestamp: number; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
|
||||
export type Message = UserMessage | DeveloperMessage | AssistantMessage | ToolResultMessage;
|
||||
|
||||
export type ImagesInputContent = TextContent | ImageContent;
|
||||
export type ImagesOutputContent = TextContent | ImageContent;
|
||||
|
|
@ -525,7 +532,7 @@ export interface OpenAICompletionsCompat {
|
|||
|
||||
/** Compatibility settings for OpenAI Responses APIs. */
|
||||
export interface OpenAIResponsesCompat {
|
||||
/** Whether the provider supports the `developer` role (vs `system`). Default: true. */
|
||||
/** Whether the provider supports mid-conversation `developer` messages. Default: false. */
|
||||
supportsDeveloperRole?: boolean;
|
||||
/** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */
|
||||
sendSessionIdHeader?: boolean;
|
||||
|
|
@ -535,8 +542,16 @@ export interface OpenAIResponsesCompat {
|
|||
supportsToolSearch?: boolean;
|
||||
}
|
||||
|
||||
/** Compatibility settings for Mistral Conversations APIs. */
|
||||
export interface MistralConversationsCompat {
|
||||
/** Whether the model supports repeated mid-conversation `system` messages for developer instructions. Default: false. */
|
||||
supportsDeveloperRole?: boolean;
|
||||
}
|
||||
|
||||
/** Compatibility settings for Anthropic Messages-compatible APIs. */
|
||||
export interface AnthropicMessagesCompat {
|
||||
/** Whether the model supports mid-conversation developer instructions, serialized as the Anthropic `system` role. Default: false. */
|
||||
supportsDeveloperRole?: boolean;
|
||||
/**
|
||||
* Whether the provider accepts per-tool `eager_input_streaming`.
|
||||
* When false, the Anthropic provider omits `tools[].eager_input_streaming`
|
||||
|
|
@ -711,14 +726,16 @@ export interface Model<TApi extends Api> {
|
|||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
headers?: Record<string, string>;
|
||||
/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */
|
||||
/** Provider/model compatibility overrides. OpenAI Chat defaults may be auto-detected from provider and baseUrl. */
|
||||
compat?: TApi extends "openai-completions"
|
||||
? OpenAICompletionsCompat
|
||||
: TApi extends "openai-responses" | "openai-codex-responses"
|
||||
: TApi extends "openai-responses" | "openai-codex-responses" | "azure-openai-responses"
|
||||
? OpenAIResponsesCompat
|
||||
: TApi extends "anthropic-messages"
|
||||
? AnthropicMessagesCompat
|
||||
: never;
|
||||
: TApi extends "mistral-conversations"
|
||||
? MistralConversationsCompat
|
||||
: never;
|
||||
}
|
||||
|
||||
export interface ImagesModel<TApi extends ImagesApi>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export function estimateMessageTokens(message: Message): number {
|
|||
let chars = 0;
|
||||
|
||||
if (message.role === "user") return estimateTextAndImageContentTokens(message.content);
|
||||
if (message.role === "developer") return estimateTextTokens(message.content);
|
||||
if (message.role === "toolResult") return estimateTextAndImageContentTokens(message.content);
|
||||
|
||||
for (const block of message.content) {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,17 @@ async function capturePayload(context: Context): Promise<unknown> {
|
|||
}
|
||||
|
||||
describe("bedrock convertMessages skips unknown content types", () => {
|
||||
it("degrades developer messages to marked user messages", async () => {
|
||||
const payload = await capturePayload({
|
||||
messages: [{ role: "developer", content: "Use concise answers.", timestamp: Date.now() }],
|
||||
});
|
||||
const p = payload as { messages: Array<{ role: string; content: Array<{ text: string }> }> };
|
||||
expect(p.messages).toHaveLength(1);
|
||||
expect(p.messages[0].role).toBe("user");
|
||||
expect(p.messages[0].content[0].text).toContain("<developer_message>");
|
||||
expect(p.messages[0].content[0].text).toContain("Use concise answers.");
|
||||
});
|
||||
|
||||
it("skips unknown user content blocks instead of throwing", async () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
|
|
|
|||
208
packages/ai/test/developer-messages.test.ts
Normal file
208
packages/ai/test/developer-messages.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamAnthropicMessages } from "../src/api/anthropic-messages.ts";
|
||||
import {
|
||||
downgradeDeveloperMessages,
|
||||
SYNTHETIC_DEVELOPER_MESSAGE_PREFIX,
|
||||
SYNTHETIC_DEVELOPER_MESSAGE_SUFFIX,
|
||||
} from "../src/api/developer-messages.ts";
|
||||
import { convertMessages as convertGoogleMessages } from "../src/api/google-shared.ts";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import { convertResponsesMessages } from "../src/api/openai-responses-shared.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context, DeveloperMessage, Model } from "../src/types.ts";
|
||||
|
||||
const developerMessage: DeveloperMessage = {
|
||||
role: "developer",
|
||||
content: "Use concise answers.",
|
||||
timestamp: 1,
|
||||
};
|
||||
|
||||
const openAICompletionsModel: Model<"openai-completions"> = {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
compat: { supportsDeveloperRole: true },
|
||||
};
|
||||
|
||||
function contextWithDeveloperMessage(): Context {
|
||||
return { messages: [developerMessage] };
|
||||
}
|
||||
|
||||
async function captureOpenAICompletionsMessages(
|
||||
model: Model<"openai-completions">,
|
||||
): Promise<Array<{ role: string; content: unknown }>> {
|
||||
let messages: Array<{ role: string; content: unknown }> | undefined;
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response('data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
);
|
||||
|
||||
const stream = streamOpenAICompletions(model, contextWithDeveloperMessage(), {
|
||||
apiKey: "test-key",
|
||||
cacheRetention: "none",
|
||||
onPayload: (payload) => {
|
||||
messages = (payload as { messages: Array<{ role: string; content: unknown }> }).messages;
|
||||
},
|
||||
});
|
||||
await stream.result();
|
||||
if (!messages) throw new Error("Expected OpenAI payload to be captured");
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function captureAnthropicMessages(
|
||||
model: Model<"anthropic-messages">,
|
||||
context: Context = { messages: [{ role: "user", content: "Hello", timestamp: 0 }, developerMessage] },
|
||||
): Promise<Array<{ role: string; content: unknown }>> {
|
||||
let messages: Array<{ role: string; content: unknown }> | undefined;
|
||||
const stream = streamAnthropicMessages({ ...model, baseUrl: "http://127.0.0.1:9" }, context, {
|
||||
apiKey: "test-key",
|
||||
cacheRetention: "none",
|
||||
onPayload: (payload) => {
|
||||
messages = (payload as { messages: Array<{ role: string; content: unknown }> }).messages;
|
||||
},
|
||||
});
|
||||
await stream.result();
|
||||
if (!messages) throw new Error("Expected Anthropic payload to be captured before request failure");
|
||||
return messages;
|
||||
}
|
||||
|
||||
describe("developer messages", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("maps to developer in OpenAI Responses", () => {
|
||||
const input = convertResponsesMessages(
|
||||
getModel("openai", "gpt-5.4"),
|
||||
contextWithDeveloperMessage(),
|
||||
new Set(["openai"]),
|
||||
);
|
||||
|
||||
expect(input).toEqual([{ role: "developer", content: "Use concise answers." }]);
|
||||
});
|
||||
|
||||
it("falls back to a marked user message for OpenAI Responses models without developer-role support", () => {
|
||||
const model: Model<"openai-responses"> = {
|
||||
...getModel("openai", "gpt-5.4"),
|
||||
compat: { supportsDeveloperRole: false },
|
||||
};
|
||||
const input = convertResponsesMessages(model, contextWithDeveloperMessage(), new Set(["openai"]));
|
||||
|
||||
expect(input).toHaveLength(1);
|
||||
expect(input[0]).toMatchObject({ role: "user" });
|
||||
expect(input[0]).toMatchObject({
|
||||
content: [{ type: "input_text", text: expect.stringContaining("<developer_message>") }],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps to developer in OpenAI Chat Completions", async () => {
|
||||
const messages = await captureOpenAICompletionsMessages(openAICompletionsModel);
|
||||
expect(messages).toEqual([{ role: "developer", content: "Use concise answers." }]);
|
||||
});
|
||||
|
||||
it("falls back to a marked user message in OpenAI Chat Completions", async () => {
|
||||
const messages = await captureOpenAICompletionsMessages({
|
||||
...openAICompletionsModel,
|
||||
compat: { supportsDeveloperRole: false },
|
||||
});
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].role).toBe("user");
|
||||
expect(messages[0].content).toContain("<developer_message>");
|
||||
});
|
||||
|
||||
it("defaults unverified OpenAI-compatible providers to marked user messages", async () => {
|
||||
const messages = await captureOpenAICompletionsMessages(getModel("groq", "llama-3.1-8b-instant"));
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].role).toBe("user");
|
||||
expect(messages[0].content).toContain("<developer_message>");
|
||||
});
|
||||
|
||||
it("uses developer messages only for verified OpenRouter models", async () => {
|
||||
const verified = await captureOpenAICompletionsMessages(getModel("openrouter", "openai/gpt-oss-20b"));
|
||||
expect(verified).toEqual([{ role: "developer", content: "Use concise answers." }]);
|
||||
|
||||
const unverified = await captureOpenAICompletionsMessages(getModel("openrouter", "anthropic/claude-opus-4.8"));
|
||||
expect(unverified).toHaveLength(1);
|
||||
expect(unverified[0].role).toBe("user");
|
||||
expect(unverified[0].content).toContain("<developer_message>");
|
||||
});
|
||||
|
||||
it("defaults unverified Responses models to marked user messages", () => {
|
||||
const model = getModel("openai-codex", "gpt-5.6-luna");
|
||||
const input = convertResponsesMessages(model, contextWithDeveloperMessage(), new Set(["openai-codex"]));
|
||||
expect(input).toHaveLength(1);
|
||||
expect(input[0]).toMatchObject({ role: "user" });
|
||||
});
|
||||
|
||||
it("uses native system messages for supported Anthropic models", async () => {
|
||||
const messages = await captureAnthropicMessages(getModel("anthropic", "claude-opus-4-8"));
|
||||
expect(messages).toEqual([
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "system", content: "Use concise answers." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("degrades developer messages for unsupported Anthropic models", async () => {
|
||||
const messages = await captureAnthropicMessages(getModel("anthropic", "claude-opus-4-7"));
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[1].role).toBe("user");
|
||||
expect(messages[1].content).toContain("<developer_message>");
|
||||
});
|
||||
|
||||
it("degrades developer messages with invalid Anthropic placement", async () => {
|
||||
const messages = await captureAnthropicMessages(
|
||||
getModel("anthropic", "claude-opus-4-8"),
|
||||
contextWithDeveloperMessage(),
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].role).toBe("user");
|
||||
expect(messages[0].content).toContain("<developer_message>");
|
||||
});
|
||||
|
||||
it("degrades to a marked user message", () => {
|
||||
expect(downgradeDeveloperMessages([developerMessage])).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: `${SYNTHETIC_DEVELOPER_MESSAGE_PREFIX}Use concise answers.${SYNTHETIC_DEVELOPER_MESSAGE_SUFFIX}`,
|
||||
timestamp: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("degrades to a marked user message for Gemini", () => {
|
||||
const messages = convertGoogleMessages(getModel("google", "gemini-2.5-flash"), contextWithDeveloperMessage());
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
text: `${SYNTHETIC_DEVELOPER_MESSAGE_PREFIX}Use concise answers.${SYNTHETIC_DEVELOPER_MESSAGE_SUFFIX}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["claude-opus-4-8", "claude-fable-5", "claude-sonnet-5"] as const)(
|
||||
"marks Anthropic %s as supporting native developer messages",
|
||||
(modelId) => {
|
||||
expect(getModel("anthropic", modelId).compat?.supportsDeveloperRole).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["claude-opus-4-7", "claude-sonnet-4-6"] as const)(
|
||||
"does not mark Anthropic %s as supporting native developer messages",
|
||||
(modelId) => {
|
||||
expect(getModel("anthropic", modelId).compat?.supportsDeveloperRole).not.toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ interface MistralPayload {
|
|||
promptMode?: "reasoning";
|
||||
reasoningEffort?: "none" | "high";
|
||||
promptCacheKey?: string;
|
||||
messages?: Array<{ role: string; content: unknown }>;
|
||||
}
|
||||
|
||||
function makeContext(): Context {
|
||||
|
|
@ -17,6 +18,7 @@ function makeContext(): Context {
|
|||
async function capturePayload(
|
||||
model: Model<"mistral-conversations">,
|
||||
options?: SimpleStreamOptions,
|
||||
context: Context = makeContext(),
|
||||
): Promise<MistralPayload> {
|
||||
let capturedPayload: MistralPayload | undefined;
|
||||
const payloadCaptureModel: Model<"mistral-conversations"> = {
|
||||
|
|
@ -24,7 +26,7 @@ async function capturePayload(
|
|||
baseUrl: "http://127.0.0.1:9",
|
||||
};
|
||||
|
||||
const stream = streamSimple(payloadCaptureModel, makeContext(), {
|
||||
const stream = streamSimple(payloadCaptureModel, context, {
|
||||
...options,
|
||||
apiKey: "fake-key",
|
||||
onPayload: (payload) => {
|
||||
|
|
@ -43,6 +45,24 @@ async function capturePayload(
|
|||
}
|
||||
|
||||
describe("Mistral reasoning mode selection", () => {
|
||||
it("maps developer messages to system messages for verified models", async () => {
|
||||
const payload = await capturePayload(getModel("mistral", "mistral-large-latest"), undefined, {
|
||||
messages: [{ role: "developer", content: "Use concise answers.", timestamp: Date.now() }],
|
||||
});
|
||||
|
||||
expect(payload.messages).toEqual([{ role: "system", content: "Use concise answers." }]);
|
||||
});
|
||||
|
||||
it("degrades developer messages for unverified models", async () => {
|
||||
const payload = await capturePayload(getModel("mistral", "mistral-large-2411"), undefined, {
|
||||
messages: [{ role: "developer", content: "Use concise answers.", timestamp: Date.now() }],
|
||||
});
|
||||
|
||||
expect(payload.messages).toHaveLength(1);
|
||||
expect(payload.messages?.[0].role).toBe("user");
|
||||
expect(payload.messages?.[0].content).toContain("<developer_message>");
|
||||
});
|
||||
|
||||
it("uses reasoning_effort for Mistral Small 4", async () => {
|
||||
const payload = await capturePayload(getModel("mistral", "mistral-small-2603"), { reasoning: "medium" });
|
||||
|
||||
|
|
|
|||
|
|
@ -1018,10 +1018,10 @@ describe("openai-completions tool_choice", () => {
|
|||
expect(params.messages?.[0]?.role).toBe("system");
|
||||
});
|
||||
|
||||
it("keeps developer messages for OpenAI and Anthropic OpenRouter reasoning model instructions", async () => {
|
||||
it("keeps developer messages for verified OpenAI and Anthropic OpenRouter models", async () => {
|
||||
for (const model of [
|
||||
getModel("openrouter", "openai/gpt-5.2-codex"),
|
||||
getModel("openrouter", "anthropic/claude-sonnet-4.5"),
|
||||
getModel("openrouter", "openai/gpt-oss-20b"),
|
||||
getModel("openrouter", "anthropic/claude-sonnet-5"),
|
||||
]) {
|
||||
expect(model).toBeDefined();
|
||||
let payload: unknown;
|
||||
|
|
@ -1084,7 +1084,7 @@ describe("openai-completions tool_choice", () => {
|
|||
expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBe(true);
|
||||
expect(model.compat?.thinkingFormat).toBe("deepseek");
|
||||
expect(model.compat?.maxTokensField).toBeUndefined();
|
||||
expect(model.compat?.supportsDeveloperRole).toBeUndefined();
|
||||
expect(model.compat?.supportsDeveloperRole).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -587,6 +587,7 @@ export class AgentSession {
|
|||
);
|
||||
} else if (
|
||||
event.message.role === "user" ||
|
||||
event.message.role === "developer" ||
|
||||
event.message.role === "assistant" ||
|
||||
event.message.role === "toolResult"
|
||||
) {
|
||||
|
|
@ -715,14 +716,18 @@ export class AgentSession {
|
|||
if (replacement) {
|
||||
// Untyped extension handlers can return messages with null/missing content;
|
||||
// normalize so it never enters agent state or session history.
|
||||
const normalized =
|
||||
let normalized = replacement;
|
||||
if (replacement.role === "developer" && replacement.content == null) {
|
||||
normalized = { ...replacement, content: "" };
|
||||
} else if (
|
||||
(replacement.role === "user" ||
|
||||
replacement.role === "assistant" ||
|
||||
replacement.role === "toolResult" ||
|
||||
replacement.role === "custom") &&
|
||||
replacement.content == null
|
||||
? ({ ...replacement, content: [] } as AgentMessage)
|
||||
: replacement;
|
||||
) {
|
||||
normalized = { ...replacement, content: [] } as AgentMessage;
|
||||
}
|
||||
this._replaceMessageInPlace(event.message, normalized);
|
||||
}
|
||||
} else if (event.type === "tool_execution_start") {
|
||||
|
|
|
|||
|
|
@ -241,6 +241,8 @@ export function estimateTokens(message: AgentMessage): number {
|
|||
let chars = 0;
|
||||
|
||||
switch (message.role) {
|
||||
case "developer":
|
||||
return Math.ceil(message.content.length / 4);
|
||||
case "user": {
|
||||
chars = estimateTextAndImageContentChars(
|
||||
(message as { content: string | Array<{ type: string; text?: string }> }).content,
|
||||
|
|
@ -282,6 +284,7 @@ export function estimateTokens(message: AgentMessage): number {
|
|||
function isCutPointMessage(message: AgentMessage): boolean {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
case "developer":
|
||||
case "assistant":
|
||||
case "bashExecution":
|
||||
case "custom":
|
||||
|
|
@ -297,6 +300,7 @@ function isCutPointMessage(message: AgentMessage): boolean {
|
|||
function isTurnStartMessage(message: AgentMessage): boolean {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
case "developer":
|
||||
case "bashExecution":
|
||||
case "custom":
|
||||
case "branchSummary":
|
||||
|
|
|
|||
|
|
@ -110,7 +110,9 @@ export function serializeConversation(messages: Message[]): string {
|
|||
const parts: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
if (msg.role === "developer") {
|
||||
if (msg.content) parts.push(`[Developer]: ${msg.content}`);
|
||||
} else if (msg.role === "user") {
|
||||
const content =
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
|
|
|
|||
|
|
@ -204,6 +204,11 @@
|
|||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tree-role-developer {
|
||||
color: var(--customMessageLabel);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tree-role-skill {
|
||||
color: var(--customMessageLabel);
|
||||
}
|
||||
|
|
@ -365,6 +370,21 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.developer-message {
|
||||
background: var(--customMessageBg);
|
||||
color: var(--customMessageText);
|
||||
padding: var(--line-height);
|
||||
border-left: 3px solid var(--customMessageLabel);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.developer-message-label {
|
||||
color: var(--customMessageLabel);
|
||||
font-weight: bold;
|
||||
margin-bottom: var(--line-height);
|
||||
}
|
||||
|
||||
/* Copy link button - appears on hover */
|
||||
.copy-link-btn {
|
||||
position: absolute;
|
||||
|
|
@ -388,6 +408,7 @@
|
|||
|
||||
.user-message:hover .copy-link-btn,
|
||||
.assistant-message:hover .copy-link-btn,
|
||||
.developer-message:hover .copy-link-btn,
|
||||
.skill-user-entry:hover .copy-link-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
@ -406,7 +427,8 @@
|
|||
|
||||
/* Highlight effect for deep-linked messages */
|
||||
.user-message.highlight,
|
||||
.assistant-message.highlight {
|
||||
.assistant-message.highlight,
|
||||
.developer-message.highlight {
|
||||
animation: highlight-pulse 2s ease-out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -656,6 +656,10 @@
|
|||
const content = truncate(normalize(rawContent));
|
||||
return labelHtml + `<span class="tree-role-user">user:</span> ${escapeHtml(content)}`;
|
||||
}
|
||||
if (msg.role === 'developer') {
|
||||
const content = truncate(normalize(msg.content || ''));
|
||||
return labelHtml + `<span class="tree-role-developer">developer:</span> ${escapeHtml(content)}`;
|
||||
}
|
||||
if (msg.role === 'assistant') {
|
||||
const textContent = truncate(normalize(extractContent(msg.content)));
|
||||
if (textContent) {
|
||||
|
|
@ -1240,6 +1244,10 @@
|
|||
return html;
|
||||
}
|
||||
|
||||
if (msg.role === 'developer') {
|
||||
return `<div class="developer-message" id="${entryDomId}">${copyBtnHtml}${tsHtml}<div class="developer-message-label">Developer instruction</div><div class="markdown-content">${safeMarkedParse(msg.content || '')}</div></div>`;
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
let html = `<div class="assistant-message" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|||
timestamp: m.timestamp,
|
||||
};
|
||||
case "user":
|
||||
case "developer":
|
||||
case "assistant":
|
||||
case "toolResult":
|
||||
return m;
|
||||
|
|
|
|||
|
|
@ -381,6 +381,9 @@ export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage
|
|||
const message = entry.message;
|
||||
// Session files are parsed without validation; old versions, forks, or
|
||||
// hand-edited files can contain messages with null/missing content.
|
||||
if (message.role === "developer" && message.content == null) {
|
||||
return [{ ...message, content: "" }];
|
||||
}
|
||||
if (
|
||||
(message.role === "user" || message.role === "assistant" || message.role === "toolResult") &&
|
||||
message.content == null
|
||||
|
|
|
|||
|
|
@ -2886,9 +2886,9 @@ export class InteractiveMode {
|
|||
if (event.message.role === "custom") {
|
||||
this.addMessageToChat(event.message);
|
||||
this.ui.requestRender();
|
||||
} else if (event.message.role === "user") {
|
||||
} else if (event.message.role === "user" || event.message.role === "developer") {
|
||||
this.addMessageToChat(event.message);
|
||||
this.updatePendingMessagesDisplay();
|
||||
if (event.message.role === "user") this.updatePendingMessagesDisplay();
|
||||
this.ui.requestRender();
|
||||
} else if (event.message.role === "assistant") {
|
||||
this.streamingComponent = new AssistantMessageComponent(
|
||||
|
|
@ -2941,7 +2941,7 @@ export class InteractiveMode {
|
|||
break;
|
||||
|
||||
case "message_end":
|
||||
if (event.message.role === "user") break;
|
||||
if (event.message.role === "user" || event.message.role === "developer") break;
|
||||
if (this.streamingComponent && event.message.role === "assistant") {
|
||||
this.streamingMessage = event.message;
|
||||
let errorMessage: string | undefined;
|
||||
|
|
@ -3261,6 +3261,21 @@ export class InteractiveMode {
|
|||
}
|
||||
break;
|
||||
}
|
||||
case "developer": {
|
||||
const developerComponent = new CustomMessageComponent(
|
||||
{
|
||||
role: "custom",
|
||||
customType: "developer",
|
||||
content: message.content,
|
||||
display: true,
|
||||
timestamp: message.timestamp,
|
||||
},
|
||||
undefined,
|
||||
this.getMarkdownThemeWithSettings(),
|
||||
);
|
||||
this.chatContainer.addChild(developerComponent);
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
const assistantComponent = new AssistantMessageComponent(
|
||||
message,
|
||||
|
|
|
|||
|
|
@ -137,6 +137,16 @@ describe("lax message content handling", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("normalizes null or missing developer message content to text", () => {
|
||||
for (const badMessage of [
|
||||
{ role: "developer", content: null, timestamp: Date.now() },
|
||||
{ role: "developer", timestamp: Date.now() },
|
||||
]) {
|
||||
const [message] = sessionEntryToContextMessages(messageEntry(badMessage));
|
||||
expect(message).toMatchObject({ role: "developer", content: "" });
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes null content when loading custom message entries", () => {
|
||||
const entry = {
|
||||
type: "custom_message",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue