From 8c61663f30e1ee18d1459772dc715c397d495b33 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 11 Jul 2026 13:07:54 +0200 Subject: [PATCH] feat(ai): add developer message role --- packages/agent/src/agent.ts | 6 +- .../src/harness/compaction/compaction.ts | 12 +- .../agent/src/harness/compaction/utils.ts | 4 +- packages/agent/src/harness/messages.ts | 1 + packages/agent/src/types.ts | 4 +- .../agent/test/harness/compaction.test.ts | 26 +++ packages/ai/scripts/generate-models.ts | 100 ++++++++- packages/ai/src/api/anthropic-messages.ts | 21 +- .../ai/src/api/bedrock-converse-stream.ts | 7 +- packages/ai/src/api/developer-messages.ts | 21 ++ packages/ai/src/api/google-shared.ts | 7 +- packages/ai/src/api/mistral-conversations.ts | 12 +- packages/ai/src/api/openai-completions.ts | 28 ++- .../ai/src/api/openai-responses-shared.ts | 20 +- packages/ai/src/api/openai-responses.ts | 2 +- packages/ai/src/api/transform-messages.ts | 5 +- packages/ai/src/providers/faux.ts | 3 + packages/ai/src/types.ts | 27 ++- packages/ai/src/utils/estimate.ts | 1 + .../ai/test/bedrock-convert-messages.test.ts | 11 + packages/ai/test/developer-messages.test.ts | 208 ++++++++++++++++++ .../ai/test/mistral-reasoning-mode.test.ts | 22 +- .../openai-completions-tool-choice.test.ts | 8 +- .../coding-agent/src/core/agent-session.ts | 11 +- .../src/core/compaction/compaction.ts | 4 + .../coding-agent/src/core/compaction/utils.ts | 4 +- .../src/core/export-html/template.css | 24 +- .../src/core/export-html/template.js | 8 + packages/coding-agent/src/core/messages.ts | 1 + .../coding-agent/src/core/session-manager.ts | 3 + .../src/modes/interactive/interactive-mode.ts | 21 +- .../test/suite/lax-message-content.test.ts | 10 + 32 files changed, 596 insertions(+), 46 deletions(-) create mode 100644 packages/ai/src/api/developer-messages.ts create mode 100644 packages/ai/test/developer-messages.test.ts diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index ab10d5ac1..984806da1 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -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", ); } diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index 4132e3ddc..3042b8e06 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -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, }; } diff --git a/packages/agent/src/harness/compaction/utils.ts b/packages/agent/src/harness/compaction/utils.ts index 07535b79b..e6b34468e 100644 --- a/packages/agent/src/harness/compaction/utils.ts +++ b/packages/agent/src/harness/compaction/utils.ts @@ -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 diff --git a/packages/agent/src/harness/messages.ts b/packages/agent/src/harness/messages.ts index 36ce96a1e..de0082555 100644 --- a/packages/agent/src/harness/messages.ts +++ b/packages/agent/src/harness/messages.ts @@ -153,6 +153,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { timestamp: m.timestamp, }; case "user": + case "developer": case "assistant": case "toolResult": return m; diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 2de50144b..31e7189f6 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -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 } diff --git a/packages/agent/test/harness/compaction.test.ts b/packages/agent/test/harness/compaction.test.ts index 8a228eeb8..01329f0db 100644 --- a/packages/agent/test/harness/compaction.test.ts +++ b/packages/agent/test/harness/compaction.test.ts @@ -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); diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index e6e5e199a..a4473b0bf 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -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): void { }; } +function applyDeveloperRoleMetadata(model: Model): 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): 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 diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index e5dc8bc65..5b934e205 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -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> { 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[] = []; diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 7a9e776e6..709407976 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -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]; diff --git a/packages/ai/src/api/developer-messages.ts b/packages/ai/src/api/developer-messages.ts new file mode 100644 index 000000000..832c2d61f --- /dev/null +++ b/packages/ai/src/api/developer-messages.ts @@ -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 = ` +The following instruction was provided by the application developer and applies from this point forward: + +`; +export const SYNTHETIC_DEVELOPER_MESSAGE_SUFFIX = ` +`; + +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)); +} diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index 1559b20b7..9e7cc1703 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -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(model: Model, 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") { diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index f1e4fc4c4..6ef7a1c8a 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -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) }); diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 32947db4b..7797049a2 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -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, diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index 246ae22aa..851f78704 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -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( 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( 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", diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 00d7a43bb..dfa4a750e 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -60,7 +60,7 @@ function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEn function getCompat(model: Model<"openai-responses">): Required { 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, diff --git a/packages/ai/src/api/transform-messages.ts b/packages/ai/src/api/transform-messages.ts index 45e90a093..f61e9a459 100644 --- a/packages/ai/src/api/transform-messages.ts +++ b/packages/ai/src/api/transform-messages.ts @@ -70,7 +70,10 @@ export function transformMessages( const toolCallIdMap = new Map(); // 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) diff --git a/packages/ai/src/providers/faux.ts b/packages/ai/src/providers/faux.ts index 4a26f1ad6..82651d2b5 100644 --- a/packages/ai/src/providers/faux.ts +++ b/packages/ai/src/providers/faux.ts @@ -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); } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 211b522cb..b48849ccf 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -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 { 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 { contextWindow: number; maxTokens: number; headers?: Record; - /** 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 diff --git a/packages/ai/src/utils/estimate.ts b/packages/ai/src/utils/estimate.ts index b434969ec..a8b6dfd27 100644 --- a/packages/ai/src/utils/estimate.ts +++ b/packages/ai/src/utils/estimate.ts @@ -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) { diff --git a/packages/ai/test/bedrock-convert-messages.test.ts b/packages/ai/test/bedrock-convert-messages.test.ts index c43f7978d..232dec853 100644 --- a/packages/ai/test/bedrock-convert-messages.test.ts +++ b/packages/ai/test/bedrock-convert-messages.test.ts @@ -67,6 +67,17 @@ async function capturePayload(context: Context): Promise { } 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(""); + expect(p.messages[0].content[0].text).toContain("Use concise answers."); + }); + it("skips unknown user content blocks instead of throwing", async () => { const messages: Message[] = [ { diff --git a/packages/ai/test/developer-messages.test.ts b/packages/ai/test/developer-messages.test.ts new file mode 100644 index 000000000..e7318ac73 --- /dev/null +++ b/packages/ai/test/developer-messages.test.ts @@ -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> { + 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> { + 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("") }], + }); + }); + + 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(""); + }); + + 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(""); + }); + + 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(""); + }); + + 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(""); + }); + + 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(""); + }); + + 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); + }, + ); +}); diff --git a/packages/ai/test/mistral-reasoning-mode.test.ts b/packages/ai/test/mistral-reasoning-mode.test.ts index bffa292da..e24f2d02a 100644 --- a/packages/ai/test/mistral-reasoning-mode.test.ts +++ b/packages/ai/test/mistral-reasoning-mode.test.ts @@ -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 { 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(""); + }); + it("uses reasoning_effort for Mistral Small 4", async () => { const payload = await capturePayload(getModel("mistral", "mistral-small-2603"), { reasoning: "medium" }); diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 1dd49c79f..0e453d210 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -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); } }); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c4c2d366b..a4279ec0a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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") { diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 39b1042d3..4756b647f 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -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": diff --git a/packages/coding-agent/src/core/compaction/utils.ts b/packages/coding-agent/src/core/compaction/utils.ts index 6cfc16227..d1ca256d1 100644 --- a/packages/coding-agent/src/core/compaction/utils.ts +++ b/packages/coding-agent/src/core/compaction/utils.ts @@ -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 diff --git a/packages/coding-agent/src/core/export-html/template.css b/packages/coding-agent/src/core/export-html/template.css index c807161ee..ac176e93a 100644 --- a/packages/coding-agent/src/core/export-html/template.css +++ b/packages/coding-agent/src/core/export-html/template.css @@ -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; } diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index cfdd16120..c30b791e0 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -656,6 +656,10 @@ const content = truncate(normalize(rawContent)); return labelHtml + `user: ${escapeHtml(content)}`; } + if (msg.role === 'developer') { + const content = truncate(normalize(msg.content || '')); + return labelHtml + `developer: ${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 `
${copyBtnHtml}${tsHtml}
Developer instruction
${safeMarkedParse(msg.content || '')}
`; + } + if (msg.role === 'assistant') { let html = `
${copyBtnHtml}${tsHtml}`; diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 81ffabb58..3c452e737 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -182,6 +182,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { timestamp: m.timestamp, }; case "user": + case "developer": case "assistant": case "toolResult": return m; diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index eadca30b1..0357c1773 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -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 diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b745c454a..5a807ff8c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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, diff --git a/packages/coding-agent/test/suite/lax-message-content.test.ts b/packages/coding-agent/test/suite/lax-message-content.test.ts index 4bfeaf838..94e1f7279 100644 --- a/packages/coding-agent/test/suite/lax-message-content.test.ts +++ b/packages/coding-agent/test/suite/lax-message-content.test.ts @@ -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",