use crate::app::message::Message;
use crate::app::{App, AppConfig};
use crate::attachment::{AttachmentOutcome, PromptAttachment, build_attachments_with};
use crate::command::{AgentCommand, Command, CommandResult, FilesystemCommand, GitCommand, GitWatchCommand};
use crate::file_index::{FileEntry, MAX_INDEXED_FILES, file_entries};
use crate::git_review::{
DiffDocument, DiffScope, FileDiff, FileStatus, GitDiffError, GitDiffEvent, GitWatchError, GitWatchEvent,
GitWatchResult, StageState,
};
pub use crate::renderer::RenderStats;
use crate::renderer::Renderer;
use crate::request::RequestId;
use crate::session::platform::BrowserOpener;
use crate::session::terminal::inline_viewport_height;
use crate::session::workspace_status::WorkspaceStatus;
use crate::settings::UiSettings;
use crate::surfaces::composer::ComposerLayout;
use acp_utils::client::AcpEvent;
use acp_utils::notifications::{
AetherCapabilities, SubAgentEvent, SubAgentProgressParams, SubAgentToolRequest, SubAgentToolResult,
};
use agent_client_protocol::schema::MaybeUndefined;
use agent_client_protocol::schema::v2::{self as acp, SessionId, SessionUpdate, ToolCallUpdate};
use clankerdiff_core::git_patch_from_texts;
use clankerdiff_git::RepositorySnapshot;
use clankerdiff_watch::RepositoryState;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize};
use ratatui::buffer::{Buffer, Cell};
use ratatui::layout::{Position, Rect, Size};
use ratatui::{Terminal, TerminalOptions, Viewport};
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::mpsc::UnboundedReceiver;
pub struct FakeExecutor {
available: VecDeque<Command>,
pending: VecDeque<Command>,
git: FakeGit,
git_watch: Option<GitWatchEvent>,
git_watch_started: bool,
git_scope: DiffScope,
git_watch_changed: bool,
git_completion: Option<GitDiffEvent>,
filesystem: FakeFilesystem,
}
impl Default for FakeExecutor {
fn default() -> Self {
Self::new()
}
}
impl FakeExecutor {
pub fn new() -> Self {
Self::with_git(FakeGit::default())
}
pub fn with_git(git: FakeGit) -> Self {
Self {
available: VecDeque::new(),
pending: VecDeque::new(),
git,
git_watch: None,
git_watch_started: false,
git_scope: DiffScope::default(),
git_watch_changed: false,
git_completion: None,
filesystem: FakeFilesystem::default(),
}
}
pub fn git(&self) -> &FakeGit {
&self.git
}
pub fn git_mut(&mut self) -> &mut FakeGit {
&mut self.git
}
pub fn git_watch_id(&self) -> Option<RequestId> {
self.git_watch.as_ref().map(|event| event.review_id)
}
pub fn next_git_watch_event(&mut self) -> Option<GitWatchEvent> {
if !self.git_watch_started {
return None;
}
let current = self.git_watch.as_mut()?;
let result = self.git.watch_snapshot(self.git_scope, current.result.as_ref().ok());
let unchanged = match (¤t.result, &result) {
(Ok(previous), Ok(next)) => {
previous.snapshot == next.snapshot && previous.error_message() == next.error_message()
}
(Err(previous), Err(next)) => previous.to_string() == next.to_string(),
_ => false,
};
if unchanged && !self.git_watch_changed {
return None;
}
self.git_watch_changed = false;
current.result = result;
Some(current.clone())
}
pub fn filesystem(&self) -> &FakeFilesystem {
&self.filesystem
}
pub fn filesystem_mut(&mut self) -> &mut FakeFilesystem {
&mut self.filesystem
}
pub fn record(&mut self, commands: impl IntoIterator<Item = Command>) {
for command in commands {
self.available.push_back(command.clone());
self.pending.push_back(command);
}
}
fn complete(&mut self, command: Command) -> Option<CommandResult> {
match command {
Command::ResolveWorkspace { cwd } => Some(CommandResult::WorkspaceResolved {
status: WorkspaceStatus::new(cwd.display().to_string(), None),
cwd,
}),
Command::Git(GitCommand::Apply { review_id, action }) => {
if self.git_watch_id() != Some(review_id) || !self.git_watch_started {
return Some(CommandResult::GitWatch(GitWatchEvent {
review_id,
result: Err(Arc::new(GitWatchError::Stopped)),
}));
}
Some(CommandResult::GitDiff(GitDiffEvent {
review_id,
result: self.git.apply(action).map_err(Arc::new),
}))
}
Command::GitWatch(command) => self.watch_git(&command),
Command::Filesystem(FilesystemCommand::PrepareSubmission { attachments }) => {
Some(CommandResult::SubmissionPrepared(self.filesystem.build_attachments(&attachments)))
}
Command::Filesystem(FilesystemCommand::IndexFiles { request_id, root }) => {
Some(CommandResult::FilesIndexed { request_id, files: self.filesystem.index_files(&root) })
}
_ => None,
}
}
fn watch_git(&mut self, command: &GitWatchCommand) -> Option<CommandResult> {
let (review_id, scope) = match *command {
GitWatchCommand::Open { review_id, scope, .. } => {
self.git_watch_started = false;
self.git_watch_changed = false;
self.git_completion = None;
self.git_watch = None;
(review_id, scope)
}
GitWatchCommand::Refresh { review_id, scope } => {
if self.git_watch_id() != Some(review_id) {
return Some(CommandResult::GitWatch(GitWatchEvent {
review_id,
result: Err(Arc::new(GitWatchError::Stopped)),
}));
}
self.git_scope = scope;
if self.git_watch_started {
self.git_watch_changed = self.next_git_watch_event().is_some();
let state = self.git_watch.as_ref().expect("active watch").result.as_ref().expect("started watch");
let result = state.error.clone().map_or(Ok(()), Err);
return Some(CommandResult::GitDiff(GitDiffEvent { review_id, result }));
}
(review_id, scope)
}
GitWatchCommand::Close { review_id } => {
if self.git_watch_id() == Some(review_id) {
self.git_watch = None;
self.git_watch_started = false;
self.git_watch_changed = false;
self.git_completion = None;
}
return None;
}
};
self.git_scope = scope;
let event = GitWatchEvent { review_id, result: self.git.watch_snapshot(scope, None) };
self.git_watch_started = event.result.is_ok();
if self.git_watch_started {
self.git_completion = Some(GitDiffEvent { review_id, result: Ok(()) });
}
self.git_watch = Some(event.clone());
Some(CommandResult::GitWatch(event))
}
fn take_pending(&mut self) -> Vec<Command> {
self.pending.drain(..).collect()
}
fn clear_available(&mut self) {
self.available.clear();
}
pub fn take_commands(&mut self) -> Vec<Command> {
self.pending.clear();
self.available.drain(..).collect()
}
}
#[derive(Clone, Default)]
pub struct FakeFilesystem {
files: BTreeMap<PathBuf, Vec<u8>>,
directories: BTreeSet<PathBuf>,
settings: Option<UiSettings>,
}
impl FakeFilesystem {
pub fn new() -> Self {
Self::default()
}
pub fn create_dir(&mut self, path: impl Into<PathBuf>) {
self.directories.insert(path.into());
}
pub fn write_file(&mut self, path: impl Into<PathBuf>, contents: impl AsRef<[u8]>) {
let path = path.into();
if let Some(parent) = path.parent() {
self.directories.insert(parent.to_path_buf());
}
self.files.insert(path, contents.as_ref().to_vec());
}
pub fn remove_file(&mut self, path: &Path) -> bool {
self.files.remove(path).is_some()
}
pub fn read_file(&self, path: &Path) -> Option<&[u8]> {
self.files.get(path).map(Vec::as_slice)
}
pub fn read_to_string(&self, path: &Path) -> Option<String> {
self.read_file(path).and_then(|contents| String::from_utf8(contents.to_vec()).ok())
}
pub fn contains(&self, path: &Path) -> bool {
self.files.contains_key(path) || self.directories.contains(path)
}
pub fn files(&self) -> impl Iterator<Item = (&Path, &[u8])> {
self.files.iter().map(|(path, contents)| (path.as_path(), contents.as_slice()))
}
pub fn directories(&self) -> impl Iterator<Item = &Path> {
self.directories.iter().map(PathBuf::as_path)
}
pub fn save_settings(&mut self, settings: UiSettings) {
self.settings = Some(settings);
}
pub fn settings(&self) -> Option<&UiSettings> {
self.settings.as_ref()
}
pub fn index_files(&self, root: &Path) -> Vec<FileEntry> {
let paths = self.files.keys().filter(|path| path.starts_with(root)).cloned();
file_entries(root, paths, MAX_INDEXED_FILES)
}
pub fn build_attachments(&self, attachments: &[PromptAttachment]) -> AttachmentOutcome {
build_attachments_with(attachments, |path, display_name| {
if self.directories.contains(path) {
return Err(format!("Failed to read {display_name}: is a directory"));
}
self.files.get(path).cloned().ok_or_else(|| format!("Failed to read {display_name}: file not found"))
})
}
}
#[derive(Clone, Default)]
pub struct FakeGit {
state: std::sync::Arc<std::sync::Mutex<FakeGitState>>,
}
#[derive(Default)]
struct FakeGitState {
root: PathBuf,
files: BTreeMap<String, FakeGitFile>,
commits: Vec<String>,
is_repo: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FakeGitFile {
pub path: String,
pub contents: Option<Vec<u8>>,
pub staged_contents: Option<Vec<u8>>,
pub committed_contents: Option<Vec<u8>>,
}
impl FakeGit {
pub fn new(root: impl Into<PathBuf>) -> Self {
let state = FakeGitState { root: root.into(), is_repo: true, ..FakeGitState::default() };
Self { state: std::sync::Arc::new(std::sync::Mutex::new(state)) }
}
pub fn not_a_repository(root: impl Into<PathBuf>) -> Self {
let state = FakeGitState { root: root.into(), ..FakeGitState::default() };
Self { state: std::sync::Arc::new(std::sync::Mutex::new(state)) }
}
pub fn set_repository_available(&mut self, available: bool) {
self.state.lock().unwrap().is_repo = available;
}
pub fn root(&self) -> PathBuf {
self.state.lock().unwrap().root.clone()
}
pub fn add_file(&mut self, path: impl Into<String>, contents: impl AsRef<[u8]>) {
let path = path.into();
self.state.lock().unwrap().files.insert(
path.clone(),
FakeGitFile {
path,
contents: Some(contents.as_ref().to_vec()),
staged_contents: None,
committed_contents: None,
},
);
}
pub fn write_file(&mut self, path: impl Into<String>, contents: impl AsRef<[u8]>) {
let path = path.into();
let mut state = self.state.lock().unwrap();
let file = state.files.entry(path.clone()).or_insert_with(|| FakeGitFile {
path,
contents: None,
staged_contents: None,
committed_contents: None,
});
file.contents = Some(contents.as_ref().to_vec());
}
pub fn remove_file(&mut self, path: &str) {
if let Some(file) = self.state.lock().unwrap().files.get_mut(path) {
file.contents = None;
}
}
pub fn stage(&mut self, path: &str) -> bool {
let mut state = self.state.lock().unwrap();
let Some(file) = state.files.get_mut(path) else { return false };
file.staged_contents = file.contents.clone();
true
}
pub fn unstage(&mut self, path: &str) -> bool {
let mut state = self.state.lock().unwrap();
let Some(file) = state.files.get_mut(path) else { return false };
file.staged_contents = file.committed_contents.clone();
true
}
pub fn stage_all(&mut self) {
let mut state = self.state.lock().unwrap();
for file in state.files.values_mut() {
file.staged_contents = file.contents.clone();
}
}
pub fn unstage_all(&mut self) {
let mut state = self.state.lock().unwrap();
for file in state.files.values_mut() {
file.staged_contents = file.committed_contents.clone();
}
}
pub fn discard(&mut self, path: &str) -> bool {
let mut state = self.state.lock().unwrap();
let Some(file) = state.files.get_mut(path) else { return false };
file.contents = file.committed_contents.clone();
file.staged_contents = file.committed_contents.clone();
true
}
pub fn commit(&mut self, message: impl Into<String>) -> Result<(), GitDiffError> {
let message = message.into();
let mut state = self.state.lock().unwrap();
if message.trim().is_empty() {
return Err(GitDiffError::EmptyCommitMessage);
}
if !state.files.values().any(|file| file.staged_contents != file.committed_contents) {
return Err(GitDiffError::CommandFailed { operation: "commit", status: Some(1), stderr: String::new() });
}
for file in state.files.values_mut() {
if file.staged_contents != file.committed_contents {
file.committed_contents = file.staged_contents.clone();
}
}
state.commits.push(message);
Ok(())
}
pub fn file(&self, path: &str) -> Option<FakeGitFile> {
self.state.lock().unwrap().files.get(path).cloned()
}
pub fn files(&self) -> Vec<FakeGitFile> {
self.state.lock().unwrap().files.values().cloned().collect()
}
pub fn commits(&self) -> Vec<String> {
self.state.lock().unwrap().commits.clone()
}
pub fn status(&self, path: &str) -> Option<(FileStatus, StageState)> {
self.state.lock().unwrap().files.get(path).and_then(status_of)
}
pub fn apply(&mut self, action: clankerdiff_ratatui::diff::RepositoryAction) -> Result<(), GitDiffError> {
use clankerdiff_ratatui::diff::RepositoryAction;
if !self.state.lock().unwrap().is_repo {
return Err(GitDiffError::NotRepository);
}
match action {
RepositoryAction::StagePaths(paths) => {
for path in paths {
self.stage(path.as_str());
}
Ok(())
}
RepositoryAction::UnstagePaths(paths) => {
for path in paths {
self.unstage(path.as_str());
}
Ok(())
}
RepositoryAction::StageAll => {
self.stage_all();
Ok(())
}
RepositoryAction::UnstageAll => {
self.unstage_all();
Ok(())
}
RepositoryAction::Commit { message } => self.commit(message),
RepositoryAction::Discard { path, status } => {
if status == FileStatus::Untracked {
self.state.lock().unwrap().files.remove(path.as_str());
} else {
self.discard(path.as_str());
}
Ok(())
}
}
}
fn watch_snapshot(&self, scope: DiffScope, previous: Option<&RepositoryState>) -> GitWatchResult {
match self.load_diff(scope) {
Ok(snapshot) => Ok(RepositoryState { snapshot: Arc::new(snapshot), error: None }),
Err(error) => match previous {
Some(previous) => {
Ok(RepositoryState { snapshot: previous.snapshot.clone(), error: Some(Arc::new(error)) })
}
None => Err(Arc::new(GitWatchError::Git(error))),
},
}
}
fn load_diff(&self, scope: DiffScope) -> Result<RepositorySnapshot, GitDiffError> {
let state = self.state.lock().unwrap();
if !state.is_repo {
return Err(GitDiffError::NotRepository);
}
let mut files = Vec::new();
for file in state.files.values() {
let (old, new) = match scope {
DiffScope::Staged => (&file.committed_contents, &file.staged_contents),
DiffScope::Unstaged => (&file.staged_contents, &file.contents),
DiffScope::Both => (&file.committed_contents, &file.contents),
};
if old == new {
continue;
}
let binary = old.as_ref().is_some_and(|bytes| is_binary(bytes))
|| new.as_ref().is_some_and(|bytes| is_binary(bytes));
let mut diff = if binary {
FileDiff::from_texts(file.path.clone(), "", "")?
} else {
let old_text = old.as_deref().map(String::from_utf8_lossy).unwrap_or_default();
let new_text = new.as_deref().map(String::from_utf8_lossy).unwrap_or_default();
FileDiff::from_texts(file.path.clone(), &old_text, &new_text)?
};
diff.status = match (old, new) {
(None, Some(_)) if file.committed_contents.is_none() && file.staged_contents.is_none() => {
FileStatus::Untracked
}
(None, Some(_)) => FileStatus::Added,
(Some(_), None) => FileStatus::Deleted,
_ => FileStatus::Modified,
};
diff.old_path = old.is_some().then(|| diff.path.clone());
diff.staged = status_of(file).map_or(StageState::Unstaged, |(_, stage)| stage);
diff.binary = binary;
diff = diff.with_sources(fake_source(old.as_deref()), fake_source(new.as_deref()));
files.push(diff);
}
let document = DiffDocument { repo_root: state.root.to_string_lossy().into_owned(), files };
Ok(clankerdiff_git::RepositorySnapshot { scope, document: std::sync::Arc::new(document) })
}
}
fn status_of(file: &FakeGitFile) -> Option<(FileStatus, StageState)> {
let staged_changed = file.staged_contents != file.committed_contents;
let working_changed = file.contents != file.staged_contents;
if !staged_changed && !working_changed {
return None;
}
if file.committed_contents.is_none() {
let stage = match (file.staged_contents.is_some(), working_changed) {
(true, true) => StageState::PartiallyStaged,
(true, false) => StageState::Staged,
(false, _) => StageState::Unstaged,
};
return Some((FileStatus::Untracked, stage));
}
let stage = match (staged_changed, working_changed) {
(true, true) => StageState::PartiallyStaged,
(true, false) => StageState::Staged,
(false, true) => StageState::Unstaged,
(false, false) => unreachable!("clean files returned above"),
};
let status = if file.contents.is_none() { FileStatus::Deleted } else { FileStatus::Modified };
Some((status, stage))
}
fn fake_source(bytes: Option<&[u8]>) -> clankerdiff_ratatui::diff::SourceResult {
use clankerdiff_ratatui::diff::{SourceDocument, SourceUnavailable};
match bytes {
None => Err(SourceUnavailable::Absent),
Some(bytes) if is_binary(bytes) => Err(SourceUnavailable::Binary),
Some(bytes) => SourceDocument::new(String::from_utf8_lossy(bytes)).map(std::sync::Arc::new),
}
}
fn is_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8192).any(|byte| *byte == 0) || std::str::from_utf8(bytes).is_err()
}
pub struct TestTerminal {
terminal: Terminal<TestBackend>,
}
impl TestTerminal {
pub fn new(width: u16, height: u16) -> Self {
Self { terminal: test_terminal(TestBackend::new(width, height)) }
}
pub fn terminal(&mut self) -> &mut Terminal<TestBackend> {
&mut self.terminal
}
pub fn resize(&mut self, width: u16, height: u16) {
self.terminal.backend_mut().resize(width, height);
}
pub fn viewport(&mut self) -> Buffer {
viewport_buffer(&mut self.terminal)
}
pub fn history(&mut self) -> Buffer {
history_buffer(&mut self.terminal)
}
pub fn conversation(&mut self) -> Buffer {
conversation_buffer(&mut self.terminal)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendEvent {
ShowCursor,
Scroll,
}
#[derive(Debug)]
pub struct RecordingBackend {
inner: TestBackend,
events: Vec<BackendEvent>,
}
impl RecordingBackend {
pub fn new(width: u16, height: u16) -> Self {
Self { inner: TestBackend::new(width, height), events: Vec::new() }
}
pub fn events(&self) -> &[BackendEvent] {
&self.events
}
pub fn clear_events(&mut self) {
self.events.clear();
}
pub fn resize(&mut self, width: u16, height: u16) {
self.inner.resize(width, height);
}
pub fn buffer(&self) -> &Buffer {
self.inner.buffer()
}
pub fn scrollback(&self) -> &Buffer {
self.inner.scrollback()
}
}
impl Backend for RecordingBackend {
type Error = std::convert::Infallible;
fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
self.inner.draw(content)
}
fn append_lines(&mut self, lines: u16) -> Result<(), Self::Error> {
self.inner.append_lines(lines)
}
fn hide_cursor(&mut self) -> Result<(), Self::Error> {
self.inner.hide_cursor()
}
fn show_cursor(&mut self) -> Result<(), Self::Error> {
self.events.push(BackendEvent::ShowCursor);
self.inner.show_cursor()
}
fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
self.inner.get_cursor_position()
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
self.inner.set_cursor_position(position)
}
fn clear(&mut self) -> Result<(), Self::Error> {
self.inner.clear()
}
fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
self.inner.clear_region(clear_type)
}
fn size(&self) -> Result<Size, Self::Error> {
self.inner.size()
}
fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
self.inner.window_size()
}
fn flush(&mut self) -> Result<(), Self::Error> {
self.inner.flush()
}
fn scroll_region_up(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
self.events.push(BackendEvent::Scroll);
let region = if region == (0..1) { 0..self.inner.size().unwrap().height } else { region };
self.inner.scroll_region_up(region, lines)
}
fn scroll_region_down(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
self.events.push(BackendEvent::Scroll);
self.inner.scroll_region_down(region, lines)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct BackendStats {
pub draws: u64,
pub cells_drawn: u64,
pub scrolls: u64,
}
#[derive(Debug)]
pub struct CountingBackend {
inner: TestBackend,
stats: BackendStats,
}
impl CountingBackend {
pub fn new(width: u16, height: u16) -> Self {
Self { inner: TestBackend::new(width, height), stats: BackendStats::default() }
}
pub fn take_stats(&mut self) -> BackendStats {
std::mem::take(&mut self.stats)
}
pub fn buffer(&self) -> &Buffer {
self.inner.buffer()
}
pub fn scrollback(&self) -> &Buffer {
self.inner.scrollback()
}
}
impl Backend for CountingBackend {
type Error = std::convert::Infallible;
fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
self.stats.draws += 1;
let mut cells = 0u64;
let drawn = self.inner.draw(content.inspect(|_| cells += 1));
self.stats.cells_drawn += cells;
drawn
}
fn hide_cursor(&mut self) -> Result<(), Self::Error> {
self.inner.hide_cursor()
}
fn show_cursor(&mut self) -> Result<(), Self::Error> {
self.inner.show_cursor()
}
fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
self.inner.get_cursor_position()
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
self.inner.set_cursor_position(position)
}
fn clear(&mut self) -> Result<(), Self::Error> {
self.inner.clear()
}
fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
self.inner.clear_region(clear_type)
}
fn size(&self) -> Result<Size, Self::Error> {
self.inner.size()
}
fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
self.inner.window_size()
}
fn flush(&mut self) -> Result<(), Self::Error> {
self.inner.flush()
}
fn scroll_region_up(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
self.stats.scrolls += 1;
self.inner.scroll_region_up(region, lines)
}
fn scroll_region_down(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
self.stats.scrolls += 1;
self.inner.scroll_region_down(region, lines)
}
}
pub struct TestUi<B: Backend = TestBackend> {
app: App,
renderer: Renderer,
terminal: Terminal<B>,
executor: FakeExecutor,
opened_urls: Arc<Mutex<Vec<String>>>,
}
impl<B: Backend> TestUi<B>
where
B::Error: std::fmt::Debug,
{
pub fn with_backend(backend: B) -> Self {
let builder = TestUiBuilder::new();
let app = App::new(builder.app_config());
Self {
app,
renderer: Renderer::new(),
terminal: test_terminal(backend),
executor: FakeExecutor::new(),
opened_urls: builder.opened_urls.clone(),
}
}
pub fn app(&self) -> &App {
&self.app
}
pub fn deliver(&mut self, message: Message) {
self.executor.record(self.app.update(message));
}
pub fn deliver_result(&mut self, result: CommandResult) {
self.deliver(Message::CommandFinished(Box::new(result)));
}
pub fn executor(&self) -> &FakeExecutor {
&self.executor
}
pub fn executor_mut(&mut self) -> &mut FakeExecutor {
&mut self.executor
}
pub fn take_commands(&mut self) -> Vec<Command> {
self.executor.take_commands()
}
pub fn next_command(&mut self) -> Option<Command> {
self.executor.available.pop_front()
}
pub fn next_agent_command(&mut self) -> Option<AgentCommand> {
while let Some(command) = self.next_command() {
if let Command::Agent(command) = command {
return Some(command);
}
}
None
}
pub fn backend(&self) -> &B {
self.terminal.backend()
}
pub fn backend_mut(&mut self) -> &mut B {
self.terminal.backend_mut()
}
pub fn viewport_area(&mut self) -> ratatui::layout::Rect {
self.terminal.get_frame().area()
}
pub fn viewport_height(&mut self) -> u16 {
self.viewport_area().height
}
pub fn composer_layout(&mut self, width: u16) -> ComposerLayout {
let theme = self.app.theme().clone();
let composer = self.app.composer_mut();
composer.on_resize(width);
composer.layout(width, &theme)
}
pub fn draw(&mut self) {
self.try_draw().unwrap();
}
pub fn try_draw(&mut self) -> Result<(), crate::error::RenderError<B::Error>> {
self.renderer.draw(&mut self.terminal, &mut self.app)
}
pub fn render_stats(&mut self) -> RenderStats {
self.renderer.take_stats()
}
pub fn opened_urls(&self) -> Vec<String> {
self.opened_urls.lock().unwrap().clone()
}
pub fn seed_long_history(&mut self, turns: usize) {
for turn in 0..turns {
let prompt = format!("Turn {turn}: reconcile the writer path in module_{turn} and add a regression test.");
self.submit(&prompt);
self.acp_event(session_update(acp::SessionUpdate::UserMessage(
acp::UserMessage::new(format!("seed-user-{turn}"))
.content(vec![acp::ContentBlock::Text(acp::TextContent::new(prompt))]),
)));
self.acp_event(session_update(acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
acp::ContentBlock::Text(acp::TextContent::new(format!(
"Reading module_{turn} to find the torn-update window before touching any call site."
))),
format!("seed-thought-{turn}"),
))));
self.acp_event(text_chunk_with_id(&format!("seed-response-{turn}"), SEED_PROSE));
self.acp_event(text_chunk_with_id(&format!("seed-response-{turn}"), SEED_CODE_BLOCK));
let bash = format!("seed-bash-{turn}");
self.acp_event(seed_bash_tool(&bash));
self.acp_event(tool_completed(&bash));
let edit = format!("seed-edit-{turn}");
self.acp_event(seed_edit_tool(&edit, turn));
self.acp_event(seed_tool_diff(&edit, turn));
if turn % 8 == 0 {
self.seed_sub_agent_tree(turn);
}
self.acp_event(text_chunk_with_id(&format!("seed-closing-{turn}"), SEED_CLOSING));
self.complete_prompt(acp::StopReason::EndTurn);
self.draw();
}
}
fn seed_sub_agent_tree(&mut self, turn: usize) {
let parent = format!("seed-spawn-{turn}");
self.acp_event(seed_spawn_tool(&parent));
self.acp_event(tool_completed(&parent));
for agent in ["explorer", "fixer"] {
let task = format!("{parent}-{agent}");
self.acp_event(seed_sub_agent(
&parent,
&task,
agent,
SubAgentEvent::ToolCall {
request: SubAgentToolRequest {
id: format!("{task}-grep"),
name: "grep".to_string(),
arguments: r#"{"pattern":"torn update"}"#.to_string(),
},
},
));
self.acp_event(seed_sub_agent(
&parent,
&task,
agent,
SubAgentEvent::ToolResult {
result: SubAgentToolResult {
id: format!("{task}-grep"),
name: "grep".to_string(),
result_meta: None,
},
},
));
self.acp_event(seed_sub_agent(&parent, &task, agent, SubAgentEvent::Done));
}
}
pub fn stream_message(&mut self, content: StreamContent, total_bytes: usize, chunk_bytes: usize) {
let thought = matches!(content, StreamContent::Thought);
let message = match content {
StreamContent::Prose => prose_message(total_bytes),
StreamContent::CodeBlock => code_block_message(total_bytes),
StreamContent::Thought => thought_message(total_bytes),
};
for chunk in chunk_message(&message, chunk_bytes.max(1)) {
if thought {
self.acp_event(thought_chunk(&chunk));
} else {
self.acp_event(text_chunk(&chunk));
}
self.draw();
}
}
pub fn settle(&mut self) {
self.complete_prompt(acp::StopReason::EndTurn);
let mut now = Instant::now();
for _ in 0..12 {
self.tick(now);
now += Duration::from_millis(500);
if !self.app().wants_tick() {
break;
}
}
assert!(!self.app().wants_tick(), "a settled session must stop driving the tick loop");
self.draw();
}
pub fn terminal_event(&mut self, event: Event) {
self.deliver(Message::Terminal(event));
}
pub fn key(&mut self, key: KeyEvent) {
self.deliver(Message::Terminal(Event::Key(key)));
}
pub fn type_text(&mut self, text: &str) {
for character in text.chars() {
self.key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
}
}
pub fn submit(&mut self, text: &str) {
self.type_text(text);
self.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
}
pub fn paste(&mut self, text: &str) {
self.deliver(Message::Terminal(Event::Paste(text.to_string())));
}
pub fn acp_event(&mut self, event: AcpEvent) {
self.deliver(Message::Agent(Box::new(event)));
}
pub fn begin_resume(&mut self, session_id: &str, cwd: &str) {
self.deliver_result(CommandResult::SessionsListed(Ok(acp::ListSessionsResponse::new(vec![
acp::SessionInfo::new(session_id.to_string(), cwd),
]))));
self.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
}
pub fn complete_prompt(&mut self, stop_reason: acp::StopReason) {
self.deliver_result(CommandResult::Prompt(Ok(acp::PromptResponse::new())));
let session_id = self.app.session_id().clone();
let update = acp::SessionUpdate::StateUpdate(acp::StateUpdate::Idle(
acp::IdleStateUpdate::new().stop_reason(stop_reason),
));
self.acp_event(acp::UpdateSessionNotification::new(session_id, update).into());
}
pub fn tick(&mut self, now: Instant) {
self.deliver(Message::Tick(now));
}
pub fn settle_tasks(&mut self) {
self.executor.clear_available();
let mut initial_batch = true;
loop {
let pending = self.executor.take_pending();
if pending.is_empty() {
if let Some(event) = self.executor.next_git_watch_event() {
self.deliver_result(CommandResult::GitWatch(event));
continue;
}
if let Some(event) = self.executor.git_completion.take() {
self.deliver_result(CommandResult::GitDiff(event));
continue;
}
return;
}
if initial_batch {
for command in &pending {
if matches!(command, Command::Agent(_)) {
self.executor.available.push_back(command.clone());
}
}
}
for command in pending {
if let Some(result) = self.executor.complete(command) {
self.deliver(Message::CommandFinished(Box::new(result)));
}
}
initial_batch = false;
}
}
}
impl TestUi<TestBackend> {
pub fn new() -> Self {
Self::with_dimensions(40, 15)
}
pub fn with_dimensions(width: u16, height: u16) -> Self {
TestUiBuilder::new().dimensions(width, height).build()
}
pub fn resize(&mut self, width: u16, height: u16) {
self.terminal.backend_mut().resize(width, height);
}
}
impl Default for TestUi<TestBackend> {
fn default() -> Self {
Self::new()
}
}
pub trait BuffersReader {
fn screen(&self) -> &Buffer;
fn scrollback(&self) -> &Buffer;
}
impl BuffersReader for TestBackend {
fn screen(&self) -> &Buffer {
self.buffer()
}
fn scrollback(&self) -> &Buffer {
self.scrollback()
}
}
impl BuffersReader for CountingBackend {
fn screen(&self) -> &Buffer {
self.buffer()
}
fn scrollback(&self) -> &Buffer {
self.scrollback()
}
}
impl BuffersReader for RecordingBackend {
fn screen(&self) -> &Buffer {
self.buffer()
}
fn scrollback(&self) -> &Buffer {
self.scrollback()
}
}
impl<B> TestUi<B>
where
B: Backend + BuffersReader,
B::Error: std::fmt::Debug,
{
pub fn viewport(&mut self) -> Buffer {
self.draw();
viewport_buffer(&mut self.terminal)
}
pub fn history(&mut self) -> Buffer {
self.draw();
history_buffer(&mut self.terminal)
}
pub fn conversation(&mut self) -> Buffer {
self.draw();
conversation_buffer(&mut self.terminal)
}
pub fn viewport_text(&mut self) -> String {
buffer_text(&self.viewport())
}
pub fn history_text(&mut self) -> String {
buffer_text(&self.history())
}
pub fn conversation_text(&mut self) -> String {
buffer_text(&self.conversation())
}
pub fn viewport_row(&mut self, needle: &str) -> Option<u16> {
row_containing(&self.viewport(), needle)
}
pub fn assert_viewport_contains(&mut self, needle: &str) {
let viewport = self.viewport_text();
assert!(
viewport.contains(needle),
"viewport should contain {needle:?}:
{viewport}"
);
}
pub fn assert_viewport_not_contains(&mut self, needle: &str) {
let viewport = self.viewport_text();
assert!(
!viewport.contains(needle),
"viewport should not contain {needle:?}:
{viewport}"
);
}
pub fn assert_history_contains(&mut self, needle: &str) {
let history = self.history_text();
assert!(
history.contains(needle),
"history should contain {needle:?}:
{history}"
);
}
pub fn assert_history_not_contains(&mut self, needle: &str) {
let history = self.history_text();
assert!(
!history.contains(needle),
"history should not contain {needle:?}:
{history}"
);
}
pub fn assert_conversation_contains(&mut self, needle: &str) {
let conversation = self.conversation_text();
assert!(
conversation.contains(needle),
"conversation should contain {needle:?}:
{conversation}"
);
}
pub fn assert_conversation_not_contains(&mut self, needle: &str) {
let conversation = self.conversation_text();
assert!(
!conversation.contains(needle),
"conversation should not contain {needle:?}:
{conversation}"
);
}
pub fn assert_viewport<S: AsRef<str>>(&mut self, expected: &[S]) {
assert_buffer_eq(&self.viewport(), expected);
}
pub fn assert_history<S: AsRef<str>>(&mut self, expected: &[S]) {
assert_buffer_eq(&self.history(), expected);
}
pub fn assert_conversation<S: AsRef<str>>(&mut self, expected: &[S]) {
assert_buffer_eq(&self.conversation(), expected);
}
}
pub struct TestUiBuilder {
width: u16,
height: u16,
working_dir: Option<PathBuf>,
workspace_access: crate::session::WorkspaceAccess,
capabilities: AetherCapabilities,
prompt_capabilities: acp::PromptCapabilities,
config_options: Vec<acp::SessionConfigOption>,
auth_methods: Vec<acp::AuthMethod>,
session_capabilities: Option<acp::SessionCapabilities>,
settings: UiSettings,
workspace_status: Option<WorkspaceStatus>,
git: FakeGit,
opened_urls: Arc<Mutex<Vec<String>>>,
}
impl Default for TestUiBuilder {
fn default() -> Self {
Self {
width: 40,
height: 15,
working_dir: None,
workspace_access: crate::session::WorkspaceAccess::Local,
capabilities: AetherCapabilities::default(),
prompt_capabilities: acp::PromptCapabilities::new(),
config_options: Vec::new(),
auth_methods: Vec::new(),
session_capabilities: None,
settings: UiSettings::default(),
workspace_status: None,
git: FakeGit::default(),
opened_urls: Arc::new(Mutex::new(Vec::new())),
}
}
}
impl TestUiBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn dimensions(mut self, width: u16, height: u16) -> Self {
self.width = width;
self.height = height;
self
}
pub fn remote_workspace(mut self) -> Self {
self.workspace_access = crate::session::WorkspaceAccess::Remote;
self
}
pub fn working_dir(mut self, working_dir: impl Into<PathBuf>) -> Self {
self.working_dir = Some(working_dir.into());
self
}
pub fn prompt_capabilities(mut self, capabilities: acp::PromptCapabilities) -> Self {
self.prompt_capabilities = capabilities;
self
}
pub fn config_options(mut self, options: Vec<acp::SessionConfigOption>) -> Self {
self.config_options = options;
self
}
pub fn auth_methods(mut self, methods: Vec<acp::AuthMethod>) -> Self {
self.auth_methods = methods;
self
}
pub fn settings(mut self, settings: UiSettings) -> Self {
self.settings = settings;
self
}
pub fn workspace_status(mut self, workspace_status: WorkspaceStatus) -> Self {
self.workspace_status = Some(workspace_status);
self
}
pub fn git(mut self, git: FakeGit) -> Self {
self.git = git;
self
}
pub fn session_capabilities(mut self, capabilities: acp::SessionCapabilities) -> Self {
self.session_capabilities = Some(capabilities);
self
}
pub fn prompt_search(mut self) -> Self {
self.capabilities.prompt_search = true;
self
}
pub fn session_preview(mut self) -> Self {
self.capabilities.session_preview = true;
self
}
pub fn workspace_move(mut self) -> Self {
self.capabilities.workspace_move = true;
self
}
pub fn build(self) -> TestUi {
self.finish()
}
pub fn build_from_session(self, session: crate::session::Session) -> (TestUi, UnboundedReceiver<AcpEvent>) {
let (app, events, _) = App::from_session(session, self.settings.clone());
let mut ui = self.finish_with_app(app);
ui.executor.record(ui.app.take_commands());
(ui, events)
}
fn finish(self) -> TestUi {
let app = App::new(self.app_config());
self.finish_with_app(app)
}
fn finish_with_app(self, app: App) -> TestUi {
TestUi {
app,
renderer: Renderer::new(),
terminal: test_terminal(TestBackend::new(self.width, self.height)),
executor: FakeExecutor::with_git(self.git),
opened_urls: self.opened_urls,
}
}
fn app_config(&self) -> AppConfig {
let session_capabilities = self
.session_capabilities
.clone()
.unwrap_or_else(|| acp::SessionCapabilities::new().meta(Some(self.capabilities.clone().to_meta())));
AppConfig {
initialize_response: acp::InitializeResponse::new(
agent_client_protocol::schema::ProtocolVersion::V2,
acp::Implementation::new("aether", "test"),
)
.capabilities(
acp::AgentCapabilities::new().session(session_capabilities.prompt(self.prompt_capabilities.clone())),
)
.auth_methods(self.auth_methods.clone()),
session_response: acp::NewSessionResponse::new("test-session").config_options(self.config_options.clone()),
workspace_status: self
.workspace_status
.clone()
.unwrap_or_else(|| WorkspaceStatus::new("~/code/demo", Some("main".to_string()))),
working_dir: self.working_dir.clone().unwrap_or_else(|| PathBuf::from(".")),
workspace_access: self.workspace_access,
settings: self.settings.clone(),
browser_opener: {
let opened = self.opened_urls.clone();
Arc::new(move |url: &str| {
opened.lock().unwrap().push(url.to_string());
Ok(())
}) as BrowserOpener
},
clipboard_writer: Arc::new(|_| Ok(())),
}
}
}
fn test_terminal<B: Backend>(backend: B) -> Terminal<B>
where
B::Error: std::fmt::Debug,
{
let height = backend.size().unwrap().height;
Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Inline(inline_viewport_height(height)) })
.unwrap()
}
fn viewport_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
where
B: Backend + BuffersReader,
{
let area = terminal.get_frame().area();
let screen = terminal.backend().screen();
let mut viewport = Buffer::empty(Rect::new(0, 0, area.width, area.height));
for y in 0..area.height {
for x in 0..area.width {
viewport[(x, y)] = screen[(area.x + x, area.y + y)].clone();
}
}
viewport
}
fn history_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
where
B: Backend + BuffersReader,
{
let viewport_area = terminal.get_frame().area();
let screen = terminal.backend().screen();
let scrollback = terminal.backend().scrollback();
let history_height = scrollback.area.height.saturating_add(viewport_area.top());
let mut history = Buffer::empty(Rect::new(0, 0, screen.area.width, history_height));
for y in 0..scrollback.area.height {
for x in 0..scrollback.area.width {
history[(x, y)] = scrollback[(x, y)].clone();
}
}
for y in 0..viewport_area.top() {
for x in 0..screen.area.width {
history[(x, scrollback.area.height + y)] = screen[(x, y)].clone();
}
}
history
}
fn conversation_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
where
B: Backend + BuffersReader,
{
let history = history_buffer(terminal);
let viewport = viewport_buffer(terminal);
let mut conversation =
Buffer::empty(Rect::new(0, 0, viewport.area.width, history.area.height.saturating_add(viewport.area.height)));
for y in 0..history.area.height {
for x in 0..history.area.width {
conversation[(x, y)] = history[(x, y)].clone();
}
}
for y in 0..viewport.area.height {
for x in 0..viewport.area.width {
conversation[(x, history.area.height + y)] = viewport[(x, y)].clone();
}
}
conversation
}
pub fn has_cell(buffer: &Buffer, symbol: &str, predicate: impl Fn(&Cell) -> bool) -> bool {
for y in buffer.area.top()..buffer.area.bottom() {
for x in buffer.area.left()..buffer.area.right() {
if let Some(cell) = buffer.cell((x, y))
&& cell.symbol() == symbol
&& predicate(cell)
{
return true;
}
}
}
false
}
pub fn line_text(line: &ratatui::text::Line<'_>) -> String {
line.spans.iter().map(|span| span.content.as_ref()).collect()
}
pub fn rows_with_background(buffer: &Buffer, background: ratatui::style::Color) -> usize {
(buffer.area.top()..buffer.area.bottom())
.filter(|&y| {
(buffer.area.left()..buffer.area.right())
.any(|x| buffer.cell((x, y)).is_some_and(|cell| cell.bg == background))
})
.count()
}
pub fn row_containing(buffer: &Buffer, needle: &str) -> Option<u16> {
(buffer.area.top()..buffer.area.bottom()).find(|&y| {
let row = (buffer.area.left()..buffer.area.right())
.map(|x| buffer.cell((x, y)).map_or(" ", Cell::symbol))
.collect::<String>();
row.contains(needle)
})
}
pub fn buffer_text(buffer: &Buffer) -> String {
let mut out = String::new();
for y in buffer.area.top()..buffer.area.bottom() {
for x in buffer.area.left()..buffer.area.right() {
out.push_str(buffer.cell((x, y)).map_or(" ", Cell::symbol));
}
out.push('\n');
}
out
}
pub fn assert_buffer_eq<S: AsRef<str>>(buffer: &Buffer, expected: &[S]) {
let actual_lines: Vec<String> =
(buffer.area.top()..buffer.area.bottom()).map(|y| row_text(buffer, y).trim_end().to_string()).collect();
for index in 0..actual_lines.len().max(expected.len()) {
let actual_line = actual_lines.get(index).map_or("", String::as_str);
let expected_line = expected.get(index).map_or("", AsRef::as_ref).trim_end();
assert_eq!(
actual_line,
expected_line,
"line {index} mismatch:\n expected: {expected_line:?}\n actual: {actual_line:?}\n\nfull buffer:\n{}",
actual_lines.join("\n")
);
}
}
pub fn row_text(buffer: &Buffer, y: u16) -> String {
(buffer.area.left()..buffer.area.right()).map(|x| buffer.cell((x, y)).map_or(" ", Cell::symbol)).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamContent {
Prose,
CodeBlock,
Thought,
}
const SEED_PROSE: &str = "\
Examining the request. The module guards its invariants behind a shared handle,
so the fix has to land on the writer side rather than at each call site. I will
rework the boundary so retries cannot observe a torn update, then cover the
regression with a test that fails on the current code.
";
const SEED_CODE_BLOCK: &str = "\
```rust
fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
let mut applied = Vec::with_capacity(incoming.len());
for delta in incoming {
if !state.accepts(&delta) {
continue;
}
state.apply(&delta);
applied.push(delta);
}
state.commit(applied)
}
```
";
const SEED_CLOSING: &str = "\
Done — the writer now retries atomically and the regression test covers the
torn window.
";
const SEED_DIFF_BEFORE: &str = "\
fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
let mut applied = Vec::new();
for delta in incoming {
state.apply(&delta);
}
state.commit(Vec::new())
}
";
const SEED_DIFF_AFTER: &str = "\
fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
let mut applied = Vec::with_capacity(incoming.len());
for delta in incoming {
state.apply(&delta);
applied.push(delta);
}
state.commit(applied)
}
";
pub fn session_update(update: acp::SessionUpdate) -> AcpEvent {
acp::UpdateSessionNotification::new(SessionId::new("test-session"), update).into()
}
pub fn compaction_update(id: &str, status: acp::CompactionStatus) -> AcpEvent {
session_update(acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new(id, status)))
}
pub fn text_chunk(text: &str) -> AcpEvent {
text_chunk_with_id("assistant", text)
}
pub fn text_chunk_with_id(message_id: &str, text: &str) -> AcpEvent {
session_update(acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
acp::ContentBlock::Text(acp::TextContent::new(text)),
message_id.to_string(),
)))
}
pub fn thought_chunk(text: &str) -> AcpEvent {
session_update(acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
acp::ContentBlock::Text(acp::TextContent::new(text)),
"thought",
)))
}
fn seed_bash_tool(id: &str) -> AcpEvent {
let mut tool_call = ToolCallUpdate::new(id.to_string()).title(format!("Run {id}"));
tool_call.name = MaybeUndefined::Value("bash".into());
tool_call.raw_input = MaybeUndefined::Value(json!({ "command": "cargo test --module writer" }));
session_update(SessionUpdate::ToolCallUpdate(tool_call))
}
fn seed_edit_tool(id: &str, turn: usize) -> AcpEvent {
session_update(SessionUpdate::ToolCallUpdate(
ToolCallUpdate::new(id.to_string()).title(format!("Editing src/module_{turn}.rs")),
))
}
fn seed_spawn_tool(id: &str) -> AcpEvent {
let mut tool_call = ToolCallUpdate::new(id.to_string()).title(format!("Spawning sub-agents ({id})"));
tool_call.name = MaybeUndefined::Value("spawn_subagent".into());
session_update(SessionUpdate::ToolCallUpdate(tool_call))
}
pub fn text_diff(path: &str, old: &str, new: &str) -> acp::Diff {
let diff_text = git_patch_from_texts(path, Some(old), Some(new)).expect("valid text diff");
acp::Diff::new(vec![acp::DiffChange::modify(acp::AbsolutePath::new(path))])
.with_patch(diff_text.map(acp::DiffPatch::new))
}
pub fn tool_completed(id: &str) -> AcpEvent {
session_update(acp::SessionUpdate::ToolCallUpdate(
acp::ToolCallUpdate::new(id.to_string()).status(acp::ToolCallStatus::Completed),
))
}
fn seed_tool_diff(id: &str, turn: usize) -> AcpEvent {
let diff = text_diff(&format!("/src/module_{turn}.rs"), SEED_DIFF_BEFORE, SEED_DIFF_AFTER);
session_update(acp::SessionUpdate::ToolCallUpdate(
acp::ToolCallUpdate::new(id.to_string())
.content(vec![acp::ToolCallContent::Diff(diff)])
.status(acp::ToolCallStatus::Completed),
))
}
fn seed_sub_agent(parent: &str, task: &str, agent: &str, event: SubAgentEvent) -> AcpEvent {
AcpEvent::SubAgentProgress(SubAgentProgressParams {
parent_tool_id: parent.to_string(),
task_id: task.to_string(),
agent_name: agent.to_string(),
event,
})
}
fn prose_message(total_bytes: usize) -> String {
let mut message = String::new();
let mut sentence = 0;
while message.len() < total_bytes {
for _ in 0..4 {
let _ =
write!(message, "Sentence {sentence} carries ordinary words so wrapping and parsing do real work. ");
sentence += 1;
}
message.push_str("\n\n");
}
message
}
fn code_block_message(total_bytes: usize) -> String {
let mut message = String::from("```rust\n");
let mut line = 0;
while message.len() < total_bytes {
let _ = writeln!(message, "let value_{line} = state.reconcile(incoming[{line}]).expect(\"delta accepted\");");
line += 1;
}
message.push_str("```\n");
message
}
fn thought_message(total_bytes: usize) -> String {
let mut message = String::new();
let mut step = 0;
while message.len() < total_bytes {
let _ = writeln!(message, "Considering step {step} of the plan before acting on it.");
step += 1;
}
message
}
pub fn chunk_message(message: &str, chunk_bytes: usize) -> Vec<String> {
let mut chunks = Vec::new();
let mut rest = message;
while !rest.is_empty() {
let mut end = rest.len().min(chunk_bytes);
while !rest.is_char_boundary(end) {
end -= 1;
}
chunks.push(rest[..end].to_string());
rest = &rest[end..];
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command::TerminalCommand;
use crate::git_review::{FileStatus, StageState};
#[test]
fn fake_executor_preserves_command_order() {
let mut executor = FakeExecutor::new();
executor
.record([Command::Filesystem(FilesystemCommand::ListThemes), Command::Terminal(TerminalCommand::RingBell)]);
assert!(matches!(executor.take_commands()[..], [Command::Filesystem(_), Command::Terminal(_)]));
}
#[test]
fn fake_filesystem_persists_files_and_settings_in_memory() {
let mut filesystem = FakeFilesystem::new();
let path = PathBuf::from("workspace/src/main.rs");
filesystem.write_file(&path, "fn main() {}");
filesystem.save_settings(UiSettings::default());
assert_eq!(filesystem.read_to_string(&path).as_deref(), Some("fn main() {}"));
assert!(filesystem.contains(Path::new("workspace/src")));
assert!(filesystem.settings().is_some());
}
#[test]
fn fake_git_models_staging_and_discarding_state() {
let mut git = FakeGit::new("workspace");
git.add_file("src/main.rs", "initial\n");
assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Unstaged)));
git.stage("src/main.rs");
assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Staged)));
git.commit("initial").unwrap();
git.write_file("src/main.rs", "changed\n");
assert_eq!(git.status("src/main.rs"), Some((FileStatus::Modified, StageState::Unstaged)));
git.discard("src/main.rs");
assert_eq!(git.file("src/main.rs").and_then(|file| file.contents), Some(b"initial\n".to_vec()));
}
}