mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Merge 8370066556 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+18
@@ -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
|
||||
+6
@@ -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
|
||||
+10
@@ -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
|
||||
+10
@@ -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
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user