use super::*;
use crate::tui::tests::test_app;
#[test]
fn terminal_lifecycle_errors_bypass_sdk_failure_handling() {
let error = sdk_failure_from_running_terminal_error(
super::during_turn::RunningTerminalError::Terminal(anyhow::anyhow!("resume failed")),
)
.unwrap_err();
assert_eq!(error.to_string(), "resume failed");
}
fn failed_turn() -> FailedTurn {
FailedTurn {
input: rho_sdk::UserInput::text("continue the existing goal turn"),
display_user: vec![Message::user_text("continuing active goal")],
display_commit: DisplayCommit::Unsaved,
notification_context: None,
boundary_recovery: Vec::new(),
initial_tool_call: None,
generate_session_title_after_completion: true,
session_title_user: None,
parent_action_required: false,
}
}
#[test]
fn running_boundary_recovery_follows_durable_receipts() {
let first = BoundaryRecovery {
model: "first findings".into(),
display: Message::System("first receipt".into()),
};
let second = BoundaryRecovery {
model: "second findings".into(),
display: Message::System("second receipt".into()),
};
for (commit, retained) in [
(DisplayCommit::Unsaved, vec![first.clone(), second.clone()]),
(
DisplayCommit::Checkpoint(vec![first.display.clone()]),
vec![second.clone()],
),
(DisplayCommit::Complete, Vec::new()),
] {
let mut retry = failed_turn();
retry.boundary_recovery = vec![first.clone(), second.clone()];
retry
.display_user
.extend([first.display.clone(), second.display.clone()]);
retry.display_commit = commit;
retry.prepare_retry();
let mut context = None;
for recovery in &retained {
context = Some(crate::tools::agent::merge_notification_context(
context.as_deref(),
&recovery.model,
));
}
let mut expected = context
.into_iter()
.map(ContentBlock::Text)
.collect::<Vec<_>>();
expected.extend_from_slice(retry.input.blocks());
pretty_assertions::assert_eq!(retry.model_input().unwrap().blocks(), expected);
pretty_assertions::assert_eq!(retry.boundary_recovery, retained);
}
}
#[test]
fn action_retry_keeps_queue_runnable_until_consumed() {
let mut action = failed_turn();
action.parent_action_required = true;
let mut retries = VecDeque::from([action.clone()]);
assert!(FailedTurn::retries_need_parent_action(&retries));
retries.push_front(failed_turn());
assert!(FailedTurn::retries_need_parent_action(&retries));
retries.push_front(failed_turn());
assert!(FailedTurn::retries_need_parent_action(&retries));
retries.pop_front();
retries.pop_front();
pretty_assertions::assert_eq!(retries.pop_front(), Some(action));
assert!(!FailedTurn::retries_need_parent_action(&retries));
retries.push_back(failed_turn());
assert!(!FailedTurn::retries_need_parent_action(&retries));
}
#[test]
fn approval_and_questionnaire_share_one_interaction_slot() {
assert!(interaction_slot_available(false, false));
assert!(!interaction_slot_available(true, false));
assert!(!interaction_slot_available(false, true));
assert!(!interaction_slot_available(true, true));
}
#[test]
fn mixed_interactions_preserve_arrival_order() {
let approval_request = rho_sdk::ApprovalRequest::new(
rho_sdk::CapabilityRequest::read_path(
"/workspace/file",
rho_sdk::PathScope::PrimaryWorkspace,
rho_sdk::CapabilitySource::built_in_tool("read_file"),
),
"approval required",
);
let (approval, _approval_response) = rho_sdk::PendingApproval::new(approval_request);
let question = rho_sdk::HostQuestion::new(
"color",
"Choose one color",
vec![rho_sdk::HostChoice::new("blue", "blue")],
rho_sdk::SelectionMode::One,
)
.unwrap();
let parent_request =
rho_sdk::HostInputRequest::questionnaire("parent", vec![question.clone()]).unwrap();
let child_request = rho_sdk::HostInputRequest::questionnaire("child", vec![question]).unwrap();
let (child_response, _child_response_rx) = tokio::sync::oneshot::channel();
let child = crate::app::subagent_host_input::SubagentHostInputRequest {
run_id: "abc123".into(),
agent_id: "worker".into(),
parent_session_id: rho_sdk::SessionId::new(),
request: child_request,
response: child_response,
};
let mut queue = RunningInteractionQueue::default();
queue.push(QueuedRunningInteraction::Approval(approval));
queue.push(QueuedRunningInteraction::SubagentQuestionnaire(child));
queue.push(QueuedRunningInteraction::ParentQuestionnaire {
call_id: rho_sdk::ToolCallId::from_string("parent-call").unwrap(),
request: parent_request,
});
assert!(matches!(
queue.pop(),
Some(QueuedRunningInteraction::Approval(_))
));
assert!(matches!(
queue.pop(),
Some(QueuedRunningInteraction::SubagentQuestionnaire(_))
));
assert!(matches!(
queue.pop(),
Some(QueuedRunningInteraction::ParentQuestionnaire { .. })
));
assert!(queue.pop().is_none());
}
#[test]
fn retry_retains_only_uncommitted_display_without_changing_model_or_title_state() {
let transcript = crate::display_transcript::DisplayTranscript(vec![
crate::display_transcript::DisplayRow::Notice("worker update".into()),
]);
for (commit, retained) in [
(DisplayCommit::Unsaved, 2),
(DisplayCommit::Complete, 0),
(DisplayCommit::Checkpoint(failed_turn().display_user), 1),
] {
let mut original = failed_turn();
original.display_user.push(transcript.display_message());
original.display_commit = commit.clone();
let mut expected = original.clone();
expected.display_commit = DisplayCommit::Unsaved;
if !matches!(commit, DisplayCommit::Unsaved) {
expected.session_title_user = Some(original.session_title_user_message());
expected
.display_user
.drain(..expected.display_user.len() - retained);
}
let mut retry = original;
retry.prepare_retry();
pretty_assertions::assert_eq!(retry, expected);
retry.display_commit = commit;
retry.prepare_retry();
pretty_assertions::assert_eq!(retry, expected);
}
}
#[test]
fn persisted_display_excludes_notification_context_for_standard_and_command_prompts() {
let cases = [
(
TurnPrompt::standard("model prompt".into(), "visible prompt".into()),
"model prompt",
"visible prompt",
),
(
TurnPrompt::command("expanded command context".into(), "/goal run tests".into()),
"expanded command context",
"/goal run tests",
),
];
for (prompt, expected_model, expected_display) in cases {
let mut failed_turn = FailedTurn::from_prompt(prompt, Vec::new()).unwrap();
failed_turn.attach_notification_context("hidden agent result".into());
let model_input = failed_turn.model_input().unwrap();
assert_eq!(
model_input.blocks(),
&[
ContentBlock::Text("hidden agent result".into()),
ContentBlock::Text(expected_model.into()),
]
);
assert_eq!(
failed_turn.display_user,
vec![Message::user_text(expected_display)]
);
}
}
#[test]
fn retry_attachment_keeps_prior_batches_and_adds_each_new_batch_once() {
let mut failed_turn = FailedTurn::from_prompt(
TurnPrompt::standard("user prompt".into(), "user prompt".into()),
Vec::new(),
)
.unwrap();
failed_turn.attach_notification_context("first batch".into());
let mut retry = failed_turn.clone();
retry.attach_notification_context("second batch".into());
let later_retry_without_new_notifications = retry.clone();
let model_input = later_retry_without_new_notifications.model_input().unwrap();
let ContentBlock::Text(context) = &model_input.blocks()[0] else {
panic!("notification context must be text")
};
assert_eq!(context.matches("first batch").count(), 1);
assert_eq!(context.matches("second batch").count(), 1);
assert!(context.find("first batch") < context.find("second batch"));
assert!(context.len() <= crate::tools::agent::NOTIFICATION_CONTEXT_BYTES);
assert_eq!(
&model_input.blocks()[1..],
&[ContentBlock::Text("user prompt".into())]
);
assert_eq!(
later_retry_without_new_notifications.display_user,
vec![Message::user_text("user prompt")]
);
}
#[test]
fn prompt_assembly_preserves_order_and_separates_document_display_from_model() {
let image = ImageContent {
data: "aW1hZ2U=".into(),
mime_type: "image/png".into(),
};
let document = ChatTextDocument {
name: "report.pdf".into(),
mime: "application/pdf".into(),
body: "extracted body".into(),
truncated: true,
warnings: vec!["some pages had no text".into()],
};
let failed_turn = FailedTurn::from_prompt(
TurnPrompt::standard("model prompt".into(), "display prompt".into()),
vec![
ChatMedia::TextDocument(document),
ChatMedia::Image(image.clone()),
],
)
.unwrap();
assert_eq!(
failed_turn.input.blocks(),
&[
ContentBlock::Text("model prompt".into()),
ContentBlock::Text(
"Attached file: report.pdf (application/pdf)\nExtraction notice: content was truncated to the attachment limit.\nExtraction warnings: some pages had no text\n<<<\nextracted body\n>>>"
.into(),
),
ContentBlock::Image(image.clone()),
]
);
assert_eq!(
failed_turn.display_user,
vec![Message::User(vec![
ContentBlock::Text("display prompt".into()),
ContentBlock::Text("[pdf: report.pdf · 14 chars · truncated]".into()),
ContentBlock::Image(image),
])]
);
}
#[test]
fn failed_turn_keeps_live_partial_assistant_text_before_error() {
let mut app = test_app();
app.begin_provider_turn_ui();
app.turn.set_current_turn_start(Some(0));
app.streams
.assistant_stream
.push_delta("partial assistant before stream failure");
let outcome =
app.finalize_failed_turn("provider stream failed".into(), failed_turn(), Vec::new());
assert_eq!(outcome.kind(), TurnOutcomeKind::Failed);
assert!(matches!(
app.history.entries(),
[Entry::Assistant(assistant), Entry::Error(error)]
if assistant.text == "partial assistant before stream failure"
&& error == "provider stream failed"
));
assert!(!app.is_ui_busy());
assert!(app.streams.assistant_stream.is_empty());
assert_eq!(app.status(), "error");
}
#[tokio::test]
async fn committed_boundary_batch_is_not_restored_after_post_start_failure() {
let mut app = test_app();
let mut agent =
crate::app::interactive_runtime::test_edit_tool_runtime(crate::config::EditTool::default())
.await;
let session_id = agent.session_id().clone();
app.subagent_inbox
.push_notice_for_test(crate::app::subagent_messaging::SubagentNotice {
acknowledged: Default::default(),
run_id: "abc123".into(),
agent_id: "worker".into(),
parent_session_id: session_id.clone(),
message: "schema inspection complete".into(),
delivery: crate::app::subagent_messaging::NoticeDelivery::NextTurn,
});
let delivery = app
.collect_turn_boundary_prompts(&mut agent)
.expect("pending notice should drain");
assert!(!app.subagent_inbox.has_pending_notices());
let mut pending = Some(delivery);
app.abandon_provider_turn_start(&mut agent, &mut pending);
assert!(pending.is_none());
assert!(
app.subagent_inbox.has_pending_notices(),
"failed provider start must restore the drained batch"
);
let delivery = app
.collect_turn_boundary_prompts(&mut agent)
.expect("restored notice should drain again");
let mut pending = Some(delivery);
if let Some(boundary) = pending.take() {
let delivered_notices = boundary.batch.notice_count();
app.subagent_inbox
.commit_delivered_notices(delivered_notices);
}
app.abandon_provider_turn_start(&mut agent, &mut pending);
assert!(
!app.subagent_inbox.has_pending_notices(),
"committed delivery must not return after a post-start failure"
);
assert!(
app.collect_turn_boundary_prompts(&mut agent).is_none(),
"next turn boundary must not repeat the committed delivery"
);
}