Merge 8370066556 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-03-27 15:31:37 -07:00
committed by GitHub
9 changed files with 439 additions and 30 deletions
+4 -8
View File
@@ -700,14 +700,14 @@ async fn maybe_request_mcp_tool_approval(
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
let approval_required = requires_mcp_tool_approval(annotations);
if !approval_required && approval_mode != AppToolApproval::Prompt {
return None;
}
let mut monitor_reason = None;
let auto_approved_by_policy = approval_mode == AppToolApproval::Approve;
if auto_approved_by_policy {
if !approval_required {
return None;
}
match maybe_monitor_auto_approved_mcp_tool_call(
sess,
turn_context,
@@ -729,10 +729,6 @@ async fn maybe_request_mcp_tool_approval(
}
}
if approval_mode == AppToolApproval::Auto && !approval_required {
return None;
}
let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode);
let persistent_approval_key =
persistent_mcp_tool_approval_key(invocation, metadata, approval_mode);
+112
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::codex::make_session_and_context;
use crate::codex::make_session_and_context_with_rx;
use crate::config::ApprovalsReviewer;
use crate::config::ConfigBuilder;
use crate::config::ConfigToml;
@@ -9,6 +10,7 @@ use crate::config::types::AppToolsConfig;
use crate::config::types::AppsConfigToml;
use crate::config::types::McpServerConfig;
use crate::config::types::McpServerToolConfig;
use crate::state::ActiveTurn;
use codex_config::CONFIG_TOML_FILE;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -1190,6 +1192,116 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
assert_eq!(decision, None);
}
#[tokio::test]
async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let (mut session, mut turn_context) = make_session_and_context().await;
turn_context
.approval_policy
.set(AskForApproval::OnRequest)
.expect("test setup should allow updating approval policy");
let mut config = (*turn_context.config).clone();
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent;
let config = Arc::new(config);
let models_manager = Arc::new(crate::test_support::models_manager_with_provider(
config.codex_home.clone(),
Arc::clone(&session.services.auth_manager),
config.model_provider.clone(),
));
session.services.models_manager = models_manager;
turn_context.config = Arc::clone(&config);
turn_context.provider = config.model_provider.clone();
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let invocation = McpInvocation {
server: "custom_server".to_string(),
tool: "read_only_tool".to_string(),
arguments: None,
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(Some(true), None, None)),
connector_id: None,
connector_name: None,
connector_description: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
codex_apps_meta: None,
};
let decision = maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-guardian",
&invocation,
Some(&metadata),
AppToolApproval::Auto,
)
.await;
assert_eq!(decision, None);
}
#[tokio::test]
async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval() {
let (session, turn_context, _rx_event) = make_session_and_context_with_rx().await;
{
let mut active_turn = session.active_turn.lock().await;
*active_turn = Some(ActiveTurn::default());
}
let invocation = McpInvocation {
server: "custom_server".to_string(),
tool: "read_only_tool".to_string(),
arguments: None,
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(Some(true), None, None)),
connector_id: None,
connector_name: None,
connector_description: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
codex_apps_meta: None,
};
let mut approval_task = {
let session = Arc::clone(&session);
let turn_context = Arc::clone(&turn_context);
tokio::spawn(async move {
maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-prompt",
&invocation,
Some(&metadata),
AppToolApproval::Prompt,
)
.await
})
};
assert!(
tokio::time::timeout(std::time::Duration::from_millis(200), &mut approval_task)
.await
.is_err(),
"prompt mode should wait for approval instead of auto-allowing"
);
approval_task.abort();
}
#[tokio::test]
async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
use wiremock::Mock;
+100 -1
View File
@@ -518,7 +518,12 @@ struct ThreadEventStore {
impl ThreadEventStore {
fn event_survives_session_refresh(event: &ThreadBufferedEvent) -> bool {
matches!(event, ThreadBufferedEvent::Request(_))
matches!(
event,
ThreadBufferedEvent::Request(_)
| ThreadBufferedEvent::Notification(ServerNotification::HookStarted(_))
| ThreadBufferedEvent::Notification(ServerNotification::HookCompleted(_))
)
}
fn new(capacity: usize) -> Self {
@@ -5727,6 +5732,16 @@ mod tests {
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::HookCompletedNotification;
use codex_app_server_protocol::HookEventName as AppServerHookEventName;
use codex_app_server_protocol::HookExecutionMode as AppServerHookExecutionMode;
use codex_app_server_protocol::HookHandlerType as AppServerHookHandlerType;
use codex_app_server_protocol::HookOutputEntry as AppServerHookOutputEntry;
use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind;
use codex_app_server_protocol::HookRunStatus as AppServerHookRunStatus;
use codex_app_server_protocol::HookRunSummary as AppServerHookRunSummary;
use codex_app_server_protocol::HookScope as AppServerHookScope;
use codex_app_server_protocol::HookStartedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext;
use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol;
@@ -8336,6 +8351,59 @@ guardian_approval = true
})
}
fn hook_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification {
ServerNotification::HookStarted(HookStartedNotification {
thread_id: thread_id.to_string(),
turn_id: Some(turn_id.to_string()),
run: AppServerHookRunSummary {
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
event_name: AppServerHookEventName::UserPromptSubmit,
handler_type: AppServerHookHandlerType::Command,
execution_mode: AppServerHookExecutionMode::Sync,
scope: AppServerHookScope::Turn,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: AppServerHookRunStatus::Running,
status_message: Some("checking go-workflow input policy".to_string()),
started_at: 1,
completed_at: None,
duration_ms: None,
entries: Vec::new(),
},
})
}
fn hook_completed_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification {
ServerNotification::HookCompleted(HookCompletedNotification {
thread_id: thread_id.to_string(),
turn_id: Some(turn_id.to_string()),
run: AppServerHookRunSummary {
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
event_name: AppServerHookEventName::UserPromptSubmit,
handler_type: AppServerHookHandlerType::Command,
execution_mode: AppServerHookExecutionMode::Sync,
scope: AppServerHookScope::Turn,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: AppServerHookRunStatus::Stopped,
status_message: Some("checking go-workflow input policy".to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
entries: vec![
AppServerHookOutputEntry {
kind: AppServerHookOutputEntryKind::Warning,
text: "go-workflow must start from PlanMode".to_string(),
},
AppServerHookOutputEntry {
kind: AppServerHookOutputEntryKind::Stop,
text: "prompt blocked".to_string(),
},
],
},
})
}
fn agent_message_delta_notification(
thread_id: ThreadId,
turn_id: &str,
@@ -8452,6 +8520,37 @@ guardian_approval = true
assert_eq!(store.has_pending_thread_approvals(), false);
}
#[test]
fn thread_event_store_rebase_preserves_hook_notifications() {
let thread_id = ThreadId::new();
let mut store = ThreadEventStore::new(8);
store.push_notification(hook_started_notification(thread_id, "turn-hook"));
store.push_notification(hook_completed_notification(thread_id, "turn-hook"));
store.rebase_buffer_after_session_refresh();
let snapshot = store.snapshot();
let hook_notifications = snapshot
.events
.into_iter()
.map(|event| match event {
ThreadBufferedEvent::Notification(notification) => {
serde_json::to_value(notification).expect("hook notification should serialize")
}
other => panic!("expected buffered hook notification, saw: {other:?}"),
})
.collect::<Vec<_>>();
assert_eq!(
hook_notifications,
vec![
serde_json::to_value(hook_started_notification(thread_id, "turn-hook"))
.expect("hook notification should serialize"),
serde_json::to_value(hook_completed_notification(thread_id, "turn-hook"))
.expect("hook notification should serialize"),
]
);
}
fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {
let mut seen = Vec::new();
while let Ok(op) = op_rx.try_recv() {
+73 -16
View File
@@ -770,6 +770,9 @@ pub(crate) struct ChatWidget {
plan_stream_controller: Option<PlanStreamController>,
// Latest completed user-visible Codex output that `/copy` should place on the clipboard.
last_copyable_output: Option<String>,
// Final-answer agent message observed during the active turn. App-server turn completion
// notifications do not repeat this payload, so we promote it when the turn completes.
pending_turn_copyable_output: Option<String>,
running_commands: HashMap<String, RunningCommand>,
collab_agent_metadata: HashMap<ThreadId, CollabAgentMetadata>,
pending_collab_spawn_requests: HashMap<String, multi_agents::SpawnRequestSummary>,
@@ -1275,6 +1278,57 @@ where
.and_then(|value| serde_json::from_value(value).ok())
}
fn hook_output_entry_from_notification(
entry: codex_app_server_protocol::HookOutputEntry,
) -> codex_protocol::protocol::HookOutputEntry {
codex_protocol::protocol::HookOutputEntry {
kind: entry.kind.to_core(),
text: entry.text,
}
}
fn hook_run_summary_from_notification(
run: codex_app_server_protocol::HookRunSummary,
) -> codex_protocol::protocol::HookRunSummary {
codex_protocol::protocol::HookRunSummary {
id: run.id,
event_name: run.event_name.to_core(),
handler_type: run.handler_type.to_core(),
execution_mode: run.execution_mode.to_core(),
scope: run.scope.to_core(),
source_path: run.source_path,
display_order: run.display_order,
status: run.status.to_core(),
status_message: run.status_message,
started_at: run.started_at,
completed_at: run.completed_at,
duration_ms: run.duration_ms,
entries: run
.entries
.into_iter()
.map(hook_output_entry_from_notification)
.collect(),
}
}
fn hook_started_event_from_notification(
notification: codex_app_server_protocol::HookStartedNotification,
) -> codex_protocol::protocol::HookStartedEvent {
codex_protocol::protocol::HookStartedEvent {
turn_id: notification.turn_id,
run: hook_run_summary_from_notification(notification.run),
}
}
fn hook_completed_event_from_notification(
notification: codex_app_server_protocol::HookCompletedNotification,
) -> codex_protocol::protocol::HookCompletedEvent {
codex_protocol::protocol::HookCompletedEvent {
turn_id: notification.turn_id,
run: hook_run_summary_from_notification(notification.run),
}
}
fn app_server_request_id_to_mcp_request_id(
request_id: &codex_app_server_protocol::RequestId,
) -> codex_protocol::mcp::RequestId {
@@ -1913,6 +1967,7 @@ impl ChatWidget {
self.config.approvals_reviewer = event.approvals_reviewer;
self.status_line_project_root_name_cache = None;
self.last_copyable_output = None;
self.pending_turn_copyable_output = None;
let forked_from_id = event.forked_from_id;
let model_for_header = event.model.clone();
self.session_header.set_model(&model_for_header);
@@ -2234,6 +2289,7 @@ impl ChatWidget {
self.plan_item_active = false;
self.adaptive_chunking.reset();
self.plan_stream_controller = None;
self.pending_turn_copyable_output = None;
self.turn_runtime_metrics = RuntimeMetricsSummary::default();
self.session_telemetry.reset_runtime_metrics();
self.bottom_pane.clear_quit_shortcut_hint();
@@ -2253,9 +2309,10 @@ impl ChatWidget {
fn on_task_complete(&mut self, last_agent_message: Option<String>, from_replay: bool) {
self.submit_pending_steers_after_interrupt = false;
if let Some(message) = last_agent_message.as_ref()
&& !message.trim().is_empty()
{
let copyable_turn_output = last_agent_message
.filter(|message| !message.trim().is_empty())
.or_else(|| self.pending_turn_copyable_output.take());
if let Some(message) = copyable_turn_output.as_ref() {
self.last_copyable_output = Some(message.clone());
}
// If a stream is currently active, finalize it.
@@ -2317,7 +2374,7 @@ impl ChatWidget {
self.maybe_send_next_queued_input();
// Emit a notification when the turn completes (suppressed if focused).
self.notify(Notification::AgentTurnComplete {
response: last_agent_message.unwrap_or_default(),
response: copyable_turn_output.unwrap_or_default(),
});
self.maybe_show_pending_rate_limit_prompt();
@@ -2663,6 +2720,7 @@ impl ChatWidget {
self.adaptive_chunking.reset();
self.stream_controller = None;
self.plan_stream_controller = None;
self.pending_turn_copyable_output = None;
self.pending_status_indicator_restore = false;
self.request_status_line_branch_refresh();
self.maybe_show_pending_rate_limit_prompt();
@@ -3886,6 +3944,12 @@ impl ChatWidget {
self.finalize_completed_assistant_message(
(!message.is_empty()).then_some(message.as_str()),
);
if self.agent_turn_running
&& !message.is_empty()
&& matches!(item.phase, Some(MessagePhase::FinalAnswer) | None)
{
self.pending_turn_copyable_output = Some(message.clone());
}
self.pending_status_indicator_restore = match item.phase {
// Models that don't support preambles only output AgentMessageItems on turn completion.
Some(MessagePhase::FinalAnswer) | None => false,
@@ -4487,6 +4551,7 @@ impl ChatWidget {
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
pending_turn_copyable_output: None,
connectors_cache: ConnectorsCacheState::default(),
connectors_partial_snapshot: None,
connectors_prefetch_in_flight: false,
@@ -6210,20 +6275,10 @@ impl ChatWidget {
})
}
ServerNotification::HookStarted(notification) => {
if let Some(run) = convert_via_json(notification.run) {
self.on_hook_started(codex_protocol::protocol::HookStartedEvent {
turn_id: notification.turn_id,
run,
});
}
self.on_hook_started(hook_started_event_from_notification(notification));
}
ServerNotification::HookCompleted(notification) => {
if let Some(run) = convert_via_json(notification.run) {
self.on_hook_completed(codex_protocol::protocol::HookCompletedEvent {
turn_id: notification.turn_id,
run,
});
}
self.on_hook_completed(hook_completed_event_from_notification(notification));
}
ServerNotification::Error(notification) => {
if notification.will_retry {
@@ -6366,6 +6421,7 @@ impl ChatWidget {
pub(crate) fn handle_thread_rolled_back(&mut self) {
self.last_copyable_output = None;
self.pending_turn_copyable_output = None;
}
fn on_mcp_server_elicitation_request(
@@ -6839,6 +6895,7 @@ impl ChatWidget {
// transcript cells, but we do not maintain rollback-aware raw-markdown history yet,
// so keeping the previous cache can return content that was just removed.
self.last_copyable_output = None;
self.pending_turn_copyable_output = None;
if from_replay {
self.app_event_tx.send(AppEvent::ApplyThreadRollback {
num_turns: rollback.num_turns,
@@ -0,0 +1,18 @@
---
source: tui_app_server/src/chatwidget/tests.rs
assertion_line: 9607
expression: popup
---
Update Model Permissions
› 1. Default Codex can read and edit files in the current
workspace, and run commands. Approval is required to
access the internet or edit other files.
2. Guardian Approvals Same workspace-write permissions as Default, but
eligible `on-request` approvals are routed through
the guardian reviewer subagent.
3. Full Access Codex can edit files outside this workspace and
access the internet without asking for approval.
Exercise caution when using.
Press enter to confirm or esc to go back
@@ -0,0 +1,6 @@
---
source: tui_app_server/src/chatwidget/tests.rs
assertion_line: 10252
expression: "lines_to_single_string(&cells[0])"
---
• Permissions updated to Guardian Approvals
@@ -0,0 +1,10 @@
---
source: tui_app_server/src/chatwidget/tests.rs
assertion_line: 12789
expression: combined
---
• Running UserPromptSubmit hook: checking go-workflow input policy
UserPromptSubmit hook (stopped)
warning: go-workflow must start from PlanMode
stop: prompt blocked
@@ -0,0 +1,10 @@
---
source: tui_app_server/src/chatwidget/tests.rs
assertion_line: 12789
expression: combined
---
• Running UserPromptSubmit hook: checking go-workflow input policy
UserPromptSubmit hook (stopped)
warning: go-workflow must start from PlanMode
stop: prompt blocked
+106 -5
View File
@@ -35,6 +35,16 @@ use codex_app_server_protocol::FileUpdateChange;
use codex_app_server_protocol::GuardianApprovalReview;
use codex_app_server_protocol::GuardianApprovalReviewStatus;
use codex_app_server_protocol::GuardianRiskLevel as AppServerGuardianRiskLevel;
use codex_app_server_protocol::HookCompletedNotification as AppServerHookCompletedNotification;
use codex_app_server_protocol::HookEventName as AppServerHookEventName;
use codex_app_server_protocol::HookExecutionMode as AppServerHookExecutionMode;
use codex_app_server_protocol::HookHandlerType as AppServerHookHandlerType;
use codex_app_server_protocol::HookOutputEntry as AppServerHookOutputEntry;
use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind;
use codex_app_server_protocol::HookRunStatus as AppServerHookRunStatus;
use codex_app_server_protocol::HookRunSummary as AppServerHookRunSummary;
use codex_app_server_protocol::HookScope as AppServerHookScope;
use codex_app_server_protocol::HookStartedNotification as AppServerHookStartedNotification;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification;
use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification;
@@ -2047,6 +2057,7 @@ async fn make_chatwidget_manual(
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
last_copyable_output: None,
pending_turn_copyable_output: None,
running_commands: HashMap::new(),
collab_agent_metadata: HashMap::new(),
pending_collab_spawn_requests: HashMap::new(),
@@ -7168,10 +7179,17 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_
}
#[tokio::test]
async fn slash_copy_is_unavailable_when_legacy_agent_message_item_is_not_repeated_on_turn_complete()
{
async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
complete_assistant_message(&mut chat, "msg-1", "Legacy item final message", None);
let _ = drain_insert_history(&mut rx);
chat.handle_codex_event(Event {
@@ -7189,10 +7207,14 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_item_is_not_repeate
assert_eq!(cells.len(), 1, "expected one info message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains(
!rendered.contains(
"`/copy` is unavailable before the first Codex output or right after a rollback."
),
"expected unavailable message, got {rendered:?}"
"expected copy state to be available, got {rendered:?}"
);
assert_eq!(
chat.last_copyable_output,
Some("Legacy item final message".to_string())
);
}
@@ -7200,11 +7222,21 @@ async fn slash_copy_is_unavailable_when_legacy_agent_message_item_is_not_repeate
async fn slash_copy_does_not_return_stale_output_after_thread_rollback() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
complete_assistant_message(&mut chat, "msg-1", "Reply that will be rolled back", None);
let _ = drain_insert_history(&mut rx);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Reply that will be rolled back".to_string()),
last_agent_message: None,
}),
});
let _ = drain_insert_history(&mut rx);
@@ -12713,6 +12745,75 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() {
assert_snapshot!(combined);
}
#[tokio::test]
async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_server_notification(
ServerNotification::HookStarted(AppServerHookStartedNotification {
thread_id: ThreadId::new().to_string(),
turn_id: Some("turn-1".to_string()),
run: AppServerHookRunSummary {
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
event_name: AppServerHookEventName::UserPromptSubmit,
handler_type: AppServerHookHandlerType::Command,
execution_mode: AppServerHookExecutionMode::Sync,
scope: AppServerHookScope::Turn,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: AppServerHookRunStatus::Running,
status_message: Some("checking go-workflow input policy".to_string()),
started_at: 1,
completed_at: None,
duration_ms: None,
entries: Vec::new(),
},
}),
None,
);
chat.handle_server_notification(
ServerNotification::HookCompleted(AppServerHookCompletedNotification {
thread_id: ThreadId::new().to_string(),
turn_id: Some("turn-1".to_string()),
run: AppServerHookRunSummary {
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
event_name: AppServerHookEventName::UserPromptSubmit,
handler_type: AppServerHookHandlerType::Command,
execution_mode: AppServerHookExecutionMode::Sync,
scope: AppServerHookScope::Turn,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: AppServerHookRunStatus::Stopped,
status_message: Some("checking go-workflow input policy".to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
entries: vec![
AppServerHookOutputEntry {
kind: AppServerHookOutputEntryKind::Warning,
text: "go-workflow must start from PlanMode".to_string(),
},
AppServerHookOutputEntry {
kind: AppServerHookOutputEntryKind::Stop,
text: "prompt blocked".to_string(),
},
],
},
}),
None,
);
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_snapshot!(
"user_prompt_submit_app_server_hook_notifications_render_snapshot",
combined
);
}
#[tokio::test]
async fn pre_tool_use_hook_events_render_snapshot() {
assert_hook_events_snapshot(