Skip to content

Commit 4ee477d

Browse files
PederHPclaude
andcommitted
feat(meai): map mid-conversation system messages for Opus 4.8
The MEAI IChatClient mapping previously hoisted every ChatRole.System message into the top-level `system` property regardless of position, so the Opus 4.8 mid-conversation system message feature was unreachable. A ChatRole.System message is now emitted as a `{"role":"system"}` message at its position when the placement is valid (immediately follows a user turn and either ends the array or precedes an assistant turn) and the resolved model supports it (claude-opus-4-8). Leading system messages, invalid positions, and unsupported models fall back to the top-level `system` property, preserving prior behavior. Consecutive system messages are merged into one, per the API constraint. Applied to both the stable and beta IChatClient mappings, with shared tests covering valid placement, mid-list placement, hoist on unsupported model, hoist after an assistant turn, consecutive-merge, leading-only, and cache control. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d25bd32 commit 4ee477d

3 files changed

Lines changed: 746 additions & 6 deletions

File tree

src/Anthropic.Tests/AnthropicClientExtensionsTestsBase.cs

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2060,6 +2060,392 @@ public async Task GetResponseAsync_WithMultipleSystemMessages()
20602060
Assert.NotNull(response);
20612061
}
20622062

2063+
[Fact]
2064+
public async Task GetResponseAsync_MidConversationSystemMessage_TrailingAfterUser_OnOpus48()
2065+
{
2066+
VerbatimHttpHandler handler = new(
2067+
expectedRequest: """
2068+
{
2069+
"model": "claude-opus-4-8",
2070+
"messages": [
2071+
{
2072+
"role": "user",
2073+
"content": [{ "type": "text", "text": "Review the process function." }]
2074+
},
2075+
{
2076+
"role": "assistant",
2077+
"content": [{ "type": "text", "text": "Looks fine for small inputs." }]
2078+
},
2079+
{
2080+
"role": "user",
2081+
"content": [{ "type": "text", "text": "Now review the caller." }]
2082+
},
2083+
{
2084+
"role": "system",
2085+
"content": [{ "type": "text", "text": "From now on, require type annotations." }]
2086+
}
2087+
],
2088+
"max_tokens": 1024,
2089+
"system": [{ "type": "text", "text": "You are a code reviewer." }]
2090+
}
2091+
""",
2092+
actualResponse: """
2093+
{
2094+
"id": "msg_mid_conv_01",
2095+
"type": "message",
2096+
"role": "assistant",
2097+
"model": "claude-opus-4-8",
2098+
"content": [{ "type": "text", "text": "Reviewing..." }],
2099+
"stop_reason": "end_turn",
2100+
"usage": { "input_tokens": 30, "output_tokens": 10 }
2101+
}
2102+
"""
2103+
);
2104+
2105+
IChatClient chatClient = CreateChatClient(handler, "claude-opus-4-8");
2106+
2107+
List<ChatMessage> messages =
2108+
[
2109+
new(ChatRole.System, "You are a code reviewer."),
2110+
new(ChatRole.User, "Review the process function."),
2111+
new(ChatRole.Assistant, "Looks fine for small inputs."),
2112+
new(ChatRole.User, "Now review the caller."),
2113+
new(ChatRole.System, "From now on, require type annotations."),
2114+
];
2115+
2116+
ChatResponse response = await chatClient.GetResponseAsync(
2117+
messages,
2118+
new(),
2119+
TestContext.Current.CancellationToken
2120+
);
2121+
Assert.NotNull(response);
2122+
}
2123+
2124+
[Fact]
2125+
public async Task GetResponseAsync_MidConversationSystemMessage_BetweenUserAndAssistant_OnOpus48()
2126+
{
2127+
VerbatimHttpHandler handler = new(
2128+
expectedRequest: """
2129+
{
2130+
"model": "claude-opus-4-8",
2131+
"messages": [
2132+
{
2133+
"role": "user",
2134+
"content": [{ "type": "text", "text": "Review this." }]
2135+
},
2136+
{
2137+
"role": "system",
2138+
"content": [{ "type": "text", "text": "Be concise." }]
2139+
},
2140+
{
2141+
"role": "assistant",
2142+
"content": [{ "type": "text", "text": "Looks good." }]
2143+
}
2144+
],
2145+
"max_tokens": 1024
2146+
}
2147+
""",
2148+
actualResponse: """
2149+
{
2150+
"id": "msg_mid_conv_02",
2151+
"type": "message",
2152+
"role": "assistant",
2153+
"model": "claude-opus-4-8",
2154+
"content": [{ "type": "text", "text": "Done." }],
2155+
"stop_reason": "end_turn",
2156+
"usage": { "input_tokens": 15, "output_tokens": 5 }
2157+
}
2158+
"""
2159+
);
2160+
2161+
IChatClient chatClient = CreateChatClient(handler, "claude-opus-4-8");
2162+
2163+
List<ChatMessage> messages =
2164+
[
2165+
new(ChatRole.User, "Review this."),
2166+
new(ChatRole.System, "Be concise."),
2167+
new(ChatRole.Assistant, "Looks good."),
2168+
];
2169+
2170+
ChatResponse response = await chatClient.GetResponseAsync(
2171+
messages,
2172+
new(),
2173+
TestContext.Current.CancellationToken
2174+
);
2175+
Assert.NotNull(response);
2176+
}
2177+
2178+
[Fact]
2179+
public async Task GetResponseAsync_MidConversationSystemMessage_OnUnsupportedModel_HoistsToTopLevel()
2180+
{
2181+
VerbatimHttpHandler handler = new(
2182+
expectedRequest: """
2183+
{
2184+
"model": "claude-haiku-4-5",
2185+
"messages": [
2186+
{
2187+
"role": "user",
2188+
"content": [{ "type": "text", "text": "Review the process function." }]
2189+
},
2190+
{
2191+
"role": "assistant",
2192+
"content": [{ "type": "text", "text": "Looks fine for small inputs." }]
2193+
},
2194+
{
2195+
"role": "user",
2196+
"content": [{ "type": "text", "text": "Now review the caller." }]
2197+
}
2198+
],
2199+
"max_tokens": 1024,
2200+
"system": [
2201+
{ "type": "text", "text": "You are a code reviewer." },
2202+
{ "type": "text", "text": "From now on, require type annotations." }
2203+
]
2204+
}
2205+
""",
2206+
actualResponse: """
2207+
{
2208+
"id": "msg_mid_conv_03",
2209+
"type": "message",
2210+
"role": "assistant",
2211+
"model": "claude-haiku-4-5",
2212+
"content": [{ "type": "text", "text": "Reviewing..." }],
2213+
"stop_reason": "end_turn",
2214+
"usage": { "input_tokens": 30, "output_tokens": 10 }
2215+
}
2216+
"""
2217+
);
2218+
2219+
IChatClient chatClient = CreateChatClient(handler, "claude-haiku-4-5");
2220+
2221+
List<ChatMessage> messages =
2222+
[
2223+
new(ChatRole.System, "You are a code reviewer."),
2224+
new(ChatRole.User, "Review the process function."),
2225+
new(ChatRole.Assistant, "Looks fine for small inputs."),
2226+
new(ChatRole.User, "Now review the caller."),
2227+
new(ChatRole.System, "From now on, require type annotations."),
2228+
];
2229+
2230+
ChatResponse response = await chatClient.GetResponseAsync(
2231+
messages,
2232+
new(),
2233+
TestContext.Current.CancellationToken
2234+
);
2235+
Assert.NotNull(response);
2236+
}
2237+
2238+
[Fact]
2239+
public async Task GetResponseAsync_SystemMessageAfterAssistant_OnOpus48_IsHoisted()
2240+
{
2241+
// A system message that follows an assistant turn is an invalid mid-conversation
2242+
// position, so it is hoisted to the top-level `system` property even on Opus 4.8.
2243+
VerbatimHttpHandler handler = new(
2244+
expectedRequest: """
2245+
{
2246+
"model": "claude-opus-4-8",
2247+
"messages": [
2248+
{
2249+
"role": "user",
2250+
"content": [{ "type": "text", "text": "Hello" }]
2251+
},
2252+
{
2253+
"role": "assistant",
2254+
"content": [{ "type": "text", "text": "Hi!" }]
2255+
},
2256+
{
2257+
"role": "user",
2258+
"content": [{ "type": "text", "text": "Tell me about AI" }]
2259+
}
2260+
],
2261+
"max_tokens": 1024,
2262+
"system": [{ "type": "text", "text": "Be concise." }]
2263+
}
2264+
""",
2265+
actualResponse: """
2266+
{
2267+
"id": "msg_mid_conv_04",
2268+
"type": "message",
2269+
"role": "assistant",
2270+
"model": "claude-opus-4-8",
2271+
"content": [{ "type": "text", "text": "AI is..." }],
2272+
"stop_reason": "end_turn",
2273+
"usage": { "input_tokens": 20, "output_tokens": 10 }
2274+
}
2275+
"""
2276+
);
2277+
2278+
IChatClient chatClient = CreateChatClient(handler, "claude-opus-4-8");
2279+
2280+
List<ChatMessage> messages =
2281+
[
2282+
new(ChatRole.User, "Hello"),
2283+
new(ChatRole.Assistant, "Hi!"),
2284+
new(ChatRole.System, "Be concise."),
2285+
new(ChatRole.User, "Tell me about AI"),
2286+
];
2287+
2288+
ChatResponse response = await chatClient.GetResponseAsync(
2289+
messages,
2290+
new(),
2291+
TestContext.Current.CancellationToken
2292+
);
2293+
Assert.NotNull(response);
2294+
}
2295+
2296+
[Fact]
2297+
public async Task GetResponseAsync_ConsecutiveSystemMessages_AreMerged_OnOpus48()
2298+
{
2299+
VerbatimHttpHandler handler = new(
2300+
expectedRequest: """
2301+
{
2302+
"model": "claude-opus-4-8",
2303+
"messages": [
2304+
{
2305+
"role": "user",
2306+
"content": [{ "type": "text", "text": "Hello" }]
2307+
},
2308+
{
2309+
"role": "system",
2310+
"content": [
2311+
{ "type": "text", "text": "Rule A." },
2312+
{ "type": "text", "text": "Rule B." }
2313+
]
2314+
}
2315+
],
2316+
"max_tokens": 1024
2317+
}
2318+
""",
2319+
actualResponse: """
2320+
{
2321+
"id": "msg_mid_conv_05",
2322+
"type": "message",
2323+
"role": "assistant",
2324+
"model": "claude-opus-4-8",
2325+
"content": [{ "type": "text", "text": "Understood." }],
2326+
"stop_reason": "end_turn",
2327+
"usage": { "input_tokens": 15, "output_tokens": 5 }
2328+
}
2329+
"""
2330+
);
2331+
2332+
IChatClient chatClient = CreateChatClient(handler, "claude-opus-4-8");
2333+
2334+
List<ChatMessage> messages =
2335+
[
2336+
new(ChatRole.User, "Hello"),
2337+
new(ChatRole.System, "Rule A."),
2338+
new(ChatRole.System, "Rule B."),
2339+
];
2340+
2341+
ChatResponse response = await chatClient.GetResponseAsync(
2342+
messages,
2343+
new(),
2344+
TestContext.Current.CancellationToken
2345+
);
2346+
Assert.NotNull(response);
2347+
}
2348+
2349+
[Fact]
2350+
public async Task GetResponseAsync_LeadingSystemMessage_OnOpus48_StaysTopLevel()
2351+
{
2352+
VerbatimHttpHandler handler = new(
2353+
expectedRequest: """
2354+
{
2355+
"model": "claude-opus-4-8",
2356+
"messages": [{
2357+
"role": "user",
2358+
"content": [{ "type": "text", "text": "Tell me about AI" }]
2359+
}],
2360+
"max_tokens": 1024,
2361+
"system": [{ "type": "text", "text": "You are helpful." }]
2362+
}
2363+
""",
2364+
actualResponse: """
2365+
{
2366+
"id": "msg_mid_conv_06",
2367+
"type": "message",
2368+
"role": "assistant",
2369+
"model": "claude-opus-4-8",
2370+
"content": [{ "type": "text", "text": "AI is..." }],
2371+
"stop_reason": "end_turn",
2372+
"usage": { "input_tokens": 20, "output_tokens": 10 }
2373+
}
2374+
"""
2375+
);
2376+
2377+
IChatClient chatClient = CreateChatClient(handler, "claude-opus-4-8");
2378+
2379+
List<ChatMessage> messages =
2380+
[
2381+
new(ChatRole.System, "You are helpful."),
2382+
new(ChatRole.User, "Tell me about AI"),
2383+
];
2384+
2385+
ChatResponse response = await chatClient.GetResponseAsync(
2386+
messages,
2387+
new(),
2388+
TestContext.Current.CancellationToken
2389+
);
2390+
Assert.NotNull(response);
2391+
}
2392+
2393+
[Fact]
2394+
public async Task GetResponseAsync_WithCacheControlOnMidConversationSystemMessage()
2395+
{
2396+
VerbatimHttpHandler handler = new(
2397+
expectedRequest: """
2398+
{
2399+
"model": "claude-opus-4-8",
2400+
"messages": [
2401+
{
2402+
"role": "user",
2403+
"content": [{ "type": "text", "text": "Hello" }]
2404+
},
2405+
{
2406+
"role": "system",
2407+
"content": [{
2408+
"type": "text",
2409+
"text": "Remember the rules.",
2410+
"cache_control": { "type": "ephemeral", "ttl": "1h" }
2411+
}]
2412+
}
2413+
],
2414+
"max_tokens": 1024
2415+
}
2416+
""",
2417+
actualResponse: """
2418+
{
2419+
"id": "msg_mid_conv_07",
2420+
"type": "message",
2421+
"role": "assistant",
2422+
"model": "claude-opus-4-8",
2423+
"content": [{ "type": "text", "text": "Understood." }],
2424+
"stop_reason": "end_turn",
2425+
"usage": { "input_tokens": 15, "output_tokens": 5 }
2426+
}
2427+
"""
2428+
);
2429+
2430+
IChatClient chatClient = CreateChatClient(handler, "claude-opus-4-8");
2431+
2432+
var systemContent = new TextContent("Remember the rules.").WithCacheControl(
2433+
Anthropic.Models.Messages.Ttl.Ttl1h
2434+
);
2435+
2436+
List<ChatMessage> messages =
2437+
[
2438+
new(ChatRole.User, "Hello"),
2439+
new(ChatRole.System, [systemContent]),
2440+
];
2441+
2442+
ChatResponse response = await chatClient.GetResponseAsync(
2443+
messages,
2444+
cancellationToken: TestContext.Current.CancellationToken
2445+
);
2446+
Assert.NotNull(response);
2447+
}
2448+
20632449
[Fact]
20642450
public async Task GetResponseAsync_WithMixedContentTypes()
20652451
{

0 commit comments

Comments
 (0)