返回 CodeWhale
role_placement.rs
根目录 / crates / tui / src / client / role_placement.rs
1 //! The one table that answers "where does a message with this role go on this
2 //! wire, and when is the pair not representable at all?"
3 //!
4 //! Before this module existed the question was answered once per adapter, and
5 //! the answers disagreed — not by design, by drift:
6 //!
7 //! * Chat Completions matched `user`/`assistant`/`system` and let anything
8 //! else fall off the end of an `if`/`else if` chain, silently.
9 //! * OpenAI Responses matched `user`/`assistant`/`tool` and swallowed
10 //! `system` in a catch-all `_ => {}`.
11 //! * Anthropic Messages forwarded `message.role` **verbatim**, so a `system`
12 //! message earned an opaque provider-side 400 that named neither the role
13 //! nor the message.
14 //!
15 //! Now each adapter asks [`role_placement`] which channel to render into, and
16 //! [`reject_unsupported_roles`] runs at the outbound seam
17 //! (`CodewhaleClient::prepare_outbound_request`) so an unrepresentable pair is
18 //! refused locally, before any transport serialization, instead of being
19 //! discovered by the provider.
20 //!
21 //! Two rules govern edits here:
22 //!
23 //! * A role is **omitted** only where dropping it was already the behaviour
24 //! and the content is not load-bearing. Turning a hard error into a silent
25 //! drop is a fail-open regression, not a cleanup.
26 //! * Placement grants no authority. It says which wire channel carries the
27 //! bytes, never how much the model should trust them.
28
29 use super::prepared::WireDialect;
30 use codewhale_models::{Message, Role};
31
32 /// Which channel of a wire body a message renders into.
33 ///
34 /// Adapters own the structural rendering for their own dialect — Chat's
35 /// `tool_calls` array, Responses' `function_call_output` items, and Anthropic's
36 /// content blocks. This enum only names the channel, so
37 /// that the *choice* of channel is made in exactly one place.
38 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
39 pub(crate) enum RolePlacement {
40 /// The wire's user/input channel.
41 User,
42 /// The wire's assistant/model output channel.
43 Assistant,
44 /// The assistant channel, with the interrupted-output prefix prepended to
45 /// the replayed text so the model can see the turn was cut short.
46 InterruptedAssistant,
47 /// A system-role entry inside the transcript body (not the top-level
48 /// system prompt, which every dialect carries separately).
49 System,
50 /// A developer-role entry inside the transcript body.
51 Developer,
52 /// Not representable on this wire, and dropped rather than sent. Every
53 /// `Omitted` cell below is behaviour that already shipped.
54 Omitted,
55 /// Not representable and not safe to drop. The outbound seam refuses the
56 /// request; adapters treat it as unreachable and fail closed if reached.
57 Rejected,
58 }
59
60 impl RolePlacement {
61 /// True when the message renders into the assistant channel, interrupted
62 /// replay included.
63 pub(crate) fn is_assistant_channel(self) -> bool {
64 matches!(self, Self::Assistant | Self::InterruptedAssistant)
65 }
66 }
67
68 /// The placement table.
69 ///
70 /// Read the cells as the current, audited behaviour of each adapter. The two
71 /// cells that deliberately changed are marked; see the commit that introduced
72 /// this module.
73 pub(crate) fn role_placement(role: &Role, dialect: WireDialect) -> RolePlacement {
74 match (role, dialect) {
75 // Every dialect carries user input.
76 (Role::User, _) => RolePlacement::User,
77
78 // Every dialect carries assistant output.
79 (Role::Assistant, _) => RolePlacement::Assistant,
80
81 // Interrupted assistant text replays as assistant output with a marker.
82 (Role::InterruptedAssistant, _) => RolePlacement::InterruptedAssistant,
83
84 // Chat Completions and Responses both accept load-bearing system and
85 // developer entries inside transcript history. Anthropic accepts only
86 // user/assistant message roles, so it preserves the positioned content
87 // by projecting those entries onto the user channel. Hoisting them to
88 // the top-level system field or dropping them would reorder or delete
89 // compaction and branch summaries.
90 (Role::System, WireDialect::ChatCompletions | WireDialect::OpenAiResponses) => {
91 RolePlacement::System
92 }
93 (Role::System, WireDialect::AnthropicMessages) => RolePlacement::User,
94
95 (Role::Developer, WireDialect::ChatCompletions | WireDialect::OpenAiResponses) => {
96 RolePlacement::Developer
97 }
98 (Role::Developer, WireDialect::AnthropicMessages) => RolePlacement::User,
99
100 // A role this build does not know, e.g. from a transcript written by
101 // a newer build. The OpenAI-shaped dialects already dropped these;
102 // Anthropic sent them verbatim for the provider to reject.
103 (Role::Unrecognized(_), WireDialect::ChatCompletions | WireDialect::OpenAiResponses) => {
104 RolePlacement::Omitted
105 }
106 // CHANGED: was a verbatim pass-through ending in a provider 400.
107 (Role::Unrecognized(_), WireDialect::AnthropicMessages) => RolePlacement::Rejected,
108 }
109 }
110
111 /// A role/dialect pair this build refuses to put on the wire.
112 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
113 #[error(
114 "message {index} has role {role:?}, which the {dialect} wire cannot represent; \
115 the request was refused before it was sent"
116 )]
117 pub(crate) struct UnsupportedRoleForDialect {
118 /// Index of the offending message in the outbound transcript.
119 pub index: usize,
120 /// The role as it appears in the transcript.
121 pub role: String,
122 /// Stable machine label for the wire dialect.
123 pub dialect: &'static str,
124 }
125
126 /// Refuse an outbound transcript that a dialect cannot represent.
127 ///
128 /// This is the validation seam: it runs inside
129 /// `CodewhaleClient::prepare_outbound_request`, before any dialect builds a
130 /// body, so no rejected pair ever reaches transport serialization.
131 pub(crate) fn reject_unsupported_roles(
132 messages: &[Message],
133 dialect: WireDialect,
134 ) -> Result<(), UnsupportedRoleForDialect> {
135 for (index, message) in messages.iter().enumerate() {
136 if role_placement(&message.role, dialect) == RolePlacement::Rejected {
137 return Err(UnsupportedRoleForDialect {
138 index,
139 role: message.role.to_string(),
140 dialect: dialect.as_str(),
141 });
142 }
143 }
144 Ok(())
145 }
146
147 #[cfg(test)]
148 mod tests {
149 use super::{RolePlacement, WireDialect, reject_unsupported_roles, role_placement};
150 use codewhale_models::{ContentBlock, Message, Role};
151
152 const DIALECTS: [WireDialect; 3] = [
153 WireDialect::ChatCompletions,
154 WireDialect::AnthropicMessages,
155 WireDialect::OpenAiResponses,
156 ];
157
158 fn message(role: Role) -> Message {
159 Message {
160 role,
161 content: vec![ContentBlock::Text {
162 text: "body".to_string(),
163 cache_control: None,
164 }],
165 }
166 }
167
168 #[test]
169 fn user_and_assistant_are_carried_by_every_dialect() {
170 for dialect in DIALECTS {
171 assert_eq!(role_placement(&Role::User, dialect), RolePlacement::User);
172 assert_eq!(
173 role_placement(&Role::Assistant, dialect),
174 RolePlacement::Assistant
175 );
176 }
177 }
178
179 #[test]
180 fn interrupted_assistant_replays_on_every_supported_dialect() {
181 for dialect in [
182 WireDialect::ChatCompletions,
183 WireDialect::AnthropicMessages,
184 WireDialect::OpenAiResponses,
185 ] {
186 let placement = role_placement(&Role::InterruptedAssistant, dialect);
187 assert_eq!(placement, RolePlacement::InterruptedAssistant);
188 assert!(placement.is_assistant_channel());
189 }
190 }
191
192 #[test]
193 fn positioned_instruction_roles_are_preserved_where_each_dialect_can_carry_them() {
194 assert_eq!(
195 role_placement(&Role::System, WireDialect::ChatCompletions),
196 RolePlacement::System
197 );
198 assert_eq!(
199 role_placement(&Role::System, WireDialect::OpenAiResponses),
200 RolePlacement::System
201 );
202 assert_eq!(
203 role_placement(&Role::System, WireDialect::AnthropicMessages),
204 RolePlacement::User
205 );
206 assert_eq!(
207 role_placement(&Role::Developer, WireDialect::ChatCompletions),
208 RolePlacement::Developer
209 );
210 assert_eq!(
211 role_placement(&Role::Developer, WireDialect::OpenAiResponses),
212 RolePlacement::Developer
213 );
214 assert_eq!(
215 role_placement(&Role::Developer, WireDialect::AnthropicMessages),
216 RolePlacement::User
217 );
218 }
219
220 #[test]
221 fn unknown_roles_never_reach_a_wire() {
222 let role = Role::Unrecognized("future_role".to_string());
223 for dialect in DIALECTS {
224 assert_ne!(
225 role_placement(&role, dialect),
226 RolePlacement::User,
227 "{} must not promote an unknown role",
228 dialect.as_str()
229 );
230 assert!(matches!(
231 role_placement(&role, dialect),
232 RolePlacement::Omitted | RolePlacement::Rejected
233 ));
234 }
235 }
236
237 #[test]
238 fn seam_accepts_positioned_system_history_on_anthropic() {
239 let messages = vec![
240 message(Role::User),
241 message(Role::Assistant),
242 message(Role::System),
243 ];
244 reject_unsupported_roles(&messages, WireDialect::AnthropicMessages)
245 .expect("Anthropic projects positioned system history onto the user channel");
246 }
247
248 #[test]
249 fn seam_accepts_what_each_dialect_can_carry() {
250 let plain = vec![message(Role::User), message(Role::Assistant)];
251 for dialect in DIALECTS {
252 assert!(reject_unsupported_roles(&plain, dialect).is_ok());
253 }
254 // Omitted is not rejected: genuinely unknown roles keep the legacy
255 // OpenAI-shaped behavior rather than failing a live session.
256 assert!(
257 reject_unsupported_roles(
258 &[
259 message(Role::System),
260 message(Role::Developer),
261 message(Role::Unrecognized("x".into()))
262 ],
263 WireDialect::OpenAiResponses,
264 )
265 .is_ok()
266 );
267 assert!(
268 reject_unsupported_roles(
269 &[message(Role::Unrecognized("x".into()))],
270 WireDialect::ChatCompletions,
271 )
272 .is_ok()
273 );
274 }
275 }
276
277 /// Each adapter, run over the same transcript, must land where the table says.
278 ///
279 /// These tests exist because the four adapters used to disagree with each
280 /// other and nothing noticed. They assert the *observable wire shape*, not the
281 /// table — a future edit that keeps the table honest but forgets to rewire an
282 /// adapter fails here.
283 #[cfg(test)]
284 mod adapter_agreement_tests {
285 use serde_json::{Value, json};
286
287 use super::super::{anthropic, chat, responses};
288 use crate::config::ApiProvider;
289 use codewhale_models::{
290 ContentBlock, INTERRUPTED_ASSISTANT_CONTEXT_PREFIX, Message, MessageRequest, Role,
291 };
292
293 fn message(role: Role, text: &str) -> Message {
294 Message {
295 role,
296 content: vec![ContentBlock::Text {
297 text: text.to_string(),
298 cache_control: None,
299 }],
300 }
301 }
302
303 fn request(messages: Vec<Message>) -> MessageRequest {
304 MessageRequest {
305 model: "test-model".to_string(),
306 messages,
307 max_tokens: 256,
308 system: None,
309 tools: None,
310 tool_choice: None,
311 metadata: None,
312 thinking: None,
313 reasoning_effort: None,
314 stream: Some(false),
315 temperature: None,
316 top_p: None,
317 }
318 }
319
320 fn roles(items: &[Value]) -> Vec<String> {
321 items
322 .iter()
323 .filter_map(|item| item.get("role").and_then(Value::as_str))
324 .map(str::to_string)
325 .collect()
326 }
327
328 fn transcript() -> Vec<Message> {
329 vec![
330 message(Role::User, "ask"),
331 message(Role::Assistant, "answer"),
332 message(Role::InterruptedAssistant, "half an answer"),
333 message(Role::System, "compaction summary"),
334 message(Role::Developer, "developer instruction"),
335 message(Role::Unrecognized("future_role".to_string()), "from later"),
336 ]
337 }
338
339 #[test]
340 fn chat_completions_maps_every_role_the_table_says_it_can_carry() {
341 let items = chat::build_chat_messages_for_request_and_provider(
342 &request(transcript()),
343 ApiProvider::Deepseek,
344 );
345 assert_eq!(
346 roles(&items),
347 vec!["user", "assistant", "assistant", "system", "developer"],
348 "one wire entry per carried message, in transcript order: the \
349 interrupted turn joins the assistant channel; positioned system \
350 and developer history survive; the unknown role is dropped",
351 );
352 let rendered = serde_json::to_string(&items).expect("serialize");
353 assert!(
354 !rendered.contains("from later"),
355 "an unknown role must not reach the wire: {rendered}"
356 );
357 assert!(rendered.contains("compaction summary"));
358 assert!(rendered.contains("developer instruction"));
359 }
360
361 #[test]
362 fn chat_completions_marks_interrupted_assistant_history() {
363 let items = chat::build_chat_messages_for_request_and_provider(
364 &request(vec![message(Role::InterruptedAssistant, "half an answer")]),
365 ApiProvider::Deepseek,
366 );
367 assert_eq!(items.len(), 1);
368 assert_eq!(items[0]["role"], json!("assistant"));
369 assert_eq!(
370 items[0]["content"].as_str().expect("text content"),
371 format!("{INTERRUPTED_ASSISTANT_CONTEXT_PREFIX}half an answer"),
372 );
373 }
374
375 #[test]
376 fn responses_preserves_positioned_instruction_history_and_marks_interrupted_history() {
377 let items = responses::convert_messages_to_responses_input(
378 &request(transcript()),
379 ApiProvider::Openai,
380 );
381 assert_eq!(
382 roles(&items),
383 vec!["user", "assistant", "assistant", "system", "developer"],
384 "Responses preserves positioned system/developer history and drops unknown roles",
385 );
386 let rendered = serde_json::to_string(&items).expect("serialize");
387 assert!(rendered.contains("compaction summary"), "{rendered}");
388 assert!(rendered.contains("developer instruction"), "{rendered}");
389 assert!(!rendered.contains("from later"), "{rendered}");
390 assert_eq!(
391 items[2]["content"][0]["text"]
392 .as_str()
393 .expect("output text"),
394 format!("{INTERRUPTED_ASSISTANT_CONTEXT_PREFIX}half an answer"),
395 );
396 }
397
398 /// Anthropic accepts only user/assistant message roles. Positioned system
399 /// and developer history is projected onto user without moving or dropping
400 /// its content; genuinely unknown roles are still refused at the seam.
401 #[test]
402 fn anthropic_never_emits_a_role_outside_user_and_assistant() {
403 for role in [Role::System, Role::Developer] {
404 let value = anthropic::message_to_anthropic(&message(role.clone(), "body"))
405 .expect("positioned instruction history is carried");
406 assert_eq!(value["role"], json!("user"), "{role}");
407 }
408 for role in [
409 Role::Unrecognized("future_role".to_string()),
410 Role::Unrecognized("tool".to_string()),
411 ] {
412 assert!(
413 anthropic::message_to_anthropic(&message(role.clone(), "body")).is_none(),
414 "anthropic must not put {role} on the wire",
415 );
416 }
417 for (role, expected) in [(Role::User, "user"), (Role::Assistant, "assistant")] {
418 let value =
419 anthropic::message_to_anthropic(&message(role, "body")).expect("carried role");
420 assert_eq!(value["role"], json!(expected));
421 }
422 }
423
424 #[test]
425 fn anthropic_marks_interrupted_assistant_history() {
426 let value =
427 anthropic::message_to_anthropic(&message(Role::InterruptedAssistant, "half an answer"))
428 .expect("interrupted output replays as assistant");
429 assert_eq!(value["role"], json!("assistant"));
430 assert_eq!(
431 value["content"][0]["text"].as_str().expect("text block"),
432 format!("{INTERRUPTED_ASSISTANT_CONTEXT_PREFIX}half an answer"),
433 );
434 }
435 }
436
436 lines RUST