use std::io::{self, Write};
use crate::error::Result;
use crate::model::{ContentBlock, UserContent};
use super::{
InputId, InputKind, MAX_INPUT_BYTES, MAX_PENDING_BYTES, MAX_PENDING_INPUTS, PendingInput,
SessionControlHandle, control_error, lock,
};
const MAX_CONTENT_BYTES: usize = 4 * 1024 * 1024;
const MAX_CONTENT_BLOCKS: usize = 128;
struct ByteBudget(usize);
impl Write for ByteBudget {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0 = self
.0
.checked_sub(bytes.len())
.ok_or_else(|| io::Error::other("queued content exceeds its byte limit"))?;
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn admitted_bytes(content: &UserContent, source: &str) -> Result<usize> {
if source.len() > MAX_INPUT_BYTES {
return Err(control_error(
"SESSION_CONTROL_INPUT",
"authored source exceeds 256 KiB",
));
}
let valid = match content {
UserContent::Text(text) => !text.trim().is_empty(),
UserContent::Blocks(blocks) => {
!blocks.is_empty()
&& blocks.len() <= MAX_CONTENT_BLOCKS
&& blocks.iter().all(|block| {
matches!(
block,
ContentBlock::Text(_) | ContentBlock::Image(_) | ContentBlock::Media(_)
)
})
&& blocks.iter().any(|block| match block {
ContentBlock::Text(text) => !text.text.trim().is_empty(),
ContentBlock::Image(image) => !image.data.is_empty(),
ContentBlock::Media(media) => !media.data.is_empty(),
_ => false,
})
}
};
if !valid {
return Err(control_error(
"SESSION_CONTROL_INPUT",
"queued user content must be nonempty text or at most 128 text/image/media blocks",
));
}
let mut budget = ByteBudget(MAX_CONTENT_BYTES);
serde_json::to_writer(&mut budget, content).map_err(|_| {
control_error(
"SESSION_CONTROL_INPUT",
"queued content exceeds 4 MiB when serialized",
)
})?;
Ok(MAX_CONTENT_BYTES - budget.0 + source.len())
}
impl SessionControlHandle {
pub fn steer_with_content(
&self,
content: &UserContent,
authored_source: &str,
) -> Result<InputId> {
self.enqueue_content(InputKind::Steering, content, authored_source)
}
pub fn follow_up_with_content(
&self,
content: &UserContent,
authored_source: &str,
) -> Result<InputId> {
self.enqueue_content(InputKind::FollowUp, content, authored_source)
}
pub(super) fn enqueue_content(
&self,
kind: InputKind,
content: &UserContent,
source: &str,
) -> Result<InputId> {
let bytes = admitted_bytes(content, source)?;
let mut data = lock(&self.run.data);
if !data.accepting_input {
return Err(control_error(
"SESSION_CONTROL_CLOSED",
"this turn no longer accepts input",
));
}
if data.pending.len() >= MAX_PENDING_INPUTS
|| bytes > MAX_PENDING_BYTES.saturating_sub(data.pending_bytes)
{
return Err(control_error(
"SESSION_CONTROL_FULL",
"pending input limit reached; no existing input was discarded",
));
}
let id = InputId(uuid::Uuid::new_v4());
let input = PendingInput {
id,
kind,
text: source.to_string(),
content: content.clone(),
bytes,
};
data.pending_bytes += bytes;
data.pending.push_back(input);
drop(data);
Ok(id)
}
}
#[cfg(test)]
mod tests {
use super::super::tests::live;
use super::*;
use crate::model::{ImageContent, Message, TextContent};
fn attachment() -> UserContent {
UserContent::Blocks(vec![
ContentBlock::Text(TextContent::new("<file>ultrathink in attached text</file>")),
ContentBlock::Image(ImageContent {
data: "aGVsbG8=".to_string(),
mime_type: "image/png".to_string(),
}),
ContentBlock::Text(TextContent::new("after image")),
])
}
#[test]
fn native_blocks_and_authorship_survive_handoff() {
let (control, _guard, _) = live();
let content = attachment();
control
.steer_with_content(&content, "inspect this attachment")
.unwrap();
let claimed = control.run.fetch(InputKind::Steering);
assert_eq!(
claimed[0].keyword_scan_source(),
Some("inspect this attachment")
);
let Message::User(user) = claimed[0].message() else {
panic!("user content");
};
assert_eq!(
serde_json::to_value(&user.content).unwrap(),
serde_json::to_value(content).unwrap()
);
}
#[test]
fn reclaim_preserves_expanded_content_and_raw_editor_text() {
let (control, guard, _) = live();
let content = attachment();
control
.follow_up_with_content(&content, "@diagram.png inspect")
.unwrap();
drop(guard);
let pending = control.take_pending();
assert_eq!(pending[0].text, "@diagram.png inspect");
assert_eq!(
serde_json::to_value(&pending[0].content).unwrap(),
serde_json::to_value(content).unwrap()
);
assert_eq!(control.snapshot().pending_bytes, 0);
}
#[test]
fn retract_removes_only_selected_pending_input_and_refunds_bytes() {
let (control, _guard, _) = live();
let first = control.steer("keep first").unwrap();
let second = control
.follow_up_with_content(&attachment(), "remove this")
.unwrap();
let before = control.snapshot().pending_bytes;
let removed = control.retract(second).unwrap();
assert_eq!(control.snapshot().pending_bytes, before - removed.bytes);
assert!(control.retract(second).is_none());
control.run.fetch(InputKind::Steering);
assert!(control.retract(first).is_none());
}
#[test]
fn content_shape_and_escaped_size_are_bounded_before_admission() {
assert!(admitted_bytes(&UserContent::Blocks(vec![]), "").is_err());
let wide = UserContent::Blocks(vec![
ContentBlock::Text(TextContent::new("x"));
MAX_CONTENT_BLOCKS + 1
]);
assert!(admitted_bytes(&wide, "").is_err());
let escaped = UserContent::Text("\u{0001}".repeat(MAX_CONTENT_BYTES / 2));
assert!(admitted_bytes(&escaped, "").is_err());
assert!(admitted_bytes(&attachment(), &"x".repeat(MAX_INPUT_BYTES + 1)).is_err());
}
#[test]
fn attachment_text_is_never_the_implicit_keyword_scan_source() {
let (control, _guard, _) = live();
control.steer_with_content(&attachment(), "").unwrap();
let claimed = control.run.fetch(InputKind::Steering);
assert_eq!(claimed[0].keyword_scan_source(), Some(""));
}
#[test]
fn stale_receipt_cannot_retract_input_from_a_later_turn() {
let (old, guard, _) = live();
let stale_id = old.steer("old input").unwrap();
drop(guard);
let (new, _guard, _) = live();
let new_id = new.steer("new input").unwrap();
assert_ne!(stale_id, new_id);
assert!(new.retract(stale_id).is_none());
assert_eq!(new.retract(new_id).unwrap().text, "new input");
}
}