Files
galaxy/crates/warp_tui/src/agent_block_tests.rs
T

234 lines
7.8 KiB
Rust

use std::rc::Rc;
use warp::tui_export::{
AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType,
AIAgentText, AIAgentTextSection, AIBlockModel, AIBlockOutputStatus, AIConversationId,
AIRequestType, Appearance, LLMId, MessageId, OutputStatusUpdateCallback, ServerOutputId,
Shared, UserQueryMode,
};
use warp_core::ui::color::blend::Blend;
use warp_core::ui::theme::Fill as ThemeFill;
use warpui::SingletonEntity;
use warpui_core::elements::tui::{Color, Modifier, TuiBufferExt, TuiRect};
use warpui_core::elements::Fill as CoreFill;
use warpui_core::presenter::tui::TuiPresenter;
use warpui_core::{App, AppContext, ViewContext};
use super::{TuiAIBlock, TuiAIBlockSection};
#[test]
fn simple_agent_block_reports_full_height_and_renders_content() {
App::test((), |app| async move {
app.add_singleton_model(|_| Appearance::mock());
app.read(|app_ctx| {
let block = test_agent_block(FakeAgentBlockModel {
inputs: vec![query_input("hello")],
status: complete_output(vec![AIAgentTextSection::PlainText {
text: "one\ntwo\nthree".to_owned().into(),
}]),
});
assert_eq!(block.desired_height(20, app_ctx), 6);
let mut presenter = TuiPresenter::new();
let frame = presenter.present_element(
block.render_element(app_ctx),
TuiRect::new(0, 0, 20, 6),
app_ctx,
);
assert_eq!(
frame
.buffer
.to_lines()
.into_iter()
.map(|line| line.trim_end().to_owned())
.collect::<Vec<_>>(),
vec!["≫ hello", "", "one", "two", "three", ""],
);
assert_eq!(frame.buffer[(0, 0)].fg, expected_prompt_text_color(app_ctx));
assert_eq!(frame.buffer[(0, 0)].bg, expected_input_background(app_ctx));
assert!(frame.buffer[(0, 0)].modifier.contains(Modifier::BOLD));
assert_eq!(frame.buffer[(2, 0)].fg, expected_prompt_text_color(app_ctx));
assert_eq!(frame.buffer[(19, 0)].bg, expected_input_background(app_ctx));
assert_eq!(frame.buffer[(0, 2)].fg, expected_output_text_color(app_ctx));
// The block paints no background of its own, so output rows show the
// terminal's own background.
assert_eq!(frame.buffer[(0, 2)].bg, Color::Reset);
assert_eq!(frame.buffer[(19, 2)].bg, Color::Reset);
});
});
}
#[test]
fn simple_agent_block_reflows_height_at_narrow_width() {
App::test((), |app| async move {
app.add_singleton_model(|_| Appearance::mock());
app.read(|app_ctx| {
let block = test_agent_block(FakeAgentBlockModel {
inputs: vec![query_input("hello world")],
status: complete_output(vec![AIAgentTextSection::PlainText {
text: "streamed output".to_owned().into(),
}]),
});
let wide = block.desired_height(40, app_ctx);
let narrow = block.desired_height(6, app_ctx);
assert!(narrow > wide, "narrow text should occupy more logical rows");
});
});
}
fn expected_prompt_text_color(app: &AppContext) -> Color {
let theme = Appearance::as_ref(app).theme();
CoreFill::from(theme.foreground()).into()
}
fn expected_input_background(app: &AppContext) -> Color {
let theme = Appearance::as_ref(app).theme();
let accent = ThemeFill::from(theme.terminal_colors().normal.cyan);
CoreFill::from(
theme
.background()
.blend(&accent.with_opacity(10))
.blend(&accent.with_opacity(10)),
)
.into()
}
fn expected_output_text_color(app: &AppContext) -> Color {
let theme = Appearance::as_ref(app).theme();
CoreFill::from(ThemeFill::from(theme.terminal_colors().normal.white)).into()
}
#[test]
fn agent_block_extracts_input_and_plain_text_from_model() {
App::test((), |app| async move {
app.read(|app_ctx| {
let block = test_agent_block(FakeAgentBlockModel {
inputs: vec![query_input("hello")],
status: complete_output(vec![
AIAgentTextSection::PlainText {
text: "one".to_owned().into(),
},
AIAgentTextSection::PlainText {
text: "two".to_owned().into(),
},
]),
});
assert_eq!(
block.sections(app_ctx),
vec![
TuiAIBlockSection::Input("hello".to_owned()),
TuiAIBlockSection::PlainText("one".to_owned()),
TuiAIBlockSection::PlainText("two".to_owned()),
]
);
});
});
}
#[test]
fn agent_block_omits_unsupported_sections_until_the_tui_can_render_them() {
App::test((), |app| async move {
app.read(|app_ctx| {
let block = test_agent_block(FakeAgentBlockModel {
inputs: Vec::new(),
status: complete_output(vec![
AIAgentTextSection::Code {
code: "println!(\"hi\");".to_owned(),
language: None,
source: None,
},
AIAgentTextSection::PlainText {
text: "visible".to_owned().into(),
},
]),
});
assert_eq!(
block.sections(app_ctx),
vec![TuiAIBlockSection::PlainText("visible".to_owned())]
);
});
});
}
struct FakeAgentBlockModel {
inputs: Vec<AIAgentInput>,
status: AIBlockOutputStatus,
}
/// Builds an agent block with fresh test identity.
fn test_agent_block(model: FakeAgentBlockModel) -> TuiAIBlock {
TuiAIBlock::new(
AIConversationId::new(),
AIAgentExchangeId::new(),
Rc::new(model),
)
}
impl AIBlockModel for FakeAgentBlockModel {
type View = TuiAIBlock;
fn status(&self, _app: &AppContext) -> AIBlockOutputStatus {
self.status.clone()
}
fn server_output_id(&self, _app: &AppContext) -> Option<ServerOutputId> {
None
}
fn model_id(&self, _app: &AppContext) -> Option<LLMId> {
None
}
fn base_model<'a>(&'a self, _app: &'a AppContext) -> Option<&'a LLMId> {
None
}
fn inputs_to_render<'a>(&'a self, _app: &'a AppContext) -> &'a [AIAgentInput] {
&self.inputs
}
fn conversation_id(&self, _app: &AppContext) -> Option<AIConversationId> {
None
}
fn on_updated_output(
&self,
_callback: OutputStatusUpdateCallback<Self::View>,
_ctx: &mut ViewContext<Self::View>,
) {
}
fn request_type(&self, _app: &AppContext) -> AIRequestType {
AIRequestType::Active
}
}
/// Builds a completed output status with one text message.
fn complete_output(sections: Vec<AIAgentTextSection>) -> AIBlockOutputStatus {
AIBlockOutputStatus::Complete {
output: Shared::new(AIAgentOutput {
messages: vec![AIAgentOutputMessage {
id: MessageId::new("message-1".to_owned()),
message: AIAgentOutputMessageType::Text(AIAgentText { sections }),
citations: Vec::new(),
}],
..Default::default()
}),
}
}
/// Builds one user-query input for model-backed extraction tests.
fn query_input(query: &str) -> AIAgentInput {
AIAgentInput::UserQuery {
query: query.to_owned(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::default(),
running_command: None,
intended_agent: None,
}
}