Compare commits

..

1 Commits

Author SHA1 Message Date
jif-oai
25fefe8735 feat: single app-server bootstrap in TUI 2026-04-02 18:18:15 +02:00
18 changed files with 979 additions and 1058 deletions

View File

@@ -146,12 +146,6 @@ if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then
post_config_bazel_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}")
fi
if [[ -n "${CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR:-}" ]]; then
post_config_bazel_args+=(
"--execution_log_compact_file=${CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR}/execution-log-${bazel_args[0]}-${GITHUB_JOB:-local}-$$.zst"
)
fi
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
windows_action_env_vars=(
INCLUDE

View File

@@ -63,12 +63,6 @@ jobs:
shell: bash
run: ./scripts/check-module-bazel-lock.sh
- name: Set up Bazel execution logs
shell: bash
run: |
mkdir -p "${RUNNER_TEMP}/bazel-execution-logs"
echo "CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR=${RUNNER_TEMP}/bazel-execution-logs" >> "${GITHUB_ENV}"
- name: bazel test //...
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
@@ -93,15 +87,6 @@ jobs:
-- \
"${bazel_targets[@]}"
- name: Upload Bazel execution logs
if: always() && !cancelled()
continue-on-error: true
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: bazel-execution-logs-test-${{ matrix.target }}
path: ${{ runner.temp }}/bazel-execution-logs
if-no-files-found: ignore
# Save bazel repository cache explicitly; make non-fatal so cache uploading
# never fails the overall job. Only save when key wasn't hit.
- name: Save bazel repository cache
@@ -141,12 +126,6 @@ jobs:
with:
target: ${{ matrix.target }}
- name: Set up Bazel execution logs
shell: bash
run: |
mkdir -p "${RUNNER_TEMP}/bazel-execution-logs"
echo "CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR=${RUNNER_TEMP}/bazel-execution-logs" >> "${GITHUB_ENV}"
- name: bazel build --config=clippy //codex-rs/...
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
@@ -164,15 +143,6 @@ jobs:
//codex-rs/... \
-//codex-rs/v8-poc:all
- name: Upload Bazel execution logs
if: always() && !cancelled()
continue-on-error: true
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: bazel-execution-logs-clippy-${{ matrix.target }}
path: ${{ runner.temp }}/bazel-execution-logs
if-no-files-found: ignore
# Save bazel repository cache explicitly; make non-fatal so cache uploading
# never fails the overall job. Only save when key wasn't hit.
- name: Save bazel repository cache

View File

@@ -71,7 +71,6 @@ single_version_override(
patch_strip = 1,
patches = [
"//patches:rules_rs_windows_gnullvm_exec.patch",
"//patches:rules_rs_delete_git_worktree_pointer.patch",
],
version = "0.0.43",
)

View File

@@ -256,5 +256,60 @@ pub enum Color {
}
#[cfg(test)]
#[path = "cli_tests.rs"]
mod tests;
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn resume_parses_prompt_after_global_flags() {
const PROMPT: &str = "echo resume-with-global-flags-after-subcommand";
let cli = Cli::parse_from([
"codex-exec",
"resume",
"--last",
"--json",
"--model",
"gpt-5.2-codex",
"--dangerously-bypass-approvals-and-sandbox",
"--skip-git-repo-check",
"--ephemeral",
PROMPT,
]);
assert!(cli.ephemeral);
let Some(Command::Resume(args)) = cli.command else {
panic!("expected resume command");
};
let effective_prompt = args.prompt.clone().or_else(|| {
if args.last {
args.session_id.clone()
} else {
None
}
});
assert_eq!(effective_prompt.as_deref(), Some(PROMPT));
}
#[test]
fn resume_accepts_output_last_message_flag_after_subcommand() {
const PROMPT: &str = "echo resume-with-output-file";
let cli = Cli::parse_from([
"codex-exec",
"resume",
"session-123",
"-o",
"/tmp/resume-output.md",
PROMPT,
]);
assert_eq!(
cli.last_message_file,
Some(PathBuf::from("/tmp/resume-output.md"))
);
let Some(Command::Resume(args)) = cli.command else {
panic!("expected resume command");
};
assert_eq!(args.session_id.as_deref(), Some("session-123"));
assert_eq!(args.prompt.as_deref(), Some(PROMPT));
}
}

View File

@@ -1,55 +0,0 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn resume_parses_prompt_after_global_flags() {
const PROMPT: &str = "echo resume-with-global-flags-after-subcommand";
let cli = Cli::parse_from([
"codex-exec",
"resume",
"--last",
"--json",
"--model",
"gpt-5.2-codex",
"--dangerously-bypass-approvals-and-sandbox",
"--skip-git-repo-check",
"--ephemeral",
PROMPT,
]);
assert!(cli.ephemeral);
let Some(Command::Resume(args)) = cli.command else {
panic!("expected resume command");
};
let effective_prompt = args.prompt.clone().or_else(|| {
if args.last {
args.session_id.clone()
} else {
None
}
});
assert_eq!(effective_prompt.as_deref(), Some(PROMPT));
}
#[test]
fn resume_accepts_output_last_message_flag_after_subcommand() {
const PROMPT: &str = "echo resume-with-output-file";
let cli = Cli::parse_from([
"codex-exec",
"resume",
"session-123",
"-o",
"/tmp/resume-output.md",
PROMPT,
]);
assert_eq!(
cli.last_message_file,
Some(PathBuf::from("/tmp/resume-output.md"))
);
let Some(Command::Resume(args)) = cli.command else {
panic!("expected resume command");
};
assert_eq!(args.session_id.as_deref(), Some("session-123"));
assert_eq!(args.prompt.as_deref(), Some(PROMPT));
}

View File

@@ -564,5 +564,350 @@ fn should_print_final_message_to_tty(
}
#[cfg(test)]
#[path = "event_processor_with_human_output_tests.rs"]
mod tests;
mod tests {
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use owo_colors::Style;
use super::EventProcessorWithHumanOutput;
use super::final_message_from_turn_items;
use super::reasoning_text;
use super::should_print_final_message_to_stdout;
use super::should_print_final_message_to_tty;
use crate::event_processor::EventProcessor;
use codex_app_server_protocol::ServerNotification;
#[test]
fn suppresses_final_stdout_message_when_both_streams_are_terminals() {
assert!(!should_print_final_message_to_stdout(
Some("hello"),
/*stdout_is_terminal*/ true,
/*stderr_is_terminal*/ true
));
}
#[test]
fn prints_final_stdout_message_when_stdout_is_not_terminal() {
assert!(should_print_final_message_to_stdout(
Some("hello"),
/*stdout_is_terminal*/ false,
/*stderr_is_terminal*/ true
));
}
#[test]
fn prints_final_stdout_message_when_stderr_is_not_terminal() {
assert!(should_print_final_message_to_stdout(
Some("hello"),
/*stdout_is_terminal*/ true,
/*stderr_is_terminal*/ false
));
}
#[test]
fn suppresses_final_stdout_message_when_missing() {
assert!(!should_print_final_message_to_stdout(
/*final_message*/ None, /*stdout_is_terminal*/ false,
/*stderr_is_terminal*/ false
));
}
#[test]
fn prints_final_tty_message_when_not_yet_rendered() {
assert!(should_print_final_message_to_tty(
Some("hello"),
/*final_message_rendered*/ false,
/*stdout_is_terminal*/ true,
/*stderr_is_terminal*/ true
));
}
#[test]
fn suppresses_final_tty_message_when_already_rendered() {
assert!(!should_print_final_message_to_tty(
Some("hello"),
/*final_message_rendered*/ true,
/*stdout_is_terminal*/ true,
/*stderr_is_terminal*/ true
));
}
#[test]
fn reasoning_text_prefers_summary_when_raw_reasoning_is_hidden() {
let text = reasoning_text(
&["summary".to_string()],
&["raw".to_string()],
/*show_raw_agent_reasoning*/ false,
);
assert_eq!(text.as_deref(), Some("summary"));
}
#[test]
fn reasoning_text_uses_raw_content_when_enabled() {
let text = reasoning_text(
&["summary".to_string()],
&["raw".to_string()],
/*show_raw_agent_reasoning*/ true,
);
assert_eq!(text.as_deref(), Some("raw"));
}
#[test]
fn final_message_from_turn_items_uses_latest_agent_message() {
let message = final_message_from_turn_items(&[
ThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "first".to_string(),
phase: None,
memory_citation: None,
},
ThreadItem::Plan {
id: "plan-1".to_string(),
text: "plan".to_string(),
},
ThreadItem::AgentMessage {
id: "msg-2".to_string(),
text: "second".to_string(),
phase: None,
memory_citation: None,
},
]);
assert_eq!(message.as_deref(), Some("second"));
}
#[test]
fn final_message_from_turn_items_falls_back_to_latest_plan() {
let message = final_message_from_turn_items(&[
ThreadItem::Reasoning {
id: "reasoning-1".to_string(),
summary: vec!["inspect".to_string()],
content: Vec::new(),
},
ThreadItem::Plan {
id: "plan-1".to_string(),
text: "first plan".to_string(),
},
ThreadItem::Plan {
id: "plan-2".to_string(),
text: "final plan".to_string(),
},
]);
assert_eq!(message.as_deref(), Some("final plan"));
}
#[test]
fn turn_completed_recovers_final_message_from_turn_items() {
let mut processor = EventProcessorWithHumanOutput {
bold: Style::new(),
cyan: Style::new(),
dimmed: Style::new(),
green: Style::new(),
italic: Style::new(),
magenta: Style::new(),
red: Style::new(),
yellow: Style::new(),
show_agent_reasoning: true,
show_raw_agent_reasoning: false,
last_message_path: None,
final_message: None,
final_message_rendered: false,
emit_final_message_on_shutdown: false,
last_total_token_usage: None,
};
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: Turn {
id: "turn-1".to_string(),
items: vec![ThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
memory_citation: None,
}],
status: TurnStatus::Completed,
error: None,
},
},
));
assert_eq!(
status,
crate::event_processor::CodexStatus::InitiateShutdown
);
assert_eq!(processor.final_message.as_deref(), Some("final answer"));
}
#[test]
fn turn_completed_overwrites_stale_final_message_from_turn_items() {
let mut processor = EventProcessorWithHumanOutput {
bold: Style::new(),
cyan: Style::new(),
dimmed: Style::new(),
green: Style::new(),
italic: Style::new(),
magenta: Style::new(),
red: Style::new(),
yellow: Style::new(),
show_agent_reasoning: true,
show_raw_agent_reasoning: false,
last_message_path: None,
final_message: Some("stale answer".to_string()),
final_message_rendered: true,
emit_final_message_on_shutdown: false,
last_total_token_usage: None,
};
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: Turn {
id: "turn-1".to_string(),
items: vec![ThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
memory_citation: None,
}],
status: TurnStatus::Completed,
error: None,
},
},
));
assert_eq!(
status,
crate::event_processor::CodexStatus::InitiateShutdown
);
assert_eq!(processor.final_message.as_deref(), Some("final answer"));
assert!(!processor.final_message_rendered);
}
#[test]
fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() {
let mut processor = EventProcessorWithHumanOutput {
bold: Style::new(),
cyan: Style::new(),
dimmed: Style::new(),
green: Style::new(),
italic: Style::new(),
magenta: Style::new(),
red: Style::new(),
yellow: Style::new(),
show_agent_reasoning: true,
show_raw_agent_reasoning: false,
last_message_path: None,
final_message: Some("streamed answer".to_string()),
final_message_rendered: false,
emit_final_message_on_shutdown: false,
last_total_token_usage: None,
};
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: Turn {
id: "turn-1".to_string(),
items: Vec::new(),
status: TurnStatus::Completed,
error: None,
},
},
));
assert_eq!(
status,
crate::event_processor::CodexStatus::InitiateShutdown
);
assert_eq!(processor.final_message.as_deref(), Some("streamed answer"));
assert!(processor.emit_final_message_on_shutdown);
}
#[test]
fn turn_failed_clears_stale_final_message() {
let mut processor = EventProcessorWithHumanOutput {
bold: Style::new(),
cyan: Style::new(),
dimmed: Style::new(),
green: Style::new(),
italic: Style::new(),
magenta: Style::new(),
red: Style::new(),
yellow: Style::new(),
show_agent_reasoning: true,
show_raw_agent_reasoning: false,
last_message_path: None,
final_message: Some("partial answer".to_string()),
final_message_rendered: true,
emit_final_message_on_shutdown: true,
last_total_token_usage: None,
};
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: Turn {
id: "turn-1".to_string(),
items: Vec::new(),
status: TurnStatus::Failed,
error: None,
},
},
));
assert_eq!(
status,
crate::event_processor::CodexStatus::InitiateShutdown
);
assert_eq!(processor.final_message, None);
assert!(!processor.final_message_rendered);
assert!(!processor.emit_final_message_on_shutdown);
}
#[test]
fn turn_interrupted_clears_stale_final_message() {
let mut processor = EventProcessorWithHumanOutput {
bold: Style::new(),
cyan: Style::new(),
dimmed: Style::new(),
green: Style::new(),
italic: Style::new(),
magenta: Style::new(),
red: Style::new(),
yellow: Style::new(),
show_agent_reasoning: true,
show_raw_agent_reasoning: false,
last_message_path: None,
final_message: Some("partial answer".to_string()),
final_message_rendered: true,
emit_final_message_on_shutdown: true,
last_total_token_usage: None,
};
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: Turn {
id: "turn-1".to_string(),
items: Vec::new(),
status: TurnStatus::Interrupted,
error: None,
},
},
));
assert_eq!(
status,
crate::event_processor::CodexStatus::InitiateShutdown
);
assert_eq!(processor.final_message, None);
assert!(!processor.final_message_rendered);
assert!(!processor.emit_final_message_on_shutdown);
}
}

View File

@@ -1,346 +0,0 @@
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use owo_colors::Style;
use pretty_assertions::assert_eq;
use super::EventProcessorWithHumanOutput;
use super::final_message_from_turn_items;
use super::reasoning_text;
use super::should_print_final_message_to_stdout;
use super::should_print_final_message_to_tty;
use crate::event_processor::EventProcessor;
#[test]
fn suppresses_final_stdout_message_when_both_streams_are_terminals() {
assert!(!should_print_final_message_to_stdout(
Some("hello"),
/*stdout_is_terminal*/ true,
/*stderr_is_terminal*/ true
));
}
#[test]
fn prints_final_stdout_message_when_stdout_is_not_terminal() {
assert!(should_print_final_message_to_stdout(
Some("hello"),
/*stdout_is_terminal*/ false,
/*stderr_is_terminal*/ true
));
}
#[test]
fn prints_final_stdout_message_when_stderr_is_not_terminal() {
assert!(should_print_final_message_to_stdout(
Some("hello"),
/*stdout_is_terminal*/ true,
/*stderr_is_terminal*/ false
));
}
#[test]
fn suppresses_final_stdout_message_when_missing() {
assert!(!should_print_final_message_to_stdout(
/*final_message*/ None, /*stdout_is_terminal*/ false,
/*stderr_is_terminal*/ false
));
}
#[test]
fn prints_final_tty_message_when_not_yet_rendered() {
assert!(should_print_final_message_to_tty(
Some("hello"),
/*final_message_rendered*/ false,
/*stdout_is_terminal*/ true,
/*stderr_is_terminal*/ true
));
}
#[test]
fn suppresses_final_tty_message_when_already_rendered() {
assert!(!should_print_final_message_to_tty(
Some("hello"),
/*final_message_rendered*/ true,
/*stdout_is_terminal*/ true,
/*stderr_is_terminal*/ true
));
}
#[test]
fn reasoning_text_prefers_summary_when_raw_reasoning_is_hidden() {
let text = reasoning_text(
&["summary".to_string()],
&["raw".to_string()],
/*show_raw_agent_reasoning*/ false,
);
assert_eq!(text.as_deref(), Some("summary"));
}
#[test]
fn reasoning_text_uses_raw_content_when_enabled() {
let text = reasoning_text(
&["summary".to_string()],
&["raw".to_string()],
/*show_raw_agent_reasoning*/ true,
);
assert_eq!(text.as_deref(), Some("raw"));
}
#[test]
fn final_message_from_turn_items_uses_latest_agent_message() {
let message = final_message_from_turn_items(&[
ThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "first".to_string(),
phase: None,
memory_citation: None,
},
ThreadItem::Plan {
id: "plan-1".to_string(),
text: "plan".to_string(),
},
ThreadItem::AgentMessage {
id: "msg-2".to_string(),
text: "second".to_string(),
phase: None,
memory_citation: None,
},
]);
assert_eq!(message.as_deref(), Some("second"));
}
#[test]
fn final_message_from_turn_items_falls_back_to_latest_plan() {
let message = final_message_from_turn_items(&[
ThreadItem::Reasoning {
id: "reasoning-1".to_string(),
summary: vec!["inspect".to_string()],
content: Vec::new(),
},
ThreadItem::Plan {
id: "plan-1".to_string(),
text: "first plan".to_string(),
},
ThreadItem::Plan {
id: "plan-2".to_string(),
text: "final plan".to_string(),
},
]);
assert_eq!(message.as_deref(), Some("final plan"));
}
#[test]
fn turn_completed_recovers_final_message_from_turn_items() {
let mut processor = EventProcessorWithHumanOutput {
bold: Style::new(),
cyan: Style::new(),
dimmed: Style::new(),
green: Style::new(),
italic: Style::new(),
magenta: Style::new(),
red: Style::new(),
yellow: Style::new(),
show_agent_reasoning: true,
show_raw_agent_reasoning: false,
last_message_path: None,
final_message: None,
final_message_rendered: false,
emit_final_message_on_shutdown: false,
last_total_token_usage: None,
};
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: Turn {
id: "turn-1".to_string(),
items: vec![ThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
memory_citation: None,
}],
status: TurnStatus::Completed,
error: None,
},
},
));
assert_eq!(
status,
crate::event_processor::CodexStatus::InitiateShutdown
);
assert_eq!(processor.final_message.as_deref(), Some("final answer"));
}
#[test]
fn turn_completed_overwrites_stale_final_message_from_turn_items() {
let mut processor = EventProcessorWithHumanOutput {
bold: Style::new(),
cyan: Style::new(),
dimmed: Style::new(),
green: Style::new(),
italic: Style::new(),
magenta: Style::new(),
red: Style::new(),
yellow: Style::new(),
show_agent_reasoning: true,
show_raw_agent_reasoning: false,
last_message_path: None,
final_message: Some("stale answer".to_string()),
final_message_rendered: true,
emit_final_message_on_shutdown: false,
last_total_token_usage: None,
};
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: Turn {
id: "turn-1".to_string(),
items: vec![ThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
memory_citation: None,
}],
status: TurnStatus::Completed,
error: None,
},
},
));
assert_eq!(
status,
crate::event_processor::CodexStatus::InitiateShutdown
);
assert_eq!(processor.final_message.as_deref(), Some("final answer"));
assert!(!processor.final_message_rendered);
}
#[test]
fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() {
let mut processor = EventProcessorWithHumanOutput {
bold: Style::new(),
cyan: Style::new(),
dimmed: Style::new(),
green: Style::new(),
italic: Style::new(),
magenta: Style::new(),
red: Style::new(),
yellow: Style::new(),
show_agent_reasoning: true,
show_raw_agent_reasoning: false,
last_message_path: None,
final_message: Some("streamed answer".to_string()),
final_message_rendered: false,
emit_final_message_on_shutdown: false,
last_total_token_usage: None,
};
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: Turn {
id: "turn-1".to_string(),
items: Vec::new(),
status: TurnStatus::Completed,
error: None,
},
},
));
assert_eq!(
status,
crate::event_processor::CodexStatus::InitiateShutdown
);
assert_eq!(processor.final_message.as_deref(), Some("streamed answer"));
assert!(processor.emit_final_message_on_shutdown);
}
#[test]
fn turn_failed_clears_stale_final_message() {
let mut processor = EventProcessorWithHumanOutput {
bold: Style::new(),
cyan: Style::new(),
dimmed: Style::new(),
green: Style::new(),
italic: Style::new(),
magenta: Style::new(),
red: Style::new(),
yellow: Style::new(),
show_agent_reasoning: true,
show_raw_agent_reasoning: false,
last_message_path: None,
final_message: Some("partial answer".to_string()),
final_message_rendered: true,
emit_final_message_on_shutdown: true,
last_total_token_usage: None,
};
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: Turn {
id: "turn-1".to_string(),
items: Vec::new(),
status: TurnStatus::Failed,
error: None,
},
},
));
assert_eq!(
status,
crate::event_processor::CodexStatus::InitiateShutdown
);
assert_eq!(processor.final_message, None);
assert!(!processor.final_message_rendered);
assert!(!processor.emit_final_message_on_shutdown);
}
#[test]
fn turn_interrupted_clears_stale_final_message() {
let mut processor = EventProcessorWithHumanOutput {
bold: Style::new(),
cyan: Style::new(),
dimmed: Style::new(),
green: Style::new(),
italic: Style::new(),
magenta: Style::new(),
red: Style::new(),
yellow: Style::new(),
show_agent_reasoning: true,
show_raw_agent_reasoning: false,
last_message_path: None,
final_message: Some("partial answer".to_string()),
final_message_rendered: true,
emit_final_message_on_shutdown: true,
last_total_token_usage: None,
};
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: Turn {
id: "turn-1".to_string(),
items: Vec::new(),
status: TurnStatus::Interrupted,
error: None,
},
},
));
assert_eq!(
status,
crate::event_processor::CodexStatus::InitiateShutdown
);
assert_eq!(processor.final_message, None);
assert!(!processor.final_message_rendered);
assert!(!processor.emit_final_message_on_shutdown);
}

View File

@@ -621,5 +621,59 @@ impl EventProcessor for EventProcessorWithJsonOutput {
}
#[cfg(test)]
#[path = "event_processor_with_jsonl_output_tests.rs"]
mod tests;
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn failed_turn_does_not_overwrite_output_last_message_file() {
let tempdir = tempdir().expect("create tempdir");
let output_path = tempdir.path().join("last-message.txt");
std::fs::write(&output_path, "keep existing contents").expect("seed output file");
let mut processor = EventProcessorWithJsonOutput::new(Some(output_path.clone()));
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification {
item: ThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "partial answer".to_string(),
phase: None,
memory_citation: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
},
));
assert_eq!(collected.status, CodexStatus::Running);
assert_eq!(processor.final_message(), Some("partial answer"));
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: codex_app_server_protocol::Turn {
id: "turn-1".to_string(),
items: Vec::new(),
status: TurnStatus::Failed,
error: Some(codex_app_server_protocol::TurnError {
message: "turn failed".to_string(),
additional_details: None,
codex_error_info: None,
}),
},
},
));
assert_eq!(status, CodexStatus::InitiateShutdown);
assert_eq!(processor.final_message(), None);
EventProcessor::print_final_output(&mut processor);
assert_eq!(
std::fs::read_to_string(&output_path).expect("read output file"),
"keep existing contents"
);
}
}

View File

@@ -1,54 +0,0 @@
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn failed_turn_does_not_overwrite_output_last_message_file() {
let tempdir = tempdir().expect("create tempdir");
let output_path = tempdir.path().join("last-message.txt");
std::fs::write(&output_path, "keep existing contents").expect("seed output file");
let mut processor = EventProcessorWithJsonOutput::new(Some(output_path.clone()));
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification {
item: ThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "partial answer".to_string(),
phase: None,
memory_citation: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
},
));
assert_eq!(collected.status, CodexStatus::Running);
assert_eq!(processor.final_message(), Some("partial answer"));
let status = processor.process_server_notification(ServerNotification::TurnCompleted(
codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: codex_app_server_protocol::Turn {
id: "turn-1".to_string(),
items: Vec::new(),
status: TurnStatus::Failed,
error: Some(codex_app_server_protocol::TurnError {
message: "turn failed".to_string(),
additional_details: None,
codex_error_info: None,
}),
},
},
));
assert_eq!(status, CodexStatus::InitiateShutdown);
assert_eq!(processor.final_message(), None);
EventProcessor::print_final_output(&mut processor);
assert_eq!(
std::fs::read_to_string(&output_path).expect("read output file"),
"keep existing contents"
);
}

View File

@@ -1657,5 +1657,407 @@ fn build_review_request(args: &ReviewArgs) -> anyhow::Result<ReviewRequest> {
}
#[cfg(test)]
#[path = "lib_tests.rs"]
mod tests;
mod tests {
use super::*;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::config_types::ApprovalsReviewer;
use opentelemetry::trace::TraceContextExt;
use opentelemetry::trace::TraceId;
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_sdk::trace::SdkTracerProvider;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use tracing_opentelemetry::OpenTelemetrySpanExt;
fn test_tracing_subscriber() -> impl tracing::Subscriber + Send + Sync {
let provider = SdkTracerProvider::builder().build();
let tracer = provider.tracer("codex-exec-tests");
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer))
}
#[test]
fn exec_defaults_analytics_to_enabled() {
assert_eq!(DEFAULT_ANALYTICS_ENABLED, true);
}
#[test]
fn exec_root_span_can_be_parented_from_trace_context() {
let subscriber = test_tracing_subscriber();
let _guard = tracing::subscriber::set_default(subscriber);
let parent = codex_protocol::protocol::W3cTraceContext {
traceparent: Some("00-00000000000000000000000000000077-0000000000000088-01".into()),
tracestate: Some("vendor=value".into()),
};
let exec_span = exec_root_span();
assert!(set_parent_from_w3c_trace_context(&exec_span, &parent));
let trace_id = exec_span.context().span().span_context().trace_id();
assert_eq!(
trace_id,
TraceId::from_hex("00000000000000000000000000000077").expect("trace id")
);
}
#[test]
fn builds_uncommitted_review_request() {
let args = ReviewArgs {
uncommitted: true,
base: None,
commit: None,
commit_title: None,
prompt: None,
};
let request = build_review_request(&args).expect("builds uncommitted review request");
let expected = ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: None,
};
assert_eq!(request, expected);
}
#[test]
fn builds_commit_review_request_with_title() {
let args = ReviewArgs {
uncommitted: false,
base: None,
commit: Some("123456789".to_string()),
commit_title: Some("Add review command".to_string()),
prompt: None,
};
let request = build_review_request(&args).expect("builds commit review request");
let expected = ReviewRequest {
target: ReviewTarget::Commit {
sha: "123456789".to_string(),
title: Some("Add review command".to_string()),
},
user_facing_hint: None,
};
assert_eq!(request, expected);
}
#[test]
fn builds_custom_review_request_trims_prompt() {
let args = ReviewArgs {
uncommitted: false,
base: None,
commit: None,
commit_title: None,
prompt: Some(" custom review instructions ".to_string()),
};
let request = build_review_request(&args).expect("builds custom review request");
let expected = ReviewRequest {
target: ReviewTarget::Custom {
instructions: "custom review instructions".to_string(),
},
user_facing_hint: None,
};
assert_eq!(request, expected);
}
#[test]
fn decode_prompt_bytes_strips_utf8_bom() {
let input = [0xEF, 0xBB, 0xBF, b'h', b'i', b'\n'];
let out = decode_prompt_bytes(&input).expect("decode utf-8 with BOM");
assert_eq!(out, "hi\n");
}
#[test]
fn decode_prompt_bytes_decodes_utf16le_bom() {
// UTF-16LE BOM + "hi\n"
let input = [0xFF, 0xFE, b'h', 0x00, b'i', 0x00, b'\n', 0x00];
let out = decode_prompt_bytes(&input).expect("decode utf-16le with BOM");
assert_eq!(out, "hi\n");
}
#[test]
fn decode_prompt_bytes_decodes_utf16be_bom() {
// UTF-16BE BOM + "hi\n"
let input = [0xFE, 0xFF, 0x00, b'h', 0x00, b'i', 0x00, b'\n'];
let out = decode_prompt_bytes(&input).expect("decode utf-16be with BOM");
assert_eq!(out, "hi\n");
}
#[test]
fn decode_prompt_bytes_rejects_utf32le_bom() {
// UTF-32LE BOM + "hi\n"
let input = [
0xFF, 0xFE, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00, 0x00, b'\n', 0x00,
0x00, 0x00,
];
let err = decode_prompt_bytes(&input).expect_err("utf-32le should be rejected");
assert_eq!(
err,
PromptDecodeError::UnsupportedBom {
encoding: "UTF-32LE"
}
);
}
#[test]
fn decode_prompt_bytes_rejects_utf32be_bom() {
// UTF-32BE BOM + "hi\n"
let input = [
0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00,
0x00, b'\n',
];
let err = decode_prompt_bytes(&input).expect_err("utf-32be should be rejected");
assert_eq!(
err,
PromptDecodeError::UnsupportedBom {
encoding: "UTF-32BE"
}
);
}
#[test]
fn decode_prompt_bytes_rejects_invalid_utf8() {
// Invalid UTF-8 sequence: 0xC3 0x28
let input = [0xC3, 0x28];
let err = decode_prompt_bytes(&input).expect_err("invalid utf-8 should fail");
assert_eq!(err, PromptDecodeError::InvalidUtf8 { valid_up_to: 0 });
}
#[test]
fn prompt_with_stdin_context_wraps_stdin_block() {
let combined = prompt_with_stdin_context("Summarize this concisely", "my output");
assert_eq!(
combined,
"Summarize this concisely\n\n<stdin>\nmy output\n</stdin>"
);
}
#[test]
fn prompt_with_stdin_context_preserves_trailing_newline() {
let combined = prompt_with_stdin_context("Summarize this concisely", "my output\n");
assert_eq!(
combined,
"Summarize this concisely\n\n<stdin>\nmy output\n</stdin>"
);
}
#[test]
fn lagged_event_warning_message_is_explicit() {
assert_eq!(
lagged_event_warning_message(/*skipped*/ 7),
"in-process app-server event stream lagged; dropped 7 events".to_string()
);
}
#[tokio::test]
async fn resume_lookup_model_providers_filters_only_last_lookup() {
let codex_home = tempdir().expect("create temp codex home");
let cwd = tempdir().expect("create temp cwd");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(cwd.path().to_path_buf()))
.build()
.await
.expect("build default config");
config.model_provider_id = "test-provider".to_string();
let last_args = crate::cli::ResumeArgs {
session_id: None,
last: true,
all: false,
images: vec![],
prompt: None,
};
let named_args = crate::cli::ResumeArgs {
session_id: Some("named-session".to_string()),
last: false,
all: false,
images: vec![],
prompt: None,
};
assert_eq!(
resume_lookup_model_providers(&config, &last_args),
Some(vec!["test-provider".to_string()])
);
assert_eq!(resume_lookup_model_providers(&config, &named_args), None);
}
#[test]
fn turn_items_for_thread_returns_matching_turn_items() {
let thread = AppServerThread {
id: "thread-1".to_string(),
preview: String::new(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 0,
updated_at: 0,
status: codex_app_server_protocol::ThreadStatus::Idle,
path: None,
cwd: PathBuf::from("/tmp/project"),
cli_version: "0.0.0-test".to_string(),
source: codex_app_server_protocol::SessionSource::Exec,
agent_nickname: None,
agent_role: None,
git_info: None,
name: None,
turns: vec![
codex_app_server_protocol::Turn {
id: "turn-1".to_string(),
items: vec![AppServerThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
memory_citation: None,
}],
status: codex_app_server_protocol::TurnStatus::Completed,
error: None,
},
codex_app_server_protocol::Turn {
id: "turn-2".to_string(),
items: vec![AppServerThreadItem::Plan {
id: "plan-1".to_string(),
text: "ship it".to_string(),
}],
status: codex_app_server_protocol::TurnStatus::Completed,
error: None,
},
],
};
assert_eq!(
turn_items_for_thread(&thread, "turn-1"),
Some(vec![AppServerThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
memory_citation: None,
}])
);
assert_eq!(turn_items_for_thread(&thread, "missing-turn"), None);
}
#[test]
fn canceled_mcp_server_elicitation_response_uses_cancel_action() {
let value = canceled_mcp_server_elicitation_response()
.expect("mcp elicitation cancel response should serialize");
let response: McpServerElicitationRequestResponse =
serde_json::from_value(value).expect("cancel response should deserialize");
assert_eq!(
response,
McpServerElicitationRequestResponse {
action: McpServerElicitationAction::Cancel,
content: None,
meta: None,
}
);
}
#[tokio::test]
async fn thread_start_params_include_review_policy_when_review_policy_is_manual_only() {
let codex_home = tempdir().expect("create temp codex home");
let cwd = tempdir().expect("create temp cwd");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
approvals_reviewer: Some(ApprovalsReviewer::User),
..Default::default()
})
.fallback_cwd(Some(cwd.path().to_path_buf()))
.build()
.await
.expect("build config with manual-only review policy");
let params = thread_start_params_from_config(&config);
assert_eq!(
params.approvals_reviewer,
Some(codex_app_server_protocol::ApprovalsReviewer::User)
);
}
#[tokio::test]
async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() {
let codex_home = tempdir().expect("create temp codex home");
let cwd = tempdir().expect("create temp cwd");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent),
..Default::default()
})
.fallback_cwd(Some(cwd.path().to_path_buf()))
.build()
.await
.expect("build config with guardian review policy");
let params = thread_start_params_from_config(&config);
assert_eq!(
params.approvals_reviewer,
Some(codex_app_server_protocol::ApprovalsReviewer::GuardianSubagent)
);
}
#[test]
fn session_configured_from_thread_response_uses_review_policy_from_response() {
let response = ThreadStartResponse {
thread: codex_app_server_protocol::Thread {
id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(),
preview: String::new(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 0,
updated_at: 0,
status: codex_app_server_protocol::ThreadStatus::Idle,
path: Some(PathBuf::from("/tmp/rollout.jsonl")),
cwd: PathBuf::from("/tmp"),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Cli,
agent_nickname: None,
agent_role: None,
git_info: None,
name: Some("thread".to_string()),
turns: vec![],
},
model: "gpt-5.4".to_string(),
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
approval_policy: codex_app_server_protocol::AskForApproval::OnRequest,
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::GuardianSubagent,
sandbox: codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
reasoning_effort: None,
};
let event = session_configured_from_thread_start_response(&response)
.expect("build bootstrap session configured event");
assert_eq!(
event.approvals_reviewer,
ApprovalsReviewer::GuardianSubagent
);
}
}

View File

@@ -1,402 +0,0 @@
use super::*;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::config_types::ApprovalsReviewer;
use opentelemetry::trace::TraceContextExt;
use opentelemetry::trace::TraceId;
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_sdk::trace::SdkTracerProvider;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use tracing_opentelemetry::OpenTelemetrySpanExt;
fn test_tracing_subscriber() -> impl tracing::Subscriber + Send + Sync {
let provider = SdkTracerProvider::builder().build();
let tracer = provider.tracer("codex-exec-tests");
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer))
}
#[test]
fn exec_defaults_analytics_to_enabled() {
assert_eq!(DEFAULT_ANALYTICS_ENABLED, true);
}
#[test]
fn exec_root_span_can_be_parented_from_trace_context() {
let subscriber = test_tracing_subscriber();
let _guard = tracing::subscriber::set_default(subscriber);
let parent = codex_protocol::protocol::W3cTraceContext {
traceparent: Some("00-00000000000000000000000000000077-0000000000000088-01".into()),
tracestate: Some("vendor=value".into()),
};
let exec_span = exec_root_span();
assert!(set_parent_from_w3c_trace_context(&exec_span, &parent));
let trace_id = exec_span.context().span().span_context().trace_id();
assert_eq!(
trace_id,
TraceId::from_hex("00000000000000000000000000000077").expect("trace id")
);
}
#[test]
fn builds_uncommitted_review_request() {
let args = ReviewArgs {
uncommitted: true,
base: None,
commit: None,
commit_title: None,
prompt: None,
};
let request = build_review_request(&args).expect("builds uncommitted review request");
let expected = ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: None,
};
assert_eq!(request, expected);
}
#[test]
fn builds_commit_review_request_with_title() {
let args = ReviewArgs {
uncommitted: false,
base: None,
commit: Some("123456789".to_string()),
commit_title: Some("Add review command".to_string()),
prompt: None,
};
let request = build_review_request(&args).expect("builds commit review request");
let expected = ReviewRequest {
target: ReviewTarget::Commit {
sha: "123456789".to_string(),
title: Some("Add review command".to_string()),
},
user_facing_hint: None,
};
assert_eq!(request, expected);
}
#[test]
fn builds_custom_review_request_trims_prompt() {
let args = ReviewArgs {
uncommitted: false,
base: None,
commit: None,
commit_title: None,
prompt: Some(" custom review instructions ".to_string()),
};
let request = build_review_request(&args).expect("builds custom review request");
let expected = ReviewRequest {
target: ReviewTarget::Custom {
instructions: "custom review instructions".to_string(),
},
user_facing_hint: None,
};
assert_eq!(request, expected);
}
#[test]
fn decode_prompt_bytes_strips_utf8_bom() {
let input = [0xEF, 0xBB, 0xBF, b'h', b'i', b'\n'];
let out = decode_prompt_bytes(&input).expect("decode utf-8 with BOM");
assert_eq!(out, "hi\n");
}
#[test]
fn decode_prompt_bytes_decodes_utf16le_bom() {
// UTF-16LE BOM + "hi\n"
let input = [0xFF, 0xFE, b'h', 0x00, b'i', 0x00, b'\n', 0x00];
let out = decode_prompt_bytes(&input).expect("decode utf-16le with BOM");
assert_eq!(out, "hi\n");
}
#[test]
fn decode_prompt_bytes_decodes_utf16be_bom() {
// UTF-16BE BOM + "hi\n"
let input = [0xFE, 0xFF, 0x00, b'h', 0x00, b'i', 0x00, b'\n'];
let out = decode_prompt_bytes(&input).expect("decode utf-16be with BOM");
assert_eq!(out, "hi\n");
}
#[test]
fn decode_prompt_bytes_rejects_utf32le_bom() {
// UTF-32LE BOM + "hi\n"
let input = [
0xFF, 0xFE, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00, 0x00, b'\n', 0x00, 0x00,
0x00,
];
let err = decode_prompt_bytes(&input).expect_err("utf-32le should be rejected");
assert_eq!(
err,
PromptDecodeError::UnsupportedBom {
encoding: "UTF-32LE"
}
);
}
#[test]
fn decode_prompt_bytes_rejects_utf32be_bom() {
// UTF-32BE BOM + "hi\n"
let input = [
0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x00, b'h', 0x00, 0x00, 0x00, b'i', 0x00, 0x00, 0x00,
b'\n',
];
let err = decode_prompt_bytes(&input).expect_err("utf-32be should be rejected");
assert_eq!(
err,
PromptDecodeError::UnsupportedBom {
encoding: "UTF-32BE"
}
);
}
#[test]
fn decode_prompt_bytes_rejects_invalid_utf8() {
// Invalid UTF-8 sequence: 0xC3 0x28
let input = [0xC3, 0x28];
let err = decode_prompt_bytes(&input).expect_err("invalid utf-8 should fail");
assert_eq!(err, PromptDecodeError::InvalidUtf8 { valid_up_to: 0 });
}
#[test]
fn prompt_with_stdin_context_wraps_stdin_block() {
let combined = prompt_with_stdin_context("Summarize this concisely", "my output");
assert_eq!(
combined,
"Summarize this concisely\n\n<stdin>\nmy output\n</stdin>"
);
}
#[test]
fn prompt_with_stdin_context_preserves_trailing_newline() {
let combined = prompt_with_stdin_context("Summarize this concisely", "my output\n");
assert_eq!(
combined,
"Summarize this concisely\n\n<stdin>\nmy output\n</stdin>"
);
}
#[test]
fn lagged_event_warning_message_is_explicit() {
assert_eq!(
lagged_event_warning_message(/*skipped*/ 7),
"in-process app-server event stream lagged; dropped 7 events".to_string()
);
}
#[tokio::test]
async fn resume_lookup_model_providers_filters_only_last_lookup() {
let codex_home = tempdir().expect("create temp codex home");
let cwd = tempdir().expect("create temp cwd");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(cwd.path().to_path_buf()))
.build()
.await
.expect("build default config");
config.model_provider_id = "test-provider".to_string();
let last_args = crate::cli::ResumeArgs {
session_id: None,
last: true,
all: false,
images: vec![],
prompt: None,
};
let named_args = crate::cli::ResumeArgs {
session_id: Some("named-session".to_string()),
last: false,
all: false,
images: vec![],
prompt: None,
};
assert_eq!(
resume_lookup_model_providers(&config, &last_args),
Some(vec!["test-provider".to_string()])
);
assert_eq!(resume_lookup_model_providers(&config, &named_args), None);
}
#[test]
fn turn_items_for_thread_returns_matching_turn_items() {
let thread = AppServerThread {
id: "thread-1".to_string(),
preview: String::new(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 0,
updated_at: 0,
status: codex_app_server_protocol::ThreadStatus::Idle,
path: None,
cwd: PathBuf::from("/tmp/project"),
cli_version: "0.0.0-test".to_string(),
source: codex_app_server_protocol::SessionSource::Exec,
agent_nickname: None,
agent_role: None,
git_info: None,
name: None,
turns: vec![
codex_app_server_protocol::Turn {
id: "turn-1".to_string(),
items: vec![AppServerThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
memory_citation: None,
}],
status: codex_app_server_protocol::TurnStatus::Completed,
error: None,
},
codex_app_server_protocol::Turn {
id: "turn-2".to_string(),
items: vec![AppServerThreadItem::Plan {
id: "plan-1".to_string(),
text: "ship it".to_string(),
}],
status: codex_app_server_protocol::TurnStatus::Completed,
error: None,
},
],
};
assert_eq!(
turn_items_for_thread(&thread, "turn-1"),
Some(vec![AppServerThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
memory_citation: None,
}])
);
assert_eq!(turn_items_for_thread(&thread, "missing-turn"), None);
}
#[test]
fn canceled_mcp_server_elicitation_response_uses_cancel_action() {
let value = canceled_mcp_server_elicitation_response()
.expect("mcp elicitation cancel response should serialize");
let response: McpServerElicitationRequestResponse =
serde_json::from_value(value).expect("cancel response should deserialize");
assert_eq!(
response,
McpServerElicitationRequestResponse {
action: McpServerElicitationAction::Cancel,
content: None,
meta: None,
}
);
}
#[tokio::test]
async fn thread_start_params_include_review_policy_when_review_policy_is_manual_only() {
let codex_home = tempdir().expect("create temp codex home");
let cwd = tempdir().expect("create temp cwd");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
approvals_reviewer: Some(ApprovalsReviewer::User),
..Default::default()
})
.fallback_cwd(Some(cwd.path().to_path_buf()))
.build()
.await
.expect("build config with manual-only review policy");
let params = thread_start_params_from_config(&config);
assert_eq!(
params.approvals_reviewer,
Some(codex_app_server_protocol::ApprovalsReviewer::User)
);
}
#[tokio::test]
async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() {
let codex_home = tempdir().expect("create temp codex home");
let cwd = tempdir().expect("create temp cwd");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent),
..Default::default()
})
.fallback_cwd(Some(cwd.path().to_path_buf()))
.build()
.await
.expect("build config with guardian review policy");
let params = thread_start_params_from_config(&config);
assert_eq!(
params.approvals_reviewer,
Some(codex_app_server_protocol::ApprovalsReviewer::GuardianSubagent)
);
}
#[test]
fn session_configured_from_thread_response_uses_review_policy_from_response() {
let response = ThreadStartResponse {
thread: codex_app_server_protocol::Thread {
id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(),
preview: String::new(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 0,
updated_at: 0,
status: codex_app_server_protocol::ThreadStatus::Idle,
path: Some(PathBuf::from("/tmp/rollout.jsonl")),
cwd: PathBuf::from("/tmp"),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Cli,
agent_nickname: None,
agent_role: None,
git_info: None,
name: Some("thread".to_string()),
turns: vec![],
},
model: "gpt-5.4".to_string(),
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
approval_policy: codex_app_server_protocol::AskForApproval::OnRequest,
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::GuardianSubagent,
sandbox: codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
reasoning_effort: None,
};
let event = session_configured_from_thread_start_response(&response)
.expect("build bootstrap session configured event");
assert_eq!(
event.approvals_reviewer,
ApprovalsReviewer::GuardianSubagent
);
}

View File

@@ -41,5 +41,42 @@ fn main() -> anyhow::Result<()> {
}
#[cfg(test)]
#[path = "main_tests.rs"]
mod tests;
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn top_cli_parses_resume_prompt_after_config_flag() {
const PROMPT: &str = "echo resume-with-global-flags-after-subcommand";
let cli = TopCli::parse_from([
"codex-exec",
"resume",
"--last",
"--json",
"--model",
"gpt-5.2-codex",
"--config",
"reasoning_level=xhigh",
"--dangerously-bypass-approvals-and-sandbox",
"--skip-git-repo-check",
PROMPT,
]);
let Some(codex_exec::Command::Resume(args)) = cli.inner.command else {
panic!("expected resume command");
};
let effective_prompt = args.prompt.clone().or_else(|| {
if args.last {
args.session_id.clone()
} else {
None
}
});
assert_eq!(effective_prompt.as_deref(), Some(PROMPT));
assert_eq!(cli.config_overrides.raw_overrides.len(), 1);
assert_eq!(
cli.config_overrides.raw_overrides[0],
"reasoning_level=xhigh"
);
}
}

View File

@@ -1,37 +0,0 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn top_cli_parses_resume_prompt_after_config_flag() {
const PROMPT: &str = "echo resume-with-global-flags-after-subcommand";
let cli = TopCli::parse_from([
"codex-exec",
"resume",
"--last",
"--json",
"--model",
"gpt-5.2-codex",
"--config",
"reasoning_level=xhigh",
"--dangerously-bypass-approvals-and-sandbox",
"--skip-git-repo-check",
PROMPT,
]);
let Some(codex_exec::Command::Resume(args)) = cli.inner.command else {
panic!("expected resume command");
};
let effective_prompt = args.prompt.clone().or_else(|| {
if args.last {
args.session_id.clone()
} else {
None
}
});
assert_eq!(effective_prompt.as_deref(), Some(PROMPT));
assert_eq!(cli.config_overrides.raw_overrides.len(), 1);
assert_eq!(
cli.config_overrides.raw_overrides[0],
"reasoning_level=xhigh"
);
}

View File

@@ -157,10 +157,9 @@ impl AppServerSession {
matches!(self.client, AppServerClient::Remote(_))
}
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
pub(crate) async fn account(&mut self) -> Result<GetAccountResponse> {
let account_request_id = self.next_request_id();
let account: GetAccountResponse = self
.client
self.client
.request_typed(ClientRequest::GetAccount {
request_id: account_request_id,
params: GetAccountParams {
@@ -168,7 +167,11 @@ impl AppServerSession {
},
})
.await
.wrap_err("account/read failed during TUI bootstrap")?;
.wrap_err("account/read failed during TUI startup")
}
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
let account = self.account().await?;
let model_request_id = self.next_request_id();
let models: ModelListResponse = self
.client

View File

@@ -14,6 +14,7 @@ use codex_app_server_client::InProcessAppServerClient;
use codex_app_server_client::InProcessClientStartArgs;
use codex_app_server_client::RemoteAppServerClient;
use codex_app_server_client::RemoteAppServerConnectArgs;
use codex_app_server_protocol::Account as AppServerAccount;
use codex_app_server_protocol::AuthMode as AppServerAuthMode;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::Thread as AppServerThread;
@@ -979,29 +980,32 @@ async fn run_ratatui_app(
// Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&initial_config);
let mut app_server = Some(
match start_app_server(
&app_server_target,
arg0_paths.clone(),
initial_config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
feedback.clone(),
)
.await
{
Ok(app_server) => AppServerSession::new(app_server),
Err(err) => {
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Err(err);
}
},
);
let should_show_trust_screen_flag = !remote_mode && should_show_trust_screen(&initial_config);
let mut trust_decision_was_made = false;
let needs_onboarding_app_server =
should_show_trust_screen_flag || initial_config.model_provider.requires_openai_auth;
let mut onboarding_app_server = if needs_onboarding_app_server {
Some(AppServerSession::new(
start_app_server(
&app_server_target,
arg0_paths.clone(),
initial_config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
feedback.clone(),
)
.await?,
))
} else {
None
};
let login_status = if initial_config.model_provider.requires_openai_auth {
let Some(app_server) = onboarding_app_server.as_mut() else {
unreachable!("onboarding app server should exist when auth is required");
let Some(app_server) = app_server.as_mut() else {
unreachable!("app server should exist when auth is required");
};
get_login_status(app_server, &initial_config).await?
} else {
@@ -1017,13 +1021,13 @@ async fn run_ratatui_app(
show_login_screen,
show_trust_screen: should_show_trust_screen_flag,
login_status,
app_server_request_handle: onboarding_app_server
app_server_request_handle: app_server
.as_ref()
.map(AppServerSession::request_handle),
config: initial_config.clone(),
},
if show_login_screen {
onboarding_app_server.take()
app_server.as_mut()
} else {
None
},
@@ -1031,6 +1035,7 @@ async fn run_ratatui_app(
)
.await?;
if onboarding_result.should_exit {
shutdown_app_server_if_present(app_server.take()).await;
terminal_restore_guard.restore_silently();
session_log::log_session_end();
let _ = tui.terminal.clear();
@@ -1070,10 +1075,8 @@ async fn run_ratatui_app(
initial_config
}
} else {
shutdown_app_server_if_present(onboarding_app_server.take()).await;
initial_config
};
shutdown_app_server_if_present(onboarding_app_server.take()).await;
let mut missing_session_exit = |id_str: &str, action: &str| {
error!("Error finding conversation path: {id_str}");
@@ -1091,45 +1094,22 @@ async fn run_ratatui_app(
})
};
let needs_app_server_session_lookup = cli.resume_last
|| cli.fork_last
|| cli.resume_session_id.is_some()
|| cli.fork_session_id.is_some()
|| cli.resume_picker
|| cli.fork_picker;
let mut session_lookup_app_server = if needs_app_server_session_lookup {
Some(AppServerSession::new(
start_app_server(
&app_server_target,
arg0_paths.clone(),
config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
feedback.clone(),
)
.await?,
))
} else {
None
};
let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some();
let session_selection = if use_fork {
if let Some(id_str) = cli.fork_session_id.as_deref() {
let Some(app_server) = session_lookup_app_server.as_mut() else {
unreachable!("session lookup app server should be initialized for --fork <id>");
let Some(startup_app_server) = app_server.as_mut() else {
unreachable!("app server should be initialized for --fork <id>");
};
match lookup_session_target_with_app_server(app_server, id_str).await? {
match lookup_session_target_with_app_server(startup_app_server, id_str).await? {
Some(target_session) => resume_picker::SessionSelection::Fork(target_session),
None => {
shutdown_app_server_if_present(session_lookup_app_server.take()).await;
shutdown_app_server_if_present(app_server.take()).await;
return missing_session_exit(id_str, "fork");
}
}
} else if cli.fork_last {
let Some(app_server) = session_lookup_app_server.as_mut() else {
unreachable!("session lookup app server should be initialized for --fork --last");
let Some(app_server) = app_server.as_mut() else {
unreachable!("app server should be initialized for --fork --last");
};
match lookup_latest_session_target_with_app_server(
app_server, &config, /*cwd_filter*/ None,
@@ -1141,8 +1121,8 @@ async fn run_ratatui_app(
None => resume_picker::SessionSelection::StartFresh,
}
} else if cli.fork_picker {
let Some(app_server) = session_lookup_app_server.take() else {
unreachable!("session lookup app server should be initialized for --fork picker");
let Some(app_server) = app_server.take() else {
unreachable!("app server should be initialized for --fork picker");
};
match resume_picker::run_fork_picker_with_app_server(
&mut tui,
@@ -1169,13 +1149,13 @@ async fn run_ratatui_app(
resume_picker::SessionSelection::StartFresh
}
} else if let Some(id_str) = cli.resume_session_id.as_deref() {
let Some(app_server) = session_lookup_app_server.as_mut() else {
unreachable!("session lookup app server should be initialized for --resume <id>");
let Some(startup_app_server) = app_server.as_mut() else {
unreachable!("app server should be initialized for --resume <id>");
};
match lookup_session_target_with_app_server(app_server, id_str).await? {
match lookup_session_target_with_app_server(startup_app_server, id_str).await? {
Some(target_session) => resume_picker::SessionSelection::Resume(target_session),
None => {
shutdown_app_server_if_present(session_lookup_app_server.take()).await;
shutdown_app_server_if_present(app_server.take()).await;
return missing_session_exit(id_str, "resume");
}
}
@@ -1185,8 +1165,8 @@ async fn run_ratatui_app(
} else {
Some(config.cwd.as_path())
};
let Some(app_server) = session_lookup_app_server.as_mut() else {
unreachable!("session lookup app server should be initialized for --resume --last");
let Some(app_server) = app_server.as_mut() else {
unreachable!("app server should be initialized for --resume --last");
};
match lookup_latest_session_target_with_app_server(
app_server,
@@ -1200,8 +1180,8 @@ async fn run_ratatui_app(
None => resume_picker::SessionSelection::StartFresh,
}
} else if cli.resume_picker {
let Some(app_server) = session_lookup_app_server.take() else {
unreachable!("session lookup app server should be initialized for --resume picker");
let Some(app_server) = app_server.take() else {
unreachable!("app server should be initialized for --resume picker");
};
match resume_picker::run_resume_picker_with_app_server(
&mut tui,
@@ -1228,7 +1208,6 @@ async fn run_ratatui_app(
} else {
resume_picker::SessionSelection::StartFresh
};
shutdown_app_server_if_present(session_lookup_app_server.take()).await;
let current_cwd = config.cwd.clone();
let allow_prompt = !remote_mode && cli.cwd.is_none();
@@ -1314,28 +1293,31 @@ async fn run_ratatui_app(
let use_alt_screen = determine_alt_screen_mode(no_alt_screen, config.tui_alternate_screen);
tui.set_alt_screen_enabled(use_alt_screen);
let app_server = match start_app_server(
&app_server_target,
arg0_paths,
config.clone(),
cli_kv_overrides.clone(),
loader_overrides,
cloud_requirements.clone(),
feedback.clone(),
)
.await
{
Ok(app_server) => app_server,
Err(err) => {
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Err(err);
}
let app_server = match app_server {
Some(app_server) => app_server,
None => match start_app_server(
&app_server_target,
arg0_paths,
config.clone(),
cli_kv_overrides.clone(),
loader_overrides,
cloud_requirements.clone(),
feedback.clone(),
)
.await
{
Ok(app_server) => AppServerSession::new(app_server),
Err(err) => {
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Err(err);
}
},
};
let app_result = App::run(
&mut tui,
AppServerSession::new(app_server),
app_server,
config,
cli_kv_overrides.clone(),
overrides.clone(),
@@ -1570,9 +1552,10 @@ async fn get_login_status(
return Ok(LoginStatus::NotAuthenticated);
}
let bootstrap = app_server.bootstrap(config).await?;
Ok(match bootstrap.account_auth_mode {
Some(auth_mode) => LoginStatus::AuthMode(auth_mode),
let account = app_server.account().await?;
Ok(match account.account {
Some(AppServerAccount::ApiKey {}) => LoginStatus::AuthMode(AppServerAuthMode::ApiKey),
Some(AppServerAccount::Chatgpt { .. }) => LoginStatus::AuthMode(AppServerAuthMode::Chatgpt),
None => LoginStatus::NotAuthenticated,
})
}

View File

@@ -433,7 +433,7 @@ impl WidgetRef for Step {
pub(crate) async fn run_onboarding_app(
args: OnboardingScreenArgs,
mut app_server: Option<AppServerSession>,
mut app_server: Option<&mut AppServerSession>,
tui: &mut Tui,
) -> Result<OnboardingResult> {
use tokio_stream::StreamExt;
@@ -519,9 +519,6 @@ pub(crate) async fn run_onboarding_app(
}
}
}
if let Some(app_server) = app_server {
app_server.shutdown().await.ok();
}
Ok(OnboardingResult {
directory_trust_decision: onboarding_screen.directory_trust_decision(),
should_exit: onboarding_screen.should_exit(),

View File

@@ -10,7 +10,6 @@ exports_files([
"rules_rust_repository_set_exec_constraints.patch",
"rules_rust_windows_msvc_direct_link_args.patch",
"rules_rust_windows_gnullvm_build_script.patch",
"rules_rs_delete_git_worktree_pointer.patch",
"rules_rs_windows_gnullvm_exec.patch",
"rusty_v8_prebuilt_out_dir.patch",
"v8_bazel_rules.patch",

View File

@@ -1,23 +0,0 @@
# What: delete .git worktree pointer from crate git checkouts.
# Why: the .git file contains an absolute path to the bazel output base,
# which differs across machines. This pollutes compile_data and causes
# action cache misses when builds run on different CI runners.
diff --git a/rs/private/crate_git_repository.bzl b/rs/private/crate_git_repository.bzl
index 1234567..abcdefg 100644
--- a/rs/private/crate_git_repository.bzl
+++ b/rs/private/crate_git_repository.bzl
@@ -35,6 +35,11 @@ def _crate_git_repository_implementation(rctx):
"HEAD"
])
if result.return_code != 0:
fail(result.stderr)
+ # Remove .git worktree pointer file. It contains an absolute path to
+ # the bazel output base which is machine-specific and non-deterministic.
+ # Leaving it in pollutes compile_data globs and causes AC misses.
+ rctx.delete(root.get_child(".git"))
+
if strip_prefix:
dest_link = dest_dir.get_child(strip_prefix)
if not dest_link.exists: