use bamboo_domain::{Message, PromptBlock};
use crate::cache::PromptCachePlan;
#[derive(Debug, Clone, Default)]
pub struct PromptIR {
pub system_text: String,
pub system_blocks: Vec<PromptBlock>,
pub segments: Vec<Segment>,
pub cache: PromptCachePlan,
pub continuation: Option<Continuation>,
}
#[derive(Debug, Clone)]
pub struct Segment {
pub role: SegmentRole,
pub messages: Vec<Message>,
}
impl Segment {
pub fn new(role: SegmentRole, messages: Vec<Message>) -> Self {
Self { role, messages }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SegmentRole {
StablePrefix,
DynamicContext,
SystemRemainder,
Conversation,
VolatileTail,
}
#[derive(Debug, Clone)]
pub struct Continuation {
pub previous_response_id: String,
pub last_committed_assistant_id: Option<String>,
}
impl PromptIR {
pub fn run(&self, role: SegmentRole) -> &[Message] {
self.segments
.iter()
.find(|segment| segment.role == role)
.map(|segment| segment.messages.as_slice())
.unwrap_or(&[])
}
pub fn system_field(&self) -> String {
if !self.system_text.is_empty() {
self.system_text.clone()
} else {
self.system_blocks
.iter()
.map(|block| block.text.as_str())
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
}
pub fn body_chat(&self) -> Vec<Message> {
let mut out = Vec::new();
out.extend_from_slice(self.run(SegmentRole::StablePrefix));
out.extend_from_slice(self.run(SegmentRole::DynamicContext));
out.extend_from_slice(self.run(SegmentRole::SystemRemainder));
out.extend_from_slice(self.run(SegmentRole::Conversation));
out.extend_from_slice(self.run(SegmentRole::VolatileTail));
out
}
pub fn flatten(&self) -> Vec<Message> {
let mut out = Vec::new();
let system = self.system_field();
if !system.trim().is_empty() {
out.push(Message::system(system.trim().to_string()));
}
out.extend(self.body_chat());
out
}
pub fn responses_input(&self) -> Vec<Message> {
self.body_chat()
}
pub fn continuation_delta(&self) -> Vec<Message> {
let mut out = Vec::new();
out.extend_from_slice(self.run(SegmentRole::SystemRemainder));
out.extend_from_slice(self.run(SegmentRole::DynamicContext));
out.extend_from_slice(self.conversation_tail());
out.extend_from_slice(self.run(SegmentRole::VolatileTail));
out
}
pub fn responses_request_options(
&self,
base: Option<&crate::provider::ResponsesRequestOptions>,
) -> crate::provider::ResponsesRequestOptions {
let mut options = base.cloned().unwrap_or_default();
options.input_messages = Some(self.responses_input());
let system = self.system_field();
let trimmed = system.trim();
options.instructions = (!trimmed.is_empty()).then(|| trimmed.to_string());
if let Some(continuation) = self.continuation.as_ref() {
options.previous_response_id = Some(continuation.previous_response_id.clone());
}
options
}
fn conversation_tail(&self) -> &[Message] {
let conversation = self.run(SegmentRole::Conversation);
match self
.continuation
.as_ref()
.and_then(|continuation| continuation.last_committed_assistant_id.as_deref())
{
Some(id) => match conversation.iter().rposition(|message| message.id == id) {
Some(index) if index + 1 < conversation.len() => &conversation[index + 1..],
_ => conversation,
},
None => conversation,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_domain::Role;
fn shape(messages: &[Message]) -> Vec<(Role, String)> {
messages
.iter()
.map(|message| (message.role.clone(), message.content.clone()))
.collect()
}
fn fixture_ir() -> PromptIR {
PromptIR {
system_text: "SYSTEM".to_string(),
segments: vec![
Segment::new(SegmentRole::StablePrefix, vec![Message::user("PREFIX")]),
Segment::new(SegmentRole::DynamicContext, vec![Message::user("DYNAMIC")]),
Segment::new(
SegmentRole::SystemRemainder,
vec![Message::system("REMAINDER")],
),
Segment::new(
SegmentRole::Conversation,
vec![
Message::user("u1"),
Message::assistant("a1", None),
Message::user("u2"),
],
),
Segment::new(SegmentRole::VolatileTail, vec![Message::user("VOLATILE")]),
],
..PromptIR::default()
}
}
#[test]
fn flatten_orders_runs_with_remainder_and_volatile() {
let ir = fixture_ir();
let expected: Vec<(Role, String)> = vec![
(Role::System, "SYSTEM".to_string()),
(Role::User, "PREFIX".to_string()),
(Role::User, "DYNAMIC".to_string()),
(Role::System, "REMAINDER".to_string()),
(Role::User, "u1".to_string()),
(Role::Assistant, "a1".to_string()),
(Role::User, "u2".to_string()),
(Role::User, "VOLATILE".to_string()),
];
assert_eq!(shape(&ir.flatten()), expected);
assert_eq!(ir.system_field(), "SYSTEM");
}
#[test]
fn chat_and_delta_place_system_remainder_in_opposite_positions() {
let chat = shape(&fixture_ir().flatten());
let dynamic = chat.iter().position(|(_, c)| c == "DYNAMIC").unwrap();
let remainder = chat.iter().position(|(_, c)| c == "REMAINDER").unwrap();
let conversation = chat.iter().position(|(_, c)| c == "u1").unwrap();
assert!(
dynamic < remainder && remainder < conversation,
"chat: remainder sits between dynamic context and the conversation"
);
let continued = PromptIR {
continuation: Some(Continuation {
previous_response_id: "resp_prev".to_string(),
last_committed_assistant_id: None,
}),
..fixture_ir()
};
let delta = shape(&continued.continuation_delta());
assert_eq!(delta[0].1, "REMAINDER", "delta: remainder is FIRST");
}
#[test]
fn continuation_delta_slices_conversation_after_committed_assistant() {
let conversation = vec![
Message::user("u1"),
Message::assistant("a1", None),
Message::user("u2"),
];
let assistant_id = conversation[1].id.clone();
let ir = PromptIR {
segments: vec![Segment::new(SegmentRole::Conversation, conversation)],
continuation: Some(Continuation {
previous_response_id: "resp".to_string(),
last_committed_assistant_id: Some(assistant_id),
}),
..PromptIR::default()
};
assert_eq!(
shape(&ir.continuation_delta()),
vec![(Role::User, "u2".to_string())],
"delta is the conversation strictly after the committed assistant turn"
);
}
#[test]
fn continuation_delta_falls_open_when_boundary_is_last_message() {
let conversation = vec![Message::user("u1"), Message::assistant("a1", None)];
let assistant_id = conversation[1].id.clone();
let ir = PromptIR {
segments: vec![Segment::new(SegmentRole::Conversation, conversation)],
continuation: Some(Continuation {
previous_response_id: "resp".to_string(),
last_committed_assistant_id: Some(assistant_id),
}),
..PromptIR::default()
};
assert_eq!(ir.continuation_delta().len(), 2, "nothing new → whole conv");
}
#[test]
fn continuation_delta_falls_open_when_boundary_id_missing() {
let conversation = vec![Message::user("u1"), Message::assistant("a1", None)];
let ir = PromptIR {
segments: vec![Segment::new(SegmentRole::Conversation, conversation)],
continuation: Some(Continuation {
previous_response_id: "resp".to_string(),
last_committed_assistant_id: Some("nonexistent-id".to_string()),
}),
..PromptIR::default()
};
assert_eq!(ir.continuation_delta().len(), 2);
}
#[test]
fn responses_input_equals_body_chat() {
let ir = fixture_ir();
assert_eq!(shape(&ir.responses_input()), shape(&ir.body_chat()));
}
#[test]
fn responses_request_options_derives_input_instructions_and_continuation() {
use crate::provider::ResponsesRequestOptions;
let base = ResponsesRequestOptions {
store: Some(false),
text_verbosity: Some("high".to_string()),
..Default::default()
};
let ir = PromptIR {
continuation: Some(Continuation {
previous_response_id: "resp_prev".to_string(),
last_committed_assistant_id: None,
}),
..fixture_ir()
};
let options = ir.responses_request_options(Some(&base));
assert_eq!(options.store, Some(false));
assert_eq!(options.text_verbosity.as_deref(), Some("high"));
assert_eq!(options.instructions.as_deref(), Some("SYSTEM"));
assert_eq!(
shape(options.input_messages.as_deref().unwrap()),
shape(&ir.responses_input()),
"input_messages is the full responses_input view (system rides instructions)"
);
assert_eq!(options.previous_response_id.as_deref(), Some("resp_prev"));
}
#[test]
fn responses_request_options_omits_instructions_when_system_blank() {
use crate::provider::ResponsesRequestOptions;
let ir = PromptIR {
system_text: " ".to_string(),
..PromptIR::default()
};
let options = ir.responses_request_options(None);
assert!(
options.instructions.is_none(),
"blank system field → no instructions (matches legacy None)"
);
assert!(options.previous_response_id.is_none());
}
#[test]
fn system_field_prefers_authoritative_text_else_joins_blocks() {
use bamboo_domain::{ContextBlockType, PromptBlock};
let blocks = vec![
PromptBlock::new("base", ContextBlockType::Base, "base"),
PromptBlock::new("skill", ContextBlockType::SkillContext, " "),
PromptBlock::new("env", ContextBlockType::EnvSnapshot, "env"),
];
let authoritative = PromptIR {
system_text: "AUTHORITATIVE".to_string(),
system_blocks: blocks.clone(),
..PromptIR::default()
};
assert_eq!(authoritative.system_field(), "AUTHORITATIVE");
let fallback = PromptIR {
system_text: String::new(),
system_blocks: blocks,
..PromptIR::default()
};
assert_eq!(fallback.system_field(), "base\n\nenv");
}
#[test]
fn dynamic_context_and_remainder_swap_between_chat_and_delta() {
let ir = PromptIR {
continuation: Some(Continuation {
previous_response_id: "r".to_string(),
last_committed_assistant_id: None,
}),
..fixture_ir()
};
let chat = shape(&ir.body_chat());
let dynamic_chat = chat.iter().position(|(_, c)| c == "DYNAMIC").unwrap();
let remainder_chat = chat.iter().position(|(_, c)| c == "REMAINDER").unwrap();
assert!(
dynamic_chat < remainder_chat,
"chat: dynamic context precedes the system remainder"
);
let delta = shape(&ir.continuation_delta());
let dynamic_delta = delta.iter().position(|(_, c)| c == "DYNAMIC").unwrap();
let remainder_delta = delta.iter().position(|(_, c)| c == "REMAINDER").unwrap();
assert!(
remainder_delta < dynamic_delta,
"delta: the system remainder precedes dynamic context (swapped vs chat)"
);
}
}