From 05ffad9a8f41b2556cdcae7c9b81f4a36b038863 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 02:13:25 +0800 Subject: [PATCH 01/60] fork: restore minimal CI signal on review PRs --- .github/workflows/ci.yml | 2 ++ .github/workflows/rust-ci.yml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76a8aa014..7fef2f9b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - name: Stage npm package + if: ${{ github.repository == 'openai/codex' }} id: stage_npm_package env: GH_TOKEN: ${{ github.token }} @@ -53,6 +54,7 @@ jobs: echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - name: Upload staged npm package artifact + if: ${{ github.repository == 'openai/codex' }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: codex-npm-staging diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 3a9eadc8b..907dd37ff 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -130,7 +130,7 @@ jobs: runs-on: ${{ matrix.runs_on || matrix.runner }} timeout-minutes: ${{ matrix.timeout_minutes }} needs: changed - if: ${{ needs.changed.outputs.argument_comment_lint == 'true' || needs.changed.outputs.workflows == 'true' }} + if: ${{ (needs.changed.outputs.argument_comment_lint == 'true' || needs.changed.outputs.workflows == 'true') && (github.repository == 'openai/codex' || matrix.name == 'Linux') }} strategy: fail-fast: false matrix: From 057ce920265228a8a62dac0c2f8a101cab93e1ae Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 02:16:16 +0800 Subject: [PATCH 02/60] fork: restrict rust-ci matrix to linux on forks --- .github/workflows/rust-ci.yml | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 907dd37ff..16ce63b46 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -130,23 +130,11 @@ jobs: runs-on: ${{ matrix.runs_on || matrix.runner }} timeout-minutes: ${{ matrix.timeout_minutes }} needs: changed - if: ${{ (needs.changed.outputs.argument_comment_lint == 'true' || needs.changed.outputs.workflows == 'true') && (github.repository == 'openai/codex' || matrix.name == 'Linux') }} + if: ${{ needs.changed.outputs.argument_comment_lint == 'true' || needs.changed.outputs.workflows == 'true' }} strategy: fail-fast: false matrix: - include: - - name: Linux - runner: ubuntu-24.04 - timeout_minutes: 30 - - name: macOS - runner: macos-15-xlarge - timeout_minutes: 30 - - name: Windows - runner: windows-x64 - timeout_minutes: 30 - runs_on: - group: codex-runners - labels: codex-windows-x64 + include: ${{ fromJSON(github.repository == 'openai/codex' && '[{"name":"Linux","runner":"ubuntu-24.04","timeout_minutes":30},{"name":"macOS","runner":"macos-15-xlarge","timeout_minutes":30},{"name":"Windows","runner":"windows-x64","timeout_minutes":30,"runs_on":{"group":"codex-runners","labels":"codex-windows-x64"}}]' || '[{"name":"Linux","runner":"ubuntu-24.04","timeout_minutes":30}]') }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: ./.github/actions/setup-bazel-ci From c339aa9930294177b8d28030c3f9718ba1301f3f Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 03:00:06 +0800 Subject: [PATCH 03/60] ci: increase fork Linux arglint timeout --- .github/workflows/rust-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 16ce63b46..4502a6c07 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -134,7 +134,7 @@ jobs: strategy: fail-fast: false matrix: - include: ${{ fromJSON(github.repository == 'openai/codex' && '[{"name":"Linux","runner":"ubuntu-24.04","timeout_minutes":30},{"name":"macOS","runner":"macos-15-xlarge","timeout_minutes":30},{"name":"Windows","runner":"windows-x64","timeout_minutes":30,"runs_on":{"group":"codex-runners","labels":"codex-windows-x64"}}]' || '[{"name":"Linux","runner":"ubuntu-24.04","timeout_minutes":30}]') }} + include: ${{ fromJSON(github.repository == 'openai/codex' && '[{"name":"Linux","runner":"ubuntu-24.04","timeout_minutes":30},{"name":"macOS","runner":"macos-15-xlarge","timeout_minutes":30},{"name":"Windows","runner":"windows-x64","timeout_minutes":30,"runs_on":{"group":"codex-runners","labels":"codex-windows-x64"}}]' || '[{"name":"Linux","runner":"ubuntu-24.04","timeout_minutes":120}]') }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: ./.github/actions/setup-bazel-ci From 4b50dadd9d7f85fe0eec970022527a007f9534ad Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 05:19:39 +0800 Subject: [PATCH 04/60] ci: add heartbeat to linux argument-comment-lint --- .github/workflows/rust-ci.yml | 42 ++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 4502a6c07..a034779cb 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -148,7 +148,47 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - name: Run argument comment lint on codex-rs via Bazel - if: ${{ runner.os != 'Windows' }} + if: ${{ runner.os == 'Linux' }} + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)" + + heartbeat() { + while true; do + sleep 60 + echo "argument comment lint still running via Bazel ($(date -u '+%Y-%m-%dT%H:%M:%SZ'))" + done + } + + heartbeat & + heartbeat_pid=$! + cleanup() { + kill "$heartbeat_pid" 2>/dev/null || true + } + trap cleanup EXIT + + set +e + timeout --foreground --signal=TERM --kill-after=30s 110m \ + ./.github/scripts/run-bazel-ci.sh \ + -- \ + build \ + --config=argument-comment-lint \ + --keep_going \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ + -- \ + ${bazel_targets} + status=$? + set -e + + if [[ $status -eq 124 ]]; then + echo "argument comment lint Bazel step exceeded 110 minutes; failing explicitly before the 120-minute job timeout so shared CI follow-up can diagnose it." + fi + + exit "$status" + - name: Run argument comment lint on codex-rs via Bazel + if: ${{ runner.os == 'macOS' }} env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} shell: bash From 6df709707965a55a97a293040fa0ceb115ca16c0 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 06:42:29 +0800 Subject: [PATCH 05/60] ci: force local bazel for fork linux argument-comment-lint --- .github/scripts/run-bazel-ci.sh | 21 ++++++++++++++++++++- .github/workflows/rust-ci.yml | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index 9c95fda15..6632193c8 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -210,7 +210,26 @@ if (( ${#bazel_startup_args[@]} > 0 )); then bazel_cmd+=("${bazel_startup_args[@]}") fi -if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then +if [[ "${CODEX_BAZEL_FORCE_LOCAL:-0}" == "1" ]]; then + echo "CODEX_BAZEL_FORCE_LOCAL=1; using local Bazel configuration." + bazel_run_args=( + "${bazel_args[@]}" + --remote_cache= + --remote_executor= + ) + if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") + fi + set +e + run_bazel "${bazel_cmd[@]:1}" \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" + bazel_status=${PIPESTATUS[0]} + set -e +elif [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then echo "BuildBuddy API key is available; using remote Bazel configuration." # Work around Bazel 9 remote repo contents cache / overlay materialization failures # seen in CI (for example "is not a symlink" or permission errors while diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index a034779cb..ede0f4225 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -151,6 +151,7 @@ jobs: if: ${{ runner.os == 'Linux' }} env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + CODEX_BAZEL_FORCE_LOCAL: ${{ github.repository != 'openai/codex' && '1' || '0' }} shell: bash run: | bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)" From 9bc87151be42f6167531713f8313cd20c550dcf4 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 07:03:42 +0800 Subject: [PATCH 06/60] ci: force fork linux arglint onto local bazel execution --- .bazelrc | 10 ++++++++++ .github/scripts/run-bazel-ci.sh | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/.bazelrc b/.bazelrc index 603ad46e4..cc59037b5 100644 --- a/.bazelrc +++ b/.bazelrc @@ -136,6 +136,16 @@ common:ci-linux --config=remote common:ci-linux --strategy=remote common:ci-linux --platforms=//:rbe +# Fork-only Linux jobs that must stay on Bazel but cannot rely on BuildBuddy +# remote execution should use this explicit local variant instead of trying to +# partially undo `ci-linux` at the callsite. +common:ci-linux-local --config=ci-bazel +common:ci-linux-local --build_metadata=TAG_os=linux +common:ci-linux-local --extra_execution_platforms= +common:ci-linux-local --platforms=//:local_linux +common:ci-linux-local --spawn_strategy=local +common:ci-linux-local --strategy=local + # On mac, we can run all the build actions remotely but test actions locally. common:ci-macos --config=ci-bazel common:ci-macos --build_metadata=TAG_os=macos diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index 6632193c8..301e9b2ca 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -65,6 +65,11 @@ case "${RUNNER_OS:-}" in ;; esac +force_local_ci_config= +if [[ "${RUNNER_OS:-}" == "Linux" ]]; then + force_local_ci_config=ci-linux-local +fi + print_bazel_test_log_tails() { local console_log="$1" local testlogs_dir @@ -217,6 +222,9 @@ if [[ "${CODEX_BAZEL_FORCE_LOCAL:-0}" == "1" ]]; then --remote_cache= --remote_executor= ) + if [[ -n "${force_local_ci_config}" ]]; then + bazel_run_args+=("--config=${force_local_ci_config}") + fi if (( ${#post_config_bazel_args[@]} > 0 )); then bazel_run_args+=("${post_config_bazel_args[@]}") fi From 7226e1cf21c8d065e24814845353983dad86f517 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 07:26:55 +0800 Subject: [PATCH 07/60] ci: run fork linux arglint via source wrapper --- .github/workflows/rust-ci.yml | 45 ++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index ede0f4225..b26e63502 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -147,19 +147,14 @@ jobs: run: | sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Run argument comment lint on codex-rs via Bazel - if: ${{ runner.os == 'Linux' }} - env: - BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} - CODEX_BAZEL_FORCE_LOCAL: ${{ github.repository != 'openai/codex' && '1' || '0' }} + - name: Run argument comment lint on codex-rs via source wrapper + if: ${{ runner.os == 'Linux' && github.repository != 'openai/codex' }} shell: bash run: | - bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)" - heartbeat() { while true; do sleep 60 - echo "argument comment lint still running via Bazel ($(date -u '+%Y-%m-%dT%H:%M:%SZ'))" + echo "argument comment lint still running via source wrapper ($(date -u '+%Y-%m-%dT%H:%M:%SZ'))" done } @@ -170,24 +165,40 @@ jobs: } trap cleanup EXIT + rustup toolchain install nightly-2025-09-18 \ + --profile minimal \ + --component llvm-tools-preview \ + --component rustc-dev \ + --component rust-src \ + --no-self-update + cargo install --locked cargo-dylint dylint-link + set +e timeout --foreground --signal=TERM --kill-after=30s 110m \ - ./.github/scripts/run-bazel-ci.sh \ - -- \ - build \ - --config=argument-comment-lint \ - --keep_going \ - --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ - -- \ - ${bazel_targets} + ./tools/argument-comment-lint/run.py status=$? set -e if [[ $status -eq 124 ]]; then - echo "argument comment lint Bazel step exceeded 110 minutes; failing explicitly before the 120-minute job timeout so shared CI follow-up can diagnose it." + echo "argument comment lint source-wrapper step exceeded 110 minutes; failing explicitly before the 120-minute job timeout so shared CI follow-up can diagnose it." fi exit "$status" + - name: Run argument comment lint on codex-rs via Bazel + if: ${{ runner.os == 'Linux' && github.repository == 'openai/codex' }} + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)" + ./.github/scripts/run-bazel-ci.sh \ + -- \ + build \ + --config=argument-comment-lint \ + --keep_going \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ + -- \ + ${bazel_targets} - name: Run argument comment lint on codex-rs via Bazel if: ${{ runner.os == 'macOS' }} env: From c7d306a2a345af7b1f7a13fe6293566ad3fafb17 Mon Sep 17 00:00:00 2001 From: piping Date: Sat, 4 Apr 2026 22:46:10 +0800 Subject: [PATCH 08/60] replay: restore profile fallback runtime --- codex-rs/protocol/src/error.rs | 10 + codex-rs/protocol/src/error_tests.rs | 35 +++ codex-rs/tui/src/app.rs | 207 ++++++++++++++++ codex-rs/tui/src/app_event.rs | 7 + codex-rs/tui/src/chatwidget.rs | 143 +++++++++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/profile_router.rs | 246 +++++++++++++++++++ 8 files changed, 651 insertions(+) create mode 100644 codex-rs/tui/src/profile_router.rs diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 3ffdeb48e..e11abca1e 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -220,6 +220,16 @@ impl CodexErr { CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code: self.http_status_code_value(), }, + CodexErr::UnexpectedStatus(err) => match err.status.as_u16() { + 401 | 403 => CodexErrorInfo::Unauthorized, + 429 => CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(429), + }, + 500..=599 => CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(err.status.as_u16()), + }, + _ => CodexErrorInfo::Other, + }, CodexErr::ConnectionFailed(_) => CodexErrorInfo::HttpConnectionFailed { http_status_code: self.http_status_code_value(), }, diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 0b1d18972..3ccaf6a06 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -71,6 +71,41 @@ fn server_overloaded_maps_to_protocol() { ); } +#[test] +fn unexpected_status_503_maps_to_response_too_many_failed_attempts() { + let err = CodexErr::UnexpectedStatus(UnexpectedResponseError { + status: StatusCode::SERVICE_UNAVAILABLE, + body: "Service temporarily unavailable".to_string(), + url: Some("https://example.com/v1/responses".to_string()), + cf_ray: None, + request_id: None, + identity_authorization_error: None, + identity_error_code: None, + }); + + assert_eq!( + err.to_codex_protocol_error(), + CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(503), + } + ); +} + +#[test] +fn unexpected_status_401_maps_to_unauthorized() { + let err = CodexErr::UnexpectedStatus(UnexpectedResponseError { + status: StatusCode::UNAUTHORIZED, + body: "Unauthorized".to_string(), + url: Some("https://example.com/v1/responses".to_string()), + cf_ray: None, + request_id: None, + identity_authorization_error: None, + identity_error_code: None, + }); + + assert_eq!(err.to_codex_protocol_error(), CodexErrorInfo::Unauthorized); +} + #[test] fn sandbox_denied_uses_aggregated_output_when_stderr_empty() { let output = ExecToolCallOutput { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac9..a2f771c37 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -42,6 +42,10 @@ use crate::multi_agents::format_agent_picker_item_name; use crate::multi_agents::next_agent_shortcut_matches; use crate::multi_agents::previous_agent_shortcut_matches; use crate::pager_overlay::Overlay; +use crate::profile_router::DefaultProfileRouter; +use crate::profile_router::PROFILE_ROUTER_STATE_RELATIVE_PATH; +use crate::profile_router::ProfileFallbackAction; +use crate::profile_router::ProfileRouterStore; use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -1131,6 +1135,197 @@ impl App { } } + fn profile_router_store(&self) -> ProfileRouterStore { + ProfileRouterStore::new(self.config.codex_home.clone()) + } + + async fn apply_runtime_config_change( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + next_config: Config, + reload_live_thread: bool, + ) -> std::result::Result<(), String> { + if !reload_live_thread || self.chat_widget.thread_id().is_none() { + self.active_profile = next_config.active_profile.clone(); + self.config = next_config; + self.chat_widget + .sync_config_for_profile_switch(&self.config); + tui.set_notification_method(self.config.tui_notification_method); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + return Ok(()); + } + + if self.chat_widget.is_task_running() { + return Err("Cannot switch API profile while a task is in progress.".to_string()); + } + + let input_state = self.chat_widget.capture_thread_input_state(); + let thread_id = self + .chat_widget + .thread_id() + .ok_or_else(|| "No active thread to reload after switching profiles.".to_string())?; + let next_thread = app_server + .resume_thread(next_config.clone(), thread_id) + .await + .map_err(|err| { + format!("Failed to reload current session after switching profiles: {err}") + })?; + + self.shutdown_current_thread(app_server).await; + self.active_profile = next_config.active_profile.clone(); + self.config = next_config; + tui.set_notification_method(self.config.tui_notification_method); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + self.replace_chat_widget_with_app_server_thread(tui, app_server, next_thread) + .await + .map_err(|err| err.to_string())?; + self.chat_widget.restore_thread_input_state(input_state); + Ok(()) + } + + async fn switch_runtime_profile( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + profile_id: &str, + ) -> std::result::Result<(), String> { + if self.active_profile.as_deref() == Some(profile_id) { + return Ok(()); + } + + let previous_override = self.harness_overrides.config_profile.clone(); + let previous_active_profile = self.active_profile.clone(); + self.harness_overrides.config_profile = Some(profile_id.to_string()); + + let current_cwd = self.chat_widget.config_ref().cwd.to_path_buf(); + let mut next_config = match self.rebuild_config_for_cwd(current_cwd).await { + Ok(config) => config, + Err(err) => { + self.harness_overrides.config_profile = previous_override; + self.active_profile = previous_active_profile; + return Err(err.to_string()); + } + }; + self.apply_runtime_policy_overrides(&mut next_config); + + if let Err(err) = self + .apply_runtime_config_change( + tui, + app_server, + next_config, + /*reload_live_thread*/ true, + ) + .await + { + self.harness_overrides.config_profile = previous_override; + self.active_profile = previous_active_profile; + return Err(err); + } + Ok(()) + } + + async fn retry_last_user_turn_with_profile_fallback( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + action: ProfileFallbackAction, + error_message: String, + ) { + if !self.chat_widget.has_retryable_user_turn() { + self.chat_widget.add_error_message(error_message); + return; + } + let retry_user_message = self.chat_widget.last_submitted_user_turn(); + + match action { + ProfileFallbackAction::RetrySameProfileFirst => { + let profile_label = self + .chat_widget + .active_profile_label() + .unwrap_or_else(|| "current".to_string()); + if self.chat_widget.profile_retry_attempted() + || !self + .chat_widget + .retry_last_user_turn_for_profile_fallback(format!( + "Retrying the last turn on profile `{profile_label}`." + )) + { + let router_state = self.profile_router_store().load().unwrap_or_default(); + let Some(next_profile_id) = DefaultProfileRouter + .fallback_profile(&router_state, self.active_profile.as_deref()) + else { + self.chat_widget.add_error_message(error_message); + return; + }; + self.chat_widget.finish_failed_turn_for_profile_fallback(); + if let Err(err) = self + .switch_runtime_profile(tui, app_server, &next_profile_id) + .await + { + self.chat_widget.add_error_message(format!( + "Failed to switch to fallback profile `{next_profile_id}`: {err}" + )); + return; + } + if let Err(err) = self.profile_router_store().update(|state| { + state.set_active_profile(&next_profile_id); + }) { + self.chat_widget.add_error_message(format!( + "Failed to persist fallback profile `{next_profile_id}` in {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + return; + } + if let Some(user_message) = retry_user_message.clone() { + self.chat_widget.submit_profile_fallback_retry( + user_message, + format!("Retrying the last turn with profile `{next_profile_id}`."), + ); + } else { + self.chat_widget.add_error_message(error_message); + } + } + } + ProfileFallbackAction::SwitchProfileImmediately => { + let router_state = self.profile_router_store().load().unwrap_or_default(); + let Some(next_profile_id) = DefaultProfileRouter + .fallback_profile(&router_state, self.active_profile.as_deref()) + else { + self.chat_widget.add_error_message(error_message); + return; + }; + self.chat_widget.finish_failed_turn_for_profile_fallback(); + if let Err(err) = self + .switch_runtime_profile(tui, app_server, &next_profile_id) + .await + { + self.chat_widget.add_error_message(format!( + "Failed to switch to fallback profile `{next_profile_id}`: {err}" + )); + return; + } + if let Err(err) = self.profile_router_store().update(|state| { + state.set_active_profile(&next_profile_id); + }) { + self.chat_widget.add_error_message(format!( + "Failed to persist fallback profile `{next_profile_id}` in {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + return; + } + if let Some(user_message) = retry_user_message { + self.chat_widget.submit_profile_fallback_retry( + user_message, + format!("Retrying the last turn with profile `{next_profile_id}`."), + ); + } else { + self.chat_widget.add_error_message(error_message); + } + } + } + } + async fn rebuild_config_for_resume_or_fallback( &mut self, current_cwd: &Path, @@ -4231,6 +4426,18 @@ impl App { } } } + AppEvent::RetryLastUserTurnWithProfileFallback { + action, + error_message, + } => { + self.retry_last_user_turn_with_profile_fallback( + tui, + app_server, + action, + error_message, + ) + .await; + } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { tui.frame_requester().schedule_frame(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index d727963f0..86e51c8e9 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -30,6 +30,7 @@ use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; use crate::history_cell::HistoryCell; +use crate::profile_router::ProfileFallbackAction; use codex_config::types::ApprovalsReviewer; use codex_features::Feature; @@ -266,6 +267,12 @@ pub(crate) enum AppEvent { InsertHistoryCell(Box), + /// Retry the last turn using the routed profile fallback policy. + RetryLastUserTurnWithProfileFallback { + action: ProfileFallbackAction, + error_message: String, + }, + /// Apply rollback semantics to local transcript cells. /// /// This is emitted when rollback was not initiated by the current diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631b..ee375a4f9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -56,6 +56,10 @@ use crate::mention_codec::LinkedMention; use crate::mention_codec::encode_history_mentions; use crate::model_catalog::ModelCatalog; use crate::multi_agents; +use crate::profile_router::ProfileFallbackAction; +use crate::profile_router::app_server_profile_fallback_action; +#[cfg(test)] +use crate::profile_router::core_profile_fallback_action; use crate::status::RateLimitWindowDisplay; use crate::status::StatusAccountDisplay; use crate::status::StatusHistoryHandle; @@ -603,6 +607,13 @@ enum RateLimitErrorKind { Generic, } +fn profile_fallback_action_label(action: ProfileFallbackAction) -> &'static str { + match action { + ProfileFallbackAction::RetrySameProfileFirst => "retry_same_profile_first", + ProfileFallbackAction::SwitchProfileImmediately => "switch_profile_immediately", + } +} + #[cfg(test)] fn core_rate_limit_error_kind(info: &CoreCodexErrorInfo) -> Option { match info { @@ -955,6 +966,8 @@ pub(crate) struct ChatWidget { realtime_conversation: RealtimeConversationUiState, last_rendered_user_message_event: Option, last_non_retry_error: Option<(String, String)>, + last_submitted_user_turn: Option, + profile_retry_attempted: bool, } /// Cached nickname and role for a collab agent thread, used to attach human-readable labels to @@ -2753,6 +2766,21 @@ impl ChatWidget { .as_ref() .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) { + } else if let Some(action) = codex_error_info + .as_ref() + .filter(|_| self.has_retryable_user_turn()) + .and_then(app_server_profile_fallback_action) + { + self.session_telemetry.counter( + "codex.profile_fallback.app_server", + /*inc*/ 1, + &[("action", profile_fallback_action_label(action))], + ); + self.app_event_tx + .send(AppEvent::RetryLastUserTurnWithProfileFallback { + action, + error_message: message, + }); } else if let Some(info) = codex_error_info .as_ref() .and_then(app_server_rate_limit_error_kind) @@ -4714,6 +4742,8 @@ impl ChatWidget { realtime_conversation: RealtimeConversationUiState::default(), last_rendered_user_message_event: None, last_non_retry_error: None, + last_submitted_user_turn: None, + profile_retry_attempted: false, }; widget @@ -5595,6 +5625,16 @@ impl ChatWidget { } let render_in_history = !self.agent_turn_running; + if render_in_history { + self.last_submitted_user_turn = Some(UserMessage { + text: text.clone(), + local_images: local_images.clone(), + remote_image_urls: remote_image_urls.clone(), + text_elements: text_elements.clone(), + mention_bindings: mention_bindings.clone(), + }); + self.profile_retry_attempted = false; + } let mut items: Vec = Vec::new(); // Special-case: "!cmd" executes a local shell command instead of sending to the model. @@ -6866,6 +6906,21 @@ impl ChatWidget { .as_ref() .is_some_and(|info| self.handle_steer_rejected_error(info)) { + } else if let Some(action) = codex_error_info + .as_ref() + .filter(|_| self.has_retryable_user_turn()) + .and_then(core_profile_fallback_action) + { + self.session_telemetry.counter( + "codex.profile_fallback.core", + /*inc*/ 1, + &[("action", profile_fallback_action_label(action))], + ); + self.app_event_tx + .send(AppEvent::RetryLastUserTurnWithProfileFallback { + action, + error_message: message, + }); } else if let Some(kind) = codex_error_info .as_ref() .and_then(core_rate_limit_error_kind) @@ -10301,6 +10356,10 @@ impl ChatWidget { self.bottom_pane.composer_is_empty() } + pub(crate) fn is_task_running(&self) -> bool { + self.bottom_pane.is_task_running() + } + #[cfg(test)] pub(crate) fn is_task_running_for_test(&self) -> bool { self.bottom_pane.is_task_running() @@ -10557,6 +10616,90 @@ impl ChatWidget { self.config.config_layer_stack = config.config_layer_stack.clone(); } + pub(crate) fn sync_config_for_profile_switch(&mut self, config: &Config) { + self.config = config.clone(); + self.set_approval_policy(self.config.permissions.approval_policy.value()); + if let Err(err) = + self.set_sandbox_policy(self.config.permissions.sandbox_policy.get().clone()) + { + tracing::warn!(%err, "failed to set sandbox policy after profile switch"); + } + self.set_approvals_reviewer(self.config.approvals_reviewer); + self.set_reasoning_effort(self.config.model_reasoning_effort); + self.set_plan_mode_reasoning_effort(self.config.plan_mode_reasoning_effort); + self.set_service_tier(self.config.service_tier); + if let Some(model) = self.config.model.clone() { + self.set_model(&model); + } + self.sync_fast_command_enabled(); + self.sync_personality_command_enabled(); + self.sync_plugins_command_enabled(); + self.refresh_plugin_mentions(); + } + + pub(crate) fn active_profile_label(&self) -> Option { + self.config.active_profile.clone() + } + + pub(crate) fn profile_retry_attempted(&self) -> bool { + self.profile_retry_attempted + } + + pub(crate) fn has_retryable_user_turn(&self) -> bool { + self.last_submitted_user_turn.is_some() + } + + pub(crate) fn last_submitted_user_turn(&self) -> Option { + self.last_submitted_user_turn.clone() + } + + pub(crate) fn retry_last_user_turn_for_profile_fallback( + &mut self, + history_message: String, + ) -> bool { + if self.profile_retry_attempted { + return false; + } + let Some(user_message) = self.last_submitted_user_turn.clone() else { + return false; + }; + + self.profile_retry_attempted = true; + self.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + self.add_to_history(history_cell::new_info_event( + history_message, + /*hint*/ None, + )); + self.submit_user_message(user_message); + true + } + + pub(crate) fn submit_profile_fallback_retry( + &mut self, + user_message: UserMessage, + history_message: String, + ) { + self.last_submitted_user_turn = Some(user_message.clone()); + self.profile_retry_attempted = true; + self.submit_pending_steers_after_interrupt = false; + self.add_to_history(history_cell::new_info_event( + history_message, + /*hint*/ None, + )); + self.submit_user_message(user_message); + } + + pub(crate) fn finish_failed_turn_for_profile_fallback(&mut self) { + if !self.bottom_pane.is_task_running() { + return; + } + + self.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + self.request_redraw(); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 46a4129a4..ddd2db8ab 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -287,6 +287,8 @@ pub(super) async fn make_chatwidget_manual( realtime_conversation: RealtimeConversationUiState::default(), last_rendered_user_message_event: None, last_non_retry_error: None, + last_submitted_user_turn: None, + profile_retry_attempted: false, }; widget.set_model(&resolved_model); (widget, rx, op_rx) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 991c91c55..a581506c1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -129,6 +129,7 @@ mod notifications; pub mod onboarding; mod oss_selection; mod pager_overlay; +mod profile_router; pub mod public_widgets; mod render; mod resume_picker; diff --git a/codex-rs/tui/src/profile_router.rs b/codex-rs/tui/src/profile_router.rs new file mode 100644 index 000000000..6c8e2b519 --- /dev/null +++ b/codex-rs/tui/src/profile_router.rs @@ -0,0 +1,246 @@ +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +#[cfg(test)] +use codex_protocol::protocol::CodexErrorInfo; +use serde::Deserialize; +use serde::Serialize; +use std::fs; +use std::io; +use std::path::PathBuf; + +pub(crate) const PROFILE_ROUTER_STATE_RELATIVE_PATH: &str = "accounts/profile-router.json"; +const PROFILE_ROUTER_STATE_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProfileFallbackAction { + RetrySameProfileFirst, + SwitchProfileImmediately, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProfileRouteEntry { + pub(crate) profile_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProfileRouterState { + pub(crate) version: u32, + pub(crate) active_profile_id: Option, + pub(crate) routes: Vec, +} + +impl Default for ProfileRouterState { + fn default() -> Self { + Self { + version: PROFILE_ROUTER_STATE_VERSION, + active_profile_id: None, + routes: Vec::new(), + } + } +} + +impl ProfileRouterState { + pub(crate) fn contains_profile(&self, profile_id: &str) -> bool { + self.routes + .iter() + .any(|route| route.profile_id == profile_id) + } + + pub(crate) fn set_active_profile(&mut self, profile_id: &str) -> bool { + if !self.contains_profile(profile_id) { + return false; + } + let next = Some(profile_id.to_string()); + if self.active_profile_id == next { + false + } else { + self.active_profile_id = next; + true + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct DefaultProfileRouter; + +impl DefaultProfileRouter { + pub(crate) fn fallback_profile( + &self, + state: &ProfileRouterState, + active_profile_id: Option<&str>, + ) -> Option { + state + .routes + .iter() + .find(|route| Some(route.profile_id.as_str()) != active_profile_id) + .map(|route| route.profile_id.clone()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ProfileRouterStore { + codex_home: PathBuf, +} + +impl ProfileRouterStore { + pub(crate) fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + fn path(&self) -> PathBuf { + self.codex_home.join(PROFILE_ROUTER_STATE_RELATIVE_PATH) + } + + pub(crate) fn load(&self) -> io::Result { + let path = self.path(); + match fs::read_to_string(path) { + Ok(contents) => serde_json::from_str(&contents).map_err(io::Error::other), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(ProfileRouterState::default()), + Err(err) => Err(err), + } + } + + fn save(&self, state: &ProfileRouterState) -> io::Result<()> { + let path = self.path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string_pretty(state).map_err(io::Error::other)?; + fs::write(path, contents) + } + + pub(crate) fn update(&self, updater: F) -> io::Result + where + F: FnOnce(&mut ProfileRouterState), + { + let mut state = self.load()?; + updater(&mut state); + self.save(&state)?; + Ok(state) + } +} + +#[cfg(test)] +pub(crate) fn core_profile_fallback_action(info: &CodexErrorInfo) -> Option { + match info { + CodexErrorInfo::UsageLimitExceeded | CodexErrorInfo::Unauthorized => { + Some(ProfileFallbackAction::SwitchProfileImmediately) + } + CodexErrorInfo::ServerOverloaded | CodexErrorInfo::InternalServerError => { + Some(ProfileFallbackAction::RetrySameProfileFirst) + } + CodexErrorInfo::HttpConnectionFailed { http_status_code } + | CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } + | CodexErrorInfo::ResponseStreamDisconnected { http_status_code } + | CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { + match http_status_code { + Some(401 | 403 | 429) => Some(ProfileFallbackAction::SwitchProfileImmediately), + Some(500..=599) | None => Some(ProfileFallbackAction::RetrySameProfileFirst), + Some(_) => None, + } + } + CodexErrorInfo::ContextWindowExceeded + | CodexErrorInfo::BadRequest + | CodexErrorInfo::SandboxError + | CodexErrorInfo::ActiveTurnNotSteerable { .. } + | CodexErrorInfo::ThreadRollbackFailed + | CodexErrorInfo::Other => None, + } +} + +pub(crate) fn app_server_profile_fallback_action( + info: &AppServerCodexErrorInfo, +) -> Option { + match info { + AppServerCodexErrorInfo::UsageLimitExceeded | AppServerCodexErrorInfo::Unauthorized => { + Some(ProfileFallbackAction::SwitchProfileImmediately) + } + AppServerCodexErrorInfo::ServerOverloaded + | AppServerCodexErrorInfo::InternalServerError => { + Some(ProfileFallbackAction::RetrySameProfileFirst) + } + AppServerCodexErrorInfo::HttpConnectionFailed { http_status_code } + | AppServerCodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } + | AppServerCodexErrorInfo::ResponseStreamDisconnected { http_status_code } + | AppServerCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { + match http_status_code { + Some(401 | 403 | 429) => Some(ProfileFallbackAction::SwitchProfileImmediately), + Some(500..=599) | None => Some(ProfileFallbackAction::RetrySameProfileFirst), + Some(_) => None, + } + } + AppServerCodexErrorInfo::ContextWindowExceeded + | AppServerCodexErrorInfo::BadRequest + | AppServerCodexErrorInfo::ThreadRollbackFailed + | AppServerCodexErrorInfo::SandboxError + | AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } + | AppServerCodexErrorInfo::Other => None, + } +} + +#[cfg(test)] +mod tests { + use super::DefaultProfileRouter; + use super::ProfileFallbackAction; + use super::ProfileRouteEntry; + use super::ProfileRouterState; + use super::ProfileRouterStore; + use super::app_server_profile_fallback_action; + use super::core_profile_fallback_action; + use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; + use codex_protocol::protocol::CodexErrorInfo; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn fallback_profile_skips_current_profile() { + let state = ProfileRouterState { + version: 1, + active_profile_id: Some("primary".to_string()), + routes: vec![ + ProfileRouteEntry { + profile_id: "primary".to_string(), + }, + ProfileRouteEntry { + profile_id: "secondary".to_string(), + }, + ], + }; + + let fallback = DefaultProfileRouter + .fallback_profile(&state, /*active_profile_id*/ Some("primary")); + + assert_eq!(fallback, Some("secondary".to_string())); + } + + #[test] + fn profile_router_store_defaults_when_file_is_missing() { + let tempdir = TempDir::new().unwrap(); + let store = ProfileRouterStore::new(tempdir.path().to_path_buf()); + + let state = store.load().unwrap(); + + assert_eq!(state, ProfileRouterState::default()); + } + + #[test] + fn unexpected_status_503_is_fallback_eligible_for_core_errors() { + let action = core_profile_fallback_action(&CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(503), + }); + + assert_eq!(action, Some(ProfileFallbackAction::RetrySameProfileFirst)); + } + + #[test] + fn unexpected_status_503_is_fallback_eligible_for_app_server_errors() { + let action = app_server_profile_fallback_action( + &AppServerCodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(503), + }, + ); + + assert_eq!(action, Some(ProfileFallbackAction::RetrySameProfileFirst)); + } +} From d1461727f1476ad3b6ba725f25daea64e1382997 Mon Sep 17 00:00:00 2001 From: piping Date: Sat, 4 Apr 2026 23:11:45 +0800 Subject: [PATCH 09/60] replay: restore routed profile app-server continuity --- codex-rs/tui/src/app.rs | 114 +++++++++++++++++- codex-rs/tui/src/chatwidget.rs | 8 ++ .../chatwidget/tests/composer_submission.rs | 59 +++++++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 + 4 files changed, 180 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a2f771c37..0599530fc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1120,6 +1120,7 @@ impl App { .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) .await?; self.apply_runtime_policy_overrides(&mut config); + self.active_profile = config.active_profile.clone(); self.config = config; self.chat_widget.sync_plugin_mentions_config(&self.config); Ok(()) @@ -1139,6 +1140,13 @@ impl App { ProfileRouterStore::new(self.config.codex_home.clone()) } + fn routed_profile_runtime_changed(current_config: &Config, next_config: &Config) -> bool { + current_config.active_profile != next_config.active_profile + || current_config.model_provider_id != next_config.model_provider_id + || current_config.model_provider != next_config.model_provider + || current_config.chatgpt_base_url != next_config.chatgpt_base_url + } + async fn apply_runtime_config_change( &mut self, tui: &mut tui::Tui, @@ -1186,6 +1194,40 @@ impl App { Ok(()) } + async fn reload_user_config_for_app_server_runtime( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + ) -> Result<()> { + let current_cwd = self.chat_widget.config_ref().cwd.to_path_buf(); + let mut next_config = self.rebuild_config_for_cwd(current_cwd).await?; + self.apply_runtime_policy_overrides(&mut next_config); + + let reload_live_thread = Self::routed_profile_runtime_changed(&self.config, &next_config); + if reload_live_thread { + self.apply_runtime_config_change( + tui, + app_server, + next_config, + /*reload_live_thread*/ true, + ) + .await + .map_err(|err| color_eyre::eyre::eyre!(err))?; + app_server.reload_user_config().await?; + } else { + app_server.reload_user_config().await?; + self.apply_runtime_config_change( + tui, + app_server, + next_config, + /*reload_live_thread*/ false, + ) + .await + .map_err(|err| color_eyre::eyre::eyre!(err))?; + } + Ok(()) + } + async fn switch_runtime_profile( &mut self, tui: &mut tui::Tui, @@ -2019,6 +2061,7 @@ impl App { async fn submit_active_thread_op( &mut self, + tui: &mut tui::Tui, app_server: &mut AppServerSession, op: AppCommand, ) -> Result<()> { @@ -2028,11 +2071,12 @@ impl App { return Ok(()); }; - self.submit_thread_op(app_server, thread_id, op).await + self.submit_thread_op(tui, app_server, thread_id, op).await } async fn submit_thread_op( &mut self, + tui: &mut tui::Tui, app_server: &mut AppServerSession, thread_id: ThreadId, op: AppCommand, @@ -2050,6 +2094,12 @@ impl App { return Ok(()); } + if matches!(op.view(), AppCommandView::ReloadUserConfig) { + self.reload_user_config_for_app_server_runtime(tui, app_server) + .await?; + return Ok(()); + } + if self .try_submit_active_thread_op_via_app_server(app_server, thread_id, &op) .await? @@ -4472,10 +4522,11 @@ impl App { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } AppEvent::CodexOp(op) => { - self.submit_active_thread_op(app_server, op.into()).await?; + self.submit_active_thread_op(tui, app_server, op.into()) + .await?; } AppEvent::SubmitThreadOp { thread_id, op } => { - self.submit_thread_op(app_server, thread_id, op.into()) + self.submit_thread_op(tui, app_server, thread_id, op.into()) .await?; } AppEvent::ThreadHistoryEntryResponse { thread_id, event } => { @@ -10307,6 +10358,63 @@ guardian_approval = true Ok(()) } + #[tokio::test] + async fn refresh_in_memory_config_from_disk_syncs_active_profile() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf(); + app.active_profile = Some("stale".to_string()); + + std::fs::write( + codex_home.path().join("config.toml"), + r#" +profile = "fresh" + +[profiles.fresh] +model = "gpt-5.2" +"#, + )?; + + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!(app.config.active_profile.as_deref(), Some("fresh")); + assert_eq!(app.active_profile.as_deref(), Some("fresh")); + Ok(()) + } + + #[tokio::test] + async fn routed_profile_runtime_changed_detects_profile_and_provider_reload_inputs() { + let app = make_test_app().await; + let current_config = app.config.clone(); + + let mut next_profile = current_config.clone(); + next_profile.active_profile = Some("secondary".to_string()); + assert!(App::routed_profile_runtime_changed( + ¤t_config, + &next_profile + )); + + let mut next_provider = current_config.clone(); + next_provider.model_provider.base_url = Some("https://example.com/v1".to_string()); + assert!(App::routed_profile_runtime_changed( + ¤t_config, + &next_provider + )); + + let mut next_chatgpt_base_url = current_config.clone(); + next_chatgpt_base_url.chatgpt_base_url = + "https://chatgpt.example.com/backend-api/".to_string(); + assert!(App::routed_profile_runtime_changed( + ¤t_config, + &next_chatgpt_base_url + )); + + assert!(!App::routed_profile_runtime_changed( + ¤t_config, + ¤t_config + )); + } + #[tokio::test] async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error() -> Result<()> { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ee375a4f9..b3941f0ba 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1053,6 +1053,8 @@ pub(crate) struct ThreadInputState { pending_steers: VecDeque, rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, + last_submitted_user_turn: Option, + profile_retry_attempted: bool, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, task_running: bool, @@ -3159,6 +3161,8 @@ impl ChatWidget { .collect(), rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), + last_submitted_user_turn: self.last_submitted_user_turn.clone(), + profile_retry_attempted: self.profile_retry_attempted, current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), task_running: self.bottom_pane.is_task_running(), @@ -3213,6 +3217,8 @@ impl ChatWidget { .collect(); self.rejected_steers_queue = input_state.rejected_steers_queue; self.queued_user_messages = input_state.queued_user_messages; + self.last_submitted_user_turn = input_state.last_submitted_user_turn; + self.profile_retry_attempted = input_state.profile_retry_attempted; } else { self.agent_turn_running = false; self.pending_steers.clear(); @@ -3226,6 +3232,8 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); self.queued_user_messages.clear(); + self.last_submitted_user_turn = None; + self.profile_retry_attempted = false; } self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b9..bb2f040df 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -795,6 +795,8 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { pending_steers: VecDeque::new(), rejected_steers_queue: VecDeque::new(), queued_user_messages: VecDeque::new(), + last_submitted_user_turn: None, + profile_retry_attempted: false, current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), task_running: true, @@ -812,6 +814,63 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn capture_and_restore_thread_input_state_preserves_profile_fallback_retry_state() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(codex_protocol::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(rollout_file.path().to_path_buf()), + }), + }); + drain_insert_history(&mut rx); + + chat.bottom_pane + .set_composer_text("retry me".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let _ = next_submit_op(&mut op_rx); + + assert_eq!( + chat.last_submitted_user_turn(), + Some(UserMessage::from("retry me")) + ); + assert!(!chat.profile_retry_attempted()); + + let snapshot = chat.capture_thread_input_state(); + chat.restore_thread_input_state(/*input_state*/ None); + + assert_eq!(chat.last_submitted_user_turn(), None); + assert!(!chat.profile_retry_attempted()); + + chat.restore_thread_input_state(snapshot); + + assert_eq!( + chat.last_submitted_user_turn(), + Some(UserMessage::from("retry me")) + ); + assert!(!chat.profile_retry_attempted()); + assert!(chat.retry_last_user_turn_for_profile_fallback( + "Retrying the last turn on profile `primary`.".to_string() + )); +} + #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a0..6ff03a79d 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -354,6 +354,8 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ pending_steers, rejected_steers_queue, queued_user_messages, + last_submitted_user_turn: None, + profile_retry_attempted: false, current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), task_running: false, From ac08abeae8a8bacc7124d2048a3cffc8af31ab22 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 23:20:32 +0800 Subject: [PATCH 10/60] fix profile switch issue due to thread_unsubscribe --- codex-rs/tui/src/app.rs | 110 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0599530fc..10a5f19a2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -168,6 +168,7 @@ use self::pending_interactive_replay::PendingInteractiveReplayState; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; +const PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); enum ThreadInteractiveRequest { Approval(ApprovalRequest), @@ -1170,18 +1171,30 @@ impl App { } let input_state = self.chat_widget.capture_thread_input_state(); + let can_resume_live_thread = self + .chat_widget + .rollout_path() + .as_ref() + .is_some_and(|path| path.exists()); let thread_id = self .chat_widget .thread_id() .ok_or_else(|| "No active thread to reload after switching profiles.".to_string())?; - let next_thread = app_server - .resume_thread(next_config.clone(), thread_id) - .await - .map_err(|err| { - format!("Failed to reload current session after switching profiles: {err}") - })?; + self.close_active_thread_for_profile_reload(app_server, thread_id) + .await?; + let next_thread = if can_resume_live_thread { + app_server + .resume_thread(next_config.clone(), thread_id) + .await + .map_err(|err| { + format!("Failed to reload current session after switching profiles: {err}") + })? + } else { + app_server.start_thread(&next_config).await.map_err(|err| { + format!("Failed to start a fresh session after switching profiles: {err}") + })? + }; - self.shutdown_current_thread(app_server).await; self.active_profile = next_config.active_profile.clone(); self.config = next_config; tui.set_notification_method(self.config.tui_notification_method); @@ -1194,6 +1207,61 @@ impl App { Ok(()) } + async fn close_active_thread_for_profile_reload( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) -> std::result::Result<(), String> { + self.backtrack.pending_rollback = None; + app_server + .thread_unsubscribe(thread_id) + .await + .map_err(|err| { + format!("Failed to unload current session before switching profiles: {err}") + })?; + + let close_wait = async { + loop { + match app_server.next_event().await { + Some(codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::ThreadClosed(notification), + )) if notification.thread_id == thread_id.to_string() => { + break Ok(()); + } + Some(codex_app_server_client::AppServerEvent::Disconnected { message }) => { + self.chat_widget.add_error_message(message.clone()); + self.app_event_tx + .send(AppEvent::FatalExitRequest(message.clone())); + break Err(format!( + "App-server disconnected while closing current session: {message}" + )); + } + Some(event) => { + self.handle_app_server_event(app_server, event).await; + } + None => { + break Err( + "App-server event stream closed while closing current session." + .to_string(), + ); + } + } + } + }; + + tokio::time::timeout(PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT, close_wait) + .await + .map_err(|_| { + format!( + "Timed out waiting for current session `{thread_id}` to close before switching profiles." + ) + })??; + + self.clear_active_thread().await; + self.abort_thread_event_listener(thread_id); + Ok(()) + } + async fn reload_user_config_for_app_server_runtime( &mut self, tui: &mut tui::Tui, @@ -11135,6 +11203,34 @@ model = "gpt-5.2" assert_eq!(handled, true); } + #[tokio::test] + async fn closing_active_thread_for_profile_reload_allows_fresh_start() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); + + app.close_active_thread_for_profile_reload(&mut app_server, thread_id) + .await + .map_err(|err| color_eyre::eyre::eyre!(err))?; + + assert_eq!(app.active_thread_id, None); + + let restarted = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + assert_ne!(restarted.session.thread_id, thread_id); + Ok(()) + } + #[tokio::test] async fn clear_only_ui_reset_preserves_chat_session_state() { let mut app = make_test_app().await; From fed91c14a8a447ec4008015496eb5d62dad61e10 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 02:04:25 +0800 Subject: [PATCH 11/60] replay: preserve session args on respawn --- codex-rs/cli/src/main.rs | 314 ++++++++++++++++++++++++++++++++-- codex-rs/tui/src/app.rs | 53 ++++++ codex-rs/tui/src/app_event.rs | 2 + codex-rs/tui/src/lib.rs | 6 + 4 files changed, 364 insertions(+), 11 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1c4f5068f..d05fdc4a9 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -30,6 +30,7 @@ use codex_tui::ExitReason; use codex_tui::update_action::UpdateAction; use codex_utils_cli::CliConfigOverrides; use owo_colors::OwoColorize; +use std::ffi::OsString; use std::io::IsTerminal; use std::path::PathBuf; use supports_color::Stream; @@ -461,6 +462,28 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec anyhow::Result<()> { + if matches!(exit_info.exit_reason, ExitReason::RespawnRequested) { + let Some(thread_id) = exit_info.thread_id.as_ref() else { + anyhow::bail!("cannot respawn Codex: current session has no thread id"); + }; + respawn_current_codex_session( + arg0_paths, + respawn_args, + &thread_id.to_string(), + exit_info.respawn_with_yolo, + )?; + return Ok(()); + } + + handle_app_exit(exit_info) +} + /// Handle the app exit and print the results. Optionally run the update action. fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { match exit_info.exit_reason { @@ -469,6 +492,7 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { std::process::exit(1); } ExitReason::UserRequested => { /* normal exit */ } + ExitReason::RespawnRequested => unreachable!("respawn should be handled before formatting"), } let update_action = exit_info.update_action; @@ -482,6 +506,167 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { Ok(()) } +fn respawn_current_codex_session( + arg0_paths: &Arg0DispatchPaths, + respawn_args: &[OsString], + thread_id: &str, + respawn_with_yolo: bool, +) -> anyhow::Result<()> { + let Some(exe_path) = arg0_paths.codex_self_exe.as_ref() else { + anyhow::bail!("unable to respawn Codex: current executable path is unavailable"); + }; + + let mut command = std::process::Command::new(exe_path); + command.args(build_codex_respawn_argv( + respawn_args, + thread_id, + respawn_with_yolo, + )); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + + let error = command.exec(); + anyhow::bail!( + "failed to respawn Codex via {}: {error}", + exe_path.display() + ); + } + + #[cfg(not(unix))] + { + command + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + command.spawn().map_err(|error| { + anyhow::anyhow!( + "failed to respawn Codex via {}: {error}", + exe_path.display() + ) + })?; + Ok(()) + } +} + +fn build_codex_respawn_argv( + respawn_args: &[OsString], + thread_id: &str, + respawn_with_yolo: bool, +) -> Vec { + let mut args = normalize_respawn_mode_args(respawn_args, respawn_with_yolo); + args.push("resume".into()); + args.push(thread_id.into()); + if respawn_with_yolo { + args.push("--yolo".into()); + } + args +} + +fn normalize_respawn_mode_args(args: &[OsString], respawn_with_yolo: bool) -> Vec { + let mut normalized = Vec::new(); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + let Some(arg_str) = arg.to_str() else { + normalized.push(arg.clone()); + continue; + }; + match arg_str { + "--yolo" | "--dangerously-bypass-approvals-and-sandbox" => {} + "--ask-for-approval" | "--sandbox" if respawn_with_yolo => { + let _ = iter.next(); + } + "--full-auto" if respawn_with_yolo => {} + _ => normalized.push(arg.clone()), + } + } + normalized +} + +fn approval_mode_cli_arg_name(value: codex_utils_cli::ApprovalModeCliArg) -> &'static str { + match value { + codex_utils_cli::ApprovalModeCliArg::Untrusted => "untrusted", + codex_utils_cli::ApprovalModeCliArg::OnFailure => "on-failure", + codex_utils_cli::ApprovalModeCliArg::OnRequest => "on-request", + codex_utils_cli::ApprovalModeCliArg::Never => "never", + } +} + +fn sandbox_mode_cli_arg_name(value: codex_utils_cli::SandboxModeCliArg) -> &'static str { + match value { + codex_utils_cli::SandboxModeCliArg::ReadOnly => "read-only", + codex_utils_cli::SandboxModeCliArg::WorkspaceWrite => "workspace-write", + codex_utils_cli::SandboxModeCliArg::DangerFullAccess => "danger-full-access", + } +} + +fn push_arg_value(args: &mut Vec, flag: &'static str, value: impl Into) { + args.push(flag.into()); + args.push(value.into()); +} + +fn extend_tui_cli_respawn_args(args: &mut Vec, interactive: &TuiCli) { + if let Some(model) = &interactive.model { + push_arg_value(args, "--model", model.clone()); + } + if interactive.oss { + args.push("--oss".into()); + } + if let Some(provider) = &interactive.oss_provider { + push_arg_value(args, "--local-provider", provider.clone()); + } + if let Some(profile) = &interactive.config_profile { + push_arg_value(args, "--profile", profile.clone()); + } + if let Some(sandbox_mode) = interactive.sandbox_mode { + push_arg_value(args, "--sandbox", sandbox_mode_cli_arg_name(sandbox_mode)); + } + if let Some(approval_policy) = interactive.approval_policy { + push_arg_value( + args, + "--ask-for-approval", + approval_mode_cli_arg_name(approval_policy), + ); + } + if interactive.full_auto { + args.push("--full-auto".into()); + } + if interactive.dangerously_bypass_approvals_and_sandbox { + args.push("--yolo".into()); + } + if let Some(cwd) = &interactive.cwd { + push_arg_value(args, "--cd", cwd.as_os_str().to_os_string()); + } + if interactive.web_search { + args.push("--search".into()); + } + for dir in &interactive.add_dir { + push_arg_value(args, "--add-dir", dir.as_os_str().to_os_string()); + } + if interactive.no_alt_screen { + args.push("--no-alt-screen".into()); + } + for raw_override in &interactive.config_overrides.raw_overrides { + push_arg_value(args, "-c", raw_override.clone()); + } +} + +fn build_interactive_respawn_args( + remote: &InteractiveRemoteOptions, + interactive: &TuiCli, +) -> Vec { + let mut args = Vec::new(); + if let Some(remote_addr) = &remote.remote { + push_arg_value(&mut args, "--remote", remote_addr.clone()); + } + if let Some(env_var) = &remote.remote_auth_token_env { + push_arg_value(&mut args, "--remote-auth-token-env", env_var.clone()); + } + extend_tui_cli_respawn_args(&mut args, interactive); + args +} + /// Run the update action and print the result. fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { println!(); @@ -638,6 +823,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { &mut interactive.config_overrides, root_config_overrides.clone(), ); + let respawn_args = build_interactive_respawn_args( + &InteractiveRemoteOptions { + remote: root_remote.clone(), + remote_auth_token_env: root_remote_auth_token_env.clone(), + }, + &interactive, + ); let exit_info = run_interactive_tui( interactive, root_remote.clone(), @@ -645,7 +837,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { arg0_paths.clone(), ) .await?; - handle_app_exit(exit_info)?; + finish_interactive_exit(exit_info, &arg0_paths, &respawn_args)?; } Some(Subcommand::Exec(mut exec_cli)) => { reject_remote_mode_for_subcommand( @@ -766,16 +958,21 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { include_non_interactive, config_overrides, ); - let exit_info = run_interactive_tui( - interactive, - remote.remote.or(root_remote.clone()), - remote + let effective_remote = InteractiveRemoteOptions { + remote: remote.remote.or(root_remote.clone()), + remote_auth_token_env: remote .remote_auth_token_env .or(root_remote_auth_token_env.clone()), + }; + let respawn_args = build_interactive_respawn_args(&effective_remote, &interactive); + let exit_info = run_interactive_tui( + interactive, + effective_remote.remote, + effective_remote.remote_auth_token_env, arg0_paths.clone(), ) .await?; - handle_app_exit(exit_info)?; + finish_interactive_exit(exit_info, &arg0_paths, &respawn_args)?; } Some(Subcommand::Fork(ForkCommand { session_id, @@ -792,16 +989,21 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { all, config_overrides, ); - let exit_info = run_interactive_tui( - interactive, - remote.remote.or(root_remote.clone()), - remote + let effective_remote = InteractiveRemoteOptions { + remote: remote.remote.or(root_remote.clone()), + remote_auth_token_env: remote .remote_auth_token_env .or(root_remote_auth_token_env.clone()), + }; + let respawn_args = build_interactive_respawn_args(&effective_remote, &interactive); + let exit_info = run_interactive_tui( + interactive, + effective_remote.remote, + effective_remote.remote_auth_token_env, arg0_paths.clone(), ) .await?; - handle_app_exit(exit_info)?; + finish_interactive_exit(exit_info, &arg0_paths, &respawn_args)?; } Some(Subcommand::Login(mut login_cli)) => { reject_remote_mode_for_subcommand( @@ -1474,6 +1676,12 @@ mod tests { use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; + fn lossy_args(args: &[OsString]) -> Vec { + args.iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + } + fn finalize_resume_from_args(args: &[&str]) -> TuiCli { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let MultitoolCli { @@ -1624,6 +1832,7 @@ mod tests { .map(Result::unwrap), thread_name: thread_name.map(str::to_string), update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, } } @@ -1635,12 +1844,95 @@ mod tests { thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }; let lines = format_exit_messages(exit_info, /*color_enabled*/ false); assert!(lines.is_empty()); } + #[test] + fn build_interactive_respawn_args_preserves_effective_session_args() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "--remote", + "ws://127.0.0.1:4500", + "--remote-auth-token-env", + "CODEX_AUTH", + "--model", + "gpt-5", + "--profile", + "work", + "--cd", + "/tmp/project", + "--search", + "--add-dir", + "/tmp/extra", + "--no-alt-screen", + "-c", + "foo=1", + ]) + .expect("parse"); + let MultitoolCli { + mut interactive, + config_overrides: root_overrides, + remote, + .. + } = cli; + prepend_config_flags(&mut interactive.config_overrides, root_overrides); + + let args = build_interactive_respawn_args(&remote, &interactive); + + assert_eq!( + lossy_args(&args), + vec![ + "--remote", + "ws://127.0.0.1:4500", + "--remote-auth-token-env", + "CODEX_AUTH", + "--model", + "gpt-5", + "--profile", + "work", + "--cd", + "/tmp/project", + "--search", + "--add-dir", + "/tmp/extra", + "--no-alt-screen", + "-c", + "foo=1", + ] + ); + } + + #[test] + fn build_codex_respawn_argv_rewrites_mode_flags_for_yolo_respawn() { + let respawn_args = vec![ + OsString::from("--remote"), + OsString::from("ws://127.0.0.1:4500"), + OsString::from("--sandbox"), + OsString::from("workspace-write"), + OsString::from("--ask-for-approval"), + OsString::from("on-request"), + OsString::from("--full-auto"), + ]; + + let argv = + build_codex_respawn_argv(&respawn_args, "thread-123", /*respawn_with_yolo*/ true); + + assert_eq!( + lossy_args(&argv), + vec![ + "--remote", + "ws://127.0.0.1:4500", + "resume", + "thread-123", + "--yolo", + ] + ); + } + #[test] fn format_exit_messages_includes_resume_hint_without_color() { let exit_info = sample_exit_info( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 10a5f19a2..c31ea9f08 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -275,6 +275,30 @@ fn guardian_approvals_mode() -> GuardianApprovalsMode { sandbox_policy: SandboxPolicy::new_workspace_write_policy(), } } + +fn should_respawn_with_yolo(config: &Config) -> bool { + config.permissions.approval_policy.value() == AskForApproval::Never + && *config.permissions.sandbox_policy.get() == SandboxPolicy::DangerFullAccess +} + +#[cfg(unix)] +fn spawn_respawn_signal_listener(app_event_tx: AppEventSender) -> std::io::Result<()> { + use tokio::signal::unix::SignalKind; + + let mut signal = tokio::signal::unix::signal(SignalKind::user_defined1())?; + tokio::spawn(async move { + if signal.recv().await.is_some() { + app_event_tx.send(AppEvent::Exit(ExitMode::RespawnImmediate)); + } + }); + Ok(()) +} + +#[cfg(not(unix))] +fn spawn_respawn_signal_listener(_app_event_tx: AppEventSender) -> std::io::Result<()> { + Ok(()) +} + /// Baseline cadence for periodic stream commit animation ticks. /// /// Smooth-mode streaming drains one line per tick, so this interval controls @@ -287,6 +311,7 @@ pub struct AppExitInfo { pub thread_id: Option, pub thread_name: Option, pub update_action: Option, + pub respawn_with_yolo: bool, pub exit_reason: ExitReason, } @@ -297,6 +322,7 @@ impl AppExitInfo { thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::Fatal(message.into()), } } @@ -311,6 +337,7 @@ pub(crate) enum AppRunControl { #[derive(Debug, Clone)] pub enum ExitReason { UserRequested, + RespawnRequested, Fatal(String), } @@ -931,6 +958,7 @@ async fn handle_model_migration_prompt_if_needed( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -3878,6 +3906,7 @@ impl App { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); + spawn_respawn_signal_listener(app_event_tx.clone())?; emit_project_config_warnings(&app_event_tx, &config); emit_system_bwrap_warning(&app_event_tx, &config); tui.set_notification_method(config.tui_notification_method); @@ -4251,6 +4280,7 @@ impl App { thread_id: app.chat_widget.thread_id(), thread_name: app.chat_widget.thread_name(), update_action: app.pending_update_action, + respawn_with_yolo: should_respawn_with_yolo(&app.config), exit_reason, }) } @@ -5872,6 +5902,10 @@ impl App { self.pending_shutdown_exit_thread_id = None; AppRunControl::Exit(ExitReason::UserRequested) } + ExitMode::RespawnImmediate => { + self.pending_shutdown_exit_thread_id = None; + AppRunControl::Exit(ExitReason::RespawnRequested) + } } } @@ -11185,6 +11219,25 @@ model = "gpt-5.2" ); } + #[tokio::test] + async fn respawn_immediate_exit_returns_respawn_requested() { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + + let control = app + .handle_exit_mode(&mut app_server, ExitMode::RespawnImmediate) + .await; + + assert_eq!(app.pending_shutdown_exit_thread_id, None); + assert!(matches!( + control, + AppRunControl::Exit(ExitReason::RespawnRequested) + )); + } + #[tokio::test] async fn interrupt_without_active_turn_is_treated_as_handled() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 86e51c8e9..a2ab65b3b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -590,6 +590,8 @@ pub(crate) enum ExitMode { /// This skips `Op::Shutdown`, so any in-flight work may be dropped and /// cleanup that normally runs before `ShutdownComplete` can be missed. Immediate, + /// Exit the UI loop immediately and request the outer CLI to respawn this session. + RespawnImmediate, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a581506c1..225837a03 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1003,6 +1003,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: Some(action), + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1076,6 +1077,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1122,6 +1124,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::Fatal(format!( "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." )), @@ -1209,6 +1212,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1270,6 +1274,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1316,6 +1321,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } From 4a54d8719f55efb9592f32327e07f6712fdc755f Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 02:09:58 +0800 Subject: [PATCH 12/60] replay: harden startup tooltip tls handling --- codex-rs/Cargo.lock | 1 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/lib.rs | 5 +++-- codex-rs/tui/src/tooltips.rs | 40 +++++++++++++++++++++++++++++------- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5cc4fe2ee..721344950 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2807,6 +2807,7 @@ dependencies = [ "codex-utils-fuzzy-match", "codex-utils-oss", "codex-utils-pty", + "codex-utils-rustls-provider", "codex-utils-sandbox-summary", "codex-utils-sleep-inhibitor", "codex-utils-string", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 696ab503b..57fff5bae 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -52,6 +52,7 @@ codex-utils-cli = { workspace = true } codex-utils-elapsed = { workspace = true } codex-utils-fuzzy-match = { workspace = true } codex-utils-oss = { workspace = true } +codex-utils-rustls-provider = { workspace = true } codex-utils-sandbox-summary = { workspace = true } codex-utils-sleep-inhibitor = { workspace = true } codex-utils-string = { workspace = true } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 225837a03..46974bc13 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -971,8 +971,6 @@ async fn run_ratatui_app( let remote_mode = matches!(&app_server_target, AppServerTarget::Remote { .. }); color_eyre::install()?; - tooltips::announcement::prewarm(); - // Forward panic reports through tracing so they appear in the UI status // line, but do not swallow the default/color-eyre panic handler. // Chain to the previous hook so users still get a rich panic report @@ -982,6 +980,9 @@ async fn run_ratatui_app( tracing::error!("panic: {info}"); prev_hook(info); })); + + tooltips::announcement::prewarm(); + let mut terminal = tui::init()?; terminal.clear()?; diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index cacb94b8e..7b57b93cb 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -123,11 +123,13 @@ pub(crate) mod announcement { use crate::version::CODEX_CLI_VERSION; use chrono::NaiveDate; use chrono::Utc; + use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use regex_lite::Regex; use serde::Deserialize; use std::sync::OnceLock; use std::thread; use std::time::Duration; + use tracing::warn; static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); @@ -177,16 +179,40 @@ pub(crate) mod announcement { fn blocking_init_announcement_tip() -> Option { // Avoid system proxy detection to prevent macOS system-configuration panics (#8912). - let client = reqwest::blocking::Client::builder() - .no_proxy() - .build() - .ok()?; - let response = client + ensure_rustls_crypto_provider(); + + let client = match reqwest::blocking::Client::builder().no_proxy().build() { + Ok(client) => client, + Err(error) => { + warn!(error = %error, "failed to build announcement tooltip HTTP client"); + return None; + } + }; + let response = match client .get(ANNOUNCEMENT_TIP_URL) .timeout(Duration::from_millis(2000)) .send() - .ok()?; - response.error_for_status().ok()?.text().ok() + { + Ok(response) => response, + Err(error) => { + warn!(error = %error, url = ANNOUNCEMENT_TIP_URL, "failed to fetch announcement tooltip"); + return None; + } + }; + let response = match response.error_for_status() { + Ok(response) => response, + Err(error) => { + warn!(error = %error, url = ANNOUNCEMENT_TIP_URL, "announcement tooltip request returned error status"); + return None; + } + }; + match response.text() { + Ok(text) => Some(text), + Err(error) => { + warn!(error = %error, url = ANNOUNCEMENT_TIP_URL, "failed to read announcement tooltip response body"); + None + } + } } pub(crate) fn parse_announcement_tip_toml(text: &str) -> Option { From cb099a038f56cdda19228a57bf4433f390808aea Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 08:24:33 +0800 Subject: [PATCH 13/60] replay: restore question tool runtime --- codex-rs/app-server/README.md | 4 +- .../app-server/tests/suite/v2/turn_start.rs | 12 +- .../src/tools/handlers/request_user_input.rs | 26 +- codex-rs/core/src/tools/spec_tests.rs | 9 + .../core/tests/suite/request_user_input.rs | 210 +++++++++++++---- .../src/collaboration_mode_presets.rs | 20 +- .../src/collaboration_mode_presets_tests.rs | 10 +- codex-rs/tools/README.md | 2 +- codex-rs/tools/src/lib.rs | 5 + codex-rs/tools/src/request_user_input_tool.rs | 164 +++++++++++-- .../src/request_user_input_tool_tests.rs | 222 +++++++++++++++++- codex-rs/tools/src/tool_registry_plan.rs | 11 + .../tools/src/tool_registry_plan_tests.rs | 18 +- 13 files changed, 603 insertions(+), 110 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c24c3fd7e..64f788548 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -994,9 +994,9 @@ Order of messages: UI guidance for IDEs: surface an approval dialog as soon as the request arrives. The turn will proceed after the server receives a response to the approval request. The terminal `item/completed` notification will be sent with the appropriate status. -### request_user_input +### question / request_user_input -When the client responds to `item/tool/requestUserInput`, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup. +When the client responds to `item/tool/requestUserInput`, whether it originated from the `question` tool or the legacy `request_user_input` tool, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup. ### MCP server elicitations diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index b99d1cb73..126a0043b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -586,7 +586,11 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { let payload = request.body_json(); assert_eq!(payload["model"].as_str(), Some("mock-model-collab")); let payload_text = payload.to_string(); - assert!(payload_text.contains("The `request_user_input` tool is available in Default mode.")); + assert!( + payload_text.contains( + "The `question` and `request_user_input` tools are available in Default mode." + ) + ); Ok(()) } @@ -671,7 +675,11 @@ async fn turn_start_uses_thread_feature_overrides_for_collaboration_mode_instruc let request = response_mock.single_request(); let payload_text = request.body_json().to_string(); - assert!(payload_text.contains("The `request_user_input` tool is available in Default mode.")); + assert!( + payload_text.contains( + "The `question` and `request_user_input` tools are available in Default mode." + ) + ); Ok(()) } diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index dcc9445a9..09c9b2fac 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -6,8 +6,9 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use codex_protocol::request_user_input::RequestUserInputArgs; -use codex_tools::REQUEST_USER_INPUT_TOOL_NAME; -use codex_tools::normalize_request_user_input_args; +use codex_tools::QUESTION_TOOL_NAME; +use codex_tools::normalize_request_user_input_args_for_tool; +use codex_tools::question_unavailable_message; use codex_tools::request_user_input_unavailable_message; pub struct RequestUserInputHandler { @@ -26,6 +27,7 @@ impl ToolHandler for RequestUserInputHandler { session, turn, call_id, + tool_name, payload, .. } = invocation; @@ -34,34 +36,34 @@ impl ToolHandler for RequestUserInputHandler { ToolPayload::Function { arguments } => arguments, _ => { return Err(FunctionCallError::RespondToModel(format!( - "{REQUEST_USER_INPUT_TOOL_NAME} handler received unsupported payload" + "{tool_name} handler received unsupported payload" ))); } }; let mode = session.collaboration_mode().await.mode; - if let Some(message) = - request_user_input_unavailable_message(mode, self.default_mode_request_user_input) - { + let unavailable_message = match tool_name.as_str() { + QUESTION_TOOL_NAME => question_unavailable_message(mode), + _ => request_user_input_unavailable_message(mode, self.default_mode_request_user_input), + }; + if let Some(message) = unavailable_message { return Err(FunctionCallError::RespondToModel(message)); } let args: RequestUserInputArgs = parse_arguments(&arguments)?; - let args = - normalize_request_user_input_args(args).map_err(FunctionCallError::RespondToModel)?; + let args = normalize_request_user_input_args_for_tool(&tool_name, args) + .map_err(FunctionCallError::RespondToModel)?; let response = session .request_user_input(turn.as_ref(), call_id, args) .await .ok_or_else(|| { FunctionCallError::RespondToModel(format!( - "{REQUEST_USER_INPUT_TOOL_NAME} was cancelled before receiving a response" + "{tool_name} was cancelled before receiving a response" )) })?; let content = serde_json::to_string(&response).map_err(|err| { - FunctionCallError::Fatal(format!( - "failed to serialize {REQUEST_USER_INPUT_TOOL_NAME} response: {err}" - )) + FunctionCallError::Fatal(format!("failed to serialize {tool_name} response: {err}")) })?; Ok(FunctionToolOutput::from_text(content, Some(true))) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 4615b4531..f15e98e99 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -300,6 +300,7 @@ fn test_build_specs_gpt5_codex_default() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -323,6 +324,7 @@ fn test_build_specs_gpt51_codex_default() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -348,6 +350,7 @@ fn test_build_specs_gpt5_codex_unified_exec_web_search() { "exec_command", "write_stdin", "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -373,6 +376,7 @@ fn test_build_specs_gpt51_codex_unified_exec_web_search() { "exec_command", "write_stdin", "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -396,6 +400,7 @@ fn test_gpt_5_1_codex_max_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -419,6 +424,7 @@ fn test_codex_5_1_mini_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -442,6 +448,7 @@ fn test_gpt_5_defaults() { "shell", &[ "update_plan", + "question", "request_user_input", "web_search", "view_image", @@ -464,6 +471,7 @@ fn test_gpt_5_1_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -489,6 +497,7 @@ fn test_gpt_5_1_codex_max_unified_exec_web_search() { "exec_command", "write_stdin", "update_plan", + "question", "request_user_input", "apply_patch", "web_search", diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c2..181a1fe24 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -30,6 +30,9 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +const QUESTION_TOOL_NAME: &str = "question"; +const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input"; + fn call_output(req: &ResponsesRequest, call_id: &str) -> String { let raw = req.function_call_output(call_id); assert_eq!( @@ -74,6 +77,34 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> } async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Result<()> { + interactive_question_round_trip_for_mode( + REQUEST_USER_INPUT_TOOL_NAME, + mode, + multiple_choice_request_args(), + /*expect_is_other*/ true, + mode == ModeKind::Default, + ) + .await +} + +async fn question_round_trip_for_mode(mode: ModeKind) -> anyhow::Result<()> { + interactive_question_round_trip_for_mode( + QUESTION_TOOL_NAME, + mode, + freeform_question_request_args(), + /*expect_is_other*/ false, + /*enable_default_mode_request_user_input*/ false, + ) + .await +} + +async fn interactive_question_round_trip_for_mode( + tool_name: &str, + mode: ModeKind, + request_args: Value, + expect_is_other: bool, + enable_default_mode_request_user_input: bool, +) -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -87,36 +118,25 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul .. } = builder .with_config(move |config| { - if mode == ModeKind::Default { - config - .features - .enable(Feature::DefaultModeRequestUserInput) - .expect("test config should allow feature update"); + if enable_default_mode_request_user_input { + assert!( + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .is_ok(), + "test config should allow feature update" + ); } }) .build(&server) .await?; - let call_id = "user-input-call"; - let request_args = json!({ - "questions": [{ - "id": "confirm_path", - "header": "Confirm", - "question": "Proceed with the plan?", - "options": [{ - "label": "Yes (Recommended)", - "description": "Continue the current plan." - }, { - "label": "No", - "description": "Stop and revisit the approach." - }] - }] - }) - .to_string(); + let call_id = format!("{tool_name}-call"); + let request_args = request_args.to_string(); let first_response = sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "request_user_input", &request_args), + ev_function_call(&call_id, tool_name, &request_args), ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; @@ -163,7 +183,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul .await; assert_eq!(request.call_id, call_id); assert_eq!(request.questions.len(), 1); - assert_eq!(request.questions[0].is_other, true); + assert_eq!(request.questions[0].is_other, expect_is_other); let mut answers = HashMap::new(); answers.insert( @@ -183,7 +203,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let req = second_mock.single_request(); - let output_text = call_output(&req, call_id); + let output_text = call_output(&req, &call_id); let output_json: Value = serde_json::from_str(&output_text)?; assert_eq!( output_json, @@ -198,6 +218,43 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul } async fn assert_request_user_input_rejected(mode_name: &str, build_mode: F) -> anyhow::Result<()> +where + F: FnOnce(String) -> CollaborationMode, +{ + assert_interactive_question_rejected( + REQUEST_USER_INPUT_TOOL_NAME, + mode_name, + multiple_choice_request_args(), + format!("request_user_input is unavailable in {mode_name} mode"), + /*enable_default_mode_request_user_input*/ false, + build_mode, + ) + .await +} + +async fn assert_question_rejected(mode_name: &str, build_mode: F) -> anyhow::Result<()> +where + F: FnOnce(String) -> CollaborationMode, +{ + assert_interactive_question_rejected( + QUESTION_TOOL_NAME, + mode_name, + freeform_question_request_args(), + format!("question is unavailable in {mode_name} mode"), + /*enable_default_mode_request_user_input*/ false, + build_mode, + ) + .await +} + +async fn assert_interactive_question_rejected( + tool_name: &str, + mode_name: &str, + request_args: Value, + expected_output: String, + enable_default_mode_request_user_input: bool, + build_mode: F, +) -> anyhow::Result<()> where F: FnOnce(String) -> CollaborationMode, { @@ -205,35 +262,34 @@ where let server = start_mock_server().await; - let mut builder = test_codex(); + let builder = test_codex(); let TestCodex { codex, cwd, session_configured, .. - } = builder.build(&server).await?; + } = builder + .with_config(move |config| { + if enable_default_mode_request_user_input { + assert!( + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .is_ok(), + "test config should allow feature update" + ); + } + }) + .build(&server) + .await?; let mode_slug = mode_name.to_lowercase().replace(' ', "-"); - let call_id = format!("user-input-{mode_slug}-call"); - let request_args = json!({ - "questions": [{ - "id": "confirm_path", - "header": "Confirm", - "question": "Proceed with the plan?", - "options": [{ - "label": "Yes (Recommended)", - "description": "Continue the current plan." - }, { - "label": "No", - "description": "Stop and revisit the approach." - }] - }] - }) - .to_string(); + let call_id = format!("{tool_name}-{mode_slug}-call"); + let request_args = request_args.to_string(); let first_response = sse(vec![ ev_response_created("resp-1"), - ev_function_call(&call_id, "request_user_input", &request_args), + ev_function_call(&call_id, tool_name, &request_args), ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; @@ -272,14 +328,38 @@ where let req = second_mock.single_request(); let (output, success) = call_output_content_and_success(&req, &call_id); assert_eq!(success, None); - assert_eq!( - output, - format!("request_user_input is unavailable in {mode_name} mode") - ); + assert_eq!(output, expected_output); Ok(()) } +fn multiple_choice_request_args() -> Value { + json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }) +} + +fn freeform_question_request_args() -> Value { + json!({ + "questions": [{ + "id": "details", + "header": "Details", + "question": "What should we keep?" + }] + }) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_rejected_in_execute_mode_alias() -> anyhow::Result<()> { assert_request_user_input_rejected("Execute", |model| CollaborationMode { @@ -323,3 +403,39 @@ async fn request_user_input_rejected_in_pair_mode_alias() -> anyhow::Result<()> }) .await } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn question_round_trip_resolves_pending_in_plan_mode() -> anyhow::Result<()> { + question_round_trip_for_mode(ModeKind::Plan).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn question_round_trip_resolves_pending_in_default_mode() -> anyhow::Result<()> { + question_round_trip_for_mode(ModeKind::Default).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn question_rejected_in_execute_mode_alias() -> anyhow::Result<()> { + assert_question_rejected("Execute", |model| CollaborationMode { + mode: ModeKind::Execute, + settings: Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn question_rejected_in_pair_mode_alias() -> anyhow::Result<()> { + assert_question_rejected("Pair Programming", |model| CollaborationMode { + mode: ModeKind::PairProgramming, + settings: Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }) + .await +} diff --git a/codex-rs/models-manager/src/collaboration_mode_presets.rs b/codex-rs/models-manager/src/collaboration_mode_presets.rs index f67ab91db..5404374c1 100644 --- a/codex-rs/models-manager/src/collaboration_mode_presets.rs +++ b/codex-rs/models-manager/src/collaboration_mode_presets.rs @@ -55,7 +55,6 @@ fn default_preset(collaboration_modes_config: CollaborationModesConfig) -> Colla fn default_mode_instructions(collaboration_modes_config: CollaborationModesConfig) -> String { let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES); let request_user_input_availability = request_user_input_availability_message( - ModeKind::Default, collaboration_modes_config.default_mode_request_user_input, ); let asking_questions_guidance = asking_questions_guidance_message( @@ -86,27 +85,22 @@ fn format_mode_names(modes: &[ModeKind]) -> String { } } -fn request_user_input_availability_message( - mode: ModeKind, - default_mode_request_user_input: bool, -) -> String { - let mode_name = mode.display_name(); - if mode.allows_request_user_input() - || (default_mode_request_user_input && mode == ModeKind::Default) - { - format!("The `request_user_input` tool is available in {mode_name} mode.") +fn request_user_input_availability_message(default_mode_request_user_input: bool) -> String { + let mode_name = ModeKind::Default.display_name(); + if default_mode_request_user_input { + format!("The `question` and `request_user_input` tools are available in {mode_name} mode.") } else { format!( - "The `request_user_input` tool is unavailable in {mode_name} mode. If you call it while in {mode_name} mode, it will return an error." + "The `question` tool is available in {mode_name} mode. The legacy `request_user_input` tool is unavailable in {mode_name} mode and will return an error." ) } } fn asking_questions_guidance_message(default_mode_request_user_input: bool) -> String { if default_mode_request_user_input { - "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, prefer using the `request_user_input` tool rather than writing a multiple choice question as a textual assistant message. Never write a multiple choice question as a textual assistant message.".to_string() + "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, prefer using the `question` tool rather than writing a multiple choice question as a textual assistant message. The legacy `request_user_input` tool is still available in Default mode. Never write a multiple choice question as a textual assistant message.".to_string() } else { - "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.".to_string() + "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, prefer using the `question` tool rather than writing a multiple choice question as a textual assistant message. For a single simple clarification, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.".to_string() } } diff --git a/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs b/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs index 361c51762..cc4542133 100644 --- a/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs +++ b/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs @@ -31,12 +31,11 @@ fn default_mode_instructions_replace_mode_names_placeholder() { let expected_snippet = format!("Known mode names are {known_mode_names}."); assert!(default_instructions.contains(&expected_snippet)); - let expected_availability_message = request_user_input_availability_message( - ModeKind::Default, - /*default_mode_request_user_input*/ true, - ); + let expected_availability_message = + request_user_input_availability_message(/*default_mode_request_user_input*/ true); assert!(default_instructions.contains(&expected_availability_message)); - assert!(default_instructions.contains("prefer using the `request_user_input` tool")); + assert!(default_instructions.contains("prefer using the `question` tool")); + assert!(default_instructions.contains("legacy `request_user_input` tool is still available")); } #[test] @@ -47,6 +46,7 @@ fn default_mode_instructions_use_plain_text_questions_when_feature_disabled() { .expect("default instructions should be set"); assert!(!default_instructions.contains("prefer using the `request_user_input` tool")); + assert!(default_instructions.contains("prefer using the `question` tool")); assert!( default_instructions.contains("ask the user directly with a concise plain-text question") ); diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index cafb4b89a..419cd0424 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -26,7 +26,7 @@ schema and Responses API tool primitives that no longer need to live in - MCP resource, `list_dir`, and `test_sync_tool` spec builders - local host tool spec builders for shell/exec/request-permissions/view-image - collaboration and agent-job `ToolSpec` builders for spawn/send/wait/close, - `request_user_input`, and CSV fanout/reporting + `question`, `request_user_input`, and CSV fanout/reporting - discoverable-tool models, client filtering, and `ToolSpec` builders for `tool_search` and `tool_suggest` - `parse_tool_input_schema()` diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 16e84700b..c4c610e6f 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -70,9 +70,14 @@ pub use mcp_resource_tool::create_read_mcp_resource_tool; pub use mcp_tool::mcp_call_tool_result_output_schema; pub use mcp_tool::parse_mcp_tool; pub use plan_tool::create_update_plan_tool; +pub use request_user_input_tool::QUESTION_TOOL_NAME; pub use request_user_input_tool::REQUEST_USER_INPUT_TOOL_NAME; +pub use request_user_input_tool::create_question_tool; pub use request_user_input_tool::create_request_user_input_tool; pub use request_user_input_tool::normalize_request_user_input_args; +pub use request_user_input_tool::normalize_request_user_input_args_for_tool; +pub use request_user_input_tool::question_tool_description; +pub use request_user_input_tool::question_unavailable_message; pub use request_user_input_tool::request_user_input_tool_description; pub use request_user_input_tool::request_user_input_unavailable_message; pub use responses_api::FreeformTool; diff --git a/codex-rs/tools/src/request_user_input_tool.rs b/codex-rs/tools/src/request_user_input_tool.rs index 77c536738..9535e9a82 100644 --- a/codex-rs/tools/src/request_user_input_tool.rs +++ b/codex-rs/tools/src/request_user_input_tool.rs @@ -6,9 +6,53 @@ use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES; use codex_protocol::request_user_input::RequestUserInputArgs; use std::collections::BTreeMap; +pub const QUESTION_TOOL_NAME: &str = "question"; pub const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input"; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QuestionOptionsPolicy { + RequireOptions, + AllowFreeform, +} + +pub fn create_question_tool(description: String) -> ToolSpec { + create_interactive_question_tool( + QUESTION_TOOL_NAME, + QuestionToolSchema { + questions_description: "Questions to show the user. There is no fixed maximum; use as many as needed for the form.", + prompt_description: "Prompt shown to the user for this field.", + options_description: "Optional mutually exclusive choices for this question. Omit this field for a freeform text answer. When provided, put the recommended option first and do not include an \"Other\" option; the client can collect additional notes separately.", + options_required: false, + }, + description, + ) +} + pub fn create_request_user_input_tool(description: String) -> ToolSpec { + create_interactive_question_tool( + REQUEST_USER_INPUT_TOOL_NAME, + QuestionToolSchema { + questions_description: "Questions to show the user. Prefer 1 and do not exceed 3", + prompt_description: "Single-sentence prompt shown to the user.", + options_description: "Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically.", + options_required: true, + }, + description, + ) +} + +struct QuestionToolSchema { + questions_description: &'static str, + prompt_description: &'static str, + options_description: &'static str, + options_required: bool, +} + +fn create_interactive_question_tool( + name: &str, + schema: QuestionToolSchema, + description: String, +) -> ToolSpec { let option_props = BTreeMap::from([ ( "label".to_string(), @@ -27,10 +71,7 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { ]); let options_schema = JsonSchema::Array { - description: Some( - "Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically." - .to_string(), - ), + description: Some(schema.options_description.to_string()), items: Box::new(JsonSchema::Object { properties: option_props, required: Some(vec!["label".to_string(), "description".to_string()]), @@ -58,22 +99,27 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { ( "question".to_string(), JsonSchema::String { - description: Some("Single-sentence prompt shown to the user.".to_string()), + description: Some(schema.prompt_description.to_string()), }, ), ("options".to_string(), options_schema), ]); let questions_schema = JsonSchema::Array { - description: Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()), + description: Some(schema.questions_description.to_string()), items: Box::new(JsonSchema::Object { properties: question_props, - required: Some(vec![ - "id".to_string(), - "header".to_string(), - "question".to_string(), - "options".to_string(), - ]), + required: Some({ + let mut required = vec![ + "id".to_string(), + "header".to_string(), + "question".to_string(), + ]; + if schema.options_required { + required.push("options".to_string()); + } + required + }), additional_properties: Some(false.into()), }), }; @@ -81,7 +127,7 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { let properties = BTreeMap::from([("questions".to_string(), questions_schema)]); ToolSpec::Function(ResponsesApiTool { - name: REQUEST_USER_INPUT_TOOL_NAME.to_string(), + name: name.to_string(), description, strict: false, defer_loading: None, @@ -94,6 +140,10 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { }) } +fn question_is_available(mode: ModeKind) -> bool { + mode.allows_request_user_input() || mode == ModeKind::Default +} + pub fn request_user_input_unavailable_message( mode: ModeKind, default_mode_request_user_input: bool, @@ -108,28 +158,81 @@ pub fn request_user_input_unavailable_message( } } +pub fn question_unavailable_message(mode: ModeKind) -> Option { + if question_is_available(mode) { + None + } else { + let mode_name = mode.display_name(); + Some(format!("question is unavailable in {mode_name} mode")) + } +} + +fn tool_is_available( + tool_name: &str, + mode: ModeKind, + default_mode_request_user_input: bool, +) -> bool { + match tool_name { + QUESTION_TOOL_NAME => question_is_available(mode), + _ => request_user_input_is_available(mode, default_mode_request_user_input), + } +} + +fn question_options_policy(tool_name: &str) -> QuestionOptionsPolicy { + match tool_name { + QUESTION_TOOL_NAME => QuestionOptionsPolicy::AllowFreeform, + _ => QuestionOptionsPolicy::RequireOptions, + } +} + pub fn normalize_request_user_input_args( + args: RequestUserInputArgs, +) -> Result { + normalize_request_user_input_args_for_tool(REQUEST_USER_INPUT_TOOL_NAME, args) +} + +pub fn normalize_request_user_input_args_for_tool( + tool_name: &str, mut args: RequestUserInputArgs, ) -> Result { - let missing_options = args - .questions - .iter() - .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)); - if missing_options { - return Err("request_user_input requires non-empty options for every question".to_string()); + for question in &mut args.questions { + if question.options.as_ref().is_some_and(Vec::is_empty) { + question.options = None; + } + if question + .options + .as_ref() + .is_some_and(|options| !options.is_empty()) + { + question.is_other = true; + } } - for question in &mut args.questions { - question.is_other = true; + if question_options_policy(tool_name) == QuestionOptionsPolicy::RequireOptions + && args + .questions + .iter() + .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)) + { + return Err("request_user_input requires non-empty options for every question".to_string()); } Ok(args) } pub fn request_user_input_tool_description(default_mode_request_user_input: bool) -> String { - let allowed_modes = format_allowed_modes(default_mode_request_user_input); - format!( - "Request user input for one to three short questions and wait for the response. This tool is only available in {allowed_modes}." + interactive_question_tool_description( + REQUEST_USER_INPUT_TOOL_NAME, + "Request user input for one to three short questions and wait for the response.", + default_mode_request_user_input, + ) +} + +pub fn question_tool_description(default_mode_request_user_input: bool) -> String { + interactive_question_tool_description( + QUESTION_TOOL_NAME, + "Ask the user a structured form with as many questions as needed and wait for the response. The client will render choices and/or text fields automatically.", + default_mode_request_user_input, ) } @@ -138,10 +241,19 @@ fn request_user_input_is_available(mode: ModeKind, default_mode_request_user_inp || (default_mode_request_user_input && mode == ModeKind::Default) } -fn format_allowed_modes(default_mode_request_user_input: bool) -> String { +fn interactive_question_tool_description( + tool_name: &str, + tool_description: &str, + default_mode_request_user_input: bool, +) -> String { + let allowed_modes = format_allowed_modes(tool_name, default_mode_request_user_input); + format!("{tool_description} This tool is only available in {allowed_modes}.") +} + +fn format_allowed_modes(tool_name: &str, default_mode_request_user_input: bool) -> String { let mode_names: Vec<&str> = TUI_VISIBLE_COLLABORATION_MODES .into_iter() - .filter(|mode| request_user_input_is_available(*mode, default_mode_request_user_input)) + .filter(|mode| tool_is_available(tool_name, *mode, default_mode_request_user_input)) .map(ModeKind::display_name) .collect(); diff --git a/codex-rs/tools/src/request_user_input_tool_tests.rs b/codex-rs/tools/src/request_user_input_tool_tests.rs index e7a305f86..17379d774 100644 --- a/codex-rs/tools/src/request_user_input_tool_tests.rs +++ b/codex-rs/tools/src/request_user_input_tool_tests.rs @@ -1,14 +1,116 @@ use super::*; use codex_protocol::config_types::ModeKind; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputQuestion; +use codex_protocol::request_user_input::RequestUserInputQuestionOption; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +#[test] +fn question_tool_includes_questions_schema() { + assert_eq!( + create_question_tool("Ask the user for details.".to_string()), + ToolSpec::Function(ResponsesApiTool { + name: QUESTION_TOOL_NAME.to_string(), + description: "Ask the user for details.".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "questions".to_string(), + JsonSchema::Array { + description: Some( + "Questions to show the user. There is no fixed maximum; use as many as needed for the form." + .to_string(), + ), + items: Box::new(JsonSchema::Object { + properties: BTreeMap::from([ + ( + "header".to_string(), + JsonSchema::String { + description: Some( + "Short header label shown in the UI (12 or fewer chars)." + .to_string(), + ), + }, + ), + ( + "id".to_string(), + JsonSchema::String { + description: Some( + "Stable identifier for mapping answers (snake_case)." + .to_string(), + ), + }, + ), + ( + "options".to_string(), + JsonSchema::Array { + description: Some( + "Optional mutually exclusive choices for this question. Omit this field for a freeform text answer. When provided, put the recommended option first and do not include an \"Other\" option; the client can collect additional notes separately." + .to_string(), + ), + items: Box::new(JsonSchema::Object { + properties: BTreeMap::from([ + ( + "description".to_string(), + JsonSchema::String { + description: Some( + "One short sentence explaining impact/tradeoff if selected." + .to_string(), + ), + }, + ), + ( + "label".to_string(), + JsonSchema::String { + description: Some( + "User-facing label (1-5 words)." + .to_string(), + ), + }, + ), + ]), + required: Some(vec![ + "label".to_string(), + "description".to_string(), + ]), + additional_properties: Some(false.into()), + }), + }, + ), + ( + "question".to_string(), + JsonSchema::String { + description: Some( + "Prompt shown to the user for this field.".to_string(), + ), + }, + ), + ]), + required: Some(vec![ + "id".to_string(), + "header".to_string(), + "question".to_string(), + ]), + additional_properties: Some(false.into()), + }), + }, + )]), + required: Some(vec!["questions".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) + ); +} + #[test] fn request_user_input_tool_includes_questions_schema() { assert_eq!( create_request_user_input_tool("Ask the user to choose.".to_string()), ToolSpec::Function(ResponsesApiTool { - name: "request_user_input".to_string(), + name: REQUEST_USER_INPUT_TOOL_NAME.to_string(), description: "Ask the user to choose.".to_string(), strict: false, defer_loading: None, @@ -102,6 +204,20 @@ fn request_user_input_tool_includes_questions_schema() { ); } +#[test] +fn question_unavailable_messages_respect_mode_rules() { + assert_eq!(question_unavailable_message(ModeKind::Plan), None); + assert_eq!(question_unavailable_message(ModeKind::Default), None); + assert_eq!( + question_unavailable_message(ModeKind::Execute), + Some("question is unavailable in Execute mode".to_string()) + ); + assert_eq!( + question_unavailable_message(ModeKind::PairProgramming), + Some("question is unavailable in Pair Programming mode".to_string()) + ); +} + #[test] fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() { assert_eq!( @@ -141,6 +257,18 @@ fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() { ); } +#[test] +fn question_tool_description_mentions_available_modes() { + assert_eq!( + question_tool_description(/*default_mode_request_user_input*/ false), + "Ask the user a structured form with as many questions as needed and wait for the response. The client will render choices and/or text fields automatically. This tool is only available in Default or Plan mode.".to_string() + ); + assert_eq!( + question_tool_description(/*default_mode_request_user_input*/ true), + "Ask the user a structured form with as many questions as needed and wait for the response. The client will render choices and/or text fields automatically. This tool is only available in Default or Plan mode.".to_string() + ); +} + #[test] fn request_user_input_tool_description_mentions_available_modes() { assert_eq!( @@ -152,3 +280,95 @@ fn request_user_input_tool_description_mentions_available_modes() { "Request user input for one to three short questions and wait for the response. This tool is only available in Default or Plan mode.".to_string() ); } + +#[test] +fn normalize_question_args_allows_freeform_questions() { + let args = RequestUserInputArgs { + questions: vec![RequestUserInputQuestion { + id: "details".to_string(), + header: "Details".to_string(), + question: "What changed?".to_string(), + is_other: false, + is_secret: false, + options: None, + }], + }; + + assert_eq!( + normalize_request_user_input_args_for_tool(QUESTION_TOOL_NAME, args.clone()), + Ok(args) + ); +} + +#[test] +fn normalize_question_args_marks_multiple_choice_entries_as_other() { + let args = RequestUserInputArgs { + questions: vec![ + RequestUserInputQuestion { + id: "details".to_string(), + header: "Details".to_string(), + question: "What changed?".to_string(), + is_other: false, + is_secret: false, + options: Some(Vec::new()), + }, + RequestUserInputQuestion { + id: "confirm".to_string(), + header: "Confirm".to_string(), + question: "Continue?".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![RequestUserInputQuestionOption { + label: "Yes".to_string(), + description: "Keep going.".to_string(), + }]), + }, + ], + }; + + assert_eq!( + normalize_request_user_input_args_for_tool(QUESTION_TOOL_NAME, args), + Ok(RequestUserInputArgs { + questions: vec![ + RequestUserInputQuestion { + id: "details".to_string(), + header: "Details".to_string(), + question: "What changed?".to_string(), + is_other: false, + is_secret: false, + options: None, + }, + RequestUserInputQuestion { + id: "confirm".to_string(), + header: "Confirm".to_string(), + question: "Continue?".to_string(), + is_other: true, + is_secret: false, + options: Some(vec![RequestUserInputQuestionOption { + label: "Yes".to_string(), + description: "Keep going.".to_string(), + }]), + }, + ], + }) + ); +} + +#[test] +fn normalize_request_user_input_args_requires_options() { + let args = RequestUserInputArgs { + questions: vec![RequestUserInputQuestion { + id: "confirm".to_string(), + header: "Confirm".to_string(), + question: "Continue?".to_string(), + is_other: false, + is_secret: false, + options: None, + }], + }; + + assert_eq!( + normalize_request_user_input_args(args), + Err("request_user_input requires non-empty options for every question".to_string()) + ); +} diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index cc706f24f..aa4c84f44 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -1,4 +1,5 @@ use crate::CommandToolOptions; +use crate::QUESTION_TOOL_NAME; use crate::REQUEST_USER_INPUT_TOOL_NAME; use crate::ShellToolOptions; use crate::SpawnAgentToolOptions; @@ -31,6 +32,7 @@ use crate::create_list_dir_tool; use crate::create_list_mcp_resource_templates_tool; use crate::create_list_mcp_resources_tool; use crate::create_local_shell_tool; +use crate::create_question_tool; use crate::create_read_mcp_resource_tool; use crate::create_report_agent_job_result_tool; use crate::create_request_permissions_tool; @@ -55,6 +57,7 @@ use crate::create_web_search_tool; use crate::create_write_stdin_tool; use crate::dynamic_tool_to_responses_api_tool; use crate::mcp_tool_to_responses_api_tool; +use crate::question_tool_description; use crate::request_permissions_tool_description; use crate::request_user_input_tool_description; use crate::tool_registry_plan_types::agent_type_description; @@ -205,6 +208,14 @@ pub fn build_tool_registry_plan( } if config.request_user_input { + plan.push_spec( + create_question_tool(question_tool_description( + config.default_mode_request_user_input, + )), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + plan.register_handler(QUESTION_TOOL_NAME, ToolHandlerKind::RequestUserInput); plan.push_spec( create_request_user_input_tool(request_user_input_tool_description( config.default_mode_request_user_input, diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index fd7ab1007..685da388f 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -81,6 +81,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { }), create_write_stdin_tool(), create_update_plan_tool(), + question_tool_spec(/*default_mode_request_user_input*/ false), request_user_input_tool_spec(/*default_mode_request_user_input*/ false), create_apply_patch_freeform_tool(), ToolSpec::WebSearch { @@ -491,11 +492,12 @@ fn test_build_specs_agent_job_worker_tools_enabled() { "report_agent_job_result", ], ); + assert_lacks_tool_name(&tools, QUESTION_TOOL_NAME); assert_lacks_tool_name(&tools, "request_user_input"); } #[test] -fn request_user_input_description_reflects_default_mode_feature_flag() { +fn interactive_question_tools_reflect_default_mode_feature_flag() { let model_info = model_info(); let mut features = Features::with_defaults(); let available_models = Vec::new(); @@ -514,6 +516,11 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { /*app_tools*/ None, &[], ); + let question_tool = find_tool(&tools, QUESTION_TOOL_NAME); + assert_eq!( + question_tool.spec, + question_tool_spec(/*default_mode_request_user_input*/ false) + ); let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME); assert_eq!( request_user_input_tool.spec, @@ -536,6 +543,11 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { /*app_tools*/ None, &[], ); + let question_tool = find_tool(&tools, QUESTION_TOOL_NAME); + assert_eq!( + question_tool.spec, + question_tool_spec(/*default_mode_request_user_input*/ true) + ); let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME); assert_eq!( request_user_input_tool.spec, @@ -1810,6 +1822,10 @@ fn assert_lacks_tool_name(tools: &[ConfiguredToolSpec], expected_absent: &str) { ); } +fn question_tool_spec(default_mode_request_user_input: bool) -> ToolSpec { + create_question_tool(question_tool_description(default_mode_request_user_input)) +} + fn request_user_input_tool_spec(default_mode_request_user_input: bool) -> ToolSpec { create_request_user_input_tool(request_user_input_tool_description( default_mode_request_user_input, From 329b4e1ae7f1af8f20a9676668a4fb91c70b8fda Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 09:04:32 +0800 Subject: [PATCH 14/60] replay: expose read_file and grep_files tools --- .../core/src/tools/handlers/grep_files.rs | 159 +++++ codex-rs/core/src/tools/handlers/mod.rs | 4 + codex-rs/core/src/tools/handlers/read_file.rs | 563 ++++++++++++++++++ codex-rs/core/src/tools/spec.rs | 8 + codex-rs/core/src/tools/spec_tests.rs | 32 + codex-rs/tools/src/lib.rs | 2 + codex-rs/tools/src/tool_registry_plan.rs | 28 + .../tools/src/tool_registry_plan_tests.rs | 27 + .../tools/src/tool_registry_plan_types.rs | 2 + codex-rs/tools/src/utility_tool.rs | 158 +++++ codex-rs/tools/src/utility_tool_tests.rs | 167 ++++++ 11 files changed, 1150 insertions(+) create mode 100644 codex-rs/core/src/tools/handlers/grep_files.rs create mode 100644 codex-rs/core/src/tools/handlers/read_file.rs diff --git a/codex-rs/core/src/tools/handlers/grep_files.rs b/codex-rs/core/src/tools/handlers/grep_files.rs new file mode 100644 index 000000000..ddc61d437 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/grep_files.rs @@ -0,0 +1,159 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use serde::Deserialize; +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::SystemTime; +use tokio::process::Command; + +pub struct GrepFilesHandler; + +const DEFAULT_LIMIT: usize = 100; + +fn default_limit() -> usize { + DEFAULT_LIMIT +} + +#[derive(Debug, Deserialize)] +struct GrepFilesArgs { + pattern: String, + #[serde(default)] + include: Option, + #[serde(default)] + path: Option, + #[serde(default = "default_limit")] + limit: usize, +} + +impl ToolHandler for GrepFilesHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { payload, turn, .. } = invocation; + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "grep_files handler received unsupported payload".to_string(), + )); + } + }; + + let args: GrepFilesArgs = parse_arguments(&arguments)?; + if args.limit == 0 { + return Err(FunctionCallError::RespondToModel( + "limit must be greater than zero".to_string(), + )); + } + + let search_root = args + .path + .map(PathBuf::from) + .map(|path| crate::util::resolve_path(turn.cwd.as_path(), &path)) + .unwrap_or_else(|| turn.cwd.to_path_buf()); + + let matches = run_rg_search( + &args.pattern, + args.include.as_deref(), + search_root.as_path(), + args.limit, + turn.cwd.as_path(), + ) + .await?; + + let output = if matches.is_empty() { + "No matching files found".to_string() + } else { + matches.join("\n") + }; + Ok(FunctionToolOutput::from_text(output, Some(true))) + } +} + +async fn run_rg_search( + pattern: &str, + include: Option<&str>, + path: &Path, + limit: usize, + cwd: &Path, +) -> Result, FunctionCallError> { + let mut command = Command::new("rg"); + command + .arg("--files-with-matches") + .arg("--no-messages") + .arg("--color") + .arg("never") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(cwd); + if let Some(include) = include { + command.arg("--glob").arg(include); + } + command.arg(pattern).arg(path); + + let output = command + .output() + .await + .map_err(|err| FunctionCallError::RespondToModel(format!("failed to run rg: {err}")))?; + + match output.status.code() { + Some(0) => { + let mut results = parse_results(&output.stdout, usize::MAX) + .into_iter() + .map(|result| crate::util::resolve_path(cwd, &PathBuf::from(result))) + .collect::>(); + results.sort_by(|left, right| { + let left_modified = modified_time(left.as_path()); + let right_modified = modified_time(right.as_path()); + right_modified + .cmp(&left_modified) + .then_with(|| left.cmp(right)) + }); + results.truncate(limit); + Ok(results + .into_iter() + .map(|path| path.display().to_string()) + .collect()) + } + Some(1) => Ok(Vec::new()), + _ => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let details = if stderr.is_empty() { + format!("rg exited with status {}", output.status) + } else { + stderr + }; + Err(FunctionCallError::RespondToModel(format!( + "grep_files search failed: {details}" + ))) + } + } +} + +fn modified_time(path: &Path) -> SystemTime { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) +} + +fn parse_results(stdout: &[u8], limit: usize) -> Vec { + String::from_utf8_lossy(stdout) + .lines() + .take(limit) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +#[path = "grep_files_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index f0a62b8c1..5903d2b5c 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod agent_jobs; pub mod apply_patch; mod dynamic; +mod grep_files; mod js_repl; mod list_dir; mod mcp; @@ -9,6 +10,7 @@ pub(crate) mod multi_agents; pub(crate) mod multi_agents_common; pub(crate) mod multi_agents_v2; mod plan; +mod read_file; mod request_permissions; mod request_user_input; mod shell; @@ -36,12 +38,14 @@ pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; +pub use grep_files::GrepFilesHandler; pub use js_repl::JsReplHandler; pub use js_repl::JsReplResetHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; +pub use read_file::ReadFileHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_user_input::RequestUserInputHandler; pub use shell::ShellCommandHandler; diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs new file mode 100644 index 000000000..031cfd143 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/read_file.rs @@ -0,0 +1,563 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_utils_string::take_bytes_at_char_boundary; +use serde::Deserialize; +use std::path::Path; +use std::path::PathBuf; +use tokio::fs; + +pub struct ReadFileHandler; + +const DEFAULT_LIMIT: usize = 200; +const DEFAULT_OFFSET: usize = 1; +const MAX_LINE_LENGTH: usize = 2_000; + +fn default_limit() -> usize { + DEFAULT_LIMIT +} + +fn default_offset() -> usize { + DEFAULT_OFFSET +} + +fn default_max_levels() -> usize { + 1 +} + +fn default_include_header() -> bool { + true +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum ReadFileMode { + #[default] + Slice, + Indentation, +} + +#[derive(Clone, Debug, Deserialize)] +struct IndentationArgs { + #[serde(default)] + anchor_line: Option, + #[serde(default)] + include_siblings: bool, + #[serde(default = "default_max_levels")] + max_levels: usize, + #[serde(default = "default_include_header")] + include_header: bool, + #[serde(default)] + max_lines: Option, +} + +impl Default for IndentationArgs { + fn default() -> Self { + Self { + anchor_line: None, + include_siblings: false, + max_levels: default_max_levels(), + include_header: default_include_header(), + max_lines: None, + } + } +} + +#[derive(Debug, Deserialize)] +struct ReadFileArgs { + file_path: String, + #[serde(default = "default_offset")] + offset: usize, + #[serde(default = "default_limit")] + limit: usize, + #[serde(default)] + mode: ReadFileMode, + #[serde(default)] + indentation: Option, +} + +impl ToolHandler for ReadFileHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { payload, turn, .. } = invocation; + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "read_file handler received unsupported payload".to_string(), + )); + } + }; + + let args: ReadFileArgs = parse_arguments(&arguments)?; + validate_offset_limit(args.offset, args.limit)?; + + let path = crate::util::resolve_path(turn.cwd.as_path(), &PathBuf::from(args.file_path)); + let lines = match args.mode { + ReadFileMode::Slice => slice::read(path.as_path(), args.offset, args.limit).await?, + ReadFileMode::Indentation => { + indentation::read_block( + path.as_path(), + args.offset, + args.limit, + args.indentation.unwrap_or_default(), + ) + .await? + } + }; + + Ok(FunctionToolOutput::from_text(lines.join("\n"), Some(true))) + } +} + +#[derive(Clone, Debug)] +struct LineRecord { + text: String, + indent: usize, + is_blank: bool, +} + +impl LineRecord { + fn new(text: String) -> Self { + let is_blank = text.trim().is_empty(); + let indent = text + .chars() + .take_while(|ch| matches!(ch, ' ' | '\t')) + .map(|ch| if ch == '\t' { 4 } else { 1 }) + .sum(); + Self { + text, + indent, + is_blank, + } + } + + fn trimmed(&self) -> &str { + self.text.trim() + } +} + +#[derive(Clone, Copy, Debug)] +struct BlockRange { + start: usize, + actual_start: usize, + end: usize, +} + +async fn load_lines(path: &Path) -> Result, FunctionCallError> { + let bytes = fs::read(path) + .await + .map_err(|err| FunctionCallError::RespondToModel(format!("failed to read file: {err}")))?; + let text = String::from_utf8_lossy(&bytes); + let mut lines = text + .split('\n') + .map(|line| LineRecord::new(line.trim_end_matches('\r').to_string())) + .collect::>(); + if matches!(lines.last(), Some(last) if last.text.is_empty()) { + lines.pop(); + } + Ok(lines) +} + +fn validate_offset_limit(offset: usize, limit: usize) -> Result<(), FunctionCallError> { + if offset == 0 { + return Err(FunctionCallError::RespondToModel( + "offset must be a 1-indexed line number".to_string(), + )); + } + if limit == 0 { + return Err(FunctionCallError::RespondToModel( + "limit must be greater than zero".to_string(), + )); + } + Ok(()) +} + +fn format_output( + lines: &[LineRecord], + start: usize, + end_exclusive: usize, + max_lines: usize, +) -> Vec { + lines[start..end_exclusive] + .iter() + .take(max_lines) + .enumerate() + .map(|(offset, line)| format_line(start + offset + 1, line.text.as_str())) + .collect() +} + +fn format_line(line_number: usize, line: &str) -> String { + let truncated = if line.len() > MAX_LINE_LENGTH { + take_bytes_at_char_boundary(line, MAX_LINE_LENGTH).to_string() + } else { + line.to_string() + }; + format!("L{line_number}: {truncated}") +} + +fn resolve_anchor_index( + lines: &[LineRecord], + anchor_line: usize, +) -> Result { + if anchor_line == 0 { + return Err(FunctionCallError::RespondToModel( + "anchor_line must be a 1-indexed line number".to_string(), + )); + } + let requested = anchor_line - 1; + if requested >= lines.len() { + return Err(FunctionCallError::RespondToModel( + "anchor_line exceeds file length".to_string(), + )); + } + if !lines[requested].is_blank { + return Ok(requested); + } + if let Some(previous) = (0..requested).rev().find(|index| !lines[*index].is_blank) { + return Ok(previous); + } + ((requested + 1)..lines.len()) + .find(|index| !lines[*index].is_blank) + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "cannot infer indentation from an empty file".to_string(), + ) + }) +} + +fn find_ancestor_start(lines: &[LineRecord], anchor_index: usize, levels: usize) -> Option { + let mut current_index = anchor_index; + let mut current_indent = lines[anchor_index].indent; + let mut found = None; + for _ in 0..levels { + let next = (0..current_index) + .rev() + .find(|index| !lines[*index].is_blank && lines[*index].indent < current_indent)?; + found = Some(next); + current_index = next; + current_indent = lines[next].indent; + } + found +} + +fn is_closing_line(line: &str) -> bool { + matches!(line.chars().next(), Some('}') | Some(']') | Some(')')) +} + +fn is_header_line(line: &str) -> bool { + line.starts_with("//") + || line.starts_with("/*") + || line.starts_with('*') + || line.starts_with("#[") + || line.starts_with("#!") + || line.starts_with("///") + || line.starts_with("//!") + || line.starts_with('@') +} + +fn extend_header_upwards(lines: &[LineRecord], actual_start: usize) -> usize { + let indent = lines[actual_start].indent; + let mut start = actual_start; + while start > 0 { + let previous = &lines[start - 1]; + if previous.is_blank || previous.indent != indent || !is_header_line(previous.trimmed()) { + break; + } + start -= 1; + } + start +} + +fn block_range( + lines: &[LineRecord], + actual_start: usize, + section_end: usize, + include_header: bool, +) -> BlockRange { + let start = if include_header { + extend_header_upwards(lines, actual_start) + } else { + actual_start + }; + let end = find_block_end(lines, actual_start, section_end); + BlockRange { + start, + actual_start, + end, + } +} + +fn find_block_end(lines: &[LineRecord], actual_start: usize, section_end: usize) -> usize { + let start_indent = lines[actual_start].indent; + let mut saw_nested = false; + let mut last_included = actual_start; + + for (index, line) in lines + .iter() + .enumerate() + .take(section_end + 1) + .skip(actual_start + 1) + { + if line.is_blank { + last_included = index; + continue; + } + if line.indent > start_indent { + saw_nested = true; + last_included = index; + continue; + } + if line.indent == start_indent && is_closing_line(line.trimmed()) { + return index; + } + if saw_nested { + return last_included; + } + return actual_start; + } + + last_included +} + +fn qualifies_as_block(lines: &[LineRecord], start: usize, section_end: usize) -> bool { + let start_indent = lines[start].indent; + for line in lines.iter().take(section_end + 1).skip(start + 1) { + if line.is_blank { + continue; + } + return line.indent > start_indent; + } + false +} + +mod slice { + use super::FunctionCallError; + use super::LineRecord; + use super::Path; + use super::format_output; + use super::load_lines; + use super::validate_offset_limit; + + pub(super) async fn read( + path: &Path, + offset: usize, + limit: usize, + ) -> Result, FunctionCallError> { + let lines = load_lines(path).await?; + read_loaded(lines.as_slice(), offset, limit) + } + + pub(super) fn read_loaded( + lines: &[LineRecord], + offset: usize, + limit: usize, + ) -> Result, FunctionCallError> { + validate_offset_limit(offset, limit)?; + let start = offset - 1; + if start >= lines.len() { + return Err(FunctionCallError::RespondToModel( + "offset exceeds file length".to_string(), + )); + } + let end = (start + limit).min(lines.len()); + Ok(format_output(lines, start, end, limit)) + } +} + +mod indentation { + use super::BlockRange; + use super::FunctionCallError; + use super::IndentationArgs; + use super::LineRecord; + use super::Path; + use super::block_range; + use super::find_ancestor_start; + use super::format_output; + use super::is_header_line; + use super::load_lines; + use super::qualifies_as_block; + use super::resolve_anchor_index; + use super::slice; + use super::validate_offset_limit; + + pub(super) async fn read_block( + path: &Path, + offset: usize, + limit: usize, + options: IndentationArgs, + ) -> Result, FunctionCallError> { + validate_offset_limit(offset, limit)?; + if options.max_levels == 0 { + return Err(FunctionCallError::RespondToModel( + "indentation.max_levels must be greater than zero".to_string(), + )); + } + + let max_lines = options.max_lines.unwrap_or(limit); + if max_lines == 0 { + return Err(FunctionCallError::RespondToModel( + "indentation.max_lines must be greater than zero".to_string(), + )); + } + + let lines = load_lines(path).await?; + if lines.is_empty() || offset > lines.len() { + return Err(FunctionCallError::RespondToModel( + "offset exceeds file length".to_string(), + )); + } + + let anchor_index = resolve_anchor_index(&lines, options.anchor_line.unwrap_or(offset))?; + let Some(actual_start) = find_ancestor_start(&lines, anchor_index, options.max_levels) + else { + return slice::read_loaded(lines.as_slice(), offset, max_lines); + }; + + let section_end = lines.len() - 1; + let mut selection = block_range(&lines, actual_start, section_end, options.include_header); + if options.include_siblings + && let Some(parent_start) = find_ancestor_start(&lines, actual_start, 1) + { + let parent_scope = block_range( + &lines, + parent_start, + section_end, + /*include_header*/ false, + ); + selection = + expand_with_siblings(&lines, parent_scope, selection, options.include_header); + } + + let end_exclusive = (selection.end + 1).min(lines.len()); + Ok(format_output( + &lines, + selection.start, + end_exclusive, + max_lines, + )) + } + + fn expand_with_siblings( + lines: &[LineRecord], + parent_scope: BlockRange, + selected: BlockRange, + include_header: bool, + ) -> BlockRange { + let items = collect_scope_items( + lines, + parent_scope, + lines[selected.actual_start].indent, + include_header, + ); + let Some(position) = items.iter().position(|item| match item { + ScopeItem::Block(block) => block.actual_start == selected.actual_start, + ScopeItem::Barrier => false, + }) else { + return selected; + }; + + let mut start = selected.start; + let mut end = selected.end; + + let mut left = position; + while left > 0 { + match items[left - 1] { + ScopeItem::Block(block) => { + start = block.start; + left -= 1; + } + ScopeItem::Barrier => break, + } + } + + let mut right = position; + while right + 1 < items.len() { + match items[right + 1] { + ScopeItem::Block(block) => { + end = block.end; + right += 1; + } + ScopeItem::Barrier => break, + } + } + + BlockRange { + start, + actual_start: selected.actual_start, + end, + } + } + + fn collect_scope_items( + lines: &[LineRecord], + parent_scope: BlockRange, + indent: usize, + include_header: bool, + ) -> Vec { + let mut items = Vec::new(); + let mut index = parent_scope.actual_start.saturating_add(1); + while index <= parent_scope.end { + let line = &lines[index]; + if line.is_blank || line.indent != indent { + index += 1; + continue; + } + + if is_header_line(line.trimmed()) { + let header_start = index; + let mut actual_start = index; + while actual_start <= parent_scope.end + && !lines[actual_start].is_blank + && lines[actual_start].indent == indent + && is_header_line(lines[actual_start].trimmed()) + { + actual_start += 1; + } + if actual_start <= parent_scope.end + && lines[actual_start].indent == indent + && qualifies_as_block(lines, actual_start, parent_scope.end) + { + let mut block = + block_range(lines, actual_start, parent_scope.end, include_header); + block.start = header_start; + items.push(ScopeItem::Block(block)); + index = block.end + 1; + continue; + } + items.push(ScopeItem::Barrier); + index = actual_start; + continue; + } + + if qualifies_as_block(lines, index, parent_scope.end) { + let block = block_range(lines, index, parent_scope.end, include_header); + items.push(ScopeItem::Block(block)); + index = block.end + 1; + } else { + items.push(ScopeItem::Barrier); + index += 1; + } + } + items + } + + #[derive(Clone, Copy, Debug)] + enum ScopeItem { + Block(BlockRange), + Barrier, + } +} + +#[cfg(test)] +#[path = "read_file_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 90b9a382a..e05117eb2 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -40,12 +40,14 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::DynamicToolHandler; + use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::JsReplHandler; use crate::tools::handlers::JsReplResetHandler; use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; + use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; @@ -154,6 +156,9 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolHandlerKind::FollowupTaskV2 => { builder.register_handler(handler.name, Arc::new(FollowupTaskHandlerV2)); } + ToolHandlerKind::GrepFiles => { + builder.register_handler(handler.name, Arc::new(GrepFilesHandler)); + } ToolHandlerKind::JsRepl => { builder.register_handler(handler.name, js_repl_handler.clone()); } @@ -175,6 +180,9 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolHandlerKind::Plan => { builder.register_handler(handler.name, plan_handler.clone()); } + ToolHandlerKind::ReadFile => { + builder.register_handler(handler.name, Arc::new(ReadFileHandler)); + } ToolHandlerKind::RequestPermissions => { builder.register_handler(handler.name, request_permissions_handler.clone()); } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index f15e98e99..0e050265e 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -338,6 +338,38 @@ fn test_build_specs_gpt51_codex_default() { ); } +#[test] +fn experimental_read_and_grep_tools_register_handlers() { + let config = test_config(); + let mut model_info = construct_model_info_offline("gpt-5-codex", &config); + model_info.experimental_supported_tools = + vec!["read_file".to_string(), "grep_files".to_string()]; + let features = Features::with_defaults(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + + let (tools, registry) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*app_tools*/ None, + &[], + ) + .build(); + + assert!(tools.iter().any(|tool| tool.name() == "read_file")); + assert!(tools.iter().any(|tool| tool.name() == "grep_files")); + assert!(registry.has_handler("read_file", /*namespace*/ None)); + assert!(registry.has_handler("grep_files", /*namespace*/ None)); +} + #[test] fn test_build_specs_gpt5_codex_unified_exec_web_search() { let mut features = Features::with_defaults(); diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index c4c610e6f..15f957769 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -136,7 +136,9 @@ pub use tool_suggest::ToolSuggestResult; pub use tool_suggest::all_suggested_connectors_picked_up; pub use tool_suggest::build_tool_suggestion_elicitation_request; pub use tool_suggest::verified_connector_suggestion_completed; +pub use utility_tool::create_grep_files_tool; pub use utility_tool::create_list_dir_tool; +pub use utility_tool::create_read_file_tool; pub use utility_tool::create_test_sync_tool; pub use view_image::ViewImageToolOptions; pub use view_image::create_view_image_tool; diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index aa4c84f44..def3718c6 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -24,6 +24,7 @@ use crate::create_close_agent_tool_v2; use crate::create_code_mode_tool; use crate::create_exec_command_tool; use crate::create_followup_task_tool; +use crate::create_grep_files_tool; use crate::create_image_generation_tool; use crate::create_js_repl_reset_tool; use crate::create_js_repl_tool; @@ -33,6 +34,7 @@ use crate::create_list_mcp_resource_templates_tool; use crate::create_list_mcp_resources_tool; use crate::create_local_shell_tool; use crate::create_question_tool; +use crate::create_read_file_tool; use crate::create_read_mcp_resource_tool; use crate::create_report_agent_job_result_tool; use crate::create_request_permissions_tool; @@ -296,6 +298,32 @@ pub fn build_tool_registry_plan( plan.register_handler("apply_patch", ToolHandlerKind::ApplyPatch); } + if config + .experimental_supported_tools + .iter() + .any(|tool| tool == "grep_files") + { + plan.push_spec( + create_grep_files_tool(), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + plan.register_handler("grep_files", ToolHandlerKind::GrepFiles); + } + + if config + .experimental_supported_tools + .iter() + .any(|tool| tool == "read_file") + { + plan.push_spec( + create_read_file_tool(), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + plan.register_handler("read_file", ToolHandlerKind::ReadFile); + } + if config .experimental_supported_tools .iter() diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 685da388f..850e5507a 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -1036,6 +1036,33 @@ fn test_test_model_info_includes_sync_tool() { assert!(tools.iter().any(|tool| tool.name() == "test_sync_tool")); } +#[test] +fn test_model_info_includes_read_and_grep_tools() { + let mut model_info = model_info(); + model_info.experimental_supported_tools = + vec!["read_file".to_string(), "grep_files".to_string()]; + let features = Features::with_defaults(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*app_tools*/ None, + &[], + ); + + assert!(tools.iter().any(|tool| tool.name() == "read_file")); + assert!(tools.iter().any(|tool| tool.name() == "grep_files")); +} + #[test] fn test_build_specs_mcp_tools_converted() { let model_info = model_info(); diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs index d15cf15d5..7fed3b9e9 100644 --- a/codex-rs/tools/src/tool_registry_plan_types.rs +++ b/codex-rs/tools/src/tool_registry_plan_types.rs @@ -18,6 +18,7 @@ pub enum ToolHandlerKind { CodeModeWait, DynamicTool, FollowupTaskV2, + GrepFiles, JsRepl, JsReplReset, ListAgentsV2, @@ -25,6 +26,7 @@ pub enum ToolHandlerKind { Mcp, McpResource, Plan, + ReadFile, RequestPermissions, RequestUserInput, ResumeAgentV1, diff --git a/codex-rs/tools/src/utility_tool.rs b/codex-rs/tools/src/utility_tool.rs index dca9d312f..c64d8b9c5 100644 --- a/codex-rs/tools/src/utility_tool.rs +++ b/codex-rs/tools/src/utility_tool.rs @@ -51,6 +51,164 @@ pub fn create_list_dir_tool() -> ToolSpec { }) } +pub fn create_grep_files_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "pattern".to_string(), + JsonSchema::String { + description: Some("Regular expression pattern to search for.".to_string()), + }, + ), + ( + "include".to_string(), + JsonSchema::String { + description: Some( + "Optional glob that limits which files are searched (e.g. \"*.rs\" or \ + \"*.{ts,tsx}\")." + .to_string(), + ), + }, + ), + ( + "path".to_string(), + JsonSchema::String { + description: Some( + "Directory or file path to search. Defaults to the session's working directory." + .to_string(), + ), + }, + ), + ( + "limit".to_string(), + JsonSchema::Number { + description: Some( + "Maximum number of file paths to return (defaults to 100).".to_string(), + ), + }, + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "grep_files".to_string(), + description: "Finds files whose contents match the pattern and lists them by modification \ + time." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["pattern".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) +} + +pub fn create_read_file_tool() -> ToolSpec { + let indentation_properties = BTreeMap::from([ + ( + "anchor_line".to_string(), + JsonSchema::Number { + description: Some( + "Anchor line to center the indentation lookup on (defaults to offset)." + .to_string(), + ), + }, + ), + ( + "max_levels".to_string(), + JsonSchema::Number { + description: Some( + "How many parent indentation levels (smaller indents) to include.".to_string(), + ), + }, + ), + ( + "include_siblings".to_string(), + JsonSchema::Boolean { + description: Some( + "When true, include additional blocks that share the anchor indentation." + .to_string(), + ), + }, + ), + ( + "include_header".to_string(), + JsonSchema::Boolean { + description: Some( + "Include doc comments or attributes directly above the selected block." + .to_string(), + ), + }, + ), + ( + "max_lines".to_string(), + JsonSchema::Number { + description: Some( + "Hard cap on the number of lines returned when using indentation mode." + .to_string(), + ), + }, + ), + ]); + + let properties = BTreeMap::from([ + ( + "file_path".to_string(), + JsonSchema::String { + description: Some("Absolute path to the file".to_string()), + }, + ), + ( + "offset".to_string(), + JsonSchema::Number { + description: Some( + "The line number to start reading from. Must be 1 or greater.".to_string(), + ), + }, + ), + ( + "limit".to_string(), + JsonSchema::Number { + description: Some("The maximum number of lines to return.".to_string()), + }, + ), + ( + "mode".to_string(), + JsonSchema::String { + description: Some( + "Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \ + to expand around an anchor line." + .to_string(), + ), + }, + ), + ( + "indentation".to_string(), + JsonSchema::Object { + properties: indentation_properties, + required: None, + additional_properties: Some(false.into()), + }, + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "read_file".to_string(), + description: + "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["file_path".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) +} + pub fn create_test_sync_tool() -> ToolSpec { let barrier_properties = BTreeMap::from([ ( diff --git a/codex-rs/tools/src/utility_tool_tests.rs b/codex-rs/tools/src/utility_tool_tests.rs index a8ea6777f..9e116b189 100644 --- a/codex-rs/tools/src/utility_tool_tests.rs +++ b/codex-rs/tools/src/utility_tool_tests.rs @@ -58,6 +58,173 @@ fn list_dir_tool_matches_expected_spec() { ); } +#[test] +fn grep_files_tool_matches_expected_spec() { + assert_eq!( + create_grep_files_tool(), + ToolSpec::Function(ResponsesApiTool { + name: "grep_files".to_string(), + description: "Finds files whose contents match the pattern and lists them by modification \ + time." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::from([ + ( + "include".to_string(), + JsonSchema::String { + description: Some( + "Optional glob that limits which files are searched (e.g. \"*.rs\" or \ + \"*.{ts,tsx}\")." + .to_string(), + ), + }, + ), + ( + "limit".to_string(), + JsonSchema::Number { + description: Some( + "Maximum number of file paths to return (defaults to 100)." + .to_string(), + ), + }, + ), + ( + "path".to_string(), + JsonSchema::String { + description: Some( + "Directory or file path to search. Defaults to the session's working directory." + .to_string(), + ), + }, + ), + ( + "pattern".to_string(), + JsonSchema::String { + description: Some( + "Regular expression pattern to search for.".to_string(), + ), + }, + ), + ]), + required: Some(vec!["pattern".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) + ); +} + +#[test] +fn read_file_tool_matches_expected_spec() { + assert_eq!( + create_read_file_tool(), + ToolSpec::Function(ResponsesApiTool { + name: "read_file".to_string(), + description: + "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::from([ + ( + "file_path".to_string(), + JsonSchema::String { + description: Some("Absolute path to the file".to_string()), + }, + ), + ( + "indentation".to_string(), + JsonSchema::Object { + properties: BTreeMap::from([ + ( + "anchor_line".to_string(), + JsonSchema::Number { + description: Some( + "Anchor line to center the indentation lookup on (defaults to offset)." + .to_string(), + ), + }, + ), + ( + "include_header".to_string(), + JsonSchema::Boolean { + description: Some( + "Include doc comments or attributes directly above the selected block." + .to_string(), + ), + }, + ), + ( + "include_siblings".to_string(), + JsonSchema::Boolean { + description: Some( + "When true, include additional blocks that share the anchor indentation." + .to_string(), + ), + }, + ), + ( + "max_levels".to_string(), + JsonSchema::Number { + description: Some( + "How many parent indentation levels (smaller indents) to include." + .to_string(), + ), + }, + ), + ( + "max_lines".to_string(), + JsonSchema::Number { + description: Some( + "Hard cap on the number of lines returned when using indentation mode." + .to_string(), + ), + }, + ), + ]), + required: None, + additional_properties: Some(false.into()), + }, + ), + ( + "limit".to_string(), + JsonSchema::Number { + description: Some( + "The maximum number of lines to return.".to_string(), + ), + }, + ), + ( + "mode".to_string(), + JsonSchema::String { + description: Some( + "Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \ + to expand around an anchor line." + .to_string(), + ), + }, + ), + ( + "offset".to_string(), + JsonSchema::Number { + description: Some( + "The line number to start reading from. Must be 1 or greater." + .to_string(), + ), + }, + ), + ]), + required: Some(vec!["file_path".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) + ); +} + #[test] fn test_sync_tool_matches_expected_spec() { assert_eq!( From 74d3fe2d504cbfe518d898224e9faf6bbd864c9c Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 20:06:16 +0800 Subject: [PATCH 15/60] replay: restore spawn agent cwd continuity --- .../src/tools/handlers/multi_agents/spawn.rs | 9 + .../src/tools/handlers/multi_agents_common.rs | 40 +++++ .../src/tools/handlers/multi_agents_tests.rs | 163 ++++++++++++++++++ .../tools/handlers/multi_agents_v2/spawn.rs | 9 + codex-rs/tools/src/agent_tool.rs | 18 ++ codex-rs/tools/src/agent_tool_tests.rs | 2 + .../tools/src/tool_registry_plan_tests.rs | 2 + 7 files changed, 243 insertions(+) diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 8e4bfb5b5..17c3362cd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -73,6 +73,14 @@ impl ToolHandler for Handler { .await .map_err(FunctionCallError::RespondToModel)?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + if let Some(cwd) = resolve_requested_agent_cwd(&turn.cwd, args.cwd.as_deref())? { + config.cwd = + codex_utils_absolute_path::AbsolutePathBuf::try_from(cwd).map_err(|error| { + FunctionCallError::RespondToModel(format!( + "spawn_agent cwd must be absolute: {error}" + )) + })?; + } apply_spawn_agent_overrides(&mut config, child_depth); let result = session @@ -175,6 +183,7 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, + cwd: Option, #[serde(default)] fork_context: bool, } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 2078c229b..e55dd2d18 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -24,6 +24,8 @@ use codex_protocol::user_input::UserInput; use serde::Serialize; use serde_json::Value as JsonValue; use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; /// Minimum wait timeout to prevent tight polling loops from burning CPU. pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; @@ -193,6 +195,44 @@ pub(crate) fn parse_collab_input( } } +pub(crate) fn resolve_requested_agent_cwd( + parent_cwd: &Path, + requested_cwd: Option<&str>, +) -> Result, FunctionCallError> { + let Some(requested_cwd) = requested_cwd else { + return Ok(None); + }; + + let requested_cwd = requested_cwd.trim(); + if requested_cwd.is_empty() { + return Err(FunctionCallError::RespondToModel( + "spawn_agent cwd cannot be empty".to_string(), + )); + } + + let requested_path = PathBuf::from(requested_cwd); + let resolved = if requested_path.is_absolute() { + requested_path + } else { + parent_cwd.join(requested_path) + }; + + if !resolved.exists() { + return Err(FunctionCallError::RespondToModel(format!( + "spawn_agent cwd {} does not exist", + resolved.display() + ))); + } + if !resolved.is_dir() { + return Err(FunctionCallError::RespondToModel(format!( + "spawn_agent cwd {} is not a directory", + resolved.display() + ))); + } + + Ok(Some(resolved)) +} + /// Builds the base config snapshot for a newly spawned sub-agent. /// /// The returned config starts from the parent's effective config and then refreshes the diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f3..9e90a8974 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -43,6 +43,7 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::user_input::UserInput; +use core_test_support::PathExt; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde::Deserialize; @@ -324,6 +325,104 @@ async fn spawn_agent_returns_agent_id_without_task_name() { assert_eq!(success, Some(true)); } +#[tokio::test] +async fn spawn_agent_applies_requested_cwd() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let child_workspace = temp_dir.path().join("worker-a"); + std::fs::create_dir(&child_workspace).expect("create child workspace"); + turn.cwd = temp_dir.path().abs(); + + let output = SpawnAgentHandler + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "cwd": "worker-a" + })), + )) + .await + .expect("spawn_agent should succeed"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + assert_eq!(snapshot.cwd, child_workspace); +} + +#[tokio::test] +async fn spawn_agent_rejects_missing_requested_cwd() { + let (session, mut turn) = make_session_and_context().await; + let temp_dir = tempfile::tempdir().expect("tempdir"); + turn.cwd = temp_dir.path().abs(); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "cwd": "missing" + })), + ); + let Err(err) = SpawnAgentHandler.handle(invocation).await else { + panic!("missing cwd should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel(format!( + "spawn_agent cwd {} does not exist", + temp_dir.path().join("missing").display() + )) + ); +} + +#[test] +fn resolve_requested_agent_cwd_rejects_empty_path() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + + let err = resolve_requested_agent_cwd(temp_dir.path(), Some(" ")) + .expect_err("empty cwd should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel("spawn_agent cwd cannot be empty".to_string()) + ); +} + +#[test] +fn resolve_requested_agent_cwd_rejects_non_directory() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("worker.txt"); + std::fs::write(&file_path, "hello").expect("write file"); + + let err = resolve_requested_agent_cwd(temp_dir.path(), Some("worker.txt")) + .expect_err("non-directory cwd should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel(format!( + "spawn_agent cwd {} is not a directory", + file_path.display() + )) + ); +} + #[tokio::test] async fn multi_agent_v2_spawn_requires_task_name() { let (mut session, mut turn) = make_session_and_context().await; @@ -515,6 +614,70 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat })); } +#[tokio::test] +async fn multi_agent_v2_spawn_applies_requested_cwd() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + task_name: String, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let temp_dir = tempfile::tempdir().expect("tempdir"); + let child_workspace = temp_dir.path().join("worker-b"); + std::fs::create_dir(&child_workspace).expect("create child workspace"); + turn.cwd = temp_dir.path().abs(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let output = SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "worker", + "cwd": "worker-b" + })), + )) + .await + .expect("spawn_agent should succeed"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + + let child_thread_id = session + .services + .agent_control + .resolve_agent_reference( + session.conversation_id, + &turn.session_source, + &result.task_name, + ) + .await + .expect("spawned task name should resolve"); + let snapshot = manager + .get_thread(child_thread_id) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + assert_eq!(snapshot.cwd, child_workspace); +} + #[tokio::test] async fn multi_agent_v2_spawn_rejects_legacy_fork_context() { let (mut session, mut turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 77c48af9e..5836ce479 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -82,6 +82,14 @@ impl ToolHandler for Handler { .await .map_err(FunctionCallError::RespondToModel)?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + if let Some(cwd) = resolve_requested_agent_cwd(&turn.cwd, args.cwd.as_deref())? { + config.cwd = + codex_utils_absolute_path::AbsolutePathBuf::try_from(cwd).map_err(|error| { + FunctionCallError::RespondToModel(format!( + "spawn_agent cwd must be absolute: {error}" + )) + })?; + } apply_spawn_agent_overrides(&mut config, child_depth); config.developer_instructions = Some( if let Some(existing_instructions) = config.developer_instructions.take() { @@ -222,6 +230,7 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, + cwd: Option, fork_turns: Option, fork_context: Option, } diff --git a/codex-rs/tools/src/agent_tool.rs b/codex-rs/tools/src/agent_tool.rs index 017cc6e5e..1570206ac 100644 --- a/codex-rs/tools/src/agent_tool.rs +++ b/codex-rs/tools/src/agent_tool.rs @@ -590,6 +590,15 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap BTreeMap Date: Tue, 7 Apr 2026 01:43:03 +0800 Subject: [PATCH 16/60] codex: fix CI failure on PR #19 --- .../src/tools/handlers/grep_files_tests.rs | 12 ++--- codex-rs/core/src/tools/handlers/read_file.rs | 2 +- .../src/tools/handlers/read_file_tests.rs | 46 ++++++++++++------- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/grep_files_tests.rs b/codex-rs/core/src/tools/handlers/grep_files_tests.rs index 0cc247c6f..3d3348616 100644 --- a/codex-rs/core/src/tools/handlers/grep_files_tests.rs +++ b/codex-rs/core/src/tools/handlers/grep_files_tests.rs @@ -5,7 +5,7 @@ use tempfile::tempdir; #[test] fn parses_basic_results() { let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n"; - let parsed = parse_results(stdout, 10); + let parsed = parse_results(stdout, /*limit*/ 10); assert_eq!( parsed, vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()] @@ -15,7 +15,7 @@ fn parses_basic_results() { #[test] fn parse_truncates_after_limit() { let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n/tmp/file_c.rs\n"; - let parsed = parse_results(stdout, 2); + let parsed = parse_results(stdout, /*limit*/ 2); assert_eq!( parsed, vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()] @@ -33,7 +33,7 @@ async fn run_search_returns_results() -> anyhow::Result<()> { std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap(); std::fs::write(dir.join("other.txt"), "omega").unwrap(); - let results = run_rg_search("alpha", None, dir, 10, dir).await?; + let results = run_rg_search("alpha", /*include*/ None, dir, /*limit*/ 10, dir).await?; assert_eq!(results.len(), 2); assert!(results.iter().any(|path| path.ends_with("match_one.txt"))); assert!(results.iter().any(|path| path.ends_with("match_two.txt"))); @@ -50,7 +50,7 @@ async fn run_search_with_glob_filter() -> anyhow::Result<()> { std::fs::write(dir.join("match_one.rs"), "alpha beta gamma").unwrap(); std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap(); - let results = run_rg_search("alpha", Some("*.rs"), dir, 10, dir).await?; + let results = run_rg_search("alpha", Some("*.rs"), dir, /*limit*/ 10, dir).await?; assert_eq!(results.len(), 1); assert!(results.iter().all(|path| path.ends_with("match_one.rs"))); Ok(()) @@ -67,7 +67,7 @@ async fn run_search_respects_limit() -> anyhow::Result<()> { std::fs::write(dir.join("two.txt"), "alpha two").unwrap(); std::fs::write(dir.join("three.txt"), "alpha three").unwrap(); - let results = run_rg_search("alpha", None, dir, 2, dir).await?; + let results = run_rg_search("alpha", /*include*/ None, dir, /*limit*/ 2, dir).await?; assert_eq!(results.len(), 2); Ok(()) } @@ -81,7 +81,7 @@ async fn run_search_handles_no_matches() -> anyhow::Result<()> { let dir = temp.path(); std::fs::write(dir.join("one.txt"), "omega").unwrap(); - let results = run_rg_search("alpha", None, dir, 5, dir).await?; + let results = run_rg_search("alpha", /*include*/ None, dir, /*limit*/ 5, dir).await?; assert!(results.is_empty()); Ok(()) } diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs index 031cfd143..91a0e9a7d 100644 --- a/codex-rs/core/src/tools/handlers/read_file.rs +++ b/codex-rs/core/src/tools/handlers/read_file.rs @@ -426,7 +426,7 @@ mod indentation { let section_end = lines.len() - 1; let mut selection = block_range(&lines, actual_start, section_end, options.include_header); if options.include_siblings - && let Some(parent_start) = find_ancestor_start(&lines, actual_start, 1) + && let Some(parent_start) = find_ancestor_start(&lines, actual_start, /*levels*/ 1) { let parent_scope = block_range( &lines, diff --git a/codex-rs/core/src/tools/handlers/read_file_tests.rs b/codex-rs/core/src/tools/handlers/read_file_tests.rs index 3921a9882..79f16d705 100644 --- a/codex-rs/core/src/tools/handlers/read_file_tests.rs +++ b/codex-rs/core/src/tools/handlers/read_file_tests.rs @@ -16,7 +16,7 @@ gamma " )?; - let lines = read(temp.path(), 2, 2).await?; + let lines = read(temp.path(), /*offset*/ 2, /*limit*/ 2).await?; assert_eq!(lines, vec!["L2: beta".to_string(), "L3: gamma".to_string()]); Ok(()) } @@ -27,7 +27,7 @@ async fn errors_when_offset_exceeds_length() -> anyhow::Result<()> { use std::io::Write as _; writeln!(temp, "only")?; - let err = read(temp.path(), 3, 1) + let err = read(temp.path(), /*offset*/ 3, /*limit*/ 1) .await .expect_err("offset exceeds length"); assert_eq!( @@ -43,7 +43,7 @@ async fn reads_non_utf8_lines() -> anyhow::Result<()> { use std::io::Write as _; temp.as_file_mut().write_all(b"\xff\xfe\nplain\n")?; - let lines = read(temp.path(), 1, 2).await?; + let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 2).await?; let expected_first = format!("L1: {}{}", '\u{FFFD}', '\u{FFFD}'); assert_eq!(lines, vec![expected_first, "L2: plain".to_string()]); Ok(()) @@ -55,7 +55,7 @@ async fn trims_crlf_endings() -> anyhow::Result<()> { use std::io::Write as _; write!(temp, "one\r\ntwo\r\n")?; - let lines = read(temp.path(), 1, 2).await?; + let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 2).await?; assert_eq!(lines, vec!["L1: one".to_string(), "L2: two".to_string()]); Ok(()) } @@ -72,7 +72,7 @@ third " )?; - let lines = read(temp.path(), 1, 2).await?; + let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 2).await?; assert_eq!( lines, vec!["L1: first".to_string(), "L2: second".to_string()] @@ -87,7 +87,7 @@ async fn truncates_lines_longer_than_max_length() -> anyhow::Result<()> { let long_line = "x".repeat(MAX_LINE_LENGTH + 50); writeln!(temp, "{long_line}")?; - let lines = read(temp.path(), 1, 1).await?; + let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 1).await?; let expected = "x".repeat(MAX_LINE_LENGTH); assert_eq!(lines, vec![format!("L1: {expected}")]); Ok(()) @@ -115,7 +115,7 @@ async fn indentation_mode_captures_block() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 3, 10, options).await?; + let lines = read_block(temp.path(), /*offset*/ 3, /*limit*/ 10, options).await?; assert_eq!( lines, @@ -150,7 +150,13 @@ async fn indentation_mode_expands_parents() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 4, 50, options.clone()).await?; + let lines = read_block( + temp.path(), + /*offset*/ 4, + /*limit*/ 50, + options.clone(), + ) + .await?; assert_eq!( lines, vec![ @@ -163,7 +169,7 @@ async fn indentation_mode_expands_parents() -> anyhow::Result<()> { ); options.max_levels = 3; - let expanded = read_block(temp.path(), 4, 50, options).await?; + let expanded = read_block(temp.path(), /*offset*/ 4, /*limit*/ 50, options).await?; assert_eq!( expanded, vec![ @@ -203,7 +209,13 @@ async fn indentation_mode_respects_sibling_flag() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 3, 50, options.clone()).await?; + let lines = read_block( + temp.path(), + /*offset*/ 3, + /*limit*/ 50, + options.clone(), + ) + .await?; assert_eq!( lines, vec![ @@ -214,7 +226,7 @@ async fn indentation_mode_respects_sibling_flag() -> anyhow::Result<()> { ); options.include_siblings = true; - let with_siblings = read_block(temp.path(), 3, 50, options).await?; + let with_siblings = read_block(temp.path(), /*offset*/ 3, /*limit*/ 50, options).await?; assert_eq!( with_siblings, vec![ @@ -257,7 +269,7 @@ class Bar: ..Default::default() }; - let lines = read_block(temp.path(), 1, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 1, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ @@ -313,7 +325,7 @@ export function other() {{ ..Default::default() }; - let lines = read_block(temp.path(), 15, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 15, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ @@ -385,7 +397,7 @@ async fn indentation_mode_handles_cpp_sample_shallow() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 18, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ @@ -413,7 +425,7 @@ async fn indentation_mode_handles_cpp_sample() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 18, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ @@ -445,7 +457,7 @@ async fn indentation_mode_handles_cpp_sample_no_headers() -> anyhow::Result<()> ..Default::default() }; - let lines = read_block(temp.path(), 18, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ @@ -476,7 +488,7 @@ async fn indentation_mode_handles_cpp_sample_siblings() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 18, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ From a59a3a6a68e407b32978c9ec3f579cd04a34e903 Mon Sep 17 00:00:00 2001 From: piping Date: Sat, 4 Apr 2026 23:44:13 +0800 Subject: [PATCH 17/60] replay: restore workflow scheduler core --- codex-rs/tui/src/app.rs | 176 ++++++++++++++++++ codex-rs/tui/src/app/workflow_scheduler.rs | 117 ++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 15 +- .../src/bottom_pane/unified_exec_footer.rs | 64 +++++-- codex-rs/tui/src/chatwidget.rs | 23 ++- codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + codex-rs/tui/src/history_cell.rs | 104 ++++++++++- 7 files changed, 479 insertions(+), 22 deletions(-) create mode 100644 codex-rs/tui/src/app/workflow_scheduler.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c31ea9f08..4bcc5fcbc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -159,12 +159,14 @@ mod app_server_adapter; mod app_server_requests; mod loaded_threads; mod pending_interactive_replay; +mod workflow_scheduler; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::pending_interactive_replay::PendingInteractiveReplayState; +use self::workflow_scheduler::WorkflowSchedulerState; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; @@ -1035,6 +1037,7 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, + workflow_scheduler: WorkflowSchedulerState, } #[derive(Default)] @@ -1872,15 +1875,103 @@ impl App { async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) { if let Some(thread_id) = self.chat_widget.thread_id() { + let shutting_down_primary = self.primary_thread_id == Some(thread_id); // Clear any in-flight rollback guard when switching threads. self.backtrack.pending_rollback = None; if let Err(err) = app_server.thread_unsubscribe(thread_id).await { tracing::warn!("failed to unsubscribe thread {thread_id}: {err}"); } self.abort_thread_event_listener(thread_id); + if shutting_down_primary { + let stopped_count = self.workflow_scheduler.stop_active_workflow_runs().await; + if stopped_count > 0 { + self.sync_background_workflow_status(); + } + } } } + fn background_workflow_labels(&self) -> Vec { + self.workflow_scheduler.background_workflow_labels() + } + + fn queued_trigger_labels(&self) -> Vec { + self.workflow_scheduler.queued_trigger_labels() + } + + fn sync_background_workflow_status(&mut self) { + self.chat_widget.sync_background_workflow_status( + self.background_workflow_labels(), + self.queued_trigger_labels(), + ); + } + + #[cfg(test)] + fn start_test_background_workflow_run( + &mut self, + workflow_name: String, + target_name: String, + is_trigger: bool, + ) -> String { + let run_id = self + .workflow_scheduler + .next_background_run_id(&workflow_name, &target_name); + let label = format!("{workflow_name} · {target_name}"); + let handle = tokio::spawn(async { + std::future::pending::<()>().await; + }); + self.workflow_scheduler.register_background_workflow_run( + run_id.clone(), + label, + is_trigger, + handle, + ); + self.sync_background_workflow_status(); + run_id + } + + #[cfg(test)] + fn start_test_manual_workflow_trigger_run( + &mut self, + workflow_name: String, + trigger_id: String, + ) -> Option { + if self.workflow_scheduler.has_running_trigger_run() { + self.workflow_scheduler + .enqueue_trigger_run(workflow_name, trigger_id); + self.sync_background_workflow_status(); + None + } else { + Some(self.start_test_background_workflow_run( + workflow_name, + trigger_id, + /*is_trigger*/ true, + )) + } + } + + #[cfg(test)] + async fn finish_test_background_workflow_run(&mut self, run_id: String) { + let Some(run) = self + .workflow_scheduler + .take_background_workflow_run(&run_id) + else { + return; + }; + run.handle.abort(); + let _ = run.handle.await; + if run.is_trigger + && let Some(next) = self.workflow_scheduler.dequeue_trigger_run() + { + self.start_test_background_workflow_run( + next.workflow_name, + next.trigger_id, + /*is_trigger*/ true, + ); + } + self.sync_background_workflow_status(); + } + fn abort_thread_event_listener(&mut self, thread_id: ThreadId) { if let Some(handle) = self.thread_event_listener_tasks.remove(&thread_id) { handle.abort(); @@ -4133,6 +4224,7 @@ impl App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + workflow_scheduler: WorkflowSchedulerState::default(), }; if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) @@ -9460,6 +9552,7 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + workflow_scheduler: WorkflowSchedulerState::default(), } } @@ -9514,6 +9607,7 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + workflow_scheduler: WorkflowSchedulerState::default(), }, rx, op_rx, @@ -11173,6 +11267,88 @@ model = "gpt-5.2" ); } + #[tokio::test] + async fn shutting_down_primary_thread_stops_background_workflow_runs() { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: started.session.forked_from_id, + thread_name: started.session.thread_name.clone(), + model: started.session.model.clone(), + model_provider_id: started.session.model_provider_id.clone(), + service_tier: started.session.service_tier, + approval_policy: started.session.approval_policy, + approvals_reviewer: started.session.approvals_reviewer, + sandbox_policy: started.session.sandbox_policy.clone(), + cwd: started.session.cwd.clone(), + reasoning_effort: started.session.reasoning_effort, + history_log_id: started.session.history_log_id, + history_entry_count: usize::try_from(started.session.history_entry_count) + .expect("history entry count fits usize"), + initial_messages: None, + network_proxy: started.session.network_proxy.clone(), + rollout_path: started.session.rollout_path.clone(), + }), + }); + + let _run_id = app.start_test_background_workflow_run( + "director".to_string(), + "review_backlog".to_string(), + /*is_trigger*/ false, + ); + assert_eq!( + app.background_workflow_labels(), + vec!["director · review_backlog".to_string()] + ); + + app.shutdown_current_thread(&mut app_server).await; + + assert!(app.background_workflow_labels().is_empty()); + assert!(app.queued_trigger_labels().is_empty()); + } + + #[tokio::test] + async fn manual_triggers_use_a_global_fifo_queue() { + let mut app = make_test_app().await; + + let slow_run = + app.start_test_manual_workflow_trigger_run("director".to_string(), "slow".to_string()); + let fast_run = + app.start_test_manual_workflow_trigger_run("director".to_string(), "fast".to_string()); + + assert!(slow_run.is_some()); + assert!(fast_run.is_none()); + assert_eq!( + app.background_workflow_labels(), + vec!["director · slow".to_string()] + ); + assert_eq!( + app.queued_trigger_labels(), + vec!["director · fast".to_string()] + ); + + app.finish_test_background_workflow_run(slow_run.expect("slow run id")) + .await; + + assert_eq!( + app.background_workflow_labels(), + vec!["director · fast".to_string()] + ); + assert!(app.queued_trigger_labels().is_empty()); + } + #[tokio::test] async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/workflow_scheduler.rs b/codex-rs/tui/src/app/workflow_scheduler.rs new file mode 100644 index 000000000..1caca60f0 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_scheduler.rs @@ -0,0 +1,117 @@ +use std::collections::HashMap; +use std::collections::VecDeque; +use tokio::task::JoinHandle; + +pub(crate) struct BackgroundWorkflowRunState { + pub(crate) label: String, + #[cfg(test)] + pub(crate) is_trigger: bool, + pub(crate) handle: JoinHandle<()>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct QueuedWorkflowTriggerRun { + pub(crate) workflow_name: String, + pub(crate) trigger_id: String, +} + +#[derive(Default)] +pub(crate) struct WorkflowSchedulerState { + running_workflows: HashMap, + queued_trigger_runs: VecDeque, + #[cfg(test)] + next_background_run_id: u64, +} + +impl WorkflowSchedulerState { + #[cfg(test)] + pub(crate) fn next_background_run_id( + &mut self, + workflow_name: &str, + target_name: &str, + ) -> String { + self.next_background_run_id = self.next_background_run_id.saturating_add(1); + format!( + "{workflow_name}/{target_name}#{}", + self.next_background_run_id + ) + } + + #[cfg(test)] + pub(crate) fn register_background_workflow_run( + &mut self, + run_id: String, + label: String, + is_trigger: bool, + handle: JoinHandle<()>, + ) { + self.running_workflows.insert( + run_id, + BackgroundWorkflowRunState { + label, + #[cfg(test)] + is_trigger, + handle, + }, + ); + } + + #[cfg(test)] + pub(crate) fn take_background_workflow_run( + &mut self, + run_id: &str, + ) -> Option { + self.running_workflows.remove(run_id) + } + + pub(crate) fn background_workflow_labels(&self) -> Vec { + let mut labels = self + .running_workflows + .values() + .map(|run| run.label.clone()) + .collect::>(); + labels.sort(); + labels + } + + pub(crate) fn queued_trigger_labels(&self) -> Vec { + self.queued_trigger_runs + .iter() + .map(|run| format!("{} · {}", run.workflow_name, run.trigger_id)) + .collect() + } + + #[cfg(test)] + pub(crate) fn has_running_trigger_run(&self) -> bool { + self.running_workflows.values().any(|run| run.is_trigger) + } + + #[cfg(test)] + pub(crate) fn enqueue_trigger_run(&mut self, workflow_name: String, trigger_id: String) { + self.queued_trigger_runs + .push_back(QueuedWorkflowTriggerRun { + workflow_name, + trigger_id, + }); + } + + #[cfg(test)] + pub(crate) fn dequeue_trigger_run(&mut self) -> Option { + self.queued_trigger_runs.pop_front() + } + + pub(crate) async fn stop_active_workflow_runs(&mut self) -> usize { + let runs = self + .running_workflows + .drain() + .map(|(_, run)| run) + .collect::>(); + let stopped_count = runs.len(); + for run in runs { + run.handle.abort(); + let _ = run.handle.await; + } + self.queued_trigger_runs.clear(); + stopped_count + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index da5e2421f..9bde1a842 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -831,17 +831,26 @@ impl BottomPane { self.pending_thread_approvals.threads() } - /// Update the unified-exec process set and refresh whichever summary surface is active. + /// Update the unified-exec activity set and refresh whichever summary surface is active. /// /// The summary may be displayed inline in the status row or as a dedicated /// footer row depending on whether a status indicator is currently visible. - pub(crate) fn set_unified_exec_processes(&mut self, processes: Vec) { - if self.unified_exec_footer.set_processes(processes) { + pub(crate) fn set_unified_exec_activity( + &mut self, + processes: Vec, + workflows: Vec, + ) { + if self.unified_exec_footer.set_activity(processes, workflows) { self.sync_status_inline_message(); self.request_redraw(); } } + #[cfg(test)] + pub(crate) fn set_unified_exec_processes(&mut self, processes: Vec) { + self.set_unified_exec_activity(processes, Vec::new()); + } + /// Copy unified-exec summary text into the active status row, if any. /// /// This keeps status-line inline text synchronized without forcing the diff --git a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs index 9b69387c1..433bc1e80 100644 --- a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs +++ b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs @@ -13,28 +13,31 @@ use ratatui::widgets::Paragraph; use crate::live_wrap::take_prefix_by_width; use crate::render::renderable::Renderable; -/// Tracks active unified-exec processes and renders a compact summary. +/// Tracks active unified-exec processes and workflows and renders a compact summary. pub(crate) struct UnifiedExecFooter { - processes: Vec, + terminals: Vec, + workflows: Vec, } impl UnifiedExecFooter { pub(crate) fn new() -> Self { Self { - processes: Vec::new(), + terminals: Vec::new(), + workflows: Vec::new(), } } - pub(crate) fn set_processes(&mut self, processes: Vec) -> bool { - if self.processes == processes { + pub(crate) fn set_activity(&mut self, terminals: Vec, workflows: Vec) -> bool { + if self.terminals == terminals && self.workflows == workflows { return false; } - self.processes = processes; + self.terminals = terminals; + self.workflows = workflows; true } pub(crate) fn is_empty(&self) -> bool { - self.processes.is_empty() + self.terminals.is_empty() && self.workflows.is_empty() } /// Returns the unindented summary text used by both footer and status-row rendering. @@ -43,14 +46,27 @@ impl UnifiedExecFooter { /// callers can choose layout-specific framing (inline separator vs. row /// indentation). Returning `None` means there is nothing to surface. pub(crate) fn summary_text(&self) -> Option { - if self.processes.is_empty() { + let terminal_count = self.terminals.len(); + let workflow_count = self.workflows.len(); + if terminal_count == 0 && workflow_count == 0 { return None; } - - let count = self.processes.len(); - let plural = if count == 1 { "" } else { "s" }; + let mut parts = Vec::new(); + if terminal_count > 0 { + let plural = if terminal_count == 1 { "" } else { "s" }; + parts.push(format!( + "{terminal_count} background terminal{plural} running" + )); + } + if workflow_count > 0 { + let plural = if workflow_count == 1 { "" } else { "s" }; + parts.push(format!( + "{workflow_count} background workflow{plural} running" + )); + } Some(format!( - "{count} background terminal{plural} running · /ps to view · /stop to close" + "{} · /ps to view · /stop to close", + parts.join(" · ") )) } @@ -96,7 +112,7 @@ mod tests { #[test] fn render_more_sessions() { let mut footer = UnifiedExecFooter::new(); - footer.set_processes(vec!["rg \"foo\" src".to_string()]); + footer.set_activity(vec!["rg \"foo\" src".to_string()], Vec::new()); let width = 50; let height = footer.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); @@ -107,11 +123,31 @@ mod tests { #[test] fn render_many_sessions() { let mut footer = UnifiedExecFooter::new(); - footer.set_processes((0..123).map(|idx| format!("cmd {idx}")).collect()); + footer.set_activity( + (0..123).map(|idx| format!("cmd {idx}")).collect(), + Vec::new(), + ); let width = 50; let height = footer.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); footer.render(Rect::new(0, 0, width, height), &mut buf); assert_snapshot!("render_many_sessions", format!("{buf:?}")); } + + #[test] + fn summary_text_includes_workflows() { + let mut footer = UnifiedExecFooter::new(); + footer.set_activity( + vec!["rg \"foo\" src".to_string()], + vec!["director · after_turn".to_string()], + ); + + assert_eq!( + footer.summary_text(), + Some( + "1 background terminal running · 1 background workflow running · /ps to view · /stop to close" + .to_string() + ) + ); + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b3941f0ba..10cc4d99e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -802,6 +802,8 @@ pub(crate) struct ChatWidget { turn_sleep_inhibitor: SleepInhibitor, task_complete_pending: bool, unified_exec_processes: Vec, + background_workflow_labels: Vec, + queued_workflow_labels: Vec, /// Tracks whether codex-core currently considers an agent turn to be in progress. /// /// This is kept separate from `mcp_startup_status` so that MCP startup progress (or completion) @@ -3652,7 +3654,18 @@ impl ChatWidget { .iter() .map(|process| process.command_display.clone()) .collect(); - self.bottom_pane.set_unified_exec_processes(processes); + self.bottom_pane + .set_unified_exec_activity(processes, self.background_workflow_labels.clone()); + } + + pub(crate) fn sync_background_workflow_status( + &mut self, + running_workflows: Vec, + queued_workflows: Vec, + ) { + self.background_workflow_labels = running_workflows; + self.queued_workflow_labels = queued_workflows; + self.sync_unified_exec_footer(); } /// Record recent stdout/stderr lines for the unified exec footer. @@ -4680,6 +4693,8 @@ impl ChatWidget { turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), task_complete_pending: false, unified_exec_processes: Vec::new(), + background_workflow_labels: Vec::new(), + queued_workflow_labels: Vec::new(), agent_turn_running: false, mcp_startup_status: None, pending_turn_copyable_output: None, @@ -7517,7 +7532,11 @@ impl ChatWidget { recent_chunks: process.recent_chunks.clone(), }) .collect(); - self.add_to_history(history_cell::new_unified_exec_processes_output(processes)); + self.add_to_history(history_cell::new_background_tasks_output( + processes, + self.background_workflow_labels.clone(), + self.queued_workflow_labels.clone(), + )); } fn clean_background_terminals(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index ddd2db8ab..4ded7ec62 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -219,6 +219,8 @@ pub(super) async fn make_chatwidget_manual( turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), task_complete_pending: false, unified_exec_processes: Vec::new(), + background_workflow_labels: Vec::new(), + queued_workflow_labels: Vec::new(), agent_turn_running: false, mcp_startup_status: None, mcp_startup_expected_servers: None, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index cf65f918e..f7602cc46 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -655,11 +655,30 @@ pub(crate) fn new_unified_exec_interaction( #[derive(Debug)] struct UnifiedExecProcessesCell { processes: Vec, + running_workflows: Vec, + queued_workflows: Vec, } impl UnifiedExecProcessesCell { + #[cfg(test)] fn new(processes: Vec) -> Self { - Self { processes } + Self { + processes, + running_workflows: Vec::new(), + queued_workflows: Vec::new(), + } + } + + fn new_with_workflows( + processes: Vec, + running_workflows: Vec, + queued_workflows: Vec, + ) -> Self { + Self { + processes, + running_workflows, + queued_workflows, + } } } @@ -678,14 +697,63 @@ impl HistoryCell for UnifiedExecProcessesCell { let wrap_width = width as usize; let max_processes = 16usize; let mut out: Vec> = Vec::new(); - out.push(vec!["Background terminals".bold()].into()); + let show_workflows = + !self.running_workflows.is_empty() || !self.queued_workflows.is_empty(); + out.push( + vec![if show_workflows { + "Background tasks".bold() + } else { + "Background terminals".bold() + }] + .into(), + ); out.push("".into()); - if self.processes.is_empty() { + if !show_workflows && self.processes.is_empty() { out.push(" • No background terminals running.".italic().into()); return out; } + if show_workflows && self.processes.is_empty() { + if !self.running_workflows.is_empty() { + out.push("Running workflows".bold().into()); + for workflow in &self.running_workflows { + out.push(vec![" • ".dim(), workflow.clone().cyan()].into()); + } + } + if !self.running_workflows.is_empty() && !self.queued_workflows.is_empty() { + out.push("".into()); + } + if !self.queued_workflows.is_empty() { + out.push("Queued workflows".bold().into()); + for workflow in &self.queued_workflows { + out.push(vec![" • ".dim(), workflow.clone().magenta()].into()); + } + } + return out; + } + + if !self.running_workflows.is_empty() { + out.push("Running workflows".bold().into()); + for workflow in &self.running_workflows { + out.push(vec![" • ".dim(), workflow.clone().cyan()].into()); + } + out.push("".into()); + } + + if !self.queued_workflows.is_empty() { + out.push("Queued workflows".bold().into()); + for workflow in &self.queued_workflows { + out.push(vec![" • ".dim(), workflow.clone().magenta()].into()); + } + out.push("".into()); + } + + if show_workflows { + out.push("Background terminals".bold().into()); + out.push("".into()); + } + let prefix = " • "; let prefix_width = UnicodeWidthStr::width(prefix); let truncation_suffix = " [...]"; @@ -779,6 +847,7 @@ impl HistoryCell for UnifiedExecProcessesCell { } } +#[cfg(test)] pub(crate) fn new_unified_exec_processes_output( processes: Vec, ) -> CompositeHistoryCell { @@ -787,6 +856,20 @@ pub(crate) fn new_unified_exec_processes_output( CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)]) } +pub(crate) fn new_background_tasks_output( + processes: Vec, + running_workflows: Vec, + queued_workflows: Vec, +) -> CompositeHistoryCell { + let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]); + let summary = UnifiedExecProcessesCell::new_with_workflows( + processes, + running_workflows, + queued_workflows, + ); + CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)]) +} + fn truncate_exec_snippet(full_cmd: &str) -> String { let mut snippet = match full_cmd.split_once('\n') { Some((first, _)) => format!("{first} ..."), @@ -3191,6 +3274,21 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn ps_output_shows_running_and_queued_workflows() { + let cell = new_background_tasks_output( + Vec::new(), + vec!["build-index · after_turn".to_string()], + vec!["sync-docs · manual".to_string()], + ); + let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n"); + + assert!(rendered.contains("Running workflows")); + assert!(rendered.contains("Queued workflows")); + assert!(rendered.contains("build-index · after_turn")); + assert!(rendered.contains("sync-docs · manual")); + } + #[test] fn error_event_oversized_input_snapshot() { let cell = new_error_event( From 6e759655a487c297e65555021fcb6920ecfe7faf Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 00:32:13 +0800 Subject: [PATCH 18/60] replay: restore workflow runtime on app-server --- codex-rs/Cargo.lock | 2 + codex-rs/tui/Cargo.toml | 2 + codex-rs/tui/src/app.rs | 74 +- codex-rs/tui/src/app/workflow_definition.rs | 380 ++++++ codex-rs/tui/src/app/workflow_runtime.rs | 1282 +++++++++++++++++++ codex-rs/tui/src/app/workflow_scheduler.rs | 37 +- codex-rs/tui/src/app_event.rs | 7 + 7 files changed, 1745 insertions(+), 39 deletions(-) create mode 100644 codex-rs/tui/src/app/workflow_definition.rs create mode 100644 codex-rs/tui/src/app/workflow_runtime.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 721344950..73d1f14f7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2820,6 +2820,7 @@ dependencies = [ "dirs", "dunce", "image", + "indexmap 2.13.0", "insta", "itertools 0.14.0", "lazy_static", @@ -2835,6 +2836,7 @@ dependencies = [ "rmcp", "serde", "serde_json", + "serde_yaml", "serial_test", "shlex", "strum 0.27.2", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 57fff5bae..1a8ebb726 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -63,6 +63,7 @@ diffy = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] } +indexmap = { workspace = true, features = ["serde"] } itertools = { workspace = true } lazy_static = { workspace = true } pathdiff = { workspace = true } @@ -80,6 +81,7 @@ reqwest = { workspace = true, features = ["json"] } rmcp = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } +serde_yaml = { workspace = true } shlex = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4bcc5fcbc..ec91f1256 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -152,6 +152,8 @@ use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::unbounded_channel; use tokio::task::JoinHandle; +#[cfg(test)] +use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; use uuid::Uuid; mod agent_navigation; @@ -159,6 +161,8 @@ mod app_server_adapter; mod app_server_requests; mod loaded_threads; mod pending_interactive_replay; +mod workflow_definition; +pub(crate) mod workflow_runtime; mod workflow_scheduler; use self::agent_navigation::AgentNavigationDirection; @@ -1906,6 +1910,30 @@ impl App { ); } + fn insert_visible_history_cell(&mut self, tui: &mut tui::Tui, cell: Arc) { + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_cell(cell.clone()); + tui.frame_requester().schedule_frame(); + } + self.transcript_cells.push(cell.clone()); + let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); + if display.is_empty() { + return; + } + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); + } else { + self.has_emitted_history_lines = true; + } + } + if self.overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } + } + #[cfg(test)] fn start_test_background_workflow_run( &mut self, @@ -1916,14 +1944,24 @@ impl App { let run_id = self .workflow_scheduler .next_background_run_id(&workflow_name, &target_name); - let label = format!("{workflow_name} · {target_name}"); let handle = tokio::spawn(async { std::future::pending::<()>().await; }); + let target = if is_trigger { + workflow_runtime::BackgroundWorkflowRunTarget::Trigger { + workflow_name, + trigger_id: target_name, + } + } else { + workflow_runtime::BackgroundWorkflowRunTarget::Job { + workflow_name, + job_name: target_name, + } + }; self.workflow_scheduler.register_background_workflow_run( run_id.clone(), - label, - is_trigger, + target, + CancellationToken::new(), handle, ); self.sync_background_workflow_status(); @@ -4642,28 +4680,14 @@ impl App { } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.insert_cell(cell.clone()); - tui.frame_requester().schedule_frame(); - } - self.transcript_cells.push(cell.clone()); - let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); - if !display.is_empty() { - // Only insert a separating blank line for new cells that are not - // part of an ongoing stream. Streaming continuations should not - // accrue extra blank lines between chunks. - if !cell.is_stream_continuation() { - if self.has_emitted_history_lines { - display.insert(0, Line::from("")); - } else { - self.has_emitted_history_lines = true; - } - } - if self.overlay.is_some() { - self.deferred_history_lines.extend(display); - } else { - tui.insert_history_lines(display); - } + self.insert_visible_history_cell(tui, cell); + } + AppEvent::BackgroundWorkflowRunCompleted { run_id, result } => { + let cells = self + .finish_background_workflow_run(app_server, run_id, *result) + .await; + for cell in cells { + self.insert_visible_history_cell(tui, cell); } } AppEvent::RetryLastUserTurnWithProfileFallback { diff --git a/codex-rs/tui/src/app/workflow_definition.rs b/codex-rs/tui/src/app/workflow_definition.rs new file mode 100644 index 000000000..1d01f89b5 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_definition.rs @@ -0,0 +1,380 @@ +use indexmap::IndexMap; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::VecDeque; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +const WORKFLOW_DIR_NAME: &str = "workflows"; + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WorkflowDefinitionError { + Io(String), + Invalid(String), +} + +impl std::fmt::Display for WorkflowDefinitionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(message) | Self::Invalid(message) => f.write_str(message), + } + } +} + +impl std::error::Error for WorkflowDefinitionError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WorkflowContextMode { + Embed, + #[default] + Ephemeral, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WorkflowResponseMode { + #[default] + Assistant, + User, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub(crate) enum WorkflowStep { + Run { run: String, retry: Option }, + Prompt { prompt: String, retry: Option }, +} + +impl WorkflowStep { + pub(crate) fn retry_attempts(&self) -> u32 { + match self { + Self::Run { retry, .. } | Self::Prompt { retry, .. } => retry.unwrap_or(1), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct WorkflowJobConfig { + #[serde(default = "default_true")] + pub(crate) enabled: bool, + #[serde(default)] + pub(crate) needs: Vec, + #[serde(default)] + pub(crate) context: WorkflowContextMode, + #[serde(default)] + pub(crate) response: WorkflowResponseMode, + #[serde(default)] + pub(crate) steps: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct WorkflowFile { + name: String, + #[serde(default)] + triggers: Vec, + #[serde(default)] + jobs: IndexMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct WorkflowTriggerConfig { + #[serde(default)] + id: Option, + #[serde(default = "default_true")] + enabled: bool, + jobs: Vec, + #[serde(flatten)] + kind: WorkflowTriggerKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum WorkflowTriggerKind { + Manual, + BeforeTurn, + AfterTurn, + Idle { after: String }, + Interval { every: String }, + Cron { cron: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedWorkflowRegistry { + pub(crate) files: Vec, + pub(crate) jobs: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedWorkflowFile { + pub(crate) name: String, + pub(crate) source_path: PathBuf, + pub(crate) triggers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedWorkflowTrigger { + pub(crate) id: String, + pub(crate) enabled: bool, + pub(crate) jobs: Vec, + pub(crate) kind: WorkflowTriggerKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedWorkflowJob { + pub(crate) name: String, + pub(crate) workflow_name: String, + pub(crate) workflow_path: PathBuf, + pub(crate) definition_index: usize, + pub(crate) config: WorkflowJobConfig, +} + +pub(crate) fn load_workflow_registry( + cwd: &Path, +) -> Result { + let workflow_dir = cwd.join(".codex").join(WORKFLOW_DIR_NAME); + if !workflow_dir.exists() { + return Ok(LoadedWorkflowRegistry { + files: Vec::new(), + jobs: BTreeMap::new(), + }); + } + + let mut files = fs::read_dir(&workflow_dir) + .map_err(|err| { + WorkflowDefinitionError::Io(format!( + "failed to read workflow directory `{}`: {err}", + workflow_dir.display() + )) + })? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "yaml") + }) + .collect::>(); + files.sort(); + + let mut workflow_names = BTreeSet::new(); + let mut loaded_files = Vec::new(); + let mut jobs = BTreeMap::new(); + let mut next_job_index = 0usize; + + for path in files { + let contents = fs::read_to_string(&path).map_err(|err| { + WorkflowDefinitionError::Io(format!( + "failed to read workflow file `{}`: {err}", + path.display() + )) + })?; + let file: WorkflowFile = serde_yaml::from_str(&contents).map_err(|err| { + WorkflowDefinitionError::Invalid(format!( + "failed to parse workflow file `{}`: {err}", + path.display() + )) + })?; + + let workflow_name = file.name.trim(); + if workflow_name.is_empty() { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow file `{}` must define a non-empty `name`", + path.display() + ))); + } + if !workflow_names.insert(workflow_name.to_string()) { + return Err(WorkflowDefinitionError::Invalid(format!( + "duplicate workflow name `{workflow_name}` detected in `{}`", + path.display() + ))); + } + + for (job_name, job) in &file.jobs { + let job_name = job_name.trim(); + if job_name.is_empty() { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` in `{}` contains an empty job name", + path.display() + ))); + } + if jobs.contains_key(job_name) { + return Err(WorkflowDefinitionError::Invalid(format!( + "duplicate job name `{job_name}` detected while loading `{}`", + path.display() + ))); + } + if job.steps.is_empty() { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` job `{job_name}` in `{}` must define at least one step", + path.display() + ))); + } + if matches!(job.context, WorkflowContextMode::Embed) + && job + .steps + .iter() + .any(|step| matches!(step, WorkflowStep::Run { .. })) + { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` job `{job_name}` in `{}` cannot use `run` steps when `context` is `embed`", + path.display() + ))); + } + jobs.insert( + job_name.to_string(), + LoadedWorkflowJob { + name: job_name.to_string(), + workflow_name: workflow_name.to_string(), + workflow_path: path.clone(), + definition_index: next_job_index, + config: job.clone(), + }, + ); + next_job_index = next_job_index.saturating_add(1); + } + + let mut trigger_ids = BTreeSet::new(); + let mut triggers = Vec::new(); + for (index, trigger) in file.triggers.iter().enumerate() { + if trigger.jobs.is_empty() { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` trigger #{index} in `{}` must reference at least one job", + path.display() + ))); + } + for job_name in &trigger.jobs { + if !file.jobs.contains_key(job_name) { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` trigger `{}` in `{}` references missing job `{job_name}`", + trigger.id.as_deref().unwrap_or(""), + path.display() + ))); + } + } + let trigger_id = trigger + .id + .clone() + .unwrap_or_else(|| format!("trigger-{}", index + 1)); + if !trigger_ids.insert(trigger_id.clone()) { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` in `{}` contains duplicate trigger id `{trigger_id}`", + path.display() + ))); + } + triggers.push(LoadedWorkflowTrigger { + id: trigger_id, + enabled: trigger.enabled, + jobs: trigger.jobs.clone(), + kind: trigger.kind.clone(), + }); + } + + loaded_files.push(LoadedWorkflowFile { + name: workflow_name.to_string(), + source_path: path, + triggers, + }); + } + + for job in jobs.values() { + for dependency in &job.config.needs { + if !jobs.contains_key(dependency) { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{}` job `{}` references missing dependency `{dependency}`", + job.workflow_name, job.name + ))); + } + } + } + + Ok(LoadedWorkflowRegistry { + files: loaded_files, + jobs, + }) +} + +pub(crate) fn ordered_jobs_for_roots( + registry: &LoadedWorkflowRegistry, + root_jobs: &[String], +) -> Result, WorkflowDefinitionError> { + let mut reachable = BTreeSet::new(); + let mut stack = root_jobs.to_vec(); + while let Some(job_name) = stack.pop() { + let job = registry.jobs.get(&job_name).ok_or_else(|| { + WorkflowDefinitionError::Invalid(format!( + "workflow execution root references missing job `{job_name}`" + )) + })?; + if reachable.insert(job_name.clone()) { + stack.extend(job.config.needs.iter().cloned()); + } + } + + let mut indegree = reachable + .iter() + .map(|job_name| (job_name.clone(), 0usize)) + .collect::>(); + let mut dependents = BTreeMap::>::new(); + for job_name in &reachable { + let job = registry + .jobs + .get(job_name) + .expect("reachable job should exist"); + for dependency in &job.config.needs { + if !reachable.contains(dependency) { + continue; + } + *indegree.entry(job_name.clone()).or_default() += 1; + dependents + .entry(dependency.clone()) + .or_default() + .push(job_name.clone()); + } + } + + let mut ready = reachable + .iter() + .filter(|job_name| indegree.get(*job_name) == Some(&0)) + .cloned() + .collect::>(); + let mut ordered = Vec::new(); + while let Some(job_name) = pop_next_job(&mut ready, registry) { + ordered.push(job_name.clone()); + for dependent in dependents.get(&job_name).into_iter().flatten() { + if let Some(entry) = indegree.get_mut(dependent) { + *entry = entry.saturating_sub(1); + if *entry == 0 { + ready.push_back(dependent.clone()); + } + } + } + } + + if ordered.len() != reachable.len() { + return Err(WorkflowDefinitionError::Invalid( + "workflow job selection contains a cyclic dependency graph".to_string(), + )); + } + + Ok(ordered) +} + +fn pop_next_job(ready: &mut VecDeque, registry: &LoadedWorkflowRegistry) -> Option { + let best_index = ready + .iter() + .enumerate() + .min_by_key(|(_, job_name)| { + registry + .jobs + .get(*job_name) + .expect("ready job should exist") + .definition_index + }) + .map(|(index, _)| index)?; + ready.remove(best_index) +} diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs new file mode 100644 index 000000000..2dd4064d9 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -0,0 +1,1282 @@ +use super::App; +use super::workflow_definition::LoadedWorkflowJob; +use super::workflow_definition::LoadedWorkflowRegistry; +use super::workflow_definition::WorkflowContextMode; +use super::workflow_definition::WorkflowResponseMode; +use super::workflow_definition::WorkflowStep; +use super::workflow_definition::WorkflowTriggerKind; +use super::workflow_definition::load_workflow_registry; +use super::workflow_definition::ordered_jobs_for_roots; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::history_cell; +use crate::history_cell::HistoryCell; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadUnsubscribeParams; +use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_core::config::Config; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use tokio::process::Command; +use tokio::time::sleep; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +const WORKFLOW_POLL_INTERVAL: Duration = Duration::from_millis(50); +const WORKFLOW_INTERRUPT_SETTLE_TIMEOUT: Duration = Duration::from_secs(1); + +type BoxFuture<'a, T> = Pin + Send + 'a>>; + +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BackgroundWorkflowRunTarget { + Trigger { + workflow_name: String, + trigger_id: String, + }, + Job { + workflow_name: String, + job_name: String, + }, +} + +impl BackgroundWorkflowRunTarget { + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn workflow_name(&self) -> &str { + match self { + Self::Trigger { workflow_name, .. } | Self::Job { workflow_name, .. } => workflow_name, + } + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn slot_key(&self) -> &str { + match self { + Self::Trigger { trigger_id, .. } => trigger_id, + Self::Job { job_name, .. } => job_name, + } + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn label(&self) -> String { + format!("{} · {}", self.workflow_name(), self.slot_key()) + } + + #[cfg_attr(not(test), allow(dead_code))] + fn started_message(&self) -> &'static str { + match self { + Self::Trigger { .. } => "Workflow trigger started", + Self::Job { .. } => "Workflow job started", + } + } + + fn completed_message(&self) -> &'static str { + match self { + Self::Trigger { .. } => "Workflow trigger completed", + Self::Job { .. } => "Workflow job completed", + } + } + + fn stopped_message(&self) -> &'static str { + match self { + Self::Trigger { .. } => "Workflow trigger stopped", + Self::Job { .. } => "Workflow job stopped", + } + } + + fn failed_message(&self) -> &'static str { + match self { + Self::Trigger { .. } => "Workflow trigger failed", + Self::Job { .. } => "Workflow job failed", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkflowOutputDelivery { + MainThreadInput, + AssistantCell, + UserFollowup, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkflowJobRunResult { + pub(crate) delivery: WorkflowOutputDelivery, + pub(crate) workflow_name: String, + pub(crate) trigger_id: String, + pub(crate) job_name: String, + pub(crate) message: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum BackgroundWorkflowRunOutcome { + Completed(Vec), + Cancelled, + Failed(String), +} + +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Debug)] +pub(crate) struct BackgroundWorkflowRunResult { + pub(crate) target: BackgroundWorkflowRunTarget, + pub(crate) outcome: BackgroundWorkflowRunOutcome, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WorkflowThreadSession { + thread_id: String, + cwd: PathBuf, +} + +#[derive(Debug, Clone, PartialEq)] +struct WorkflowTurnState { + status: TurnStatus, + error: Option, + last_agent_message: Option, +} + +trait WorkflowRuntimeClient: Send + Sync { + fn start_workflow_thread(&self) -> BoxFuture<'_, Result>; + fn start_turn( + &self, + thread_id: String, + cwd: PathBuf, + input: String, + ) -> BoxFuture<'_, Result>; + fn read_turn( + &self, + thread_id: String, + turn_id: String, + ) -> BoxFuture<'_, Result>; + fn interrupt_turn( + &self, + thread_id: String, + turn_id: String, + ) -> BoxFuture<'_, Result<(), String>>; + fn unsubscribe_thread(&self, thread_id: String) -> BoxFuture<'_, Result<(), String>>; +} + +pub(crate) struct AppServerWorkflowRuntimeClient { + request_handle: AppServerRequestHandle, + config: Config, + primary_thread_id: Option, + is_remote: bool, + remote_cwd_override: Option, +} + +impl AppServerWorkflowRuntimeClient { + pub(crate) fn new( + app_server: &AppServerSession, + config: Config, + primary_thread_id: Option, + ) -> Self { + Self { + request_handle: app_server.request_handle(), + config, + primary_thread_id, + is_remote: app_server.is_remote(), + remote_cwd_override: app_server.remote_cwd_override().map(PathBuf::from), + } + } +} + +impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { + fn start_workflow_thread(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + if let Some(primary_thread_id) = self.primary_thread_id { + let response: ThreadForkResponse = self + .request_handle + .request_typed(ClientRequest::ThreadFork { + request_id: request_id(), + params: workflow_thread_fork_params( + &self.config, + primary_thread_id, + self.is_remote, + self.remote_cwd_override.as_deref(), + ), + }) + .await + .map_err(|err| format!("failed to fork workflow thread: {err}"))?; + return Ok(WorkflowThreadSession { + thread_id: response.thread.id, + cwd: response.cwd, + }); + } + + let response: ThreadStartResponse = self + .request_handle + .request_typed(ClientRequest::ThreadStart { + request_id: request_id(), + params: workflow_thread_start_params( + &self.config, + self.is_remote, + self.remote_cwd_override.as_deref(), + ), + }) + .await + .map_err(|err| format!("failed to start workflow thread: {err}"))?; + Ok(WorkflowThreadSession { + thread_id: response.thread.id, + cwd: response.cwd, + }) + }) + } + + fn start_turn( + &self, + thread_id: String, + cwd: PathBuf, + input: String, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let response: TurnStartResponse = self + .request_handle + .request_typed(ClientRequest::TurnStart { + request_id: request_id(), + params: TurnStartParams { + thread_id, + input: vec![ + UserInput::Text { + text: input, + text_elements: Vec::new(), + } + .into(), + ], + cwd: Some(cwd), + approval_policy: Some( + self.config.permissions.approval_policy.value().into(), + ), + approvals_reviewer: Some(self.config.approvals_reviewer.into()), + sandbox_policy: Some( + self.config.permissions.sandbox_policy.get().clone().into(), + ), + model: self.config.model.clone(), + service_tier: None, + effort: self.config.model_reasoning_effort, + summary: self.config.model_reasoning_summary, + personality: None, + output_schema: None, + collaboration_mode: None, + }, + }) + .await + .map_err(|err| format!("failed to start workflow turn: {err}"))?; + Ok(response.turn.id) + }) + } + + fn read_turn( + &self, + thread_id: String, + turn_id: String, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let response: ThreadReadResponse = self + .request_handle + .request_typed(ClientRequest::ThreadRead { + request_id: request_id(), + params: ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: true, + }, + }) + .await + .map_err(|err| format!("failed to read workflow thread: {err}"))?; + let turn = response + .thread + .turns + .into_iter() + .find(|turn| turn.id == turn_id) + .ok_or_else(|| { + format!("workflow turn `{turn_id}` is missing from thread `{thread_id}`") + })?; + Ok(WorkflowTurnState { + status: turn.status, + error: turn.error.map(|error| error.message), + last_agent_message: turn.items.into_iter().fold(None, |_, item| match item { + codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } => { + (!text.trim().is_empty()).then_some(text.trim().to_string()) + } + _ => None, + }), + }) + }) + } + + fn interrupt_turn( + &self, + thread_id: String, + turn_id: String, + ) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + let _: TurnInterruptResponse = self + .request_handle + .request_typed(ClientRequest::TurnInterrupt { + request_id: request_id(), + params: TurnInterruptParams { thread_id, turn_id }, + }) + .await + .map_err(|err| format!("failed to interrupt workflow turn: {err}"))?; + Ok(()) + }) + } + + fn unsubscribe_thread(&self, thread_id: String) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + let _: ThreadUnsubscribeResponse = self + .request_handle + .request_typed(ClientRequest::ThreadUnsubscribe { + request_id: request_id(), + params: ThreadUnsubscribeParams { thread_id }, + }) + .await + .map_err(|err| format!("failed to unsubscribe workflow thread: {err}"))?; + Ok(()) + }) + } +} + +#[cfg_attr(not(test), allow(dead_code))] +impl App { + pub(crate) fn start_manual_workflow_trigger_run( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + trigger_id: String, + ) -> Arc { + let label = format!("{workflow_name} · {trigger_id}"); + match self.queue_or_start_trigger_run(app_server, workflow_name, trigger_id) { + TriggerRunDispatch::Started => Arc::new(history_cell::new_info_event( + "Workflow trigger started".to_string(), + Some(label), + )), + TriggerRunDispatch::Queued => Arc::new(history_cell::new_info_event( + "Workflow trigger queued".to_string(), + Some(label), + )), + } + } + + pub(crate) fn start_manual_workflow_job_run( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + job_name: String, + ) -> Arc { + let target = BackgroundWorkflowRunTarget::Job { + workflow_name, + job_name, + }; + let cell: Arc = Arc::new(history_cell::new_info_event( + target.started_message().to_string(), + Some(target.label()), + )); + self.start_background_workflow_run(app_server, target); + cell + } + + pub(crate) fn start_scheduled_workflow_trigger_run( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + trigger_id: String, + ) { + let _ = self.queue_or_start_trigger_run(app_server, workflow_name, trigger_id); + } + + pub(crate) fn dispatch_next_queued_trigger_run(&mut self, app_server: &AppServerSession) { + if self.workflow_scheduler.has_running_trigger_run() { + return; + } + let Some(next) = self.workflow_scheduler.dequeue_trigger_run() else { + return; + }; + self.start_background_workflow_run( + app_server, + BackgroundWorkflowRunTarget::Trigger { + workflow_name: next.workflow_name, + trigger_id: next.trigger_id, + }, + ); + } + + pub(crate) async fn finish_background_workflow_run( + &mut self, + app_server: &AppServerSession, + run_id: String, + result: BackgroundWorkflowRunResult, + ) -> Vec> { + let Some(run) = self + .workflow_scheduler + .take_background_workflow_run(&run_id) + else { + return Vec::new(); + }; + let completed_trigger = run.is_trigger; + let _ = run.handle.await; + + let mut visible_cells = Vec::new(); + if let Some(primary_thread_id) = self.primary_thread_id { + let visible = self.active_thread_id == Some(primary_thread_id); + match result.outcome { + BackgroundWorkflowRunOutcome::Completed(results) => { + for result in results { + let cell: Arc = Arc::new(history_cell::new_info_event( + run.target.completed_message().to_string(), + Some(format!( + "{}/{}/{}", + result.workflow_name, result.trigger_id, result.job_name + )), + )); + if visible { + visible_cells.push(cell); + } + } + } + BackgroundWorkflowRunOutcome::Cancelled => { + let cell: Arc = Arc::new(history_cell::new_info_event( + run.target.stopped_message().to_string(), + Some(run.target.label()), + )); + if visible { + visible_cells.push(cell); + } + } + BackgroundWorkflowRunOutcome::Failed(error) => { + let cell: Arc = Arc::new(history_cell::new_error_event( + format!("{}: {error}", run.target.failed_message()), + )); + if visible { + visible_cells.push(cell); + } + } + } + } + + if completed_trigger { + self.dispatch_next_queued_trigger_run(app_server); + } + self.sync_background_workflow_status(); + visible_cells + } + + fn queue_or_start_trigger_run( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + trigger_id: String, + ) -> TriggerRunDispatch { + if self.workflow_scheduler.has_running_trigger_run() { + self.workflow_scheduler + .enqueue_trigger_run(workflow_name, trigger_id); + self.sync_background_workflow_status(); + return TriggerRunDispatch::Queued; + } + + self.start_background_workflow_run( + app_server, + BackgroundWorkflowRunTarget::Trigger { + workflow_name, + trigger_id, + }, + ); + TriggerRunDispatch::Started + } + + fn start_background_workflow_run( + &mut self, + app_server: &AppServerSession, + target: BackgroundWorkflowRunTarget, + ) { + let run_id = self + .workflow_scheduler + .next_background_run_id(target.workflow_name(), target.slot_key()); + let runtime_client = AppServerWorkflowRuntimeClient::new( + app_server, + self.config.clone(), + self.primary_thread_id, + ); + let workflow_cwd = self.config.cwd.to_path_buf(); + let app_event_tx = self.app_event_tx.clone(); + let run_id_for_task = run_id.clone(); + let target_for_task = target.clone(); + let cancellation = CancellationToken::new(); + let cancellation_for_task = cancellation.clone(); + let handle = tokio::spawn(async move { + let result = run_background_workflow( + &runtime_client, + workflow_cwd, + target_for_task, + cancellation_for_task, + ) + .await; + app_event_tx.send(AppEvent::BackgroundWorkflowRunCompleted { + run_id: run_id_for_task, + result: Box::new(result), + }); + }); + self.workflow_scheduler.register_background_workflow_run( + run_id, + target, + cancellation, + handle, + ); + self.sync_background_workflow_status(); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(not(test), allow(dead_code))] +enum TriggerRunDispatch { + Started, + Queued, +} + +async fn run_background_workflow( + client: &dyn WorkflowRuntimeClient, + workflow_cwd: PathBuf, + target: BackgroundWorkflowRunTarget, + cancellation: CancellationToken, +) -> BackgroundWorkflowRunResult { + let outcome = + match run_background_workflow_selection(client, workflow_cwd, &target, &cancellation).await + { + Ok(results) => BackgroundWorkflowRunOutcome::Completed(results), + Err(WorkflowRunError::Cancelled) => BackgroundWorkflowRunOutcome::Cancelled, + Err(WorkflowRunError::Failed(error)) => BackgroundWorkflowRunOutcome::Failed(error), + }; + BackgroundWorkflowRunResult { target, outcome } +} + +async fn run_background_workflow_selection( + client: &dyn WorkflowRuntimeClient, + workflow_cwd: PathBuf, + target: &BackgroundWorkflowRunTarget, + cancellation: &CancellationToken, +) -> Result, WorkflowRunError> { + let registry = load_workflow_registry(workflow_cwd.as_path()) + .map_err(|error| WorkflowRunError::Failed(error.to_string()))?; + match target { + BackgroundWorkflowRunTarget::Trigger { + workflow_name, + trigger_id, + } => { + let workflow = registry + .files + .iter() + .find(|workflow| workflow.name == *workflow_name) + .ok_or_else(|| { + WorkflowRunError::Failed(format!("workflow `{workflow_name}` does not exist")) + })?; + let trigger = workflow + .triggers + .iter() + .find(|trigger| trigger.id == *trigger_id) + .ok_or_else(|| { + WorkflowRunError::Failed(format!("trigger `{trigger_id}` does not exist")) + })?; + if !trigger.enabled { + return Err(WorkflowRunError::Failed(format!( + "workflow trigger `{workflow_name}/{trigger_id}` is disabled" + ))); + } + if !matches!(trigger.kind, WorkflowTriggerKind::Manual) { + return Err(WorkflowRunError::Failed(format!( + "workflow trigger `{workflow_name}/{trigger_id}` is not runnable as a queued manual trigger" + ))); + } + run_workflow_jobs( + client, + ®istry, + workflow_name, + trigger_id, + &trigger.jobs, + cancellation, + ) + .await + } + BackgroundWorkflowRunTarget::Job { + workflow_name, + job_name, + } => { + let job = registry.jobs.get(job_name).ok_or_else(|| { + WorkflowRunError::Failed(format!("workflow job `{job_name}` does not exist")) + })?; + if job.workflow_name != *workflow_name { + return Err(WorkflowRunError::Failed(format!( + "workflow `{workflow_name}` does not define job `{job_name}`" + ))); + } + run_workflow_jobs( + client, + ®istry, + workflow_name, + &manual_workflow_job_trigger_id(job_name), + std::slice::from_ref(job_name), + cancellation, + ) + .await + } + } +} + +async fn run_workflow_jobs( + client: &dyn WorkflowRuntimeClient, + registry: &LoadedWorkflowRegistry, + workflow_name: &str, + trigger_id: &str, + root_jobs: &[String], + cancellation: &CancellationToken, +) -> Result, WorkflowRunError> { + let ordered = ordered_jobs_for_roots(registry, root_jobs) + .map_err(|error| WorkflowRunError::Failed(error.to_string()))?; + let mut results = Vec::new(); + let mut completed = BTreeMap::::new(); + for job_name in ordered { + if cancellation.is_cancelled() { + return Err(WorkflowRunError::Cancelled); + } + let job = registry.jobs.get(&job_name).ok_or_else(|| { + WorkflowRunError::Failed(format!("workflow job `{job_name}` does not exist")) + })?; + if !job.config.enabled { + completed.insert(job_name, false); + continue; + } + if job + .config + .needs + .iter() + .any(|dependency| completed.get(dependency) == Some(&false)) + { + completed.insert(job.name.clone(), false); + continue; + } + let result = run_workflow_job(client, workflow_name, trigger_id, job, cancellation).await?; + completed.insert(job.name.clone(), true); + results.push(result); + } + Ok(results) +} + +async fn run_workflow_job( + client: &dyn WorkflowRuntimeClient, + workflow_name: &str, + trigger_id: &str, + job: &LoadedWorkflowJob, + cancellation: &CancellationToken, +) -> Result { + if matches!(job.config.context, WorkflowContextMode::Embed) { + let prompt = job + .config + .steps + .iter() + .find_map(|step| match step { + WorkflowStep::Prompt { prompt, .. } => Some(prompt.clone()), + WorkflowStep::Run { .. } => None, + }) + .ok_or_else(|| { + WorkflowRunError::Failed(format!( + "workflow `{workflow_name}` job `{}` uses embed context but has no prompt step", + job.name + )) + })?; + return Ok(WorkflowJobRunResult { + delivery: WorkflowOutputDelivery::MainThreadInput, + workflow_name: workflow_name.to_string(), + trigger_id: trigger_id.to_string(), + job_name: job.name.clone(), + message: Some(prompt), + }); + } + + let mut thread: Option = None; + let mut step_outputs = Vec::new(); + let mut last_prompt_response = None; + for step in &job.config.steps { + let attempts = step.retry_attempts(); + let mut last_error = None; + for attempt in 1..=attempts { + if cancellation.is_cancelled() { + if let Some(thread) = thread.as_ref() { + let _ = client.unsubscribe_thread(thread.thread_id.clone()).await; + } + return Err(WorkflowRunError::Cancelled); + } + let result = execute_workflow_step( + client, + &mut thread, + workflow_name, + trigger_id, + job, + step, + &step_outputs, + cancellation, + ) + .await; + match result { + Ok(Some(output)) => { + if matches!(step, WorkflowStep::Prompt { .. }) { + last_prompt_response = Some(output.clone()); + } + step_outputs.push(output); + last_error = None; + break; + } + Ok(None) => { + last_error = None; + break; + } + Err(error) => { + last_error = Some(error); + if attempt < attempts + && !matches!(last_error, Some(WorkflowRunError::Cancelled)) + { + sleep(retry_backoff_delay(attempt)).await; + } + } + } + } + if let Some(error) = last_error { + if let Some(thread) = thread.as_ref() { + let _ = client.unsubscribe_thread(thread.thread_id.clone()).await; + } + return Err(error); + } + } + + if let Some(thread) = thread.as_ref() { + let _ = client.unsubscribe_thread(thread.thread_id.clone()).await; + } + + Ok(WorkflowJobRunResult { + delivery: match job.config.response { + WorkflowResponseMode::Assistant => WorkflowOutputDelivery::AssistantCell, + WorkflowResponseMode::User => WorkflowOutputDelivery::UserFollowup, + }, + workflow_name: workflow_name.to_string(), + trigger_id: trigger_id.to_string(), + job_name: job.name.clone(), + message: last_prompt_response + .or_else(|| (!step_outputs.is_empty()).then(|| step_outputs.join("\n\n"))), + }) +} + +async fn execute_workflow_step( + client: &dyn WorkflowRuntimeClient, + thread: &mut Option, + workflow_name: &str, + trigger_id: &str, + job: &LoadedWorkflowJob, + step: &WorkflowStep, + step_outputs: &[String], + cancellation: &CancellationToken, +) -> Result, WorkflowRunError> { + match step { + WorkflowStep::Run { run, .. } => { + run_workflow_command(run, &job.workflow_path, cancellation).await + } + WorkflowStep::Prompt { prompt, .. } => { + let thread = match thread { + Some(thread) => thread.clone(), + None => { + let started = client + .start_workflow_thread() + .await + .map_err(WorkflowRunError::Failed)?; + *thread = Some(started.clone()); + started + } + }; + let prompt = build_workflow_prompt_input( + workflow_name, + trigger_id, + &job.name, + prompt, + step_outputs, + ); + run_workflow_prompt(client, &thread, prompt, cancellation).await + } + } +} + +async fn run_workflow_prompt( + client: &dyn WorkflowRuntimeClient, + thread: &WorkflowThreadSession, + prompt: String, + cancellation: &CancellationToken, +) -> Result, WorkflowRunError> { + let turn_id = client + .start_turn(thread.thread_id.clone(), thread.cwd.clone(), prompt) + .await + .map_err(WorkflowRunError::Failed)?; + loop { + if cancellation.is_cancelled() { + interrupt_active_workflow_turn(client, thread.thread_id.clone(), turn_id.clone()).await; + return Err(WorkflowRunError::Cancelled); + } + let turn = client + .read_turn(thread.thread_id.clone(), turn_id.clone()) + .await + .map_err(WorkflowRunError::Failed)?; + match turn.status { + TurnStatus::Completed => return Ok(turn.last_agent_message), + TurnStatus::Interrupted => return Err(WorkflowRunError::Cancelled), + TurnStatus::Failed => { + return Err(WorkflowRunError::Failed( + turn.error + .unwrap_or_else(|| "workflow prompt turn failed".to_string()), + )); + } + TurnStatus::InProgress => sleep(WORKFLOW_POLL_INTERVAL).await, + } + } +} + +async fn interrupt_active_workflow_turn( + client: &dyn WorkflowRuntimeClient, + thread_id: String, + turn_id: String, +) { + let _ = client + .interrupt_turn(thread_id.clone(), turn_id.clone()) + .await; + let deadline = tokio::time::Instant::now() + WORKFLOW_INTERRUPT_SETTLE_TIMEOUT; + while tokio::time::Instant::now() < deadline { + match client.read_turn(thread_id.clone(), turn_id.clone()).await { + Ok(turn) if !matches!(turn.status, TurnStatus::InProgress) => return, + Ok(_) | Err(_) => sleep(WORKFLOW_POLL_INTERVAL).await, + } + } +} + +async fn run_workflow_command( + command: &str, + workflow_path: &PathBuf, + cancellation: &CancellationToken, +) -> Result, WorkflowRunError> { + #[cfg(windows)] + let mut cmd = { + let mut cmd = Command::new("cmd"); + cmd.arg("/C").arg(command); + cmd + }; + #[cfg(not(windows))] + let mut cmd = { + let mut cmd = Command::new("bash"); + cmd.arg("-lc").arg(command); + cmd + }; + cmd.kill_on_drop(true); + let workflow_dir = workflow_path + .parent() + .and_then(|parent| parent.parent()) + .and_then(|parent| parent.parent()) + .unwrap_or(workflow_path.as_path()); + let child = cmd + .current_dir(workflow_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| { + WorkflowRunError::Failed(format!("failed to run workflow command `{command}`: {err}")) + })?; + let wait_with_output = child.wait_with_output(); + tokio::pin!(wait_with_output); + let output = tokio::select! { + _ = cancellation.cancelled() => return Err(WorkflowRunError::Cancelled), + output = &mut wait_with_output => output, + } + .map_err(|err| { + WorkflowRunError::Failed(format!("failed to run workflow command `{command}`: {err}")) + })?; + let mut text = String::new(); + if !output.stdout.is_empty() { + text.push_str(&String::from_utf8_lossy(&output.stdout)); + } + if !output.stderr.is_empty() { + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(&String::from_utf8_lossy(&output.stderr)); + } + let text = text.trim().to_string(); + if !output.status.success() { + return Err(WorkflowRunError::Failed(match text.is_empty() { + true => format!( + "workflow command `{command}` failed with status {}", + output.status + ), + false => format!( + "workflow command `{command}` failed with status {}: {text}", + output.status + ), + })); + } + Ok((!text.is_empty()).then_some(text)) +} + +fn build_workflow_prompt_input( + workflow_name: &str, + trigger_id: &str, + job_name: &str, + prompt: &str, + step_outputs: &[String], +) -> String { + let mut sections = vec![format!( + "Workflow: {workflow_name}\nTrigger: {trigger_id}\nJob: {job_name}" + )]; + if !step_outputs.is_empty() { + sections.push(format!( + "Previous workflow step outputs:\n{}", + step_outputs.join("\n\n") + )); + } + sections.push(format!("Current workflow prompt:\n{prompt}")); + sections.join("\n\n") +} + +fn workflow_thread_start_params( + config: &Config, + is_remote: bool, + remote_cwd_override: Option<&std::path::Path>, +) -> ThreadStartParams { + ThreadStartParams { + model: config.model.clone(), + model_provider: (!is_remote).then_some(config.model_provider_id.clone()), + cwd: workflow_thread_cwd(config, is_remote, remote_cwd_override), + approval_policy: Some(config.permissions.approval_policy.value().into()), + approvals_reviewer: Some(AppServerApprovalsReviewer::from(config.approvals_reviewer)), + sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()), + config: config.active_profile.as_ref().map(|profile| { + HashMap::from([( + "profile".to_string(), + serde_json::Value::String(profile.clone()), + )]) + }), + ephemeral: Some(true), + persist_extended_history: true, + ..ThreadStartParams::default() + } +} + +fn workflow_thread_fork_params( + config: &Config, + thread_id: ThreadId, + is_remote: bool, + remote_cwd_override: Option<&std::path::Path>, +) -> ThreadForkParams { + ThreadForkParams { + thread_id: thread_id.to_string(), + model: config.model.clone(), + model_provider: (!is_remote).then_some(config.model_provider_id.clone()), + cwd: workflow_thread_cwd(config, is_remote, remote_cwd_override), + approval_policy: Some(config.permissions.approval_policy.value().into()), + approvals_reviewer: Some(AppServerApprovalsReviewer::from(config.approvals_reviewer)), + sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()), + config: config.active_profile.as_ref().map(|profile| { + HashMap::from([( + "profile".to_string(), + serde_json::Value::String(profile.clone()), + )]) + }), + ephemeral: true, + persist_extended_history: true, + ..ThreadForkParams::default() + } +} + +fn workflow_thread_cwd( + config: &Config, + is_remote: bool, + remote_cwd_override: Option<&std::path::Path>, +) -> Option { + if is_remote { + remote_cwd_override.map(|cwd| cwd.to_string_lossy().to_string()) + } else { + Some(config.cwd.to_string_lossy().to_string()) + } +} + +fn sandbox_mode_from_policy(policy: SandboxPolicy) -> Option { + match policy { + SandboxPolicy::DangerFullAccess => Some(SandboxMode::DangerFullAccess), + SandboxPolicy::ReadOnly { .. } => Some(SandboxMode::ReadOnly), + SandboxPolicy::WorkspaceWrite { .. } => Some(SandboxMode::WorkspaceWrite), + SandboxPolicy::ExternalSandbox { .. } => None, + } +} + +fn request_id() -> RequestId { + RequestId::String(format!("workflow-{}", Uuid::new_v4())) +} + +fn manual_workflow_job_trigger_id(job_name: &str) -> String { + format!("job:{job_name}") +} + +fn retry_backoff_delay(attempt: u32) -> Duration { + let seconds = 2u64.saturating_pow(attempt.saturating_sub(1)).min(8); + Duration::from_secs(seconds.max(1)) +} + +#[derive(Debug)] +enum WorkflowRunError { + Failed(String), + Cancelled, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::collections::VecDeque; + use std::sync::Mutex; + use tempfile::tempdir; + + struct FakeWorkflowRuntimeClient { + calls: Mutex>, + thread_id: String, + turn_id: String, + reads: Mutex>>, + } + + impl FakeWorkflowRuntimeClient { + fn new(reads: Vec>) -> Self { + Self { + calls: Mutex::new(Vec::new()), + thread_id: "thr_workflow".to_string(), + turn_id: "turn_workflow".to_string(), + reads: Mutex::new(reads.into()), + } + } + } + + impl WorkflowRuntimeClient for FakeWorkflowRuntimeClient { + fn start_workflow_thread(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.calls + .lock() + .expect("calls lock") + .push("start_workflow_thread".to_string()); + Ok(WorkflowThreadSession { + thread_id: self.thread_id.clone(), + cwd: PathBuf::from("/tmp/workflow"), + }) + }) + } + + fn start_turn( + &self, + thread_id: String, + _cwd: PathBuf, + input: String, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.calls + .lock() + .expect("calls lock") + .push(format!("start_turn:{thread_id}:{input}")); + Ok(self.turn_id.clone()) + }) + } + + fn read_turn( + &self, + thread_id: String, + turn_id: String, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.calls + .lock() + .expect("calls lock") + .push(format!("read_turn:{thread_id}:{turn_id}")); + self.reads + .lock() + .expect("reads lock") + .pop_front() + .unwrap_or({ + Ok(WorkflowTurnState { + status: TurnStatus::InProgress, + error: None, + last_agent_message: None, + }) + }) + }) + } + + fn interrupt_turn( + &self, + thread_id: String, + turn_id: String, + ) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + self.calls + .lock() + .expect("calls lock") + .push(format!("interrupt_turn:{thread_id}:{turn_id}")); + Ok(()) + }) + } + + fn unsubscribe_thread(&self, thread_id: String) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + self.calls + .lock() + .expect("calls lock") + .push(format!("unsubscribe_thread:{thread_id}")); + Ok(()) + }) + } + } + + #[tokio::test] + async fn prompt_workflow_job_uses_app_server_runtime_sequence() { + let tempdir = tempdir().expect("tempdir"); + let workflows_dir = tempdir.path().join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).expect("workflow dir"); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +triggers: + - type: manual + jobs: [review_backlog] + +jobs: + review_backlog: + steps: + - prompt: summarize the backlog +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(vec![ + Ok(WorkflowTurnState { + status: TurnStatus::InProgress, + error: None, + last_agent_message: None, + }), + Ok(WorkflowTurnState { + status: TurnStatus::Completed, + error: None, + last_agent_message: Some("workflow reply".to_string()), + }), + ]); + let result = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }, + CancellationToken::new(), + ) + .await; + + match result.outcome { + BackgroundWorkflowRunOutcome::Completed(results) => { + assert_eq!(results.len(), 1); + assert_eq!(results[0].message.as_deref(), Some("workflow reply")); + } + other => panic!("expected completed run, got {other:?}"), + } + assert_eq!( + client.calls.lock().expect("calls lock").clone(), + vec![ + "start_workflow_thread".to_string(), + "start_turn:thr_workflow:Workflow: director\nTrigger: job:review_backlog\nJob: review_backlog\n\nCurrent workflow prompt:\nsummarize the backlog".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "unsubscribe_thread:thr_workflow".to_string(), + ] + ); + } + + #[tokio::test] + async fn cancellation_interrupts_active_workflow_turn() { + let tempdir = tempdir().expect("tempdir"); + let workflows_dir = tempdir.path().join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).expect("workflow dir"); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +jobs: + review_backlog: + steps: + - prompt: summarize the backlog +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(vec![ + Ok(WorkflowTurnState { + status: TurnStatus::InProgress, + error: None, + last_agent_message: None, + }), + Ok(WorkflowTurnState { + status: TurnStatus::Interrupted, + error: None, + last_agent_message: None, + }), + ]); + let cancellation = CancellationToken::new(); + let run = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }, + cancellation.clone(), + ); + tokio::pin!(run); + let result = tokio::select! { + result = &mut run => result, + _ = sleep(Duration::from_millis(10)) => { + cancellation.cancel(); + run.await + } + }; + + assert!(matches!( + result.outcome, + BackgroundWorkflowRunOutcome::Cancelled + )); + assert_eq!( + client.calls.lock().expect("calls lock").clone(), + vec![ + "start_workflow_thread".to_string(), + "start_turn:thr_workflow:Workflow: director\nTrigger: job:review_backlog\nJob: review_backlog\n\nCurrent workflow prompt:\nsummarize the backlog".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "interrupt_turn:thr_workflow:turn_workflow".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "unsubscribe_thread:thr_workflow".to_string(), + ] + ); + } +} diff --git a/codex-rs/tui/src/app/workflow_scheduler.rs b/codex-rs/tui/src/app/workflow_scheduler.rs index 1caca60f0..89425457b 100644 --- a/codex-rs/tui/src/app/workflow_scheduler.rs +++ b/codex-rs/tui/src/app/workflow_scheduler.rs @@ -1,11 +1,17 @@ use std::collections::HashMap; use std::collections::VecDeque; +use std::time::Duration; use tokio::task::JoinHandle; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; + +use super::workflow_runtime::BackgroundWorkflowRunTarget; pub(crate) struct BackgroundWorkflowRunState { pub(crate) label: String, - #[cfg(test)] pub(crate) is_trigger: bool, + pub(crate) target: BackgroundWorkflowRunTarget, + pub(crate) cancellation: CancellationToken, pub(crate) handle: JoinHandle<()>, } @@ -19,12 +25,10 @@ pub(crate) struct QueuedWorkflowTriggerRun { pub(crate) struct WorkflowSchedulerState { running_workflows: HashMap, queued_trigger_runs: VecDeque, - #[cfg(test)] next_background_run_id: u64, } impl WorkflowSchedulerState { - #[cfg(test)] pub(crate) fn next_background_run_id( &mut self, workflow_name: &str, @@ -37,26 +41,27 @@ impl WorkflowSchedulerState { ) } - #[cfg(test)] pub(crate) fn register_background_workflow_run( &mut self, run_id: String, - label: String, - is_trigger: bool, + target: BackgroundWorkflowRunTarget, + cancellation: CancellationToken, handle: JoinHandle<()>, ) { + let label = target.label(); + let is_trigger = matches!(target, BackgroundWorkflowRunTarget::Trigger { .. }); self.running_workflows.insert( run_id, BackgroundWorkflowRunState { label, - #[cfg(test)] is_trigger, + target, + cancellation, handle, }, ); } - #[cfg(test)] pub(crate) fn take_background_workflow_run( &mut self, run_id: &str, @@ -81,12 +86,11 @@ impl WorkflowSchedulerState { .collect() } - #[cfg(test)] pub(crate) fn has_running_trigger_run(&self) -> bool { self.running_workflows.values().any(|run| run.is_trigger) } - #[cfg(test)] + #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn enqueue_trigger_run(&mut self, workflow_name: String, trigger_id: String) { self.queued_trigger_runs .push_back(QueuedWorkflowTriggerRun { @@ -95,7 +99,6 @@ impl WorkflowSchedulerState { }); } - #[cfg(test)] pub(crate) fn dequeue_trigger_run(&mut self) -> Option { self.queued_trigger_runs.pop_front() } @@ -107,9 +110,15 @@ impl WorkflowSchedulerState { .map(|(_, run)| run) .collect::>(); let stopped_count = runs.len(); - for run in runs { - run.handle.abort(); - let _ = run.handle.await; + for mut run in runs { + run.cancellation.cancel(); + if timeout(Duration::from_secs(1), &mut run.handle) + .await + .is_err() + { + run.handle.abort(); + let _ = run.handle.await; + } } self.queued_trigger_runs.clear(); stopped_count diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a2ab65b3b..c776c5d0c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -26,6 +26,7 @@ use codex_protocol::protocol::RateLimitSnapshot; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; +use crate::app::workflow_runtime::BackgroundWorkflowRunResult; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; @@ -267,6 +268,12 @@ pub(crate) enum AppEvent { InsertHistoryCell(Box), + /// Final result for one background workflow execution. + BackgroundWorkflowRunCompleted { + run_id: String, + result: Box, + }, + /// Retry the last turn using the routed profile fallback policy. RetryLastUserTurnWithProfileFallback { action: ProfileFallbackAction, From 29a75c2381b19c1417d3a0c2f63885675d18174a Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 00:57:07 +0800 Subject: [PATCH 19/60] replay: restore workflow main-thread continuity --- codex-rs/tui/src/app.rs | 225 +++++++++++++++++++++++ codex-rs/tui/src/app/workflow_history.rs | 213 +++++++++++++++++++++ codex-rs/tui/src/app/workflow_runtime.rs | 82 +++++++-- codex-rs/tui/src/app_event.rs | 5 + 4 files changed, 507 insertions(+), 18 deletions(-) create mode 100644 codex-rs/tui/src/app/workflow_history.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ec91f1256..791ee6138 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -162,6 +162,7 @@ mod app_server_requests; mod loaded_threads; mod pending_interactive_replay; mod workflow_definition; +mod workflow_history; pub(crate) mod workflow_runtime; mod workflow_scheduler; @@ -170,6 +171,7 @@ use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::pending_interactive_replay::PendingInteractiveReplayState; +use self::workflow_history::WorkflowHistoryState; use self::workflow_scheduler::WorkflowSchedulerState; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; @@ -1042,6 +1044,7 @@ pub(crate) struct App { pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, workflow_scheduler: WorkflowSchedulerState, + workflow_history: WorkflowHistoryState, } #[derive(Default)] @@ -3188,6 +3191,7 @@ impl App { self.chat_widget.handle_thread_session(session); self.chat_widget .replay_thread_turns(turns, ReplayKind::ResumeInitialMessages); + self.queue_workflow_history_replay_for_thread(thread_id); let pending = std::mem::take(&mut self.pending_primary_events); for pending_event in pending { match pending_event { @@ -3983,6 +3987,9 @@ impl App { for event in snapshot.events { self.handle_thread_event_replay(event); } + if let Some(thread_id) = self.active_thread_id { + self.queue_workflow_history_replay_for_thread(thread_id); + } self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); self.chat_widget @@ -4263,6 +4270,7 @@ impl App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), workflow_scheduler: WorkflowSchedulerState::default(), + workflow_history: WorkflowHistoryState::default(), }; if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) @@ -4682,6 +4690,19 @@ impl App { let cell: Arc = cell.into(); self.insert_visible_history_cell(tui, cell); } + AppEvent::ReplayWorkflowHistory { thread_id } => { + if self.active_thread_id == Some(thread_id) { + let lines = self.replay_workflow_history_cells_for_thread( + thread_id, + tui.terminal.last_known_screen_size.width, + ); + if self.overlay.is_some() { + self.deferred_history_lines.extend(lines); + } else if !lines.is_empty() { + tui.insert_history_lines(lines); + } + } + } AppEvent::BackgroundWorkflowRunCompleted { run_id, result } => { let cells = self .finish_background_workflow_run(app_server, run_id, *result) @@ -9577,6 +9598,7 @@ guardian_approval = true pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), workflow_scheduler: WorkflowSchedulerState::default(), + workflow_history: WorkflowHistoryState::default(), } } @@ -9632,6 +9654,7 @@ guardian_approval = true pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), workflow_scheduler: WorkflowSchedulerState::default(), + workflow_history: WorkflowHistoryState::default(), }, rx, op_rx, @@ -11009,6 +11032,130 @@ model = "gpt-5.2" ); } + #[tokio::test] + async fn replay_thread_snapshot_queues_workflow_history_after_turn_replay() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + let stored_cell: Arc = Arc::new(history_cell::new_info_event( + "Workflow reply".to_string(), + Some("director/review_backlog".to_string()), + )); + let _ = app.record_workflow_history_cell(thread_id, stored_cell); + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: Some(test_thread_session( + thread_id, + PathBuf::from("/home/user/project"), + )), + turns: vec![test_turn( + "turn-1", + TurnStatus::Completed, + vec![ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![AppServerUserInput::Text { + text: "first prompt".to_string(), + text_elements: Vec::new(), + }], + }], + )], + events: Vec::new(), + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + + let mut replay_order = Vec::new(); + while let Ok(event) = app_event_rx.try_recv() { + match event { + AppEvent::InsertHistoryCell(cell) => { + let cell: Arc = cell.into(); + let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80)); + if transcript.contains("first prompt") { + replay_order.push("turn"); + } + app.transcript_cells.push(cell); + } + AppEvent::ReplayWorkflowHistory { + thread_id: replay_thread_id, + } => { + assert_eq!(replay_thread_id, thread_id); + replay_order.push("workflow"); + let lines = app.replay_workflow_history_cells_for_thread( + replay_thread_id, + /*width*/ 80, + ); + assert!(lines_to_single_string(&lines).contains("Workflow reply")); + } + _ => {} + } + } + + assert_eq!(replay_order, vec!["turn", "workflow"]); + let transcript: Vec = app + .transcript_cells + .iter() + .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + .collect(); + assert!(transcript.iter().any(|cell| cell.contains("first prompt"))); + assert!( + transcript + .last() + .is_some_and(|cell| cell.contains("Workflow reply")) + ); + } + + #[tokio::test] + async fn enqueue_primary_thread_session_queues_workflow_history_after_turn_replay() -> Result<()> + { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let stored_cell: Arc = Arc::new(history_cell::new_info_event( + "Workflow reply".to_string(), + Some("director/review_backlog".to_string()), + )); + let _ = app.record_workflow_history_cell(thread_id, stored_cell); + + app.enqueue_primary_thread_session( + test_thread_session(thread_id, PathBuf::from("/tmp/project")), + vec![test_turn( + "turn-1", + TurnStatus::Completed, + vec![ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![AppServerUserInput::Text { + text: "earlier prompt".to_string(), + text_elements: Vec::new(), + }], + }], + )], + ) + .await?; + + let mut replay_order = Vec::new(); + while let Ok(event) = app_event_rx.try_recv() { + match event { + AppEvent::InsertHistoryCell(cell) => { + let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80)); + if transcript.contains("earlier prompt") { + replay_order.push("turn"); + } + } + AppEvent::ReplayWorkflowHistory { + thread_id: replay_thread_id, + } => { + assert_eq!(replay_thread_id, thread_id); + replay_order.push("workflow"); + } + _ => {} + } + } + + assert_eq!(replay_order, vec!["turn", "workflow"]); + Ok(()) + } + #[tokio::test] async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -11373,6 +11520,84 @@ model = "gpt-5.2" assert!(app.queued_trigger_labels().is_empty()); } + #[tokio::test] + async fn workflow_follow_up_completion_submits_to_primary_thread() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + app.primary_session_configured = Some(started.session); + app.active_thread_id = Some(thread_id); + + let run_id = app + .workflow_scheduler + .next_background_run_id("director", "review_backlog"); + let target = workflow_runtime::BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }; + app.workflow_scheduler.register_background_workflow_run( + run_id.clone(), + target.clone(), + CancellationToken::new(), + tokio::spawn(async {}), + ); + + let cells = app + .finish_background_workflow_run( + &app_server, + run_id, + workflow_runtime::BackgroundWorkflowRunResult { + target, + outcome: workflow_runtime::BackgroundWorkflowRunOutcome::Completed(vec![ + workflow_runtime::WorkflowJobRunResult { + delivery: workflow_runtime::WorkflowOutputDelivery::UserFollowup, + workflow_name: "director".to_string(), + trigger_id: "job:review_backlog".to_string(), + job_name: "review_backlog".to_string(), + message: Some("workflow follow-up".to_string()), + }, + ]), + }, + ) + .await; + + let rendered_cells: Vec = cells + .iter() + .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + .collect(); + assert_eq!(rendered_cells.len(), 2); + assert!(rendered_cells[0].contains("Workflow job completed")); + assert!(rendered_cells[1].contains("Workflow reply")); + + match app_event_rx + .try_recv() + .expect("expected queued workflow follow-up") + { + AppEvent::SubmitThreadOp { + thread_id: submit_thread_id, + op: Op::UserTurn { items, .. }, + } => { + assert_eq!(submit_thread_id, thread_id); + assert_eq!( + items, + vec![UserInput::Text { + text: "workflow follow-up".to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected workflow follow-up submission, got {other:?}"), + } + } + #[tokio::test] async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/workflow_history.rs b/codex-rs/tui/src/app/workflow_history.rs new file mode 100644 index 000000000..6e0062765 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_history.rs @@ -0,0 +1,213 @@ +use super::App; +use crate::app_event::AppEvent; +use crate::history_cell; +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::markdown::append_markdown; +use codex_protocol::ThreadId; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use ratatui::text::Line; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +#[derive(Default)] +pub(crate) struct WorkflowHistoryState { + pub(super) thread_history_cells: HashMap>>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct WorkflowReplySource { + workflow_id: String, + action: Option, +} + +impl WorkflowReplySource { + pub(crate) fn new(workflow_id: String, action: Option) -> Self { + Self { + workflow_id, + action, + } + } + + pub(crate) fn hint(&self) -> String { + match self + .action + .as_deref() + .map(str::trim) + .filter(|action| !action.is_empty()) + { + Some(action) => format!("{} · {}", self.workflow_id, workflow_prompt_prefix(action)), + None => self.workflow_id.clone(), + } + } +} + +impl App { + pub(crate) fn queue_workflow_history_replay_for_thread(&self, thread_id: ThreadId) { + if self + .workflow_history + .thread_history_cells + .contains_key(&thread_id) + { + self.app_event_tx + .send(AppEvent::ReplayWorkflowHistory { thread_id }); + } + } + + pub(crate) fn replay_workflow_history_cells_for_thread( + &mut self, + thread_id: ThreadId, + width: u16, + ) -> Vec> { + let Some(cells) = self.workflow_history.thread_history_cells.get(&thread_id) else { + return Vec::new(); + }; + + let mut rendered = Vec::new(); + for cell in cells { + self.transcript_cells.push(cell.clone()); + let mut display = cell.display_lines(width); + if display.is_empty() { + continue; + } + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::default()); + } else { + self.has_emitted_history_lines = true; + } + } + rendered.extend(display); + } + rendered + } + + pub(crate) fn record_workflow_history_cell( + &mut self, + thread_id: ThreadId, + cell: Arc, + ) -> Option> { + self.workflow_history + .thread_history_cells + .entry(thread_id) + .or_default() + .push(cell.clone()); + (self.active_thread_id == Some(thread_id)).then_some(cell) + } + + pub(crate) fn queue_workflow_followup_to_primary( + &mut self, + text: String, + source: WorkflowReplySource, + ) -> Option> { + let Some(primary_thread_id) = self.primary_thread_id else { + self.chat_widget.add_error_message( + "Failed to find the main thread for background follow-up.".to_string(), + ); + return None; + }; + + let trimmed = text.trim().to_string(); + if trimmed.is_empty() { + return None; + } + + let origin_cell: Arc = Arc::new(workflow_info_cell(&source)); + let visible_cell = self.record_workflow_history_cell(primary_thread_id, origin_cell); + let Some(op) = self.workflow_followup_user_turn(trimmed) else { + self.chat_widget.add_error_message( + "Failed to build the main-thread follow-up for the workflow.".to_string(), + ); + return visible_cell; + }; + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id: primary_thread_id, + op, + }); + visible_cell + } + + fn workflow_followup_user_turn(&self, text: String) -> Option { + let text = text.trim().to_string(); + if text.is_empty() { + return None; + } + + let session = self.primary_session_configured.as_ref(); + let cwd = session + .map(|session| session.cwd.clone()) + .unwrap_or_else(|| self.config.cwd.to_path_buf()); + let approval_policy = session + .map(|session| session.approval_policy) + .unwrap_or_else(|| self.config.permissions.approval_policy.value()); + let approvals_reviewer = session.map(|session| session.approvals_reviewer); + let sandbox_policy = session + .map(|session| session.sandbox_policy.clone()) + .unwrap_or_else(|| self.config.permissions.sandbox_policy.get().clone()); + let model = session + .map(|session| session.model.clone()) + .filter(|model| !model.trim().is_empty()) + .or_else(|| self.config.model.clone()) + .unwrap_or_else(|| self.chat_widget.current_model().to_string()); + let effort = session.and_then(|session| session.reasoning_effort); + let service_tier = session.and_then(|session| session.service_tier.map(Some)); + + Some(Op::UserTurn { + items: vec![UserInput::Text { + text, + text_elements: Vec::new(), + }], + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + model, + effort, + summary: None, + service_tier, + final_output_json_schema: None, + collaboration_mode: None, + personality: self.config.personality, + }) + } +} + +pub(crate) fn workflow_result_cell(message: &str, cwd: &Path) -> AgentMessageCell { + let mut rendered = vec![Line::default()]; + append_markdown(message, /*width*/ None, Some(cwd), &mut rendered); + AgentMessageCell::new(rendered, /*is_first_line*/ false) +} + +fn workflow_info_cell(source: &WorkflowReplySource) -> PlainHistoryCell { + history_cell::new_info_event("Workflow reply".to_string(), Some(source.hint())) +} + +fn workflow_prompt_prefix(prompt: &str) -> String { + let prefix = prompt.chars().take(48).collect::(); + if prompt.chars().count() > 48 { + format!("{prefix}...") + } else { + prefix + } +} + +#[cfg(test)] +mod tests { + use super::WorkflowReplySource; + use pretty_assertions::assert_eq; + + #[test] + fn workflow_reply_source_hint_prefers_action_when_present() { + let source = WorkflowReplySource::new( + "director/review:summary".to_string(), + Some("summarize it".to_string()), + ); + assert_eq!( + source.hint(), + "director/review:summary · summarize it".to_string() + ); + } +} diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index 2dd4064d9..ce4b35542 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -7,6 +7,8 @@ use super::workflow_definition::WorkflowStep; use super::workflow_definition::WorkflowTriggerKind; use super::workflow_definition::load_workflow_registry; use super::workflow_definition::ordered_jobs_for_roots; +use super::workflow_history::WorkflowReplySource; +use super::workflow_history::workflow_result_cell; use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; use crate::history_cell; @@ -86,6 +88,7 @@ impl BackgroundWorkflowRunTarget { } #[cfg_attr(not(test), allow(dead_code))] + #[allow(dead_code)] fn started_message(&self) -> &'static str { match self { Self::Trigger { .. } => "Workflow trigger started", @@ -141,6 +144,7 @@ pub(crate) enum BackgroundWorkflowRunOutcome { #[cfg_attr(not(test), allow(dead_code))] #[derive(Debug)] pub(crate) struct BackgroundWorkflowRunResult { + #[allow(dead_code)] pub(crate) target: BackgroundWorkflowRunTarget, pub(crate) outcome: BackgroundWorkflowRunOutcome, } @@ -359,7 +363,7 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { } } -#[cfg_attr(not(test), allow(dead_code))] +#[allow(dead_code)] impl App { pub(crate) fn start_manual_workflow_trigger_run( &mut self, @@ -440,36 +444,71 @@ impl App { let mut visible_cells = Vec::new(); if let Some(primary_thread_id) = self.primary_thread_id { - let visible = self.active_thread_id == Some(primary_thread_id); match result.outcome { BackgroundWorkflowRunOutcome::Completed(results) => { for result in results { - let cell: Arc = Arc::new(history_cell::new_info_event( - run.target.completed_message().to_string(), - Some(format!( - "{}/{}/{}", - result.workflow_name, result.trigger_id, result.job_name - )), - )); - if visible { + let source = WorkflowReplySource::new( + workflow_job_source(&result), + /*action*/ None, + ); + let completed_cell: Arc = + Arc::new(history_cell::new_info_event( + run.target.completed_message().to_string(), + Some(source.hint()), + )); + if let Some(cell) = + self.record_workflow_history_cell(primary_thread_id, completed_cell) + { visible_cells.push(cell); } + + let Some(message) = + result.message.filter(|message| !message.trim().is_empty()) + else { + continue; + }; + + match result.delivery { + WorkflowOutputDelivery::AssistantCell => { + let assistant_cell: Arc = Arc::new( + workflow_result_cell(&message, self.config.cwd.as_path()), + ); + if let Some(cell) = self + .record_workflow_history_cell(primary_thread_id, assistant_cell) + { + visible_cells.push(cell); + } + } + WorkflowOutputDelivery::MainThreadInput + | WorkflowOutputDelivery::UserFollowup => { + if let Some(cell) = + self.queue_workflow_followup_to_primary(message, source) + { + visible_cells.push(cell); + } + } + } } } BackgroundWorkflowRunOutcome::Cancelled => { - let cell: Arc = Arc::new(history_cell::new_info_event( - run.target.stopped_message().to_string(), - Some(run.target.label()), - )); - if visible { + let cancelled_cell: Arc = + Arc::new(history_cell::new_info_event( + run.target.stopped_message().to_string(), + Some(run.target.label()), + )); + if let Some(cell) = + self.record_workflow_history_cell(primary_thread_id, cancelled_cell) + { visible_cells.push(cell); } } BackgroundWorkflowRunOutcome::Failed(error) => { - let cell: Arc = Arc::new(history_cell::new_error_event( + let error_cell: Arc = Arc::new(history_cell::new_error_event( format!("{}: {error}", run.target.failed_message()), )); - if visible { + if let Some(cell) = + self.record_workflow_history_cell(primary_thread_id, error_cell) + { visible_cells.push(cell); } } @@ -549,7 +588,7 @@ impl App { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(not(test), allow(dead_code))] +#[allow(dead_code)] enum TriggerRunDispatch { Started, Queued, @@ -1039,6 +1078,13 @@ fn manual_workflow_job_trigger_id(job_name: &str) -> String { format!("job:{job_name}") } +fn workflow_job_source(result: &WorkflowJobRunResult) -> String { + format!( + "{}/{}:{}", + result.workflow_name, result.trigger_id, result.job_name + ) +} + fn retry_backoff_delay(attempt: u32) -> Duration { let seconds = 2u64.saturating_pow(attempt.saturating_sub(1)).min(8); Duration::from_secs(seconds.max(1)) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index c776c5d0c..b2c06f1ca 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -268,6 +268,11 @@ pub(crate) enum AppEvent { InsertHistoryCell(Box), + /// Replay stored workflow-only transcript cells for a specific thread after its turn replay. + ReplayWorkflowHistory { + thread_id: ThreadId, + }, + /// Final result for one background workflow execution. BackgroundWorkflowRunCompleted { run_id: String, From f829b3560e024f903ef2633ed942bb1172dc8a0d Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 01:30:23 +0800 Subject: [PATCH 20/60] replay: restore workflow round continuity --- codex-rs/tui/src/app.rs | 362 ++++++++++++++++++++- codex-rs/tui/src/app/app_server_adapter.rs | 20 +- codex-rs/tui/src/app/workflow_history.rs | 207 ++++++++++++ codex-rs/tui/src/app/workflow_runtime.rs | 136 +++++++- codex-rs/tui/src/app_command.rs | 4 + 5 files changed, 709 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 791ee6138..23b075d01 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -249,6 +249,36 @@ fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[Str } } +fn workflow_after_turn_last_agent_message( + primary_thread_id: Option, + thread_id: ThreadId, + notification: &ServerNotification, +) -> Option> { + if primary_thread_id != Some(thread_id) { + return None; + } + let ServerNotification::TurnCompleted(notification) = notification else { + return None; + }; + if !matches!( + notification.turn.status, + TurnStatus::Completed | TurnStatus::Failed + ) { + return None; + } + Some(last_agent_message_for_turn(¬ification.turn)) +} + +fn last_agent_message_for_turn(turn: &Turn) -> Option { + turn.items.iter().fold(None, |_, item| match item { + ThreadItem::AgentMessage { text, .. } => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + _ => None, + }) +} + fn default_exec_approval_decisions( network_approval_context: Option<&codex_protocol::protocol::NetworkApprovalContext>, proposed_execpolicy_amendment: Option<&codex_protocol::approvals::ExecPolicyAmendment>, @@ -2309,6 +2339,9 @@ impl App { thread_id: ThreadId, op: AppCommand, ) -> Result<()> { + let (op, workflow_cells) = self + .augment_primary_thread_op_with_before_turn_workflows(app_server, thread_id, op) + .await; crate::session_log::log_outbound_op(&op); if self.try_handle_local_history_op(thread_id, &op).await? { @@ -2336,6 +2369,11 @@ impl App { self.note_thread_outbound_op(thread_id, &op).await; self.refresh_pending_thread_approvals().await; } + for cell in workflow_cells { + if let Some(visible_cell) = self.record_workflow_history_cell(thread_id, cell) { + self.insert_visible_history_cell(tui, visible_cell); + } + } return Ok(()); } @@ -3696,7 +3734,7 @@ impl App { }; self.chat_widget.add_info_message(message, /*hint*/ None); } - self.drain_active_thread_events(tui).await?; + self.drain_active_thread_events(tui, app_server).await?; self.refresh_pending_thread_approvals().await; Ok(()) @@ -3913,7 +3951,11 @@ impl App { config } - async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> { + async fn drain_active_thread_events( + &mut self, + tui: &mut tui::Tui, + app_server: &AppServerSession, + ) -> Result<()> { let Some(mut rx) = self.active_thread_rx.take() else { return Ok(()); }; @@ -3921,7 +3963,32 @@ impl App { let mut disconnected = false; loop { match rx.try_recv() { - Ok(event) => self.handle_thread_event_now(event), + Ok(event) => { + let after_turn = + self.active_thread_id + .and_then(|active_thread_id| match &event { + ThreadBufferedEvent::Notification(notification) => { + workflow_after_turn_last_agent_message( + self.primary_thread_id, + active_thread_id, + notification, + ) + } + _ => None, + }); + self.handle_thread_event_now(event); + if let Some(last_agent_message) = after_turn { + for cell in self + .handle_primary_thread_turn_complete_for_workflows( + app_server, + last_agent_message, + ) + .await + { + self.insert_visible_history_cell(tui, cell); + } + } + } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { disconnected = true; @@ -6193,7 +6260,27 @@ impl App { .await; } + let after_turn = self + .active_thread_id + .and_then(|active_thread_id| match &event { + ThreadBufferedEvent::Notification(notification) => { + workflow_after_turn_last_agent_message( + self.primary_thread_id, + active_thread_id, + notification, + ) + } + _ => None, + }); self.handle_thread_event_now(event); + if let Some(last_agent_message) = after_turn { + for cell in self + .handle_primary_thread_turn_complete_for_workflows(app_server, last_agent_message) + .await + { + self.insert_visible_history_cell(tui, cell); + } + } if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); } @@ -6711,6 +6798,7 @@ mod tests { use crate::history_cell::new_session_info; use crate::multi_agents::AgentPickerThreadEntry; use assert_matches::assert_matches; + use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; @@ -6785,6 +6873,7 @@ mod tests { use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::Line; + use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -9661,6 +9750,50 @@ guardian_approval = true ) } + fn write_test_before_turn_workflow(workspace_cwd: &Path) -> Result<()> { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir)?; + std::fs::write( + workflows_dir.join("before_turn.yaml"), + r#"name: director + +triggers: + - type: before_turn + jobs: [augment] + +jobs: + augment: + context: embed + steps: + - prompt: | + added by before_turn +"#, + )?; + Ok(()) + } + + fn write_test_after_turn_workflow(workspace_cwd: &Path) -> Result<()> { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir)?; + std::fs::write( + workflows_dir.join("after_turn.yaml"), + r#"name: director + +triggers: + - type: after_turn + jobs: [followup] + +jobs: + followup: + context: embed + steps: + - prompt: | + follow up from workflow +"#, + )?; + Ok(()) + } + fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { thread_id, @@ -9708,6 +9841,27 @@ guardian_approval = true }) } + fn turn_completed_notification_with_agent_message( + thread_id: ThreadId, + turn_id: &str, + status: TurnStatus, + message: &str, + ) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: test_turn( + turn_id, + status, + vec![ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: message.to_string(), + phase: None, + memory_citation: None, + }], + ), + }) + } + fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification { ServerNotification::ThreadClosed(ThreadClosedNotification { thread_id: thread_id.to_string(), @@ -11490,6 +11644,208 @@ model = "gpt-5.2" assert!(app.queued_trigger_labels().is_empty()); } + #[tokio::test] + async fn before_turn_workflow_augments_primary_user_turn() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + write_test_before_turn_workflow(app.config.cwd.as_path())?; + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + + let (op, cells) = app + .augment_primary_thread_op_with_before_turn_workflows( + &app_server, + thread_id, + AppCommand::from_core(Op::UserTurn { + items: vec![UserInput::Text { + text: "original prompt".to_string(), + text_elements: Vec::new(), + }], + cwd: app.config.cwd.to_path_buf(), + approval_policy: AskForApproval::Never, + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: "gpt-test".to_string(), + effort: None, + summary: None, + service_tier: None, + final_output_json_schema: None, + collaboration_mode: None, + personality: None, + }), + ) + .await; + + let AppCommandView::UserTurn { items, .. } = op.view() else { + panic!("expected user turn"); + }; + assert_eq!( + items, + &[ + UserInput::Text { + text: "original prompt".to_string(), + text_elements: Vec::new(), + }, + UserInput::Text { + text: "added by before_turn".to_string(), + text_elements: Vec::new(), + } + ] + ); + let rendered_cells: Vec = cells + .iter() + .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + .collect(); + assert_eq!(rendered_cells.len(), 1); + assert!(rendered_cells[0].contains("Workflow job completed")); + Ok(()) + } + + #[tokio::test] + async fn active_primary_turn_complete_waits_for_consumption_before_after_turn() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + write_test_after_turn_workflow(app.config.cwd.as_path())?; + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, Vec::new()) + .await?; + while app_event_rx.try_recv().is_ok() {} + + app.enqueue_thread_notification( + thread_id, + turn_completed_notification_with_agent_message( + thread_id, + "turn-1", + TurnStatus::Completed, + "final reply", + ), + ) + .await?; + + assert!( + app_event_rx.try_recv().is_err(), + "after_turn should not run before the active thread consumes TurnCompleted" + ); + + let queued_event = app + .active_thread_rx + .as_mut() + .expect("active thread receiver") + .recv() + .await + .expect("queued active-thread event"); + app.handle_thread_event_now(queued_event); + + assert!( + app_event_rx.try_recv().is_err(), + "after_turn should not run before event consumption finishes" + ); + + let visible_cells = app + .handle_primary_thread_turn_complete_for_workflows( + &app_server, + Some("final reply".to_string()), + ) + .await; + assert_eq!(visible_cells.len(), 2); + + match app_event_rx + .try_recv() + .expect("expected workflow follow-up submission") + { + AppEvent::SubmitThreadOp { + thread_id: submit_thread_id, + op: Op::UserTurn { items, .. }, + } => { + assert_eq!(submit_thread_id, thread_id); + assert_eq!( + items, + vec![UserInput::Text { + text: "follow up from workflow".to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected workflow follow-up submission, got {other:?}"), + } + Ok(()) + } + + #[tokio::test] + async fn inactive_primary_turn_complete_still_runs_after_turn_continuity() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + write_test_after_turn_workflow(app.config.cwd.as_path())?; + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, Vec::new()) + .await?; + let agent_thread_id = ThreadId::new(); + app.active_thread_id = Some(agent_thread_id); + while app_event_rx.try_recv().is_ok() {} + + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(turn_completed_notification_with_agent_message( + thread_id, + "turn-1", + TurnStatus::Completed, + "final reply", + )), + ) + .await; + + match app_event_rx + .try_recv() + .expect("expected inactive primary workflow follow-up submission") + { + AppEvent::SubmitThreadOp { + thread_id: submit_thread_id, + op: Op::UserTurn { items, .. }, + } => { + assert_eq!(submit_thread_id, thread_id); + assert_eq!( + items, + vec![UserInput::Text { + text: "follow up from workflow".to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected workflow follow-up submission, got {other:?}"), + } + Ok(()) + } + #[tokio::test] async fn manual_triggers_use_a_global_fifo_queue() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e3..06250eca9 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -153,7 +153,7 @@ impl App { async fn handle_server_notification_event( &mut self, - _app_server_client: &AppServerSession, + app_server_client: &AppServerSession, notification: ServerNotification, ) { match ¬ification { @@ -192,14 +192,28 @@ impl App { let result = if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() { - self.enqueue_primary_thread_notification(notification).await + self.enqueue_primary_thread_notification(notification.clone()) + .await } else { - self.enqueue_thread_notification(thread_id, notification) + self.enqueue_thread_notification(thread_id, notification.clone()) .await }; if let Err(err) = result { tracing::warn!("failed to enqueue app-server notification: {err}"); + } else if self.active_thread_id != Some(thread_id) + && let Some(last_agent_message) = super::workflow_after_turn_last_agent_message( + self.primary_thread_id, + thread_id, + ¬ification, + ) + { + let _ = self + .handle_primary_thread_turn_complete_for_workflows( + app_server_client, + last_agent_message, + ) + .await; } return; } diff --git a/codex-rs/tui/src/app/workflow_history.rs b/codex-rs/tui/src/app/workflow_history.rs index 6e0062765..df3dc8e98 100644 --- a/codex-rs/tui/src/app/workflow_history.rs +++ b/codex-rs/tui/src/app/workflow_history.rs @@ -1,5 +1,11 @@ use super::App; +use super::workflow_runtime::WorkflowOutputDelivery; +use super::workflow_runtime::WorkflowPhaseContext; +use super::workflow_runtime::WorkflowRunPhase; +use crate::app_command::AppCommand; +use crate::app_command::AppCommandView; use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; use crate::history_cell; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; @@ -46,6 +52,122 @@ impl WorkflowReplySource { } impl App { + pub(crate) async fn augment_primary_thread_op_with_before_turn_workflows( + &mut self, + app_server: &AppServerSession, + thread_id: ThreadId, + op: AppCommand, + ) -> (AppCommand, Vec>) { + if self.primary_thread_id != Some(thread_id) + || !matches!(op.view(), AppCommandView::UserTurn { .. }) + { + return (op, Vec::new()); + } + + let Op::UserTurn { + mut items, + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + model, + effort, + summary, + service_tier, + final_output_json_schema, + collaboration_mode, + personality, + } = op.into_core() + else { + unreachable!("UserTurn view should match UserTurn core op"); + }; + + let current_user_turn = user_text_from_inputs(&items); + if current_user_turn.trim().is_empty() { + return ( + AppCommand::from_core(Op::UserTurn { + items, + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + model, + effort, + summary, + service_tier, + final_output_json_schema, + collaboration_mode, + personality, + }), + Vec::new(), + ); + } + + let mut cells: Vec> = Vec::new(); + match self + .run_phase_workflows( + app_server, + WorkflowRunPhase::BeforeTurn, + WorkflowPhaseContext { + current_user_turn: Some(current_user_turn.as_str()), + last_assistant_message: None, + }, + ) + .await + { + Ok(results) => { + for result in results { + let source = WorkflowReplySource::new(workflow_job_source_hint(&result), None); + cells.push(Arc::new(history_cell::new_info_event( + "Workflow job completed".to_string(), + Some(source.hint()), + ))); + let Some(message) = result.message.filter(|message| !message.trim().is_empty()) + else { + continue; + }; + let message = message.trim().to_string(); + match result.delivery { + WorkflowOutputDelivery::AssistantCell => { + cells.push(Arc::new(workflow_result_cell( + &message, + self.config.cwd.as_path(), + ))); + } + WorkflowOutputDelivery::MainThreadInput + | WorkflowOutputDelivery::UserFollowup => { + items.push(UserInput::Text { + text: message, + text_elements: Vec::new(), + }); + } + } + } + } + Err(error) => cells.push(Arc::new(history_cell::new_error_event(format!( + "Workflow before_turn failed: {error}" + )))), + } + + ( + AppCommand::from_core(Op::UserTurn { + items, + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + model, + effort, + summary, + service_tier, + final_output_json_schema, + collaboration_mode, + personality, + }), + cells, + ) + } + pub(crate) fn queue_workflow_history_replay_for_thread(&self, thread_id: ThreadId) { if self .workflow_history @@ -173,6 +295,69 @@ impl App { personality: self.config.personality, }) } + + pub(crate) async fn handle_primary_thread_turn_complete_for_workflows( + &mut self, + app_server: &AppServerSession, + last_agent_message: Option, + ) -> Vec> { + let Some(primary_thread_id) = self.primary_thread_id else { + return Vec::new(); + }; + + let results = match self + .run_phase_workflows( + app_server, + WorkflowRunPhase::AfterTurn, + WorkflowPhaseContext { + current_user_turn: None, + last_assistant_message: last_agent_message.as_deref(), + }, + ) + .await + { + Ok(results) => results, + Err(error) => { + self.chat_widget + .add_error_message(format!("Workflow after_turn failed: {error}")); + return Vec::new(); + } + }; + + let mut visible_cells = Vec::new(); + for result in results { + let source = WorkflowReplySource::new(workflow_job_source_hint(&result), None); + let completed_cell: Arc = Arc::new(history_cell::new_info_event( + "Workflow job completed".to_string(), + Some(source.hint()), + )); + let visible_completed = + self.record_workflow_history_cell(primary_thread_id, completed_cell); + + if let Some(cell) = visible_completed { + visible_cells.push(cell); + } + + let Some(message) = result.message.filter(|message| !message.trim().is_empty()) else { + continue; + }; + + let next_visible = match result.delivery { + WorkflowOutputDelivery::AssistantCell => { + let cell: Arc = + Arc::new(workflow_result_cell(&message, self.config.cwd.as_path())); + self.record_workflow_history_cell(primary_thread_id, cell) + } + WorkflowOutputDelivery::MainThreadInput | WorkflowOutputDelivery::UserFollowup => { + self.queue_workflow_followup_to_primary(message, source) + } + }; + if let Some(cell) = next_visible { + visible_cells.push(cell); + } + } + visible_cells + } } pub(crate) fn workflow_result_cell(message: &str, cwd: &Path) -> AgentMessageCell { @@ -194,6 +379,28 @@ fn workflow_prompt_prefix(prompt: &str) -> String { } } +fn workflow_job_source_hint(result: &super::workflow_runtime::WorkflowJobRunResult) -> String { + format!( + "{}/{}:{}", + result.workflow_name, result.trigger_id, result.job_name + ) +} + +fn user_text_from_inputs(items: &[UserInput]) -> String { + items + .iter() + .filter_map(|item| match item { + UserInput::Text { text, .. } => Some(text.as_str()), + UserInput::Image { .. } + | UserInput::LocalImage { .. } + | UserInput::Skill { .. } + | UserInput::Mention { .. } + | _ => None, + }) + .collect::>() + .join("\n") +} + #[cfg(test)] mod tests { use super::WorkflowReplySource; diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index ce4b35542..228cbeccb 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -125,6 +125,18 @@ pub(crate) enum WorkflowOutputDelivery { UserFollowup, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkflowRunPhase { + BeforeTurn, + AfterTurn, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct WorkflowPhaseContext<'a> { + pub(crate) current_user_turn: Option<&'a str>, + pub(crate) last_assistant_message: Option<&'a str>, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WorkflowJobRunResult { pub(crate) delivery: WorkflowOutputDelivery, @@ -365,6 +377,46 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { #[allow(dead_code)] impl App { + pub(crate) async fn run_phase_workflows( + &self, + app_server: &AppServerSession, + phase: WorkflowRunPhase, + phase_context: WorkflowPhaseContext<'_>, + ) -> Result, String> { + let registry = load_workflow_registry(self.config.cwd.as_path()) + .map_err(|error| format!("failed to load workflows: {error}"))?; + let client = AppServerWorkflowRuntimeClient::new( + app_server, + self.config.clone(), + self.primary_thread_id, + ); + let mut results = Vec::new(); + for workflow in ®istry.files { + for trigger in &workflow.triggers { + if !trigger.enabled || !workflow_trigger_matches_phase(&trigger.kind, phase) { + continue; + } + results.extend( + run_workflow_jobs( + &client, + ®istry, + &workflow.name, + &trigger.id, + &trigger.jobs, + phase_context, + /*cancellation*/ None, + ) + .await + .map_err(|error| match error { + WorkflowRunError::Failed(message) => message, + WorkflowRunError::Cancelled => "workflow run cancelled".to_string(), + })?, + ); + } + } + Ok(results) + } + pub(crate) fn start_manual_workflow_trigger_run( &mut self, app_server: &AppServerSession, @@ -653,7 +705,11 @@ async fn run_background_workflow_selection( workflow_name, trigger_id, &trigger.jobs, - cancellation, + WorkflowPhaseContext { + current_user_turn: None, + last_assistant_message: None, + }, + Some(cancellation), ) .await } @@ -675,27 +731,42 @@ async fn run_background_workflow_selection( workflow_name, &manual_workflow_job_trigger_id(job_name), std::slice::from_ref(job_name), - cancellation, + WorkflowPhaseContext { + current_user_turn: None, + last_assistant_message: None, + }, + Some(cancellation), ) .await } } } +fn workflow_trigger_matches_phase(trigger: &WorkflowTriggerKind, phase: WorkflowRunPhase) -> bool { + matches!( + (trigger, phase), + ( + &WorkflowTriggerKind::BeforeTurn, + WorkflowRunPhase::BeforeTurn + ) | (&WorkflowTriggerKind::AfterTurn, WorkflowRunPhase::AfterTurn) + ) +} + async fn run_workflow_jobs( client: &dyn WorkflowRuntimeClient, registry: &LoadedWorkflowRegistry, workflow_name: &str, trigger_id: &str, root_jobs: &[String], - cancellation: &CancellationToken, + phase_context: WorkflowPhaseContext<'_>, + cancellation: Option<&CancellationToken>, ) -> Result, WorkflowRunError> { let ordered = ordered_jobs_for_roots(registry, root_jobs) .map_err(|error| WorkflowRunError::Failed(error.to_string()))?; let mut results = Vec::new(); let mut completed = BTreeMap::::new(); for job_name in ordered { - if cancellation.is_cancelled() { + if cancellation.is_some_and(CancellationToken::is_cancelled) { return Err(WorkflowRunError::Cancelled); } let job = registry.jobs.get(&job_name).ok_or_else(|| { @@ -714,7 +785,15 @@ async fn run_workflow_jobs( completed.insert(job.name.clone(), false); continue; } - let result = run_workflow_job(client, workflow_name, trigger_id, job, cancellation).await?; + let result = run_workflow_job( + client, + workflow_name, + trigger_id, + job, + phase_context, + cancellation, + ) + .await?; completed.insert(job.name.clone(), true); results.push(result); } @@ -726,7 +805,8 @@ async fn run_workflow_job( workflow_name: &str, trigger_id: &str, job: &LoadedWorkflowJob, - cancellation: &CancellationToken, + phase_context: WorkflowPhaseContext<'_>, + cancellation: Option<&CancellationToken>, ) -> Result { if matches!(job.config.context, WorkflowContextMode::Embed) { let prompt = job @@ -759,7 +839,7 @@ async fn run_workflow_job( let attempts = step.retry_attempts(); let mut last_error = None; for attempt in 1..=attempts { - if cancellation.is_cancelled() { + if cancellation.is_some_and(CancellationToken::is_cancelled) { if let Some(thread) = thread.as_ref() { let _ = client.unsubscribe_thread(thread.thread_id.clone()).await; } @@ -773,6 +853,7 @@ async fn run_workflow_job( job, step, &step_outputs, + phase_context, cancellation, ) .await; @@ -832,7 +913,8 @@ async fn execute_workflow_step( job: &LoadedWorkflowJob, step: &WorkflowStep, step_outputs: &[String], - cancellation: &CancellationToken, + phase_context: WorkflowPhaseContext<'_>, + cancellation: Option<&CancellationToken>, ) -> Result, WorkflowRunError> { match step { WorkflowStep::Run { run, .. } => { @@ -855,6 +937,7 @@ async fn execute_workflow_step( trigger_id, &job.name, prompt, + phase_context, step_outputs, ); run_workflow_prompt(client, &thread, prompt, cancellation).await @@ -866,14 +949,14 @@ async fn run_workflow_prompt( client: &dyn WorkflowRuntimeClient, thread: &WorkflowThreadSession, prompt: String, - cancellation: &CancellationToken, + cancellation: Option<&CancellationToken>, ) -> Result, WorkflowRunError> { let turn_id = client .start_turn(thread.thread_id.clone(), thread.cwd.clone(), prompt) .await .map_err(WorkflowRunError::Failed)?; loop { - if cancellation.is_cancelled() { + if cancellation.is_some_and(CancellationToken::is_cancelled) { interrupt_active_workflow_turn(client, thread.thread_id.clone(), turn_id.clone()).await; return Err(WorkflowRunError::Cancelled); } @@ -914,8 +997,8 @@ async fn interrupt_active_workflow_turn( async fn run_workflow_command( command: &str, - workflow_path: &PathBuf, - cancellation: &CancellationToken, + workflow_path: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result, WorkflowRunError> { #[cfg(windows)] let mut cmd = { @@ -934,7 +1017,7 @@ async fn run_workflow_command( .parent() .and_then(|parent| parent.parent()) .and_then(|parent| parent.parent()) - .unwrap_or(workflow_path.as_path()); + .unwrap_or(workflow_path); let child = cmd .current_dir(workflow_dir) .stdin(Stdio::null()) @@ -947,7 +1030,13 @@ async fn run_workflow_command( let wait_with_output = child.wait_with_output(); tokio::pin!(wait_with_output); let output = tokio::select! { - _ = cancellation.cancelled() => return Err(WorkflowRunError::Cancelled), + _ = async { + if let Some(cancellation) = cancellation { + cancellation.cancelled().await; + } else { + std::future::pending::<()>().await; + } + } => return Err(WorkflowRunError::Cancelled), output = &mut wait_with_output => output, } .map_err(|err| { @@ -984,11 +1073,30 @@ fn build_workflow_prompt_input( trigger_id: &str, job_name: &str, prompt: &str, + phase_context: WorkflowPhaseContext<'_>, step_outputs: &[String], ) -> String { let mut sections = vec![format!( "Workflow: {workflow_name}\nTrigger: {trigger_id}\nJob: {job_name}" )]; + if let Some(current_user_turn) = phase_context + .current_user_turn + .map(str::trim) + .filter(|text| !text.is_empty()) + { + sections.push(format!( + "Current main-thread user turn:\n{current_user_turn}" + )); + } + if let Some(last_assistant_message) = phase_context + .last_assistant_message + .map(str::trim) + .filter(|text| !text.is_empty()) + { + sections.push(format!( + "Latest main-thread assistant response:\n{last_assistant_message}" + )); + } if !step_outputs.is_empty() { sections.push(format!( "Previous workflow step outputs:\n{}", diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297..6e614cc35 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -279,6 +279,10 @@ impl AppCommand { self.0 } + pub(crate) fn from_core(op: Op) -> Self { + Self(op) + } + pub(crate) fn is_review(&self) -> bool { matches!(self.view(), AppCommandView::Review { .. }) } From 9db1a9188ee330bc6fbb340080a9179aef56674f Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 01:42:08 +0800 Subject: [PATCH 21/60] replay: clean workflow clippy warnings --- codex-rs/tui/src/app/workflow_definition.rs | 17 ++++----- codex-rs/tui/src/app/workflow_runtime.rs | 38 +++++++++++---------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/codex-rs/tui/src/app/workflow_definition.rs b/codex-rs/tui/src/app/workflow_definition.rs index 1d01f89b5..1faa46775 100644 --- a/codex-rs/tui/src/app/workflow_definition.rs +++ b/codex-rs/tui/src/app/workflow_definition.rs @@ -321,10 +321,11 @@ pub(crate) fn ordered_jobs_for_roots( .collect::>(); let mut dependents = BTreeMap::>::new(); for job_name in &reachable { - let job = registry - .jobs - .get(job_name) - .expect("reachable job should exist"); + let Some(job) = registry.jobs.get(job_name) else { + return Err(WorkflowDefinitionError::Invalid(format!( + "reachable workflow job `{job_name}` is missing from registry" + ))); + }; for dependency in &job.config.needs { if !reachable.contains(dependency) { continue; @@ -368,13 +369,13 @@ fn pop_next_job(ready: &mut VecDeque, registry: &LoadedWorkflowRegistry) let best_index = ready .iter() .enumerate() - .min_by_key(|(_, job_name)| { + .filter_map(|(index, job_name)| { registry .jobs - .get(*job_name) - .expect("ready job should exist") - .definition_index + .get(job_name) + .map(|job| (index, job.definition_index)) }) + .min_by_key(|(_, definition_index)| *definition_index) .map(|(index, _)| index)?; ready.remove(best_index) } diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index 228cbeccb..4378a7975 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -167,6 +167,15 @@ struct WorkflowThreadSession { cwd: PathBuf, } +#[derive(Clone, Copy)] +struct WorkflowStepExecutionContext<'a> { + workflow_name: &'a str, + trigger_id: &'a str, + job: &'a LoadedWorkflowJob, + phase_context: WorkflowPhaseContext<'a>, + cancellation: Option<&'a CancellationToken>, +} + #[derive(Debug, Clone, PartialEq)] struct WorkflowTurnState { status: TurnStatus, @@ -845,18 +854,15 @@ async fn run_workflow_job( } return Err(WorkflowRunError::Cancelled); } - let result = execute_workflow_step( - client, - &mut thread, + let context = WorkflowStepExecutionContext { workflow_name, trigger_id, job, - step, - &step_outputs, phase_context, cancellation, - ) - .await; + }; + let result = + execute_workflow_step(client, &mut thread, context, step, &step_outputs).await; match result { Ok(Some(output)) => { if matches!(step, WorkflowStep::Prompt { .. }) { @@ -908,17 +914,13 @@ async fn run_workflow_job( async fn execute_workflow_step( client: &dyn WorkflowRuntimeClient, thread: &mut Option, - workflow_name: &str, - trigger_id: &str, - job: &LoadedWorkflowJob, + context: WorkflowStepExecutionContext<'_>, step: &WorkflowStep, step_outputs: &[String], - phase_context: WorkflowPhaseContext<'_>, - cancellation: Option<&CancellationToken>, ) -> Result, WorkflowRunError> { match step { WorkflowStep::Run { run, .. } => { - run_workflow_command(run, &job.workflow_path, cancellation).await + run_workflow_command(run, &context.job.workflow_path, context.cancellation).await } WorkflowStep::Prompt { prompt, .. } => { let thread = match thread { @@ -933,14 +935,14 @@ async fn execute_workflow_step( } }; let prompt = build_workflow_prompt_input( - workflow_name, - trigger_id, - &job.name, + context.workflow_name, + context.trigger_id, + &context.job.name, prompt, - phase_context, + context.phase_context, step_outputs, ); - run_workflow_prompt(client, &thread, prompt, cancellation).await + run_workflow_prompt(client, &thread, prompt, context.cancellation).await } } } From ea74b8961987389ce5997c9e4e85181cca2699d8 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 16:40:37 +0800 Subject: [PATCH 22/60] replay: restore workflow ui on new tui --- codex-rs/tui/src/app.rs | 136 +++++++++ codex-rs/tui/src/app/workflow_controls.rs | 258 ++++++++++++++++++ codex-rs/tui/src/app_event.rs | 13 + codex-rs/tui/src/chatwidget.rs | 3 + .../src/chatwidget/tests/slash_commands.rs | 8 + codex-rs/tui/src/slash_command.rs | 3 + ...__app__tests__workflow_controls_popup.snap | 19 ++ 7 files changed, 440 insertions(+) create mode 100644 codex-rs/tui/src/app/workflow_controls.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 23b075d01..df01a29ad 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -161,6 +161,7 @@ mod app_server_adapter; mod app_server_requests; mod loaded_threads; mod pending_interactive_replay; +mod workflow_controls; mod workflow_definition; mod workflow_history; pub(crate) mod workflow_runtime; @@ -1941,6 +1942,7 @@ impl App { self.background_workflow_labels(), self.queued_trigger_labels(), ); + self.refresh_workflow_controls_if_active(); } fn insert_visible_history_cell(&mut self, tui: &mut tui::Tui, cell: Arc) { @@ -4790,6 +4792,44 @@ impl App { ) .await; } + AppEvent::OpenWorkflowControls => { + self.open_workflow_controls_popup(); + } + AppEvent::StartManualWorkflowTrigger { + workflow_name, + trigger_id, + } => { + let cell = self.start_manual_workflow_trigger_from_ui( + app_server, + workflow_name, + trigger_id, + ); + self.insert_visible_history_cell(tui, cell); + } + AppEvent::StartManualWorkflowJob { + workflow_name, + job_name, + } => { + let cell = + self.start_manual_workflow_job_from_ui(app_server, workflow_name, job_name); + self.insert_visible_history_cell(tui, cell); + } + AppEvent::ShowWorkflowBackgroundTasks => { + self.chat_widget.add_ps_output(); + } + AppEvent::RetryLastUserTurnWithProfileFallback { + action, + error_message, + } => { + self.retry_last_user_turn_with_profile_fallback( + tui, + app_server, + action, + error_message, + ) + .await; + } + } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { tui.frame_requester().schedule_frame(); @@ -6800,6 +6840,7 @@ mod tests { use assert_matches::assert_matches; use codex_app_server_client::AppServerEvent; + use crate::render::renderable::Renderable; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; @@ -6872,6 +6913,8 @@ mod tests { use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; use ratatui::prelude::Line; use std::path::Path; use std::path::PathBuf; @@ -9794,6 +9837,41 @@ jobs: Ok(()) } + fn write_test_manual_workflow(workspace_cwd: &Path) -> Result<()> { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir)?; + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +triggers: + - type: manual + id: review_backlog + jobs: [summarize] + - type: manual + id: triage + jobs: [notify] + - type: after_turn + id: followup + jobs: [notify] + +jobs: + summarize: + context: embed + steps: + - prompt: | + summarize the backlog + notify: + context: embed + response: user + steps: + - prompt: | + send workflow update +"#, + )?; + Ok(()) + } + fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { thread_id, @@ -11846,6 +11924,64 @@ model = "gpt-5.2" Ok(()) } + #[tokio::test] + async fn workflow_ui_popup_snapshot() -> Result<()> { + let mut app = make_test_app().await; + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path())?; + + let slow_run = app + .start_test_manual_workflow_trigger_run( + "director".to_string(), + "review_backlog".to_string(), + ) + .expect("running manual trigger"); + let queued_run = app + .start_test_manual_workflow_trigger_run("director".to_string(), "triage".to_string()); + assert!(queued_run.is_none()); + + app.open_workflow_controls_popup(); + + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert_snapshot!("workflow_controls_popup", popup); + + app.finish_test_background_workflow_run(slow_run).await; + Ok(()) + } + + #[tokio::test] + async fn workflow_ui_manual_trigger_action_updates_scheduler_status() -> Result<()> { + let mut app = make_test_app().await; + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path())?; + + let started_app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let cell = app.start_manual_workflow_trigger_from_ui( + &started_app_server, + "director".to_string(), + "review_backlog".to_string(), + ); + let rendered = cell + .display_lines(/*width*/ 100) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(rendered.contains("Workflow trigger started")); + assert_eq!( + app.background_workflow_labels(), + vec!["director · review_backlog".to_string()] + ); + + let stopped = app.workflow_scheduler.stop_active_workflow_runs().await; + assert_eq!(stopped, 1); + app.sync_background_workflow_status(); + Ok(()) + } + #[tokio::test] async fn manual_triggers_use_a_global_fifo_queue() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/workflow_controls.rs b/codex-rs/tui/src/app/workflow_controls.rs new file mode 100644 index 000000000..cc370ba19 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_controls.rs @@ -0,0 +1,258 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::history_cell::HistoryCell; + +use super::App; +use super::workflow_definition::LoadedWorkflowRegistry; +use super::workflow_definition::WorkflowTriggerKind; +use super::workflow_definition::load_workflow_registry; +use crate::app_server_session::AppServerSession; + +const WORKFLOW_CONTROLS_VIEW_ID: &str = "workflow-controls"; + +impl App { + pub(crate) fn open_workflow_controls_popup(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(WORKFLOW_CONTROLS_VIEW_ID); + let params = self.workflow_controls_popup_params(initial_selected_idx); + if !self + .chat_widget + .replace_selection_view_if_active(WORKFLOW_CONTROLS_VIEW_ID, params) + { + self.chat_widget + .show_selection_view(self.workflow_controls_popup_params(initial_selected_idx)); + } + } + + pub(crate) fn refresh_workflow_controls_if_active(&mut self) { + let Some(initial_selected_idx) = self + .chat_widget + .selected_index_for_active_view(WORKFLOW_CONTROLS_VIEW_ID) + else { + return; + }; + let _ = self.chat_widget.replace_selection_view_if_active( + WORKFLOW_CONTROLS_VIEW_ID, + self.workflow_controls_popup_params(Some(initial_selected_idx)), + ); + } + + pub(crate) fn start_manual_workflow_trigger_from_ui( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + trigger_id: String, + ) -> Arc { + let cell = self.start_manual_workflow_trigger_run(app_server, workflow_name, trigger_id); + self.refresh_workflow_controls_if_active(); + cell + } + + pub(crate) fn start_manual_workflow_job_from_ui( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + job_name: String, + ) -> Arc { + let cell = self.start_manual_workflow_job_run(app_server, workflow_name, job_name); + self.refresh_workflow_controls_if_active(); + cell + } + + fn workflow_controls_popup_params( + &self, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let running_labels = self.background_workflow_labels(); + let queued_labels = self.queued_trigger_labels(); + let mut items = vec![SelectionItem { + name: "Background Tasks".to_string(), + description: Some(workflow_status_summary(&running_labels, &queued_labels)), + selected_description: Some( + "Insert a background task snapshot into the transcript. /ps shows the same live workflow state." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::ShowWorkflowBackgroundTasks))], + dismiss_on_select: false, + ..Default::default() + }]; + + match load_workflow_registry(self.config.cwd.as_path()) { + Ok(registry) => { + let mut registry_items = + workflow_registry_items(®istry, &running_labels, &queued_labels); + items.append(&mut registry_items); + } + Err(error) => { + items.push(SelectionItem { + name: "Workflow Registry Error".to_string(), + description: Some(error.to_string()), + selected_description: Some( + "Fix the workflow files under .codex/workflows, then reopen /workflow." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + } + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Workflow".to_string()), + subtitle: Some( + "Trigger workspace workflows and inspect current scheduler/runtime state." + .to_string(), + ), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search workflows".to_string()), + initial_selected_idx, + ..Default::default() + } + } +} + +fn workflow_registry_items( + registry: &LoadedWorkflowRegistry, + running_labels: &[String], + queued_labels: &[String], +) -> Vec { + let running_set = running_labels.iter().cloned().collect::>(); + let queued_set = queued_labels.iter().cloned().collect::>(); + let mut items = Vec::new(); + + let trigger_items = registry + .files + .iter() + .flat_map(|workflow| { + workflow.triggers.iter().filter_map(|trigger| { + if !matches!(trigger.kind, WorkflowTriggerKind::Manual) { + return None; + } + let label = format!("{} · {}", workflow.name, trigger.id); + let status = workflow_target_status(&label, &running_set, &queued_set); + Some(SelectionItem { + name: label.clone(), + description: Some(format!("Manual trigger · {status}")), + selected_description: Some( + "Run this workflow trigger now. Running and queued state will stay visible in the footer and /ps." + .to_string(), + ), + search_value: Some(format!("{} {}", workflow.name, trigger.id)), + actions: vec![Box::new({ + let workflow_name = workflow.name.clone(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::StartManualWorkflowTrigger { + workflow_name: workflow_name.clone(), + trigger_id: trigger_id.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }) + }) + }) + .collect::>(); + + if !trigger_items.is_empty() { + items.push(disabled_section_item( + "Manual Triggers", + trigger_items.len(), + )); + items.extend(trigger_items); + } + + let job_items = registry + .jobs + .values() + .map(|job| { + let label = format!("{} · {}", job.workflow_name, job.name); + let status = workflow_target_status(&label, &running_set, &queued_set); + SelectionItem { + name: label.clone(), + description: Some(format!("Job run · {status}")), + selected_description: Some( + "Run this workflow job directly. Running state will stay visible in the footer and /ps." + .to_string(), + ), + search_value: Some(format!("{} {}", job.workflow_name, job.name)), + actions: vec![Box::new({ + let workflow_name = job.workflow_name.clone(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::StartManualWorkflowJob { + workflow_name: workflow_name.clone(), + job_name: job_name.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + } + }) + .collect::>(); + + if !job_items.is_empty() { + items.push(disabled_section_item("Jobs", job_items.len())); + items.extend(job_items); + } + + if items.is_empty() { + items.push(SelectionItem { + name: "No runnable workflow targets".to_string(), + description: Some( + "Add manual triggers or jobs under .codex/workflows to make them runnable from /workflow." + .to_string(), + ), + selected_description: Some( + "Automatic before_turn / after_turn workflows still run through the existing scheduler/runtime path." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + + items +} + +fn disabled_section_item(title: &str, count: usize) -> SelectionItem { + SelectionItem { + name: title.to_string(), + description: Some(format!("{count} available")), + is_disabled: true, + ..Default::default() + } +} + +fn workflow_status_summary(running_labels: &[String], queued_labels: &[String]) -> String { + format!( + "Running: {} · Queued: {}", + running_labels.len(), + queued_labels.len() + ) +} + +fn workflow_target_status( + label: &str, + running_set: &HashSet, + queued_set: &HashSet, +) -> &'static str { + if running_set.contains(label) { + "Running" + } else if queued_set.contains(label) { + "Queued" + } else { + "Ready" + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b2c06f1ca..88cef69b8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -279,6 +279,19 @@ pub(crate) enum AppEvent { result: Box, }, + OpenWorkflowControls, + + StartManualWorkflowTrigger { + workflow_name: String, + trigger_id: String, + }, + + StartManualWorkflowJob { + workflow_name: String, + job_name: String, + }, + + ShowWorkflowBackgroundTasks, /// Retry the last turn using the routed profile fallback policy. RetryLastUserTurnWithProfileFallback { action: ProfileFallbackAction, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 10cc4d99e..74bdf7d4f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5350,6 +5350,9 @@ impl ChatWidget { SlashCommand::Plugins => { self.add_plugins_output(); } + SlashCommand::Workflow => { + self.app_event_tx.send(AppEvent::OpenWorkflowControls); + } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc95..4ccf83280 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -401,6 +401,14 @@ async fn slash_mcp_requests_inventory_via_app_server() { assert_matches!(rx.try_recv(), Ok(AppEvent::FetchMcpInventory)); assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); } +async fn slash_workflow_opens_controls_popup() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Workflow); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenWorkflowControls)); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} #[tokio::test] async fn slash_memory_update_reports_stubbed_feature() { diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb..7ef0097e6 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -44,6 +44,7 @@ pub enum SlashCommand { Mcp, Apps, Plugins, + Workflow, Logout, Quit, Exit, @@ -112,6 +113,7 @@ impl SlashCommand { SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Apps => "manage apps", SlashCommand::Plugins => "browse plugins", + SlashCommand::Workflow => "inspect and trigger workspace workflows", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", SlashCommand::TestApproval => "test approval request", @@ -171,6 +173,7 @@ impl SlashCommand { | SlashCommand::Mcp | SlashCommand::Apps | SlashCommand::Plugins + | SlashCommand::Workflow | SlashCommand::Feedback | SlashCommand::Quit | SlashCommand::Exit => true, diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap new file mode 100644 index 000000000..9b0e34413 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/app.rs +assertion_line: 12532 +expression: popup +--- + Workflow + Trigger workspace workflows and inspect current scheduler/runtime state. + + Type to search workflows +› Background Tasks Insert a background task snapshot into the transcript. /ps shows the + same live workflow state. + Manual Triggers 2 available + director · review_backlog Manual trigger · Running + director · triage Manual trigger · Queued + Jobs 2 available + director · notify Job run · Ready + director · summarize Job run · Ready + + Press enter to confirm or esc to go back From 40d8ef895532fc99eb4f87b40cf56bc7a0fd6d06 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 00:10:02 +0800 Subject: [PATCH 23/60] fix: handle ephemeral workflow turns via notifications --- codex-rs/tui/src/app.rs | 61 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 6 + codex-rs/tui/src/app/workflow_runtime.rs | 333 +++++++++++++++++---- codex-rs/tui/src/app_event.rs | 10 + 4 files changed, 358 insertions(+), 52 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index df01a29ad..75a3cf1b6 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1074,6 +1074,8 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, + workflow_thread_notification_channels: + HashMap>, workflow_scheduler: WorkflowSchedulerState, workflow_history: WorkflowHistoryState, } @@ -4338,6 +4340,7 @@ impl App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + workflow_thread_notification_channels: HashMap::new(), workflow_scheduler: WorkflowSchedulerState::default(), workflow_history: WorkflowHistoryState::default(), }; @@ -4780,6 +4783,19 @@ impl App { self.insert_visible_history_cell(tui, cell); } } + AppEvent::RegisterWorkflowThreadNotificationForwarder { + thread_id, + sender, + ready_tx, + } => { + self.workflow_thread_notification_channels + .insert(thread_id, sender); + let _ = ready_tx.send(()); + } + AppEvent::UnregisterWorkflowThreadNotificationForwarder { thread_id } => { + self.workflow_thread_notification_channels + .remove(&thread_id); + } AppEvent::RetryLastUserTurnWithProfileFallback { action, error_message, @@ -6857,6 +6873,7 @@ mod tests { 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::ItemCompletedNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol; @@ -9729,6 +9746,7 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + workflow_thread_notification_channels: HashMap::new(), workflow_scheduler: WorkflowSchedulerState::default(), workflow_history: WorkflowHistoryState::default(), } @@ -9785,6 +9803,7 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + workflow_thread_notification_channels: HashMap::new(), workflow_scheduler: WorkflowSchedulerState::default(), workflow_history: WorkflowHistoryState::default(), }, @@ -12090,6 +12109,48 @@ model = "gpt-5.2" } } + #[tokio::test] + async fn app_server_notifications_forward_to_workflow_thread_receivers() -> Result<()> { + let mut app = make_test_app().await; + let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + app.workflow_thread_notification_channels + .insert(thread_id, sender); + + let notification = ServerNotification::ItemCompleted(ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "workflow reply".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + }); + + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(notification.clone()), + ) + .await; + + match receiver.recv().await { + Some(ServerNotification::ItemCompleted(received)) => { + assert_eq!(received.thread_id, thread_id.to_string()); + assert_eq!(received.turn_id, "turn-1"); + let ThreadItem::AgentMessage { text, .. } = received.item else { + panic!("expected forwarded workflow agent message"); + }; + assert_eq!(text, "workflow reply"); + } + other => panic!("expected forwarded workflow notification, got {other:?}"), + } + Ok(()) + } + #[tokio::test] async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 06250eca9..a177ec56e 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -215,6 +215,12 @@ impl App { ) .await; } + if let Some(sender) = self.workflow_thread_notification_channels.get(&thread_id) + && sender.send(notification.clone()).is_err() + { + self.workflow_thread_notification_channels + .remove(&thread_id); + } return; } ServerNotificationThreadTarget::InvalidThreadId(thread_id) => { diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index 4378a7975..7606aae61 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -10,22 +10,27 @@ use super::workflow_definition::ordered_jobs_for_roots; use super::workflow_history::WorkflowReplySource; use super::workflow_history::workflow_result_cell; use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::history_cell; use crate::history_cell::HistoryCell; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -44,6 +49,7 @@ use std::process::Stdio; use std::sync::Arc; use std::time::Duration; use tokio::process::Command; +use tokio::sync::mpsc; use tokio::time::sleep; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -161,10 +167,11 @@ pub(crate) struct BackgroundWorkflowRunResult { pub(crate) outcome: BackgroundWorkflowRunOutcome, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone)] struct WorkflowThreadSession { thread_id: String, cwd: PathBuf, + notifications: Arc>>, } #[derive(Clone, Copy)] @@ -191,11 +198,11 @@ trait WorkflowRuntimeClient: Send + Sync { cwd: PathBuf, input: String, ) -> BoxFuture<'_, Result>; - fn read_turn( - &self, - thread_id: String, + fn read_turn<'a>( + &'a self, + thread: &'a WorkflowThreadSession, turn_id: String, - ) -> BoxFuture<'_, Result>; + ) -> BoxFuture<'a, Result>; fn interrupt_turn( &self, thread_id: String, @@ -206,6 +213,7 @@ trait WorkflowRuntimeClient: Send + Sync { pub(crate) struct AppServerWorkflowRuntimeClient { request_handle: AppServerRequestHandle, + app_event_tx: AppEventSender, config: Config, primary_thread_id: Option, is_remote: bool, @@ -215,11 +223,13 @@ pub(crate) struct AppServerWorkflowRuntimeClient { impl AppServerWorkflowRuntimeClient { pub(crate) fn new( app_server: &AppServerSession, + app_event_tx: AppEventSender, config: Config, primary_thread_id: Option, ) -> Self { Self { request_handle: app_server.request_handle(), + app_event_tx, config, primary_thread_id, is_remote: app_server.is_remote(), @@ -231,7 +241,29 @@ impl AppServerWorkflowRuntimeClient { impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { fn start_workflow_thread(&self) -> BoxFuture<'_, Result> { Box::pin(async move { - if let Some(primary_thread_id) = self.primary_thread_id { + let fork_source_thread_id = if let Some(primary_thread_id) = self.primary_thread_id { + let response: ThreadReadResponse = self + .request_handle + .request_typed(ClientRequest::ThreadRead { + request_id: request_id(), + params: ThreadReadParams { + thread_id: primary_thread_id.to_string(), + include_turns: false, + }, + }) + .await + .map_err(|err| format!("failed to inspect workflow source thread: {err}"))?; + response + .thread + .path + .as_ref() + .is_some_and(|path| path.exists()) + .then_some(primary_thread_id) + } else { + None + }; + + if let Some(primary_thread_id) = fork_source_thread_id { let response: ThreadForkResponse = self .request_handle .request_typed(ClientRequest::ThreadFork { @@ -245,9 +277,27 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { }) .await .map_err(|err| format!("failed to fork workflow thread: {err}"))?; + let thread_id = ThreadId::from_string(&response.thread.id).map_err(|err| { + format!( + "workflow thread id `{}` is invalid: {err}", + response.thread.id + ) + })?; + let (sender, receiver) = mpsc::unbounded_channel(); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + self.app_event_tx + .send(AppEvent::RegisterWorkflowThreadNotificationForwarder { + thread_id, + sender, + ready_tx, + }); + ready_rx.await.map_err(|_| { + "workflow thread notification forwarder setup was cancelled".to_string() + })?; return Ok(WorkflowThreadSession { thread_id: response.thread.id, cwd: response.cwd, + notifications: Arc::new(tokio::sync::Mutex::new(receiver)), }); } @@ -263,9 +313,27 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { }) .await .map_err(|err| format!("failed to start workflow thread: {err}"))?; + let thread_id = ThreadId::from_string(&response.thread.id).map_err(|err| { + format!( + "workflow thread id `{}` is invalid: {err}", + response.thread.id + ) + })?; + let (sender, receiver) = mpsc::unbounded_channel(); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + self.app_event_tx + .send(AppEvent::RegisterWorkflowThreadNotificationForwarder { + thread_id, + sender, + ready_tx, + }); + ready_rx.await.map_err(|_| { + "workflow thread notification forwarder setup was cancelled".to_string() + })?; Ok(WorkflowThreadSession { thread_id: response.thread.id, cwd: response.cwd, + notifications: Arc::new(tokio::sync::Mutex::new(receiver)), }) }) } @@ -313,41 +381,43 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { }) } - fn read_turn( - &self, - thread_id: String, + fn read_turn<'a>( + &'a self, + thread: &'a WorkflowThreadSession, turn_id: String, - ) -> BoxFuture<'_, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async move { - let response: ThreadReadResponse = self - .request_handle - .request_typed(ClientRequest::ThreadRead { - request_id: request_id(), - params: ThreadReadParams { - thread_id: thread_id.clone(), - include_turns: true, - }, - }) - .await - .map_err(|err| format!("failed to read workflow thread: {err}"))?; - let turn = response - .thread - .turns - .into_iter() - .find(|turn| turn.id == turn_id) - .ok_or_else(|| { - format!("workflow turn `{turn_id}` is missing from thread `{thread_id}`") - })?; - Ok(WorkflowTurnState { - status: turn.status, - error: turn.error.map(|error| error.message), - last_agent_message: turn.items.into_iter().fold(None, |_, item| match item { - codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } => { - (!text.trim().is_empty()).then_some(text.trim().to_string()) + let mut notifications = thread.notifications.lock().await; + let mut last_agent_message = None; + loop { + match notifications.recv().await { + Some(ServerNotification::ItemCompleted(notification)) + if notification.thread_id == thread.thread_id + && notification.turn_id == turn_id => + { + update_last_workflow_agent_message(&mut last_agent_message, ¬ification); } - _ => None, - }), - }) + Some(ServerNotification::TurnCompleted(notification)) + if notification.thread_id == thread.thread_id + && notification.turn.id == turn_id => + { + let status = notification.turn.status.clone(); + let error = notification.turn.error.clone().map(|error| error.message); + return Ok(WorkflowTurnState { + status, + error, + last_agent_message: last_agent_message + .or_else(|| last_agent_message_for_turn_completed(¬ification)), + }); + } + Some(_) => {} + None => { + return Err(format!( + "workflow notification stream closed before turn `{turn_id}` completed" + )); + } + } + } }) } @@ -371,15 +441,23 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { fn unsubscribe_thread(&self, thread_id: String) -> BoxFuture<'_, Result<(), String>> { Box::pin(async move { - let _: ThreadUnsubscribeResponse = self + let result: Result = self .request_handle .request_typed(ClientRequest::ThreadUnsubscribe { request_id: request_id(), - params: ThreadUnsubscribeParams { thread_id }, + params: ThreadUnsubscribeParams { + thread_id: thread_id.clone(), + }, }) .await - .map_err(|err| format!("failed to unsubscribe workflow thread: {err}"))?; - Ok(()) + .map_err(|err| format!("failed to unsubscribe workflow thread: {err}")); + if let Ok(parsed_thread_id) = ThreadId::from_string(&thread_id) { + self.app_event_tx + .send(AppEvent::UnregisterWorkflowThreadNotificationForwarder { + thread_id: parsed_thread_id, + }); + } + result.map(|_| ()) }) } } @@ -396,6 +474,7 @@ impl App { .map_err(|error| format!("failed to load workflows: {error}"))?; let client = AppServerWorkflowRuntimeClient::new( app_server, + self.app_event_tx.clone(), self.config.clone(), self.primary_thread_id, ); @@ -616,6 +695,7 @@ impl App { .next_background_run_id(target.workflow_name(), target.slot_key()); let runtime_client = AppServerWorkflowRuntimeClient::new( app_server, + self.app_event_tx.clone(), self.config.clone(), self.primary_thread_id, ); @@ -959,11 +1039,11 @@ async fn run_workflow_prompt( .map_err(WorkflowRunError::Failed)?; loop { if cancellation.is_some_and(CancellationToken::is_cancelled) { - interrupt_active_workflow_turn(client, thread.thread_id.clone(), turn_id.clone()).await; + interrupt_active_workflow_turn(client, thread, turn_id.clone()).await; return Err(WorkflowRunError::Cancelled); } let turn = client - .read_turn(thread.thread_id.clone(), turn_id.clone()) + .read_turn(thread, turn_id.clone()) .await .map_err(WorkflowRunError::Failed)?; match turn.status { @@ -982,15 +1062,15 @@ async fn run_workflow_prompt( async fn interrupt_active_workflow_turn( client: &dyn WorkflowRuntimeClient, - thread_id: String, + thread: &WorkflowThreadSession, turn_id: String, ) { let _ = client - .interrupt_turn(thread_id.clone(), turn_id.clone()) + .interrupt_turn(thread.thread_id.clone(), turn_id.clone()) .await; let deadline = tokio::time::Instant::now() + WORKFLOW_INTERRUPT_SETTLE_TIMEOUT; while tokio::time::Instant::now() < deadline { - match client.read_turn(thread_id.clone(), turn_id.clone()).await { + match client.read_turn(thread, turn_id.clone()).await { Ok(turn) if !matches!(turn.status, TurnStatus::InProgress) => return, Ok(_) | Err(_) => sleep(WORKFLOW_POLL_INTERVAL).await, } @@ -1200,6 +1280,34 @@ fn retry_backoff_delay(attempt: u32) -> Duration { Duration::from_secs(seconds.max(1)) } +fn update_last_workflow_agent_message( + last_agent_message: &mut Option, + notification: &ItemCompletedNotification, +) { + if let ThreadItem::AgentMessage { text, .. } = ¬ification.item { + let trimmed = text.trim(); + if !trimmed.is_empty() { + *last_agent_message = Some(trimmed.to_string()); + } + } +} + +fn last_agent_message_for_turn_completed( + notification: &TurnCompletedNotification, +) -> Option { + notification + .turn + .items + .iter() + .fold(None, |_, item| match item { + ThreadItem::AgentMessage { text, .. } => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + _ => None, + }) +} + #[derive(Debug)] enum WorkflowRunError { Failed(String), @@ -1209,11 +1317,21 @@ enum WorkflowRunError { #[cfg(test)] mod tests { use super::*; + use codex_core::config::ConfigBuilder; use pretty_assertions::assert_eq; use std::collections::VecDeque; use std::sync::Mutex; + use tempfile::TempDir; use tempfile::tempdir; + async fn build_config(temp_dir: &TempDir) -> Config { + ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await + .expect("config should build") + } + struct FakeWorkflowRuntimeClient { calls: Mutex>, thread_id: String, @@ -1235,6 +1353,7 @@ mod tests { impl WorkflowRuntimeClient for FakeWorkflowRuntimeClient { fn start_workflow_thread(&self) -> BoxFuture<'_, Result> { Box::pin(async move { + let (_sender, receiver) = mpsc::unbounded_channel(); self.calls .lock() .expect("calls lock") @@ -1242,6 +1361,7 @@ mod tests { Ok(WorkflowThreadSession { thread_id: self.thread_id.clone(), cwd: PathBuf::from("/tmp/workflow"), + notifications: Arc::new(tokio::sync::Mutex::new(receiver)), }) }) } @@ -1261,16 +1381,16 @@ mod tests { }) } - fn read_turn( - &self, - thread_id: String, + fn read_turn<'a>( + &'a self, + thread: &'a WorkflowThreadSession, turn_id: String, - ) -> BoxFuture<'_, Result> { + ) -> BoxFuture<'a, Result> { Box::pin(async move { self.calls .lock() .expect("calls lock") - .push(format!("read_turn:{thread_id}:{turn_id}")); + .push(format!("read_turn:{}:{turn_id}", thread.thread_id)); self.reads .lock() .expect("reads lock") @@ -1435,4 +1555,113 @@ jobs: ] ); } + + #[tokio::test] + async fn read_turn_uses_forwarded_notifications_for_ephemeral_threads() { + let temp_dir = tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("embedded app server"); + let (app_event_tx, _app_event_rx) = tokio::sync::mpsc::unbounded_channel(); + let client = AppServerWorkflowRuntimeClient::new( + &app_server, + crate::app_event_sender::AppEventSender::new(app_event_tx), + config, + None, + ); + let (sender, receiver) = mpsc::unbounded_channel(); + let thread = WorkflowThreadSession { + thread_id: "thr_workflow".to_string(), + cwd: PathBuf::from("/tmp/workflow"), + notifications: Arc::new(tokio::sync::Mutex::new(receiver)), + }; + + sender + .send(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "workflow reply".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: thread.thread_id.clone(), + turn_id: "turn-1".to_string(), + }, + )) + .expect("item completed notification"); + sender + .send(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: thread.thread_id.clone(), + turn: codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: Vec::new(), + error: None, + status: TurnStatus::Completed, + }, + }, + )) + .expect("turn completed notification"); + + let state = client + .read_turn(&thread, "turn-1".to_string()) + .await + .expect("read workflow turn"); + + assert_eq!( + state, + WorkflowTurnState { + status: TurnStatus::Completed, + error: None, + last_agent_message: Some("workflow reply".to_string()), + } + ); + } + + #[tokio::test] + async fn start_workflow_thread_starts_fresh_thread_when_primary_thread_is_unmaterialized() { + let temp_dir = tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let mut app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("embedded app server"); + let primary = app_server + .start_thread(&config) + .await + .expect("start primary thread"); + assert!( + primary + .session + .rollout_path + .as_ref() + .is_some_and(|path| !path.exists()) + ); + let (app_event_tx, mut app_event_rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + let Some(AppEvent::RegisterWorkflowThreadNotificationForwarder { ready_tx, .. }) = + app_event_rx.recv().await + else { + panic!("expected workflow notification registration event"); + }; + let _ = ready_tx.send(()); + }); + + let client = AppServerWorkflowRuntimeClient::new( + &app_server, + crate::app_event_sender::AppEventSender::new(app_event_tx), + config, + Some(primary.session.thread_id), + ); + let workflow_thread = client + .start_workflow_thread() + .await + .expect("start workflow thread"); + + assert_ne!( + workflow_thread.thread_id, + primary.session.thread_id.to_string() + ); + } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 88cef69b8..af7308b23 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -16,6 +16,7 @@ use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::ServerNotification; use codex_chatgpt::connectors::AppInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; @@ -279,6 +280,15 @@ pub(crate) enum AppEvent { result: Box, }, + RegisterWorkflowThreadNotificationForwarder { + thread_id: ThreadId, + sender: tokio::sync::mpsc::UnboundedSender, + ready_tx: tokio::sync::oneshot::Sender<()>, + }, + + UnregisterWorkflowThreadNotificationForwarder { + thread_id: ThreadId, + }, OpenWorkflowControls, StartManualWorkflowTrigger { From 4cce74ab7e11175d8f9d03c44f3bd74a1ae7fa37 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 01:55:04 +0800 Subject: [PATCH 24/60] codex: fix CI failure on PR #18 --- codex-rs/tui/src/app.rs | 18 ++---------------- codex-rs/tui/src/app/workflow_history.rs | 8 ++++++-- codex-rs/tui/src/app/workflow_runtime.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 13 +++++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 +- 6 files changed, 24 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 75a3cf1b6..683b9e7e4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4833,19 +4833,6 @@ impl App { AppEvent::ShowWorkflowBackgroundTasks => { self.chat_widget.add_ps_output(); } - AppEvent::RetryLastUserTurnWithProfileFallback { - action, - error_message, - } => { - self.retry_last_user_turn_with_profile_fallback( - tui, - app_server, - action, - error_message, - ) - .await; - } - } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { tui.frame_requester().schedule_frame(); @@ -6846,6 +6833,7 @@ mod tests { use crate::chatwidget::ChatWidgetInit; use crate::chatwidget::create_initial_user_message; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; + use crate::chatwidget::tests::render_bottom_popup; use crate::chatwidget::tests::set_chatgpt_auth; use crate::file_search::FileSearchManager; use crate::history_cell::AgentMessageCell; @@ -6856,7 +6844,6 @@ mod tests { use assert_matches::assert_matches; use codex_app_server_client::AppServerEvent; - use crate::render::renderable::Renderable; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; @@ -6930,8 +6917,7 @@ mod tests { use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; + use ratatui::prelude::Line; use std::path::Path; use std::path::PathBuf; diff --git a/codex-rs/tui/src/app/workflow_history.rs b/codex-rs/tui/src/app/workflow_history.rs index df3dc8e98..b14b4ecf4 100644 --- a/codex-rs/tui/src/app/workflow_history.rs +++ b/codex-rs/tui/src/app/workflow_history.rs @@ -117,7 +117,10 @@ impl App { { Ok(results) => { for result in results { - let source = WorkflowReplySource::new(workflow_job_source_hint(&result), None); + let source = WorkflowReplySource::new( + workflow_job_source_hint(&result), + /*action*/ None, + ); cells.push(Arc::new(history_cell::new_info_event( "Workflow job completed".to_string(), Some(source.hint()), @@ -326,7 +329,8 @@ impl App { let mut visible_cells = Vec::new(); for result in results { - let source = WorkflowReplySource::new(workflow_job_source_hint(&result), None); + let source = + WorkflowReplySource::new(workflow_job_source_hint(&result), /*action*/ None); let completed_cell: Arc = Arc::new(history_cell::new_info_event( "Workflow job completed".to_string(), Some(source.hint()), diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index 7606aae61..47e7bf66f 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -1568,7 +1568,7 @@ jobs: &app_server, crate::app_event_sender::AppEventSender::new(app_event_tx), config, - None, + /*primary_thread_id*/ None, ); let (sender, receiver) = mpsc::unbounded_channel(); let thread = WorkflowThreadSession { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 74bdf7d4f..0c77b0578 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -10069,6 +10069,19 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn replace_selection_view_if_active( + &mut self, + view_id: &'static str, + params: SelectionViewParams, + ) -> bool { + self.bottom_pane + .replace_selection_view_if_active(view_id, params) + } + + pub(crate) fn selected_index_for_active_view(&self, view_id: &'static str) -> Option { + self.bottom_pane.selected_index_for_active_view(view_id) + } + fn open_connectors_loading_popup(&mut self) { if !self.bottom_pane.replace_selection_view_if_active( CONNECTORS_SELECTION_VIEW_ID, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4156082cb..7fbe5cdb2 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -261,5 +261,6 @@ mod status_and_layout; mod status_command_tests; pub(crate) use helpers::make_chatwidget_manual_with_sender; +pub(crate) use helpers::render_bottom_popup; pub(crate) use helpers::set_chatgpt_auth; pub(super) use helpers::*; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 4ded7ec62..dff089711 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -673,7 +673,7 @@ pub(super) fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { String::new() } -pub(super) fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { +pub(crate) fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { let height = chat.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); From 406389863c3ff81529c983ae3455bd439d9b57a9 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 09:36:05 +0800 Subject: [PATCH 25/60] replay: restore keybinding continuity on new tui --- codex-rs/tui/src/app.rs | 208 +++++++++++++++++- codex-rs/tui/src/app/key_chord.rs | 165 ++++++++++++++ codex-rs/tui/src/app_backtrack.rs | 24 +- codex-rs/tui/src/bottom_pane/footer.rs | 61 ++++- ...__tests__footer_mode_shortcut_overlay.snap | 13 +- ...shortcuts_collaboration_modes_enabled.snap | 14 +- ...tests__footer_shortcuts_shift_and_esc.snap | 13 +- codex-rs/tui/src/chatwidget.rs | 60 ++--- 8 files changed, 505 insertions(+), 53 deletions(-) create mode 100644 codex-rs/tui/src/app/key_chord.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 683b9e7e4..2035c8591 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -159,6 +159,7 @@ use uuid::Uuid; mod agent_navigation; mod app_server_adapter; mod app_server_requests; +mod key_chord; mod loaded_threads; mod pending_interactive_replay; mod workflow_controls; @@ -170,6 +171,9 @@ mod workflow_scheduler; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; +use self::key_chord::KeyChordAction; +use self::key_chord::KeyChordResolution; +use self::key_chord::KeyChordState; use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::pending_interactive_replay::PendingInteractiveReplayState; use self::workflow_history::WorkflowHistoryState; @@ -1040,6 +1044,7 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, + key_chord: KeyChordState, /// When set, the next draw re-renders the transcript into terminal scrollback once. /// /// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed @@ -4322,6 +4327,7 @@ impl App { status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), backtrack: BacktrackState::default(), + key_chord: KeyChordState::default(), backtrack_render_pending: false, feedback: feedback.clone(), feedback_audience, @@ -6458,12 +6464,57 @@ impl App { tui.frame_requester().schedule_frame(); } + fn handle_key_chord_key_event(&mut self, key_event: KeyEvent) -> Option { + if self.overlay.is_some() + || !self.chat_widget.no_modal_or_popup_active() + || self.chat_widget.external_editor_state() != ExternalEditorState::Closed + { + self.key_chord.clear(); + return Some(key_event); + } + + match self.key_chord.handle_key_event(key_event) { + KeyChordResolution::NoMatch => Some(key_event), + KeyChordResolution::AwaitingSecondKey | KeyChordResolution::Cancelled => None, + KeyChordResolution::Forward(forwarded_key_event) => Some(forwarded_key_event), + KeyChordResolution::Matched(action) => { + match action { + KeyChordAction::UndoLastUserMessage => { + self.undo_last_user_message(); + } + KeyChordAction::CopyLatestOutput => { + self.chat_widget.copy_latest_output_to_clipboard(); + } + KeyChordAction::RespawnCurrentSession => { + if self.chat_widget.can_run_respawn_now() { + self.app_event_tx + .send(AppEvent::Exit(ExitMode::RespawnImmediate)); + } + } + } + None + } + } + } + async fn handle_key_event( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, key_event: KeyEvent, ) { + let mut key_event = key_event; + if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + && key_event.code != KeyCode::Esc + && self.backtrack.primed + { + self.reset_backtrack_state(); + } + let Some(forwarded_key_event) = self.handle_key_chord_key_event(key_event) else { + return; + }; + key_event = forwarded_key_event; + // Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless // enhanced keyboard reporting is available. We only treat those word-motion fallbacks as // agent-switch shortcuts when the composer is empty so we never steal the expected @@ -6582,12 +6633,6 @@ impl App { kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => { - // Any non-Esc key press should cancel a primed backtrack. - // This avoids stale "Esc-primed" state after the user starts typing - // (even if they later backspace to empty). - if key_event.code != KeyCode::Esc && self.backtrack.primed { - self.reset_backtrack_state(); - } self.chat_widget.handle_key_event(key_event); } _ => { @@ -9714,6 +9759,7 @@ guardian_approval = true status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + key_chord: KeyChordState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, @@ -9771,6 +9817,7 @@ guardian_approval = true status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + key_chord: KeyChordState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, @@ -11197,6 +11244,155 @@ model = "gpt-5.2" })); } + #[tokio::test] + async fn undo_last_user_message_restores_latest_user_input_and_rolls_back_one_turn() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + + let remote_image_url = "https://example.com/latest.png".to_string(); + app.transcript_cells = vec![ + Arc::new(UserHistoryCell { + message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + Arc::new(UserHistoryCell { + message: "latest".to_string(), + text_elements: vec![TextElement::new( + codex_protocol::user_input::ByteRange { start: 0, end: 6 }, + Some("latest".to_string()), + )], + local_image_paths: Vec::new(), + remote_image_urls: vec![remote_image_url.clone()], + }) as Arc, + ]; + app.chat_widget + .set_composer_text("stale draft".to_string(), Vec::new(), Vec::new()); + + assert!(app.undo_last_user_message()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "latest"); + assert_eq!(app.chat_widget.remote_image_urls(), vec![remote_image_url]); + + let mut rollback_turns = None; + while let Ok(op) = op_rx.try_recv() { + if let Op::ThreadRollback { num_turns } = op { + rollback_turns = Some(num_turns); + } + } + + assert_eq!(rollback_turns, Some(1)); + } + + #[tokio::test] + async fn ctrl_x_ctrl_u_triggers_undo_last_user_message() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + + let thread_id = ThreadId::new(); + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }); + + app.transcript_cells = vec![ + Arc::new(UserHistoryCell { + message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + Arc::new(UserHistoryCell { + message: "latest".to_string(), + text_elements: vec![TextElement::new( + codex_protocol::user_input::ByteRange { start: 0, end: 6 }, + Some("latest".to_string()), + )], + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + ]; + + assert_eq!( + app.handle_key_chord_key_event(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::CONTROL, + )), + None + ); + assert_eq!( + app.handle_key_chord_key_event(KeyEvent::new( + KeyCode::Char('u'), + KeyModifiers::CONTROL, + )), + None + ); + + let mut rollback_turns = None; + while let Ok(op) = op_rx.try_recv() { + if let Op::ThreadRollback { num_turns } = op { + rollback_turns = Some(num_turns); + } + } + + assert_eq!(rollback_turns, Some(1)); + } + + #[tokio::test] + async fn ctrl_x_unknown_second_key_falls_through_to_composer_input() { + let mut app = make_test_app().await; + + assert_eq!( + app.handle_key_chord_key_event(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::CONTROL, + )), + None + ); + let forwarded = + app.handle_key_chord_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + assert_eq!( + forwarded, + Some(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)) + ); + } + + #[tokio::test] + async fn ctrl_x_ctrl_y_runs_copy_action_without_inserting_y_into_the_composer() { + let mut app = make_test_app().await; + + assert_eq!( + app.handle_key_chord_key_event(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::CONTROL, + )), + None + ); + assert_eq!( + app.handle_key_chord_key_event(KeyEvent::new( + KeyCode::Char('y'), + KeyModifiers::CONTROL, + )), + None + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + } + #[tokio::test] async fn replay_thread_snapshot_replays_turn_history_in_order() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/key_chord.rs b/codex-rs/tui/src/app/key_chord.rs new file mode 100644 index 000000000..9704121df --- /dev/null +++ b/codex-rs/tui/src/app/key_chord.rs @@ -0,0 +1,165 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum KeyChordState { + #[default] + Idle, + AwaitingCtrlXSecondKey, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum KeyChordAction { + UndoLastUserMessage, + CopyLatestOutput, + RespawnCurrentSession, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum KeyChordResolution { + NoMatch, + AwaitingSecondKey, + Matched(KeyChordAction), + Cancelled, + Forward(KeyEvent), +} + +impl KeyChordState { + pub(crate) fn clear(&mut self) { + *self = Self::Idle; + } + + pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyChordResolution { + match self { + Self::Idle => handle_idle_key_event(self, key_event), + Self::AwaitingCtrlXSecondKey => handle_ctrl_x_second_key(self, key_event), + } + } +} + +fn handle_idle_key_event(state: &mut KeyChordState, key_event: KeyEvent) -> KeyChordResolution { + if key_event.kind != KeyEventKind::Press { + return KeyChordResolution::NoMatch; + } + + if key_event.code == KeyCode::Char('x') && key_event.modifiers == KeyModifiers::CONTROL { + *state = KeyChordState::AwaitingCtrlXSecondKey; + KeyChordResolution::AwaitingSecondKey + } else { + KeyChordResolution::NoMatch + } +} + +fn handle_ctrl_x_second_key(state: &mut KeyChordState, key_event: KeyEvent) -> KeyChordResolution { + if key_event.kind != KeyEventKind::Press { + return KeyChordResolution::AwaitingSecondKey; + } + + let resolution = match (key_event.code, key_event.modifiers) { + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + KeyChordResolution::Matched(KeyChordAction::UndoLastUserMessage) + } + (KeyCode::Char('y'), KeyModifiers::CONTROL) => { + KeyChordResolution::Matched(KeyChordAction::CopyLatestOutput) + } + (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + KeyChordResolution::Matched(KeyChordAction::RespawnCurrentSession) + } + (KeyCode::Char('x'), KeyModifiers::CONTROL) => KeyChordResolution::AwaitingSecondKey, + (KeyCode::Esc, _) => KeyChordResolution::Cancelled, + _ => KeyChordResolution::Forward(key_event), + }; + + if !matches!(resolution, KeyChordResolution::AwaitingSecondKey) { + *state = KeyChordState::Idle; + } + + resolution +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn ctrl_x_ctrl_u_matches_undo_last_user_message() { + let mut state = KeyChordState::default(); + + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)), + KeyChordResolution::Matched(KeyChordAction::UndoLastUserMessage) + ); + assert_eq!(state, KeyChordState::Idle); + } + + #[test] + fn ctrl_x_ctrl_y_matches_copy_latest_output() { + let mut state = KeyChordState::default(); + + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)), + KeyChordResolution::Matched(KeyChordAction::CopyLatestOutput) + ); + assert_eq!(state, KeyChordState::Idle); + } + + #[test] + fn ctrl_x_ctrl_r_matches_respawn_current_session() { + let mut state = KeyChordState::default(); + + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)), + KeyChordResolution::Matched(KeyChordAction::RespawnCurrentSession) + ); + assert_eq!(state, KeyChordState::Idle); + } + + #[test] + fn ctrl_x_unknown_second_key_is_forwarded_and_clears_state() { + let mut state = KeyChordState::default(); + + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)), + KeyChordResolution::Forward(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)) + ); + assert_eq!(state, KeyChordState::Idle); + } + + #[test] + fn ctrl_x_release_keeps_waiting_for_second_key() { + let mut state = KeyChordState::default(); + + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!( + state.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Char('x'), + KeyModifiers::CONTROL, + KeyEventKind::Release, + )), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!(state, KeyChordState::AwaitingCtrlXSecondKey); + } +} diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index db1149e76..d2bac10b9 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -459,6 +459,25 @@ impl App { tui.frame_requester().schedule_frame(); } + pub(crate) fn undo_last_user_message(&mut self) -> bool { + self.reset_backtrack_state(); + + let Some(nth_user_message) = user_count(&self.transcript_cells).checked_sub(1) else { + self.chat_widget + .add_error_message("No prior user message to restore.".to_string()); + return false; + }; + + let Some(selection) = self.backtrack_selection(nth_user_message) else { + self.chat_widget + .add_error_message("Failed to restore the last user message.".to_string()); + return false; + }; + + self.apply_backtrack_rollback(selection); + true + } + pub(crate) fn handle_backtrack_rollback_succeeded(&mut self, num_turns: u32) { if self.backtrack.pending_rollback.is_some() { self.finish_pending_backtrack(); @@ -507,8 +526,9 @@ impl App { } fn backtrack_selection(&self, nth_user_message: usize) -> Option { - let base_id = self.backtrack.base_id?; - if self.chat_widget.thread_id() != Some(base_id) { + if let Some(base_id) = self.backtrack.base_id + && self.chat_widget.thread_id() != Some(base_id) + { return None; } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 194f662b3..875bf1bc5 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -756,6 +756,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut paste_image = Line::from(""); let mut external_editor = Line::from(""); let mut edit_previous = Line::from(""); + let mut undo_last_message = Line::from(""); + let mut copy_latest_output = Line::from(""); + let mut respawn_current_session = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); let mut change_mode = Line::from(""); @@ -771,6 +774,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::PasteImage => paste_image = text, ShortcutId::ExternalEditor => external_editor = text, ShortcutId::EditPrevious => edit_previous = text, + ShortcutId::UndoLastMessage => undo_last_message = text, + ShortcutId::CopyLatestOutput => copy_latest_output = text, + ShortcutId::RespawnCurrentSession => respawn_current_session = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, ShortcutId::ChangeMode => change_mode = text, @@ -787,6 +793,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { paste_image, external_editor, edit_previous, + undo_last_message, + copy_latest_output, + respawn_current_session, quit, ]; if change_mode.width() > 0 { @@ -869,6 +878,9 @@ enum ShortcutId { PasteImage, ExternalEditor, EditPrevious, + UndoLastMessage, + CopyLatestOutput, + RespawnCurrentSession, Quit, ShowTranscript, ChangeMode, @@ -911,6 +923,7 @@ struct ShortcutDescriptor { id: ShortcutId, bindings: &'static [ShortcutBinding], prefix: &'static str, + display_label: Option<&'static str>, label: &'static str, } @@ -921,7 +934,12 @@ impl ShortcutDescriptor { fn overlay_entry(&self, state: ShortcutsState) -> Option> { let binding = self.binding_for(state)?; - let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]); + let mut line = Line::from(vec![self.prefix.into()]); + if let Some(display_label) = self.display_label { + line.push_span(display_label); + } else { + line.push_span(Span::from(binding.key)); + } match self.id { ShortcutId::EditPrevious => { if state.esc_backtrack_hint { @@ -948,6 +966,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " for commands", }, ShortcutDescriptor { @@ -957,6 +976,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " for shell commands", }, ShortcutDescriptor { @@ -972,6 +992,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ }, ], prefix: "", + display_label: None, label: " for newline", }, ShortcutDescriptor { @@ -981,6 +1002,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to queue message", }, ShortcutDescriptor { @@ -990,6 +1012,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " for file paths", }, ShortcutDescriptor { @@ -1007,6 +1030,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ }, ], prefix: "", + display_label: None, label: " to paste images", }, ShortcutDescriptor { @@ -1016,6 +1040,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to edit in external editor", }, ShortcutDescriptor { @@ -1025,8 +1050,39 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: "", }, + ShortcutDescriptor { + id: ShortcutId::UndoLastMessage, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('x')), + condition: DisplayCondition::Always, + }], + prefix: "", + display_label: Some("ctrl + x then ctrl + u"), + label: " to undo last message", + }, + ShortcutDescriptor { + id: ShortcutId::CopyLatestOutput, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('x')), + condition: DisplayCondition::Always, + }], + prefix: "", + display_label: Some("ctrl + x then ctrl + y"), + label: " to copy last output", + }, + ShortcutDescriptor { + id: ShortcutId::RespawnCurrentSession, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('x')), + condition: DisplayCondition::Always, + }], + prefix: "", + display_label: Some("ctrl + x then ctrl + r"), + label: " to restart Codex", + }, ShortcutDescriptor { id: ShortcutId::Quit, bindings: &[ShortcutBinding { @@ -1034,6 +1090,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to exit", }, ShortcutDescriptor { @@ -1043,6 +1100,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to view transcript", }, ShortcutDescriptor { @@ -1052,6 +1110,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::WhenCollaborationModesEnabled, }], prefix: "", + display_label: None, label: " to change mode", }, ]; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 8486a9ec6..cf6256e02 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -10,9 +10,10 @@ expression: terminal.backend() " " " " " " -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + x then ctrl + u to undo last message ctrl + x then ctrl + y to copy last output " +" ctrl + x then ctrl + r to restart Codex ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap index 1bb213bbe..4d0bd340b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -2,9 +2,11 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands ! for shell commands " -" ctrl + j for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc esc to edit previous message " -" ctrl + c to exit shift + tab to change mode " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" ctrl + j for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc esc to edit previous me" +" ctrl + x then ctrl + u to undo last message ctrl + x then ctrl + y to c" +" ctrl + x then ctrl + r to restart Codex ctrl + c to exit " +" shift + tab to change mode " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index c1f00d443..5e0d28f23 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,9 +2,10 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous " +" ctrl + x then ctrl + u to undo last message ctrl + x then ctrl + y to c" +" ctrl + x then ctrl + r to restart Codex ctrl + c to exit " +" ctrl + t to view transcript" diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0c77b0578..af0f708c9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5041,6 +5041,39 @@ impl ChatWidget { false } + pub(crate) fn can_run_respawn_now(&mut self) -> bool { + if !self.bottom_pane.is_task_running() { + return true; + } + + let message = "Ctrl-X Ctrl-R is disabled while a task is in progress.".to_string(); + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + false + } + + pub(crate) fn copy_latest_output_to_clipboard(&mut self) { + let Some(text) = self.last_copyable_output.as_deref() else { + self.add_info_message( + "`/copy` is unavailable before the first Codex output or right after a rollback." + .to_string(), + /*hint*/ None, + ); + return; + }; + + match clipboard_text::copy_text_to_clipboard(text) { + Ok(()) => { + let hint = self.agent_turn_running.then_some( + "Current turn is still running; copied the latest completed output (not the in-progress response)." + .to_string(), + ); + self.add_info_message("Copied latest Codex output to clipboard.".to_string(), hint); + } + Err(err) => self.add_error_message(format!("Failed to copy to clipboard: {err}")), + } + } + fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( @@ -5270,32 +5303,7 @@ impl ChatWidget { }); } SlashCommand::Copy => { - let Some(text) = self.last_copyable_output.as_deref() else { - self.add_info_message( - "`/copy` is unavailable before the first Codex output or right after a rollback." - .to_string(), - /*hint*/ None, - ); - return; - }; - - let copy_result = clipboard_text::copy_text_to_clipboard(text); - - match copy_result { - Ok(()) => { - let hint = self.agent_turn_running.then_some( - "Current turn is still running; copied the latest completed output (not the in-progress response)." - .to_string(), - ); - self.add_info_message( - "Copied latest Codex output to clipboard.".to_string(), - hint, - ); - } - Err(err) => { - self.add_error_message(format!("Failed to copy to clipboard: {err}")) - } - } + self.copy_latest_output_to_clipboard(); } SlashCommand::Mention => { self.insert_str("@"); From 0f571b53c0cc695cd3cf3e8be282bd7cb3c9c186 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 10:15:11 +0800 Subject: [PATCH 26/60] replay: restore ui show-hidden on new tui --- codex-rs/tui/src/app.rs | 59 ++++++++ codex-rs/tui/src/app_event.rs | 7 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 1 - codex-rs/tui/src/bottom_pane/command_popup.rs | 10 +- codex-rs/tui/src/bottom_pane/mod.rs | 2 + .../tui/src/bottom_pane/slash_commands.rs | 20 +-- codex-rs/tui/src/chatwidget.rs | 143 +++++++++++++----- ...tests__realtime_audio_selection_popup.snap | 7 +- ...realtime_audio_selection_popup_narrow.snap | 8 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 4 + .../src/chatwidget/tests/history_replay.rs | 17 ++- .../tui/src/chatwidget/tests/plan_mode.rs | 1 + .../chatwidget/tests/popups_and_settings.rs | 5 +- .../src/chatwidget/tests/status_and_layout.rs | 1 + codex-rs/tui/src/display_preferences.rs | 66 ++++++++ codex-rs/tui/src/display_preferences_menu.rs | 114 ++++++++++++++ codex-rs/tui/src/history_cell.rs | 75 +++++++++ codex-rs/tui/src/lib.rs | 2 + codex-rs/tui/src/slash_command.rs | 2 +- ...menu__tests__display_preferences_menu.snap | 15 ++ 20 files changed, 484 insertions(+), 75 deletions(-) create mode 100644 codex-rs/tui/src/display_preferences.rs create mode 100644 codex-rs/tui/src/display_preferences_menu.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2035c8591..e87cd9b30 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -25,6 +25,11 @@ use crate::chatwidget::ReplayKind; use crate::chatwidget::ThreadInputState; use crate::cwd_prompt::CwdPromptAction; use crate::diff_render::DiffSummary; +use crate::display_preferences::DisplayPreferences; +use crate::display_preferences::display_preference_edit; +use crate::display_preferences::set_display_preference_in_config; +use crate::display_preferences_menu::DISPLAY_PREFERENCES_SELECTION_VIEW_ID; +use crate::display_preferences_menu::display_preferences_panel_params; use crate::exec_command::split_command_string; use crate::exec_command::strip_bash_lc_and_escape; use crate::external_editor; @@ -1045,6 +1050,7 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, key_chord: KeyChordState, + display_preferences: DisplayPreferences, /// When set, the next draw re-renders the transcript into terminal scrollback once. /// /// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed @@ -1160,6 +1166,7 @@ impl App { ) -> crate::chatwidget::ChatWidgetInit { crate::chatwidget::ChatWidgetInit { config: cfg, + display_preferences: self.display_preferences.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), // Fork/resume bootstraps here don't carry any prefilled message content. @@ -1199,6 +1206,7 @@ impl App { self.apply_runtime_policy_overrides(&mut config); self.active_profile = config.active_profile.clone(); self.config = config; + self.display_preferences.sync_from_config(&self.config); self.chat_widget.sync_plugin_mentions_config(&self.config); Ok(()) } @@ -3418,6 +3426,22 @@ impl App { }); } + fn open_display_preferences_panel(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(DISPLAY_PREFERENCES_SELECTION_VIEW_ID); + if !self.chat_widget.replace_selection_view_if_active( + DISPLAY_PREFERENCES_SELECTION_VIEW_ID, + display_preferences_panel_params(&self.display_preferences, initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(display_preferences_panel_params( + &self.display_preferences, + initial_selected_idx, + )); + } + } + fn is_terminal_thread_read_error(err: &color_eyre::Report) -> bool { err.chain() .any(|cause| cause.to_string().contains("thread not loaded:")) @@ -4185,6 +4209,7 @@ impl App { let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false)); + let display_preferences = DisplayPreferences::from_config(&config); let enhanced_keys_supported = tui.enhanced_keys_supported(); let wait_for_initial_session_configured = @@ -4197,6 +4222,7 @@ impl App { .await; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), + display_preferences: display_preferences.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), initial_user_message: crate::chatwidget::create_initial_user_message( @@ -4231,6 +4257,7 @@ impl App { })?; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), + display_preferences: display_preferences.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), initial_user_message: crate::chatwidget::create_initial_user_message( @@ -4270,6 +4297,7 @@ impl App { })?; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), + display_preferences: display_preferences.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), initial_user_message: crate::chatwidget::create_initial_user_message( @@ -4312,6 +4340,7 @@ impl App { app_event_tx, chat_widget, config, + display_preferences, active_profile, cli_kv_overrides, harness_overrides, @@ -4705,6 +4734,9 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } + AppEvent::OpenDisplayPreferencesPanel => { + self.open_display_preferences_panel(); + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", @@ -5745,6 +5777,27 @@ impl App { AppEvent::UpdateFeatureFlags { updates } => { self.update_feature_flags(updates).await; } + AppEvent::ToggleDisplayPreference(key) => { + let enabled = !self.display_preferences.is_enabled(key); + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(self.active_profile.as_deref()) + .with_edits([display_preference_edit(key, enabled)]) + .apply() + .await + { + tracing::error!( + error = %err, + ?key, + "failed to persist display preference update" + ); + self.chat_widget + .add_error_message(format!("Failed to save UI preference: {err}")); + } else { + self.display_preferences.set_enabled(key, enabled); + set_display_preference_in_config(&mut self.config, key, enabled); + self.open_display_preferences_panel(); + } + } AppEvent::SkipNextWorldWritableScan => { self.windows_sandbox.skip_world_writable_scan_once = true; } @@ -7219,6 +7272,7 @@ mod tests { let model = codex_core::test_support::get_model_offline(config.model.as_deref()); app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit { config, + display_preferences: app.display_preferences.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), initial_user_message: create_initial_user_message( @@ -9734,6 +9788,7 @@ guardian_approval = true async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); + let display_preferences = DisplayPreferences::from_config(&config); let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -9744,6 +9799,7 @@ guardian_approval = true app_event_tx, chat_widget, config, + display_preferences, active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), @@ -9791,6 +9847,7 @@ guardian_approval = true ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); + let display_preferences = DisplayPreferences::from_config(&config); let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -9802,6 +9859,7 @@ guardian_approval = true app_event_tx, chat_widget, config, + display_preferences, active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), @@ -11603,6 +11661,7 @@ model = "gpt-5.2" let replacement = ChatWidget::new_with_app_event(ChatWidgetInit { config: app.config.clone(), + display_preferences: app.display_preferences.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), initial_user_message: None, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index af7308b23..83abadc53 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -31,6 +31,7 @@ use crate::app::workflow_runtime::BackgroundWorkflowRunResult; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; +use crate::display_preferences::DisplayPreferenceKey; use crate::history_cell::HistoryCell; use crate::profile_router::ProfileFallbackAction; @@ -108,6 +109,9 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, + /// Open the local TUI display preferences panel. + OpenDisplayPreferencesPanel, + /// Fork the current session into a new thread. ForkCurrentSession, @@ -464,6 +468,9 @@ pub(crate) enum AppEvent { updates: Vec<(Feature, bool)>, }, + /// Toggle one local TUI display preference and persist the updated config. + ToggleDisplayPreference(DisplayPreferenceKey), + /// Update whether the full access warning prompt has been acknowledged. UpdateFullAccessWarningAcknowledged(bool), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6cf6c18d3..f11d3da89 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -371,7 +371,6 @@ impl ChatComposer { fast_command_enabled: self.fast_command_enabled, personality_command_enabled: self.personality_command_enabled, realtime_conversation_enabled: self.realtime_conversation_enabled, - audio_device_selection_enabled: self.audio_device_selection_enabled, allow_elevate_sandbox: self.windows_degraded_sandbox_active, } } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 8bef8ddbc..eeb374a54 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -36,6 +36,7 @@ pub(crate) struct CommandPopupFlags { pub(crate) fast_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, + #[allow(dead_code)] pub(crate) audio_device_selection_enabled: bool, pub(crate) windows_degraded_sandbox_active: bool, } @@ -49,7 +50,6 @@ impl From for slash_commands::BuiltinCommandFlags { fast_command_enabled: value.fast_command_enabled, personality_command_enabled: value.personality_command_enabled, realtime_conversation_enabled: value.realtime_conversation_enabled, - audio_device_selection_enabled: value.audio_device_selection_enabled, allow_elevate_sandbox: value.windows_degraded_sandbox_active, } } @@ -436,7 +436,7 @@ mod tests { } #[test] - fn settings_command_hidden_when_audio_device_selection_is_disabled() { + fn settings_command_remains_visible_when_audio_device_selection_is_disabled() { let mut popup = CommandPopup::new(CommandPopupFlags { collaboration_modes_enabled: false, connectors_enabled: false, @@ -447,7 +447,7 @@ mod tests { audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, }); - popup.on_composer_text_change("/aud".to_string()); + popup.on_composer_text_change("/set".to_string()); let cmds: Vec<&str> = popup .filtered_items() @@ -458,8 +458,8 @@ mod tests { .collect(); assert!( - !cmds.contains(&"settings"), - "expected '/settings' to be hidden when audio device selection is disabled, got {cmds:?}" + cmds.contains(&"settings"), + "expected '/settings' to stay visible when audio device selection is disabled, got {cmds:?}" ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 9bde1a842..2750e927d 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -151,6 +151,8 @@ use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; pub(crate) use experimental_features_view::ExperimentalFeatureItem; pub(crate) use experimental_features_view::ExperimentalFeaturesView; +#[cfg(test)] +pub(crate) use list_selection_view::ListSelectionView; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 54b1a8cf4..90845bc8b 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -18,7 +18,6 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) fast_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, - pub(crate) audio_device_selection_enabled: bool, pub(crate) allow_elevate_sandbox: bool, } @@ -36,7 +35,6 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast) .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) - .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) .collect() } @@ -69,7 +67,6 @@ mod tests { fast_command_enabled: true, personality_command_enabled: true, realtime_conversation_enabled: true, - audio_device_selection_enabled: true, allow_elevate_sandbox: true, } } @@ -119,17 +116,20 @@ mod tests { } #[test] - fn settings_command_is_hidden_when_realtime_is_disabled() { + fn settings_command_stays_available_when_realtime_is_disabled() { let mut flags = all_enabled_flags(); flags.realtime_conversation_enabled = false; - flags.audio_device_selection_enabled = false; - assert_eq!(find_builtin_command("settings", flags), None); + assert_eq!( + find_builtin_command("settings", flags), + Some(SlashCommand::Settings) + ); } #[test] - fn settings_command_is_hidden_when_audio_device_selection_is_disabled() { - let mut flags = all_enabled_flags(); - flags.audio_device_selection_enabled = false; - assert_eq!(find_builtin_command("settings", flags), None); + fn settings_command_stays_available_when_audio_device_selection_is_disabled() { + assert_eq!( + find_builtin_command("settings", all_enabled_flags()), + Some(SlashCommand::Settings) + ); } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index af0f708c9..c1c601287 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -323,6 +323,7 @@ use crate::clipboard_paste::paste_image_to_temp_png; use crate::clipboard_text; use crate::collaboration_modes; use crate::diff_render::display_path_for; +use crate::display_preferences::DisplayPreferences; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; use crate::exec_cell::new_active_exec_command; @@ -552,6 +553,7 @@ pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { /// Common initialization parameters shared by all `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { pub(crate) config: Config, + pub(crate) display_preferences: DisplayPreferences, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, pub(crate) initial_user_message: Option, @@ -835,10 +837,15 @@ pub(crate) struct ChatWidget { plugin_install_auth_flow: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, + display_preferences: DisplayPreferences, // Accumulates the current reasoning block text to extract a header reasoning_buffer: String, // Accumulates full reasoning content for transcript-only recording full_reasoning_buffer: String, + // Accumulates the current raw reasoning block for TUI-only visibility toggles. + raw_reasoning_buffer: String, + // Accumulates the full raw reasoning content for transcript-only recording. + full_raw_reasoning_buffer: String, // The currently rendered footer state. We keep the already-formatted // details here so transient stream interruptions can restore the footer // exactly as it was shown. @@ -2267,6 +2274,8 @@ impl ChatWidget { fn on_agent_reasoning_final(&mut self) { // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + self.full_raw_reasoning_buffer + .push_str(&self.raw_reasoning_buffer); if !self.full_reasoning_buffer.is_empty() { let cell = history_cell::new_reasoning_summary_block( self.full_reasoning_buffer.clone(), @@ -2274,8 +2283,18 @@ impl ChatWidget { ); self.add_boxed_history(cell); } + if !self.full_raw_reasoning_buffer.is_empty() { + let cell = history_cell::new_reasoning_raw_block( + self.full_raw_reasoning_buffer.clone(), + &self.config.cwd, + self.display_preferences.clone(), + ); + self.add_boxed_history(cell); + } self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); + self.raw_reasoning_buffer.clear(); + self.full_raw_reasoning_buffer.clear(); self.request_redraw(); } @@ -2284,9 +2303,15 @@ impl ChatWidget { self.full_reasoning_buffer.push_str(&self.reasoning_buffer); self.full_reasoning_buffer.push_str("\n\n"); self.reasoning_buffer.clear(); + self.full_raw_reasoning_buffer + .push_str(&self.raw_reasoning_buffer); + self.full_raw_reasoning_buffer.push_str("\n\n"); + self.raw_reasoning_buffer.clear(); } - // Raw reasoning uses the same flow as summarized reasoning + fn on_raw_reasoning_delta(&mut self, delta: String) { + self.raw_reasoning_buffer.push_str(&delta); + } fn on_task_started(&mut self) { self.agent_turn_running = true; @@ -2314,6 +2339,8 @@ impl ChatWidget { self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); + self.full_raw_reasoning_buffer.clear(); + self.raw_reasoning_buffer.clear(); self.request_redraw(); } @@ -4598,6 +4625,7 @@ impl ChatWidget { fn new_with_op_target(common: ChatWidgetInit, codex_op_target: CodexOpTarget) -> Self { let ChatWidgetInit { config, + display_preferences, frame_requester, app_event_tx, initial_user_message, @@ -4712,8 +4740,11 @@ impl ChatWidget { plugin_install_apps_needing_auth: Vec::new(), plugin_install_auth_flow: None, interrupts: InterruptManager::new(), + display_preferences, reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + raw_reasoning_buffer: String::new(), + full_raw_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), terminal_title_status_kind: TerminalTitleStatusKind::Working, @@ -4936,6 +4967,8 @@ impl ChatWidget { // Reset any reasoning header only when we are actually submitting a turn. self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); + self.raw_reasoning_buffer.clear(); + self.full_raw_reasoning_buffer.clear(); self.set_status_header(String::from("Working")); self.submit_user_message(user_message); } else { @@ -5020,6 +5053,24 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn replace_selection_view_if_active( + &mut self, + view_id: &'static str, + params: SelectionViewParams, + ) -> bool { + let replaced = self + .bottom_pane + .replace_selection_view_if_active(view_id, params); + if replaced { + self.request_redraw(); + } + replaced + } + + pub(crate) fn selected_index_for_active_view(&self, view_id: &'static str) -> Option { + self.bottom_pane.selected_index_for_active_view(view_id) + } + pub(crate) fn no_modal_or_popup_active(&self) -> bool { self.bottom_pane.no_modal_or_popup_active() } @@ -5168,10 +5219,7 @@ impl ChatWidget { } } SlashCommand::Settings => { - if !self.realtime_audio_device_selection_enabled() { - return; - } - self.open_realtime_audio_popup(); + self.open_settings_popup(); } SlashCommand::Personality => { self.open_personality_popup(); @@ -5499,6 +5547,8 @@ impl ChatWidget { if self.is_session_configured() { self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); + self.raw_reasoning_buffer.clear(); + self.full_raw_reasoning_buffer.clear(); self.set_status_header(String::from("Working")); self.submit_user_message(user_message); } else { @@ -6094,10 +6144,8 @@ impl ChatWidget { for delta in summary { self.on_agent_reasoning_delta(delta); } - if self.config.show_raw_agent_reasoning { - for delta in content { - self.on_agent_reasoning_delta(delta); - } + for delta in content { + self.on_raw_reasoning_delta(delta); } } self.on_agent_reasoning_final(); @@ -6413,9 +6461,7 @@ impl ChatWidget { self.on_agent_reasoning_delta(notification.delta); } ServerNotification::ReasoningTextDelta(notification) => { - if self.config.show_raw_agent_reasoning { - self.on_agent_reasoning_delta(notification.delta); - } + self.on_raw_reasoning_delta(notification.delta); } ServerNotification::ReasoningSummaryPartAdded(_) => self.on_reasoning_section_break(), ServerNotification::TerminalInteraction(notification) => { @@ -6904,13 +6950,15 @@ impl ChatWidget { self.on_agent_message_delta(delta) } EventMsg::PlanDelta(event) => self.on_plan_delta(event.delta), - EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) - | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { + EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { + self.on_agent_reasoning_delta(delta) + } + EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta, - }) => self.on_agent_reasoning_delta(delta), + }) => self.on_raw_reasoning_delta(delta), EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { - self.on_agent_reasoning_delta(text); + self.on_raw_reasoning_delta(text); self.on_agent_reasoning_final(); } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), @@ -7858,33 +7906,48 @@ impl ChatWidget { }); } - pub(crate) fn open_realtime_audio_popup(&mut self) { - let items = [ - RealtimeAudioDeviceKind::Microphone, - RealtimeAudioDeviceKind::Speaker, - ] - .into_iter() - .map(|kind| { - let description = Some(format!( - "Current: {}", - self.current_realtime_audio_selection_label(kind) - )); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind }); - })]; - SelectionItem { - name: kind.title().to_string(), - description, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); + pub(crate) fn open_settings_popup(&mut self) { + let mut items = vec![SelectionItem { + name: "UI".to_string(), + description: Some( + "Configure local TUI-only transcript visibility for raw reasoning.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenDisplayPreferencesPanel) + })], + dismiss_on_select: true, + ..Default::default() + }]; + + if self.realtime_audio_device_selection_enabled() { + items.extend( + [ + RealtimeAudioDeviceKind::Microphone, + RealtimeAudioDeviceKind::Speaker, + ] + .into_iter() + .map(|kind| { + let description = Some(format!( + "Current: {}", + self.current_realtime_audio_selection_label(kind) + )); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind }); + })]; + SelectionItem { + name: kind.title().to_string(), + description, + actions, + dismiss_on_select: true, + ..Default::default() + } + }), + ); + } self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Settings".to_string()), - subtitle: Some("Configure settings for Codex.".to_string()), + subtitle: Some("Configure UI and realtime settings for Codex.".to_string()), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap index 8c60f961f..6dde00449 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap @@ -1,11 +1,10 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Settings - Configure settings for Codex. + Configure UI and realtime settings for Codex. -› 1. Microphone Current: System default - 2. Speaker Current: System default +› 1. UI Configure local TUI-only transcript visibility for raw reasoning. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap index 8c60f961f..db286e7b1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap @@ -1,11 +1,11 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Settings - Configure settings for Codex. + Configure UI and realtime settings for Codex. -› 1. Microphone Current: System default - 2. Speaker Current: System default +› 1. UI Configure local TUI-only transcript + visibility for raw reasoning. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index dff089711..81a8edea8 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -179,6 +179,7 @@ pub(super) async fn make_chatwidget_manual( }; let current_collaboration_mode = base_mode; let active_collaboration_mask = collaboration_modes::default_mask(model_catalog.as_ref()); + let display_preferences = crate::display_preferences::DisplayPreferences::from_config(&cfg); let mut widget = ChatWidget { app_event_tx, codex_op_target: super::CodexOpTarget::Direct(op_tx), @@ -237,8 +238,11 @@ pub(super) async fn make_chatwidget_manual( plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), + display_preferences, reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + raw_reasoning_buffer: String::new(), + full_raw_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), retry_status_header: None, pending_status_indicator_restore: false, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e..4eb662d97 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -583,7 +583,6 @@ async fn replayed_thread_closed_notification_does_not_exit_tui() { #[tokio::test] async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.show_raw_agent_reasoning = false; chat.handle_codex_event(Event { id: "configured".into(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -630,7 +629,10 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { #[tokio::test] async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.show_raw_agent_reasoning = true; + chat.display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::RawThinking, + true, + ); chat.handle_codex_event(Event { id: "configured".into(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -664,12 +666,11 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { ReplayKind::ThreadSnapshot, ); - let rendered = match rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => { - lines_to_single_string(&cell.transcript_lines(/*width*/ 80)) - } - other => panic!("expected InsertHistoryCell, got {other:?}"), - }; + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::>() + .join("\n\n"); assert!(rendered.contains("Raw reasoning")); } diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d..d635c66e2 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1219,6 +1219,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() { let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), + display_preferences: crate::display_preferences::DisplayPreferences::from_config(&cfg), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), initial_user_message: None, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index f89926785..a41459211 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -62,6 +62,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() { let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), + display_preferences: crate::display_preferences::DisplayPreferences::from_config(&cfg), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), initial_user_message: None, @@ -1525,7 +1526,7 @@ async fn personality_selection_popup_snapshot() { #[tokio::test] async fn realtime_audio_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; - chat.open_realtime_audio_popup(); + chat.open_settings_popup(); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("realtime_audio_selection_popup", popup); @@ -1535,7 +1536,7 @@ async fn realtime_audio_selection_popup_snapshot() { #[tokio::test] async fn realtime_audio_selection_popup_narrow_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; - chat.open_realtime_audio_popup(); + chat.open_settings_popup(); let popup = render_bottom_popup(&chat, /*width*/ 56); assert_chatwidget_snapshot!("realtime_audio_selection_popup_narrow", popup); diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba8..784154323 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -120,6 +120,7 @@ async fn helpers_are_available_and_do_not_panic() { let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), + display_preferences: crate::display_preferences::DisplayPreferences::from_config(&cfg), frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, initial_user_message: None, diff --git a/codex-rs/tui/src/display_preferences.rs b/codex-rs/tui/src/display_preferences.rs new file mode 100644 index 000000000..502ca50d4 --- /dev/null +++ b/codex-rs/tui/src/display_preferences.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +use codex_core::config::Config; +use codex_core::config::edit::ConfigEdit; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DisplayPreferenceKey { + RawThinking, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct DisplayPreferences { + show_raw_thinking: Arc, +} + +impl DisplayPreferences { + pub(crate) fn from_config(config: &Config) -> Self { + let preferences = Self::default(); + preferences.sync_from_config(config); + preferences + } + + pub(crate) fn is_enabled(&self, key: DisplayPreferenceKey) -> bool { + match key { + DisplayPreferenceKey::RawThinking => self.show_raw_thinking(), + } + } + + pub(crate) fn set_enabled(&self, key: DisplayPreferenceKey, enabled: bool) { + match key { + DisplayPreferenceKey::RawThinking => { + self.show_raw_thinking.store(enabled, Ordering::Relaxed); + } + } + } + + pub(crate) fn show_raw_thinking(&self) -> bool { + self.show_raw_thinking.load(Ordering::Relaxed) + } + + pub(crate) fn sync_from_config(&self, config: &Config) { + self.show_raw_thinking + .store(config.show_raw_agent_reasoning, Ordering::Relaxed); + } +} + +pub(crate) fn display_preference_edit(key: DisplayPreferenceKey, enabled: bool) -> ConfigEdit { + match key { + DisplayPreferenceKey::RawThinking => ConfigEdit::SetPath { + segments: vec!["show_raw_agent_reasoning".to_string()], + value: enabled.into(), + }, + } +} + +pub(crate) fn set_display_preference_in_config( + config: &mut Config, + key: DisplayPreferenceKey, + enabled: bool, +) { + match key { + DisplayPreferenceKey::RawThinking => config.show_raw_agent_reasoning = enabled, + } +} diff --git a/codex-rs/tui/src/display_preferences_menu.rs b/codex-rs/tui/src/display_preferences_menu.rs new file mode 100644 index 000000000..c3dc83d4e --- /dev/null +++ b/codex-rs/tui/src/display_preferences_menu.rs @@ -0,0 +1,114 @@ +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::display_preferences::DisplayPreferenceKey; +use crate::display_preferences::DisplayPreferences; +use ratatui::style::Stylize; + +pub(crate) const DISPLAY_PREFERENCES_SELECTION_VIEW_ID: &str = "display-preferences-panel"; + +pub(crate) fn display_preferences_items( + display_preferences: &DisplayPreferences, +) -> Vec { + [DisplayPreferenceKey::RawThinking] + .into_iter() + .map(|key| display_preference_item(display_preferences, key)) + .collect() +} + +pub(crate) fn display_preferences_panel_params( + display_preferences: &DisplayPreferences, + initial_selected_idx: Option, +) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(DISPLAY_PREFERENCES_SELECTION_VIEW_ID), + title: Some("UI".to_string()), + subtitle: Some("These settings only affect local TUI rendering.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + footer_note: Some( + "Model context and persisted rollout history are unchanged." + .dim() + .into(), + ), + items: display_preferences_items(display_preferences), + initial_selected_idx, + ..Default::default() + } +} + +fn display_preference_item( + display_preferences: &DisplayPreferences, + key: DisplayPreferenceKey, +) -> SelectionItem { + let enabled = display_preferences.is_enabled(key); + let (name, description) = match (key, enabled) { + (DisplayPreferenceKey::RawThinking, true) => ( + "Hide Raw Thinking", + "Currently visible. Hide raw reasoning text while keeping summaries.", + ), + (DisplayPreferenceKey::RawThinking, false) => ( + "Show Raw Thinking", + "Currently hidden. Reveal raw reasoning text in this TUI only.", + ), + }; + + SelectionItem { + name: name.to_string(), + description: Some(description.to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ToggleDisplayPreference(key)); + })], + dismiss_on_select: false, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::ListSelectionView; + use crate::render::renderable::Renderable; + + fn render_lines(view: &ListSelectionView) -> String { + let width = 48; + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line + }) + .collect::>() + .join("\n") + } + + #[test] + fn display_preferences_menu_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + display_preferences_panel_params(&DisplayPreferences::default(), None), + tx, + ); + + assert_snapshot!("display_preferences_menu", render_lines(&view)); + } +} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index f7602cc46..278d8ea4f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -12,6 +12,7 @@ use crate::diff_render::create_diff_summary; use crate::diff_render::display_path_for; +use crate::display_preferences::DisplayPreferences; use crate::exec_cell::CommandOutput; use crate::exec_cell::OutputLinesParams; use crate::exec_cell::TOOL_CALL_MAX_LINES; @@ -446,6 +447,36 @@ impl HistoryCell for ReasoningSummaryCell { } } +#[derive(Debug)] +pub(crate) struct ReasoningRawContentCell { + content: String, + cwd: PathBuf, + display_preferences: DisplayPreferences, +} + +impl HistoryCell for ReasoningRawContentCell { + fn display_lines(&self, width: u16) -> Vec> { + if !self.display_preferences.show_raw_thinking() { + return Vec::new(); + } + + let mut lines = vec![vec!["• ".dim(), "Raw Thinking".dim().italic()].into()]; + let mut body = Vec::new(); + append_markdown( + &self.content, + Some((width as usize).saturating_sub(4)), + Some(self.cwd.as_path()), + &mut body, + ); + lines.extend(prefix_lines(body, " ".into(), " ".into())); + lines + } + + fn transcript_lines(&self, width: u16) -> Vec> { + self.display_lines(width) + } +} + #[derive(Debug)] pub(crate) struct AgentMessageCell { lines: Vec>, @@ -2675,6 +2706,18 @@ pub(crate) fn new_reasoning_summary_block( )) } +pub(crate) fn new_reasoning_raw_block( + full_raw_reasoning_buffer: String, + cwd: &Path, + display_preferences: DisplayPreferences, +) -> Box { + Box::new(ReasoningRawContentCell { + content: full_raw_reasoning_buffer.trim().to_string(), + cwd: cwd.to_path_buf(), + display_preferences, + }) +} + #[derive(Debug)] /// A visual divider between turns, optionally showing how long the assistant "worked for". /// @@ -4761,6 +4804,38 @@ mod tests { assert_eq!(rendered_transcript, vec!["• We should fix the bug next."]); } + #[test] + fn reasoning_raw_block_is_hidden_when_display_preference_is_disabled() { + let cell = new_reasoning_raw_block( + "secret chain of thought".to_string(), + &test_cwd(), + DisplayPreferences::default(), + ); + + assert!(cell.display_lines(/*width*/ 80).is_empty()); + assert!(cell.transcript_lines(/*width*/ 80).is_empty()); + } + + #[test] + fn reasoning_raw_block_is_visible_when_display_preference_is_enabled() { + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::RawThinking, + true, + ); + let cell = new_reasoning_raw_block( + "secret chain of thought".to_string(), + &test_cwd(), + display_preferences, + ); + + let rendered = render_transcript(cell.as_ref()); + assert_eq!( + rendered, + vec!["• Raw Thinking", " secret chain of thought"] + ); + } + #[test] fn deprecation_notice_renders_summary_with_details() { let cell = new_deprecation_notice( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 46974bc13..be9f975fc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -106,6 +106,8 @@ pub mod custom_terminal; mod cwd_prompt; mod debug_config; mod diff_render; +mod display_preferences; +mod display_preferences_menu; mod exec_cell; mod exec_command; mod external_editor; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 7ef0097e6..0d1a3f9a6 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -99,7 +99,7 @@ impl SlashCommand { SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", SlashCommand::Personality => "choose a communication style for Codex", SlashCommand::Realtime => "toggle realtime voice mode (experimental)", - SlashCommand::Settings => "configure realtime microphone/speaker", + SlashCommand::Settings => "configure UI visibility and realtime devices", SlashCommand::Plan => "switch to Plan mode", SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", diff --git a/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap b/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap new file mode 100644 index 000000000..9fc362136 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/display_preferences_menu.rs +expression: render_lines(&view) +--- + + UI + These settings only affect local TUI renderi + +› 1. Show Raw Thinking Currently hidden. + Reveal raw reasoning + text in this TUI only. + + Model context and persisted rollout history + are unchanged. + Press enter to confirm or esc to go back From 3640cc465474231e133e3fe796479a9e1d378727 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 17:22:40 +0800 Subject: [PATCH 27/60] replay: restore btw hidden ephemeral discussion --- codex-rs/tui/src/app.rs | 90 ++- codex-rs/tui/src/app/app_server_adapter.rs | 3 + codex-rs/tui/src/app/btw.rs | 649 ++++++++++++++++++ ...i__app__btw__tests__btw_failure_popup.snap | 43 ++ ...i__app__btw__tests__btw_loading_popup.snap | 43 ++ ...ui__app__btw__tests__btw_result_popup.snap | 61 ++ codex-rs/tui/src/app_event.rs | 15 + codex-rs/tui/src/chatwidget.rs | 15 + .../src/chatwidget/tests/slash_commands.rs | 34 + codex-rs/tui/src/slash_command.rs | 6 + 10 files changed, 956 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/app/btw.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_failure_popup.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_loading_popup.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_result_popup.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e87cd9b30..4e75e11ea 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -141,6 +141,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; use std::collections::HashMap; +use std::collections::HashSet; use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; @@ -164,6 +165,7 @@ use uuid::Uuid; mod agent_navigation; mod app_server_adapter; mod app_server_requests; +mod btw; mod key_chord; mod loaded_threads; mod pending_interactive_replay; @@ -176,6 +178,7 @@ mod workflow_scheduler; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; +use self::btw::BtwSessionState; use self::key_chord::KeyChordAction; use self::key_chord::KeyChordResolution; use self::key_chord::KeyChordState; @@ -1089,6 +1092,8 @@ pub(crate) struct App { HashMap>, workflow_scheduler: WorkflowSchedulerState, workflow_history: WorkflowHistoryState, + btw_session: Option, + btw_closing_thread_ids: HashSet, } #[derive(Default)] @@ -4378,6 +4383,8 @@ impl App { workflow_thread_notification_channels: HashMap::new(), workflow_scheduler: WorkflowSchedulerState::default(), workflow_history: WorkflowHistoryState::default(), + btw_session: None, + btw_closing_thread_ids: HashSet::new(), }; if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) @@ -4834,6 +4841,25 @@ impl App { self.workflow_thread_notification_channels .remove(&thread_id); } + AppEvent::StartBtwDiscussion { prompt } => { + self.start_btw_discussion(app_server, prompt).await; + } + AppEvent::BtwCompleted { thread_id, result } => { + let is_error = result.is_err(); + self.finish_btw_discussion(thread_id, result); + if is_error { + self.close_btw_session(app_server).await; + } + } + AppEvent::BtwInsertSummary => { + self.insert_btw_summary(app_server).await; + } + AppEvent::BtwInsertFull => { + self.insert_btw_full(app_server).await; + } + AppEvent::BtwDiscard => { + self.discard_btw_session(app_server).await; + } AppEvent::RetryLastUserTurnWithProfileFallback { action, error_message, @@ -9837,6 +9863,8 @@ guardian_approval = true workflow_thread_notification_channels: HashMap::new(), workflow_scheduler: WorkflowSchedulerState::default(), workflow_history: WorkflowHistoryState::default(), + btw_session: None, + btw_closing_thread_ids: HashSet::new(), } } @@ -9894,9 +9922,11 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), - workflow_thread_notification_channels: HashMap::new(), - workflow_scheduler: WorkflowSchedulerState::default(), - workflow_history: WorkflowHistoryState::default(), + workflow_thread_notification_channels: HashMap::new(), + workflow_scheduler: WorkflowSchedulerState::default(), + workflow_history: WorkflowHistoryState::default(), + btw_session: None, + btw_closing_thread_ids: HashSet::new(), }, rx, op_rx, @@ -12393,6 +12423,60 @@ model = "gpt-5.2" } #[tokio::test] + async fn btw_completion_notification_emits_completion_event_and_is_swallowed() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.btw_session = Some(BtwSessionState { + thread_id, + final_message: None, + }); + + let swallowed = app.handle_btw_notification( + thread_id, + &turn_completed_notification_with_agent_message( + thread_id, + "turn-btw", + TurnStatus::Completed, + "Temporary answer", + ), + ); + + assert!(swallowed); + match app_event_rx.try_recv() { + Ok(AppEvent::BtwCompleted { + thread_id: actual_thread_id, + result: Ok(message), + }) => { + assert_eq!(actual_thread_id, thread_id); + assert_eq!(message, "Temporary answer"); + } + other => panic!("expected BtwCompleted event, got {other:?}"), + } + } + + #[tokio::test] + async fn btw_insert_summary_appends_to_existing_composer() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let thread_id = ThreadId::new(); + app.btw_session = Some(BtwSessionState { + thread_id, + final_message: Some("First point.\n\nSecond point.".to_string()), + }); + app.chat_widget + .set_composer_text("Existing draft".to_string(), Vec::new(), Vec::new()); + + app.insert_btw_summary(&mut app_server).await; + + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Existing draft\n\nBTW summary:\nFirst point.\nSecond point." + ); + assert!(app.btw_session.is_none()); + Ok(()) + } + async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() { let mut app = make_test_app().await; let thread_id = ThreadId::new(); diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index a177ec56e..cdcd2ef0f 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -189,6 +189,9 @@ impl App { match server_notification_thread_target(¬ification) { ServerNotificationThreadTarget::Thread(thread_id) => { + if self.handle_btw_notification(thread_id, ¬ification) { + return; + } let result = if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() { diff --git a/codex-rs/tui/src/app/btw.rs b/codex-rs/tui/src/app/btw.rs new file mode 100644 index 000000000..f7bc20447 --- /dev/null +++ b/codex-rs/tui/src/app/btw.rs @@ -0,0 +1,649 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_protocol::ThreadId; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use uuid::Uuid; + +use super::App; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::SideContentWidth; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; + +const BTW_DEVELOPER_INSTRUCTIONS: &str = concat!( + "This is a hidden `/btw` discussion thread. ", + "Treat it as a temporary scratchpad that must not mutate the workspace or persistent state. ", + "Do not write files, apply patches, spawn agents, or perform side-effectful actions. ", + "If you need to inspect local context, keep it read-only and concise. ", + "Your answer will be shown to the user in a temporary confirmation view and may be inserted ", + "back into the main composer." +); +const BTW_DISCUSSION_VIEW_ID: &str = "btw-discussion"; +const PREVIEW_CHAR_LIMIT: usize = 1_200; +const SUMMARY_MAX_LINES: usize = 4; +const SUMMARY_MAX_CHARS: usize = 500; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BtwSessionState { + pub(crate) thread_id: ThreadId, + pub(crate) final_message: Option, +} + +impl App { + pub(crate) async fn start_btw_discussion( + &mut self, + app_server: &mut AppServerSession, + prompt: String, + ) { + if self.btw_session.is_some() { + self.chat_widget.add_info_message( + "A `/btw` discussion is already active.".to_string(), + Some("Finish or discard it before starting another one.".to_string()), + ); + return; + } + + let trimmed_prompt = prompt.trim(); + if trimmed_prompt.is_empty() { + self.chat_widget + .add_error_message("Usage: /btw ".to_string()); + return; + } + + self.open_btw_loading_popup(); + + let thread_id = match self.start_btw_thread(app_server).await { + Ok(thread_id) => thread_id, + Err(err) => { + self.open_btw_failure_popup(&format!("Failed to start `/btw`: {err}")); + return; + } + }; + self.btw_session = Some(BtwSessionState { + thread_id, + final_message: None, + }); + + let turn_result = app_server + .turn_start( + thread_id, + btw_turn_input(trimmed_prompt), + self.btw_turn_cwd_path(app_server), + AskForApproval::Never, + self.config.approvals_reviewer, + SandboxPolicy::new_read_only_policy(), + self.chat_widget.current_model().to_string(), + self.chat_widget.current_reasoning_effort(), + /*summary*/ None, + self.chat_widget.current_service_tier().map(Some), + /*collaboration_mode*/ None, + self.config.personality, + /*output_schema*/ None, + ) + .await; + if let Err(err) = turn_result { + self.close_btw_session(app_server).await; + self.open_btw_failure_popup(&format!("Failed to submit `/btw`: {err}")); + } + } + + pub(crate) fn finish_btw_discussion( + &mut self, + thread_id: ThreadId, + result: Result, + ) { + let Some(session) = self.btw_session.as_mut() else { + return; + }; + if session.thread_id != thread_id { + return; + } + + match result { + Ok(message) => { + session.final_message = Some(message.clone()); + self.open_btw_result_popup(&message); + } + Err(err) => { + self.open_btw_failure_popup(&format!("`/btw` failed: {err}")); + } + } + } + + pub(crate) fn handle_btw_notification( + &mut self, + thread_id: ThreadId, + notification: &codex_app_server_protocol::ServerNotification, + ) -> bool { + if self.btw_closing_thread_ids.contains(&thread_id) { + if matches!( + notification, + codex_app_server_protocol::ServerNotification::ThreadClosed(_) + ) { + self.btw_closing_thread_ids.remove(&thread_id); + } + return true; + } + + let Some(session) = self.btw_session.as_ref() else { + return false; + }; + if session.thread_id != thread_id { + return false; + } + + if session.final_message.is_some() { + return true; + } + + match notification { + codex_app_server_protocol::ServerNotification::Error(notification) + if !notification.will_retry => + { + self.app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: Err(notification.error.message.clone()), + }); + } + codex_app_server_protocol::ServerNotification::TurnCompleted(notification) => { + let result = match notification.turn.status { + TurnStatus::Completed => last_agent_message_or_error(¬ification.turn), + TurnStatus::Failed => last_agent_message_or_error(¬ification.turn) + .or_else(|_| turn_failed_error(¬ification.turn)), + TurnStatus::Interrupted => { + Err("Temporary discussion was interrupted.".to_string()) + } + TurnStatus::InProgress => return true, + }; + self.app_event_tx + .send(AppEvent::BtwCompleted { thread_id, result }); + } + codex_app_server_protocol::ServerNotification::ThreadClosed(_) => { + self.app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: Err("Temporary discussion closed before a final answer.".to_string()), + }); + } + _ => {} + } + + true + } + + pub(crate) async fn insert_btw_summary(&mut self, app_server: &mut AppServerSession) { + let Some(message) = self + .btw_session + .as_ref() + .and_then(|session| session.final_message.as_deref()) + else { + self.open_btw_failure_popup("`/btw` summary is unavailable."); + return; + }; + + self.insert_btw_text(summarize_message(message)); + self.chat_widget.add_info_message( + "Inserted `/btw` summary into the composer.".to_string(), + /*hint*/ None, + ); + self.close_btw_session(app_server).await; + } + + pub(crate) async fn insert_btw_full(&mut self, app_server: &mut AppServerSession) { + let Some(message) = self + .btw_session + .as_ref() + .and_then(|session| session.final_message.as_deref()) + else { + self.open_btw_failure_popup("`/btw` answer is unavailable."); + return; + }; + + self.insert_btw_text(full_insert_text(message)); + self.chat_widget.add_info_message( + "Inserted `/btw` answer into the composer.".to_string(), + /*hint*/ None, + ); + self.close_btw_session(app_server).await; + } + + pub(crate) async fn discard_btw_session(&mut self, app_server: &mut AppServerSession) { + let had_session = self.btw_session.is_some(); + self.close_btw_session(app_server).await; + if had_session { + self.chat_widget.add_info_message( + "Discarded `/btw` discussion.".to_string(), + /*hint*/ None, + ); + } + } + + fn insert_btw_text(&mut self, text: String) { + if !self + .chat_widget + .composer_text_with_pending() + .trim() + .is_empty() + { + self.chat_widget.insert_str("\n\n"); + } + self.chat_widget.insert_str(&text); + } + + pub(crate) async fn close_btw_session(&mut self, app_server: &mut AppServerSession) { + let Some(session) = self.btw_session.take() else { + return; + }; + self.close_btw_thread(app_server, session.thread_id).await; + } + + async fn close_btw_thread(&mut self, app_server: &mut AppServerSession, thread_id: ThreadId) { + if !self.btw_closing_thread_ids.insert(thread_id) { + return; + } + if let Err(err) = app_server.thread_unsubscribe(thread_id).await { + tracing::warn!(thread_id = %thread_id, error = %err, "failed to close `/btw` thread"); + } + } + + async fn start_btw_thread(&self, app_server: &AppServerSession) -> Result { + let request_handle = app_server.request_handle(); + if let Some(thread_id) = self.chat_widget.thread_id() { + let response: ThreadForkResponse = request_handle + .request_typed(ClientRequest::ThreadFork { + request_id: request_id(), + params: btw_thread_fork_params(self, thread_id, app_server), + }) + .await + .map_err(|err| format!("failed to fork temporary thread: {err}"))?; + ThreadId::from_string(&response.thread.id) + .map_err(|err| format!("invalid `/btw` thread id: {err}")) + } else { + let response: ThreadStartResponse = request_handle + .request_typed(ClientRequest::ThreadStart { + request_id: request_id(), + params: btw_thread_start_params(self, app_server), + }) + .await + .map_err(|err| format!("failed to start temporary thread: {err}"))?; + ThreadId::from_string(&response.thread.id) + .map_err(|err| format!("invalid `/btw` thread id: {err}")) + } + } + + fn open_btw_loading_popup(&mut self) { + self.show_btw_popup(btw_loading_view_params); + } + + fn open_btw_result_popup(&mut self, message: &str) { + self.show_btw_popup(|| btw_result_view_params(message)); + } + + fn open_btw_failure_popup(&mut self, error: &str) { + self.show_btw_popup(|| btw_failure_view_params(error)); + } + + fn show_btw_popup(&mut self, build: F) + where + F: Fn() -> SelectionViewParams, + { + if self + .chat_widget + .selected_index_for_active_view(BTW_DISCUSSION_VIEW_ID) + .is_some() + { + let _ = self + .chat_widget + .replace_selection_view_if_active(BTW_DISCUSSION_VIEW_ID, build()); + } else { + self.chat_widget.show_selection_view(build()); + } + } + + fn btw_thread_cwd(&self, app_server: &AppServerSession) -> Option { + if app_server.is_remote() { + app_server + .remote_cwd_override() + .map(|cwd| cwd.to_string_lossy().to_string()) + } else { + Some(self.config.cwd.to_string_lossy().to_string()) + } + } + + fn btw_turn_cwd_path(&self, app_server: &AppServerSession) -> PathBuf { + if app_server.is_remote() { + app_server + .remote_cwd_override() + .map(PathBuf::from) + .unwrap_or_else(|| self.config.cwd.to_path_buf()) + } else { + self.config.cwd.to_path_buf() + } + } +} + +fn request_id() -> RequestId { + RequestId::String(format!("btw-{}", Uuid::new_v4())) +} + +fn btw_turn_input(prompt: &str) -> Vec { + vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }] +} + +fn btw_thread_start_params(app: &App, app_server: &AppServerSession) -> ThreadStartParams { + ThreadStartParams { + model: Some(app.chat_widget.current_model().to_string()), + model_provider: (!app_server.is_remote()).then_some(app.config.model_provider_id.clone()), + cwd: app.btw_thread_cwd(app_server), + approval_policy: Some(AskForApproval::Never.into()), + approvals_reviewer: Some(AppServerApprovalsReviewer::from( + app.config.approvals_reviewer, + )), + sandbox: Some(SandboxMode::ReadOnly), + config: config_overrides(app.active_profile.as_deref()), + developer_instructions: Some(merge_developer_instructions( + app.config.developer_instructions.as_deref(), + )), + personality: app.config.personality, + ephemeral: Some(true), + persist_extended_history: true, + ..ThreadStartParams::default() + } +} + +fn btw_thread_fork_params( + app: &App, + thread_id: ThreadId, + app_server: &AppServerSession, +) -> ThreadForkParams { + ThreadForkParams { + thread_id: thread_id.to_string(), + model: Some(app.chat_widget.current_model().to_string()), + model_provider: (!app_server.is_remote()).then_some(app.config.model_provider_id.clone()), + cwd: app.btw_thread_cwd(app_server), + approval_policy: Some(AskForApproval::Never.into()), + approvals_reviewer: Some(AppServerApprovalsReviewer::from( + app.config.approvals_reviewer, + )), + sandbox: Some(SandboxMode::ReadOnly), + config: config_overrides(app.active_profile.as_deref()), + developer_instructions: Some(merge_developer_instructions( + app.config.developer_instructions.as_deref(), + )), + ephemeral: true, + persist_extended_history: true, + ..ThreadForkParams::default() + } +} + +fn config_overrides(active_profile: Option<&str>) -> Option> { + active_profile.map(|profile| { + HashMap::from([( + "profile".to_string(), + serde_json::Value::String(profile.to_string()), + )]) + }) +} + +fn merge_developer_instructions(existing: Option<&str>) -> String { + match existing { + Some(existing) if !existing.trim().is_empty() => { + format!("{existing}\n\n{BTW_DEVELOPER_INSTRUCTIONS}") + } + _ => BTW_DEVELOPER_INSTRUCTIONS.to_string(), + } +} + +fn preview_text(message: &str) -> String { + let trimmed = message.trim(); + if trimmed.chars().count() <= PREVIEW_CHAR_LIMIT { + return trimmed.to_string(); + } + + let preview: String = trimmed.chars().take(PREVIEW_CHAR_LIMIT).collect(); + format!("{preview}\n\n...preview truncated...") +} + +fn summarize_message(message: &str) -> String { + let mut kept = Vec::new(); + let mut used_chars = 0usize; + for line in message + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let next_len = used_chars.saturating_add(line.chars().count()); + if !kept.is_empty() && (kept.len() >= SUMMARY_MAX_LINES || next_len > SUMMARY_MAX_CHARS) { + break; + } + kept.push(line.to_string()); + used_chars = next_len; + } + + if kept.is_empty() { + "BTW summary:\n(Empty answer)".to_string() + } else { + format!("BTW summary:\n{}", kept.join("\n")) + } +} + +fn full_insert_text(message: &str) -> String { + format!("BTW discussion:\n{message}") +} + +fn last_agent_message_or_error(turn: &codex_app_server_protocol::Turn) -> Result { + super::last_agent_message_for_turn(turn) + .ok_or_else(|| "Temporary discussion finished without a final answer.".to_string()) +} + +fn turn_failed_error(turn: &codex_app_server_protocol::Turn) -> Result { + Err(turn + .error + .as_ref() + .map(|error| error.message.clone()) + .unwrap_or_else(|| "Temporary discussion failed without a final error.".to_string())) +} + +fn btw_loading_view_params() -> SelectionViewParams { + SelectionViewParams { + view_id: Some(BTW_DISCUSSION_VIEW_ID), + title: Some("Temporary BTW discussion".to_string()), + subtitle: Some("Running a hidden temporary discussion thread.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![SelectionItem { + name: "Discard".to_string(), + description: Some("Cancel and destroy the temporary discussion.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwDiscard))], + dismiss_on_select: true, + ..Default::default() + }], + side_content: Paragraph::new( + "Codex is answering in a hidden ephemeral thread. Nothing will be written back to the \ + main composer unless you explicitly choose an insert action." + .to_string(), + ) + .wrap(Wrap { trim: false }) + .into(), + side_content_width: SideContentWidth::Half, + side_content_min_width: 28, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))), + ..Default::default() + } +} + +fn btw_result_view_params(message: &str) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(BTW_DISCUSSION_VIEW_ID), + title: Some("Temporary BTW answer".to_string()), + subtitle: Some("Choose what to do with the temporary answer.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "Insert Summary".to_string(), + description: Some("Insert a short summary into the main composer.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwInsertSummary))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Insert Full".to_string(), + description: Some("Insert the full answer into the main composer.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwInsertFull))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Discard".to_string(), + description: Some( + "Destroy the temporary discussion and keep the main composer untouched." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwDiscard))], + dismiss_on_select: true, + ..Default::default() + }, + ], + side_content: Paragraph::new(preview_text(message)) + .wrap(Wrap { trim: false }) + .into(), + side_content_width: SideContentWidth::Half, + side_content_min_width: 28, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))), + ..Default::default() + } +} + +fn btw_failure_view_params(error: &str) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(BTW_DISCUSSION_VIEW_ID), + title: Some("Temporary BTW failed".to_string()), + subtitle: Some("The hidden temporary discussion did not complete.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![SelectionItem { + name: "Close".to_string(), + description: Some("Dismiss this temporary discussion.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwDiscard))], + dismiss_on_select: true, + ..Default::default() + }], + side_content: Paragraph::new(error.to_string()) + .wrap(Wrap { trim: false }) + .into(), + side_content_width: SideContentWidth::Half, + side_content_min_width: 28, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + use super::btw_failure_view_params; + use super::btw_loading_view_params; + use super::btw_result_view_params; + use super::merge_developer_instructions; + use super::preview_text; + use super::summarize_message; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::ListSelectionView; + use crate::render::renderable::Renderable; + + fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|frame| { + let area = Rect::new(0, 0, width, height); + view.render(area, frame.buffer_mut()); + }) + .expect("draw popup"); + format!("{:?}", terminal.backend()) + } + + #[test] + fn btw_loading_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new(btw_loading_view_params(), tx); + + assert_snapshot!("btw_loading_popup", render_selection_popup(&view, 92, 20)); + } + + #[test] + fn btw_result_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + btw_result_view_params( + "Use a hidden thread to brainstorm tradeoffs, then choose whether to insert the \ + summary or the full answer back into the main composer.", + ), + tx, + ); + + assert_snapshot!("btw_result_popup", render_selection_popup(&view, 92, 28)); + } + + #[test] + fn btw_failure_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + btw_failure_view_params("`/btw` failed: upstream unavailable"), + tx, + ); + + assert_snapshot!("btw_failure_popup", render_selection_popup(&view, 92, 20)); + } + + #[test] + fn summarize_btw_message_keeps_short_prefix_for_insertion() { + let summary = summarize_message( + "First point.\n\nSecond point.\nThird point.\nFourth point.\nFifth point.", + ); + + assert_eq!( + summary, + "BTW summary:\nFirst point.\nSecond point.\nThird point.\nFourth point." + ); + } + + #[test] + fn preview_text_truncates_long_messages() { + let preview = preview_text(&"a".repeat(1_250)); + assert!(preview.contains("preview truncated")); + } + + #[test] + fn merge_developer_instructions_appends_btw_guardrail() { + assert_eq!( + merge_developer_instructions(Some("Stay focused.")), + format!("Stay focused.\n\n{}", super::BTW_DEVELOPER_INSTRUCTIONS) + ); + } +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_failure_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_failure_popup.snap new file mode 100644 index 000000000..fad24b9dc --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_failure_popup.snap @@ -0,0 +1,43 @@ +--- +source: tui/src/app/btw.rs +expression: "render_selection_popup(&view, 92, 20)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 20 }, + content: [ + " ", + " Temporary BTW failed `/btw` failed: upstream unavailable ", + " The hidden temporary discussion did not com ", + " ", + "› 1. Close Dismiss this temporary ", + " discussion. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 22, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 34, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 23, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_loading_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_loading_popup.snap new file mode 100644 index 000000000..1ddee4b07 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_loading_popup.snap @@ -0,0 +1,43 @@ +--- +source: tui/src/app/btw.rs +expression: "render_selection_popup(&view, 92, 20)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 20 }, + content: [ + " ", + " Temporary BTW discussion Codex is answering in a hidden ephemeral ", + " Running a hidden temporary discussion threa thread. Nothing will be written back to the ", + " main composer unless you explicitly choose ", + "› 1. Discard Cancel and destroy the an insert action. ", + " temporary discussion. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 26, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 36, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 35, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_result_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_result_popup.snap new file mode 100644 index 000000000..6261de893 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_result_popup.snap @@ -0,0 +1,61 @@ +--- +source: tui/src/app/btw.rs +expression: "render_selection_popup(&view, 92, 28)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 28 }, + content: [ + " ", + " Temporary BTW answer Use a hidden thread to brainstorm ", + " Choose what to do with the temporary answer tradeoffs, then choose whether to insert ", + " the summary or the full answer back into ", + "› 1. Insert Summary Insert a short summary the main composer. ", + " into the main composer. ", + " 2. Insert Full Insert the full answer ", + " into the main composer. ", + " 3. Discard Destroy the temporary ", + " discussion and keep the ", + " main composer untouched. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 22, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 43, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 44, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 43, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 44, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 42, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 44, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 27, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 83abadc53..fe37ff7f0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -306,6 +306,21 @@ pub(crate) enum AppEvent { }, ShowWorkflowBackgroundTasks, + + StartBtwDiscussion { + prompt: String, + }, + + BtwCompleted { + thread_id: ThreadId, + result: Result, + }, + + BtwInsertSummary, + + BtwInsertFull, + + BtwDiscard, /// Retry the last turn using the routed profile fallback policy. RetryLastUserTurnWithProfileFallback { action: ProfileFallbackAction, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c1c601287..36763e4c9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5409,6 +5409,9 @@ impl ChatWidget { SlashCommand::Workflow => { self.app_event_tx.send(AppEvent::OpenWorkflowControls); } + SlashCommand::Btw => { + self.add_error_message("Usage: /btw ".to_string()); + } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( @@ -5583,6 +5586,18 @@ impl ChatWidget { }); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::Btw if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.app_event_tx.send(AppEvent::StartBtwDiscussion { + prompt: prepared_args, + }); + self.bottom_pane.drain_pending_submission_state(); + } _ => self.dispatch_command(cmd), } } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 4ccf83280..a3c10ce3b 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -79,6 +79,40 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[tokio::test] +async fn slash_btw_requires_prompt() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Btw); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one error message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Usage: /btw "), + "expected usage message, got {rendered:?}" + ); +} + +#[tokio::test] +async fn slash_btw_dispatches_start_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.bottom_pane.set_composer_text( + "/btw compare the two approaches".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::StartBtwDiscussion { prompt }) => { + assert_eq!(prompt, "compare the two approaches"); + } + other => panic!("expected StartBtwDiscussion event, got {other:?}"), + } +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 0d1a3f9a6..5a87487fd 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -45,6 +45,7 @@ pub enum SlashCommand { Apps, Plugins, Workflow, + Btw, Logout, Quit, Exit, @@ -114,6 +115,9 @@ impl SlashCommand { SlashCommand::Apps => "manage apps", SlashCommand::Plugins => "browse plugins", SlashCommand::Workflow => "inspect and trigger workspace workflows", + SlashCommand::Btw => "run a hidden read-only temporary discussion", + SlashCommand::Workflow => "inspect and trigger workspace workflows", + SlashCommand::Btw => "run a hidden read-only temporary discussion", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", SlashCommand::TestApproval => "test approval request", @@ -134,6 +138,7 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Plan | SlashCommand::Fast + | SlashCommand::Btw | SlashCommand::SandboxReadRoot ) } @@ -157,6 +162,7 @@ impl SlashCommand { | SlashCommand::Experimental | SlashCommand::Review | SlashCommand::Plan + | SlashCommand::Btw | SlashCommand::Clear | SlashCommand::Logout | SlashCommand::MemoryDrop From 06efb8dda84127142354af6b61a3502185199daf Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 17:44:59 +0800 Subject: [PATCH 28/60] replay: restore thread actions and jump-to-message --- codex-rs/tui/src/app.rs | 24 ++ codex-rs/tui/src/app/jump_navigation.rs | 154 +++++++++++ ...u__tests__jump_to_message_empty_popup.snap | 43 +++ ...ad_menu__tests__jump_to_message_popup.snap | 47 ++++ ...hread_menu__tests__thread_panel_popup.snap | 49 ++++ codex-rs/tui/src/app/thread_menu.rs | 253 ++++++++++++++++++ codex-rs/tui/src/app_event.rs | 14 + codex-rs/tui/src/chatwidget.rs | 3 + .../src/chatwidget/tests/slash_commands.rs | 9 + codex-rs/tui/src/pager_overlay.rs | 15 +- codex-rs/tui/src/slash_command.rs | 5 +- 11 files changed, 612 insertions(+), 4 deletions(-) create mode 100644 codex-rs/tui/src/app/jump_navigation.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_empty_popup.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_popup.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__thread_panel_popup.snap create mode 100644 codex-rs/tui/src/app/thread_menu.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4e75e11ea..7222e1a95 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -166,6 +166,7 @@ mod agent_navigation; mod app_server_adapter; mod app_server_requests; mod btw; +mod jump_navigation; mod key_chord; mod loaded_threads; mod pending_interactive_replay; @@ -174,6 +175,7 @@ mod workflow_definition; mod workflow_history; pub(crate) mod workflow_runtime; mod workflow_scheduler; +mod thread_menu; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; @@ -4744,6 +4746,25 @@ impl App { AppEvent::OpenDisplayPreferencesPanel => { self.open_display_preferences_panel(); } + AppEvent::OpenThreadPanel => { + self.open_thread_panel(); + } + AppEvent::OpenJumpToMessagePanel => { + self.open_jump_to_message_panel(); + } + AppEvent::JumpToTranscriptCell { cell_index } => { + self.reset_backtrack_state(); + self.backtrack.overlay_preview_active = false; + if !matches!(self.overlay, Some(Overlay::Transcript(_))) { + self.open_transcript_overlay(tui); + } + if let Some(Overlay::Transcript(overlay)) = &mut self.overlay + && cell_index < self.transcript_cells.len() + { + overlay.set_highlight_cell(Some(cell_index)); + tui.frame_requester().schedule_frame(); + } + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", @@ -4803,6 +4824,9 @@ impl App { tui.frame_requester().schedule_frame(); } + AppEvent::UndoLastUserMessage => { + self.undo_last_user_message(); + } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); self.insert_visible_history_cell(tui, cell); diff --git a/codex-rs/tui/src/app/jump_navigation.rs b/codex-rs/tui/src/app/jump_navigation.rs new file mode 100644 index 000000000..17e77d39e --- /dev/null +++ b/codex-rs/tui/src/app/jump_navigation.rs @@ -0,0 +1,154 @@ +use std::sync::Arc; + +use ratatui::text::Line; + +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::McpToolCallCell; +use crate::history_cell::ReasoningSummaryCell; +use crate::history_cell::UserHistoryCell; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum JumpTargetKind { + UserMessage, + AgentMessage, + Reasoning, + ToolCall, + Event, +} + +impl JumpTargetKind { + fn title(self) -> &'static str { + match self { + Self::UserMessage => "User Message", + Self::AgentMessage => "Assistant Message", + Self::Reasoning => "Reasoning", + Self::ToolCall => "Tool Call", + Self::Event => "Event", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct JumpTarget { + pub(crate) cell_index: usize, + pub(crate) ordinal: usize, + pub(crate) kind: JumpTargetKind, + pub(crate) title: String, + pub(crate) preview: String, +} + +impl JumpTarget { + fn new(cell_index: usize, ordinal: usize, kind: JumpTargetKind, preview: String) -> Self { + Self { + cell_index, + ordinal, + kind, + title: format!("{} {ordinal}", kind.title()), + preview, + } + } + + pub(crate) fn search_value(&self) -> String { + format!("{} {}", self.title, self.preview) + } +} + +pub(crate) fn build_jump_targets(cells: &[Arc]) -> Vec { + let mut targets = Vec::new(); + let mut ordinal = 1usize; + + for (cell_index, cell) in cells.iter().enumerate() { + let preview = preview_from_lines(cell.transcript_lines(u16::MAX)); + if preview.is_empty() { + continue; + } + + targets.push(JumpTarget::new( + cell_index, + ordinal, + classify_history_cell(cell.as_ref()), + preview, + )); + ordinal += 1; + } + + targets +} + +fn classify_history_cell(cell: &dyn HistoryCell) -> JumpTargetKind { + if cell.as_any().is::() { + JumpTargetKind::UserMessage + } else if cell.as_any().is::() { + JumpTargetKind::AgentMessage + } else if cell.as_any().is::() { + JumpTargetKind::Reasoning + } else if cell.as_any().is::() { + JumpTargetKind::ToolCall + } else { + JumpTargetKind::Event + } +} + +fn preview_from_lines(lines: Vec>) -> String { + lines + .into_iter() + .map(line_to_plain_text) + .map(|line| { + line.trim() + .trim_start_matches(['•', '-', '>', '›']) + .trim() + .to_string() + }) + .filter(|line| !line.is_empty()) + .take(2) + .collect::>() + .join(" ") +} + +fn line_to_plain_text(line: Line<'static>) -> String { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::() +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::text::Line; + use std::sync::Arc; + + use super::JumpTargetKind; + use super::build_jump_targets; + use crate::history_cell::AgentMessageCell; + use crate::history_cell::PlainHistoryCell; + use crate::history_cell::UserHistoryCell; + + #[test] + fn build_jump_targets_classifies_cells_and_skips_empty_entries() { + let cells = vec![ + Arc::new(UserHistoryCell { + message: "first question".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + Arc::new(PlainHistoryCell::new(vec![Line::from(" ")])), + Arc::new(AgentMessageCell::new( + vec![Line::from("first answer")], + /*is_first_line*/ true, + )), + ]; + + let targets = build_jump_targets(&cells); + + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].kind, JumpTargetKind::UserMessage); + assert_eq!(targets[0].title, "User Message 1"); + assert_eq!(targets[0].preview, "first question"); + assert_eq!(targets[1].kind, JumpTargetKind::AgentMessage); + assert_eq!(targets[1].title, "Assistant Message 2"); + assert_eq!(targets[1].preview, "first answer"); + } +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_empty_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_empty_popup.snap new file mode 100644 index 000000000..a1eb1b655 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_empty_popup.snap @@ -0,0 +1,43 @@ +--- +source: tui/src/app/thread_menu.rs +expression: "render_selection_popup(&view, 92, 18)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 18 }, + content: [ + " ", + " Jump To Message ", + " No committed transcript entries are available yet. ", + " ", + " Type to search committed transcript ", + "› Nothing to jump to yet Send a message or wait for a response before using Jump To ", + " Message. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 52, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 37, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 84, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 17, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_popup.snap new file mode 100644 index 000000000..4837987a9 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_popup.snap @@ -0,0 +1,47 @@ +--- +source: tui/src/app/thread_menu.rs +expression: "render_selection_popup(&view, 92, 22)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 22 }, + content: [ + " ", + " Jump To Message ", + " 2 committed transcript entries are available. ", + " ", + " Type to search committed transcript ", + "› User Message 1 How do I keep the retry chain intact? ", + " Assistant Message 2 Classify 503 as fallback-eligible before surfacing the final error. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 47, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 37, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 60, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 23, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 90, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 21, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__thread_panel_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__thread_panel_popup.snap new file mode 100644 index 000000000..b464ab7fc --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__thread_panel_popup.snap @@ -0,0 +1,49 @@ +--- +source: tui/src/app/thread_menu.rs +expression: "render_selection_popup(&view, 92, 20)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 20 }, + content: [ + " ", + " Thread ", + " Fork, rewind, or jump within the current conversation. ", + " ", + "› 1. Fork Current Session Fork the current thread into a new session and continue ", + " there. ", + " 2. Jump To Message Search committed transcript entries and open the transcript ", + " overlay. ", + " 3. Undo Last User Message Restore the last sent input and roll back one turn. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 56, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 84, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 35, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 88, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 37, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 80, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/thread_menu.rs b/codex-rs/tui/src/app/thread_menu.rs new file mode 100644 index 000000000..3eb9c628d --- /dev/null +++ b/codex-rs/tui/src/app/thread_menu.rs @@ -0,0 +1,253 @@ +use std::sync::Arc; + +use super::App; +use super::jump_navigation::build_jump_targets; +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::history_cell::HistoryCell; + +const THREAD_PANEL_VIEW_ID: &str = "thread-actions"; +const JUMP_TO_MESSAGE_VIEW_ID: &str = "jump-to-message"; + +impl App { + pub(crate) fn open_thread_panel(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(THREAD_PANEL_VIEW_ID); + let params = thread_panel_params(self.chat_widget.is_task_running(), initial_selected_idx); + if !self + .chat_widget + .replace_selection_view_if_active(THREAD_PANEL_VIEW_ID, params) + { + self.chat_widget.show_selection_view(thread_panel_params( + self.chat_widget.is_task_running(), + initial_selected_idx, + )); + } + } + + pub(crate) fn open_jump_to_message_panel(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(JUMP_TO_MESSAGE_VIEW_ID); + let params = jump_to_message_panel_params(&self.transcript_cells, initial_selected_idx); + if !self + .chat_widget + .replace_selection_view_if_active(JUMP_TO_MESSAGE_VIEW_ID, params) + { + self.chat_widget + .show_selection_view(jump_to_message_panel_params( + &self.transcript_cells, + initial_selected_idx, + )); + } + } +} + +fn thread_panel_params( + task_running: bool, + initial_selected_idx: Option, +) -> SelectionViewParams { + let mut items = vec![SelectionItem { + name: "Fork Current Session".to_string(), + description: Some("Create a new thread from the current session state.".to_string()), + selected_description: Some( + "Fork the current thread into a new session and continue there.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::ForkCurrentSession))], + dismiss_on_select: true, + is_disabled: task_running, + disabled_reason: task_running + .then_some("Wait for the current task to finish before forking.".to_string()), + ..Default::default() + }]; + + items.push(SelectionItem { + name: "Jump To Message".to_string(), + description: Some( + "Search committed transcript entries and open the transcript overlay.".to_string(), + ), + selected_description: Some( + "Search the current transcript and jump directly to a committed entry.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenJumpToMessagePanel))], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Undo Last User Message".to_string(), + description: Some("Restore the last sent input and roll back one turn.".to_string()), + selected_description: Some( + "Restore the latest user input to the composer and rewind one committed turn." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::UndoLastUserMessage))], + dismiss_on_select: true, + is_disabled: task_running, + disabled_reason: task_running + .then_some("Wait for the current task to finish before undoing a turn.".to_string()), + ..Default::default() + }); + + SelectionViewParams { + view_id: Some(THREAD_PANEL_VIEW_ID), + title: Some("Thread".to_string()), + subtitle: Some("Fork, rewind, or jump within the current conversation.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() + } +} + +fn jump_to_message_panel_params( + transcript_cells: &[Arc], + initial_selected_idx: Option, +) -> SelectionViewParams { + let targets = build_jump_targets(transcript_cells); + let subtitle = if targets.is_empty() { + Some("No committed transcript entries are available yet.".to_string()) + } else { + Some(format!( + "{} committed transcript entr{} available.", + targets.len(), + if targets.len() == 1 { + "y is" + } else { + "ies are" + }, + )) + }; + let items = if targets.is_empty() { + vec![SelectionItem { + name: "Nothing to jump to yet".to_string(), + description: Some( + "Send a message or wait for a response before using Jump To Message.".to_string(), + ), + is_disabled: true, + ..Default::default() + }] + } else { + targets + .into_iter() + .map(|target| { + let cell_index = target.cell_index; + let search_value = target.search_value(); + let name = target.title; + let description = target.preview; + SelectionItem { + name, + description: Some(description), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::JumpToTranscriptCell { cell_index }); + })], + dismiss_on_select: true, + search_value: Some(search_value), + ..Default::default() + } + }) + .collect() + }; + + SelectionViewParams { + view_id: Some(JUMP_TO_MESSAGE_VIEW_ID), + title: Some("Jump To Message".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search committed transcript".to_string()), + initial_selected_idx, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use std::sync::Arc; + use tokio::sync::mpsc::unbounded_channel; + + use super::jump_to_message_panel_params; + use super::thread_panel_params; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::ListSelectionView; + use crate::history_cell::AgentMessageCell; + use crate::history_cell::HistoryCell; + use crate::history_cell::UserHistoryCell; + use crate::render::renderable::Renderable; + + fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|frame| { + let area = Rect::new(0, 0, width, height); + view.render(area, frame.buffer_mut()); + }) + .expect("draw popup"); + format!("{:?}", terminal.backend()) + } + + #[test] + fn thread_panel_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new(thread_panel_params(/*task_running*/ false, None), tx); + + assert_snapshot!("thread_panel_popup", render_selection_popup(&view, 92, 20)); + } + + #[test] + fn thread_panel_disables_mutations_while_task_running() { + let params = thread_panel_params(/*task_running*/ true, None); + + assert_eq!(params.items[0].is_disabled, true); + assert_eq!(params.items[1].is_disabled, false); + assert_eq!(params.items[2].is_disabled, true); + } + + #[test] + fn jump_to_message_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let cells = vec![ + Arc::new(UserHistoryCell { + message: "How do I keep the retry chain intact?".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + Arc::new(AgentMessageCell::new( + vec!["Classify 503 as fallback-eligible before surfacing the final error.".into()], + /*is_first_line*/ true, + )) as Arc, + ]; + let view = ListSelectionView::new(jump_to_message_panel_params(&cells, None), tx); + + assert_snapshot!( + "jump_to_message_popup", + render_selection_popup(&view, 92, 22) + ); + } + + #[test] + fn jump_to_message_empty_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let cells: Vec> = Vec::new(); + let view = ListSelectionView::new(jump_to_message_panel_params(&cells, None), tx); + + assert_snapshot!( + "jump_to_message_empty_popup", + render_selection_popup(&view, 92, 18) + ); + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fe37ff7f0..832180552 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -112,9 +112,23 @@ pub(crate) enum AppEvent { /// Open the local TUI display preferences panel. OpenDisplayPreferencesPanel, + /// Open thread-specific actions for the current conversation. + OpenThreadPanel, + + /// Open the committed transcript jump picker. + OpenJumpToMessagePanel, + + /// Open the transcript overlay and jump to the selected committed cell. + JumpToTranscriptCell { + cell_index: usize, + }, + /// Fork the current session into a new thread. ForkCurrentSession, + /// Restore the last user input and roll back one committed turn. + UndoLastUserMessage, + /// Request to exit the application. /// /// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 36763e4c9..e2f395c51 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5162,6 +5162,9 @@ impl ChatWidget { SlashCommand::Fork => { self.app_event_tx.send(AppEvent::ForkCurrentSession); } + SlashCommand::Thread => { + self.app_event_tx.send(AppEvent::OpenThreadPanel); + } SlashCommand::Init => { let init_target = match self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME) { Ok(path) => path, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index a3c10ce3b..b885a9b72 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -94,6 +94,15 @@ async fn slash_btw_requires_prompt() { ); } +#[tokio::test] +async fn slash_thread_dispatches_open_thread_panel_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Thread); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenThreadPanel)); +} + #[tokio::test] async fn slash_btw_dispatches_start_event() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index bca5f1f36..53a7734f1 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -477,10 +477,11 @@ impl TranscriptOverlay { .enumerate() .flat_map(|(i, c)| { let mut v: Vec> = Vec::new(); + let highlighted = highlight_cell == Some(i); let mut cell_renderable = if c.as_any().is::() { Box::new(CachedRenderable::new(CellRenderable { cell: c.clone(), - style: if highlight_cell == Some(i) { + style: if highlighted { user_message_style().reversed() } else { user_message_style() @@ -489,7 +490,11 @@ impl TranscriptOverlay { } else { Box::new(CachedRenderable::new(CellRenderable { cell: c.clone(), - style: Style::default(), + style: if highlighted { + Style::default().reversed() + } else { + Style::default() + }, })) as Box }; if !c.is_stream_continuation() && i > 0 { @@ -717,6 +722,11 @@ impl TranscriptOverlay { pub(crate) fn committed_cell_count(&self) -> usize { self.cells.len() } + + #[cfg(test)] + pub(crate) fn highlighted_cell(&self) -> Option { + self.highlight_cell + } } pub(crate) struct StaticOverlay { @@ -875,6 +885,7 @@ mod tests { lines: vec![Line::from("hello")], })]); overlay.set_highlight_cell(Some(0)); + assert_eq!(overlay.highlighted_cell(), Some(0)); // Render into a wide buffer so the footer hints aren't truncated. let area = Rect::new(0, 0, 120, 10); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 5a87487fd..8423518e5 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -27,6 +27,7 @@ pub enum SlashCommand { New, Resume, Fork, + Thread, Init, Compact, Plan, @@ -81,6 +82,7 @@ impl SlashCommand { SlashCommand::Resume => "resume a saved chat", SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat", + SlashCommand::Thread => "open thread actions for the current conversation", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", @@ -116,8 +118,6 @@ impl SlashCommand { SlashCommand::Plugins => "browse plugins", SlashCommand::Workflow => "inspect and trigger workspace workflows", SlashCommand::Btw => "run a hidden read-only temporary discussion", - SlashCommand::Workflow => "inspect and trigger workspace workflows", - SlashCommand::Btw => "run a hidden read-only temporary discussion", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", SlashCommand::TestApproval => "test approval request", @@ -180,6 +180,7 @@ impl SlashCommand { | SlashCommand::Apps | SlashCommand::Plugins | SlashCommand::Workflow + | SlashCommand::Thread | SlashCommand::Feedback | SlashCommand::Quit | SlashCommand::Exit => true, From ff71e811a9b2bbec4f32a3387c32426c015829d3 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 18:03:45 +0800 Subject: [PATCH 29/60] replay: restore routed profile management on new tui --- codex-rs/tui/src/app.rs | 48 ++- codex-rs/tui/src/app/profile_management.rs | 368 ++++++++++++++++++ ...ment__tests__profile_management_popup.snap | 55 +++ codex-rs/tui/src/app_event.rs | 14 + codex-rs/tui/src/chatwidget.rs | 3 + .../src/chatwidget/tests/slash_commands.rs | 9 + codex-rs/tui/src/profile_router.rs | 30 +- codex-rs/tui/src/slash_command.rs | 3 + 8 files changed, 521 insertions(+), 9 deletions(-) create mode 100644 codex-rs/tui/src/app/profile_management.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7222e1a95..a3e329e6b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5,6 +5,7 @@ use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event::FeedbackCategory; use crate::app_event::RealtimeAudioDeviceKind; +use crate::app_event::RuntimeProfileTarget; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; @@ -175,6 +176,7 @@ mod workflow_definition; mod workflow_history; pub(crate) mod workflow_runtime; mod workflow_scheduler; +mod profile_management; mod thread_menu; use self::agent_navigation::AgentNavigationDirection; @@ -1249,6 +1251,7 @@ impl App { if !reload_live_thread || self.chat_widget.thread_id().is_none() { self.active_profile = next_config.active_profile.clone(); self.config = next_config; + self.display_preferences.sync_from_config(&self.config); self.chat_widget .sync_config_for_profile_switch(&self.config); tui.set_notification_method(self.config.tui_notification_method); @@ -1285,9 +1288,9 @@ impl App { format!("Failed to start a fresh session after switching profiles: {err}") })? }; - self.active_profile = next_config.active_profile.clone(); self.config = next_config; + self.display_preferences.sync_from_config(&self.config); tui.set_notification_method(self.config.tui_notification_method); self.file_search .update_search_dir(self.config.cwd.to_path_buf()); @@ -1386,20 +1389,19 @@ impl App { } Ok(()) } - async fn switch_runtime_profile( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, - profile_id: &str, + profile_id: Option<&str>, ) -> std::result::Result<(), String> { - if self.active_profile.as_deref() == Some(profile_id) { + if self.active_profile.as_deref() == profile_id { return Ok(()); } let previous_override = self.harness_overrides.config_profile.clone(); let previous_active_profile = self.active_profile.clone(); - self.harness_overrides.config_profile = Some(profile_id.to_string()); + self.harness_overrides.config_profile = profile_id.map(ToOwned::to_owned); let current_cwd = self.chat_widget.config_ref().cwd.to_path_buf(); let mut next_config = match self.rebuild_config_for_cwd(current_cwd).await { @@ -1526,7 +1528,6 @@ impl App { } } } - async fn rebuild_config_for_resume_or_fallback( &mut self, current_cwd: &Path, @@ -4746,6 +4747,9 @@ impl App { AppEvent::OpenDisplayPreferencesPanel => { self.open_display_preferences_panel(); } + AppEvent::OpenProfileManagementPanel => { + self.open_profile_management_panel(); + } AppEvent::OpenThreadPanel => { self.open_thread_panel(); } @@ -4827,6 +4831,38 @@ impl App { AppEvent::UndoLastUserMessage => { self.undo_last_user_message(); } + AppEvent::SwitchRuntimeProfile { target } => { + let is_default_target = matches!(&target, RuntimeProfileTarget::Default); + let target_profile = match &target { + RuntimeProfileTarget::Default => None, + RuntimeProfileTarget::Named(profile_id) => Some(profile_id.as_str()), + }; + let target_label = target_profile.unwrap_or("default"); + if let Err(err) = self + .switch_runtime_profile(tui, app_server, target_profile) + .await + { + self.chat_widget.add_error_message(format!( + "Failed to switch to profile `{target_label}`: {err}" + )); + } else if let Err(err) = self.profile_router_store().update(|state| { + state.set_runtime_active_profile(target_profile); + }) { + self.chat_widget.add_error_message(format!( + "Switched to profile `{target_label}`, but failed to persist {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + } else if is_default_target { + self.chat_widget.add_info_message( + "Switched to the default config profile.".to_string(), + /*hint*/ None, + ); + } else { + self.chat_widget.add_info_message( + format!("Switched to profile `{target_label}`."), + /*hint*/ None, + ); + } + } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); self.insert_visible_history_cell(tui, cell); diff --git a/codex-rs/tui/src/app/profile_management.rs b/codex-rs/tui/src/app/profile_management.rs new file mode 100644 index 000000000..bc9d0c073 --- /dev/null +++ b/codex-rs/tui/src/app/profile_management.rs @@ -0,0 +1,368 @@ +use toml::Value as TomlValue; + +use super::App; +use crate::app_event::AppEvent; +use crate::app_event::RuntimeProfileTarget; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::profile_router::ProfileRouterState; + +const PROFILE_MANAGEMENT_VIEW_ID: &str = "profile-management"; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RoutedProfileSummary { + id: String, + provider_label: String, + model: Option, + base_url: Option, + route_position: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DefaultProfileSummary { + provider_label: String, + model: Option, + base_url: Option, +} + +impl App { + pub(crate) fn open_profile_management_panel(&mut self) { + let router_state = self.profile_router_store().load().unwrap_or_default(); + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(PROFILE_MANAGEMENT_VIEW_ID); + let params = profile_management_panel_params( + self.active_profile.as_deref(), + &self.default_profile_summary(), + &self.routed_profile_summaries(&router_state), + router_state.routes.len(), + self.chat_widget.is_task_running(), + initial_selected_idx, + ); + if !self + .chat_widget + .replace_selection_view_if_active(PROFILE_MANAGEMENT_VIEW_ID, params) + { + self.chat_widget + .show_selection_view(profile_management_panel_params( + self.active_profile.as_deref(), + &self.default_profile_summary(), + &self.routed_profile_summaries(&router_state), + router_state.routes.len(), + self.chat_widget.is_task_running(), + initial_selected_idx, + )); + } + } + + fn default_profile_summary(&self) -> DefaultProfileSummary { + let effective_config = self.config.config_layer_stack.effective_config(); + let table = effective_config.as_table(); + let root_provider_id = table + .and_then(|table| table.get("model_provider")) + .and_then(TomlValue::as_str) + .unwrap_or(self.config.model_provider_id.as_str()); + let root_model = table + .and_then(|table| table.get("model")) + .and_then(TomlValue::as_str) + .map(ToOwned::to_owned); + let (provider_label, base_url) = self.provider_label_and_base_url(root_provider_id); + + DefaultProfileSummary { + provider_label, + model: root_model, + base_url, + } + } + + fn routed_profile_summaries( + &self, + router_state: &ProfileRouterState, + ) -> Vec { + let effective_config = self.config.config_layer_stack.effective_config(); + let Some(table) = effective_config.as_table() else { + return Vec::new(); + }; + let root_model = table + .get("model") + .and_then(TomlValue::as_str) + .map(ToOwned::to_owned); + let root_provider_id = table + .get("model_provider") + .and_then(TomlValue::as_str) + .unwrap_or(self.config.model_provider_id.as_str()); + let Some(profiles) = table.get("profiles").and_then(TomlValue::as_table) else { + return Vec::new(); + }; + + let mut profile_ids = profiles.keys().cloned().collect::>(); + profile_ids.sort(); + + profile_ids + .into_iter() + .map(|id| { + let profile = profiles.get(&id).and_then(TomlValue::as_table); + let provider_id = profile + .and_then(|profile| profile.get("model_provider")) + .and_then(TomlValue::as_str) + .unwrap_or(root_provider_id); + let (provider_label, base_url) = self.provider_label_and_base_url(provider_id); + RoutedProfileSummary { + route_position: router_state + .routes + .iter() + .position(|route| route.profile_id == id) + .map(|index| index + 1), + model: profile + .and_then(|profile| profile.get("model")) + .and_then(TomlValue::as_str) + .map(ToOwned::to_owned) + .or_else(|| root_model.clone()), + id, + provider_label, + base_url, + } + }) + .collect() + } + + fn provider_label_and_base_url(&self, provider_id: &str) -> (String, Option) { + if provider_id == self.config.model_provider_id { + ( + self.config.model_provider.name.clone(), + self.config.model_provider.base_url.clone(), + ) + } else if let Some(provider) = self.config.model_providers.get(provider_id) { + (provider.name.clone(), provider.base_url.clone()) + } else { + (provider_id.to_string(), None) + } + } +} + +fn profile_management_panel_params( + active_profile: Option<&str>, + default_profile: &DefaultProfileSummary, + profiles: &[RoutedProfileSummary], + routed_count: usize, + task_running: bool, + initial_selected_idx: Option, +) -> SelectionViewParams { + let mut items = vec![profile_selection_item( + "Default Config".to_string(), + default_profile_description(default_profile), + active_profile.is_none(), + task_running, + RuntimeProfileTarget::Default, + )]; + + if profiles.is_empty() { + items.push(SelectionItem { + name: "No named profiles".to_string(), + description: Some("Add `[profiles.]` entries in config.toml to route API traffic through alternate endpoints.".to_string()), + is_disabled: true, + ..Default::default() + }); + } else { + items.extend(profiles.iter().cloned().map(|profile| { + profile_selection_item( + profile.id.clone(), + routed_profile_description(&profile), + active_profile == Some(profile.id.as_str()), + task_running, + RuntimeProfileTarget::Named(profile.id), + ) + })); + } + + SelectionViewParams { + view_id: Some(PROFILE_MANAGEMENT_VIEW_ID), + title: Some("Profiles".to_string()), + subtitle: Some(format!( + "Current: {} · {} named profile(s) · {} routed fallback entr{}.", + active_profile.unwrap_or("default"), + profiles.len(), + routed_count, + if routed_count == 1 { "y" } else { "ies" }, + )), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search profiles".to_string()), + initial_selected_idx, + ..Default::default() + } +} + +fn profile_selection_item( + name: String, + description: String, + is_current: bool, + task_running: bool, + target: RuntimeProfileTarget, +) -> SelectionItem { + let (is_disabled, disabled_reason) = if is_current { + (true, Some("Already active.".to_string())) + } else if task_running { + ( + true, + Some("Wait for the current task to finish before switching profiles.".to_string()), + ) + } else { + (false, None) + }; + + SelectionItem { + name: name.clone(), + description: Some(description.clone()), + selected_description: Some( + "Reload the current session with this profile while preserving input continuity." + .to_string(), + ), + is_current, + is_disabled, + disabled_reason, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SwitchRuntimeProfile { + target: target.clone(), + }); + })], + dismiss_on_select: true, + search_value: Some(format!("{name} {description}")), + ..Default::default() + } +} + +fn default_profile_description(profile: &DefaultProfileSummary) -> String { + let mut parts = vec![format!("provider: {}", profile.provider_label)]; + if let Some(base_url) = &profile.base_url { + parts.push(base_url.clone()); + } + if let Some(model) = &profile.model { + parts.push(format!("model: {model}")); + } + parts.push("root config".to_string()); + parts.join(" · ") +} + +fn routed_profile_description(profile: &RoutedProfileSummary) -> String { + let mut parts = vec![format!("provider: {}", profile.provider_label)]; + if let Some(base_url) = &profile.base_url { + parts.push(base_url.clone()); + } + if let Some(model) = &profile.model { + parts.push(format!("model: {model}")); + } + parts.push( + profile + .route_position + .map(|position| format!("fallback #{position}")) + .unwrap_or_else(|| "not in fallback route".to_string()), + ); + parts.join(" · ") +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + use super::DefaultProfileSummary; + use super::RoutedProfileSummary; + use super::profile_management_panel_params; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::ListSelectionView; + use crate::render::renderable::Renderable; + + fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|frame| { + let area = Rect::new(0, 0, width, height); + view.render(area, frame.buffer_mut()); + }) + .expect("draw popup"); + format!("{:?}", terminal.backend()) + } + + #[test] + fn profile_management_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let profiles = vec![ + RoutedProfileSummary { + id: "primary".to_string(), + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.primary.example/v1".to_string()), + route_position: Some(1), + }, + RoutedProfileSummary { + id: "secondary".to_string(), + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.secondary.example/v1".to_string()), + route_position: Some(2), + }, + ]; + let view = ListSelectionView::new( + profile_management_panel_params( + Some("primary"), + &DefaultProfileSummary { + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.openai.com/v1".to_string()), + }, + &profiles, + /*routed_count*/ 2, + /*task_running*/ false, + None, + ), + tx, + ); + + assert_snapshot!( + "profile_management_popup", + render_selection_popup(&view, 96, 22) + ); + } + + #[test] + fn profile_management_panel_disables_switches_while_task_running() { + let params = profile_management_panel_params( + Some("primary"), + &DefaultProfileSummary { + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: None, + }, + &[RoutedProfileSummary { + id: "primary".to_string(), + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: None, + route_position: Some(1), + }], + /*routed_count*/ 1, + /*task_running*/ true, + None, + ); + + assert_eq!(params.items[0].is_disabled, true); + assert_eq!(params.items[1].is_disabled, true); + assert_eq!( + params.items[0].disabled_reason.as_deref(), + Some("Wait for the current task to finish before switching profiles.") + ); + assert_eq!( + params.items[1].disabled_reason.as_deref(), + Some("Already active.") + ); + } +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup.snap new file mode 100644 index 000000000..74b0bcd64 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup.snap @@ -0,0 +1,55 @@ +--- +source: tui/src/app/profile_management.rs +expression: "render_selection_popup(&view, 96, 22)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 96, height: 22 }, + content: [ + " ", + " Profiles ", + " Current: primary · 2 named profile(s) · 2 routed fallback entries. ", + " ", + " Type to search profiles ", + "› Default Config Reload the current session with this profile while preserving ", + " input continuity. ", + " primary (current) (disabled) provider: OpenAI · https://api.primary.example/v1 · model: ", + " gpt-5 · fallback #1 (disabled: Already active.) ", + " secondary provider: OpenAI · https://api.secondary.example/v1 · model: ", + " gpt-5 · fallback #2 ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 10, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 68, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 25, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 93, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 49, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 90, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 79, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 32, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 92, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 32, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 21, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 96, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 832180552..0f73c5d70 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -66,6 +66,12 @@ impl RealtimeAudioDeviceKind { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RuntimeProfileTarget { + Default, + Named(String), +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxEnableMode { @@ -112,6 +118,9 @@ pub(crate) enum AppEvent { /// Open the local TUI display preferences panel. OpenDisplayPreferencesPanel, + /// Open the routed profile management panel. + OpenProfileManagementPanel, + /// Open thread-specific actions for the current conversation. OpenThreadPanel, @@ -129,6 +138,11 @@ pub(crate) enum AppEvent { /// Restore the last user input and roll back one committed turn. UndoLastUserMessage, + /// Switch the current runtime to the selected config profile. + SwitchRuntimeProfile { + target: RuntimeProfileTarget, + }, + /// Request to exit the application. /// /// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e2f395c51..4be0bbc2c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5165,6 +5165,9 @@ impl ChatWidget { SlashCommand::Thread => { self.app_event_tx.send(AppEvent::OpenThreadPanel); } + SlashCommand::Profile => { + self.app_event_tx.send(AppEvent::OpenProfileManagementPanel); + } SlashCommand::Init => { let init_target = match self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME) { Ok(path) => path, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index b885a9b72..e7d56888c 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -103,6 +103,15 @@ async fn slash_thread_dispatches_open_thread_panel_event() { assert_matches!(rx.try_recv(), Ok(AppEvent::OpenThreadPanel)); } +#[tokio::test] +async fn slash_profile_dispatches_open_profile_management_panel_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Profile); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenProfileManagementPanel)); +} + #[tokio::test] async fn slash_btw_dispatches_start_event() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/profile_router.rs b/codex-rs/tui/src/profile_router.rs index 6c8e2b519..1ae649dab 100644 --- a/codex-rs/tui/src/profile_router.rs +++ b/codex-rs/tui/src/profile_router.rs @@ -15,7 +15,6 @@ pub(crate) enum ProfileFallbackAction { RetrySameProfileFirst, SwitchProfileImmediately, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ProfileRouteEntry { @@ -52,6 +51,19 @@ impl ProfileRouterState { return false; } let next = Some(profile_id.to_string()); + + if self.active_profile_id == next { + false + } else { + self.active_profile_id = next; + true + } + } + + pub(crate) fn set_runtime_active_profile(&mut self, profile_id: Option<&str>) -> bool { + let next = profile_id + .filter(|profile_id| self.contains_profile(profile_id)) + .map(ToOwned::to_owned); if self.active_profile_id == next { false } else { @@ -77,7 +89,6 @@ impl DefaultProfileRouter { .map(|route| route.profile_id.clone()) } } - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ProfileRouterStore { codex_home: PathBuf, @@ -179,7 +190,6 @@ pub(crate) fn app_server_profile_fallback_action( } } -#[cfg(test)] mod tests { use super::DefaultProfileRouter; use super::ProfileFallbackAction; @@ -243,4 +253,18 @@ mod tests { assert_eq!(action, Some(ProfileFallbackAction::RetrySameProfileFirst)); } + + #[test] + fn runtime_active_profile_clears_when_selected_profile_is_not_routed() { + let mut state = ProfileRouterState { + version: 1, + active_profile_id: Some("secondary".to_string()), + routes: vec![ProfileRouteEntry { + profile_id: "secondary".to_string(), + }], + }; + + assert!(state.set_runtime_active_profile(Some("missing"))); + assert_eq!(state.active_profile_id, None); + } } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 8423518e5..0084e8a4d 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -28,6 +28,7 @@ pub enum SlashCommand { Resume, Fork, Thread, + Profile, Init, Compact, Plan, @@ -83,6 +84,7 @@ impl SlashCommand { SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat", SlashCommand::Thread => "open thread actions for the current conversation", + SlashCommand::Profile => "show and switch routed API profiles", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", @@ -181,6 +183,7 @@ impl SlashCommand { | SlashCommand::Plugins | SlashCommand::Workflow | SlashCommand::Thread + | SlashCommand::Profile | SlashCommand::Feedback | SlashCommand::Quit | SlashCommand::Exit => true, From f99eebfcc7ca8c35b5babe4d83882b901d5ecdd0 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 19:05:51 +0800 Subject: [PATCH 30/60] replay: restore startup tooltip display preference --- codex-rs/tui/src/display_preferences.rs | 56 +++++++++++++++++++ codex-rs/tui/src/display_preferences_menu.rs | 19 +++++-- ...menu__tests__display_preferences_menu.snap | 13 ++++- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/display_preferences.rs b/codex-rs/tui/src/display_preferences.rs index 502ca50d4..7672a805d 100644 --- a/codex-rs/tui/src/display_preferences.rs +++ b/codex-rs/tui/src/display_preferences.rs @@ -7,11 +7,13 @@ use codex_core::config::edit::ConfigEdit; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DisplayPreferenceKey { + StartupTooltips, RawThinking, } #[derive(Clone, Debug, Default)] pub(crate) struct DisplayPreferences { + show_startup_tooltips: Arc, show_raw_thinking: Arc, } @@ -24,23 +26,33 @@ impl DisplayPreferences { pub(crate) fn is_enabled(&self, key: DisplayPreferenceKey) -> bool { match key { + DisplayPreferenceKey::StartupTooltips => self.show_startup_tooltips(), DisplayPreferenceKey::RawThinking => self.show_raw_thinking(), } } pub(crate) fn set_enabled(&self, key: DisplayPreferenceKey, enabled: bool) { match key { + DisplayPreferenceKey::StartupTooltips => { + self.show_startup_tooltips.store(enabled, Ordering::Relaxed); + } DisplayPreferenceKey::RawThinking => { self.show_raw_thinking.store(enabled, Ordering::Relaxed); } } } + pub(crate) fn show_startup_tooltips(&self) -> bool { + self.show_startup_tooltips.load(Ordering::Relaxed) + } + pub(crate) fn show_raw_thinking(&self) -> bool { self.show_raw_thinking.load(Ordering::Relaxed) } pub(crate) fn sync_from_config(&self, config: &Config) { + self.show_startup_tooltips + .store(config.show_tooltips, Ordering::Relaxed); self.show_raw_thinking .store(config.show_raw_agent_reasoning, Ordering::Relaxed); } @@ -48,6 +60,10 @@ impl DisplayPreferences { pub(crate) fn display_preference_edit(key: DisplayPreferenceKey, enabled: bool) -> ConfigEdit { match key { + DisplayPreferenceKey::StartupTooltips => ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "show_tooltips".to_string()], + value: enabled.into(), + }, DisplayPreferenceKey::RawThinking => ConfigEdit::SetPath { segments: vec!["show_raw_agent_reasoning".to_string()], value: enabled.into(), @@ -61,6 +77,46 @@ pub(crate) fn set_display_preference_in_config( enabled: bool, ) { match key { + DisplayPreferenceKey::StartupTooltips => config.show_tooltips = enabled, DisplayPreferenceKey::RawThinking => config.show_raw_agent_reasoning = enabled, } } + +#[cfg(test)] +mod tests { + use super::DisplayPreferenceKey; + use super::DisplayPreferences; + use super::display_preference_edit; + use super::set_display_preference_in_config; + use codex_core::config::ConfigBuilder; + use codex_core::config::edit::ConfigEdit; + + #[tokio::test] + async fn startup_tooltips_follow_config_and_setters() { + let mut config = ConfigBuilder::default().build().await.expect("config"); + config.show_tooltips = false; + + let preferences = DisplayPreferences::from_config(&config); + assert!(!preferences.show_startup_tooltips()); + + preferences.set_enabled(DisplayPreferenceKey::StartupTooltips, true); + assert!(preferences.show_startup_tooltips()); + + set_display_preference_in_config(&mut config, DisplayPreferenceKey::StartupTooltips, true); + assert!(config.show_tooltips); + } + + #[test] + fn startup_tooltips_edit_targets_tui_show_tooltips() { + match display_preference_edit(DisplayPreferenceKey::StartupTooltips, false) { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + vec!["tui".to_string(), "show_tooltips".to_string()] + ); + assert_eq!(value.to_string(), "false"); + } + other => panic!("unexpected config edit: {other:?}"), + } + } +} diff --git a/codex-rs/tui/src/display_preferences_menu.rs b/codex-rs/tui/src/display_preferences_menu.rs index c3dc83d4e..14b65a917 100644 --- a/codex-rs/tui/src/display_preferences_menu.rs +++ b/codex-rs/tui/src/display_preferences_menu.rs @@ -11,10 +11,13 @@ pub(crate) const DISPLAY_PREFERENCES_SELECTION_VIEW_ID: &str = "display-preferen pub(crate) fn display_preferences_items( display_preferences: &DisplayPreferences, ) -> Vec { - [DisplayPreferenceKey::RawThinking] - .into_iter() - .map(|key| display_preference_item(display_preferences, key)) - .collect() + [ + DisplayPreferenceKey::StartupTooltips, + DisplayPreferenceKey::RawThinking, + ] + .into_iter() + .map(|key| display_preference_item(display_preferences, key)) + .collect() } pub(crate) fn display_preferences_panel_params( @@ -43,6 +46,14 @@ fn display_preference_item( ) -> SelectionItem { let enabled = display_preferences.is_enabled(key); let (name, description) = match (key, enabled) { + (DisplayPreferenceKey::StartupTooltips, true) => ( + "Hide Startup Tooltips", + "Currently visible. Hide first-run and local startup tooltip hints in this TUI.", + ), + (DisplayPreferenceKey::StartupTooltips, false) => ( + "Show Startup Tooltips", + "Currently hidden. Restore first-run and local startup tooltip hints in this TUI.", + ), (DisplayPreferenceKey::RawThinking, true) => ( "Hide Raw Thinking", "Currently visible. Hide raw reasoning text while keeping summaries.", diff --git a/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap b/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap index 9fc362136..586e2294e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap @@ -1,14 +1,21 @@ --- source: tui/src/display_preferences_menu.rs +assertion_line: 123 expression: render_lines(&view) --- UI These settings only affect local TUI renderi -› 1. Show Raw Thinking Currently hidden. - Reveal raw reasoning - text in this TUI only. +› 1. Show Startup Tooltips Currently hidden. + Restore first-run + and local startup + tooltip hints in + this TUI. + 2. Show Raw Thinking Currently hidden. + Reveal raw + reasoning text in + this TUI only. Model context and persisted rollout history are unchanged. From 83ad3dfe2b89585251cd423eda3d279807c2a7ae Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 19:33:47 +0800 Subject: [PATCH 31/60] replay: restore transcript visibility display preferences --- codex-rs/config/src/types.rs | 27 +++ codex-rs/core/config.schema.json | 28 +++ codex-rs/core/src/config/config_tests.rs | 32 ++++ codex-rs/core/src/config/mod.rs | 9 + codex-rs/tui/src/chatwidget.rs | 8 +- codex-rs/tui/src/display_preferences.rs | 117 +++++++++++- codex-rs/tui/src/display_preferences_menu.rs | 18 ++ codex-rs/tui/src/history_cell.rs | 174 +++++++++++++++++- codex-rs/tui/src/pager_overlay.rs | 12 +- ...menu__tests__display_preferences_menu.snap | 17 +- 10 files changed, 426 insertions(+), 16 deletions(-) diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index c52383352..e7441084f 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -450,6 +450,29 @@ pub struct ModelAvailabilityNuxConfig { pub shown_count: HashMap, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiDisplayPreferences { + /// Show MCP/custom tool result bodies in transcript cells. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_tool_results: bool, + + /// Show patch/edit diff summaries in transcript cells. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_patch_diffs: bool, +} + +impl Default for TuiDisplayPreferences { + fn default() -> Self { + Self { + show_tool_results: true, + show_patch_diffs: true, + } + } +} + /// Collection of settings that are specific to the TUI. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -510,6 +533,10 @@ pub struct Tui { /// Startup tooltip availability NUX state persisted by the TUI. #[serde(default)] pub model_availability_nux: ModelAvailabilityNuxConfig, + + /// Transcript visibility preferences that affect only local TUI rendering. + #[serde(default)] + pub display_preferences: TuiDisplayPreferences, } const fn default_true() -> bool { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7f5be535e..696d6fe45 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1735,6 +1735,18 @@ "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", "type": "boolean" }, + "display_preferences": { + "allOf": [ + { + "$ref": "#/definitions/TuiDisplayPreferences" + } + ], + "default": { + "show_patch_diffs": true, + "show_tool_results": true + }, + "description": "Transcript visibility preferences that affect only local TUI rendering." + }, "model_availability_nux": { "allOf": [ { @@ -1791,6 +1803,22 @@ }, "type": "object" }, + "TuiDisplayPreferences": { + "additionalProperties": false, + "properties": { + "show_patch_diffs": { + "default": true, + "description": "Show patch/edit diff summaries in transcript cells. Defaults to `true`.", + "type": "boolean" + }, + "show_tool_results": { + "default": true, + "description": "Show MCP/custom tool result bodies in transcript cells. Defaults to `true`.", + "type": "boolean" + } + }, + "type": "object" + }, "UriBasedFileOpener": { "oneOf": [ { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 384458a69..87f43d02d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -18,6 +18,7 @@ use codex_config::types::ModelAvailabilityNuxConfig; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; use codex_config::types::ToolSuggestDiscoverableType; +use codex_config::types::TuiDisplayPreferences; use codex_features::Feature; use codex_features::FeaturesToml; use codex_model_provider_info::WireApi; @@ -293,6 +294,7 @@ fn config_toml_deserializes_model_availability_nux() { ("gpt-foo".to_string(), 2), ]), }, + display_preferences: TuiDisplayPreferences::default(), } ); } @@ -310,6 +312,31 @@ fn runtime_config_defaults_model_availability_nux() { cfg.model_availability_nux, ModelAvailabilityNuxConfig::default() ); + assert_eq!( + cfg.tui_display_preferences, + TuiDisplayPreferences::default() + ); +} + +#[test] +fn config_toml_deserializes_tui_display_preferences() { + let toml = r#" +[tui.display_preferences] +show_tool_results = false +show_patch_diffs = false +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for TUI display prefs"); + + assert_eq!( + cfg.tui + .expect("tui config should deserialize") + .display_preferences, + TuiDisplayPreferences { + show_tool_results: false, + show_patch_diffs: false, + } + ); } #[test] @@ -985,6 +1012,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { terminal_title: None, theme: None, model_availability_nux: ModelAvailabilityNuxConfig::default(), + display_preferences: TuiDisplayPreferences::default(), } ); } @@ -4519,6 +4547,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4664,6 +4693,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4807,6 +4837,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(false), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4936,6 +4967,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8eb069133..d0f72ece6 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -48,6 +48,7 @@ use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::Tui; +use codex_config::types::TuiDisplayPreferences; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_config::types::WindowsToml; @@ -337,6 +338,9 @@ pub struct Config { /// Persisted startup availability NUX state for model tooltips. pub model_availability_nux: ModelAvailabilityNuxConfig, + /// Transcript visibility preferences that affect only local TUI rendering. + pub tui_display_preferences: TuiDisplayPreferences, + /// Start the TUI in the specified collaboration mode (plan/default). /// Controls whether the TUI uses the terminal's alternate screen buffer. @@ -2780,6 +2784,11 @@ impl Config { .as_ref() .map(|t| t.model_availability_nux.clone()) .unwrap_or_default(), + tui_display_preferences: cfg + .tui + .as_ref() + .map(|t| t.display_preferences.clone()) + .unwrap_or_default(), tui_alternate_screen: cfg .tui .as_ref() diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4be0bbc2c..858172c8a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3442,7 +3442,10 @@ impl ChatWidget { .iter() .map(|path| path.display().to_string()) .collect::>(); - history_cell::new_guardian_denied_patch_request(files) + history_cell::new_guardian_denied_patch_request( + files, + self.display_preferences.clone(), + ) } GuardianAssessmentAction::McpToolCall { server, tool_name, .. @@ -3582,6 +3585,7 @@ impl ChatWidget { self.add_to_history(history_cell::new_patch_event( event.changes, &self.config.cwd, + self.display_preferences.clone(), )); } @@ -4569,6 +4573,7 @@ impl ChatWidget { ev.call_id, ev.invocation, self.config.animations, + self.display_preferences.clone(), ))); self.bump_active_cell_revision(); self.request_redraw(); @@ -4595,6 +4600,7 @@ impl ChatWidget { call_id, invocation, self.config.animations, + self.display_preferences.clone(), ); let extra_cell = cell.complete(duration, result); self.active_cell = Some(Box::new(cell)); diff --git a/codex-rs/tui/src/display_preferences.rs b/codex-rs/tui/src/display_preferences.rs index 7672a805d..35857532a 100644 --- a/codex-rs/tui/src/display_preferences.rs +++ b/codex-rs/tui/src/display_preferences.rs @@ -9,12 +9,27 @@ use codex_core::config::edit::ConfigEdit; pub(crate) enum DisplayPreferenceKey { StartupTooltips, RawThinking, + ToolResults, + PatchDiffs, } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub(crate) struct DisplayPreferences { show_startup_tooltips: Arc, show_raw_thinking: Arc, + show_tool_results: Arc, + show_patch_diffs: Arc, +} + +impl Default for DisplayPreferences { + fn default() -> Self { + Self { + show_startup_tooltips: Arc::new(AtomicBool::new(true)), + show_raw_thinking: Arc::new(AtomicBool::new(false)), + show_tool_results: Arc::new(AtomicBool::new(true)), + show_patch_diffs: Arc::new(AtomicBool::new(true)), + } + } } impl DisplayPreferences { @@ -28,6 +43,8 @@ impl DisplayPreferences { match key { DisplayPreferenceKey::StartupTooltips => self.show_startup_tooltips(), DisplayPreferenceKey::RawThinking => self.show_raw_thinking(), + DisplayPreferenceKey::ToolResults => self.show_tool_results(), + DisplayPreferenceKey::PatchDiffs => self.show_patch_diffs(), } } @@ -39,6 +56,12 @@ impl DisplayPreferences { DisplayPreferenceKey::RawThinking => { self.show_raw_thinking.store(enabled, Ordering::Relaxed); } + DisplayPreferenceKey::ToolResults => { + self.show_tool_results.store(enabled, Ordering::Relaxed); + } + DisplayPreferenceKey::PatchDiffs => { + self.show_patch_diffs.store(enabled, Ordering::Relaxed); + } } } @@ -50,11 +73,27 @@ impl DisplayPreferences { self.show_raw_thinking.load(Ordering::Relaxed) } + pub(crate) fn show_tool_results(&self) -> bool { + self.show_tool_results.load(Ordering::Relaxed) + } + + pub(crate) fn show_patch_diffs(&self) -> bool { + self.show_patch_diffs.load(Ordering::Relaxed) + } + pub(crate) fn sync_from_config(&self, config: &Config) { self.show_startup_tooltips .store(config.show_tooltips, Ordering::Relaxed); self.show_raw_thinking .store(config.show_raw_agent_reasoning, Ordering::Relaxed); + self.show_tool_results.store( + config.tui_display_preferences.show_tool_results, + Ordering::Relaxed, + ); + self.show_patch_diffs.store( + config.tui_display_preferences.show_patch_diffs, + Ordering::Relaxed, + ); } } @@ -68,6 +107,22 @@ pub(crate) fn display_preference_edit(key: DisplayPreferenceKey, enabled: bool) segments: vec!["show_raw_agent_reasoning".to_string()], value: enabled.into(), }, + DisplayPreferenceKey::ToolResults => ConfigEdit::SetPath { + segments: vec![ + "tui".to_string(), + "display_preferences".to_string(), + "show_tool_results".to_string(), + ], + value: enabled.into(), + }, + DisplayPreferenceKey::PatchDiffs => ConfigEdit::SetPath { + segments: vec![ + "tui".to_string(), + "display_preferences".to_string(), + "show_patch_diffs".to_string(), + ], + value: enabled.into(), + }, } } @@ -79,6 +134,12 @@ pub(crate) fn set_display_preference_in_config( match key { DisplayPreferenceKey::StartupTooltips => config.show_tooltips = enabled, DisplayPreferenceKey::RawThinking => config.show_raw_agent_reasoning = enabled, + DisplayPreferenceKey::ToolResults => { + config.tui_display_preferences.show_tool_results = enabled; + } + DisplayPreferenceKey::PatchDiffs => { + config.tui_display_preferences.show_patch_diffs = enabled; + } } } @@ -106,6 +167,27 @@ mod tests { assert!(config.show_tooltips); } + #[tokio::test] + async fn transcript_visibility_preferences_follow_config_and_setters() { + let mut config = ConfigBuilder::default().build().await.expect("config"); + config.tui_display_preferences.show_tool_results = false; + config.tui_display_preferences.show_patch_diffs = false; + + let preferences = DisplayPreferences::from_config(&config); + assert!(!preferences.show_tool_results()); + assert!(!preferences.show_patch_diffs()); + + preferences.set_enabled(DisplayPreferenceKey::ToolResults, true); + preferences.set_enabled(DisplayPreferenceKey::PatchDiffs, true); + assert!(preferences.show_tool_results()); + assert!(preferences.show_patch_diffs()); + + set_display_preference_in_config(&mut config, DisplayPreferenceKey::ToolResults, true); + set_display_preference_in_config(&mut config, DisplayPreferenceKey::PatchDiffs, true); + assert!(config.tui_display_preferences.show_tool_results); + assert!(config.tui_display_preferences.show_patch_diffs); + } + #[test] fn startup_tooltips_edit_targets_tui_show_tooltips() { match display_preference_edit(DisplayPreferenceKey::StartupTooltips, false) { @@ -119,4 +201,37 @@ mod tests { other => panic!("unexpected config edit: {other:?}"), } } + + #[test] + fn transcript_visibility_edits_target_tui_display_preferences() { + match display_preference_edit(DisplayPreferenceKey::ToolResults, false) { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + vec![ + "tui".to_string(), + "display_preferences".to_string(), + "show_tool_results".to_string(), + ] + ); + assert_eq!(value.to_string(), "false"); + } + other => panic!("unexpected config edit: {other:?}"), + } + + match display_preference_edit(DisplayPreferenceKey::PatchDiffs, false) { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + vec![ + "tui".to_string(), + "display_preferences".to_string(), + "show_patch_diffs".to_string(), + ] + ); + assert_eq!(value.to_string(), "false"); + } + other => panic!("unexpected config edit: {other:?}"), + } + } } diff --git a/codex-rs/tui/src/display_preferences_menu.rs b/codex-rs/tui/src/display_preferences_menu.rs index 14b65a917..1a6f2cb25 100644 --- a/codex-rs/tui/src/display_preferences_menu.rs +++ b/codex-rs/tui/src/display_preferences_menu.rs @@ -14,6 +14,8 @@ pub(crate) fn display_preferences_items( [ DisplayPreferenceKey::StartupTooltips, DisplayPreferenceKey::RawThinking, + DisplayPreferenceKey::ToolResults, + DisplayPreferenceKey::PatchDiffs, ] .into_iter() .map(|key| display_preference_item(display_preferences, key)) @@ -62,6 +64,22 @@ fn display_preference_item( "Show Raw Thinking", "Currently hidden. Reveal raw reasoning text in this TUI only.", ), + (DisplayPreferenceKey::ToolResults, true) => ( + "Hide Tool Results", + "Currently visible. Collapse MCP/custom tool result bodies in transcript cells.", + ), + (DisplayPreferenceKey::ToolResults, false) => ( + "Show Tool Results", + "Currently hidden. Reveal MCP/custom tool result bodies in transcript cells.", + ), + (DisplayPreferenceKey::PatchDiffs, true) => ( + "Hide Patch Diffs", + "Currently visible. Collapse patch/edit diff summaries in transcript cells.", + ), + (DisplayPreferenceKey::PatchDiffs, false) => ( + "Show Patch Diffs", + "Currently hidden. Reveal patch/edit diff summaries in transcript cells.", + ), }; SelectionItem { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 278d8ea4f..ac288cf94 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1041,19 +1041,26 @@ impl ApprovalDecisionActor { } } -pub fn new_guardian_denied_patch_request(files: Vec) -> Box { +pub fn new_guardian_denied_patch_request( + files: Vec, + display_preferences: DisplayPreferences, +) -> Box { let mut summary = vec![ "Request ".into(), "denied".bold(), " for codex to apply ".into(), ]; - if files.len() == 1 { + if display_preferences.show_patch_diffs() && files.len() == 1 { summary.push("a patch touching ".into()); summary.push(Span::from(files[0].clone()).dim()); } else { + let noun = if files.len() == 1 { "file" } else { "files" }; summary.push("a patch touching ".into()); summary.push(Span::from(files.len().to_string()).dim()); - summary.push(" files".into()); + summary.push(format!(" {noun}").into()); + if !display_preferences.show_patch_diffs() { + summary.push(" (diff hidden)".dim()); + } } Box::new(PrefixedWrappedHistoryCell::new( @@ -1094,10 +1101,20 @@ pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { pub(crate) struct PatchHistoryCell { changes: HashMap, cwd: PathBuf, + display_preferences: DisplayPreferences, } impl HistoryCell for PatchHistoryCell { fn display_lines(&self, width: u16) -> Vec> { + if !self.display_preferences.show_patch_diffs() { + let file_count = self.changes.len(); + let noun = if file_count == 1 { "file" } else { "files" }; + return vec![ + format!("• Edited {file_count} {noun} (diff hidden)") + .dim() + .into(), + ]; + } create_diff_summary(&self.changes, &self.cwd, width as usize) } } @@ -1105,9 +1122,13 @@ impl HistoryCell for PatchHistoryCell { #[derive(Debug)] struct CompletedMcpToolCallWithImageOutput { _image: DynamicImage, + display_preferences: DisplayPreferences, } impl HistoryCell for CompletedMcpToolCallWithImageOutput { fn display_lines(&self, _width: u16) -> Vec> { + if !self.display_preferences.show_tool_results() { + return Vec::new(); + } vec!["tool result (image output)".into()] } } @@ -1524,6 +1545,7 @@ pub(crate) struct McpToolCallCell { duration: Option, result: Option>, animations_enabled: bool, + display_preferences: DisplayPreferences, } impl McpToolCallCell { @@ -1531,6 +1553,7 @@ impl McpToolCallCell { call_id: String, invocation: McpInvocation, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> Self { Self { call_id, @@ -1539,6 +1562,7 @@ impl McpToolCallCell { duration: None, result: None, animations_enabled, + display_preferences, } } @@ -1551,8 +1575,11 @@ impl McpToolCallCell { duration: Duration, result: Result, ) -> Option> { - let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result) - .map(|cell| Box::new(cell) as Box); + let image_cell = try_new_completed_mcp_tool_call_with_image_output( + &result, + self.display_preferences.clone(), + ) + .map(|cell| Box::new(cell) as Box); self.duration = Some(duration); self.result = Some(result); image_cell @@ -1644,7 +1671,9 @@ impl HistoryCell for McpToolCallCell { // Reserve four columns for the tree prefix (" └ "/" ") and ensure the wrapper still has at least one cell to work with. let detail_wrap_width = (width as usize).saturating_sub(4).max(1); - if let Some(result) = &self.result { + if self.display_preferences.show_tool_results() + && let Some(result) = &self.result + { match result { Ok(codex_protocol::mcp::CallToolResult { content, .. }) => { if !content.is_empty() { @@ -1705,8 +1734,9 @@ pub(crate) fn new_active_mcp_tool_call( call_id: String, invocation: McpInvocation, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> McpToolCallCell { - McpToolCallCell::new(call_id, invocation, animations_enabled) + McpToolCallCell::new(call_id, invocation, animations_enabled, display_preferences) } fn web_search_header(completed: bool) -> &'static str { @@ -1813,6 +1843,7 @@ pub(crate) fn new_web_search_call( /// even when the first block is not a valid image. fn try_new_completed_mcp_tool_call_with_image_output( result: &Result, + display_preferences: DisplayPreferences, ) -> Option { let image = result .as_ref() @@ -1821,7 +1852,10 @@ fn try_new_completed_mcp_tool_call_with_image_output( .iter() .find_map(decode_mcp_image)?; - Some(CompletedMcpToolCallWithImageOutput { _image: image }) + Some(CompletedMcpToolCallWithImageOutput { + _image: image, + display_preferences, + }) } /// Decodes an MCP `ImageContent` block into an in-memory image. @@ -2606,10 +2640,12 @@ impl HistoryCell for PlanUpdateCell { pub(crate) fn new_patch_event( changes: HashMap, cwd: &Path, + display_preferences: DisplayPreferences, ) -> PatchHistoryCell { PatchHistoryCell { changes, cwd: cwd.to_path_buf(), + display_preferences, } } @@ -3712,6 +3748,7 @@ mod tests { "call-1".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n"); @@ -3748,6 +3785,7 @@ mod tests { "call-2".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_millis(1420), Ok(result)) @@ -3783,6 +3821,7 @@ mod tests { "call-image".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); let extra_cell = cell .complete(Duration::from_millis(25), Ok(result)) @@ -3814,6 +3853,7 @@ mod tests { "call-image-data-url".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); let extra_cell = cell .complete(Duration::from_millis(25), Ok(result)) @@ -3823,6 +3863,41 @@ mod tests { assert_eq!(rendered, vec!["tool result (image output)"]); } + #[test] + fn completed_mcp_tool_call_hides_image_result_placeholder_when_disabled() { + let invocation = McpInvocation { + server: "image".into(), + tool: "generate".into(), + arguments: Some(json!({ + "prompt": "tiny image", + })), + }; + + let result = CallToolResult { + content: vec![image_block(SMALL_PNG_BASE64)], + is_error: None, + structured_content: None, + meta: None, + }; + + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + false, + ); + let mut cell = new_active_mcp_tool_call( + "call-image-hidden".into(), + invocation, + /*animations_enabled*/ true, + display_preferences, + ); + let extra_cell = cell + .complete(Duration::from_millis(25), Ok(result)) + .expect("expected image cell"); + + assert!(extra_cell.display_lines(/*width*/ 80).is_empty()); + } + #[test] fn completed_mcp_tool_call_skips_invalid_image_blocks() { let invocation = McpInvocation { @@ -3844,6 +3919,7 @@ mod tests { "call-image-2".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); let extra_cell = cell .complete(Duration::from_millis(25), Ok(result)) @@ -3868,6 +3944,7 @@ mod tests { "call-3".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_secs(2), Err("network timeout".into())) @@ -3879,6 +3956,47 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn completed_mcp_tool_call_hides_text_results_when_disabled() { + let invocation = McpInvocation { + server: "search".into(), + tool: "find_docs".into(), + arguments: Some(json!({ + "query": "ratatui styling", + "limit": 3, + })), + }; + + let result = CallToolResult { + content: vec![text_block("Found styling guidance in styles.md")], + is_error: None, + structured_content: None, + meta: None, + }; + + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + false, + ); + let mut cell = new_active_mcp_tool_call( + "call-hidden-text".into(), + invocation, + /*animations_enabled*/ true, + display_preferences, + ); + assert!( + cell.complete(Duration::from_millis(200), Ok(result)) + .is_none() + ); + + let rendered = render_lines(&cell.display_lines(/*width*/ 80)); + assert_eq!( + rendered, + vec!["• Called search.find_docs({\"query\":\"ratatui styling\",\"limit\":3})"] + ); + } + #[test] fn completed_mcp_tool_call_multiple_outputs_snapshot() { let invocation = McpInvocation { @@ -3911,6 +4029,7 @@ mod tests { "call-4".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_millis(640), Ok(result)) @@ -3922,6 +4041,43 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn patch_event_hides_diff_summary_when_disabled() { + let mut changes = HashMap::new(); + changes.insert( + PathBuf::from("src/lib.rs"), + FileChange::Add { + content: "pub fn demo() {}\n".to_string(), + }, + ); + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::PatchDiffs, + false, + ); + + let cell = new_patch_event(changes, Path::new("/tmp/project"), display_preferences); + let rendered = render_lines(&cell.display_lines(/*width*/ 80)); + assert_eq!(rendered, vec!["• Edited 1 file (diff hidden)"]); + } + + #[test] + fn guardian_denied_patch_request_hides_diff_details_when_disabled() { + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::PatchDiffs, + false, + ); + + let cell = + new_guardian_denied_patch_request(vec!["src/lib.rs".to_string()], display_preferences); + let rendered = render_transcript(cell.as_ref()); + assert_eq!( + rendered, + vec!["✗ Request denied for codex to apply a patch touching 1 file (diff hidden)"] + ); + } + #[test] fn completed_mcp_tool_call_wrapped_outputs_snapshot() { let invocation = McpInvocation { @@ -3946,6 +4102,7 @@ mod tests { "call-5".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_millis(1280), Ok(result)) @@ -3982,6 +4139,7 @@ mod tests { "call-6".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_millis(320), Ok(result)) diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 53a7734f1..6edf28852 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -997,7 +997,11 @@ mod tests { content: "hello\nworld\n".to_string(), }, ); - let approval_cell: Arc = Arc::new(new_patch_event(approval_changes, &cwd)); + let approval_cell: Arc = Arc::new(new_patch_event( + approval_changes, + &cwd, + crate::display_preferences::DisplayPreferences::default(), + )); cells.push(approval_cell); let mut apply_changes = HashMap::new(); @@ -1007,7 +1011,11 @@ mod tests { content: "hello\nworld\n".to_string(), }, ); - let apply_begin_cell: Arc = Arc::new(new_patch_event(apply_changes, &cwd)); + let apply_begin_cell: Arc = Arc::new(new_patch_event( + apply_changes, + &cwd, + crate::display_preferences::DisplayPreferences::default(), + )); cells.push(apply_begin_cell); let apply_end_cell: Arc = history_cell::new_approval_decision_cell( diff --git a/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap b/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap index 586e2294e..c9b730000 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap @@ -1,21 +1,30 @@ --- source: tui/src/display_preferences_menu.rs -assertion_line: 123 expression: render_lines(&view) --- UI These settings only affect local TUI renderi -› 1. Show Startup Tooltips Currently hidden. - Restore first-run - and local startup +› 1. Hide Startup Tooltips Currently visible. + Hide first-run and + local startup tooltip hints in this TUI. 2. Show Raw Thinking Currently hidden. Reveal raw reasoning text in this TUI only. + 3. Hide Tool Results Currently visible. + Collapse MCP/ + custom tool result + bodies in + transcript cells. + 4. Hide Patch Diffs Currently visible. + Collapse patch/ + edit diff + summaries in + transcript cells. Model context and persisted rollout history are unchanged. From 36b871b26a3748e6c25014664ef2f4df3ff95d5b Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 00:14:30 +0800 Subject: [PATCH 32/60] fix: improve hidden btw status and rejection handling --- codex-rs/tui/src/app.rs | 70 ++++++++- codex-rs/tui/src/app/app_server_adapter.rs | 16 ++ codex-rs/tui/src/app/btw.rs | 162 ++++++++++++++++++--- 3 files changed, 223 insertions(+), 25 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a3e329e6b..b1bc3f530 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -10267,6 +10267,22 @@ jobs: } } + fn request_user_input_request( + thread_id: ThreadId, + turn_id: &str, + item_id: &str, + ) -> ServerRequest { + ServerRequest::ToolRequestUserInput { + request_id: AppServerRequestId::Integer(99), + params: ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + questions: Vec::new(), + }, + } + } + #[test] fn thread_event_store_tracks_active_turn_lifecycle() { let mut store = ThreadEventStore::new(/*capacity*/ 8); @@ -12489,6 +12505,7 @@ model = "gpt-5.2" app.btw_session = Some(BtwSessionState { thread_id, final_message: None, + last_status: None, }); let swallowed = app.handle_btw_notification( @@ -12514,6 +12531,57 @@ model = "gpt-5.2" } } + #[tokio::test] + async fn btw_loading_popup_surfaces_hidden_hook_status() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.btw_session = Some(BtwSessionState { + thread_id, + final_message: None, + last_status: None, + }); + + let swallowed = app + .handle_btw_notification(thread_id, &hook_started_notification(thread_id, "turn-btw")); + + assert!(swallowed); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!( + popup.contains("Current hidden status:") + && popup.contains("Running UserPromptSubmit hook: checking") + && popup.contains("go-workflow input policy"), + "expected hidden hook status in /btw popup: {popup}" + ); + } + + #[tokio::test] + async fn btw_request_user_input_opens_failure_popup_instead_of_hanging() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.btw_session = Some(BtwSessionState { + thread_id, + final_message: None, + last_status: None, + }); + + let reason = app.reject_btw_request( + thread_id, + &request_user_input_request(thread_id, "turn-btw", "call-1"), + ); + + assert_eq!( + reason, + Some( + "the hidden temporary discussion asked for interactive user input. Run the prompt in the main thread instead.".to_string() + ) + ); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!( + popup.contains("asked for interactive user input"), + "expected /btw failure popup for hidden request_user_input: {popup}" + ); + } + #[tokio::test] async fn btw_insert_summary_appends_to_existing_composer() -> Result<()> { let mut app = make_test_app().await; @@ -12523,6 +12591,7 @@ model = "gpt-5.2" app.btw_session = Some(BtwSessionState { thread_id, final_message: Some("First point.\n\nSecond point.".to_string()), + last_status: None, }); app.chat_widget .set_composer_text("Existing draft".to_string(), Vec::new(), Vec::new()); @@ -12536,7 +12605,6 @@ model = "gpt-5.2" assert!(app.btw_session.is_none()); Ok(()) } - async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() { let mut app = make_test_app().await; let thread_id = ThreadId::new(); diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index cdcd2ef0f..0c4e842c5 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -274,6 +274,22 @@ impl App { return; }; + if let Some(reason) = self.reject_btw_request(thread_id, &request) { + if let Err(err) = self + .reject_app_server_request(app_server_client, request.id().clone(), reason) + .await + { + tracing::warn!("{err}"); + } + return; + } + + if self + .maybe_auto_resolve_clawbot_server_request(app_server_client, thread_id, &request) + .await + { + return; + } let result = if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() { self.enqueue_primary_thread_request(request).await diff --git a/codex-rs/tui/src/app/btw.rs b/codex-rs/tui/src/app/btw.rs index f7bc20447..29eca7bec 100644 --- a/codex-rs/tui/src/app/btw.rs +++ b/codex-rs/tui/src/app/btw.rs @@ -3,8 +3,12 @@ use std::path::PathBuf; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::HookEventName as AppServerHookEventName; +use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadStartParams; @@ -43,6 +47,7 @@ const SUMMARY_MAX_CHARS: usize = 500; pub(crate) struct BtwSessionState { pub(crate) thread_id: ThreadId, pub(crate) final_message: Option, + pub(crate) last_status: Option, } impl App { @@ -66,7 +71,7 @@ impl App { return; } - self.open_btw_loading_popup(); + self.open_btw_loading_popup(/*status_message*/ None); let thread_id = match self.start_btw_thread(app_server).await { Ok(thread_id) => thread_id, @@ -78,6 +83,7 @@ impl App { self.btw_session = Some(BtwSessionState { thread_id, final_message: None, + last_status: None, }); let turn_result = app_server @@ -121,47 +127,68 @@ impl App { self.open_btw_result_popup(&message); } Err(err) => { - self.open_btw_failure_popup(&format!("`/btw` failed: {err}")); + let message = btw_failure_message(&err, session.last_status.as_deref()); + self.open_btw_failure_popup(&message); } } } + pub(crate) fn reject_btw_request( + &mut self, + thread_id: ThreadId, + request: &ServerRequest, + ) -> Option { + let session = self.btw_session.as_mut()?; + if session.thread_id != thread_id { + return None; + } + + let reason = btw_request_rejection_reason(request); + session.last_status = Some(reason.clone()); + self.open_btw_failure_popup(&format!("`/btw` cannot continue: {reason}")); + Some(reason) + } + pub(crate) fn handle_btw_notification( &mut self, thread_id: ThreadId, - notification: &codex_app_server_protocol::ServerNotification, + notification: &ServerNotification, ) -> bool { if self.btw_closing_thread_ids.contains(&thread_id) { - if matches!( - notification, - codex_app_server_protocol::ServerNotification::ThreadClosed(_) - ) { + if matches!(notification, ServerNotification::ThreadClosed(_)) { self.btw_closing_thread_ids.remove(&thread_id); } return true; } - let Some(session) = self.btw_session.as_ref() else { + let Some(session) = self.btw_session.as_mut() else { return false; }; if session.thread_id != thread_id { return false; } + if let Some(status) = btw_status_message(notification) + && session.final_message.is_none() + && session.last_status.as_deref() != Some(status.as_str()) + { + session.last_status = Some(status); + let status_message = session.last_status.clone(); + self.open_btw_loading_popup(status_message.as_deref()); + } + if session.final_message.is_some() { return true; } match notification { - codex_app_server_protocol::ServerNotification::Error(notification) - if !notification.will_retry => - { + ServerNotification::Error(notification) if !notification.will_retry => { self.app_event_tx.send(AppEvent::BtwCompleted { thread_id, result: Err(notification.error.message.clone()), }); } - codex_app_server_protocol::ServerNotification::TurnCompleted(notification) => { + ServerNotification::TurnCompleted(notification) => { let result = match notification.turn.status { TurnStatus::Completed => last_agent_message_or_error(¬ification.turn), TurnStatus::Failed => last_agent_message_or_error(¬ification.turn) @@ -174,7 +201,7 @@ impl App { self.app_event_tx .send(AppEvent::BtwCompleted { thread_id, result }); } - codex_app_server_protocol::ServerNotification::ThreadClosed(_) => { + ServerNotification::ThreadClosed(_) => { self.app_event_tx.send(AppEvent::BtwCompleted { thread_id, result: Err("Temporary discussion closed before a final answer.".to_string()), @@ -286,8 +313,9 @@ impl App { } } - fn open_btw_loading_popup(&mut self) { - self.show_btw_popup(btw_loading_view_params); + fn open_btw_loading_popup(&mut self, status_message: Option<&str>) { + let status_message = status_message.map(ToOwned::to_owned); + self.show_btw_popup(move || btw_loading_view_params(status_message.as_deref())); } fn open_btw_result_popup(&mut self, message: &str) { @@ -462,7 +490,17 @@ fn turn_failed_error(turn: &codex_app_server_protocol::Turn) -> Result SelectionViewParams { +fn btw_loading_view_params(status_message: Option<&str>) -> SelectionViewParams { + let mut body = String::new(); + if let Some(status_message) = status_message.filter(|status| !status.trim().is_empty()) { + body.push_str("Current hidden status:\n"); + body.push_str(status_message); + body.push_str("\n\n"); + } + body.push_str( + "Codex is answering in a hidden ephemeral thread. Nothing will be written back to the \ + main composer unless you explicitly choose an insert action.", + ); SelectionViewParams { view_id: Some(BTW_DISCUSSION_VIEW_ID), title: Some("Temporary BTW discussion".to_string()), @@ -475,13 +513,7 @@ fn btw_loading_view_params() -> SelectionViewParams { dismiss_on_select: true, ..Default::default() }], - side_content: Paragraph::new( - "Codex is answering in a hidden ephemeral thread. Nothing will be written back to the \ - main composer unless you explicitly choose an insert action." - .to_string(), - ) - .wrap(Wrap { trim: false }) - .into(), + side_content: Paragraph::new(body).wrap(Wrap { trim: false }).into(), side_content_width: SideContentWidth::Half, side_content_min_width: 28, on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))), @@ -489,6 +521,88 @@ fn btw_loading_view_params() -> SelectionViewParams { } } +fn btw_failure_message(error: &str, last_status: Option<&str>) -> String { + let mut message = format!("`/btw` failed: {error}"); + if let Some(last_status) = last_status.filter(|status| !status.trim().is_empty()) + && !message.contains(last_status) + { + message.push_str("\n\nLast hidden status:\n"); + message.push_str(last_status); + } + message +} + +fn btw_request_rejection_reason(request: &ServerRequest) -> String { + match request { + ServerRequest::ToolRequestUserInput { .. } => { + "the hidden temporary discussion asked for interactive user input. Run the prompt in the main thread instead.".to_string() + } + ServerRequest::CommandExecutionRequestApproval { .. } => { + "the hidden temporary discussion requested command approval, which `/btw` does not support.".to_string() + } + ServerRequest::FileChangeRequestApproval { .. } => { + "the hidden temporary discussion tried to change files, which `/btw` does not allow.".to_string() + } + ServerRequest::PermissionsRequestApproval { .. } => { + "the hidden temporary discussion requested extra permissions, which `/btw` does not allow.".to_string() + } + ServerRequest::McpServerElicitationRequest { .. } => { + "the hidden temporary discussion requested MCP interaction, which `/btw` cannot surface.".to_string() + } + ServerRequest::DynamicToolCall { .. } => { + "the hidden temporary discussion required an unsupported client-side tool call.".to_string() + } + ServerRequest::ChatgptAuthTokensRefresh { .. } + | ServerRequest::ApplyPatchApproval { .. } + | ServerRequest::ExecCommandApproval { .. } => { + "the hidden temporary discussion required an unsupported client-side request.".to_string() + } + } +} + +fn btw_status_message(notification: &ServerNotification) -> Option { + match notification { + ServerNotification::HookStarted(notification) => { + let label = hook_event_label(notification.run.event_name); + Some(match notification.run.status_message.as_deref() { + Some(status_message) if !status_message.is_empty() => { + format!("Running {label} hook: {status_message}") + } + _ => format!("Running {label} hook"), + }) + } + ServerNotification::HookCompleted(notification) => { + let label = hook_event_label(notification.run.event_name); + let status = format!("{:?}", notification.run.status).to_lowercase(); + let mut message = format!("{label} hook ({status})"); + if let Some(entry) = notification.run.entries.iter().find(|entry| { + matches!( + entry.kind, + AppServerHookOutputEntryKind::Warning + | AppServerHookOutputEntryKind::Stop + | AppServerHookOutputEntryKind::Error + ) + }) { + message.push_str(": "); + message.push_str(&entry.text); + } + Some(message) + } + ServerNotification::Error(notification) => Some(notification.error.message.clone()), + _ => None, + } +} + +fn hook_event_label(event_name: AppServerHookEventName) -> &'static str { + match event_name { + AppServerHookEventName::PreToolUse => "PreToolUse", + AppServerHookEventName::PostToolUse => "PostToolUse", + AppServerHookEventName::SessionStart => "SessionStart", + AppServerHookEventName::UserPromptSubmit => "UserPromptSubmit", + AppServerHookEventName::Stop => "Stop", + } +} + fn btw_result_view_params(message: &str) -> SelectionViewParams { SelectionViewParams { view_id: Some(BTW_DISCUSSION_VIEW_ID), @@ -589,7 +703,7 @@ mod tests { fn btw_loading_popup_snapshot() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let view = ListSelectionView::new(btw_loading_view_params(), tx); + let view = ListSelectionView::new(btw_loading_view_params(/*status_message*/ None), tx); assert_snapshot!("btw_loading_popup", render_selection_popup(&view, 92, 20)); } From 09189880f2457d9be0a933ce39e5901cb73675b0 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 02:12:41 +0800 Subject: [PATCH 33/60] codex: fix CI failure on PR #20 --- codex-rs/cli/src/main.rs | 3 +- codex-rs/tui/src/app.rs | 224 +---------------- codex-rs/tui/src/app/app_server_adapter.rs | 6 - codex-rs/tui/src/app/btw.rs | 40 +++- codex-rs/tui/src/app/profile_management.rs | 226 +++++++++++++++++- codex-rs/tui/src/app/thread_menu.rs | 30 ++- codex-rs/tui/src/chatwidget.rs | 13 - .../src/chatwidget/tests/history_replay.rs | 2 +- codex-rs/tui/src/display_preferences.rs | 33 ++- codex-rs/tui/src/display_preferences_menu.rs | 5 +- codex-rs/tui/src/history_cell.rs | 10 +- codex-rs/tui/src/profile_router.rs | 3 +- 12 files changed, 321 insertions(+), 274 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d05fdc4a9..31250ef9e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -491,8 +491,7 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { eprintln!("ERROR: {message}"); std::process::exit(1); } - ExitReason::UserRequested => { /* normal exit */ } - ExitReason::RespawnRequested => unreachable!("respawn should be handled before formatting"), + ExitReason::UserRequested | ExitReason::RespawnRequested => { /* normal exit */ } } let update_action = exit_info.update_action; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b1bc3f530..29aa11aa7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -48,10 +48,7 @@ use crate::multi_agents::format_agent_picker_item_name; use crate::multi_agents::next_agent_shortcut_matches; use crate::multi_agents::previous_agent_shortcut_matches; use crate::pager_overlay::Overlay; -use crate::profile_router::DefaultProfileRouter; use crate::profile_router::PROFILE_ROUTER_STATE_RELATIVE_PATH; -use crate::profile_router::ProfileFallbackAction; -use crate::profile_router::ProfileRouterStore; use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -171,13 +168,13 @@ mod jump_navigation; mod key_chord; mod loaded_threads; mod pending_interactive_replay; +mod profile_management; +mod thread_menu; mod workflow_controls; mod workflow_definition; mod workflow_history; pub(crate) mod workflow_runtime; mod workflow_scheduler; -mod profile_management; -mod thread_menu; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; @@ -1230,17 +1227,6 @@ impl App { } } - fn profile_router_store(&self) -> ProfileRouterStore { - ProfileRouterStore::new(self.config.codex_home.clone()) - } - - fn routed_profile_runtime_changed(current_config: &Config, next_config: &Config) -> bool { - current_config.active_profile != next_config.active_profile - || current_config.model_provider_id != next_config.model_provider_id - || current_config.model_provider != next_config.model_provider - || current_config.chatgpt_base_url != next_config.chatgpt_base_url - } - async fn apply_runtime_config_change( &mut self, tui: &mut tui::Tui, @@ -1301,61 +1287,6 @@ impl App { Ok(()) } - async fn close_active_thread_for_profile_reload( - &mut self, - app_server: &mut AppServerSession, - thread_id: ThreadId, - ) -> std::result::Result<(), String> { - self.backtrack.pending_rollback = None; - app_server - .thread_unsubscribe(thread_id) - .await - .map_err(|err| { - format!("Failed to unload current session before switching profiles: {err}") - })?; - - let close_wait = async { - loop { - match app_server.next_event().await { - Some(codex_app_server_client::AppServerEvent::ServerNotification( - ServerNotification::ThreadClosed(notification), - )) if notification.thread_id == thread_id.to_string() => { - break Ok(()); - } - Some(codex_app_server_client::AppServerEvent::Disconnected { message }) => { - self.chat_widget.add_error_message(message.clone()); - self.app_event_tx - .send(AppEvent::FatalExitRequest(message.clone())); - break Err(format!( - "App-server disconnected while closing current session: {message}" - )); - } - Some(event) => { - self.handle_app_server_event(app_server, event).await; - } - None => { - break Err( - "App-server event stream closed while closing current session." - .to_string(), - ); - } - } - } - }; - - tokio::time::timeout(PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT, close_wait) - .await - .map_err(|_| { - format!( - "Timed out waiting for current session `{thread_id}` to close before switching profiles." - ) - })??; - - self.clear_active_thread().await; - self.abort_thread_event_listener(thread_id); - Ok(()) - } - async fn reload_user_config_for_app_server_runtime( &mut self, tui: &mut tui::Tui, @@ -1389,145 +1320,6 @@ impl App { } Ok(()) } - async fn switch_runtime_profile( - &mut self, - tui: &mut tui::Tui, - app_server: &mut AppServerSession, - profile_id: Option<&str>, - ) -> std::result::Result<(), String> { - if self.active_profile.as_deref() == profile_id { - return Ok(()); - } - - let previous_override = self.harness_overrides.config_profile.clone(); - let previous_active_profile = self.active_profile.clone(); - self.harness_overrides.config_profile = profile_id.map(ToOwned::to_owned); - - let current_cwd = self.chat_widget.config_ref().cwd.to_path_buf(); - let mut next_config = match self.rebuild_config_for_cwd(current_cwd).await { - Ok(config) => config, - Err(err) => { - self.harness_overrides.config_profile = previous_override; - self.active_profile = previous_active_profile; - return Err(err.to_string()); - } - }; - self.apply_runtime_policy_overrides(&mut next_config); - - if let Err(err) = self - .apply_runtime_config_change( - tui, - app_server, - next_config, - /*reload_live_thread*/ true, - ) - .await - { - self.harness_overrides.config_profile = previous_override; - self.active_profile = previous_active_profile; - return Err(err); - } - Ok(()) - } - - async fn retry_last_user_turn_with_profile_fallback( - &mut self, - tui: &mut tui::Tui, - app_server: &mut AppServerSession, - action: ProfileFallbackAction, - error_message: String, - ) { - if !self.chat_widget.has_retryable_user_turn() { - self.chat_widget.add_error_message(error_message); - return; - } - let retry_user_message = self.chat_widget.last_submitted_user_turn(); - - match action { - ProfileFallbackAction::RetrySameProfileFirst => { - let profile_label = self - .chat_widget - .active_profile_label() - .unwrap_or_else(|| "current".to_string()); - if self.chat_widget.profile_retry_attempted() - || !self - .chat_widget - .retry_last_user_turn_for_profile_fallback(format!( - "Retrying the last turn on profile `{profile_label}`." - )) - { - let router_state = self.profile_router_store().load().unwrap_or_default(); - let Some(next_profile_id) = DefaultProfileRouter - .fallback_profile(&router_state, self.active_profile.as_deref()) - else { - self.chat_widget.add_error_message(error_message); - return; - }; - self.chat_widget.finish_failed_turn_for_profile_fallback(); - if let Err(err) = self - .switch_runtime_profile(tui, app_server, &next_profile_id) - .await - { - self.chat_widget.add_error_message(format!( - "Failed to switch to fallback profile `{next_profile_id}`: {err}" - )); - return; - } - if let Err(err) = self.profile_router_store().update(|state| { - state.set_active_profile(&next_profile_id); - }) { - self.chat_widget.add_error_message(format!( - "Failed to persist fallback profile `{next_profile_id}` in {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" - )); - return; - } - if let Some(user_message) = retry_user_message.clone() { - self.chat_widget.submit_profile_fallback_retry( - user_message, - format!("Retrying the last turn with profile `{next_profile_id}`."), - ); - } else { - self.chat_widget.add_error_message(error_message); - } - } - } - ProfileFallbackAction::SwitchProfileImmediately => { - let router_state = self.profile_router_store().load().unwrap_or_default(); - let Some(next_profile_id) = DefaultProfileRouter - .fallback_profile(&router_state, self.active_profile.as_deref()) - else { - self.chat_widget.add_error_message(error_message); - return; - }; - self.chat_widget.finish_failed_turn_for_profile_fallback(); - if let Err(err) = self - .switch_runtime_profile(tui, app_server, &next_profile_id) - .await - { - self.chat_widget.add_error_message(format!( - "Failed to switch to fallback profile `{next_profile_id}`: {err}" - )); - return; - } - if let Err(err) = self.profile_router_store().update(|state| { - state.set_active_profile(&next_profile_id); - }) { - self.chat_widget.add_error_message(format!( - "Failed to persist fallback profile `{next_profile_id}` in {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" - )); - return; - } - if let Some(user_message) = retry_user_message { - self.chat_widget.submit_profile_fallback_retry( - user_message, - format!("Retrying the last turn with profile `{next_profile_id}`."), - ); - } else { - self.chat_widget.add_error_message(error_message); - } - } - } - } async fn rebuild_config_for_resume_or_fallback( &mut self, current_cwd: &Path, @@ -7062,6 +6854,7 @@ mod tests { use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; use codex_app_server_protocol::TokenUsageBreakdown; + use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -9982,11 +9775,11 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), - workflow_thread_notification_channels: HashMap::new(), - workflow_scheduler: WorkflowSchedulerState::default(), - workflow_history: WorkflowHistoryState::default(), - btw_session: None, - btw_closing_thread_ids: HashSet::new(), + workflow_thread_notification_channels: HashMap::new(), + workflow_scheduler: WorkflowSchedulerState::default(), + workflow_history: WorkflowHistoryState::default(), + btw_session: None, + btw_closing_thread_ids: HashSet::new(), }, rx, op_rx, @@ -12154,7 +11947,6 @@ model = "gpt-5.2" assert!(rendered_cells[0].contains("Workflow job completed")); Ok(()) } - #[tokio::test] async fn active_primary_turn_complete_waits_for_consumption_before_after_turn() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 0c4e842c5..88abdd714 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -284,12 +284,6 @@ impl App { return; } - if self - .maybe_auto_resolve_clawbot_server_request(app_server_client, thread_id, &request) - .await - { - return; - } let result = if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() { self.enqueue_primary_thread_request(request).await diff --git a/codex-rs/tui/src/app/btw.rs b/codex-rs/tui/src/app/btw.rs index 29eca7bec..b962e8045 100644 --- a/codex-rs/tui/src/app/btw.rs +++ b/codex-rs/tui/src/app/btw.rs @@ -168,16 +168,22 @@ impl App { return false; } - if let Some(status) = btw_status_message(notification) + let status_message = if let Some(status) = btw_status_message(notification) && session.final_message.is_none() && session.last_status.as_deref() != Some(status.as_str()) { - session.last_status = Some(status); - let status_message = session.last_status.clone(); - self.open_btw_loading_popup(status_message.as_deref()); + session.last_status = Some(status.clone()); + Some(status) + } else { + None + }; + let final_message_recorded = session.final_message.is_some(); + + if let Some(status_message) = status_message.as_deref() { + self.open_btw_loading_popup(Some(status_message)); } - if session.final_message.is_some() { + if final_message_recorded { return true; } @@ -477,8 +483,15 @@ fn full_insert_text(message: &str) -> String { format!("BTW discussion:\n{message}") } +fn last_agent_message_for_turn(turn: &codex_app_server_protocol::Turn) -> Option { + turn.items.iter().rev().find_map(|item| match item { + codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } => Some(text.clone()), + _ => None, + }) +} + fn last_agent_message_or_error(turn: &codex_app_server_protocol::Turn) -> Result { - super::last_agent_message_for_turn(turn) + last_agent_message_for_turn(turn) .ok_or_else(|| "Temporary discussion finished without a final answer.".to_string()) } @@ -705,7 +718,10 @@ mod tests { let tx = AppEventSender::new(tx_raw); let view = ListSelectionView::new(btw_loading_view_params(/*status_message*/ None), tx); - assert_snapshot!("btw_loading_popup", render_selection_popup(&view, 92, 20)); + assert_snapshot!( + "btw_loading_popup", + render_selection_popup(&view, /*width*/ 92, /*height*/ 20) + ); } #[test] @@ -720,7 +736,10 @@ mod tests { tx, ); - assert_snapshot!("btw_result_popup", render_selection_popup(&view, 92, 28)); + assert_snapshot!( + "btw_result_popup", + render_selection_popup(&view, /*width*/ 92, /*height*/ 28) + ); } #[test] @@ -732,7 +751,10 @@ mod tests { tx, ); - assert_snapshot!("btw_failure_popup", render_selection_popup(&view, 92, 20)); + assert_snapshot!( + "btw_failure_popup", + render_selection_popup(&view, /*width*/ 92, /*height*/ 20) + ); } #[test] diff --git a/codex-rs/tui/src/app/profile_management.rs b/codex-rs/tui/src/app/profile_management.rs index bc9d0c073..bf76dd9dc 100644 --- a/codex-rs/tui/src/app/profile_management.rs +++ b/codex-rs/tui/src/app/profile_management.rs @@ -1,12 +1,23 @@ use toml::Value as TomlValue; use super::App; +use super::PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT; use crate::app_event::AppEvent; use crate::app_event::RuntimeProfileTarget; +use crate::app_server_session::AppServerSession; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::profile_router::DefaultProfileRouter; +use crate::profile_router::PROFILE_ROUTER_STATE_RELATIVE_PATH; +use crate::profile_router::ProfileFallbackAction; use crate::profile_router::ProfileRouterState; +use crate::profile_router::ProfileRouterStore; +use crate::tui; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::ServerNotification; +use codex_core::config::Config; +use codex_protocol::ThreadId; const PROFILE_MANAGEMENT_VIEW_ID: &str = "profile-management"; @@ -27,6 +38,20 @@ struct DefaultProfileSummary { } impl App { + pub(super) fn profile_router_store(&self) -> ProfileRouterStore { + ProfileRouterStore::new(self.config.codex_home.clone()) + } + + pub(super) fn routed_profile_runtime_changed( + current_config: &Config, + next_config: &Config, + ) -> bool { + current_config.active_profile != next_config.active_profile + || current_config.model_provider_id != next_config.model_provider_id + || current_config.model_provider != next_config.model_provider + || current_config.chatgpt_base_url != next_config.chatgpt_base_url + } + pub(crate) fn open_profile_management_panel(&mut self) { let router_state = self.profile_router_store().load().unwrap_or_default(); let initial_selected_idx = self @@ -139,6 +164,201 @@ impl App { (provider_id.to_string(), None) } } + + pub(super) async fn close_active_thread_for_profile_reload( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) -> std::result::Result<(), String> { + self.backtrack.pending_rollback = None; + app_server + .thread_unsubscribe(thread_id) + .await + .map_err(|err| { + format!("Failed to unload current session before switching profiles: {err}") + })?; + + let close_wait = async { + loop { + match app_server.next_event().await { + Some(AppServerEvent::ServerNotification(ServerNotification::ThreadClosed( + notification, + ))) if notification.thread_id == thread_id.to_string() => { + break Ok(()); + } + Some(AppServerEvent::Disconnected { message }) => { + self.chat_widget.add_error_message(message.clone()); + self.app_event_tx + .send(AppEvent::FatalExitRequest(message.clone())); + break Err(format!( + "App-server disconnected while closing current session: {message}" + )); + } + Some(event) => { + self.handle_app_server_event(app_server, event).await; + } + None => { + break Err( + "App-server event stream closed while closing current session." + .to_string(), + ); + } + } + } + }; + + tokio::time::timeout(PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT, close_wait) + .await + .map_err(|_| { + format!( + "Timed out waiting for current session `{thread_id}` to close before switching profiles." + ) + })??; + + self.clear_active_thread().await; + self.abort_thread_event_listener(thread_id); + Ok(()) + } + + pub(super) async fn switch_runtime_profile( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + profile_id: Option<&str>, + ) -> std::result::Result<(), String> { + if self.active_profile.as_deref() == profile_id { + return Ok(()); + } + + let previous_override = self.harness_overrides.config_profile.clone(); + let previous_active_profile = self.active_profile.clone(); + self.harness_overrides.config_profile = profile_id.map(ToOwned::to_owned); + + let current_cwd = self.chat_widget.config_ref().cwd.to_path_buf(); + let mut next_config = match self.rebuild_config_for_cwd(current_cwd).await { + Ok(config) => config, + Err(err) => { + self.harness_overrides.config_profile = previous_override; + self.active_profile = previous_active_profile; + return Err(err.to_string()); + } + }; + self.apply_runtime_policy_overrides(&mut next_config); + + if let Err(err) = self + .apply_runtime_config_change( + tui, + app_server, + next_config, + /*reload_live_thread*/ true, + ) + .await + { + self.harness_overrides.config_profile = previous_override; + self.active_profile = previous_active_profile; + return Err(err); + } + Ok(()) + } + + pub(super) async fn retry_last_user_turn_with_profile_fallback( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + action: ProfileFallbackAction, + error_message: String, + ) { + if !self.chat_widget.has_retryable_user_turn() { + self.chat_widget.add_error_message(error_message); + return; + } + let retry_user_message = self.chat_widget.last_submitted_user_turn(); + + match action { + ProfileFallbackAction::RetrySameProfileFirst => { + let profile_label = self + .chat_widget + .active_profile_label() + .unwrap_or_else(|| "current".to_string()); + if self.chat_widget.profile_retry_attempted() + || !self + .chat_widget + .retry_last_user_turn_for_profile_fallback(format!( + "Retrying the last turn on profile `{profile_label}`." + )) + { + let router_state = self.profile_router_store().load().unwrap_or_default(); + let Some(next_profile_id) = DefaultProfileRouter + .fallback_profile(&router_state, self.active_profile.as_deref()) + else { + self.chat_widget.add_error_message(error_message); + return; + }; + self.chat_widget.finish_failed_turn_for_profile_fallback(); + if let Err(err) = self + .switch_runtime_profile(tui, app_server, Some(&next_profile_id)) + .await + { + self.chat_widget.add_error_message(format!( + "Failed to switch to fallback profile `{next_profile_id}`: {err}" + )); + return; + } + if let Err(err) = self.profile_router_store().update(|state| { + state.set_active_profile(&next_profile_id); + }) { + self.chat_widget.add_error_message(format!( + "Failed to persist fallback profile `{next_profile_id}` in {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + return; + } + if let Some(user_message) = retry_user_message.clone() { + self.chat_widget.submit_profile_fallback_retry( + user_message, + format!("Retrying the last turn with profile `{next_profile_id}`."), + ); + } else { + self.chat_widget.add_error_message(error_message); + } + } + } + ProfileFallbackAction::SwitchProfileImmediately => { + let router_state = self.profile_router_store().load().unwrap_or_default(); + let Some(next_profile_id) = DefaultProfileRouter + .fallback_profile(&router_state, self.active_profile.as_deref()) + else { + self.chat_widget.add_error_message(error_message); + return; + }; + self.chat_widget.finish_failed_turn_for_profile_fallback(); + if let Err(err) = self + .switch_runtime_profile(tui, app_server, Some(&next_profile_id)) + .await + { + self.chat_widget.add_error_message(format!( + "Failed to switch to fallback profile `{next_profile_id}`: {err}" + )); + return; + } + if let Err(err) = self.profile_router_store().update(|state| { + state.set_active_profile(&next_profile_id); + }) { + self.chat_widget.add_error_message(format!( + "Failed to persist fallback profile `{next_profile_id}` in {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + return; + } + if let Some(user_message) = retry_user_message { + self.chat_widget.submit_profile_fallback_retry( + user_message, + format!("Retrying the last turn with profile `{next_profile_id}`."), + ); + } else { + self.chat_widget.add_error_message(error_message); + } + } + } + } } fn profile_management_panel_params( @@ -322,14 +542,14 @@ mod tests { &profiles, /*routed_count*/ 2, /*task_running*/ false, - None, + /*initial_selected_idx*/ None, ), tx, ); assert_snapshot!( "profile_management_popup", - render_selection_popup(&view, 96, 22) + render_selection_popup(&view, /*width*/ 96, /*height*/ 22) ); } @@ -351,7 +571,7 @@ mod tests { }], /*routed_count*/ 1, /*task_running*/ true, - None, + /*initial_selected_idx*/ None, ); assert_eq!(params.items[0].is_disabled, true); diff --git a/codex-rs/tui/src/app/thread_menu.rs b/codex-rs/tui/src/app/thread_menu.rs index 3eb9c628d..8231f45e4 100644 --- a/codex-rs/tui/src/app/thread_menu.rs +++ b/codex-rs/tui/src/app/thread_menu.rs @@ -200,14 +200,24 @@ mod tests { fn thread_panel_popup_snapshot() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let view = ListSelectionView::new(thread_panel_params(/*task_running*/ false, None), tx); + let view = ListSelectionView::new( + thread_panel_params( + /*task_running*/ false, /*initial_selected_idx*/ None, + ), + tx, + ); - assert_snapshot!("thread_panel_popup", render_selection_popup(&view, 92, 20)); + assert_snapshot!( + "thread_panel_popup", + render_selection_popup(&view, /*width*/ 92, /*height*/ 20) + ); } #[test] fn thread_panel_disables_mutations_while_task_running() { - let params = thread_panel_params(/*task_running*/ true, None); + let params = thread_panel_params( + /*task_running*/ true, /*initial_selected_idx*/ None, + ); assert_eq!(params.items[0].is_disabled, true); assert_eq!(params.items[1].is_disabled, false); @@ -230,11 +240,14 @@ mod tests { /*is_first_line*/ true, )) as Arc, ]; - let view = ListSelectionView::new(jump_to_message_panel_params(&cells, None), tx); + let view = ListSelectionView::new( + jump_to_message_panel_params(&cells, /*initial_selected_idx*/ None), + tx, + ); assert_snapshot!( "jump_to_message_popup", - render_selection_popup(&view, 92, 22) + render_selection_popup(&view, /*width*/ 92, /*height*/ 22) ); } @@ -243,11 +256,14 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cells: Vec> = Vec::new(); - let view = ListSelectionView::new(jump_to_message_panel_params(&cells, None), tx); + let view = ListSelectionView::new( + jump_to_message_panel_params(&cells, /*initial_selected_idx*/ None), + tx, + ); assert_snapshot!( "jump_to_message_empty_popup", - render_selection_popup(&view, 92, 18) + render_selection_popup(&view, /*width*/ 92, /*height*/ 18) ); } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 858172c8a..911dd6790 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -10167,19 +10167,6 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn replace_selection_view_if_active( - &mut self, - view_id: &'static str, - params: SelectionViewParams, - ) -> bool { - self.bottom_pane - .replace_selection_view_if_active(view_id, params) - } - - pub(crate) fn selected_index_for_active_view(&self, view_id: &'static str) -> Option { - self.bottom_pane.selected_index_for_active_view(view_id) - } - fn open_connectors_loading_popup(&mut self) { if !self.bottom_pane.replace_selection_view_if_active( CONNECTORS_SELECTION_VIEW_ID, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 4eb662d97..b0f3bab50 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -631,7 +631,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.display_preferences.set_enabled( crate::display_preferences::DisplayPreferenceKey::RawThinking, - true, + /*enabled*/ true, ); chat.handle_codex_event(Event { id: "configured".into(), diff --git a/codex-rs/tui/src/display_preferences.rs b/codex-rs/tui/src/display_preferences.rs index 35857532a..3dc22226f 100644 --- a/codex-rs/tui/src/display_preferences.rs +++ b/codex-rs/tui/src/display_preferences.rs @@ -160,10 +160,14 @@ mod tests { let preferences = DisplayPreferences::from_config(&config); assert!(!preferences.show_startup_tooltips()); - preferences.set_enabled(DisplayPreferenceKey::StartupTooltips, true); + preferences.set_enabled(DisplayPreferenceKey::StartupTooltips, /*enabled*/ true); assert!(preferences.show_startup_tooltips()); - set_display_preference_in_config(&mut config, DisplayPreferenceKey::StartupTooltips, true); + set_display_preference_in_config( + &mut config, + DisplayPreferenceKey::StartupTooltips, + /*enabled*/ true, + ); assert!(config.show_tooltips); } @@ -177,20 +181,31 @@ mod tests { assert!(!preferences.show_tool_results()); assert!(!preferences.show_patch_diffs()); - preferences.set_enabled(DisplayPreferenceKey::ToolResults, true); - preferences.set_enabled(DisplayPreferenceKey::PatchDiffs, true); + preferences.set_enabled(DisplayPreferenceKey::ToolResults, /*enabled*/ true); + preferences.set_enabled(DisplayPreferenceKey::PatchDiffs, /*enabled*/ true); assert!(preferences.show_tool_results()); assert!(preferences.show_patch_diffs()); - set_display_preference_in_config(&mut config, DisplayPreferenceKey::ToolResults, true); - set_display_preference_in_config(&mut config, DisplayPreferenceKey::PatchDiffs, true); + set_display_preference_in_config( + &mut config, + DisplayPreferenceKey::ToolResults, + /*enabled*/ true, + ); + set_display_preference_in_config( + &mut config, + DisplayPreferenceKey::PatchDiffs, + /*enabled*/ true, + ); assert!(config.tui_display_preferences.show_tool_results); assert!(config.tui_display_preferences.show_patch_diffs); } #[test] fn startup_tooltips_edit_targets_tui_show_tooltips() { - match display_preference_edit(DisplayPreferenceKey::StartupTooltips, false) { + match display_preference_edit( + DisplayPreferenceKey::StartupTooltips, + /*enabled*/ false, + ) { ConfigEdit::SetPath { segments, value } => { assert_eq!( segments, @@ -204,7 +219,7 @@ mod tests { #[test] fn transcript_visibility_edits_target_tui_display_preferences() { - match display_preference_edit(DisplayPreferenceKey::ToolResults, false) { + match display_preference_edit(DisplayPreferenceKey::ToolResults, /*enabled*/ false) { ConfigEdit::SetPath { segments, value } => { assert_eq!( segments, @@ -219,7 +234,7 @@ mod tests { other => panic!("unexpected config edit: {other:?}"), } - match display_preference_edit(DisplayPreferenceKey::PatchDiffs, false) { + match display_preference_edit(DisplayPreferenceKey::PatchDiffs, /*enabled*/ false) { ConfigEdit::SetPath { segments, value } => { assert_eq!( segments, diff --git a/codex-rs/tui/src/display_preferences_menu.rs b/codex-rs/tui/src/display_preferences_menu.rs index 1a6f2cb25..30e3021d4 100644 --- a/codex-rs/tui/src/display_preferences_menu.rs +++ b/codex-rs/tui/src/display_preferences_menu.rs @@ -134,7 +134,10 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let view = ListSelectionView::new( - display_preferences_panel_params(&DisplayPreferences::default(), None), + display_preferences_panel_params( + &DisplayPreferences::default(), + /*initial_selected_idx*/ None, + ), tx, ); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index ac288cf94..6cecb148a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -3883,7 +3883,7 @@ mod tests { let display_preferences = DisplayPreferences::default(); display_preferences.set_enabled( crate::display_preferences::DisplayPreferenceKey::ToolResults, - false, + /*enabled*/ false, ); let mut cell = new_active_mcp_tool_call( "call-image-hidden".into(), @@ -3977,7 +3977,7 @@ mod tests { let display_preferences = DisplayPreferences::default(); display_preferences.set_enabled( crate::display_preferences::DisplayPreferenceKey::ToolResults, - false, + /*enabled*/ false, ); let mut cell = new_active_mcp_tool_call( "call-hidden-text".into(), @@ -4053,7 +4053,7 @@ mod tests { let display_preferences = DisplayPreferences::default(); display_preferences.set_enabled( crate::display_preferences::DisplayPreferenceKey::PatchDiffs, - false, + /*enabled*/ false, ); let cell = new_patch_event(changes, Path::new("/tmp/project"), display_preferences); @@ -4066,7 +4066,7 @@ mod tests { let display_preferences = DisplayPreferences::default(); display_preferences.set_enabled( crate::display_preferences::DisplayPreferenceKey::PatchDiffs, - false, + /*enabled*/ false, ); let cell = @@ -4979,7 +4979,7 @@ mod tests { let display_preferences = DisplayPreferences::default(); display_preferences.set_enabled( crate::display_preferences::DisplayPreferenceKey::RawThinking, - true, + /*enabled*/ true, ); let cell = new_reasoning_raw_block( "secret chain of thought".to_string(), diff --git a/codex-rs/tui/src/profile_router.rs b/codex-rs/tui/src/profile_router.rs index 1ae649dab..a7a3c24f0 100644 --- a/codex-rs/tui/src/profile_router.rs +++ b/codex-rs/tui/src/profile_router.rs @@ -1,5 +1,4 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; -#[cfg(test)] use codex_protocol::protocol::CodexErrorInfo; use serde::Deserialize; use serde::Serialize; @@ -132,7 +131,6 @@ impl ProfileRouterStore { } } -#[cfg(test)] pub(crate) fn core_profile_fallback_action(info: &CodexErrorInfo) -> Option { match info { CodexErrorInfo::UsageLimitExceeded | CodexErrorInfo::Unauthorized => { @@ -190,6 +188,7 @@ pub(crate) fn app_server_profile_fallback_action( } } +#[cfg(test)] mod tests { use super::DefaultProfileRouter; use super::ProfileFallbackAction; From 55ec5cdb510cf3daa9ef5db084a863e345ad760f Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 11:03:40 +0800 Subject: [PATCH 34/60] replay: restore feishu clawbot runtime bridge --- MODULE.bazel.lock | 32 + codex-rs/Cargo.lock | 638 +++++++++++++++++- codex-rs/Cargo.toml | 2 + codex-rs/clawbot/BUILD.bazel | 6 + codex-rs/clawbot/Cargo.toml | 45 ++ codex-rs/clawbot/src/config.rs | 25 + codex-rs/clawbot/src/events.rs | 12 + codex-rs/clawbot/src/lib.rs | 32 + codex-rs/clawbot/src/model.rs | 159 +++++ codex-rs/clawbot/src/provider/feishu.rs | 421 ++++++++++++ .../src/provider/feishu/runtime_loop.rs | 100 +++ codex-rs/clawbot/src/provider/mod.rs | 23 + codex-rs/clawbot/src/runtime.rs | 378 +++++++++++ codex-rs/clawbot/src/store.rs | 341 ++++++++++ codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 211 +++++- codex-rs/tui/src/app/app_server_adapter.rs | 10 + codex-rs/tui/src/app/clawbot.rs | 406 +++++++++++ codex-rs/tui/src/app_event.rs | 9 + 19 files changed, 2815 insertions(+), 36 deletions(-) create mode 100644 codex-rs/clawbot/BUILD.bazel create mode 100644 codex-rs/clawbot/Cargo.toml create mode 100644 codex-rs/clawbot/src/config.rs create mode 100644 codex-rs/clawbot/src/events.rs create mode 100644 codex-rs/clawbot/src/lib.rs create mode 100644 codex-rs/clawbot/src/model.rs create mode 100644 codex-rs/clawbot/src/provider/feishu.rs create mode 100644 codex-rs/clawbot/src/provider/feishu/runtime_loop.rs create mode 100644 codex-rs/clawbot/src/provider/mod.rs create mode 100644 codex-rs/clawbot/src/runtime.rs create mode 100644 codex-rs/clawbot/src/store.rs create mode 100644 codex-rs/tui/src/app/clawbot.rs diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 1e2123ec4..7ef01da37 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -996,6 +996,7 @@ "lalrpop_0.19.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ascii-canvas\",\"req\":\"^3.0\"},{\"default_features\":false,\"name\":\"bit-set\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"name\":\"diff\",\"req\":\"^0.1.12\"},{\"default_features\":false,\"name\":\"ena\",\"req\":\"^0.14\"},{\"name\":\"is-terminal\",\"req\":\"^0.4.2\"},{\"default_features\":false,\"features\":[\"use_std\"],\"name\":\"itertools\",\"req\":\"^0.10\"},{\"name\":\"lalrpop-util\",\"req\":\"^0.19.12\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"pico-args\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-case\",\"unicode-perl\"],\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"unicode\"],\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"unicode-case\",\"unicode-perl\"],\"kind\":\"dev\",\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"string_cache\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"term\",\"req\":\"^0.7\"},{\"features\":[\"sha3\"],\"name\":\"tiny-keccak\",\"req\":\"^2.0.2\"},{\"default_features\":false,\"name\":\"unicode-xid\",\"req\":\"^0.2\"}],\"features\":{\"default\":[\"lexer\"],\"lexer\":[\"lalrpop-util/lexer\"],\"test\":[]}}", "landlock_0.4.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"enumflags2\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2.175\"},{\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"strum_macros\",\"req\":\"^0.26\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"}],\"features\":{}}", "language-tags_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", + "lark-websocket-protobuf_0.1.1": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.6.0\"},{\"name\":\"prost\",\"req\":\"^0.13.1\"},{\"kind\":\"build\",\"name\":\"prost-build\",\"req\":\"^0.12.6\"}],\"features\":{}}", "lazy_static_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"once\"],\"name\":\"spin\",\"optional\":true,\"req\":\"^0.9.8\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{\"spin_no_std\":[\"spin\"]}}", "leb128fmt_0.1.0": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[]}}", "libc_0.2.182": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", @@ -1103,6 +1104,26 @@ "onig_6.5.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"onig_sys\",\"req\":\"^69.9.1\"}],\"features\":{\"default\":[\"generate\"],\"generate\":[\"onig_sys/generate\"],\"posix-api\":[\"onig_sys/posix-api\"],\"print-debug\":[\"onig_sys/print-debug\"],\"std-pattern\":[]}}", "onig_sys_69.9.1": "{\"dependencies\":[{\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.71\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.16\"}],\"features\":{\"default\":[\"generate\"],\"generate\":[\"bindgen\"],\"posix-api\":[],\"print-debug\":[]}}", "opaque-debug_0.3.1": "{\"dependencies\":[],\"features\":{}}", + "openlark-ai_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"}],\"features\":{\"default\":[\"v1\"],\"full\":[\"v1\"],\"v1\":[]}}", + "openlark-analytics_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"all-analytics\":[\"full\"],\"analytics\":[\"search\",\"report\"],\"core\":[],\"default\":[\"search\",\"report\"],\"full\":[\"search\",\"report\",\"v4\"],\"report\":[\"report-core\",\"core\"],\"report-core\":[],\"search\":[\"search-core\",\"core\"],\"search-core\":[],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"v4\":[\"v3\"]}}", + "openlark-application_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"default\":[\"v1\",\"async\"],\"full\":[\"v1\",\"async\"],\"v1\":[]}}", + "openlark-auth_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"features\":[\"serde\",\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"features\":[\"html_reports\"],\"name\":\"criterion\",\"optional\":true,\"req\":\"^0.5\"},{\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12.7\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.18\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"optional\":true,\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\",\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"advanced-cache\":[\"cache\",\"encryption\"],\"cache\":[\"token-management\"],\"default\":[\"token-management\",\"cache\",\"oauth\"],\"encryption\":[\"ring\",\"sha2\",\"hmac\",\"pbkdf2\"],\"monitoring\":[],\"oauth\":[\"reqwest\",\"url\"],\"token-management\":[]}}", + "openlark-cardkit_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"kind\":\"dev\",\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"v1\"],\"full\":[\"v1\"],\"v1\":[]}}", + "openlark-client_0.15.0-rc.2": "{\"dependencies\":[{\"features\":[\"serde\",\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.30\"},{\"name\":\"lark-websocket-protobuf\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-ai\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-auth\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-cardkit\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-communication\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-docs\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-hr\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-meeting\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-security\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"prost\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"rustls-tls-native-roots\"],\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.23\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"optional\":true,\"req\":\"^2.5.0\"},{\"features\":[\"v4\",\"serde\",\"v4\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"ai\":[\"dep:openlark-ai\"],\"auth\":[\"dep:openlark-auth\"],\"cardkit\":[\"auth\",\"dep:openlark-cardkit\"],\"communication\":[\"auth\",\"dep:openlark-communication\"],\"core-layer\":[\"communication\",\"docs\",\"security\"],\"default\":[\"auth\",\"communication\"],\"docs\":[\"auth\",\"dep:openlark-docs\"],\"hr\":[\"dep:openlark-hr\"],\"meeting\":[\"auth\",\"dep:openlark-meeting\"],\"p0-services\":[\"communication\",\"docs\",\"security\"],\"security\":[\"auth\",\"dep:openlark-security\"],\"websocket\":[\"tokio-tungstenite\",\"futures-util\",\"lark-websocket-protobuf\",\"url\",\"prost\",\"reqwest\",\"log\"]}}", + "openlark-communication_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"aily\":[],\"contact\":[],\"default\":[\"im\",\"contact\",\"moments\"],\"full\":[\"im\",\"contact\",\"moments\",\"aily\"],\"im\":[],\"moments\":[]}}", + "openlark-core_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.30\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"name\":\"http\",\"req\":\"^1.0\"},{\"name\":\"lark-websocket-protobuf\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"num_cpus\",\"req\":\"^1.16\"},{\"name\":\"openlark-protocol\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"opentelemetry\",\"optional\":true,\"req\":\"^0.24\"},{\"name\":\"opentelemetry-otlp\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"rt-tokio\"],\"name\":\"opentelemetry_sdk\",\"optional\":true,\"req\":\"^0.24\"},{\"name\":\"prost\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"quick_cache\",\"req\":\"^0.6.3\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_with\",\"req\":\"^3\"},{\"name\":\"sha2\",\"req\":\"^0.10.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"features\":[\"rustls-tls-native-roots\"],\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.23\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"tracing-opentelemetry\",\"optional\":true,\"req\":\"^0.25\"},{\"features\":[\"env-filter\",\"json\"],\"name\":\"tracing-subscriber\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"env-filter\",\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"testing\"],\"otel\":[\"tracing-init\",\"opentelemetry\",\"opentelemetry_sdk\",\"opentelemetry-otlp\",\"tracing-opentelemetry\"],\"testing\":[\"tracing-init\"],\"tracing-init\":[\"tracing-subscriber\"],\"websocket\":[\"tokio-tungstenite\",\"prost\",\"openlark-protocol\",\"lark-websocket-protobuf\"]}}", + "openlark-docs_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"all-cloud-docs\":[\"full\"],\"baike\":[],\"base\":[\"core\"],\"bitable\":[\"core\"],\"ccm\":[\"ccm-core\",\"ccm-doc\",\"ccm-docx\",\"ccm-drive\",\"ccm-sheets\",\"ccm-wiki\"],\"ccm-core\":[],\"ccm-doc\":[\"ccm-core\"],\"ccm-docx\":[\"ccm-core\"],\"ccm-drive\":[\"ccm-core\"],\"ccm-sheets\":[\"ccm-sheets-v3\"],\"ccm-sheets-v3\":[\"ccm-core\"],\"ccm-wiki\":[\"ccm-core\"],\"cloud-docs\":[\"ccm\",\"bitable\",\"base\"],\"core\":[],\"default\":[],\"docs\":[\"ccm-doc\"],\"docx\":[\"ccm-docx\"],\"full\":[\"ccm\",\"bitable\",\"base\",\"baike\",\"minutes\",\"v3\"],\"lingo\":[],\"minutes\":[\"core\"],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"wiki\":[\"ccm-wiki\"]}}", + "openlark-helpdesk_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"default\":[\"v1\",\"async\"],\"full\":[\"v1\",\"async\"],\"v1\":[]}}", + "openlark-hr_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"attendance\":[],\"compensation\":[],\"corehr\":[],\"default\":[\"attendance\",\"corehr\",\"compensation\",\"payroll\",\"performance\",\"okr\",\"hire\",\"ehr\"],\"ehr\":[],\"hire\":[],\"hr-full\":[\"attendance\",\"corehr\",\"compensation\",\"payroll\",\"performance\",\"okr\",\"hire\",\"ehr\"],\"okr\":[],\"payroll\":[],\"performance\":[]}}", + "openlark-mail_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"default\":[\"v1\",\"async\"],\"full\":[\"v1\",\"async\"],\"v1\":[]}}", + "openlark-meeting_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1\"}],\"features\":{\"calendar\":[\"calendar-v4\"],\"calendar-v4\":[],\"default\":[\"vc\",\"calendar\"],\"full\":[\"vc\",\"calendar\",\"meeting-room\"],\"meeting-room\":[\"meeting-room-v1\"],\"meeting-room-v1\":[],\"vc\":[\"vc-v1\"],\"vc-v1\":[]}}", + "openlark-platform_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"admin\":[\"admin-core\",\"core\"],\"admin-core\":[],\"all-platform\":[\"full\"],\"app-engine\":[\"app-engine-core\",\"core\"],\"app-engine-core\":[],\"core\":[],\"default\":[\"app-engine\",\"directory\",\"admin\",\"mdm\",\"tenant\",\"trust_party\"],\"directory\":[\"directory-core\",\"core\"],\"directory-core\":[],\"full\":[\"app-engine\",\"directory\",\"admin\",\"mdm\",\"tenant\",\"trust_party\",\"v4\"],\"mdm\":[\"mdm-core\",\"core\"],\"mdm-core\":[],\"platform\":[\"app-engine\",\"directory\",\"admin\"],\"tenant\":[\"tenant-core\",\"core\"],\"tenant-core\":[],\"trust_party\":[\"trust_party-core\",\"core\"],\"trust_party-core\":[],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"v4\":[\"v3\"]}}", + "openlark-protocol_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.6.0\"},{\"name\":\"prost\",\"req\":\"^0.13.1\"},{\"kind\":\"build\",\"name\":\"prost-build\",\"req\":\"^0.12.6\"}],\"features\":{}}", + "openlark-security_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"name\":\"sha2\",\"req\":\"^0.10.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"acs\":[\"auth\"],\"audit\":[\"core\"],\"auth\":[\"core\"],\"compliance\":[\"auth\"],\"core\":[],\"default\":[\"auth\",\"acs\"],\"full\":[\"auth\",\"acs\",\"audit\",\"token\",\"compliance\",\"v3\"],\"security\":[\"full\"],\"token\":[\"auth\"],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"]}}", + "openlark-user_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"all-user\":[\"full\"],\"core\":[],\"default\":[\"settings\",\"preferences\"],\"full\":[\"settings\",\"preferences\",\"v4\"],\"preferences\":[\"preferences-core\",\"core\"],\"preferences-core\":[],\"settings\":[\"settings-core\",\"core\"],\"settings-core\":[],\"user\":[\"settings\",\"preferences\"],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"v4\":[\"v3\"]}}", + "openlark-webhook_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.1\"},{\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"card\":[],\"default\":[\"robot\"],\"robot\":[],\"signature\":[\"hmac\",\"sha2\",\"base64\"]}}", + "openlark-workflow_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"board\":[],\"default\":[\"v1\",\"v2\",\"async\",\"board\"],\"full\":[\"v1\",\"v2\",\"async\",\"board\"],\"v1\":[],\"v2\":[]}}", + "openlark_0.15.0-rc.1": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4\"},{\"kind\":\"dev\",\"name\":\"colored\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-ai\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-analytics\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-application\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-auth\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-cardkit\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-client\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-communication\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-docs\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-helpdesk\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-hr\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-mail\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-meeting\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-platform\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-protocol\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-security\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-user\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-webhook\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-workflow\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"kind\":\"dev\",\"name\":\"test-log\",\"req\":\"^0.2\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"ai\":[\"client\",\"openlark-ai\"],\"analytics\":[\"client\",\"openlark-analytics\"],\"application\":[\"client\",\"openlark-application\"],\"auth\":[\"client\",\"openlark-auth\"],\"base\":[\"client\",\"openlark-docs\"],\"bitable\":[\"client\",\"openlark-docs\"],\"cardkit\":[\"client\",\"openlark-cardkit\"],\"client\":[\"openlark-client\"],\"communication\":[\"client\",\"openlark-communication\"],\"core-services\":[\"auth\",\"communication\",\"docs\",\"workflow\"],\"default\":[\"core-services\"],\"dev-tools\":[],\"docs\":[\"client\",\"openlark-docs\"],\"helpdesk\":[\"client\",\"openlark-helpdesk\"],\"hr\":[\"client\",\"openlark-hr\"],\"mail\":[\"client\",\"openlark-mail\"],\"meeting\":[\"client\",\"openlark-meeting\"],\"platform\":[\"client\",\"openlark-platform\"],\"protocol\":[\"openlark-protocol\"],\"security\":[\"client\",\"openlark-security\"],\"user\":[\"client\",\"openlark-user\"],\"webhook\":[\"client\",\"openlark-webhook\"],\"websocket\":[\"protocol\",\"openlark-client/websocket\"],\"workflow\":[\"client\",\"openlark-workflow\"]}}", "openssl-macros_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "openssl-probe_0.1.6": "{\"dependencies\":[],\"features\":{}}", "openssl-probe_0.2.1": "{\"dependencies\":[],\"features\":{}}", @@ -1169,7 +1190,13 @@ "proc-macro2_1.0.106": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"proc-macro\"],\"nightly\":[],\"proc-macro\":[],\"span-locations\":[]}}", "process-wrap_9.0.1": "{\"dependencies\":[{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.30\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"default_features\":false,\"features\":[\"fs\",\"poll\",\"signal\"],\"name\":\"nix\",\"optional\":true,\"req\":\"^0.30.1\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"remoteprocess\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.20.0\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38.2\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38.2\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"name\":\"windows\",\"optional\":true,\"req\":\"^0.62.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"creation-flags\":[\"dep:windows\",\"windows/Win32_System_Threading\"],\"default\":[\"creation-flags\",\"job-object\",\"kill-on-drop\",\"process-group\",\"process-session\",\"tracing\"],\"job-object\":[\"dep:windows\",\"windows/Win32_Security\",\"windows/Win32_System_Diagnostics_ToolHelp\",\"windows/Win32_System_IO\",\"windows/Win32_System_JobObjects\",\"windows/Win32_System_Threading\"],\"kill-on-drop\":[],\"process-group\":[],\"process-session\":[\"process-group\"],\"reset-sigmask\":[],\"std\":[\"dep:nix\"],\"tokio1\":[\"dep:nix\",\"dep:futures\",\"dep:tokio\"],\"tracing\":[\"dep:tracing\"]}}", "proptest_1.9.0": "{\"dependencies\":[{\"name\":\"bit-set\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bitflags\",\"req\":\"^2.9\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.15\"},{\"name\":\"proptest-macro\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"req\":\"^0.9\"},{\"name\":\"rand_xorshift\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rusty-fork\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"=1.0.112\"},{\"name\":\"unarray\",\"req\":\"^0.1.4\"},{\"name\":\"x86\",\"optional\":true,\"req\":\"^0.52.0\"}],\"features\":{\"alloc\":[],\"atomic64bit\":[],\"attr-macro\":[\"proptest-macro\"],\"bit-set\":[\"dep:bit-set\",\"dep:bit-vec\"],\"default\":[\"std\",\"fork\",\"timeout\",\"bit-set\"],\"default-code-coverage\":[\"std\",\"fork\",\"timeout\",\"bit-set\"],\"fork\":[\"std\",\"rusty-fork\",\"tempfile\"],\"handle-panics\":[\"std\"],\"hardware-rng\":[\"x86\"],\"no_std\":[\"num-traits/libm\"],\"std\":[\"rand/std\",\"rand/os_rng\",\"regex-syntax\",\"num-traits/std\"],\"timeout\":[\"fork\",\"rusty-fork/timeout\"],\"unstable\":[]}}", + "prost-build_0.12.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"heck\",\"req\":\">=0.4, <=0.5\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.12\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"multimap\",\"req\":\">=0.8, <=0.10\"},{\"name\":\"once_cell\",\"req\":\"^1.17.1\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.6\"},{\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"prost\",\"req\":\"^0.12.6\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.12.6\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.9.1\"},{\"name\":\"pulldown-cmark-to-cmark\",\"optional\":true,\"req\":\"^10.0.1\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-bool\"],\"name\":\"regex\",\"req\":\"^1.8.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"cleanup-markdown\":[\"dep:pulldown-cmark\",\"dep:pulldown-cmark-to-cmark\"],\"default\":[\"format\"],\"format\":[\"dep:prettyplease\",\"dep:syn\"]}}", + "prost-derive_0.12.6": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.12\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", + "prost-derive_0.13.5": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"name\":\"itertools\",\"req\":\">=0.10.1, <=0.14\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "prost-derive_0.14.3": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"name\":\"itertools\",\"req\":\">=0.10.1, <=0.14\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", + "prost-types_0.12.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"prost-derive\"],\"name\":\"prost\",\"req\":\"^0.12.6\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"prost/std\"]}}", + "prost_0.12.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.12.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"prost-derive\":[\"derive\"],\"std\":[]}}", + "prost_0.13.5": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.13.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"prost-derive\":[\"derive\"],\"std\":[]}}", "prost_0.14.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"std\":[]}}", "psl-types_2.0.11": "{\"dependencies\":[],\"features\":{}}", "psl_2.1.184": "{\"dependencies\":[{\"name\":\"psl-types\",\"req\":\"^2.0.11\"},{\"kind\":\"dev\",\"name\":\"rspec\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"helpers\"],\"helpers\":[]}}", @@ -1178,6 +1205,7 @@ "pxfm_0.1.27": "{\"dependencies\":[{\"name\":\"num-traits\",\"req\":\"^0.2.3\"}],\"features\":{}}", "quick-error_2.0.1": "{\"dependencies\":[],\"features\":{}}", "quick-xml_0.38.4": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\">=0.4, <0.8\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\">=1.0.139\"},{\"kind\":\"dev\",\"name\":\"serde-value\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.206\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"}],\"features\":{\"async-tokio\":[\"tokio\"],\"default\":[],\"encoding\":[\"encoding_rs\"],\"escape-html\":[],\"overlapped-lists\":[],\"serde-types\":[\"serde/derive\"],\"serialize\":[\"serde\"]}}", + "quick_cache_0.6.21": "{\"dependencies\":[{\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"crossbeam-utils\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"equivalent\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.16\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"name\":\"shuttle\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{\"default\":[\"ahash\",\"parking_lot\"],\"sharded-lock\":[\"dep:crossbeam-utils\"],\"shuttle\":[\"dep:shuttle\"],\"stats\":[]}}", "quinn-proto_0.11.13": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.1\"},{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"fastbloom\",\"optional\":true,\"req\":\"^0.14\"},{\"default_features\":false,\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"lru-slab\",\"req\":\"^0.1.2\"},{\"name\":\"qlog\",\"optional\":true,\"req\":\"^0.15.2\"},{\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"features\":[\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"features\":[\"web\"],\"name\":\"rustls-pki-types\",\"req\":\"^1.7\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"slab\",\"req\":\"^0.4.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"alloc\",\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs?/aws-lc-sys\",\"aws-lc-rs?/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"aws-lc-rs\",\"aws-lc-rs?/fips\"],\"bloom\":[\"dep:fastbloom\"],\"default\":[\"rustls-ring\",\"log\",\"bloom\"],\"log\":[\"tracing/log\"],\"platform-verifier\":[\"dep:rustls-platform-verifier\"],\"qlog\":[\"dep:qlog\"],\"ring\":[\"dep:ring\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"rustls?/aws-lc-rs\",\"aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"rustls-aws-lc-rs\",\"aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"rustls?/ring\",\"ring\"]}}", "quinn-udp_0.5.14": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"libc\",\"req\":\"^0.2.158\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.19\",\"target\":\"cfg(windows)\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_IO\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.60\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"tracing\",\"log\"],\"direct-log\":[\"dep:log\"],\"fast-apple-datapath\":[],\"log\":[\"tracing/log\"]}}", "quinn_0.11.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.22\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.11\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"crc\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"directories-next\",\"req\":\"^2\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.19\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"proto\",\"package\":\"quinn-proto\",\"req\":\"^0.11.12\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"smol\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"time\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"std-future\"],\"kind\":\"dev\",\"name\":\"tracing-futures\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"tracing\"],\"name\":\"udp\",\"package\":\"quinn-udp\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"aws-lc-rs\":[\"proto/aws-lc-rs\"],\"aws-lc-rs-fips\":[\"proto/aws-lc-rs-fips\"],\"bloom\":[\"proto/bloom\"],\"default\":[\"log\",\"platform-verifier\",\"runtime-tokio\",\"rustls-ring\",\"bloom\"],\"lock_tracking\":[],\"log\":[\"tracing/log\",\"proto/log\",\"udp/log\"],\"platform-verifier\":[\"proto/platform-verifier\"],\"qlog\":[\"proto/qlog\"],\"ring\":[\"proto/ring\"],\"runtime-async-std\":[\"async-io\",\"async-std\"],\"runtime-smol\":[\"async-io\",\"smol\"],\"runtime-tokio\":[\"tokio/time\",\"tokio/rt\",\"tokio/net\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"aws-lc-rs\",\"proto/rustls-aws-lc-rs\",\"proto/aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"dep:rustls\",\"aws-lc-rs-fips\",\"proto/rustls-aws-lc-rs-fips\",\"proto/aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"ring\",\"proto/rustls-ring\",\"proto/ring\"]}}", @@ -1249,7 +1277,9 @@ "rustix_0.38.44": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.161\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.5.2\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_NetworkManagement_IpHelper\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"procfs\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"cc\":[],\"default\":[\"std\",\"use-libc-auxv\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"linux-raw-sys/io_uring\"],\"libc-extra-traits\":[\"libc?/extra_traits\"],\"linux_4_11\":[],\"linux_latest\":[\"linux_4_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[\"fs\"],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"procfs\":[\"once_cell\",\"itoa\",\"fs\"],\"pty\":[\"itoa\",\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"compiler_builtins\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\",\"compiler_builtins?/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\",\"libc-extra-traits\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\",\"libc-extra-traits\"],\"use-libc-auxv\":[]}}", "rustix_1.1.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", "rustix_1.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", + "rustls-native-certs_0.7.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.1.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.102\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.16\"}],\"features\":{}}", "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", + "rustls-pemfile_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.9\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"pki-types/std\"]}}", "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", "rustls-webpki_0.103.10": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", "rustls_0.23.36": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", @@ -1407,6 +1437,7 @@ "tokio-rustls_0.26.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.1\"},{\"features\":[\"pem\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.27\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"rustls/aws_lc_rs\"],\"brotli\":[\"rustls/brotli\"],\"default\":[\"logging\",\"tls12\",\"aws_lc_rs\"],\"early-data\":[],\"fips\":[\"rustls/fips\"],\"logging\":[\"rustls/logging\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"rustls/tls12\"],\"zlib\":[\"rustls/zlib\"]}}", "tokio-stream_0.1.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"}],\"features\":{\"default\":[\"time\"],\"fs\":[\"tokio/fs\"],\"full\":[\"time\",\"net\",\"io-util\",\"fs\",\"sync\",\"signal\"],\"io-util\":[\"tokio/io-util\"],\"net\":[\"tokio/net\"],\"signal\":[\"tokio/signal\"],\"sync\":[\"tokio/sync\",\"tokio-util\"],\"time\":[\"tokio/time\"]}}", "tokio-test_0.4.5": "{\"dependencies\":[{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.0\"},{\"features\":[\"rt\",\"sync\",\"time\",\"test-util\"],\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"name\":\"tokio-stream\",\"req\":\"^0.1.1\"}],\"features\":{}}", + "tokio-tungstenite_0.23.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.28\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.11\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.27.0\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.0\"},{\"default_features\":false,\"name\":\"tungstenite\",\"req\":\"^0.23.0\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26.0\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]}}", "tokio-util_0.7.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.44.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}", "tokio_1.49.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.6\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", "toml_0.5.11": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0.97\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[],\"preserve_order\":[\"indexmap\"]}}", @@ -1442,6 +1473,7 @@ "try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}", "ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}", "ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", + "tungstenite_0.23.0": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.3.2\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\"},{\"name\":\"data-encoding\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.3.4\"},{\"kind\":\"dev\",\"name\":\"input_buffer\",\"req\":\"^0.5.0\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.3\"},{\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.5.5\"},{\"name\":\"thiserror\",\"req\":\"^1.0.23\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"utf-8\",\"req\":\"^0.7.5\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]}}", "two-face_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cargo-lock\",\"req\":\"^10.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.44.3\"},{\"default_features\":false,\"features\":[\"read\"],\"kind\":\"dev\",\"name\":\"object\",\"req\":\"^0.36.7\"},{\"name\":\"serde\",\"req\":\"^1.0.228\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26.3\"},{\"default_features\":false,\"features\":[\"dump-load\",\"parsing\"],\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"default_features\":false,\"features\":[\"html\"],\"kind\":\"dev\",\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.23\"},{\"default_features\":false,\"features\":[\"std\",\"xxhash64\"],\"kind\":\"dev\",\"name\":\"twox-hash\",\"req\":\"^2.1.2\"}],\"features\":{\"default\":[\"syntect-onig\"],\"syntect-default-fancy\":[\"syntect-fancy\",\"syntect/default-fancy\"],\"syntect-default-onig\":[\"syntect-onig\",\"syntect/default-onig\"],\"syntect-fancy\":[\"syntect/regex-fancy\"],\"syntect-onig\":[\"syntect/regex-onig\"]}}", "type-map_0.5.1": "{\"dependencies\":[{\"name\":\"rustc-hash\",\"req\":\"^2\"}],\"features\":{}}", "typenum_1.19.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 73d1f14f7..6ef6805bb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -448,7 +448,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -800,7 +800,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tower", "tower-layer", "tower-service", @@ -1385,10 +1385,10 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-test", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tokio-util", "tracing", - "tungstenite", + "tungstenite 0.27.0", "url", "wiremock", ] @@ -1452,7 +1452,7 @@ dependencies = [ "tempfile", "time", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tokio-util", "toml 0.9.11+spec-1.1.0", "tracing", @@ -1477,7 +1477,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "toml 0.9.11+spec-1.1.0", "tracing", "url", @@ -1527,7 +1527,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "tungstenite", + "tungstenite 0.27.0", "url", "uuid", ] @@ -1616,6 +1616,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-clawbot" +version = "0.0.0" +dependencies = [ + "anyhow", + "openlark", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml 0.9.11+spec-1.1.0", +] + [[package]] name = "codex-cli" version = "0.0.0" @@ -1684,7 +1698,7 @@ dependencies = [ "rand 0.9.2", "reqwest", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", @@ -1935,7 +1949,7 @@ dependencies = [ "test-log", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tokio-util", "toml 0.9.11+spec-1.1.0", "toml_edit 0.24.0+spec-1.1.0", @@ -2059,7 +2073,7 @@ dependencies = [ "test-case", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", ] @@ -2469,7 +2483,7 @@ dependencies = [ "strum_macros 0.28.0", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -2781,6 +2795,7 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-chatgpt", + "codex-clawbot", "codex-cli", "codex-cloud-requirements", "codex-config", @@ -3320,7 +3335,7 @@ dependencies = [ "shlex", "tempfile", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -3959,7 +3974,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4204,7 +4219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5071,7 +5086,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls", @@ -5125,7 +5140,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -5631,7 +5646,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5852,6 +5867,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" +[[package]] +name = "lark-websocket-protobuf" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79d4b1f8c37d37efd353c1b116df0f98ff21c8bcb216f67ab026dd2fd2b9ab81" +dependencies = [ + "bytes", + "prost 0.13.5", + "prost-build", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -6409,7 +6435,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6585,7 +6611,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -6855,6 +6881,401 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openlark" +version = "0.15.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe508c03b5517af1605c03235638f9be5736d89c2c921c73b5e0c24c97014541" +dependencies = [ + "chrono", + "openlark-ai", + "openlark-analytics", + "openlark-application", + "openlark-auth", + "openlark-cardkit", + "openlark-client", + "openlark-communication", + "openlark-core", + "openlark-docs", + "openlark-helpdesk", + "openlark-hr", + "openlark-mail", + "openlark-meeting", + "openlark-platform", + "openlark-protocol", + "openlark-security", + "openlark-user", + "openlark-webhook", + "openlark-workflow", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "openlark-ai" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437bc45fefa2eaef48b51104cde5ecd46e19972a41da26951d6fd24e16851a0b" +dependencies = [ + "anyhow", + "async-trait", + "openlark-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "openlark-analytics" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f6b4997b64c556a11fbc8765d69854a517a58faa6b0a74b6230423382c1fd3" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "log", + "once_cell", + "openlark-core", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "openlark-application" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fc6037b46ca9409ae2b2514612a023d09b171b7c425463ec6434edd959028" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "openlark-auth" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8311905dc8fc2a5042fdfe5a836f03cf6ca3046f102dada2222be30f194de357" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "hex", + "openlark-core", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "openlark-cardkit" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62aeb9d280b283e1293392c6ec5c5e22d2bea52663b34a5cb041560c4738465" +dependencies = [ + "openlark-core", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "openlark-client" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ccb83d3ce877c8c1a505f415ff755e5029f5f291ebcf203570a866e089d461" +dependencies = [ + "chrono", + "futures-util", + "lark-websocket-protobuf", + "log", + "openlark-auth", + "openlark-communication", + "openlark-core", + "prost 0.13.5", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.23.1", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "openlark-communication" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4dc90d7df526fca7490734bd5b6a6f107dbfb2f9eb1f6ad7a9aa55fd6eef42f" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "openlark-core" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e135b64cab6d8a7c9db3ee78d2ee97ddf8761a99b0a964020313777a2fe70ca6" +dependencies = [ + "base64 0.22.1", + "chrono", + "futures-util", + "hmac", + "http 1.4.0", + "num_cpus", + "quick_cache", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_with", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "openlark-docs" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34bb68d37e3cec00d4e9b9499c40a6add97f23d715a1ce736a1a008573a15401" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "futures-util", + "log", + "once_cell", + "openlark-core", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "tokio", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "openlark-helpdesk" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2c2a905897a1d546aff303a07d65dd4dde7e29eb7461259f1391cfea027077" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "openlark-hr" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ab434c649a8f1d27265a284d9745b860a5ae81d2d5de4ef020d190efe466473" +dependencies = [ + "anyhow", + "async-trait", + "log", + "openlark-core", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "openlark-mail" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c884205245b3c89106e929cd546451eaf9cc105e06329e3cbec754dc77d74016" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "openlark-meeting" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b51fa17f59697a3bb1861e62e6474338fdf3fb7dd6cbfcba848fd4094946aa0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "openlark-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "openlark-platform" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ecf4038d68ca80830c188b947128e45681a38d998a363e72032786c8eaf76f8" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "log", + "once_cell", + "openlark-core", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "tokio", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "openlark-protocol" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51d6c682fe4e39ac528f2aaa53f3a36e212bcbbc537c7b3b7244f2bfe123acc" +dependencies = [ + "bytes", + "prost 0.13.5", + "prost-build", +] + +[[package]] +name = "openlark-security" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029be662cd74764e034e9b07189350491b36750d37b7c7458352bf7aef9392b1" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "hmac", + "log", + "openlark-core", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "openlark-user" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6192b2beb505c1efa81d854de57f0cf53cfcf92caad4b5cc6b253a6bdf7b7a" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "log", + "once_cell", + "openlark-core", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "openlark-webhook" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ec41748444100f1c516c438c5186de09632ce9256aaad5c1e19948046212a0" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "openlark-workflow" +version = "0.15.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f54035a3c78a58f0fd2201f46459c27e78b0f0bbf2dc61934f44c620f8fcd874" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "openssl" version = "0.10.75" @@ -6965,7 +7386,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "reqwest", "serde_json", "thiserror 2.0.18", @@ -6984,7 +7405,7 @@ dependencies = [ "const-hex", "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "serde", "serde_json", "tonic", @@ -7053,7 +7474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -7504,6 +7925,26 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive 0.12.6", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.3" @@ -7511,7 +7952,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck", + "itertools 0.10.5", + "log", + "multimap", + "once_cell", + "petgraph 0.6.5", + "prettyplease", + "prost 0.12.6", + "prost-types", + "regex", + "syn 2.0.114", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] @@ -7527,6 +8015,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost 0.12.6", +] + [[package]] name = "psl" version = "2.1.184" @@ -7586,6 +8083,18 @@ dependencies = [ "serde", ] +[[package]] +name = "quick_cache" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3" +dependencies = [ + "ahash", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quinn" version = "0.11.9" @@ -7599,7 +8108,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -7636,9 +8145,9 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -7936,7 +8445,7 @@ dependencies = [ "rama-utils", "rcgen", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls", @@ -8241,12 +8750,13 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", @@ -8474,7 +8984,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8493,6 +9003,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.3" @@ -8505,6 +9028,15 @@ dependencies = [ "security-framework 3.5.1", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -9908,7 +10440,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10294,6 +10826,22 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "tokio-tungstenite" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.23.0", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -10302,11 +10850,11 @@ dependencies = [ "futures-util", "log", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.27.0", ] [[package]] @@ -10414,7 +10962,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "sync_wrapper", "tokio", "tokio-rustls", @@ -10432,7 +10980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" dependencies = [ "bytes", - "prost", + "prost 0.14.3", "tonic", ] @@ -10690,6 +11238,26 @@ dependencies = [ "termcolor", ] +[[package]] +name = "tungstenite" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.8.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.27.0" @@ -11354,7 +11922,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 16329276c..0504ac247 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -20,6 +20,7 @@ members = [ "cloud-tasks-client", "cloud-tasks-mock-client", "cli", + "clawbot", "collaboration-mode-templates", "connectors", "config", @@ -115,6 +116,7 @@ codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli" } +codex-clawbot = { path = "clawbot" } codex-client = { path = "codex-client" } codex-collaboration-mode-templates = { path = "collaboration-mode-templates" } codex-cloud-requirements = { path = "cloud-requirements" } diff --git a/codex-rs/clawbot/BUILD.bazel b/codex-rs/clawbot/BUILD.bazel new file mode 100644 index 000000000..f559c0e78 --- /dev/null +++ b/codex-rs/clawbot/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "clawbot", + crate_name = "codex_clawbot", +) diff --git a/codex-rs/clawbot/Cargo.toml b/codex-rs/clawbot/Cargo.toml new file mode 100644 index 000000000..b76340649 --- /dev/null +++ b/codex-rs/clawbot/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "codex-clawbot" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_clawbot" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +openlark = { version = "0.15.0-rc.1", default-features = false, features = [ + "ai", + "analytics", + "application", + "auth", + "base", + "bitable", + "cardkit", + "communication", + "docs", + "helpdesk", + "hr", + "mail", + "meeting", + "platform", + "protocol", + "security", + "user", + "webhook", + "websocket", + "workflow", +] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt", "sync", "time"] } +toml = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/clawbot/src/config.rs b/codex-rs/clawbot/src/config.rs new file mode 100644 index 000000000..944efcbc7 --- /dev/null +++ b/codex-rs/clawbot/src/config.rs @@ -0,0 +1,25 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct ClawbotConfig { + pub feishu: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct FeishuConfig { + pub app_id: String, + pub app_secret: String, + pub verification_token: Option, + pub encrypt_key: Option, + pub bot_open_id: Option, + pub bot_user_id: Option, +} + +impl FeishuConfig { + pub fn has_api_credentials(&self) -> bool { + !self.app_id.trim().is_empty() && !self.app_secret.trim().is_empty() + } +} diff --git a/codex-rs/clawbot/src/events.rs b/codex-rs/clawbot/src/events.rs new file mode 100644 index 000000000..d66710856 --- /dev/null +++ b/codex-rs/clawbot/src/events.rs @@ -0,0 +1,12 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::model::ProviderSessionRef; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProviderInboundMessage { + pub session: ProviderSessionRef, + pub message_id: String, + pub text: String, + pub received_at: i64, +} diff --git a/codex-rs/clawbot/src/lib.rs b/codex-rs/clawbot/src/lib.rs new file mode 100644 index 000000000..109a76800 --- /dev/null +++ b/codex-rs/clawbot/src/lib.rs @@ -0,0 +1,32 @@ +mod config; +mod events; +mod model; +mod provider; +mod runtime; +mod store; + +pub use config::ClawbotConfig; +pub use config::FeishuConfig; +pub use events::ProviderInboundMessage; +pub use model::CLAWBOT_BINDINGS_RELATIVE_PATH; +pub use model::CLAWBOT_CONFIG_RELATIVE_PATH; +pub use model::CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH; +pub use model::CLAWBOT_RELATIVE_DIR; +pub use model::CLAWBOT_RUNTIME_RELATIVE_PATH; +pub use model::CLAWBOT_SESSIONS_RELATIVE_PATH; +pub use model::CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH; +pub use model::CachedUnreadMessage; +pub use model::ClawbotSnapshot; +pub use model::ConnectionStatus; +pub use model::ProviderKind; +pub use model::ProviderRuntimeState; +pub use model::ProviderSession; +pub use model::ProviderSessionRef; +pub use model::SessionBinding; +pub use model::SessionStatus; +pub use provider::FeishuProviderRuntime; +pub use provider::ProviderEvent; +pub use provider::ProviderOutboundTextMessage; +pub use provider::feishu_failure_reply_text; +pub use runtime::ClawbotRuntime; +pub use store::ClawbotStore; diff --git a/codex-rs/clawbot/src/model.rs b/codex-rs/clawbot/src/model.rs new file mode 100644 index 000000000..c5970bb76 --- /dev/null +++ b/codex-rs/clawbot/src/model.rs @@ -0,0 +1,159 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::config::ClawbotConfig; + +pub const CLAWBOT_RELATIVE_DIR: &str = ".codex/clawbot"; +pub const CLAWBOT_CONFIG_RELATIVE_PATH: &str = ".codex/clawbot/config.toml"; +pub const CLAWBOT_SESSIONS_RELATIVE_PATH: &str = ".codex/clawbot/sessions.json"; +pub const CLAWBOT_BINDINGS_RELATIVE_PATH: &str = ".codex/clawbot/bindings.json"; +pub const CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH: &str = ".codex/clawbot/unread_messages.jsonl"; +pub const CLAWBOT_RUNTIME_RELATIVE_PATH: &str = ".codex/clawbot/runtime.json"; +pub const CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH: &str = ".codex/clawbot/inbound_receipts.json"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ClawbotSnapshot { + pub config: ClawbotConfig, + pub runtime: Vec, + pub sessions: Vec, + pub bindings: Vec, + pub unread_message_count: usize, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + Feishu, +} + +impl ProviderKind { + pub fn title(self) -> &'static str { + match self { + Self::Feishu => "Feishu", + } + } +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionStatus { + #[default] + Unconfigured, + Disconnected, + Connecting, + Connected, + Error, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SessionStatus { + #[default] + Discovered, + Bound, + Disconnected, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProviderRuntimeState { + pub provider: ProviderKind, + pub connection: ConnectionStatus, + pub last_error: Option, + pub updated_at: Option, +} + +impl ProviderRuntimeState { + pub fn unconfigured(provider: ProviderKind) -> Self { + Self { + provider, + connection: ConnectionStatus::Unconfigured, + last_error: None, + updated_at: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct ProviderSessionRef { + pub provider: ProviderKind, + pub session_id: String, +} + +impl ProviderSessionRef { + pub fn new(provider: ProviderKind, session_id: impl Into) -> Self { + Self { + provider, + session_id: session_id.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProviderSession { + pub provider: ProviderKind, + pub session_id: String, + pub display_name: Option, + pub unread_count: usize, + pub last_message_at: Option, + pub status: SessionStatus, + pub bound_thread_id: Option, +} + +impl ProviderSession { + pub fn session_ref(&self) -> ProviderSessionRef { + ProviderSessionRef::new(self.provider, self.session_id.clone()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionBinding { + pub provider: ProviderKind, + pub session_id: String, + pub thread_id: String, + #[serde(default = "default_session_forwarding_enabled")] + pub inbound_forwarding_enabled: bool, + #[serde(default = "default_session_forwarding_enabled")] + pub outbound_forwarding_enabled: bool, + pub created_at: i64, + pub updated_at: i64, +} + +impl SessionBinding { + pub fn session_ref(&self) -> ProviderSessionRef { + ProviderSessionRef::new(self.provider, self.session_id.clone()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CachedUnreadMessage { + pub provider: ProviderKind, + pub session_id: String, + pub message_id: String, + pub text: String, + pub received_at: i64, +} + +impl CachedUnreadMessage { + pub fn session_ref(&self) -> ProviderSessionRef { + ProviderSessionRef::new(self.provider, self.session_id.clone()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InboundMessageReceipt { + pub provider: ProviderKind, + pub session_id: String, + pub message_id: String, + pub received_at: i64, +} + +impl InboundMessageReceipt { + pub fn session_ref(&self) -> ProviderSessionRef { + ProviderSessionRef::new(self.provider, self.session_id.clone()) + } +} + +fn default_session_forwarding_enabled() -> bool { + true +} diff --git a/codex-rs/clawbot/src/provider/feishu.rs b/codex-rs/clawbot/src/provider/feishu.rs new file mode 100644 index 000000000..1de159ef3 --- /dev/null +++ b/codex-rs/clawbot/src/provider/feishu.rs @@ -0,0 +1,421 @@ +mod runtime_loop; + +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Result; +use anyhow::anyhow; +use open_lark::openlark_client; +use open_lark::openlark_communication::im::im::v1::message::create::CreateMessageBody; +use open_lark::openlark_communication::im::im::v1::message::create::CreateMessageRequest; +use open_lark::openlark_communication::im::im::v1::message::models::ReceiveIdType; +use serde::Deserialize; +use tokio::sync::mpsc; + +use super::ProviderEvent; +use super::ProviderOutboundTextMessage; +use crate::config::FeishuConfig; +use crate::events::ProviderInboundMessage; +use crate::model::ConnectionStatus; +use crate::model::ProviderKind; +use crate::model::ProviderRuntimeState; +use crate::model::ProviderSession; +use crate::model::ProviderSessionRef; +use crate::model::SessionStatus; + +#[derive(Debug, Clone)] +pub struct FeishuInboundPrivateMessage { + pub chat_id: String, + pub chat_type: String, + pub message_id: String, + pub sender_open_id: Option, + pub sender_user_id: Option, + pub sender_union_id: Option, + pub sender_name: Option, + pub text: String, + pub received_at: i64, +} + +#[derive(Debug, Clone)] +pub struct FeishuProviderRuntime { + config: FeishuConfig, +} + +impl FeishuProviderRuntime { + pub fn new(config: FeishuConfig) -> Self { + Self { config } + } + + pub async fn run(self, provider_event_tx: mpsc::UnboundedSender) -> Result<()> { + runtime_loop::run_with_reconnect(self.config, provider_event_tx).await + } + + pub async fn send_text(&self, message: ProviderOutboundTextMessage) -> Result<()> { + if message.session.provider != ProviderKind::Feishu { + return Err(anyhow!( + "cannot send {} message via Feishu runtime", + message.session.provider.title() + )); + } + + let body = CreateMessageBody { + receive_id: message.session.session_id, + msg_type: "text".to_string(), + content: serde_json::to_string(&serde_json::json!({ "text": message.text }))?, + uuid: None, + }; + CreateMessageRequest::new(self.messaging_config()?) + .receive_id_type(ReceiveIdType::ChatId) + .execute(body) + .await + .map_err(|error| anyhow!("failed to send Feishu text message: {error}"))?; + Ok(()) + } + + pub fn normalize_private_chat_message( + message: FeishuInboundPrivateMessage, + ) -> Option> { + if !is_private_chat_type(&message.chat_type) || message.text.trim().is_empty() { + return None; + } + + let session = ProviderSession { + provider: ProviderKind::Feishu, + session_id: message.chat_id.clone(), + display_name: message + .sender_name + .or(message.sender_open_id.clone()) + .or(message.sender_user_id.clone()) + .or(message.sender_union_id.clone()), + unread_count: 0, + last_message_at: Some(message.received_at), + status: SessionStatus::Discovered, + bound_thread_id: None, + }; + let inbound_message = ProviderInboundMessage { + session: ProviderSessionRef::new(ProviderKind::Feishu, message.chat_id), + message_id: message.message_id, + text: message.text, + received_at: message.received_at, + }; + + Some(vec![ + ProviderEvent::SessionUpserted(session), + ProviderEvent::InboundMessage(inbound_message), + ]) + } + + pub(super) fn websocket_config(&self) -> Result { + runtime_loop::build_websocket_config(&self.config) + } + + fn messaging_config(&self) -> Result { + Ok(self + .websocket_config()? + .build_core_config_with_token_provider()) + } +} + +pub fn failure_reply_text(message: &str) -> String { + let summary = message + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("unknown error"); + let truncated = truncate_chars(summary, /*max_chars*/ 160); + format!("Request failed: {truncated}") +} + +pub(super) fn provider_events_from_payload(payload: &[u8]) -> Vec { + let Ok(envelope) = serde_json::from_slice::(payload) else { + return Vec::new(); + }; + + match envelope.header.event_type.as_str() { + "im.message.receive_v1" => { + serde_json::from_value::(envelope.event) + .ok() + .and_then(|event| { + normalize_message_receive_event(FeishuMessageReceiveEnvelope { event }) + }) + .unwrap_or_default() + } + "im.chat.access_event.bot_p2p_chat_entered_v1" => { + serde_json::from_value::(envelope.event) + .ok() + .map(|event| normalize_chat_entered_event(FeishuChatEnteredEnvelope { event })) + .unwrap_or_default() + } + _ => Vec::new(), + } +} + +fn normalize_message_receive_event( + envelope: FeishuMessageReceiveEnvelope, +) -> Option> { + let chat = envelope.event.chat; + let message = envelope.event.message; + if !is_private_chat_type(&message.chat_type) || message.message_type != "text" { + return None; + } + + let chat_id = chat + .as_ref() + .map(|chat| chat.chat_id.clone()) + .or(message.chat_id.clone())?; + let text = serde_json::from_str::(&message.content) + .ok() + .map(|content| content.text) + .unwrap_or_default(); + let received_at = parse_optional_timestamp(Some(message.create_time))?; + + FeishuProviderRuntime::normalize_private_chat_message(FeishuInboundPrivateMessage { + chat_id, + chat_type: message.chat_type, + message_id: message.message_id, + sender_open_id: envelope.event.sender.sender_id.open_id, + sender_user_id: envelope.event.sender.sender_id.user_id, + sender_union_id: envelope.event.sender.sender_id.union_id, + sender_name: chat.and_then(|chat| chat.name), + text, + received_at, + }) +} + +fn normalize_chat_entered_event(envelope: FeishuChatEnteredEnvelope) -> Vec { + let operator = envelope.event.operator_id; + vec![ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: envelope.event.chat_id, + display_name: operator + .open_id + .clone() + .or(operator.user_id.clone()) + .or(operator.union_id), + unread_count: 0, + last_message_at: parse_optional_timestamp(envelope.event.last_message_create_time), + status: SessionStatus::Discovered, + bound_thread_id: None, + })] +} + +fn parse_optional_timestamp(timestamp: Option) -> Option { + timestamp.and_then(|value| value.parse::().ok()) +} + +fn is_private_chat_type(chat_type: &str) -> bool { + matches!(chat_type, "p2p" | "private") +} + +pub(super) fn runtime_state( + connection: ConnectionStatus, + last_error: Option, +) -> Result { + Ok(ProviderRuntimeState { + provider: ProviderKind::Feishu, + connection, + last_error, + updated_at: Some(unix_timestamp_now()?), + }) +} + +fn unix_timestamp_now() -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64) +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + let mut chars = value.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +#[derive(Debug, Deserialize)] +struct FeishuEventEnvelope { + header: FeishuEventHeader, + event: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct FeishuEventHeader { + event_type: String, +} + +#[derive(Debug, Deserialize)] +struct FeishuMessageReceiveEnvelope { + event: FeishuMessageReceiveEvent, +} + +#[derive(Debug, Deserialize)] +struct FeishuMessageReceiveEvent { + sender: FeishuEventSender, + message: FeishuEventMessage, + #[serde(default)] + chat: Option, +} + +#[derive(Debug, Deserialize)] +struct FeishuEventSender { + sender_id: FeishuUserId, +} + +#[derive(Debug, Deserialize)] +struct FeishuUserId { + open_id: Option, + user_id: Option, + union_id: Option, +} + +#[derive(Debug, Deserialize)] +struct FeishuEventMessage { + message_id: String, + create_time: String, + #[serde(default)] + chat_id: Option, + chat_type: String, + message_type: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct FeishuEventChat { + chat_id: String, + #[serde(default)] + name: Option, +} + +#[derive(Debug, Deserialize)] +struct FeishuTextContent { + text: String, +} + +#[derive(Debug, Deserialize)] +struct FeishuChatEnteredEnvelope { + event: FeishuChatEnteredEvent, +} + +#[derive(Debug, Deserialize)] +struct FeishuChatEnteredEvent { + chat_id: String, + operator_id: FeishuUserId, + last_message_create_time: Option, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::FeishuInboundPrivateMessage; + use super::failure_reply_text; + use super::normalize_chat_entered_event; + use super::normalize_message_receive_event; + use crate::model::ProviderKind; + use crate::model::ProviderSession; + use crate::model::ProviderSessionRef; + use crate::model::SessionStatus; + use crate::provider::ProviderEvent; + + #[test] + fn normalize_private_chat_message_creates_session_and_inbound_events() { + let events = super::FeishuProviderRuntime::normalize_private_chat_message( + FeishuInboundPrivateMessage { + chat_id: "chat_123".to_string(), + chat_type: "p2p".to_string(), + message_id: "msg_123".to_string(), + sender_open_id: Some("ou_123".to_string()), + sender_user_id: None, + sender_union_id: None, + sender_name: Some("Alice".to_string()), + text: "hello".to_string(), + received_at: 123, + }, + ) + .expect("events"); + + assert_eq!( + events, + vec![ + ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_123".to_string(), + display_name: Some("Alice".to_string()), + unread_count: 0, + last_message_at: Some(123), + status: SessionStatus::Discovered, + bound_thread_id: None, + }), + ProviderEvent::InboundMessage(crate::events::ProviderInboundMessage { + session: ProviderSessionRef::new(ProviderKind::Feishu, "chat_123"), + message_id: "msg_123".to_string(), + text: "hello".to_string(), + received_at: 123, + }), + ] + ); + } + + #[test] + fn message_receive_event_skips_non_text_messages() { + let envelope = super::FeishuMessageReceiveEnvelope { + event: super::FeishuMessageReceiveEvent { + sender: super::FeishuEventSender { + sender_id: super::FeishuUserId { + open_id: Some("ou_123".to_string()), + user_id: None, + union_id: None, + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_123".to_string(), + create_time: "456".to_string(), + chat_id: Some("chat_123".to_string()), + chat_type: "p2p".to_string(), + message_type: "image".to_string(), + content: "{}".to_string(), + }, + chat: None, + }, + }; + + assert_eq!(normalize_message_receive_event(envelope), None); + } + + #[test] + fn chat_entered_event_creates_discovered_session() { + let events = normalize_chat_entered_event(super::FeishuChatEnteredEnvelope { + event: super::FeishuChatEnteredEvent { + chat_id: "chat_123".to_string(), + operator_id: super::FeishuUserId { + open_id: Some("ou_123".to_string()), + user_id: None, + union_id: None, + }, + last_message_create_time: Some("456".to_string()), + }, + }); + + assert_eq!( + events, + vec![ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_123".to_string(), + display_name: Some("ou_123".to_string()), + unread_count: 0, + last_message_at: Some(456), + status: SessionStatus::Discovered, + bound_thread_id: None, + })] + ); + } + + #[test] + fn failure_reply_text_uses_first_nonempty_line() { + assert_eq!( + failure_reply_text("\nboom\nsecond"), + "Request failed: boom".to_string() + ); + } +} diff --git a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs new file mode 100644 index 000000000..7b4fa59d8 --- /dev/null +++ b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use open_lark::openlark_client; +use open_lark::openlark_client::ws_client::EventDispatcherHandler; +use open_lark::openlark_client::ws_client::LarkWsClient; +use tokio::sync::mpsc; + +use super::provider_events_from_payload; +use super::runtime_state; +use crate::config::FeishuConfig; +use crate::model::ConnectionStatus; +use crate::provider::ProviderEvent; + +const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(2); +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); + +pub(super) async fn run_with_reconnect( + config: FeishuConfig, + provider_event_tx: mpsc::UnboundedSender, +) -> Result<()> { + if !config.has_api_credentials() { + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Unconfigured, + Some("missing app_id/app_secret".to_string()), + )?)); + return Err(anyhow!("missing app_id/app_secret")); + } + + let mut reconnect_delay = INITIAL_RECONNECT_DELAY; + loop { + match run_once(&config, &provider_event_tx).await { + Ok(()) => { + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Disconnected, + Some(format!( + "Feishu websocket runtime exited; reconnecting in {}s", + reconnect_delay.as_secs() + )), + )?)); + } + Err(error) => { + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Error, + Some(format!( + "Feishu websocket runtime failed: {error}; reconnecting in {}s", + reconnect_delay.as_secs() + )), + )?)); + } + } + + tokio::time::sleep(reconnect_delay).await; + reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY); + } +} + +async fn run_once( + config: &FeishuConfig, + provider_event_tx: &mpsc::UnboundedSender, +) -> Result<()> { + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Connecting, + None, + )?)); + + let ws_config = Arc::new(build_websocket_config(config)?); + let (payload_tx, mut payload_rx) = mpsc::unbounded_channel::>(); + let event_handler = EventDispatcherHandler::builder() + .payload_sender(payload_tx) + .build(); + let payload_provider_event_tx = provider_event_tx.clone(); + let payload_task = tokio::spawn(async move { + while let Some(payload) = payload_rx.recv().await { + for event in provider_events_from_payload(&payload) { + let _ = payload_provider_event_tx.send(event); + } + } + }); + + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Connected, + None, + )?)); + + let open_result = LarkWsClient::open(ws_config, event_handler).await; + payload_task.abort(); + open_result.map_err(|error| anyhow!("Feishu websocket runtime failed: {error}")) +} + +pub(super) fn build_websocket_config(config: &FeishuConfig) -> Result { + openlark_client::Config::builder() + .app_id(config.app_id.clone()) + .app_secret(config.app_secret.clone()) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| anyhow!("failed to build Feishu websocket config: {error}")) +} diff --git a/codex-rs/clawbot/src/provider/mod.rs b/codex-rs/clawbot/src/provider/mod.rs new file mode 100644 index 000000000..abddd74ec --- /dev/null +++ b/codex-rs/clawbot/src/provider/mod.rs @@ -0,0 +1,23 @@ +mod feishu; + +use crate::events::ProviderInboundMessage; +use crate::model::ProviderRuntimeState; +use crate::model::ProviderSession; +use crate::model::ProviderSessionRef; + +pub use feishu::FeishuProviderRuntime; +pub use feishu::failure_reply_text as feishu_failure_reply_text; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderOutboundTextMessage { + pub session: ProviderSessionRef, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderEvent { + RuntimeStateUpdated(ProviderRuntimeState), + SessionUpserted(ProviderSession), + SessionRemoved(ProviderSessionRef), + InboundMessage(ProviderInboundMessage), +} diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs new file mode 100644 index 000000000..39b671d3d --- /dev/null +++ b/codex-rs/clawbot/src/runtime.rs @@ -0,0 +1,378 @@ +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Context; +use anyhow::Result; + +use crate::model::CachedUnreadMessage; +use crate::model::ClawbotSnapshot; +use crate::model::InboundMessageReceipt; +use crate::model::ProviderSession; +use crate::model::ProviderSessionRef; +use crate::model::SessionBinding; +use crate::model::SessionStatus; +use crate::provider::FeishuProviderRuntime; +use crate::provider::ProviderEvent; +use crate::store::ClawbotStore; + +#[derive(Debug)] +pub struct ClawbotRuntime { + store: ClawbotStore, + snapshot: ClawbotSnapshot, +} + +impl ClawbotRuntime { + pub fn load(workspace_root: PathBuf) -> Result { + let store = ClawbotStore::new(workspace_root); + let snapshot = store.load_snapshot()?; + Ok(Self { store, snapshot }) + } + + pub fn reload(&mut self) -> Result<&ClawbotSnapshot> { + self.snapshot = self.store.load_snapshot()?; + Ok(&self.snapshot) + } + + pub fn snapshot(&self) -> &ClawbotSnapshot { + &self.snapshot + } + + pub fn store(&self) -> &ClawbotStore { + &self.store + } + + pub fn feishu_provider(&self) -> Option { + self.snapshot + .config + .feishu + .clone() + .map(FeishuProviderRuntime::new) + } + + pub fn persist_session(&mut self, session: ProviderSession) -> Result<&ClawbotSnapshot> { + self.store.upsert_session(session)?; + self.reload() + } + + pub fn persist_binding(&mut self, binding: SessionBinding) -> Result<&ClawbotSnapshot> { + self.store.upsert_binding(binding)?; + self.reload() + } + + pub fn connect_session_to_thread( + &mut self, + session: &ProviderSessionRef, + thread_id: String, + ) -> Result<&ClawbotSnapshot> { + let now = unix_timestamp_now()?; + let mut bindings = self.store.load_bindings()?; + let mut sessions = self.store.load_sessions()?; + let existing_binding = bindings + .iter() + .find(|binding| binding.session_ref() == *session) + .cloned(); + + for binding in &bindings { + if binding.thread_id == thread_id + && binding.session_ref() != *session + && let Some(existing_session) = sessions + .iter_mut() + .find(|existing| existing.session_ref() == binding.session_ref()) + { + existing_session.bound_thread_id = None; + existing_session.status = SessionStatus::Discovered; + } + } + + bindings + .retain(|binding| binding.thread_id != thread_id || binding.session_ref() == *session); + if let Some(binding) = bindings + .iter_mut() + .find(|binding| binding.session_ref() == *session) + { + binding.thread_id = thread_id.clone(); + binding.updated_at = now; + } else { + bindings.push(SessionBinding { + provider: session.provider, + session_id: session.session_id.clone(), + thread_id: thread_id.clone(), + inbound_forwarding_enabled: true, + outbound_forwarding_enabled: true, + created_at: existing_binding + .as_ref() + .map_or(now, |binding| binding.created_at), + updated_at: now, + }); + } + + if let Some(provider_session) = sessions + .iter_mut() + .find(|provider_session| provider_session.session_ref() == *session) + { + provider_session.bound_thread_id = Some(thread_id.clone()); + provider_session.status = SessionStatus::Bound; + } else { + sessions.push(ProviderSession { + provider: session.provider, + session_id: session.session_id.clone(), + display_name: None, + unread_count: self.unread_count_for_session(session)?, + last_message_at: None, + status: SessionStatus::Bound, + bound_thread_id: Some(thread_id), + }); + } + + self.store.save_bindings(&bindings)?; + self.store.save_sessions(&sessions)?; + self.reload() + } + + pub fn load_binding_for_thread(&self, thread_id: &str) -> Result> { + Ok(self + .store + .load_bindings()? + .into_iter() + .find(|binding| binding.thread_id == thread_id)) + } + + pub fn load_binding_for_session( + &self, + session: &ProviderSessionRef, + ) -> Result> { + Ok(self + .store + .load_bindings()? + .into_iter() + .find(|binding| binding.session_ref() == *session)) + } + + pub fn bound_session_for_thread(&self, thread_id: &str) -> Result> { + Ok(self + .load_binding_for_thread(thread_id)? + .as_ref() + .map(SessionBinding::session_ref)) + } + + pub fn take_next_unread_message( + &mut self, + session: &ProviderSessionRef, + ) -> Result> { + let message = self.store.take_next_unread_message(session)?; + if message.is_some() + && let Some(mut provider_session) = self.load_session(session)? + { + provider_session.unread_count = provider_session.unread_count.saturating_sub(1); + self.store.upsert_session(provider_session)?; + } + self.reload()?; + Ok(message) + } + + pub fn apply_provider_event(&mut self, event: ProviderEvent) -> Result<&ClawbotSnapshot> { + match event { + ProviderEvent::RuntimeStateUpdated(state) => { + self.store.upsert_runtime_state(state)?; + } + ProviderEvent::SessionUpserted(mut session) => { + session.bound_thread_id = self.lookup_bound_thread_id(&session.session_ref())?; + session.unread_count = self.unread_count_for_session(&session.session_ref())?; + if session.bound_thread_id.is_some() { + session.status = SessionStatus::Bound; + } + self.store.upsert_session(session)?; + } + ProviderEvent::SessionRemoved(session) => { + self.store.remove_session(&session)?; + } + ProviderEvent::InboundMessage(message) => { + if self + .store + .has_inbound_receipt(&message.session, &message.message_id)? + { + return self.reload(); + } + + self.store.append_unread_message(&CachedUnreadMessage { + provider: message.session.provider, + session_id: message.session.session_id.clone(), + message_id: message.message_id.clone(), + text: message.text, + received_at: message.received_at, + })?; + self.store.record_inbound_receipt(InboundMessageReceipt { + provider: message.session.provider, + session_id: message.session.session_id.clone(), + message_id: message.message_id, + received_at: message.received_at, + })?; + + let mut session = self + .load_session(&message.session)? + .unwrap_or(ProviderSession { + provider: message.session.provider, + session_id: message.session.session_id.clone(), + display_name: None, + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }); + session.bound_thread_id = self.lookup_bound_thread_id(&message.session)?; + session.unread_count = self.unread_count_for_session(&message.session)?; + session.last_message_at = Some(message.received_at); + if session.bound_thread_id.is_some() { + session.status = SessionStatus::Bound; + } + self.store.upsert_session(session)?; + } + } + self.reload() + } + + fn load_session(&self, session: &ProviderSessionRef) -> Result> { + Ok(self + .store + .load_sessions()? + .into_iter() + .find(|existing| existing.session_ref() == *session)) + } + + fn lookup_bound_thread_id(&self, session: &ProviderSessionRef) -> Result> { + Ok(self + .store + .load_bindings()? + .into_iter() + .find(|binding| binding.session_ref() == *session) + .map(|binding| binding.thread_id)) + } + + fn unread_count_for_session(&self, session: &ProviderSessionRef) -> Result { + Ok(self + .store + .load_unread_messages()? + .into_iter() + .filter(|message| message.session_ref() == *session) + .count()) + } +} + +fn unix_timestamp_now() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before UNIX_EPOCH")? + .as_secs() as i64) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use super::ClawbotRuntime; + use crate::events::ProviderInboundMessage; + use crate::model::ConnectionStatus; + use crate::model::ProviderKind; + use crate::model::ProviderRuntimeState; + use crate::model::ProviderSession; + use crate::model::ProviderSessionRef; + use crate::model::SessionStatus; + use crate::provider::ProviderEvent; + + #[test] + fn take_next_unread_message_is_fifo_per_session() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_1"); + + runtime + .persist_session(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + display_name: Some("Alice".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("session"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: session.clone(), + message_id: "msg_2".to_string(), + text: "second".to_string(), + received_at: 2, + })) + .expect("second"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "first".to_string(), + received_at: 1, + })) + .expect("first"); + + assert_eq!( + runtime + .take_next_unread_message(&session) + .expect("take first") + .expect("message") + .message_id, + "msg_1" + ); + assert_eq!( + runtime + .take_next_unread_message(&session) + .expect("take second") + .expect("message") + .message_id, + "msg_2" + ); + assert_eq!( + runtime + .take_next_unread_message(&session) + .expect("take none"), + None + ); + } + + #[test] + fn apply_provider_event_deduplicates_inbound_messages() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_2"); + + runtime + .apply_provider_event(ProviderEvent::RuntimeStateUpdated(ProviderRuntimeState { + provider: ProviderKind::Feishu, + connection: ConnectionStatus::Connected, + last_error: None, + updated_at: Some(1), + })) + .expect("runtime state"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "hello".to_string(), + received_at: 10, + })) + .expect("first inbound"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session, + message_id: "msg_1".to_string(), + text: "hello".to_string(), + received_at: 10, + })) + .expect("duplicate inbound"); + + assert_eq!(runtime.snapshot().runtime.len(), 1); + assert_eq!(runtime.snapshot().unread_message_count, 1); + assert_eq!(runtime.snapshot().sessions.len(), 1); + assert_eq!(runtime.snapshot().sessions[0].unread_count, 1); + } +} diff --git a/codex-rs/clawbot/src/store.rs b/codex-rs/clawbot/src/store.rs new file mode 100644 index 000000000..2823045fa --- /dev/null +++ b/codex-rs/clawbot/src/store.rs @@ -0,0 +1,341 @@ +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::config::ClawbotConfig; +use crate::model::CLAWBOT_BINDINGS_RELATIVE_PATH; +use crate::model::CLAWBOT_CONFIG_RELATIVE_PATH; +use crate::model::CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH; +use crate::model::CLAWBOT_RELATIVE_DIR; +use crate::model::CLAWBOT_RUNTIME_RELATIVE_PATH; +use crate::model::CLAWBOT_SESSIONS_RELATIVE_PATH; +use crate::model::CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH; +use crate::model::CachedUnreadMessage; +use crate::model::ClawbotSnapshot; +use crate::model::InboundMessageReceipt; +use crate::model::ProviderKind; +use crate::model::ProviderRuntimeState; +use crate::model::ProviderSession; +use crate::model::ProviderSessionRef; +use crate::model::SessionBinding; + +const MAX_INBOUND_RECEIPTS: usize = 4_096; + +#[derive(Debug, Clone)] +pub struct ClawbotStore { + workspace_root: PathBuf, +} + +impl ClawbotStore { + pub fn new(workspace_root: impl Into) -> Self { + Self { + workspace_root: workspace_root.into(), + } + } + + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + pub fn root_dir(&self) -> PathBuf { + self.workspace_root.join(CLAWBOT_RELATIVE_DIR) + } + + pub fn config_path(&self) -> PathBuf { + self.workspace_root.join(CLAWBOT_CONFIG_RELATIVE_PATH) + } + + pub fn sessions_path(&self) -> PathBuf { + self.workspace_root.join(CLAWBOT_SESSIONS_RELATIVE_PATH) + } + + pub fn bindings_path(&self) -> PathBuf { + self.workspace_root.join(CLAWBOT_BINDINGS_RELATIVE_PATH) + } + + pub fn unread_messages_path(&self) -> PathBuf { + self.workspace_root + .join(CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH) + } + + pub fn runtime_path(&self) -> PathBuf { + self.workspace_root.join(CLAWBOT_RUNTIME_RELATIVE_PATH) + } + + pub fn inbound_receipts_path(&self) -> PathBuf { + self.workspace_root + .join(CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH) + } + + pub fn ensure_root_dir(&self) -> Result<()> { + fs::create_dir_all(self.root_dir()) + .with_context(|| format!("failed to create {}", self.root_dir().display())) + } + + pub fn load_snapshot(&self) -> Result { + let config = self.load_config()?; + let mut runtime = self.load_runtime_states()?; + if config.feishu.is_none() + && runtime + .iter() + .all(|state| state.provider != ProviderKind::Feishu) + { + runtime.push(ProviderRuntimeState::unconfigured(ProviderKind::Feishu)); + } + let sessions = self.load_sessions()?; + let bindings = self.load_bindings()?; + let unread_message_count = self.load_unread_messages()?.len(); + Ok(ClawbotSnapshot { + config, + runtime, + sessions, + bindings, + unread_message_count, + }) + } + + pub fn load_config(&self) -> Result { + let config_path = self.config_path(); + if !config_path.exists() { + return Ok(ClawbotConfig::default()); + } + let raw = fs::read_to_string(&config_path) + .with_context(|| format!("failed to read {}", config_path.display()))?; + toml::from_str(&raw).with_context(|| format!("failed to parse {}", config_path.display())) + } + + pub fn load_runtime_states(&self) -> Result> { + read_optional_json_file(&self.runtime_path()) + .with_context(|| format!("failed to load {}", self.runtime_path().display())) + } + + pub fn save_runtime_states(&self, runtime_states: &[ProviderRuntimeState]) -> Result<()> { + let mut sorted = runtime_states.to_vec(); + sorted.sort_by_key(|state| state.provider.title()); + self.write_json_file(&self.runtime_path(), &sorted) + } + + pub fn upsert_runtime_state( + &self, + runtime_state: ProviderRuntimeState, + ) -> Result> { + let mut runtime_states = self.load_runtime_states()?; + if let Some(existing) = runtime_states + .iter_mut() + .find(|state| state.provider == runtime_state.provider) + { + *existing = runtime_state; + } else { + runtime_states.push(runtime_state); + } + self.save_runtime_states(&runtime_states)?; + Ok(runtime_states) + } + + pub fn load_sessions(&self) -> Result> { + read_optional_json_file(&self.sessions_path()) + .with_context(|| format!("failed to load {}", self.sessions_path().display())) + } + + pub fn save_sessions(&self, sessions: &[ProviderSession]) -> Result<()> { + let mut sorted = sessions.to_vec(); + sorted.sort_by(|left, right| { + left.provider + .title() + .cmp(right.provider.title()) + .then(left.session_id.cmp(&right.session_id)) + }); + self.write_json_file(&self.sessions_path(), &sorted) + } + + pub fn upsert_session(&self, session: ProviderSession) -> Result> { + let mut sessions = self.load_sessions()?; + if let Some(existing) = sessions + .iter_mut() + .find(|existing| existing.session_ref() == session.session_ref()) + { + *existing = session; + } else { + sessions.push(session); + } + self.save_sessions(&sessions)?; + Ok(sessions) + } + + pub fn remove_session(&self, session: &ProviderSessionRef) -> Result> { + let mut sessions = self.load_sessions()?; + sessions.retain(|existing| existing.session_ref() != *session); + self.save_sessions(&sessions)?; + Ok(sessions) + } + + pub fn load_bindings(&self) -> Result> { + read_optional_json_file(&self.bindings_path()) + .with_context(|| format!("failed to load {}", self.bindings_path().display())) + } + + pub fn save_bindings(&self, bindings: &[SessionBinding]) -> Result<()> { + let mut sorted = bindings.to_vec(); + sorted.sort_by(|left, right| { + left.provider + .title() + .cmp(right.provider.title()) + .then(left.session_id.cmp(&right.session_id)) + .then(left.thread_id.cmp(&right.thread_id)) + }); + self.write_json_file(&self.bindings_path(), &sorted) + } + + pub fn upsert_binding(&self, binding: SessionBinding) -> Result> { + let mut bindings = self.load_bindings()?; + if let Some(existing) = bindings + .iter_mut() + .find(|existing| existing.session_ref() == binding.session_ref()) + { + *existing = binding; + } else { + bindings.push(binding); + } + self.save_bindings(&bindings)?; + Ok(bindings) + } + + pub fn load_unread_messages(&self) -> Result> { + let unread_messages_path = self.unread_messages_path(); + if !unread_messages_path.exists() { + return Ok(Vec::new()); + } + + let raw = fs::read_to_string(&unread_messages_path) + .with_context(|| format!("failed to read {}", unread_messages_path.display()))?; + raw.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str::(line) + .with_context(|| format!("failed to parse {}", unread_messages_path.display())) + }) + .collect() + } + + pub fn save_unread_messages(&self, unread_messages: &[CachedUnreadMessage]) -> Result<()> { + let mut sorted = unread_messages.to_vec(); + sorted.sort_by(|left, right| { + left.provider + .title() + .cmp(right.provider.title()) + .then(left.session_id.cmp(&right.session_id)) + .then(left.received_at.cmp(&right.received_at)) + .then(left.message_id.cmp(&right.message_id)) + }); + sorted.dedup_by(|left, right| { + left.provider == right.provider + && left.session_id == right.session_id + && left.message_id == right.message_id + }); + + let rendered = if sorted.is_empty() { + String::new() + } else { + let lines = sorted + .iter() + .map(|message| serde_json::to_string(message).context("failed to encode unread")) + .collect::>>()?; + format!("{}\n", lines.join("\n")) + }; + self.write_string_file(&self.unread_messages_path(), &rendered) + } + + pub fn append_unread_message(&self, message: &CachedUnreadMessage) -> Result<()> { + let mut unread_messages = self.load_unread_messages()?; + unread_messages.push(message.clone()); + self.save_unread_messages(&unread_messages) + } + + pub fn take_next_unread_message( + &self, + session: &ProviderSessionRef, + ) -> Result> { + let mut unread_messages = self.load_unread_messages()?; + let Some(index) = unread_messages + .iter() + .enumerate() + .filter(|(_, message)| message.session_ref() == *session) + .min_by(|(_, left), (_, right)| { + left.received_at + .cmp(&right.received_at) + .then(left.message_id.cmp(&right.message_id)) + }) + .map(|(index, _)| index) + else { + return Ok(None); + }; + let message = unread_messages.remove(index); + self.save_unread_messages(&unread_messages)?; + Ok(Some(message)) + } + + pub fn load_inbound_receipts(&self) -> Result> { + read_optional_json_file(&self.inbound_receipts_path()) + .with_context(|| format!("failed to load {}", self.inbound_receipts_path().display())) + } + + pub fn has_inbound_receipt( + &self, + session: &ProviderSessionRef, + message_id: &str, + ) -> Result { + Ok(self + .load_inbound_receipts()? + .into_iter() + .any(|receipt| receipt.session_ref() == *session && receipt.message_id == message_id)) + } + + pub fn record_inbound_receipt(&self, receipt: InboundMessageReceipt) -> Result<()> { + let mut receipts = self.load_inbound_receipts()?; + receipts.retain(|existing| { + existing.session_ref() != receipt.session_ref() + || existing.message_id != receipt.message_id + }); + receipts.push(receipt); + receipts.sort_by(|left, right| { + left.received_at + .cmp(&right.received_at) + .then(left.session_id.cmp(&right.session_id)) + .then(left.message_id.cmp(&right.message_id)) + }); + if receipts.len() > MAX_INBOUND_RECEIPTS { + receipts.drain(..receipts.len() - MAX_INBOUND_RECEIPTS); + } + self.write_json_file(&self.inbound_receipts_path(), &receipts) + } + + fn write_json_file(&self, path: &Path, value: &T) -> Result<()> + where + T: Serialize, + { + let rendered = serde_json::to_string_pretty(value).context("failed to encode json")?; + self.write_string_file(path, &format!("{rendered}\n")) + } + + fn write_string_file(&self, path: &Path, contents: &str) -> Result<()> { + self.ensure_root_dir()?; + fs::write(path, contents).with_context(|| format!("failed to write {}", path.display())) + } +} + +fn read_optional_json_file(path: &Path) -> Result> +where + T: DeserializeOwned, +{ + if !path.exists() { + return Ok(Vec::new()); + } + let raw = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display())) +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 1a8ebb726..a6f389507 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -30,6 +30,7 @@ codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-chatgpt = { workspace = true } +codex-clawbot = { workspace = true } codex-cloud-requirements = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 29aa11aa7..01f17b4c1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -89,6 +89,8 @@ use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; +#[cfg(test)] +use codex_clawbot::ProviderOutboundTextMessage; use codex_config::types::ApprovalsReviewer; use codex_config::types::ModelAvailabilityNuxConfig; use codex_core::config::Config; @@ -164,6 +166,7 @@ mod agent_navigation; mod app_server_adapter; mod app_server_requests; mod btw; +mod clawbot; mod jump_navigation; mod key_chord; mod loaded_threads; @@ -180,6 +183,7 @@ use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; use self::btw::BtwSessionState; +use self::clawbot::PendingClawbotTurn; use self::key_chord::KeyChordAction; use self::key_chord::KeyChordResolution; use self::key_chord::KeyChordState; @@ -1095,6 +1099,11 @@ pub(crate) struct App { workflow_history: WorkflowHistoryState, btw_session: Option, btw_closing_thread_ids: HashSet, + clawbot_workspace_root: Option, + clawbot_provider_task: Option>, + clawbot_pending_turns: HashMap>, + #[cfg(test)] + clawbot_outbound_messages: Vec, } #[derive(Default)] @@ -3673,6 +3682,7 @@ impl App { self.enqueue_primary_thread_session(started.session, started.turns) .await?; self.backfill_loaded_subagent_threads(app_server).await; + self.sync_clawbot_workspace(app_server).await; Ok(()) } @@ -4180,11 +4190,17 @@ impl App { workflow_history: WorkflowHistoryState::default(), btw_session: None, btw_closing_thread_ids: HashSet::new(), + clawbot_workspace_root: None, + clawbot_provider_task: None, + clawbot_pending_turns: HashMap::new(), + #[cfg(test)] + clawbot_outbound_messages: Vec::new(), }; if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) .await?; } + app.sync_clawbot_workspace(&mut app_server).await; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. #[cfg(target_os = "windows")] @@ -4306,6 +4322,7 @@ impl App { } } }; + app.abort_clawbot_provider_runtime(); if let Err(err) = app_server.shutdown().await { tracing::warn!(error = %err, "failed to shut down embedded app server"); } @@ -4749,6 +4766,23 @@ impl App { AppEvent::ShowWorkflowBackgroundTasks => { self.chat_widget.add_ps_output(); } + AppEvent::ClawbotProviderEvent { event } => { + if let Err(err) = self.handle_clawbot_provider_event(app_server, event).await { + tracing::warn!(error = %err, "failed to handle clawbot provider event"); + self.chat_widget + .add_error_message(format!("Clawbot provider event failed: {err}")); + } + } + AppEvent::ClawbotTurnCompleted { thread_id, turn } => { + if let Err(err) = self + .handle_clawbot_turn_completed(app_server, thread_id, turn) + .await + { + tracing::warn!(error = %err, "failed to handle clawbot turn completion"); + self.chat_widget + .add_error_message(format!("Clawbot turn forwarding failed: {err}")); + } + } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { tui.frame_requester().schedule_frame(); @@ -6861,6 +6895,13 @@ mod tests { use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as AppServerUserInput; + use codex_clawbot::ClawbotRuntime; + use codex_clawbot::ProviderEvent as ClawbotProviderEvent; + use codex_clawbot::ProviderKind as ClawbotProviderKind; + use codex_clawbot::ProviderOutboundTextMessage; + use codex_clawbot::ProviderSession; + use codex_clawbot::ProviderSessionRef; + use codex_clawbot::SessionStatus as ClawbotSessionStatus; use codex_config::types::ModelAvailabilityNuxConfig; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -9718,6 +9759,11 @@ guardian_approval = true workflow_history: WorkflowHistoryState::default(), btw_session: None, btw_closing_thread_ids: HashSet::new(), + clawbot_workspace_root: None, + clawbot_provider_task: None, + clawbot_pending_turns: HashMap::new(), + #[cfg(test)] + clawbot_outbound_messages: Vec::new(), } } @@ -9780,6 +9826,11 @@ guardian_approval = true workflow_history: WorkflowHistoryState::default(), btw_session: None, btw_closing_thread_ids: HashSet::new(), + clawbot_workspace_root: None, + clawbot_provider_task: None, + clawbot_pending_turns: HashMap::new(), + #[cfg(test)] + clawbot_outbound_messages: Vec::new(), }, rx, op_rx, @@ -9864,7 +9915,36 @@ jobs: )?; Ok(()) } - + async fn bind_test_clawbot_session( + app: &mut App, + app_server: &mut AppServerSession, + session_id: &str, + ) -> Result<(ThreadId, ProviderSessionRef)> { + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + let session = ProviderSessionRef::new(ClawbotProviderKind::Feishu, session_id); + let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .persist_session(ProviderSession { + provider: ClawbotProviderKind::Feishu, + session_id: session_id.to_string(), + display_name: Some("Alice".to_string()), + unread_count: 0, + last_message_at: None, + status: ClawbotSessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("persist session"); + runtime + .connect_session_to_thread(&session, thread_id.to_string()) + .expect("connect session"); + app.sync_clawbot_workspace(app_server).await; + Ok((thread_id, session)) + } fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { thread_id, @@ -11829,6 +11909,135 @@ model = "gpt-5.2" ); } + async fn clawbot_inbound_message_resumes_bound_thread_and_starts_turn() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_resume").await?; + + app.handle_clawbot_provider_event( + &mut app_server, + ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "hello from feishu".to_string(), + received_at: 1, + }), + ) + .await + .expect("handle clawbot inbound message"); + + assert!(app.thread_event_channels.contains_key(&thread_id)); + assert_eq!( + app.clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + assert!(app.active_turn_id_for_thread(thread_id).await.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn clawbot_turn_completed_forwards_reply_and_drains_next_message() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_reply").await?; + + app.handle_clawbot_provider_event( + &mut app_server, + ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "first".to_string(), + received_at: 1, + }), + ) + .await + .expect("handle first clawbot inbound"); + app.handle_clawbot_provider_event( + &mut app_server, + ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_2".to_string(), + text: "second".to_string(), + received_at: 2, + }), + ) + .await + .expect("handle second clawbot inbound"); + + let first_turn_id = app + .clawbot_pending_turns + .get(&thread_id) + .and_then(|queue| queue.front()) + .map(|pending| pending.turn_id.clone()) + .expect("first pending turn"); + let queued_runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!(queued_runtime.snapshot().unread_message_count, 1); + + app.enqueue_thread_notification( + thread_id, + turn_completed_notification_with_agent_message( + thread_id, + &first_turn_id, + TurnStatus::Completed, + "forwarded reply", + ), + ) + .await?; + app.handle_clawbot_turn_completed( + &mut app_server, + thread_id, + test_turn( + &first_turn_id, + TurnStatus::Completed, + vec![ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "forwarded reply".to_string(), + phase: None, + memory_citation: None, + }], + ), + ) + .await + .expect("handle clawbot turn completion"); + + assert_eq!( + app.clawbot_outbound_messages, + vec![ProviderOutboundTextMessage { + session: session.clone(), + text: "forwarded reply".to_string(), + }] + ); + assert_eq!( + app.clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + let drained_runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!(drained_runtime.snapshot().unread_message_count, 0); + + Ok(()) + } + #[tokio::test] async fn shutting_down_primary_thread_stops_background_workflow_runs() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 88abdd714..46ba79671 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -189,6 +189,12 @@ impl App { match server_notification_thread_target(¬ification) { ServerNotificationThreadTarget::Thread(thread_id) => { + let clawbot_turn = match ¬ification { + ServerNotification::TurnCompleted(notification) => { + Some(notification.turn.clone()) + } + _ => None, + }; if self.handle_btw_notification(thread_id, ¬ification) { return; } @@ -224,6 +230,10 @@ impl App { self.workflow_thread_notification_channels .remove(&thread_id); } + if let Some(turn) = clawbot_turn { + self.app_event_tx + .send(AppEvent::ClawbotTurnCompleted { thread_id, turn }); + } return; } ServerNotificationThreadTarget::InvalidThreadId(thread_id) => { diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs new file mode 100644 index 000000000..05de07ec8 --- /dev/null +++ b/codex-rs/tui/src/app/clawbot.rs @@ -0,0 +1,406 @@ +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; +use codex_clawbot::CachedUnreadMessage; +use codex_clawbot::ClawbotRuntime; +use codex_clawbot::FeishuConfig; +use codex_clawbot::FeishuProviderRuntime; +use codex_clawbot::ProviderEvent; +use codex_clawbot::ProviderOutboundTextMessage; +use codex_clawbot::ProviderSessionRef; +use codex_clawbot::feishu_failure_reply_text; +use codex_protocol::ThreadId; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use tokio::sync::mpsc; + +use super::App; +use crate::app_command::AppCommand; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::app_server_session::ThreadSessionState; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PendingClawbotTurn { + pub(crate) turn_id: String, + pub(crate) session: ProviderSessionRef, +} + +impl App { + pub(super) async fn sync_clawbot_workspace(&mut self, app_server: &mut AppServerSession) { + if let Err(err) = self.sync_clawbot_workspace_inner(app_server).await { + tracing::warn!(error = %err, "failed to sync clawbot workspace"); + self.chat_widget + .add_error_message(format!("Clawbot workspace sync failed: {err}")); + } + } + + async fn sync_clawbot_workspace_inner( + &mut self, + app_server: &mut AppServerSession, + ) -> Result<()> { + let workspace_root = self.config.cwd.to_path_buf(); + let workspace_changed = self.clawbot_workspace_root.as_ref() != Some(&workspace_root); + if workspace_changed { + self.abort_clawbot_provider_runtime(); + self.clawbot_workspace_root = Some(workspace_root.clone()); + self.clawbot_pending_turns.clear(); + let runtime = ClawbotRuntime::load(workspace_root.clone())?; + if let Some(feishu) = runtime.snapshot().config.feishu.clone() + && feishu.has_api_credentials() + { + self.start_clawbot_provider_runtime(workspace_root.as_path(), feishu); + } + } + + let runtime = ClawbotRuntime::load(workspace_root)?; + let sessions_to_drain = runtime + .snapshot() + .sessions + .iter() + .filter(|session| session.bound_thread_id.is_some()) + .filter(|session| session.unread_count > 0) + .map(codex_clawbot::ProviderSession::session_ref) + .collect::>(); + for session in sessions_to_drain { + self.dispatch_next_clawbot_message(app_server, &session) + .await?; + } + Ok(()) + } + + fn start_clawbot_provider_runtime(&mut self, workspace_root: &Path, config: FeishuConfig) { + self.abort_clawbot_provider_runtime(); + let app_event_tx = self.app_event_tx.clone(); + let workspace = workspace_root.display().to_string(); + self.clawbot_provider_task = Some(tokio::spawn(async move { + let (provider_event_tx, mut provider_event_rx) = mpsc::unbounded_channel(); + let forward_task = tokio::spawn(async move { + while let Some(event) = provider_event_rx.recv().await { + app_event_tx.send(AppEvent::ClawbotProviderEvent { event }); + } + }); + if let Err(err) = FeishuProviderRuntime::new(config) + .run(provider_event_tx) + .await + { + tracing::warn!(workspace, error = %err, "clawbot provider runtime exited"); + } + forward_task.abort(); + let _ = forward_task.await; + })); + } + + pub(super) fn abort_clawbot_provider_runtime(&mut self) { + if let Some(handle) = self.clawbot_provider_task.take() { + handle.abort(); + } + } + + pub(super) async fn handle_clawbot_provider_event( + &mut self, + app_server: &mut AppServerSession, + event: ProviderEvent, + ) -> Result<()> { + let Some(workspace_root) = self.clawbot_workspace_root.clone() else { + return Ok(()); + }; + let session_to_drain = match &event { + ProviderEvent::InboundMessage(message) => Some(message.session.clone()), + _ => None, + }; + let mut runtime = ClawbotRuntime::load(workspace_root)?; + runtime.apply_provider_event(event)?; + if let Some(session) = session_to_drain { + self.dispatch_next_clawbot_message(app_server, &session) + .await?; + } + Ok(()) + } + + pub(super) async fn handle_clawbot_turn_completed( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + turn: codex_app_server_protocol::Turn, + ) -> Result<()> { + let Some(workspace_root) = self.clawbot_workspace_root.clone() else { + return Ok(()); + }; + let next_session = if let Some(pending) = + self.take_pending_clawbot_turn(thread_id, &turn.id) + { + if let Some(text) = clawbot_outbound_text_for_turn(&turn) { + self.send_clawbot_thread_reply(workspace_root.as_path(), &pending.session, text) + .await?; + } + Some(pending.session) + } else { + let runtime = ClawbotRuntime::load(workspace_root)?; + runtime.bound_session_for_thread(&thread_id.to_string())? + }; + + if let Some(session) = next_session { + self.dispatch_next_clawbot_message(app_server, &session) + .await?; + } + Ok(()) + } + + async fn dispatch_next_clawbot_message( + &mut self, + app_server: &mut AppServerSession, + session: &ProviderSessionRef, + ) -> Result<()> { + let Some(workspace_root) = self.clawbot_workspace_root.clone() else { + return Ok(()); + }; + let mut runtime = ClawbotRuntime::load(workspace_root)?; + let Some(binding) = runtime.load_binding_for_session(session)? else { + return Ok(()); + }; + if !binding.inbound_forwarding_enabled { + return Ok(()); + } + let thread_id = ThreadId::from_string(&binding.thread_id) + .with_context(|| format!("invalid clawbot thread id `{}`", binding.thread_id))?; + if self.active_turn_id_for_thread(thread_id).await.is_some() { + return Ok(()); + } + + let Some(message) = next_unread_message_for_session(&runtime, session) else { + return Ok(()); + }; + self.attach_clawbot_bound_thread_if_needed(app_server, thread_id) + .await?; + let turn_id = self + .submit_clawbot_message_to_thread(app_server, thread_id, message.text.clone()) + .await?; + self.register_pending_clawbot_turn(thread_id, session.clone(), turn_id); + let removed = runtime.take_next_unread_message(session)?; + if removed.as_ref().map(|entry| entry.message_id.as_str()) + != Some(message.message_id.as_str()) + { + tracing::warn!( + session = session.session_id, + expected = message.message_id, + actual = removed.as_ref().map(|entry| entry.message_id.as_str()), + "clawbot unread FIFO state drifted while dispatching" + ); + } + Ok(()) + } + + async fn attach_clawbot_bound_thread_if_needed( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) -> Result<()> { + if self.thread_event_channels.contains_key(&thread_id) { + return Ok(()); + } + if let Err(attach_err) = self + .attach_live_thread_for_selection(app_server, thread_id) + .await + { + tracing::warn!( + thread_id = %thread_id, + error = %attach_err, + "falling back to thread/read for clawbot thread attachment" + ); + let thread = app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + .map_err(|err| { + anyhow::anyhow!("failed to read clawbot thread {thread_id}: {err}") + })?; + let session = self.session_state_for_thread_read(thread_id, &thread).await; + let channel = self.ensure_thread_channel(thread_id); + let mut store = channel.store.lock().await; + store.set_session(session, Vec::new()); + } + Ok(()) + } + + async fn submit_clawbot_message_to_thread( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + message: String, + ) -> Result { + let trimmed = message.trim().to_string(); + if trimmed.is_empty() { + return Err(anyhow::anyhow!("cannot submit empty clawbot message")); + } + + let session = self + .clawbot_thread_session(thread_id) + .await? + .with_context(|| { + format!("missing live thread session for clawbot thread {thread_id}") + })?; + let op = AppCommand::from_core(Op::UserTurn { + items: vec![UserInput::Text { + text: trimmed.clone(), + text_elements: Vec::new(), + }], + cwd: session.cwd.clone(), + approval_policy: session.approval_policy, + approvals_reviewer: Some(session.approvals_reviewer), + sandbox_policy: session.sandbox_policy.clone(), + model: session.model.clone(), + effort: session.reasoning_effort, + summary: None, + service_tier: Some(session.service_tier), + final_output_json_schema: None, + collaboration_mode: None, + personality: self.config.personality, + }); + crate::session_log::log_outbound_op(&op); + let response = app_server + .turn_start( + thread_id, + vec![UserInput::Text { + text: trimmed, + text_elements: Vec::new(), + }], + session.cwd, + session.approval_policy, + session.approvals_reviewer, + session.sandbox_policy, + session.model, + session.reasoning_effort, + None, + Some(session.service_tier), + None, + self.config.personality, + None, + ) + .await + .map_err(|err| { + anyhow::anyhow!("failed to start clawbot turn for thread {thread_id}: {err}") + })?; + self.note_thread_outbound_op(thread_id, &op).await; + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + store.active_turn_id = Some(response.turn.id.clone()); + } + Ok(response.turn.id) + } + + async fn clawbot_thread_session( + &self, + thread_id: ThreadId, + ) -> Result> { + let Some(channel) = self.thread_event_channels.get(&thread_id) else { + return Ok(None); + }; + let store = channel.store.lock().await; + Ok(store.session.clone()) + } + + async fn send_clawbot_thread_reply( + &mut self, + workspace_root: &Path, + session: &ProviderSessionRef, + text: String, + ) -> Result<()> { + let runtime = ClawbotRuntime::load(workspace_root.to_path_buf())?; + let Some(binding) = runtime.load_binding_for_session(session)? else { + return Ok(()); + }; + if !binding.outbound_forwarding_enabled { + return Ok(()); + } + self.send_clawbot_outbound_text( + workspace_root, + ProviderOutboundTextMessage { + session: session.clone(), + text, + }, + ) + .await + } + + async fn send_clawbot_outbound_text( + &mut self, + workspace_root: &Path, + message: ProviderOutboundTextMessage, + ) -> Result<()> { + #[cfg(test)] + { + let _ = workspace_root; + self.clawbot_outbound_messages.push(message); + Ok(()) + } + + #[cfg(not(test))] + { + let runtime = ClawbotRuntime::load(workspace_root.to_path_buf())?; + let provider = runtime + .feishu_provider() + .context("missing Feishu config for clawbot outbound bridge")?; + provider.send_text(message).await + } + } + + fn register_pending_clawbot_turn( + &mut self, + thread_id: ThreadId, + session: ProviderSessionRef, + turn_id: String, + ) { + self.clawbot_pending_turns + .entry(thread_id) + .or_default() + .push_back(PendingClawbotTurn { turn_id, session }); + } + + fn take_pending_clawbot_turn( + &mut self, + thread_id: ThreadId, + turn_id: &str, + ) -> Option { + let queue = self.clawbot_pending_turns.get_mut(&thread_id)?; + let pending = queue + .iter() + .position(|pending| pending.turn_id == turn_id) + .and_then(|index| queue.remove(index)); + if queue.is_empty() { + self.clawbot_pending_turns.remove(&thread_id); + } + pending + } +} + +fn clawbot_outbound_text_for_turn(turn: &codex_app_server_protocol::Turn) -> Option { + match turn.status { + codex_app_server_protocol::TurnStatus::Completed => { + super::last_agent_message_for_turn(turn) + } + codex_app_server_protocol::TurnStatus::Failed => turn + .error + .as_ref() + .map(|error| feishu_failure_reply_text(&error.message)), + codex_app_server_protocol::TurnStatus::Interrupted => None, + codex_app_server_protocol::TurnStatus::InProgress => None, + } +} + +fn next_unread_message_for_session( + runtime: &ClawbotRuntime, + session: &ProviderSessionRef, +) -> Option { + runtime + .store() + .load_unread_messages() + .ok()? + .into_iter() + .filter(|message| message.session_ref() == *session) + .min_by(|left, right| { + left.received_at + .cmp(&right.received_at) + .then(left.message_id.cmp(&right.message_id)) + }) +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0f73c5d70..f5fd26245 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -17,7 +17,9 @@ use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::Turn as AppServerTurn; use codex_chatgpt::connectors::AppInfo; +use codex_clawbot::ProviderEvent as ClawbotProviderEvent; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; @@ -300,6 +302,9 @@ pub(crate) enum AppEvent { }, InsertHistoryCell(Box), + ClawbotProviderEvent { + event: ClawbotProviderEvent, + }, /// Replay stored workflow-only transcript cells for a specific thread after its turn replay. ReplayWorkflowHistory { @@ -354,6 +359,10 @@ pub(crate) enum AppEvent { action: ProfileFallbackAction, error_message: String, }, + ClawbotTurnCompleted { + thread_id: ThreadId, + turn: AppServerTurn, + }, /// Apply rollback semantics to local transcript cells. /// From 063dfd1006ffb8e3eb42008422b01c80bde489e4 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 16:29:13 +0800 Subject: [PATCH 35/60] replay: restore clawbot feishu config binding management --- codex-rs/clawbot/src/config.rs | 21 + codex-rs/clawbot/src/lib.rs | 2 + codex-rs/clawbot/src/model.rs | 12 + codex-rs/clawbot/src/runtime.rs | 181 +++++++ codex-rs/clawbot/src/store.rs | 16 + codex-rs/tui/src/app.rs | 207 +++++++ codex-rs/tui/src/app/clawbot.rs | 23 +- codex-rs/tui/src/app/clawbot_controls.rs | 505 ++++++++++++++++++ codex-rs/tui/src/app_event.rs | 41 ++ codex-rs/tui/src/bottom_pane/mod.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 9 +- .../src/chatwidget/tests/slash_commands.rs | 10 + codex-rs/tui/src/slash_command.rs | 3 + ..._app__tests__clawbot_management_popup.snap | 19 + 14 files changed, 1042 insertions(+), 9 deletions(-) create mode 100644 codex-rs/tui/src/app/clawbot_controls.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap diff --git a/codex-rs/clawbot/src/config.rs b/codex-rs/clawbot/src/config.rs index 944efcbc7..bf06ec5a9 100644 --- a/codex-rs/clawbot/src/config.rs +++ b/codex-rs/clawbot/src/config.rs @@ -22,4 +22,25 @@ impl FeishuConfig { pub fn has_api_credentials(&self) -> bool { !self.app_id.trim().is_empty() && !self.app_secret.trim().is_empty() } + + pub fn is_empty(&self) -> bool { + self.app_id.trim().is_empty() + && self.app_secret.trim().is_empty() + && self + .verification_token + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && self + .encrypt_key + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && self + .bot_open_id + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && self + .bot_user_id + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + } } diff --git a/codex-rs/clawbot/src/lib.rs b/codex-rs/clawbot/src/lib.rs index 109a76800..dc3e3eb0f 100644 --- a/codex-rs/clawbot/src/lib.rs +++ b/codex-rs/clawbot/src/lib.rs @@ -18,6 +18,8 @@ pub use model::CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH; pub use model::CachedUnreadMessage; pub use model::ClawbotSnapshot; pub use model::ConnectionStatus; +pub use model::ForwardingDirection; +pub use model::ForwardingState; pub use model::ProviderKind; pub use model::ProviderRuntimeState; pub use model::ProviderSession; diff --git a/codex-rs/clawbot/src/model.rs b/codex-rs/clawbot/src/model.rs index c5970bb76..50ac28098 100644 --- a/codex-rs/clawbot/src/model.rs +++ b/codex-rs/clawbot/src/model.rs @@ -125,6 +125,18 @@ impl SessionBinding { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForwardingDirection { + Inbound, + Outbound, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForwardingState { + Enabled, + Disabled, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct CachedUnreadMessage { pub provider: ProviderKind, diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs index 39b671d3d..d236acb47 100644 --- a/codex-rs/clawbot/src/runtime.rs +++ b/codex-rs/clawbot/src/runtime.rs @@ -5,8 +5,11 @@ use std::time::UNIX_EPOCH; use anyhow::Context; use anyhow::Result; +use crate::config::FeishuConfig; use crate::model::CachedUnreadMessage; use crate::model::ClawbotSnapshot; +use crate::model::ForwardingDirection; +use crate::model::ForwardingState; use crate::model::InboundMessageReceipt; use crate::model::ProviderSession; use crate::model::ProviderSessionRef; @@ -50,6 +53,16 @@ impl ClawbotRuntime { .map(FeishuProviderRuntime::new) } + pub fn update_feishu_config( + &mut self, + feishu: Option, + ) -> Result<&ClawbotSnapshot> { + self.store.save_config(&crate::config::ClawbotConfig { + feishu: feishu.filter(|config| !config.is_empty()), + })?; + self.reload() + } + pub fn persist_session(&mut self, session: ProviderSession) -> Result<&ClawbotSnapshot> { self.store.upsert_session(session)?; self.reload() @@ -156,6 +169,53 @@ impl ClawbotRuntime { .map(SessionBinding::session_ref)) } + pub fn disconnect_thread(&mut self, thread_id: &str) -> Result> { + let Some(binding) = self.load_binding_for_thread(thread_id)? else { + return Ok(None); + }; + let session = binding.session_ref(); + let mut bindings = self.store.load_bindings()?; + bindings.retain(|candidate| candidate.thread_id != thread_id); + self.store.save_bindings(&bindings)?; + + let mut sessions = self.store.load_sessions()?; + if let Some(existing) = sessions + .iter_mut() + .find(|candidate| candidate.session_ref() == session) + { + existing.bound_thread_id = None; + existing.status = SessionStatus::Discovered; + } + self.store.save_sessions(&sessions)?; + self.reload()?; + Ok(Some(session)) + } + + pub fn set_forwarding_state_for_thread( + &mut self, + thread_id: &str, + direction: ForwardingDirection, + state: ForwardingState, + ) -> Result> { + let mut bindings = self.store.load_bindings()?; + let Some(binding) = bindings + .iter_mut() + .find(|candidate| candidate.thread_id == thread_id) + else { + return Ok(None); + }; + let next_enabled = matches!(state, ForwardingState::Enabled); + match direction { + ForwardingDirection::Inbound => binding.inbound_forwarding_enabled = next_enabled, + ForwardingDirection::Outbound => binding.outbound_forwarding_enabled = next_enabled, + } + binding.updated_at = unix_timestamp_now()?; + let updated = binding.clone(); + self.store.save_bindings(&bindings)?; + self.reload()?; + Ok(Some(updated)) + } + pub fn take_next_unread_message( &mut self, session: &ProviderSessionRef, @@ -272,12 +332,16 @@ mod tests { use tempfile::tempdir; use super::ClawbotRuntime; + use crate::config::FeishuConfig; use crate::events::ProviderInboundMessage; use crate::model::ConnectionStatus; + use crate::model::ForwardingDirection; + use crate::model::ForwardingState; use crate::model::ProviderKind; use crate::model::ProviderRuntimeState; use crate::model::ProviderSession; use crate::model::ProviderSessionRef; + use crate::model::SessionBinding; use crate::model::SessionStatus; use crate::provider::ProviderEvent; @@ -375,4 +439,121 @@ mod tests { assert_eq!(runtime.snapshot().sessions.len(), 1); assert_eq!(runtime.snapshot().sessions[0].unread_count, 1); } + + #[test] + fn update_feishu_config_clears_empty_values() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + + runtime + .update_feishu_config(Some(FeishuConfig { + app_id: "app".to_string(), + app_secret: "secret".to_string(), + verification_token: Some("token".to_string()), + encrypt_key: None, + bot_open_id: None, + bot_user_id: None, + })) + .expect("save config"); + assert_eq!( + runtime.snapshot().config.feishu, + Some(FeishuConfig { + app_id: "app".to_string(), + app_secret: "secret".to_string(), + verification_token: Some("token".to_string()), + encrypt_key: None, + bot_open_id: None, + bot_user_id: None, + }) + ); + + runtime + .update_feishu_config(Some(FeishuConfig::default())) + .expect("clear config"); + assert_eq!(runtime.snapshot().config.feishu, None); + } + + #[test] + fn disconnect_thread_clears_binding_and_session_state() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_3"); + + runtime + .persist_session(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_3".to_string(), + display_name: Some("Bob".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("session"); + runtime + .connect_session_to_thread(&session, "thread_1".to_string()) + .expect("bind session"); + + assert_eq!( + runtime.disconnect_thread("thread_1").expect("disconnect"), + Some(session.clone()) + ); + assert_eq!( + runtime + .bound_session_for_thread("thread_1") + .expect("bound session"), + None + ); + assert_eq!( + runtime.snapshot().sessions, + vec![ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_3".to_string(), + display_name: Some("Bob".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }] + ); + } + + #[test] + fn set_forwarding_state_for_thread_updates_binding_flags() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_4"); + + runtime + .connect_session_to_thread(&session, "thread_2".to_string()) + .expect("bind session"); + + runtime + .set_forwarding_state_for_thread( + "thread_2", + ForwardingDirection::Inbound, + ForwardingState::Disabled, + ) + .expect("disable inbound"); + runtime + .set_forwarding_state_for_thread( + "thread_2", + ForwardingDirection::Outbound, + ForwardingState::Disabled, + ) + .expect("disable outbound"); + + assert_eq!( + runtime.snapshot().bindings[0].clone(), + SessionBinding { + provider: ProviderKind::Feishu, + session_id: "chat_4".to_string(), + thread_id: "thread_2".to_string(), + inbound_forwarding_enabled: false, + outbound_forwarding_enabled: false, + created_at: runtime.snapshot().bindings[0].created_at, + updated_at: runtime.snapshot().bindings[0].updated_at, + } + ); + } } diff --git a/codex-rs/clawbot/src/store.rs b/codex-rs/clawbot/src/store.rs index 2823045fa..2bddeabcd 100644 --- a/codex-rs/clawbot/src/store.rs +++ b/codex-rs/clawbot/src/store.rs @@ -6,6 +6,7 @@ use anyhow::Context; use anyhow::Result; use serde::Serialize; use serde::de::DeserializeOwned; +use toml::Value as TomlValue; use crate::config::ClawbotConfig; use crate::model::CLAWBOT_BINDINGS_RELATIVE_PATH; @@ -109,6 +110,21 @@ impl ClawbotStore { toml::from_str(&raw).with_context(|| format!("failed to parse {}", config_path.display())) } + pub fn save_config(&self, config: &ClawbotConfig) -> Result<()> { + let rendered = toml::to_string_pretty(config).context("failed to encode config")?; + let contents = if rendered.trim().is_empty() { + String::new() + } else { + let normalized = rendered + .parse::() + .ok() + .and_then(|value| toml::to_string_pretty(&value).ok()) + .unwrap_or(rendered); + format!("{normalized}\n") + }; + self.write_string_file(&self.config_path(), &contents) + } + pub fn load_runtime_states(&self) -> Result> { read_optional_json_file(&self.runtime_path()) .with_context(|| format!("failed to load {}", self.runtime_path().display())) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 01f17b4c1..9c672ebc7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -169,6 +169,7 @@ mod btw; mod clawbot; mod jump_navigation; mod key_chord; +mod clawbot_controls; mod loaded_threads; mod pending_interactive_replay; mod profile_management; @@ -4783,6 +4784,49 @@ impl App { .add_error_message(format!("Clawbot turn forwarding failed: {err}")); } } + AppEvent::OpenClawbotManagement => { + self.open_clawbot_management_popup(); + } + AppEvent::OpenClawbotFeishuConfigPrompt { field } => { + self.open_clawbot_feishu_config_prompt(field); + } + AppEvent::SaveClawbotFeishuConfigValue { field, value } => { + if let Err(err) = self.save_clawbot_feishu_config_value(field, value) { + self.chat_widget + .add_error_message(format!("Failed to save Clawbot config: {err}")); + } + } + AppEvent::OpenClawbotManualBindPrompt => { + self.open_clawbot_manual_bind_prompt(); + } + AppEvent::SaveClawbotManualBindSessionId { session_id } => { + if let Err(err) = self + .save_clawbot_manual_bind_session_id(app_server, session_id) + .await + { + self.chat_widget + .add_error_message(format!("Failed to bind Clawbot session: {err}")); + } + } + AppEvent::ClawbotDisconnectCurrentThread => { + if let Err(err) = self.clawbot_disconnect_current_thread() { + self.chat_widget + .add_error_message(format!("Failed to disconnect Clawbot binding: {err}")); + } + } + AppEvent::ClawbotSetCurrentThreadForwarding { channel, enabled } => { + if let Err(err) = self.clawbot_set_current_thread_forwarding(channel, enabled) { + self.chat_widget + .add_error_message(format!("Failed to update Clawbot forwarding: {err}")); + } + } + AppEvent::RetryClawbotFeishuConnection => { + if let Err(err) = self.retry_clawbot_feishu_connection() { + self.chat_widget.add_error_message(format!( + "Failed to restart Clawbot Feishu runtime: {err}" + )); + } + } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { tui.frame_requester().schedule_frame(); @@ -6854,6 +6898,7 @@ mod tests { use assert_matches::assert_matches; use codex_app_server_client::AppServerEvent; + use crate::app_event::ClawbotForwardingChannel; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; @@ -6934,6 +6979,9 @@ mod tests { use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyModifiers; use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use crate::render::renderable::Renderable; use pretty_assertions::assert_eq; use ratatui::prelude::Line; @@ -9945,6 +9993,38 @@ jobs: app.sync_clawbot_workspace(app_server).await; Ok((thread_id, session)) } + + fn render_bottom_popup(chat: &crate::chatwidget::ChatWidget, width: u16) -> String { + let height = chat.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + chat.render(area, &mut buf); + + let mut lines: Vec = (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line.trim_end().to_string() + }) + .collect(); + + while lines.first().is_some_and(|line| line.trim().is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|line| line.trim().is_empty()) { + lines.pop(); + } + + lines.join("\n") + } + fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { thread_id, @@ -12090,6 +12170,58 @@ model = "gpt-5.2" assert!(app.queued_trigger_labels().is_empty()); } + #[tokio::test] + async fn clawbot_manual_bind_replays_cached_unread_messages() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.active_thread_id = Some(thread_id); + + let session = ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_bind"); + let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .apply_provider_event(ClawbotProviderEvent::InboundMessage( + codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "queued before bind".to_string(), + received_at: 1, + }, + )) + .expect("queue unread"); + + app.save_clawbot_manual_bind_session_id(&mut app_server, "chat_bind".to_string()) + .await + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!( + runtime + .bound_session_for_thread(&thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + Some(session) + ); + assert_eq!( + app.clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + Ok(()) + } + #[tokio::test] async fn before_turn_workflow_augments_primary_user_turn() -> Result<()> { let mut app = make_test_app().await; @@ -12499,6 +12631,81 @@ model = "gpt-5.2" Ok(()) } + #[tokio::test] + async fn clawbot_current_thread_controls_update_binding_state() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, _session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_controls").await?; + app.active_thread_id = Some(thread_id); + + app.clawbot_set_current_thread_forwarding(ClawbotForwardingChannel::Inbound, false) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + app.clawbot_set_current_thread_forwarding(ClawbotForwardingChannel::Outbound, false) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let binding = runtime + .load_binding_for_thread(&thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))? + .expect("binding"); + assert!(!binding.inbound_forwarding_enabled); + assert!(!binding.outbound_forwarding_enabled); + + app.clawbot_disconnect_current_thread() + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!( + runtime + .load_binding_for_thread(&thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + None + ); + Ok(()) + } + + #[tokio::test] + async fn clawbot_management_popup_snapshot() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, _session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_snapshot").await?; + app.active_thread_id = Some(thread_id); + + let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .update_feishu_config(Some(codex_clawbot::FeishuConfig { + app_id: "cli_app_123".to_string(), + app_secret: "secret_value_4567".to_string(), + verification_token: Some("verify_token".to_string()), + encrypt_key: None, + bot_open_id: Some("ou_bot_open_id".to_string()), + bot_user_id: None, + })) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + app.open_clawbot_management_popup(); + + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert_snapshot!("clawbot_management_popup", popup); + Ok(()) + } + #[tokio::test] async fn btw_completion_notification_emits_completion_event_and_is_swallowed() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs index 05de07ec8..e373eaf7e 100644 --- a/codex-rs/tui/src/app/clawbot.rs +++ b/codex-rs/tui/src/app/clawbot.rs @@ -46,12 +46,7 @@ impl App { self.abort_clawbot_provider_runtime(); self.clawbot_workspace_root = Some(workspace_root.clone()); self.clawbot_pending_turns.clear(); - let runtime = ClawbotRuntime::load(workspace_root.clone())?; - if let Some(feishu) = runtime.snapshot().config.feishu.clone() - && feishu.has_api_credentials() - { - self.start_clawbot_provider_runtime(workspace_root.as_path(), feishu); - } + self.refresh_clawbot_provider_runtime()?; } let runtime = ClawbotRuntime::load(workspace_root)?; @@ -98,6 +93,20 @@ impl App { } } + pub(super) fn refresh_clawbot_provider_runtime(&mut self) -> Result<()> { + let workspace_root = self.config.cwd.to_path_buf(); + self.clawbot_workspace_root = Some(workspace_root.clone()); + let runtime = ClawbotRuntime::load(workspace_root.clone())?; + if let Some(feishu) = runtime.snapshot().config.feishu.clone() + && feishu.has_api_credentials() + { + self.start_clawbot_provider_runtime(workspace_root.as_path(), feishu); + } else { + self.abort_clawbot_provider_runtime(); + } + Ok(()) + } + pub(super) async fn handle_clawbot_provider_event( &mut self, app_server: &mut AppServerSession, @@ -148,7 +157,7 @@ impl App { Ok(()) } - async fn dispatch_next_clawbot_message( + pub(super) async fn dispatch_next_clawbot_message( &mut self, app_server: &mut AppServerSession, session: &ProviderSessionRef, diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs new file mode 100644 index 000000000..c8953b448 --- /dev/null +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -0,0 +1,505 @@ +use anyhow::Context; +use anyhow::Result; +use codex_clawbot::ClawbotRuntime; +use codex_clawbot::ConnectionStatus; +use codex_clawbot::FeishuConfig; +use codex_clawbot::ForwardingDirection; +use codex_clawbot::ForwardingState; +use codex_clawbot::ProviderKind; +use codex_clawbot::ProviderRuntimeState; +use codex_clawbot::ProviderSessionRef; + +use super::App; +use crate::app_event::AppEvent; +use crate::app_event::ClawbotFeishuConfigField; +use crate::app_event::ClawbotForwardingChannel; +use crate::app_server_session::AppServerSession; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::custom_prompt_view::CustomPromptView; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; + +const CLAWBOT_MANAGEMENT_VIEW_ID: &str = "clawbot-management"; + +impl ClawbotFeishuConfigField { + fn title(self) -> &'static str { + match self { + Self::AppId => "Feishu App ID", + Self::AppSecret => "Feishu App Secret", + Self::VerificationToken => "Feishu Verification Token", + Self::EncryptKey => "Feishu Encrypt Key", + Self::BotOpenId => "Feishu Bot Open ID", + Self::BotUserId => "Feishu Bot User ID", + } + } + + fn current_value(self, config: Option<&FeishuConfig>) -> Option { + let value = match self { + Self::AppId => config.map(|config| config.app_id.clone()), + Self::AppSecret => config.map(|config| config.app_secret.clone()), + Self::VerificationToken => config.and_then(|config| config.verification_token.clone()), + Self::EncryptKey => config.and_then(|config| config.encrypt_key.clone()), + Self::BotOpenId => config.and_then(|config| config.bot_open_id.clone()), + Self::BotUserId => config.and_then(|config| config.bot_user_id.clone()), + }?; + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + } + + fn description(self, config: Option<&FeishuConfig>) -> String { + let Some(value) = self.current_value(config) else { + return "Not set".to_string(); + }; + if self.is_secret() { + format!("Configured: {}", mask_secret(&value)) + } else { + format!("Configured: {}", truncate_value(&value, 28)) + } + } + + fn prompt_placeholder(self) -> &'static str { + match self { + Self::AppId => "Paste the Feishu app_id and press Enter", + Self::AppSecret => "Paste the Feishu app_secret and press Enter", + Self::VerificationToken => { + "Paste the verification token, or submit an empty value to clear it" + } + Self::EncryptKey => "Paste the encrypt key, or submit an empty value to clear it", + Self::BotOpenId => "Paste the bot open_id, or submit an empty value to clear it", + Self::BotUserId => "Paste the bot user_id, or submit an empty value to clear it", + } + } + + fn prompt_context_label(self, config: Option<&FeishuConfig>) -> String { + match self.current_value(config) { + Some(value) if self.is_secret() => { + format!("Current: {}", mask_secret(&value)) + } + Some(value) => { + format!("Current: {}", truncate_value(&value, 40)) + } + None => "Current: not set".to_string(), + } + } + + fn is_secret(self) -> bool { + matches!( + self, + Self::AppSecret | Self::VerificationToken | Self::EncryptKey + ) + } +} + +impl ClawbotForwardingChannel { + fn title(self) -> &'static str { + match self { + Self::Inbound => "Inbound Forwarding", + Self::Outbound => "Outbound Forwarding", + } + } + + fn description(self, enabled: bool) -> String { + let direction = match self { + Self::Inbound => "Feishu -> Codex", + Self::Outbound => "Codex -> Feishu", + }; + let state = if enabled { "Enabled" } else { "Disabled" }; + format!("{state}: {direction}") + } + + fn selected_description(self, enabled: bool) -> String { + match (self, enabled) { + (Self::Inbound, true) => { + "Disable automatic delivery of unread Feishu messages into the bound thread." + .to_string() + } + (Self::Inbound, false) => { + "Re-enable automatic delivery of unread Feishu messages into the bound thread." + .to_string() + } + (Self::Outbound, true) => { + "Disable reply forwarding from Codex back to the bound Feishu session.".to_string() + } + (Self::Outbound, false) => { + "Re-enable reply forwarding from Codex back to the bound Feishu session." + .to_string() + } + } + } +} + +impl App { + pub(crate) fn open_clawbot_management_popup(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(CLAWBOT_MANAGEMENT_VIEW_ID); + let params = self.clawbot_management_popup_params(initial_selected_idx); + if !self + .chat_widget + .replace_selection_view_if_active(CLAWBOT_MANAGEMENT_VIEW_ID, params) + { + self.chat_widget + .show_selection_view(self.clawbot_management_popup_params(initial_selected_idx)); + } + } + + pub(crate) fn open_clawbot_feishu_config_prompt(&mut self, field: ClawbotFeishuConfigField) { + let config = ClawbotRuntime::load(self.config.cwd.to_path_buf()) + .ok() + .and_then(|runtime| runtime.snapshot().config.feishu.clone()); + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + field.title().to_string(), + field.prompt_placeholder().to_string(), + Some(field.prompt_context_label(config.as_ref())), + Box::new(move |value| { + tx.send(AppEvent::SaveClawbotFeishuConfigValue { field, value }); + }), + ); + self.chat_widget.show_view(Box::new(view)); + } + + pub(crate) fn open_clawbot_manual_bind_prompt(&mut self) { + let current_thread = self + .active_thread_id + .map(|thread_id| thread_id.to_string()) + .unwrap_or_else(|| "No active thread".to_string()); + let current_binding = self + .active_thread_id + .and_then(|thread_id| { + ClawbotRuntime::load(self.config.cwd.to_path_buf()) + .ok() + .and_then(|runtime| { + runtime + .bound_session_for_thread(&thread_id.to_string()) + .ok() + }) + .flatten() + .map(|session| session.session_id) + }) + .unwrap_or_else(|| "not bound".to_string()); + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Bind Feishu session to current thread".to_string(), + "Paste a Feishu session id and press Enter".to_string(), + Some(format!( + "Thread: {current_thread} · Current binding: {current_binding}" + )), + Box::new(move |session_id| { + tx.send(AppEvent::SaveClawbotManualBindSessionId { session_id }); + }), + ); + self.chat_widget.show_view(Box::new(view)); + } + + pub(crate) fn save_clawbot_feishu_config_value( + &mut self, + field: ClawbotFeishuConfigField, + value: String, + ) -> Result<()> { + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + let mut config = runtime.snapshot().config.feishu.clone().unwrap_or_default(); + let trimmed = value.trim().to_string(); + match field { + ClawbotFeishuConfigField::AppId => { + config.app_id = trimmed; + } + ClawbotFeishuConfigField::AppSecret => { + config.app_secret = trimmed; + } + ClawbotFeishuConfigField::VerificationToken => { + config.verification_token = (!trimmed.is_empty()).then_some(trimmed); + } + ClawbotFeishuConfigField::EncryptKey => { + config.encrypt_key = (!trimmed.is_empty()).then_some(trimmed); + } + ClawbotFeishuConfigField::BotOpenId => { + config.bot_open_id = (!trimmed.is_empty()).then_some(trimmed); + } + ClawbotFeishuConfigField::BotUserId => { + config.bot_user_id = (!trimmed.is_empty()).then_some(trimmed); + } + } + runtime.update_feishu_config(Some(config))?; + self.refresh_clawbot_provider_runtime()?; + self.open_clawbot_management_popup(); + self.chat_widget + .add_info_message(format!("Updated {}.", field.title()), /*hint*/ None); + Ok(()) + } + + pub(crate) async fn save_clawbot_manual_bind_session_id( + &mut self, + app_server: &mut AppServerSession, + session_id: String, + ) -> Result<()> { + let thread_id = self + .active_thread_id + .context("no active thread available for Clawbot binding")?; + let trimmed = session_id.trim().to_string(); + if trimmed.is_empty() { + return Err(anyhow::anyhow!("session id cannot be empty")); + } + let session = ProviderSessionRef::new(ProviderKind::Feishu, trimmed.clone()); + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + runtime.connect_session_to_thread(&session, thread_id.to_string())?; + self.refresh_clawbot_provider_runtime()?; + self.dispatch_next_clawbot_message(app_server, &session) + .await?; + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + format!("Bound thread {thread_id} to Feishu session {trimmed}."), + /*hint*/ None, + ); + Ok(()) + } + + pub(crate) fn clawbot_disconnect_current_thread(&mut self) -> Result<()> { + let thread_id = self + .active_thread_id + .context("no active thread available for Clawbot disconnect")?; + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + let Some(session) = runtime.disconnect_thread(&thread_id.to_string())? else { + return Err(anyhow::anyhow!( + "current thread is not bound to a Clawbot session" + )); + }; + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + format!( + "Disconnected Feishu session {} from thread {thread_id}.", + session.session_id + ), + /*hint*/ None, + ); + Ok(()) + } + + pub(crate) fn clawbot_set_current_thread_forwarding( + &mut self, + channel: ClawbotForwardingChannel, + enabled: bool, + ) -> Result<()> { + let thread_id = self + .active_thread_id + .context("no active thread available for Clawbot forwarding")?; + let direction = match channel { + ClawbotForwardingChannel::Inbound => ForwardingDirection::Inbound, + ClawbotForwardingChannel::Outbound => ForwardingDirection::Outbound, + }; + let state = if enabled { + ForwardingState::Enabled + } else { + ForwardingState::Disabled + }; + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + runtime + .set_forwarding_state_for_thread(&thread_id.to_string(), direction, state)? + .context("current thread is not bound to a Clawbot session")?; + self.open_clawbot_management_popup(); + self.chat_widget + .add_info_message(channel.description(enabled), /*hint*/ None); + Ok(()) + } + + pub(crate) fn retry_clawbot_feishu_connection(&mut self) -> Result<()> { + let runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + let Some(config) = runtime.snapshot().config.feishu.as_ref() else { + return Err(anyhow::anyhow!("Feishu credentials are not configured")); + }; + if !config.has_api_credentials() { + return Err(anyhow::anyhow!("Feishu app_id and app_secret are required")); + } + self.refresh_clawbot_provider_runtime()?; + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + "Restarted Feishu runtime bridge.".to_string(), + /*hint*/ None, + ); + Ok(()) + } + + fn clawbot_management_popup_params( + &self, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let snapshot = ClawbotRuntime::load(self.config.cwd.to_path_buf()) + .map(|runtime| runtime.snapshot().clone()) + .unwrap_or_default(); + let feishu_config = snapshot.config.feishu.as_ref(); + let provider_state = snapshot + .runtime + .iter() + .find(|state| state.provider == ProviderKind::Feishu) + .cloned() + .unwrap_or(ProviderRuntimeState::unconfigured(ProviderKind::Feishu)); + let active_thread_id = self.active_thread_id.map(|thread_id| thread_id.to_string()); + let current_binding = active_thread_id.as_deref().and_then(|thread_id| { + snapshot + .bindings + .iter() + .find(|binding| binding.thread_id == thread_id) + }); + let mut items = vec![ + clawbot_config_item(ClawbotFeishuConfigField::AppId, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::AppSecret, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::VerificationToken, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::EncryptKey, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::BotOpenId, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::BotUserId, feishu_config), + SelectionItem { + name: "Retry Feishu Connection".to_string(), + description: Some(connection_description(&provider_state)), + selected_description: Some( + "Restart the workspace-local Feishu websocket/runtime bridge.".to_string(), + ), + is_disabled: !feishu_config.is_some_and(FeishuConfig::has_api_credentials), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::RetryClawbotFeishuConnection) + })], + dismiss_on_select: false, + ..Default::default() + }, + ]; + + let bind_description = match (&active_thread_id, current_binding) { + (Some(thread_id), Some(binding)) => { + format!("Thread {thread_id} -> {}", binding.session_id) + } + (Some(thread_id), None) => format!("Thread {thread_id} is not bound"), + (None, _) => "No active thread".to_string(), + }; + items.push(SelectionItem { + name: "Bind Current Thread".to_string(), + description: Some(bind_description), + selected_description: Some( + "Manually bind the current Codex thread to a Feishu session id.".to_string(), + ), + is_disabled: active_thread_id.is_none(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenClawbotManualBindPrompt) + })], + dismiss_on_select: false, + ..Default::default() + }); + + for channel in [ + ClawbotForwardingChannel::Inbound, + ClawbotForwardingChannel::Outbound, + ] { + let enabled = current_binding.is_some_and(|binding| match channel { + ClawbotForwardingChannel::Inbound => binding.inbound_forwarding_enabled, + ClawbotForwardingChannel::Outbound => binding.outbound_forwarding_enabled, + }); + items.push(SelectionItem { + name: channel.title().to_string(), + description: Some(channel.description(enabled)), + selected_description: Some(channel.selected_description(enabled)), + is_disabled: current_binding.is_none(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ClawbotSetCurrentThreadForwarding { + channel, + enabled: !enabled, + }); + })], + dismiss_on_select: false, + ..Default::default() + }); + } + + items.push(SelectionItem { + name: "Disconnect Current Thread".to_string(), + description: Some( + current_binding + .map(|binding| format!("Unbind {}", binding.session_id)) + .unwrap_or_else(|| "No binding for current thread".to_string()), + ), + selected_description: Some( + "Remove the current thread's Feishu binding without deleting cached session state." + .to_string(), + ), + is_disabled: current_binding.is_none(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::ClawbotDisconnectCurrentThread) + })], + dismiss_on_select: false, + ..Default::default() + }); + + SelectionViewParams { + view_id: Some(CLAWBOT_MANAGEMENT_VIEW_ID), + title: Some("Clawbot".to_string()), + subtitle: Some( + "Manage workspace-local Feishu credentials and the current thread binding." + .to_string(), + ), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() + } + } +} + +fn clawbot_config_item( + field: ClawbotFeishuConfigField, + config: Option<&FeishuConfig>, +) -> SelectionItem { + SelectionItem { + name: field.title().to_string(), + description: Some(field.description(config)), + selected_description: Some( + "Persist this workspace-local Feishu setting under .codex/clawbot/config.toml." + .to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenClawbotFeishuConfigPrompt { field }); + })], + dismiss_on_select: false, + ..Default::default() + } +} + +fn connection_description(state: &ProviderRuntimeState) -> String { + let status = match state.connection { + ConnectionStatus::Unconfigured => "Unconfigured", + ConnectionStatus::Disconnected => "Disconnected", + ConnectionStatus::Connecting => "Connecting", + ConnectionStatus::Connected => "Connected", + ConnectionStatus::Error => "Error", + }; + match state.last_error.as_deref() { + Some(error) if !error.trim().is_empty() => { + format!("{status}: {}", truncate_value(error, 48)) + } + _ => status.to_string(), + } +} + +fn mask_secret(value: &str) -> String { + let chars = value.chars().count(); + if chars <= 4 { + return "*".repeat(chars.max(1)); + } + let suffix: String = value + .chars() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{}{}", "*".repeat(chars.saturating_sub(4)), suffix) +} + +fn truncate_value(value: &str, max_chars: usize) -> String { + let chars = value.chars().collect::>(); + if chars.len() <= max_chars { + return value.to_string(); + } + let prefix = chars + .into_iter() + .take(max_chars.saturating_sub(1)) + .collect::(); + format!("{prefix}…") +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index f5fd26245..2d1531868 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -74,6 +74,22 @@ pub(crate) enum RuntimeProfileTarget { Named(String), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClawbotFeishuConfigField { + AppId, + AppSecret, + VerificationToken, + EncryptKey, + BotOpenId, + BotUserId, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClawbotForwardingChannel { + Inbound, + Outbound, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxEnableMode { @@ -364,6 +380,31 @@ pub(crate) enum AppEvent { turn: AppServerTurn, }, + OpenClawbotManagement, + + OpenClawbotFeishuConfigPrompt { + field: ClawbotFeishuConfigField, + }, + + SaveClawbotFeishuConfigValue { + field: ClawbotFeishuConfigField, + value: String, + }, + + OpenClawbotManualBindPrompt, + + SaveClawbotManualBindSessionId { + session_id: String, + }, + + ClawbotDisconnectCurrentThread, + + ClawbotSetCurrentThreadForwarding { + channel: ClawbotForwardingChannel, + enabled: bool, + }, + + RetryClawbotFeishuConnection, /// Apply rollback semantics to local transcript cells. /// /// This is emitted when rollback was not initiated by the current diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2750e927d..0c4b1efcb 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -26,7 +26,6 @@ use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; -use bottom_pane_view::BottomPaneView; use codex_core::plugins::PluginCapabilitySummary; use codex_core::skills::model::SkillMetadata; use codex_features::Features; @@ -59,6 +58,7 @@ pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest; pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay; pub(crate) use request_user_input::RequestUserInputOverlay; mod bottom_pane_view; +pub(crate) use bottom_pane_view::BottomPaneView; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct LocalImageAttachment { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 911dd6790..68d19d598 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -47,6 +47,7 @@ use crate::app_server_approval_conversions::network_approval_context_to_core; use crate::app_server_session::ThreadSessionState; #[cfg(not(target_os = "linux"))] use crate::audio_device::list_realtime_audio_device_names; +use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; @@ -5059,6 +5060,10 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn show_view(&mut self, view: Box) { + self.bottom_pane.show_view(view); + self.request_redraw(); + } pub(crate) fn replace_selection_view_if_active( &mut self, view_id: &'static str, @@ -5076,7 +5081,6 @@ impl ChatWidget { pub(crate) fn selected_index_for_active_view(&self, view_id: &'static str) -> Option { self.bottom_pane.selected_index_for_active_view(view_id) } - pub(crate) fn no_modal_or_popup_active(&self) -> bool { self.bottom_pane.no_modal_or_popup_active() } @@ -5233,6 +5237,9 @@ impl ChatWidget { SlashCommand::Settings => { self.open_settings_popup(); } + SlashCommand::Clawbot => { + self.app_event_tx.send(AppEvent::OpenClawbotManagement); + } SlashCommand::Personality => { self.open_personality_popup(); } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e7d56888c..2ec75349f 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -462,6 +462,16 @@ async fn slash_workflow_opens_controls_popup() { assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); } +#[tokio::test] +async fn slash_clawbot_opens_management_popup() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Clawbot); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenClawbotManagement)); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + #[tokio::test] async fn slash_memory_update_reports_stubbed_feature() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 0084e8a4d..6dea03223 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -60,6 +60,7 @@ pub enum SlashCommand { Personality, Realtime, Settings, + Clawbot, TestApproval, #[strum(serialize = "subagents")] MultiAgents, @@ -105,6 +106,7 @@ impl SlashCommand { SlashCommand::Personality => "choose a communication style for Codex", SlashCommand::Realtime => "toggle realtime voice mode (experimental)", SlashCommand::Settings => "configure UI visibility and realtime devices", + SlashCommand::Clawbot => "manage workspace-local Feishu bridge and bindings", SlashCommand::Plan => "switch to Plan mode", SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", @@ -191,6 +193,7 @@ impl SlashCommand { SlashCommand::TestApproval => true, SlashCommand::Realtime => true, SlashCommand::Settings => true, + SlashCommand::Clawbot => true, SlashCommand::Collab => true, SlashCommand::Agent | SlashCommand::MultiAgents => true, SlashCommand::Statusline => false, diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap new file mode 100644 index 000000000..f9d0297da --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/app.rs +assertion_line: 12451 +expression: popup +--- + Clawbot + Manage workspace-local Feishu credentials and the current thread binding. + +› 1. Feishu App ID Persist this workspace-local Feishu setting under .codex/clawbot/ + config.toml. + 2. Feishu App Secret Configured: *************4567 + 3. Feishu Verification Token Configured: ********oken + 4. Feishu Encrypt Key Not set + 5. Feishu Bot Open ID Configured: ou_bot_open_id + 6. Feishu Bot User ID Not set + 7. Retry Feishu Connection Unconfigured + 8. Bind Current Thread Thread 019d5cc1-df25-7d53-9c3e-00d8a2608ac8 -> chat_snapshot + + Press enter to confirm or esc to go back From 2ee91453f7d67f238b970cf34596d65f9c1d5ad4 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 18:31:45 +0800 Subject: [PATCH 36/60] replay: restore clawbot message actions and non-interactive turn mode --- codex-rs/clawbot/src/config.rs | 15 ++ codex-rs/clawbot/src/lib.rs | 3 + codex-rs/clawbot/src/model.rs | 21 ++ codex-rs/clawbot/src/provider/feishu.rs | 44 ++++ codex-rs/clawbot/src/provider/mod.rs | 7 + codex-rs/clawbot/src/runtime.rs | 29 ++- codex-rs/tui/src/app.rs | 118 ++++++++++- codex-rs/tui/src/app/app_server_adapter.rs | 7 + codex-rs/tui/src/app/clawbot.rs | 189 +++++++++++++++++- codex-rs/tui/src/app/clawbot_controls.rs | 58 ++++++ codex-rs/tui/src/app_event.rs | 5 + ..._app__tests__clawbot_management_popup.snap | 19 +- 12 files changed, 491 insertions(+), 24 deletions(-) diff --git a/codex-rs/clawbot/src/config.rs b/codex-rs/clawbot/src/config.rs index bf06ec5a9..bd16e50b9 100644 --- a/codex-rs/clawbot/src/config.rs +++ b/codex-rs/clawbot/src/config.rs @@ -1,10 +1,25 @@ use serde::Deserialize; use serde::Serialize; +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ClawbotTurnMode { + #[default] + Interactive, + NonInteractive, +} + +impl ClawbotTurnMode { + pub fn uses_noninteractive_prompt_handling(self) -> bool { + matches!(self, Self::NonInteractive) + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct ClawbotConfig { pub feishu: Option, + pub turn_mode: ClawbotTurnMode, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] diff --git a/codex-rs/clawbot/src/lib.rs b/codex-rs/clawbot/src/lib.rs index dc3e3eb0f..de3f743c6 100644 --- a/codex-rs/clawbot/src/lib.rs +++ b/codex-rs/clawbot/src/lib.rs @@ -6,6 +6,7 @@ mod runtime; mod store; pub use config::ClawbotConfig; +pub use config::ClawbotTurnMode; pub use config::FeishuConfig; pub use events::ProviderInboundMessage; pub use model::CLAWBOT_BINDINGS_RELATIVE_PATH; @@ -21,6 +22,7 @@ pub use model::ConnectionStatus; pub use model::ForwardingDirection; pub use model::ForwardingState; pub use model::ProviderKind; +pub use model::ProviderMessageRef; pub use model::ProviderRuntimeState; pub use model::ProviderSession; pub use model::ProviderSessionRef; @@ -28,6 +30,7 @@ pub use model::SessionBinding; pub use model::SessionStatus; pub use provider::FeishuProviderRuntime; pub use provider::ProviderEvent; +pub use provider::ProviderOutboundReaction; pub use provider::ProviderOutboundTextMessage; pub use provider::feishu_failure_reply_text; pub use runtime::ClawbotRuntime; diff --git a/codex-rs/clawbot/src/model.rs b/codex-rs/clawbot/src/model.rs index 50ac28098..1cf66283f 100644 --- a/codex-rs/clawbot/src/model.rs +++ b/codex-rs/clawbot/src/model.rs @@ -89,6 +89,27 @@ impl ProviderSessionRef { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct ProviderMessageRef { + pub provider: ProviderKind, + pub session_id: String, + pub message_id: String, +} + +impl ProviderMessageRef { + pub fn new( + provider: ProviderKind, + session_id: impl Into, + message_id: impl Into, + ) -> Self { + Self { + provider, + session_id: session_id.into(), + message_id: message_id.into(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProviderSession { pub provider: ProviderKind, diff --git a/codex-rs/clawbot/src/provider/feishu.rs b/codex-rs/clawbot/src/provider/feishu.rs index 1de159ef3..74cd09fb6 100644 --- a/codex-rs/clawbot/src/provider/feishu.rs +++ b/codex-rs/clawbot/src/provider/feishu.rs @@ -6,13 +6,20 @@ use std::time::UNIX_EPOCH; use anyhow::Result; use anyhow::anyhow; use open_lark::openlark_client; +use open_lark::openlark_communication::common::api_utils::serialize_params; +use open_lark::openlark_communication::endpoints::IM_V1_MESSAGES; use open_lark::openlark_communication::im::im::v1::message::create::CreateMessageBody; use open_lark::openlark_communication::im::im::v1::message::create::CreateMessageRequest; use open_lark::openlark_communication::im::im::v1::message::models::ReceiveIdType; +use open_lark::openlark_communication::im::im::v1::message::reaction::models::CreateMessageReactionBody; +use open_lark::openlark_communication::im::im::v1::message::reaction::models::ReactionType; +use open_lark::openlark_core::api::ApiRequest; use serde::Deserialize; +use serde_json::Value; use tokio::sync::mpsc; use super::ProviderEvent; +use super::ProviderOutboundReaction; use super::ProviderOutboundTextMessage; use crate::config::FeishuConfig; use crate::events::ProviderInboundMessage; @@ -72,6 +79,43 @@ impl FeishuProviderRuntime { Ok(()) } + pub async fn add_reaction(&self, reaction: ProviderOutboundReaction) -> Result<()> { + if reaction.target.provider != ProviderKind::Feishu { + return Err(anyhow!( + "cannot send {} reaction via Feishu runtime", + reaction.target.provider.title() + )); + } + + let request: ApiRequest = ApiRequest::post(format!( + "{IM_V1_MESSAGES}/{}/reactions", + reaction.target.message_id + )) + .body(serialize_params( + &CreateMessageReactionBody { + reaction_type: ReactionType { + emoji_type: reaction.emoji_type, + }, + }, + "添加消息表情回复", + )?); + let response = open_lark::openlark_core::http::Transport::::request( + request, + &self.messaging_config()?, + Some(Default::default()), + ) + .await + .map_err(|error| anyhow!("failed to add Feishu message reaction: {error}"))?; + if response.is_success() { + Ok(()) + } else { + Err(anyhow!( + "failed to add Feishu message reaction: {}", + response.msg() + )) + } + } + pub fn normalize_private_chat_message( message: FeishuInboundPrivateMessage, ) -> Option> { diff --git a/codex-rs/clawbot/src/provider/mod.rs b/codex-rs/clawbot/src/provider/mod.rs index abddd74ec..a7304ff23 100644 --- a/codex-rs/clawbot/src/provider/mod.rs +++ b/codex-rs/clawbot/src/provider/mod.rs @@ -1,6 +1,7 @@ mod feishu; use crate::events::ProviderInboundMessage; +use crate::model::ProviderMessageRef; use crate::model::ProviderRuntimeState; use crate::model::ProviderSession; use crate::model::ProviderSessionRef; @@ -14,6 +15,12 @@ pub struct ProviderOutboundTextMessage { pub text: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderOutboundReaction { + pub target: ProviderMessageRef, + pub emoji_type: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProviderEvent { RuntimeStateUpdated(ProviderRuntimeState), diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs index d236acb47..1592e6724 100644 --- a/codex-rs/clawbot/src/runtime.rs +++ b/codex-rs/clawbot/src/runtime.rs @@ -5,6 +5,7 @@ use std::time::UNIX_EPOCH; use anyhow::Context; use anyhow::Result; +use crate::config::ClawbotTurnMode; use crate::config::FeishuConfig; use crate::model::CachedUnreadMessage; use crate::model::ClawbotSnapshot; @@ -57,9 +58,14 @@ impl ClawbotRuntime { &mut self, feishu: Option, ) -> Result<&ClawbotSnapshot> { - self.store.save_config(&crate::config::ClawbotConfig { - feishu: feishu.filter(|config| !config.is_empty()), - })?; + self.snapshot.config.feishu = feishu.filter(|config| !config.is_empty()); + self.store.save_config(&self.snapshot.config)?; + self.reload() + } + + pub fn update_turn_mode(&mut self, mode: ClawbotTurnMode) -> Result<&ClawbotSnapshot> { + self.snapshot.config.turn_mode = mode; + self.store.save_config(&self.snapshot.config)?; self.reload() } @@ -332,6 +338,7 @@ mod tests { use tempfile::tempdir; use super::ClawbotRuntime; + use crate::config::ClawbotTurnMode; use crate::config::FeishuConfig; use crate::events::ProviderInboundMessage; use crate::model::ConnectionStatus; @@ -473,6 +480,22 @@ mod tests { assert_eq!(runtime.snapshot().config.feishu, None); } + #[test] + fn update_turn_mode_persists_in_config() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + + runtime + .update_turn_mode(ClawbotTurnMode::NonInteractive) + .expect("save turn mode"); + + let reloaded = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("reload runtime"); + assert_eq!( + reloaded.snapshot().config.turn_mode, + ClawbotTurnMode::NonInteractive + ); + } + #[test] fn disconnect_thread_clears_binding_and_session_state() { let tempdir = tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9c672ebc7..3b835fef7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -90,6 +90,8 @@ use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; #[cfg(test)] +use codex_clawbot::ProviderOutboundReaction; +#[cfg(test)] use codex_clawbot::ProviderOutboundTextMessage; use codex_config::types::ApprovalsReviewer; use codex_config::types::ModelAvailabilityNuxConfig; @@ -1105,6 +1107,8 @@ pub(crate) struct App { clawbot_pending_turns: HashMap>, #[cfg(test)] clawbot_outbound_messages: Vec, + #[cfg(test)] + clawbot_outbound_reactions: Vec, } #[derive(Default)] @@ -4196,6 +4200,8 @@ impl App { clawbot_pending_turns: HashMap::new(), #[cfg(test)] clawbot_outbound_messages: Vec::new(), + #[cfg(test)] + clawbot_outbound_reactions: Vec::new(), }; if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) @@ -4808,6 +4814,12 @@ impl App { .add_error_message(format!("Failed to bind Clawbot session: {err}")); } } + AppEvent::ClawbotSetTurnMode { mode } => { + if let Err(err) = self.save_clawbot_turn_mode(mode) { + self.chat_widget + .add_error_message(format!("Failed to save Clawbot turn mode: {err}")); + } + } AppEvent::ClawbotDisconnectCurrentThread => { if let Err(err) = self.clawbot_disconnect_current_thread() { self.chat_widget @@ -6899,6 +6911,7 @@ mod tests { use codex_app_server_client::AppServerEvent; use crate::app_event::ClawbotForwardingChannel; + use crate::render::renderable::Renderable; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; @@ -6941,8 +6954,11 @@ mod tests { use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as AppServerUserInput; use codex_clawbot::ClawbotRuntime; + use codex_clawbot::ClawbotTurnMode; use codex_clawbot::ProviderEvent as ClawbotProviderEvent; use codex_clawbot::ProviderKind as ClawbotProviderKind; + use codex_clawbot::ProviderMessageRef; + use codex_clawbot::ProviderOutboundReaction; use codex_clawbot::ProviderOutboundTextMessage; use codex_clawbot::ProviderSession; use codex_clawbot::ProviderSessionRef; @@ -6979,11 +6995,11 @@ mod tests { use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyModifiers; use insta::assert_snapshot; + use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use crate::render::renderable::Renderable; use pretty_assertions::assert_eq; - use ratatui::prelude::Line; use std::path::Path; use std::path::PathBuf; @@ -9812,6 +9828,8 @@ guardian_approval = true clawbot_pending_turns: HashMap::new(), #[cfg(test)] clawbot_outbound_messages: Vec::new(), + #[cfg(test)] + clawbot_outbound_reactions: Vec::new(), } } @@ -9879,6 +9897,8 @@ guardian_approval = true clawbot_pending_turns: HashMap::new(), #[cfg(test)] clawbot_outbound_messages: Vec::new(), + #[cfg(test)] + clawbot_outbound_reactions: Vec::new(), }, rx, op_rx, @@ -12014,6 +12034,17 @@ model = "gpt-5.2" .expect("handle clawbot inbound message"); assert!(app.thread_event_channels.contains_key(&thread_id)); + assert_eq!( + app.clawbot_outbound_reactions, + vec![ProviderOutboundReaction { + target: ProviderMessageRef::new( + ClawbotProviderKind::Feishu, + "chat_resume", + "msg_1" + ), + emoji_type: "TONGUE".to_string(), + }] + ); assert_eq!( app.clawbot_pending_turns .get(&thread_id) @@ -12025,6 +12056,91 @@ model = "gpt-5.2" Ok(()) } + #[tokio::test] + async fn noninteractive_clawbot_request_user_input_builds_auto_response() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.clawbot_pending_turns.insert( + thread_id, + VecDeque::from([PendingClawbotTurn { + turn_id: "turn-1".to_string(), + session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), + turn_mode: ClawbotTurnMode::NonInteractive, + }]), + ); + let request = ServerRequest::ToolRequestUserInput { + request_id: AppServerRequestId::Integer(1), + params: ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + questions: Vec::new(), + }, + }; + + let op = app + .clawbot_auto_response_op_for_server_request(thread_id, &request) + .expect("auto response op"); + + match op.view() { + crate::app_command::AppCommandView::UserInputAnswer { id, response } => { + assert_eq!(id, "turn-1"); + assert_eq!( + response, + &codex_protocol::request_user_input::RequestUserInputResponse { + answers: HashMap::new(), + } + ); + } + _ => panic!("expected UserInputAnswer"), + } + } + + #[tokio::test] + async fn noninteractive_clawbot_permissions_request_builds_auto_response() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.clawbot_pending_turns.insert( + thread_id, + VecDeque::from([PendingClawbotTurn { + turn_id: "turn-1".to_string(), + session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), + turn_mode: ClawbotTurnMode::NonInteractive, + }]), + ); + let request = ServerRequest::PermissionsRequestApproval { + request_id: AppServerRequestId::Integer(7), + params: PermissionsRequestApprovalParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-approval".to_string(), + reason: Some("Need access".to_string()), + permissions: codex_app_server_protocol::RequestPermissionProfile { + network: None, + file_system: None, + }, + }, + }; + + let op = app + .clawbot_auto_response_op_for_server_request(thread_id, &request) + .expect("auto response op"); + + match op.view() { + crate::app_command::AppCommandView::RequestPermissionsResponse { id, response } => { + assert_eq!(id, "call-approval"); + assert_eq!( + response, + &codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: Default::default(), + scope: codex_protocol::request_permissions::PermissionGrantScope::Turn, + } + ); + } + _ => panic!("expected RequestPermissionsResponse"), + } + } + #[tokio::test] async fn clawbot_turn_completed_forwards_reply_and_drains_next_message() -> Result<()> { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 46ba79671..c7d91b047 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -294,6 +294,13 @@ impl App { return; } + if self + .maybe_auto_resolve_clawbot_server_request(app_server_client, thread_id, &request) + .await + { + return; + } + let result = if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() { self.enqueue_primary_thread_request(request).await diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs index e373eaf7e..9a9b8aa58 100644 --- a/codex-rs/tui/src/app/clawbot.rs +++ b/codex-rs/tui/src/app/clawbot.rs @@ -1,17 +1,27 @@ +use std::collections::HashMap; use std::path::Path; use anyhow::Context; use anyhow::Result; +use codex_app_server_protocol::ServerRequest; use codex_clawbot::CachedUnreadMessage; use codex_clawbot::ClawbotRuntime; +use codex_clawbot::ClawbotTurnMode; use codex_clawbot::FeishuConfig; use codex_clawbot::FeishuProviderRuntime; use codex_clawbot::ProviderEvent; +use codex_clawbot::ProviderMessageRef; +use codex_clawbot::ProviderOutboundReaction; use codex_clawbot::ProviderOutboundTextMessage; use codex_clawbot::ProviderSessionRef; use codex_clawbot::feishu_failure_reply_text; use codex_protocol::ThreadId; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::Op; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionsResponse; +use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; use tokio::sync::mpsc; @@ -21,10 +31,13 @@ use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; use crate::app_server_session::ThreadSessionState; +const FEISHU_AUTO_ACK_EMOJI_TYPE: &str = "TONGUE"; + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PendingClawbotTurn { pub(crate) turn_id: String, pub(crate) session: ProviderSessionRef, + pub(crate) turn_mode: ClawbotTurnMode, } impl App { @@ -181,12 +194,36 @@ impl App { let Some(message) = next_unread_message_for_session(&runtime, session) else { return Ok(()); }; + let turn_mode = runtime.snapshot().config.turn_mode; self.attach_clawbot_bound_thread_if_needed(app_server, thread_id) .await?; + if let Err(err) = self + .send_clawbot_outbound_reaction(ProviderOutboundReaction { + target: ProviderMessageRef::new( + message.provider, + message.session_id.clone(), + message.message_id.clone(), + ), + emoji_type: FEISHU_AUTO_ACK_EMOJI_TYPE.to_string(), + }) + .await + { + tracing::warn!( + thread_id = %thread_id, + session = session.session_id, + error = %err, + "failed to auto-ack clawbot inbound message" + ); + } let turn_id = self - .submit_clawbot_message_to_thread(app_server, thread_id, message.text.clone()) + .submit_clawbot_message_to_thread( + app_server, + thread_id, + message.text.clone(), + turn_mode, + ) .await?; - self.register_pending_clawbot_turn(thread_id, session.clone(), turn_id); + self.register_pending_clawbot_turn(thread_id, session.clone(), turn_id, turn_mode); let removed = runtime.take_next_unread_message(session)?; if removed.as_ref().map(|entry| entry.message_id.as_str()) != Some(message.message_id.as_str()) @@ -237,6 +274,7 @@ impl App { app_server: &mut AppServerSession, thread_id: ThreadId, message: String, + turn_mode: ClawbotTurnMode, ) -> Result { let trimmed = message.trim().to_string(); if trimmed.is_empty() { @@ -249,13 +287,13 @@ impl App { .with_context(|| { format!("missing live thread session for clawbot thread {thread_id}") })?; - let op = AppCommand::from_core(Op::UserTurn { + let op: AppCommand = Op::UserTurn { items: vec![UserInput::Text { text: trimmed.clone(), text_elements: Vec::new(), }], cwd: session.cwd.clone(), - approval_policy: session.approval_policy, + approval_policy: clawbot_approval_policy(session.approval_policy, turn_mode), approvals_reviewer: Some(session.approvals_reviewer), sandbox_policy: session.sandbox_policy.clone(), model: session.model.clone(), @@ -265,7 +303,8 @@ impl App { final_output_json_schema: None, collaboration_mode: None, personality: self.config.personality, - }); + } + .into(); crate::session_log::log_outbound_op(&op); let response = app_server .turn_start( @@ -275,7 +314,7 @@ impl App { text_elements: Vec::new(), }], session.cwd, - session.approval_policy, + clawbot_approval_policy(session.approval_policy, turn_mode), session.approvals_reviewer, session.sandbox_policy, session.model, @@ -354,16 +393,45 @@ impl App { } } + async fn send_clawbot_outbound_reaction( + &mut self, + reaction: ProviderOutboundReaction, + ) -> Result<()> { + #[cfg(test)] + { + self.clawbot_outbound_reactions.push(reaction); + Ok(()) + } + + #[cfg(not(test))] + { + let workspace_root = self + .clawbot_workspace_root + .clone() + .context("missing clawbot workspace root for outbound reaction")?; + let runtime = ClawbotRuntime::load(workspace_root)?; + let provider = runtime + .feishu_provider() + .context("missing Feishu config for clawbot outbound bridge")?; + provider.add_reaction(reaction).await + } + } + fn register_pending_clawbot_turn( &mut self, thread_id: ThreadId, session: ProviderSessionRef, turn_id: String, + turn_mode: ClawbotTurnMode, ) { self.clawbot_pending_turns .entry(thread_id) .or_default() - .push_back(PendingClawbotTurn { turn_id, session }); + .push_back(PendingClawbotTurn { + turn_id, + session, + turn_mode, + }); } fn take_pending_clawbot_turn( @@ -381,13 +449,114 @@ impl App { } pending } + + fn clawbot_turn_mode_for_turn( + &self, + thread_id: ThreadId, + turn_id: &str, + ) -> Option { + self.clawbot_pending_turns + .get(&thread_id)? + .iter() + .find(|pending| pending.turn_id == turn_id) + .map(|pending| pending.turn_mode) + } + + pub(super) fn clawbot_auto_response_op_for_server_request( + &self, + thread_id: ThreadId, + request: &ServerRequest, + ) -> Option { + match request { + ServerRequest::ToolRequestUserInput { params, .. } => { + let turn_mode = self.clawbot_turn_mode_for_turn(thread_id, ¶ms.turn_id)?; + if !turn_mode.uses_noninteractive_prompt_handling() { + return None; + } + Some(AppCommand::user_input_answer( + params.turn_id.clone(), + RequestUserInputResponse { + answers: HashMap::new(), + }, + )) + } + ServerRequest::PermissionsRequestApproval { params, .. } => { + let turn_mode = self.clawbot_turn_mode_for_turn(thread_id, ¶ms.turn_id)?; + if !turn_mode.uses_noninteractive_prompt_handling() { + return None; + } + Some(AppCommand::request_permissions_response( + params.item_id.clone(), + RequestPermissionsResponse { + permissions: Default::default(), + scope: PermissionGrantScope::Turn, + }, + )) + } + _ => None, + } + } + + pub(super) async fn maybe_auto_resolve_clawbot_server_request( + &mut self, + app_server: &AppServerSession, + thread_id: ThreadId, + request: &ServerRequest, + ) -> bool { + let Some(op) = self.clawbot_auto_response_op_for_server_request(thread_id, request) else { + return false; + }; + + match self + .try_resolve_app_server_request(app_server, thread_id, &op) + .await + { + Ok(true) => true, + Ok(false) => { + self.pending_app_server_requests + .note_server_request(request); + false + } + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to auto-resolve clawbot server request" + ); + self.pending_app_server_requests + .note_server_request(request); + false + } + } + } +} + +fn clawbot_approval_policy( + existing_policy: AskForApproval, + turn_mode: ClawbotTurnMode, +) -> AskForApproval { + if !turn_mode.uses_noninteractive_prompt_handling() { + return existing_policy; + } + + AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }) } fn clawbot_outbound_text_for_turn(turn: &codex_app_server_protocol::Turn) -> Option { match turn.status { - codex_app_server_protocol::TurnStatus::Completed => { - super::last_agent_message_for_turn(turn) - } + codex_app_server_protocol::TurnStatus::Completed => turn.items.iter().find_map(|item| { + let codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } = item else { + return None; + }; + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }), codex_app_server_protocol::TurnStatus::Failed => turn .error .as_ref() diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index c8953b448..d98486171 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -1,6 +1,7 @@ use anyhow::Context; use anyhow::Result; use codex_clawbot::ClawbotRuntime; +use codex_clawbot::ClawbotTurnMode; use codex_clawbot::ConnectionStatus; use codex_clawbot::FeishuConfig; use codex_clawbot::ForwardingDirection; @@ -228,6 +229,20 @@ impl App { Ok(()) } + pub(crate) fn save_clawbot_turn_mode(&mut self, mode: ClawbotTurnMode) -> Result<()> { + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + runtime.update_turn_mode(mode)?; + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + format!( + "Clawbot turn mode set to {}.", + clawbot_turn_mode_label(mode) + ), + /*hint*/ None, + ); + Ok(()) + } + pub(crate) async fn save_clawbot_manual_bind_session_id( &mut self, app_server: &mut AppServerSession, @@ -340,7 +355,31 @@ impl App { .iter() .find(|binding| binding.thread_id == thread_id) }); + let turn_mode = snapshot.config.turn_mode; + let next_turn_mode = match turn_mode { + ClawbotTurnMode::Interactive => ClawbotTurnMode::NonInteractive, + ClawbotTurnMode::NonInteractive => ClawbotTurnMode::Interactive, + }; let mut items = vec![ + SelectionItem { + name: "Turn Mode".to_string(), + description: Some(clawbot_turn_mode_summary(turn_mode)), + selected_description: Some(match turn_mode { + ClawbotTurnMode::Interactive => { + "Switch clawbot-originated turns into non-interactive mode so remote sessions do not block on prompts.".to_string() + } + ClawbotTurnMode::NonInteractive => { + "Restore normal interactive prompt handling for clawbot-originated turns.".to_string() + } + }), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ClawbotSetTurnMode { + mode: next_turn_mode, + }); + })], + dismiss_on_select: false, + ..Default::default() + }, clawbot_config_item(ClawbotFeishuConfigField::AppId, feishu_config), clawbot_config_item(ClawbotFeishuConfigField::AppSecret, feishu_config), clawbot_config_item(ClawbotFeishuConfigField::VerificationToken, feishu_config), @@ -476,6 +515,25 @@ fn connection_description(state: &ProviderRuntimeState) -> String { } } +fn clawbot_turn_mode_label(mode: ClawbotTurnMode) -> &'static str { + match mode { + ClawbotTurnMode::Interactive => "interactive", + ClawbotTurnMode::NonInteractive => "non-interactive", + } +} + +fn clawbot_turn_mode_summary(mode: ClawbotTurnMode) -> String { + match mode { + ClawbotTurnMode::Interactive => { + "interactive: clawbot turns may surface question and approval prompts.".to_string() + } + ClawbotTurnMode::NonInteractive => { + "non-interactive: clawbot turns auto-dismiss question and permission prompts." + .to_string() + } + } +} + fn mask_secret(value: &str) -> String { let chars = value.chars().count(); if chars <= 4 { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 2d1531868..f3db72d13 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -19,6 +19,7 @@ use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::Turn as AppServerTurn; use codex_chatgpt::connectors::AppInfo; +use codex_clawbot::ClawbotTurnMode; use codex_clawbot::ProviderEvent as ClawbotProviderEvent; use codex_file_search::FileMatch; use codex_protocol::ThreadId; @@ -397,6 +398,10 @@ pub(crate) enum AppEvent { session_id: String, }, + ClawbotSetTurnMode { + mode: ClawbotTurnMode, + }, + ClawbotDisconnectCurrentThread, ClawbotSetCurrentThreadForwarding { diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap index f9d0297da..d63bb90d8 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap @@ -1,19 +1,18 @@ --- source: tui/src/app.rs -assertion_line: 12451 expression: popup --- Clawbot Manage workspace-local Feishu credentials and the current thread binding. -› 1. Feishu App ID Persist this workspace-local Feishu setting under .codex/clawbot/ - config.toml. - 2. Feishu App Secret Configured: *************4567 - 3. Feishu Verification Token Configured: ********oken - 4. Feishu Encrypt Key Not set - 5. Feishu Bot Open ID Configured: ou_bot_open_id - 6. Feishu Bot User ID Not set - 7. Retry Feishu Connection Unconfigured - 8. Bind Current Thread Thread 019d5cc1-df25-7d53-9c3e-00d8a2608ac8 -> chat_snapshot +› 1. Turn Mode Switch clawbot-originated turns into non-interactive mode so + remote sessions do not block on prompts. + 2. Feishu App ID Configured: cli_app_123 + 3. Feishu App Secret Configured: *************4567 + 4. Feishu Verification Token Configured: ********oken + 5. Feishu Encrypt Key Not set + 6. Feishu Bot Open ID Configured: ou_bot_open_id + 7. Feishu Bot User ID Not set + 8. Retry Feishu Connection Unconfigured Press enter to confirm or esc to go back From 8cb52adf950d6cdcbdd7fbf3dd673135c1ef5806 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 18:55:39 +0800 Subject: [PATCH 37/60] replay: restore clawbot session discovery and cleanup --- codex-rs/clawbot/src/provider/feishu.rs | 9 + codex-rs/clawbot/src/provider/feishu/sync.rs | 221 ++++++++++++++++++ codex-rs/clawbot/src/runtime.rs | 113 +++++++++ codex-rs/tui/src/app.rs | 37 +++ codex-rs/tui/src/app/clawbot_controls.rs | 189 ++++++++++++++- codex-rs/tui/src/app_event.rs | 4 + ..._app__tests__clawbot_management_popup.snap | 18 +- 7 files changed, 578 insertions(+), 13 deletions(-) create mode 100644 codex-rs/clawbot/src/provider/feishu/sync.rs diff --git a/codex-rs/clawbot/src/provider/feishu.rs b/codex-rs/clawbot/src/provider/feishu.rs index 74cd09fb6..e97981b6f 100644 --- a/codex-rs/clawbot/src/provider/feishu.rs +++ b/codex-rs/clawbot/src/provider/feishu.rs @@ -1,4 +1,5 @@ mod runtime_loop; +mod sync; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -116,6 +117,14 @@ impl FeishuProviderRuntime { } } + pub async fn scan_sessions(&self) -> Result> { + let sessions = sync::discover_private_sessions(&self.messaging_config()?).await?; + Ok(sessions + .into_iter() + .map(ProviderEvent::SessionUpserted) + .collect()) + } + pub fn normalize_private_chat_message( message: FeishuInboundPrivateMessage, ) -> Option> { diff --git a/codex-rs/clawbot/src/provider/feishu/sync.rs b/codex-rs/clawbot/src/provider/feishu/sync.rs new file mode 100644 index 000000000..a51d1cec7 --- /dev/null +++ b/codex-rs/clawbot/src/provider/feishu/sync.rs @@ -0,0 +1,221 @@ +use anyhow::Context; +use anyhow::Result; +use open_lark::openlark_communication::im::im::v1::chat::get::GetChatRequest; +use open_lark::openlark_communication::im::im::v1::chat::list::ListChatsRequest; +use open_lark::openlark_communication::im::im::v1::chat::models::ChatSortType; +use open_lark::openlark_communication::im::im::v1::message::models::UserIdType; +use serde::Deserialize; + +use crate::model::ProviderKind; +use crate::model::ProviderSession; +use crate::model::SessionStatus; + +pub(super) async fn discover_private_sessions( + config: &open_lark::openlark_core::config::Config, +) -> Result> { + let mut sessions = Vec::new(); + let mut page_token = None; + + loop { + let response = list_chat_page(config, page_token.clone()).await?; + let next_page_token = response.page_token.clone(); + + for chat in response.items { + if let Some(session) = load_private_session(config, chat).await? { + sessions.push(session); + } + } + + if !response.has_more { + break; + } + + let Some(token) = next_page_token.filter(|token| !token.is_empty()) else { + break; + }; + page_token = Some(token); + } + + sessions.sort_by(|left, right| left.session_id.cmp(&right.session_id)); + sessions.dedup_by(|left, right| left.session_id == right.session_id); + Ok(sessions) +} + +async fn list_chat_page( + config: &open_lark::openlark_core::config::Config, + page_token: Option, +) -> Result { + let mut request = ListChatsRequest::new(config.clone()) + .user_id_type(UserIdType::OpenId) + .sort_type(ChatSortType::ByActiveTimeDesc) + .page_size(100); + if let Some(token) = page_token { + request = request.page_token(token); + } + + let response = request + .execute() + .await + .context("failed to list Feishu chats")?; + serde_json::from_value(response).context("failed to parse Feishu chat list response") +} + +async fn load_private_session( + config: &open_lark::openlark_core::config::Config, + chat: FeishuChatListItem, +) -> Result> { + let chat_id = chat.chat_id.clone(); + let response = GetChatRequest::new(config.clone()) + .chat_id(chat_id.clone()) + .user_id_type(UserIdType::OpenId) + .execute() + .await + .with_context(|| format!("failed to load Feishu chat {chat_id}"))?; + let details: FeishuChatDetails = serde_json::from_value(response) + .with_context(|| format!("failed to parse Feishu chat details for {chat_id}"))?; + + if !is_private_chat(&details) { + return Ok(None); + } + + Ok(Some(ProviderSession { + provider: ProviderKind::Feishu, + session_id: chat.chat_id, + display_name: first_non_empty([chat.name, details.name]), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + })) +} + +fn first_non_empty(values: [Option; 2]) -> Option { + values + .into_iter() + .flatten() + .map(|value| value.trim().to_string()) + .find(|value| !value.is_empty()) +} + +fn is_private_chat(details: &FeishuChatDetails) -> bool { + details.chat_type.as_deref() == Some("private") + || details.chat_mode.as_deref() == Some("p2p") + || details.r#type.as_deref() == Some("p2p") +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct FeishuChatListResponse { + #[serde(default)] + items: Vec, + #[serde(default)] + page_token: Option, + #[serde(default)] + has_more: bool, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct FeishuChatListItem { + chat_id: String, + name: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct FeishuChatDetails { + name: Option, + chat_mode: Option, + chat_type: Option, + #[serde(rename = "type")] + r#type: Option, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::FeishuChatDetails; + use super::FeishuChatListItem; + use super::FeishuChatListResponse; + use super::first_non_empty; + use super::is_private_chat; + + #[test] + fn parse_list_chat_response_with_items() { + let response: FeishuChatListResponse = serde_json::from_value(serde_json::json!({ + "items": [ + { "chat_id": "oc_1", "name": "Alice" }, + { "chat_id": "oc_2", "name": "Bob" } + ], + "page_token": "next_token", + "has_more": true + })) + .expect("response"); + + assert_eq!( + response, + FeishuChatListResponse { + items: vec![ + FeishuChatListItem { + chat_id: "oc_1".to_string(), + name: Some("Alice".to_string()), + }, + FeishuChatListItem { + chat_id: "oc_2".to_string(), + name: Some("Bob".to_string()), + }, + ], + page_token: Some("next_token".to_string()), + has_more: true, + } + ); + } + + #[test] + fn parse_chat_details_with_type_field() { + let response: FeishuChatDetails = serde_json::from_value(serde_json::json!({ + "chat_id": "oc_1", + "name": "Alice", + "type": "p2p" + })) + .expect("response"); + + assert_eq!( + response, + FeishuChatDetails { + name: Some("Alice".to_string()), + chat_mode: None, + chat_type: None, + r#type: Some("p2p".to_string()), + } + ); + } + + #[test] + fn private_chat_detection_accepts_private_variants() { + assert_eq!( + is_private_chat(&FeishuChatDetails { + name: Some("Alice".to_string()), + chat_mode: Some("group".to_string()), + chat_type: Some("private".to_string()), + r#type: None, + }), + true + ); + assert_eq!( + is_private_chat(&FeishuChatDetails { + name: Some("Alice".to_string()), + chat_mode: Some("p2p".to_string()), + chat_type: Some("group".to_string()), + r#type: None, + }), + true + ); + } + + #[test] + fn first_non_empty_skips_blank_values() { + assert_eq!( + first_non_empty([Some(" ".to_string()), Some("Alice".to_string())]), + Some("Alice".to_string()) + ); + } +} diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs index 1592e6724..d770817ad 100644 --- a/codex-rs/clawbot/src/runtime.rs +++ b/codex-rs/clawbot/src/runtime.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::path::PathBuf; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -12,6 +13,7 @@ use crate::model::ClawbotSnapshot; use crate::model::ForwardingDirection; use crate::model::ForwardingState; use crate::model::InboundMessageReceipt; +use crate::model::ProviderKind; use crate::model::ProviderSession; use crate::model::ProviderSessionRef; use crate::model::SessionBinding; @@ -69,6 +71,47 @@ impl ClawbotRuntime { self.reload() } + pub async fn scan_feishu_sessions(&mut self) -> Result<&ClawbotSnapshot> { + let provider = self + .feishu_provider() + .context("Feishu credentials are not configured")?; + for event in provider.scan_sessions().await? { + self.apply_provider_event(event)?; + } + self.reload() + } + + pub fn clear_unbound_feishu_sessions(&mut self) -> Result<&ClawbotSnapshot> { + let bound_sessions = self + .store + .load_bindings()? + .into_iter() + .map(|binding| binding.session_ref()) + .collect::>(); + let sessions = self + .store + .load_sessions()? + .into_iter() + .filter(|session| { + session.provider != ProviderKind::Feishu + || bound_sessions.contains(&session.session_ref()) + }) + .collect::>(); + let unread_messages = self + .store + .load_unread_messages()? + .into_iter() + .filter(|message| { + message.provider != ProviderKind::Feishu + || bound_sessions.contains(&message.session_ref()) + }) + .collect::>(); + + self.store.save_sessions(&sessions)?; + self.store.save_unread_messages(&unread_messages)?; + self.reload() + } + pub fn persist_session(&mut self, session: ProviderSession) -> Result<&ClawbotSnapshot> { self.store.upsert_session(session)?; self.reload() @@ -341,6 +384,7 @@ mod tests { use crate::config::ClawbotTurnMode; use crate::config::FeishuConfig; use crate::events::ProviderInboundMessage; + use crate::model::CachedUnreadMessage; use crate::model::ConnectionStatus; use crate::model::ForwardingDirection; use crate::model::ForwardingState; @@ -579,4 +623,73 @@ mod tests { } ); } + + #[test] + fn clear_unbound_feishu_sessions_removes_unbound_sessions_and_unread_cache() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let bound_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_bound"); + let unbound_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_unbound"); + + runtime + .connect_session_to_thread(&bound_session, "thread_3".to_string()) + .expect("bind session"); + runtime + .persist_session(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_unbound".to_string(), + display_name: Some("Unbound".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("persist unbound session"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: bound_session.clone(), + message_id: "msg_bound".to_string(), + text: "bound".to_string(), + received_at: 1, + })) + .expect("bound unread"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: unbound_session, + message_id: "msg_unbound".to_string(), + text: "unbound".to_string(), + received_at: 2, + })) + .expect("unbound unread"); + + runtime + .clear_unbound_feishu_sessions() + .expect("clear unbound sessions"); + + assert_eq!( + runtime.snapshot().sessions, + vec![ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_bound".to_string(), + display_name: None, + unread_count: 1, + last_message_at: Some(1), + status: SessionStatus::Bound, + bound_thread_id: Some("thread_3".to_string()), + }] + ); + assert_eq!( + runtime + .store() + .load_unread_messages() + .expect("unread messages"), + vec![CachedUnreadMessage { + provider: ProviderKind::Feishu, + session_id: "chat_bound".to_string(), + message_id: "msg_bound".to_string(), + text: "bound".to_string(), + received_at: 1, + }] + ); + } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3b835fef7..6157370bc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4832,6 +4832,19 @@ impl App { .add_error_message(format!("Failed to update Clawbot forwarding: {err}")); } } + AppEvent::ScanClawbotFeishuSessions => { + if let Err(err) = self.scan_clawbot_feishu_sessions().await { + self.chat_widget + .add_error_message(format!("Failed to scan Feishu sessions: {err}")); + } + } + AppEvent::ClearClawbotFeishuSessions => { + if let Err(err) = self.clear_clawbot_feishu_sessions() { + self.chat_widget.add_error_message(format!( + "Failed to clear unbound Feishu sessions: {err}" + )); + } + } AppEvent::RetryClawbotFeishuConnection => { if let Err(err) = self.retry_clawbot_feishu_connection() { self.chat_widget.add_error_message(format!( @@ -12814,6 +12827,30 @@ model = "gpt-5.2" bot_user_id: None, })) .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .persist_session(ProviderSession { + provider: ClawbotProviderKind::Feishu, + session_id: "chat_discovered".to_string(), + display_name: Some("Bob".to_string()), + unread_count: 0, + last_message_at: None, + status: ClawbotSessionStatus::Discovered, + bound_thread_id: None, + }) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .apply_provider_event(ClawbotProviderEvent::InboundMessage( + codex_clawbot::ProviderInboundMessage { + session: ProviderSessionRef::new( + ClawbotProviderKind::Feishu, + "chat_discovered", + ), + message_id: "msg_discovered".to_string(), + text: "hello".to_string(), + received_at: 10, + }, + )) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; app.open_clawbot_management_popup(); diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index d98486171..da9c30ac7 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -8,6 +8,7 @@ use codex_clawbot::ForwardingDirection; use codex_clawbot::ForwardingState; use codex_clawbot::ProviderKind; use codex_clawbot::ProviderRuntimeState; +use codex_clawbot::ProviderSession; use codex_clawbot::ProviderSessionRef; use super::App; @@ -334,13 +335,81 @@ impl App { Ok(()) } + pub(crate) async fn scan_clawbot_feishu_sessions(&mut self) -> Result<()> { + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + runtime.scan_feishu_sessions().await?; + let discovered = runtime + .snapshot() + .sessions + .iter() + .filter(|session| session.provider == ProviderKind::Feishu) + .count(); + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + format!("Scanned Feishu sessions. {discovered} discovered."), + /*hint*/ None, + ); + Ok(()) + } + + pub(crate) fn clear_clawbot_feishu_sessions(&mut self) -> Result<()> { + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + let sessions_before = runtime + .snapshot() + .sessions + .iter() + .filter(|session| { + session.provider == ProviderKind::Feishu && session.bound_thread_id.is_none() + }) + .count(); + let unread_before = runtime + .store() + .load_unread_messages()? + .into_iter() + .filter(|message| message.provider == ProviderKind::Feishu) + .filter(|message| { + !runtime + .snapshot() + .bindings + .iter() + .any(|binding| binding.session_ref() == message.session_ref()) + }) + .count(); + runtime.clear_unbound_feishu_sessions()?; + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + format!( + "Cleared {sessions_before} unbound Feishu sessions and {unread_before} cached unread messages." + ), + /*hint*/ None, + ); + Ok(()) + } + fn clawbot_management_popup_params( &self, initial_selected_idx: Option, ) -> SelectionViewParams { - let snapshot = ClawbotRuntime::load(self.config.cwd.to_path_buf()) - .map(|runtime| runtime.snapshot().clone()) - .unwrap_or_default(); + let (snapshot, clearable_unread_count) = + ClawbotRuntime::load(self.config.cwd.to_path_buf()) + .map(|runtime| { + let snapshot = runtime.snapshot().clone(); + let clearable_unread_count = runtime + .store() + .load_unread_messages() + .unwrap_or_default() + .into_iter() + .filter(|message| message.provider == ProviderKind::Feishu) + .filter(|message| { + !snapshot + .bindings + .iter() + .any(|binding| binding.session_ref() == message.session_ref()) + }) + .count(); + (snapshot, clearable_unread_count) + }) + .unwrap_or_default(); let feishu_config = snapshot.config.feishu.as_ref(); let provider_state = snapshot .runtime @@ -360,6 +429,25 @@ impl App { ClawbotTurnMode::Interactive => ClawbotTurnMode::NonInteractive, ClawbotTurnMode::NonInteractive => ClawbotTurnMode::Interactive, }; + let mut feishu_sessions = snapshot + .sessions + .iter() + .filter(|session| session.provider == ProviderKind::Feishu) + .cloned() + .collect::>(); + feishu_sessions.sort_by(|left, right| { + right + .bound_thread_id + .is_some() + .cmp(&left.bound_thread_id.is_some()) + .then(session_title(left).cmp(&session_title(right))) + .then(left.session_id.cmp(&right.session_id)) + }); + let bound_session_count = feishu_sessions + .iter() + .filter(|session| session.bound_thread_id.is_some()) + .count(); + let unbound_session_count = feishu_sessions.len().saturating_sub(bound_session_count); let mut items = vec![ SelectionItem { name: "Turn Mode".to_string(), @@ -380,6 +468,71 @@ impl App { dismiss_on_select: false, ..Default::default() }, + SelectionItem { + name: "Feishu Sessions".to_string(), + description: Some(format!( + "{} total · {} bound · {} unbound", + feishu_sessions.len(), + bound_session_count, + unbound_session_count + )), + selected_description: Some( + "Scan discovered Feishu chats, inspect bound/unbound state, and clear stale unbound cache." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Scan Feishu Sessions".to_string(), + description: Some( + "Discover private Feishu chats using the persisted workspace credentials." + .to_string(), + ), + selected_description: Some( + "Refresh the discovered session list before binding or cleanup." + .to_string(), + ), + is_disabled: !feishu_config.is_some_and(FeishuConfig::has_api_credentials), + actions: vec![Box::new(|tx| tx.send(AppEvent::ScanClawbotFeishuSessions))], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Clear Unbound Sessions".to_string(), + description: Some(format!( + "Remove {unbound_session_count} unbound sessions and {clearable_unread_count} cached unread messages." + )), + selected_description: Some( + "Keep active bindings intact while dropping stale discovered-session cache." + .to_string(), + ), + is_disabled: unbound_session_count == 0 && clearable_unread_count == 0, + actions: vec![Box::new(|tx| tx.send(AppEvent::ClearClawbotFeishuSessions))], + dismiss_on_select: false, + ..Default::default() + }, + ]; + + if feishu_sessions.is_empty() { + items.push(SelectionItem { + name: "No Feishu sessions discovered".to_string(), + description: Some( + "Run Scan Feishu Sessions to refresh the workspace-local discovered session list." + .to_string(), + ), + selected_description: Some( + "Discovered sessions appear here with their current bound or unbound state." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + items.extend(feishu_sessions.iter().map(clawbot_session_item)); + } + + items.extend([ clawbot_config_item(ClawbotFeishuConfigField::AppId, feishu_config), clawbot_config_item(ClawbotFeishuConfigField::AppSecret, feishu_config), clawbot_config_item(ClawbotFeishuConfigField::VerificationToken, feishu_config), @@ -399,7 +552,7 @@ impl App { dismiss_on_select: false, ..Default::default() }, - ]; + ]); let bind_description = match (&active_thread_id, current_binding) { (Some(thread_id), Some(binding)) => { @@ -469,7 +622,7 @@ impl App { view_id: Some(CLAWBOT_MANAGEMENT_VIEW_ID), title: Some("Clawbot".to_string()), subtitle: Some( - "Manage workspace-local Feishu credentials and the current thread binding." + "Manage workspace-local Feishu credentials, sessions, and the current thread binding." .to_string(), ), footer_hint: Some(standard_popup_hint_line()), @@ -515,6 +668,32 @@ fn connection_description(state: &ProviderRuntimeState) -> String { } } +fn clawbot_session_item(session: &ProviderSession) -> SelectionItem { + let state = if session.bound_thread_id.is_some() { + "bound" + } else { + "unbound" + }; + SelectionItem { + name: session_title(session), + description: Some(format!("{state} · {} unread", session.unread_count)), + selected_description: Some( + "Discovered session state is read-only here. Use Bind Current Thread to connect a session id." + .to_string(), + ), + is_disabled: true, + ..Default::default() + } +} + +fn session_title(session: &ProviderSession) -> String { + session + .display_name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| session.session_id.clone()) +} + fn clawbot_turn_mode_label(mode: ClawbotTurnMode) -> &'static str { match mode { ClawbotTurnMode::Interactive => "interactive", diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index f3db72d13..91918c810 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -409,6 +409,10 @@ pub(crate) enum AppEvent { enabled: bool, }, + ScanClawbotFeishuSessions, + + ClearClawbotFeishuSessions, + RetryClawbotFeishuConnection, /// Apply rollback semantics to local transcript cells. /// diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap index d63bb90d8..017a7fc4d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap @@ -1,18 +1,20 @@ --- source: tui/src/app.rs +assertion_line: 12754 expression: popup --- Clawbot - Manage workspace-local Feishu credentials and the current thread binding. + Manage workspace-local Feishu credentials, sessions, and the current thread binding. › 1. Turn Mode Switch clawbot-originated turns into non-interactive mode so remote sessions do not block on prompts. - 2. Feishu App ID Configured: cli_app_123 - 3. Feishu App Secret Configured: *************4567 - 4. Feishu Verification Token Configured: ********oken - 5. Feishu Encrypt Key Not set - 6. Feishu Bot Open ID Configured: ou_bot_open_id - 7. Feishu Bot User ID Not set - 8. Retry Feishu Connection Unconfigured + Feishu Sessions 2 total · 1 bound · 1 unbound + 3. Scan Feishu Sessions Discover private Feishu chats using the persisted workspace + credentials. + 4. Clear Unbound Sessions Remove 1 unbound sessions and 1 cached unread messages. + Alice bound · 0 unread + Bob unbound · 1 unread + 7. Feishu App ID Configured: cli_app_123 + 8. Feishu App Secret Configured: *************4567 Press enter to confirm or esc to go back From 15ccb40a14947e01cdef526e5e376d25c1064b37 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 19:48:46 +0800 Subject: [PATCH 38/60] replay: restore clawbot provider admin tail --- codex-rs/tui/src/app.rs | 45 ++++++++++++- codex-rs/tui/src/app/clawbot_controls.rs | 65 +++++++++++++++---- ..._app__tests__clawbot_management_popup.snap | 3 +- 3 files changed, 97 insertions(+), 16 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6157370bc..06eb88673 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4807,7 +4807,7 @@ impl App { } AppEvent::SaveClawbotManualBindSessionId { session_id } => { if let Err(err) = self - .save_clawbot_manual_bind_session_id(app_server, session_id) + .bind_clawbot_session_to_current_thread(app_server, session_id) .await { self.chat_widget @@ -12330,7 +12330,7 @@ model = "gpt-5.2" )) .expect("queue unread"); - app.save_clawbot_manual_bind_session_id(&mut app_server, "chat_bind".to_string()) + app.bind_clawbot_session_to_current_thread(&mut app_server, "chat_bind".to_string()) .await .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; @@ -12859,6 +12859,47 @@ model = "gpt-5.2" Ok(()) } + #[tokio::test] + async fn clawbot_rebinds_discovered_session_from_management_actions() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let target_thread_id = started.session.thread_id; + app.active_thread_id = Some(target_thread_id); + + let (source_thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_rebind").await?; + + app.bind_clawbot_session_to_current_thread(&mut app_server, "chat_rebind".to_string()) + .await + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!( + runtime + .bound_session_for_thread(&target_thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + Some(session.clone()) + ); + assert_eq!( + runtime + .bound_session_for_thread(&source_thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + None + ); + Ok(()) + } + #[tokio::test] async fn btw_completion_notification_emits_completion_event_and_is_swallowed() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index da9c30ac7..21685aa2b 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -244,7 +244,7 @@ impl App { Ok(()) } - pub(crate) async fn save_clawbot_manual_bind_session_id( + pub(crate) async fn bind_clawbot_session_to_current_thread( &mut self, app_server: &mut AppServerSession, session_id: String, @@ -529,7 +529,13 @@ impl App { ..Default::default() }); } else { - items.extend(feishu_sessions.iter().map(clawbot_session_item)); + items.extend(feishu_sessions.iter().map(|session| { + clawbot_session_item( + session, + active_thread_id.as_deref(), + current_binding.map(|binding| binding.session_id.as_str()), + ) + })); } items.extend([ @@ -668,20 +674,55 @@ fn connection_description(state: &ProviderRuntimeState) -> String { } } -fn clawbot_session_item(session: &ProviderSession) -> SelectionItem { - let state = if session.bound_thread_id.is_some() { - "bound" +fn clawbot_session_item( + session: &ProviderSession, + active_thread_id: Option<&str>, + current_binding_session_id: Option<&str>, +) -> SelectionItem { + let is_current = current_binding_session_id.is_some_and(|session_id| { + session_id == session.session_id && session.provider == ProviderKind::Feishu + }); + let description = if session.bound_thread_id.is_some() { + format!("bound · {} unread", session.unread_count) } else { - "unbound" + format!("unbound · {} unread", session.unread_count) + }; + let selected_description = match active_thread_id { + Some(thread_id) if is_current => { + format!( + "Thread {thread_id} is already bound to Feishu session {}.", + session.session_id + ) + } + Some(thread_id) => match session.bound_thread_id.as_deref() { + Some(bound_thread_id) if bound_thread_id != thread_id => { + format!( + "Bind current thread {thread_id} to Feishu session {} and move it from thread {bound_thread_id}.", + session.session_id + ) + } + _ => format!( + "Bind current thread {thread_id} directly to Feishu session {}.", + session.session_id + ), + }, + None => format!( + "Open a Codex thread before binding Feishu session {}.", + session.session_id + ), }; + let session_id = session.session_id.clone(); SelectionItem { name: session_title(session), - description: Some(format!("{state} · {} unread", session.unread_count)), - selected_description: Some( - "Discovered session state is read-only here. Use Bind Current Thread to connect a session id." - .to_string(), - ), - is_disabled: true, + description: Some(description), + selected_description: Some(selected_description), + is_disabled: active_thread_id.is_none() || is_current, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SaveClawbotManualBindSessionId { + session_id: session_id.clone(), + }); + })], + dismiss_on_select: false, ..Default::default() } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap index 017a7fc4d..b9841e0f4 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap @@ -1,6 +1,5 @@ --- source: tui/src/app.rs -assertion_line: 12754 expression: popup --- Clawbot @@ -13,7 +12,7 @@ expression: popup credentials. 4. Clear Unbound Sessions Remove 1 unbound sessions and 1 cached unread messages. Alice bound · 0 unread - Bob unbound · 1 unread + 6. Bob unbound · 1 unread 7. Feishu App ID Configured: cli_app_123 8. Feishu App Secret Configured: *************4567 From 0b43376649013d4357f6f682904d2a06877b5178 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 00:31:23 +0800 Subject: [PATCH 39/60] fix: reconcile stale clawbot feishu bindings --- codex-rs/clawbot/src/runtime.rs | 112 ++++++++++++++++++++++- codex-rs/tui/src/app/clawbot.rs | 6 ++ codex-rs/tui/src/app/clawbot_controls.rs | 13 +++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs index d770817ad..b2f2dddc4 100644 --- a/codex-rs/clawbot/src/runtime.rs +++ b/codex-rs/clawbot/src/runtime.rs @@ -75,10 +75,18 @@ impl ClawbotRuntime { let provider = self .feishu_provider() .context("Feishu credentials are not configured")?; - for event in provider.scan_sessions().await? { - self.apply_provider_event(event)?; - } - self.reload() + let discovered_sessions = provider + .scan_sessions() + .await? + .into_iter() + .filter_map(|event| match event { + ProviderEvent::SessionUpserted(session) => Some(session), + ProviderEvent::RuntimeStateUpdated(_) + | ProviderEvent::SessionRemoved(_) + | ProviderEvent::InboundMessage(_) => None, + }) + .collect::>(); + self.reconcile_feishu_sessions(discovered_sessions) } pub fn clear_unbound_feishu_sessions(&mut self) -> Result<&ClawbotSnapshot> { @@ -366,6 +374,50 @@ impl ClawbotRuntime { .filter(|message| message.session_ref() == *session) .count()) } + + fn reconcile_feishu_sessions( + &mut self, + discovered_sessions: Vec, + ) -> Result<&ClawbotSnapshot> { + let discovered_refs = discovered_sessions + .iter() + .map(ProviderSession::session_ref) + .collect::>(); + let bindings = self + .store + .load_bindings()? + .into_iter() + .filter(|binding| { + binding.provider != ProviderKind::Feishu + || discovered_refs.contains(&binding.session_ref()) + }) + .collect::>(); + let sessions = self + .store + .load_sessions()? + .into_iter() + .filter(|session| { + session.provider != ProviderKind::Feishu + || discovered_refs.contains(&session.session_ref()) + }) + .collect::>(); + let unread_messages = self + .store + .load_unread_messages()? + .into_iter() + .filter(|message| { + message.provider != ProviderKind::Feishu + || discovered_refs.contains(&message.session_ref()) + }) + .collect::>(); + self.store.save_bindings(&bindings)?; + self.store.save_sessions(&sessions)?; + self.store.save_unread_messages(&unread_messages)?; + for session in discovered_sessions { + self.apply_provider_event(ProviderEvent::SessionUpserted(session))?; + } + self.reload() + } } fn unix_timestamp_now() -> Result { @@ -692,4 +744,56 @@ mod tests { }] ); } + + #[test] + fn reconcile_feishu_sessions_prunes_missing_bound_sessions_and_unread_cache() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let stale_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_stale"); + let live_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_live"); + + runtime + .connect_session_to_thread(&stale_session, "thread_stale".to_string()) + .expect("bind stale session"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: stale_session, + message_id: "msg_stale".to_string(), + text: "stale".to_string(), + received_at: 1, + })) + .expect("stale unread"); + runtime + .reconcile_feishu_sessions(vec![ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_live".to_string(), + display_name: Some("Live".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }]) + .expect("reconcile discovered sessions"); + + assert_eq!(runtime.snapshot().bindings, Vec::::new()); + assert_eq!( + runtime.snapshot().sessions, + vec![ProviderSession { + provider: ProviderKind::Feishu, + session_id: live_session.session_id, + display_name: Some("Live".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }] + ); + assert_eq!( + runtime + .store() + .load_unread_messages() + .expect("unread messages"), + Vec::::new() + ); + } } diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs index 9a9b8aa58..5017734d3 100644 --- a/codex-rs/tui/src/app/clawbot.rs +++ b/codex-rs/tui/src/app/clawbot.rs @@ -60,6 +60,12 @@ impl App { self.clawbot_workspace_root = Some(workspace_root.clone()); self.clawbot_pending_turns.clear(); self.refresh_clawbot_provider_runtime()?; + if let Ok(mut runtime) = ClawbotRuntime::load(workspace_root.clone()) + && runtime.snapshot().config.feishu.is_some() + && let Err(err) = runtime.scan_feishu_sessions().await + { + tracing::warn!(error = %err, "failed to refresh clawbot Feishu sessions"); + } } let runtime = ClawbotRuntime::load(workspace_root)?; diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index 21685aa2b..624014bdd 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -258,6 +258,19 @@ impl App { } let session = ProviderSessionRef::new(ProviderKind::Feishu, trimmed.clone()); let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + if runtime.snapshot().config.feishu.is_some() { + runtime.scan_feishu_sessions().await?; + if !runtime + .snapshot() + .sessions + .iter() + .any(|existing| existing.session_ref() == session) + { + return Err(anyhow::anyhow!( + "Feishu session {trimmed} is not visible to the current bot" + )); + } + } runtime.connect_session_to_thread(&session, thread_id.to_string())?; self.refresh_clawbot_provider_runtime()?; self.dispatch_next_clawbot_message(app_server, &session) From ff8875c1736b82c185a2f4eacd392692b643df58 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 22:45:40 +0800 Subject: [PATCH 40/60] Fix clawbot Feishu delivery and route /btw through workflow runtime --- .../clawbot/src/bin/feishu-payload-dump.rs | 174 ++++++ codex-rs/clawbot/src/config.rs | 12 + codex-rs/clawbot/src/diagnostics.rs | 50 ++ codex-rs/clawbot/src/lib.rs | 3 + codex-rs/clawbot/src/model.rs | 1 + codex-rs/clawbot/src/provider/feishu.rs | 513 +++++++++++++++--- .../src/provider/feishu/runtime_loop.rs | 48 +- codex-rs/clawbot/src/provider/feishu/sync.rs | 42 +- codex-rs/clawbot/src/runtime.rs | 8 +- codex-rs/tui/src/app/clawbot.rs | 151 +++++- codex-rs/tui/src/app/clawbot_controls.rs | 2 +- ..._app__tests__clawbot_management_popup.snap | 2 +- 12 files changed, 923 insertions(+), 83 deletions(-) create mode 100644 codex-rs/clawbot/src/bin/feishu-payload-dump.rs create mode 100644 codex-rs/clawbot/src/diagnostics.rs diff --git a/codex-rs/clawbot/src/bin/feishu-payload-dump.rs b/codex-rs/clawbot/src/bin/feishu-payload-dump.rs new file mode 100644 index 000000000..b981256d7 --- /dev/null +++ b/codex-rs/clawbot/src/bin/feishu-payload-dump.rs @@ -0,0 +1,174 @@ +use std::env; +use std::fs; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_clawbot::CLAWBOT_RELATIVE_DIR; +use codex_clawbot::ClawbotRuntime; +use codex_clawbot::FeishuConfig; +use open_lark::openlark_client; +use open_lark::openlark_client::ws_client::EventDispatcherHandler; +use open_lark::openlark_client::ws_client::LarkWsClient; +use serde_json::Value; +use tokio::runtime::Builder; +use tokio::sync::mpsc; + +const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(2); +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); +const DEFAULT_DUMP_FILE_NAME: &str = "feishu_payload_dump.jsonl"; + +fn main() -> Result<()> { + Builder::new_current_thread() + .enable_all() + .build() + .context("failed to build tokio runtime")? + .block_on(async_main()) +} + +async fn async_main() -> Result<()> { + let workspace_root = workspace_root_from_args()?; + let runtime = ClawbotRuntime::load(workspace_root.clone()).with_context(|| { + format!( + "failed to load clawbot config from {}", + workspace_root.display() + ) + })?; + let feishu = runtime + .snapshot() + .config + .feishu + .clone() + .context("missing [feishu] config in .codex/clawbot/config.toml")?; + if !feishu.has_api_credentials() { + return Err(anyhow!( + "missing app_id/app_secret in clawbot feishu config" + )); + } + + let dump_path = dump_path_from_args(&workspace_root); + ensure_parent_dir(&dump_path)?; + eprintln!("workspace: {}", workspace_root.display()); + eprintln!("dump file: {}", dump_path.display()); + + let mut reconnect_delay = INITIAL_RECONNECT_DELAY; + loop { + match run_once(&feishu, &dump_path).await { + Ok(()) => { + eprintln!( + "feishu websocket exited; reconnecting in {}s", + reconnect_delay.as_secs() + ); + } + Err(err) => { + eprintln!( + "feishu websocket failed: {err}; reconnecting in {}s", + reconnect_delay.as_secs() + ); + } + } + + tokio::time::sleep(reconnect_delay).await; + reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY); + } +} + +async fn run_once(feishu: &FeishuConfig, dump_path: &Path) -> Result<()> { + let ws_config = Arc::new(build_websocket_config(feishu)?); + let (payload_tx, mut payload_rx) = mpsc::unbounded_channel::>(); + let dump_path = dump_path.to_path_buf(); + let payload_task = tokio::spawn(async move { + while let Some(payload) = payload_rx.recv().await { + if let Err(err) = append_payload_record(&dump_path, &payload) { + eprintln!("failed to write payload record: {err}"); + } + } + }); + + let event_handler = EventDispatcherHandler::builder() + .payload_sender(payload_tx) + .build(); + eprintln!("feishu websocket connected; waiting for events..."); + let result = LarkWsClient::open(ws_config, event_handler) + .await + .map_err(|err| anyhow!("websocket runtime failed: {err}")); + payload_task.abort(); + let _ = payload_task.await; + result +} + +fn workspace_root_from_args() -> Result { + Ok(match env::args().nth(1) { + Some(path) => PathBuf::from(path), + None => env::current_dir().context("failed to resolve current directory")?, + }) +} + +fn dump_path_from_args(workspace_root: &Path) -> PathBuf { + env::args().nth(2).map_or_else( + || { + workspace_root + .join(CLAWBOT_RELATIVE_DIR) + .join(DEFAULT_DUMP_FILE_NAME) + }, + PathBuf::from, + ) +} + +fn ensure_parent_dir(path: &Path) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("path has no parent: {}", path.display()))?; + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display())) +} + +fn build_websocket_config(feishu: &FeishuConfig) -> Result { + openlark_client::Config::builder() + .app_id(feishu.app_id.clone()) + .app_secret(feishu.app_secret.clone()) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|err| anyhow!("failed to build websocket config: {err}")) +} + +fn append_payload_record(dump_path: &Path, payload: &[u8]) -> Result<()> { + let raw_text = String::from_utf8_lossy(payload).into_owned(); + let parsed_payload = serde_json::from_slice::(payload).ok(); + let event_type = parsed_payload + .as_ref() + .and_then(|value| value.pointer("/header/event_type")) + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let recorded_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before UNIX_EPOCH")? + .as_secs() as i64; + let record = match parsed_payload { + Some(payload) => serde_json::json!({ + "recorded_at": recorded_at, + "event_type": event_type, + "payload": payload, + }), + None => serde_json::json!({ + "recorded_at": recorded_at, + "event_type": event_type, + "payload_text": raw_text, + }), + }; + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(dump_path) + .with_context(|| format!("failed to open {}", dump_path.display()))?; + writeln!(file, "{}", serde_json::to_string(&record)?) + .with_context(|| format!("failed to append {}", dump_path.display())) +} diff --git a/codex-rs/clawbot/src/config.rs b/codex-rs/clawbot/src/config.rs index bd16e50b9..ff40a1edb 100644 --- a/codex-rs/clawbot/src/config.rs +++ b/codex-rs/clawbot/src/config.rs @@ -38,6 +38,18 @@ impl FeishuConfig { !self.app_id.trim().is_empty() && !self.app_secret.trim().is_empty() } + pub fn is_bot_sender(&self, open_id: Option<&str>, user_id: Option<&str>) -> bool { + self.bot_open_id + .as_deref() + .zip(open_id) + .is_some_and(|(bot_open_id, sender_open_id)| bot_open_id == sender_open_id) + || self + .bot_user_id + .as_deref() + .zip(user_id) + .is_some_and(|(bot_user_id, sender_user_id)| bot_user_id == sender_user_id) + } + pub fn is_empty(&self) -> bool { self.app_id.trim().is_empty() && self.app_secret.trim().is_empty() diff --git a/codex-rs/clawbot/src/diagnostics.rs b/codex-rs/clawbot/src/diagnostics.rs new file mode 100644 index 000000000..bd32bfe1d --- /dev/null +++ b/codex-rs/clawbot/src/diagnostics.rs @@ -0,0 +1,50 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Context; +use anyhow::Result; +use serde::Serialize; + +use crate::model::CLAWBOT_DIAGNOSTICS_RELATIVE_PATH; + +#[derive(Debug, Serialize)] +struct DiagnosticEvent { + ts_ms: i64, + kind: String, + payload: T, +} + +pub fn append_diagnostic_event(workspace_root: &Path, kind: &str, payload: T) -> Result<()> +where + T: Serialize, +{ + let path = workspace_root.join(CLAWBOT_DIAGNOSTICS_RELATIVE_PATH); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let event = DiagnosticEvent { + ts_ms: unix_timestamp_ms_now()?, + kind: kind.to_string(), + payload, + }; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("failed to open {}", path.display()))?; + serde_json::to_writer(&mut file, &event) + .context("failed to encode clawbot diagnostic event")?; + file.write_all(b"\n") + .with_context(|| format!("failed to append {}", path.display())) +} + +fn unix_timestamp_ms_now() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before unix epoch")? + .as_millis() as i64) +} diff --git a/codex-rs/clawbot/src/lib.rs b/codex-rs/clawbot/src/lib.rs index de3f743c6..b4ba835ee 100644 --- a/codex-rs/clawbot/src/lib.rs +++ b/codex-rs/clawbot/src/lib.rs @@ -1,4 +1,5 @@ mod config; +mod diagnostics; mod events; mod model; mod provider; @@ -8,9 +9,11 @@ mod store; pub use config::ClawbotConfig; pub use config::ClawbotTurnMode; pub use config::FeishuConfig; +pub use diagnostics::append_diagnostic_event; pub use events::ProviderInboundMessage; pub use model::CLAWBOT_BINDINGS_RELATIVE_PATH; pub use model::CLAWBOT_CONFIG_RELATIVE_PATH; +pub use model::CLAWBOT_DIAGNOSTICS_RELATIVE_PATH; pub use model::CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH; pub use model::CLAWBOT_RELATIVE_DIR; pub use model::CLAWBOT_RUNTIME_RELATIVE_PATH; diff --git a/codex-rs/clawbot/src/model.rs b/codex-rs/clawbot/src/model.rs index 1cf66283f..3f32e587c 100644 --- a/codex-rs/clawbot/src/model.rs +++ b/codex-rs/clawbot/src/model.rs @@ -10,6 +10,7 @@ pub const CLAWBOT_BINDINGS_RELATIVE_PATH: &str = ".codex/clawbot/bindings.json"; pub const CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH: &str = ".codex/clawbot/unread_messages.jsonl"; pub const CLAWBOT_RUNTIME_RELATIVE_PATH: &str = ".codex/clawbot/runtime.json"; pub const CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH: &str = ".codex/clawbot/inbound_receipts.json"; +pub const CLAWBOT_DIAGNOSTICS_RELATIVE_PATH: &str = ".codex/clawbot/diagnostics.jsonl"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct ClawbotSnapshot { diff --git a/codex-rs/clawbot/src/provider/feishu.rs b/codex-rs/clawbot/src/provider/feishu.rs index e97981b6f..030481248 100644 --- a/codex-rs/clawbot/src/provider/feishu.rs +++ b/codex-rs/clawbot/src/provider/feishu.rs @@ -1,6 +1,8 @@ mod runtime_loop; mod sync; +use std::path::Path; +use std::path::PathBuf; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -22,6 +24,7 @@ use tokio::sync::mpsc; use super::ProviderEvent; use super::ProviderOutboundReaction; use super::ProviderOutboundTextMessage; +use crate::append_diagnostic_event; use crate::config::FeishuConfig; use crate::events::ProviderInboundMessage; use crate::model::ConnectionStatus; @@ -32,30 +35,34 @@ use crate::model::ProviderSessionRef; use crate::model::SessionStatus; #[derive(Debug, Clone)] -pub struct FeishuInboundPrivateMessage { +pub struct FeishuInboundMessage { pub chat_id: String, pub chat_type: String, + pub chat_name: Option, pub message_id: String, pub sender_open_id: Option, pub sender_user_id: Option, pub sender_union_id: Option, - pub sender_name: Option, pub text: String, pub received_at: i64, } #[derive(Debug, Clone)] pub struct FeishuProviderRuntime { + workspace_root: PathBuf, config: FeishuConfig, } impl FeishuProviderRuntime { - pub fn new(config: FeishuConfig) -> Self { - Self { config } + pub fn new(workspace_root: impl Into, config: FeishuConfig) -> Self { + Self { + workspace_root: workspace_root.into(), + config, + } } pub async fn run(self, provider_event_tx: mpsc::UnboundedSender) -> Result<()> { - runtime_loop::run_with_reconnect(self.config, provider_event_tx).await + runtime_loop::run_with_reconnect(self.workspace_root, self.config, provider_event_tx).await } pub async fn send_text(&self, message: ProviderOutboundTextMessage) -> Result<()> { @@ -66,18 +73,43 @@ impl FeishuProviderRuntime { )); } - let body = CreateMessageBody { - receive_id: message.session.session_id, - msg_type: "text".to_string(), - content: serde_json::to_string(&serde_json::json!({ "text": message.text }))?, - uuid: None, - }; - CreateMessageRequest::new(self.messaging_config()?) + let text = message.text; + let session_id = message.session.session_id; + let response = CreateMessageRequest::new(self.messaging_config()?) .receive_id_type(ReceiveIdType::ChatId) - .execute(body) + .execute(CreateMessageBody { + receive_id: session_id.clone(), + msg_type: "text".to_string(), + content: serde_json::to_string(&serde_json::json!({ "text": text.clone() }))?, + uuid: None, + }) .await - .map_err(|error| anyhow!("failed to send Feishu text message: {error}"))?; - Ok(()) + .map_err(|error| anyhow!("failed to send Feishu text message: {error}")); + match response { + Ok(_) => { + let _ = append_diagnostic_event( + self.workspace_root.as_path(), + "feishu.send_text_succeeded", + serde_json::json!({ + "session_id": session_id, + "text": text, + }), + ); + Ok(()) + } + Err(error) => { + let _ = append_diagnostic_event( + self.workspace_root.as_path(), + "feishu.send_text_failed", + serde_json::json!({ + "session_id": session_id, + "text": text, + "error": error.to_string(), + }), + ); + Err(error) + } + } } pub async fn add_reaction(&self, reaction: ProviderOutboundReaction) -> Result<()> { @@ -118,28 +150,31 @@ impl FeishuProviderRuntime { } pub async fn scan_sessions(&self) -> Result> { - let sessions = sync::discover_private_sessions(&self.messaging_config()?).await?; + let sessions = sync::discover_supported_sessions(&self.messaging_config()?).await?; Ok(sessions .into_iter() .map(ProviderEvent::SessionUpserted) .collect()) } - pub fn normalize_private_chat_message( - message: FeishuInboundPrivateMessage, - ) -> Option> { - if !is_private_chat_type(&message.chat_type) || message.text.trim().is_empty() { + pub fn normalize_chat_message(message: FeishuInboundMessage) -> Option> { + if !is_supported_chat_type(&message.chat_type) || message.text.trim().is_empty() { return None; } + let display_name = if is_group_chat_type(&message.chat_type) { + message.chat_name + } else { + message + .chat_name + .or(message.sender_open_id.clone()) + .or(message.sender_user_id.clone()) + .or(message.sender_union_id.clone()) + }; let session = ProviderSession { provider: ProviderKind::Feishu, session_id: message.chat_id.clone(), - display_name: message - .sender_name - .or(message.sender_open_id.clone()) - .or(message.sender_user_id.clone()) - .or(message.sender_union_id.clone()), + display_name, unread_count: 0, last_message_at: Some(message.received_at), status: SessionStatus::Discovered, @@ -179,8 +214,19 @@ pub fn failure_reply_text(message: &str) -> String { format!("Request failed: {truncated}") } -pub(super) fn provider_events_from_payload(payload: &[u8]) -> Vec { +pub(super) fn provider_events_from_payload( + payload: &[u8], + config: &FeishuConfig, + workspace_root: &Path, +) -> Vec { let Ok(envelope) = serde_json::from_slice::(payload) else { + let _ = append_diagnostic_event( + workspace_root, + "feishu.payload_parse_failed", + serde_json::json!({ + "payload": String::from_utf8_lossy(payload), + }), + ); return Vec::new(); }; @@ -189,7 +235,11 @@ pub(super) fn provider_events_from_payload(payload: &[u8]) -> Vec serde_json::from_value::(envelope.event) .ok() .and_then(|event| { - normalize_message_receive_event(FeishuMessageReceiveEnvelope { event }) + normalize_message_receive_event( + FeishuMessageReceiveEnvelope { event }, + config, + workspace_root, + ) }) .unwrap_or_default() } @@ -199,16 +249,99 @@ pub(super) fn provider_events_from_payload(payload: &[u8]) -> Vec .map(|event| normalize_chat_entered_event(FeishuChatEnteredEnvelope { event })) .unwrap_or_default() } - _ => Vec::new(), + _ => { + let _ = append_diagnostic_event( + workspace_root, + "feishu.unsupported_event", + serde_json::json!({ + "event_type": envelope.header.event_type, + }), + ); + Vec::new() + } } } fn normalize_message_receive_event( envelope: FeishuMessageReceiveEnvelope, + config: &FeishuConfig, + workspace_root: &Path, ) -> Option> { let chat = envelope.event.chat; let message = envelope.event.message; - if !is_private_chat_type(&message.chat_type) || message.message_type != "text" { + let chat_type = message + .chat_type + .clone() + .or(chat.as_ref().and_then(|chat| chat.chat_type.clone())); + let message_type = message + .message_type + .as_deref() + .or(message.msg_type.as_deref()); + let sender = envelope.event.sender.sender_id; + let Some(chat_type) = chat_type else { + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_dropped", + serde_json::json!({ + "reason": "missing_chat_type", + "message_id": message.message_id, + }), + ); + return None; + }; + let Some(message_type) = message_type else { + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_dropped", + serde_json::json!({ + "reason": "missing_message_type", + "chat_id": message.chat_id, + "message_id": message.message_id, + "chat_type": chat_type, + }), + ); + return None; + }; + if !is_supported_chat_type(&chat_type) { + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_dropped", + serde_json::json!({ + "reason": "unsupported_chat_type", + "chat_id": message.chat_id, + "message_id": message.message_id, + "chat_type": chat_type, + }), + ); + return None; + } + if message_type != "text" { + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_dropped", + serde_json::json!({ + "reason": "unsupported_message_type", + "chat_id": message.chat_id, + "message_id": message.message_id, + "chat_type": chat_type, + "message_type": message_type, + }), + ); + return None; + } + if config.is_bot_sender(sender.open_id.as_deref(), sender.user_id.as_deref()) { + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_dropped", + serde_json::json!({ + "reason": "bot_sender", + "chat_id": message.chat_id, + "message_id": message.message_id, + "chat_type": chat_type, + "sender_open_id": sender.open_id, + "sender_user_id": sender.user_id, + }), + ); return None; } @@ -216,21 +349,40 @@ fn normalize_message_receive_event( .as_ref() .map(|chat| chat.chat_id.clone()) .or(message.chat_id.clone())?; - let text = serde_json::from_str::(&message.content) + let raw_content = message + .content + .or(message.body.and_then(|body| body.content)) + .unwrap_or_default(); + let text = serde_json::from_str::(&raw_content) .ok() .map(|content| content.text) - .unwrap_or_default(); - let received_at = parse_optional_timestamp(Some(message.create_time))?; - - FeishuProviderRuntime::normalize_private_chat_message(FeishuInboundPrivateMessage { + .unwrap_or(raw_content); + let normalized_text = if is_group_chat_type(&chat_type) { + strip_group_mention_prefix(&text) + } else { + text + }; + let received_at = parse_timestamp_value(message.create_time)?; + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_normalized", + serde_json::json!({ + "chat_id": chat_id.clone(), + "chat_type": chat_type.clone(), + "message_id": message.message_id.clone(), + "text": normalized_text.clone(), + }), + ); + + FeishuProviderRuntime::normalize_chat_message(FeishuInboundMessage { chat_id, - chat_type: message.chat_type, + chat_type, + chat_name: chat.and_then(|chat| chat.name), message_id: message.message_id, - sender_open_id: envelope.event.sender.sender_id.open_id, - sender_user_id: envelope.event.sender.sender_id.user_id, - sender_union_id: envelope.event.sender.sender_id.union_id, - sender_name: chat.and_then(|chat| chat.name), - text, + sender_open_id: sender.open_id, + sender_user_id: sender.user_id, + sender_union_id: sender.union_id, + text: normalized_text, received_at, }) } @@ -256,10 +408,57 @@ fn parse_optional_timestamp(timestamp: Option) -> Option { timestamp.and_then(|value| value.parse::().ok()) } +fn parse_timestamp_value(timestamp: serde_json::Value) -> Option { + match timestamp { + serde_json::Value::String(value) => parse_optional_timestamp(Some(value)), + serde_json::Value::Number(value) => value.as_i64(), + _ => None, + } +} + +fn is_supported_chat_type(chat_type: &str) -> bool { + is_private_chat_type(chat_type) || is_group_chat_type(chat_type) +} + fn is_private_chat_type(chat_type: &str) -> bool { matches!(chat_type, "p2p" | "private") } +fn is_group_chat_type(chat_type: &str) -> bool { + matches!(chat_type, "group") +} + +fn strip_group_mention_prefix(text: &str) -> String { + let mut remaining = text.trim_start(); + let mut stripped = false; + + loop { + let Some(after_at) = remaining.strip_prefix('@') else { + break; + }; + let mention_len = after_at + .char_indices() + .find_map(|(idx, ch)| { + (ch.is_whitespace() || matches!(ch, ':' | ':' | ',' | ',')).then_some(idx) + }) + .unwrap_or(after_at.len()); + if mention_len == 0 { + break; + } + remaining = &after_at[mention_len..]; + remaining = remaining.trim_start_matches(|ch: char| { + ch.is_whitespace() || matches!(ch, ':' | ':' | ',' | ',') + }); + stripped = true; + } + + if stripped { + remaining.to_string() + } else { + text.to_string() + } +} + pub(super) fn runtime_state( connection: ConnectionStatus, last_error: Option, @@ -325,18 +524,27 @@ struct FeishuUserId { #[derive(Debug, Deserialize)] struct FeishuEventMessage { message_id: String, - create_time: String, + create_time: serde_json::Value, #[serde(default)] chat_id: Option, - chat_type: String, - message_type: String, - content: String, + #[serde(default)] + chat_type: Option, + #[serde(default)] + message_type: Option, + #[serde(default)] + msg_type: Option, + #[serde(default)] + content: Option, + #[serde(default)] + body: Option, } #[derive(Debug, Deserialize)] struct FeishuEventChat { chat_id: String, #[serde(default)] + chat_type: Option, + #[serde(default)] name: Option, } @@ -345,6 +553,11 @@ struct FeishuTextContent { text: String, } +#[derive(Debug, Deserialize)] +struct FeishuEventMessageBody { + content: Option, +} + #[derive(Debug, Deserialize)] struct FeishuChatEnteredEnvelope { event: FeishuChatEnteredEvent, @@ -359,12 +572,16 @@ struct FeishuChatEnteredEvent { #[cfg(test)] mod tests { + use std::path::Path; + use pretty_assertions::assert_eq; - use super::FeishuInboundPrivateMessage; + use super::FeishuInboundMessage; use super::failure_reply_text; use super::normalize_chat_entered_event; use super::normalize_message_receive_event; + use super::strip_group_mention_prefix; + use crate::config::FeishuConfig; use crate::model::ProviderKind; use crate::model::ProviderSession; use crate::model::ProviderSessionRef; @@ -372,20 +589,18 @@ mod tests { use crate::provider::ProviderEvent; #[test] - fn normalize_private_chat_message_creates_session_and_inbound_events() { - let events = super::FeishuProviderRuntime::normalize_private_chat_message( - FeishuInboundPrivateMessage { - chat_id: "chat_123".to_string(), - chat_type: "p2p".to_string(), - message_id: "msg_123".to_string(), - sender_open_id: Some("ou_123".to_string()), - sender_user_id: None, - sender_union_id: None, - sender_name: Some("Alice".to_string()), - text: "hello".to_string(), - received_at: 123, - }, - ) + fn normalize_chat_message_creates_session_and_inbound_events() { + let events = super::FeishuProviderRuntime::normalize_chat_message(FeishuInboundMessage { + chat_id: "chat_123".to_string(), + chat_type: "p2p".to_string(), + chat_name: Some("Alice".to_string()), + message_id: "msg_123".to_string(), + sender_open_id: Some("ou_123".to_string()), + sender_user_id: None, + sender_union_id: None, + text: "hello".to_string(), + received_at: 123, + }) .expect("events"); assert_eq!( @@ -423,17 +638,171 @@ mod tests { }, message: super::FeishuEventMessage { message_id: "msg_123".to_string(), - create_time: "456".to_string(), + create_time: serde_json::json!("456"), chat_id: Some("chat_123".to_string()), - chat_type: "p2p".to_string(), - message_type: "image".to_string(), - content: "{}".to_string(), + chat_type: Some("p2p".to_string()), + message_type: Some("image".to_string()), + msg_type: None, + content: Some("{}".to_string()), + body: None, }, chat: None, }, }; - assert_eq!(normalize_message_receive_event(envelope), None); + assert_eq!( + normalize_message_receive_event(envelope, &FeishuConfig::default(), Path::new("/tmp")), + None + ); + } + + #[test] + fn message_receive_event_accepts_group_text_messages() { + let envelope = super::FeishuMessageReceiveEnvelope { + event: super::FeishuMessageReceiveEvent { + sender: super::FeishuEventSender { + sender_id: super::FeishuUserId { + open_id: Some("ou_member".to_string()), + user_id: None, + union_id: None, + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_group_1".to_string(), + create_time: serde_json::json!("456"), + chat_id: Some("chat_group_123".to_string()), + chat_type: Some("group".to_string()), + message_type: Some("text".to_string()), + msg_type: None, + content: Some("{\"text\":\"hello group\"}".to_string()), + body: None, + }, + chat: Some(super::FeishuEventChat { + chat_id: "chat_group_123".to_string(), + chat_type: Some("group".to_string()), + name: Some("tracker".to_string()), + }), + }, + }; + + assert_eq!( + normalize_message_receive_event(envelope, &FeishuConfig::default(), Path::new("/tmp")), + Some(vec![ + ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_group_123".to_string(), + display_name: Some("tracker".to_string()), + unread_count: 0, + last_message_at: Some(456), + status: SessionStatus::Discovered, + bound_thread_id: None, + }), + ProviderEvent::InboundMessage(crate::events::ProviderInboundMessage { + session: ProviderSessionRef::new(ProviderKind::Feishu, "chat_group_123"), + message_id: "msg_group_1".to_string(), + text: "hello group".to_string(), + received_at: 456, + }), + ]) + ); + } + + #[test] + fn message_receive_event_skips_bot_authored_messages() { + let envelope = super::FeishuMessageReceiveEnvelope { + event: super::FeishuMessageReceiveEvent { + sender: super::FeishuEventSender { + sender_id: super::FeishuUserId { + open_id: Some("ou_bot".to_string()), + user_id: None, + union_id: None, + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_bot_1".to_string(), + create_time: serde_json::json!("456"), + chat_id: Some("chat_group_123".to_string()), + chat_type: Some("group".to_string()), + message_type: Some("text".to_string()), + msg_type: None, + content: Some("{\"text\":\"hello group\"}".to_string()), + body: None, + }, + chat: Some(super::FeishuEventChat { + chat_id: "chat_group_123".to_string(), + chat_type: Some("group".to_string()), + name: Some("tracker".to_string()), + }), + }, + }; + + assert_eq!( + normalize_message_receive_event( + envelope, + &FeishuConfig { + bot_open_id: Some("ou_bot".to_string()), + ..FeishuConfig::default() + }, + Path::new("/tmp"), + ), + None + ); + } + + #[test] + fn message_receive_event_uses_chat_fallbacks_for_group_payloads() { + let envelope = super::FeishuMessageReceiveEnvelope { + event: super::FeishuMessageReceiveEvent { + sender: super::FeishuEventSender { + sender_id: super::FeishuUserId { + open_id: Some("ou_member".to_string()), + user_id: None, + union_id: None, + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_group_fallback_1".to_string(), + create_time: serde_json::json!(456), + chat_id: None, + chat_type: None, + message_type: None, + msg_type: Some("text".to_string()), + content: None, + body: Some(super::FeishuEventMessageBody { + content: Some("{\"text\":\"hello fallback\"}".to_string()), + }), + }, + chat: Some(super::FeishuEventChat { + chat_id: "chat_group_fallback_123".to_string(), + chat_type: Some("group".to_string()), + name: Some("tracker".to_string()), + }), + }, + }; + + assert_eq!( + normalize_message_receive_event(envelope, &FeishuConfig::default(), Path::new("/tmp")), + Some(vec![ + ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_group_fallback_123".to_string(), + display_name: Some("tracker".to_string()), + unread_count: 0, + last_message_at: Some(456), + status: SessionStatus::Discovered, + bound_thread_id: None, + }), + ProviderEvent::InboundMessage(crate::events::ProviderInboundMessage { + session: ProviderSessionRef::new( + ProviderKind::Feishu, + "chat_group_fallback_123", + ), + message_id: "msg_group_fallback_1".to_string(), + text: "hello fallback".to_string(), + received_at: 456, + }), + ]) + ); } #[test] @@ -471,4 +840,20 @@ mod tests { "Request failed: boom".to_string() ); } + + #[test] + fn strip_group_mention_prefix_removes_leading_mentions() { + assert_eq!( + strip_group_mention_prefix("@_user_1 TRACKER TEST 2"), + "TRACKER TEST 2".to_string() + ); + assert_eq!( + strip_group_mention_prefix("@bot: hello"), + "hello".to_string() + ); + assert_eq!( + strip_group_mention_prefix("@bot @helper ping"), + "ping".to_string() + ); + } } diff --git a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs index 7b4fa59d8..ba5ad5521 100644 --- a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs +++ b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs @@ -1,3 +1,5 @@ +use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -10,6 +12,7 @@ use tokio::sync::mpsc; use super::provider_events_from_payload; use super::runtime_state; +use crate::append_diagnostic_event; use crate::config::FeishuConfig; use crate::model::ConnectionStatus; use crate::provider::ProviderEvent; @@ -18,6 +21,7 @@ const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(2); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); pub(super) async fn run_with_reconnect( + workspace_root: PathBuf, config: FeishuConfig, provider_event_tx: mpsc::UnboundedSender, ) -> Result<()> { @@ -31,8 +35,15 @@ pub(super) async fn run_with_reconnect( let mut reconnect_delay = INITIAL_RECONNECT_DELAY; loop { - match run_once(&config, &provider_event_tx).await { + match run_once(workspace_root.as_path(), &config, &provider_event_tx).await { Ok(()) => { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "feishu.runtime_disconnected", + serde_json::json!({ + "reconnect_delay_secs": reconnect_delay.as_secs(), + }), + ); let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( ConnectionStatus::Disconnected, Some(format!( @@ -42,6 +53,14 @@ pub(super) async fn run_with_reconnect( )?)); } Err(error) => { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "feishu.runtime_failed", + serde_json::json!({ + "error": error.to_string(), + "reconnect_delay_secs": reconnect_delay.as_secs(), + }), + ); let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( ConnectionStatus::Error, Some(format!( @@ -58,6 +77,7 @@ pub(super) async fn run_with_reconnect( } async fn run_once( + workspace_root: &Path, config: &FeishuConfig, provider_event_tx: &mpsc::UnboundedSender, ) -> Result<()> { @@ -72,14 +92,30 @@ async fn run_once( .payload_sender(payload_tx) .build(); let payload_provider_event_tx = provider_event_tx.clone(); + let payload_config = config.clone(); + let payload_workspace_root = workspace_root.to_path_buf(); let payload_task = tokio::spawn(async move { while let Some(payload) = payload_rx.recv().await { - for event in provider_events_from_payload(&payload) { + let _ = append_diagnostic_event( + payload_workspace_root.as_path(), + "feishu.raw_payload", + payload_debug_value(&payload), + ); + for event in provider_events_from_payload( + &payload, + &payload_config, + payload_workspace_root.as_path(), + ) { let _ = payload_provider_event_tx.send(event); } } }); + let _ = append_diagnostic_event( + workspace_root, + "feishu.runtime_connected", + serde_json::json!({}), + ); let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( ConnectionStatus::Connected, None, @@ -98,3 +134,11 @@ pub(super) fn build_websocket_config(config: &FeishuConfig) -> Result serde_json::Value { + serde_json::from_slice(payload).unwrap_or_else(|_| { + serde_json::json!({ + "raw": String::from_utf8_lossy(payload), + }) + }) +} diff --git a/codex-rs/clawbot/src/provider/feishu/sync.rs b/codex-rs/clawbot/src/provider/feishu/sync.rs index a51d1cec7..a8b6a0aff 100644 --- a/codex-rs/clawbot/src/provider/feishu/sync.rs +++ b/codex-rs/clawbot/src/provider/feishu/sync.rs @@ -10,7 +10,7 @@ use crate::model::ProviderKind; use crate::model::ProviderSession; use crate::model::SessionStatus; -pub(super) async fn discover_private_sessions( +pub(super) async fn discover_supported_sessions( config: &open_lark::openlark_core::config::Config, ) -> Result> { let mut sessions = Vec::new(); @@ -21,7 +21,7 @@ pub(super) async fn discover_private_sessions( let next_page_token = response.page_token.clone(); for chat in response.items { - if let Some(session) = load_private_session(config, chat).await? { + if let Some(session) = load_supported_session(config, chat).await? { sessions.push(session); } } @@ -60,7 +60,7 @@ async fn list_chat_page( serde_json::from_value(response).context("failed to parse Feishu chat list response") } -async fn load_private_session( +async fn load_supported_session( config: &open_lark::openlark_core::config::Config, chat: FeishuChatListItem, ) -> Result> { @@ -74,7 +74,7 @@ async fn load_private_session( let details: FeishuChatDetails = serde_json::from_value(response) .with_context(|| format!("failed to parse Feishu chat details for {chat_id}"))?; - if !is_private_chat(&details) { + if !is_supported_chat(&details) { return Ok(None); } @@ -97,12 +97,22 @@ fn first_non_empty(values: [Option; 2]) -> Option { .find(|value| !value.is_empty()) } +fn is_supported_chat(details: &FeishuChatDetails) -> bool { + is_private_chat(details) || is_group_chat(details) +} + fn is_private_chat(details: &FeishuChatDetails) -> bool { details.chat_type.as_deref() == Some("private") || details.chat_mode.as_deref() == Some("p2p") || details.r#type.as_deref() == Some("p2p") } +fn is_group_chat(details: &FeishuChatDetails) -> bool { + details.chat_type.as_deref() == Some("group") + || details.chat_mode.as_deref() == Some("group") + || details.r#type.as_deref() == Some("group") +} + #[derive(Debug, Deserialize, PartialEq, Eq)] struct FeishuChatListResponse { #[serde(default)] @@ -136,7 +146,9 @@ mod tests { use super::FeishuChatListItem; use super::FeishuChatListResponse; use super::first_non_empty; + use super::is_group_chat; use super::is_private_chat; + use super::is_supported_chat; #[test] fn parse_list_chat_response_with_items() { @@ -211,6 +223,28 @@ mod tests { ); } + #[test] + fn group_chat_detection_accepts_group_variants() { + assert_eq!( + is_group_chat(&FeishuChatDetails { + name: Some("tracker".to_string()), + chat_mode: Some("group".to_string()), + chat_type: Some("group".to_string()), + r#type: None, + }), + true + ); + assert_eq!( + is_supported_chat(&FeishuChatDetails { + name: Some("tracker".to_string()), + chat_mode: None, + chat_type: Some("group".to_string()), + r#type: None, + }), + true + ); + } + #[test] fn first_non_empty_skips_blank_values() { assert_eq!( diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs index b2f2dddc4..51abe4ea1 100644 --- a/codex-rs/clawbot/src/runtime.rs +++ b/codex-rs/clawbot/src/runtime.rs @@ -49,11 +49,9 @@ impl ClawbotRuntime { } pub fn feishu_provider(&self) -> Option { - self.snapshot - .config - .feishu - .clone() - .map(FeishuProviderRuntime::new) + self.snapshot.config.feishu.clone().map(|config| { + FeishuProviderRuntime::new(self.store.workspace_root().to_path_buf(), config) + }) } pub fn update_feishu_config( diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs index 5017734d3..1798d93bc 100644 --- a/codex-rs/tui/src/app/clawbot.rs +++ b/codex-rs/tui/src/app/clawbot.rs @@ -14,6 +14,7 @@ use codex_clawbot::ProviderMessageRef; use codex_clawbot::ProviderOutboundReaction; use codex_clawbot::ProviderOutboundTextMessage; use codex_clawbot::ProviderSessionRef; +use codex_clawbot::append_diagnostic_event; use codex_clawbot::feishu_failure_reply_text; use codex_protocol::ThreadId; use codex_protocol::protocol::AskForApproval; @@ -88,6 +89,7 @@ impl App { self.abort_clawbot_provider_runtime(); let app_event_tx = self.app_event_tx.clone(); let workspace = workspace_root.display().to_string(); + let workspace_root = workspace_root.to_path_buf(); self.clawbot_provider_task = Some(tokio::spawn(async move { let (provider_event_tx, mut provider_event_rx) = mpsc::unbounded_channel(); let forward_task = tokio::spawn(async move { @@ -95,7 +97,7 @@ impl App { app_event_tx.send(AppEvent::ClawbotProviderEvent { event }); } }); - if let Err(err) = FeishuProviderRuntime::new(config) + if let Err(err) = FeishuProviderRuntime::new(workspace_root.to_path_buf(), config) .run(provider_event_tx) .await { @@ -135,7 +137,18 @@ impl App { return Ok(()); }; let session_to_drain = match &event { - ProviderEvent::InboundMessage(message) => Some(message.session.clone()), + ProviderEvent::InboundMessage(message) => { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.inbound_message_received", + serde_json::json!({ + "session_id": message.session.session_id, + "message_id": message.message_id, + "text": message.text, + }), + ); + Some(message.session.clone()) + } _ => None, }; let mut runtime = ClawbotRuntime::load(workspace_root)?; @@ -159,7 +172,48 @@ impl App { let next_session = if let Some(pending) = self.take_pending_clawbot_turn(thread_id, &turn.id) { - if let Some(text) = clawbot_outbound_text_for_turn(&turn) { + let reply_text = if let Some(text) = clawbot_outbound_text_for_turn(&turn) { + Some(text) + } else if matches!( + turn.status, + codex_app_server_protocol::TurnStatus::Completed + | codex_app_server_protocol::TurnStatus::Failed + | codex_app_server_protocol::TurnStatus::Interrupted + ) { + match app_server + .thread_read(thread_id, /*include_turns*/ true) + .await + { + Ok(thread) => thread + .turns + .iter() + .find(|thread_turn| thread_turn.id == turn.id) + .and_then(clawbot_outbound_text_for_turn), + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + turn_id = turn.id, + error = %err, + "failed to load full turn for clawbot outbound reply" + ); + None + } + } + } else { + None + }; + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.turn_completed", + serde_json::json!({ + "session_id": pending.session.session_id.clone(), + "thread_id": thread_id, + "turn_id": turn.id, + "status": format!("{:?}", turn.status), + "reply_text": reply_text.clone(), + }), + ); + if let Some(text) = reply_text { self.send_clawbot_thread_reply(workspace_root.as_path(), &pending.session, text) .await?; } @@ -184,20 +238,55 @@ impl App { let Some(workspace_root) = self.clawbot_workspace_root.clone() else { return Ok(()); }; - let mut runtime = ClawbotRuntime::load(workspace_root)?; + let mut runtime = ClawbotRuntime::load(workspace_root.clone())?; let Some(binding) = runtime.load_binding_for_session(session)? else { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.dispatch_skipped", + serde_json::json!({ + "reason": "missing_binding", + "session_id": session.session_id, + }), + ); return Ok(()); }; if !binding.inbound_forwarding_enabled { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.dispatch_skipped", + serde_json::json!({ + "reason": "inbound_forwarding_disabled", + "session_id": session.session_id, + "thread_id": binding.thread_id, + }), + ); return Ok(()); } let thread_id = ThreadId::from_string(&binding.thread_id) .with_context(|| format!("invalid clawbot thread id `{}`", binding.thread_id))?; if self.active_turn_id_for_thread(thread_id).await.is_some() { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.dispatch_skipped", + serde_json::json!({ + "reason": "thread_busy", + "session_id": session.session_id, + "thread_id": thread_id, + }), + ); return Ok(()); } let Some(message) = next_unread_message_for_session(&runtime, session) else { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.dispatch_skipped", + serde_json::json!({ + "reason": "no_unread_message", + "session_id": session.session_id, + "thread_id": thread_id, + }), + ); return Ok(()); }; let turn_mode = runtime.snapshot().config.turn_mode; @@ -229,6 +318,17 @@ impl App { turn_mode, ) .await?; + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.thread_turn_started", + serde_json::json!({ + "session_id": session.session_id, + "thread_id": thread_id, + "turn_id": turn_id.clone(), + "message_id": message.message_id.clone(), + "text": message.text.clone(), + }), + ); self.register_pending_clawbot_turn(thread_id, session.clone(), turn_id, turn_mode); let removed = runtime.take_next_unread_message(session)?; if removed.as_ref().map(|entry| entry.message_id.as_str()) @@ -362,9 +462,28 @@ impl App { ) -> Result<()> { let runtime = ClawbotRuntime::load(workspace_root.to_path_buf())?; let Some(binding) = runtime.load_binding_for_session(session)? else { + let _ = append_diagnostic_event( + workspace_root, + "bridge.reply_skipped", + serde_json::json!({ + "reason": "missing_binding", + "session_id": session.session_id, + "text": text.clone(), + }), + ); return Ok(()); }; if !binding.outbound_forwarding_enabled { + let _ = append_diagnostic_event( + workspace_root, + "bridge.reply_skipped", + serde_json::json!({ + "reason": "outbound_forwarding_disabled", + "session_id": session.session_id, + "thread_id": binding.thread_id, + "text": text.clone(), + }), + ); return Ok(()); } self.send_clawbot_outbound_text( @@ -395,7 +514,24 @@ impl App { let provider = runtime .feishu_provider() .context("missing Feishu config for clawbot outbound bridge")?; - provider.send_text(message).await + let session_id = message.session.session_id.clone(); + let text = message.text.clone(); + let send_result = provider.send_text(message).await; + let kind = if send_result.is_ok() { + "bridge.reply_forwarded" + } else { + "bridge.reply_forward_failed" + }; + let _ = append_diagnostic_event( + workspace_root, + kind, + serde_json::json!({ + "session_id": session_id, + "text": text, + "error": send_result.as_ref().err().map(ToString::to_string), + }), + ); + send_result } } @@ -567,7 +703,10 @@ fn clawbot_outbound_text_for_turn(turn: &codex_app_server_protocol::Turn) -> Opt .error .as_ref() .map(|error| feishu_failure_reply_text(&error.message)), - codex_app_server_protocol::TurnStatus::Interrupted => None, + codex_app_server_protocol::TurnStatus::Interrupted => { + super::last_agent_message_for_turn(turn) + .or_else(|| Some("Request interrupted.".to_string())) + } codex_app_server_protocol::TurnStatus::InProgress => None, } } diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index 624014bdd..79ef868b6 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -499,7 +499,7 @@ impl App { SelectionItem { name: "Scan Feishu Sessions".to_string(), description: Some( - "Discover private Feishu chats using the persisted workspace credentials." + "Discover Feishu chats and bot groups using the persisted workspace credentials." .to_string(), ), selected_description: Some( diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap index b9841e0f4..cd5936e6c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap @@ -8,7 +8,7 @@ expression: popup › 1. Turn Mode Switch clawbot-originated turns into non-interactive mode so remote sessions do not block on prompts. Feishu Sessions 2 total · 1 bound · 1 unbound - 3. Scan Feishu Sessions Discover private Feishu chats using the persisted workspace + 3. Scan Feishu Sessions Discover Feishu chats and bot groups using the persisted workspace credentials. 4. Clear Unbound Sessions Remove 1 unbound sessions and 1 cached unread messages. Alice bound · 0 unread From ae9a2d938831711c185e43161cf0ec73359e5efb Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 00:33:46 +0800 Subject: [PATCH 41/60] replay: restore clawbot session jump continuity --- codex-rs/tui/src/app/clawbot_controls.rs | 165 +++++++++++++++--- ...trols__tests__bound_session_jump_item.snap | 36 ++++ 2 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__clawbot_controls__tests__bound_session_jump_item.snap diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index 79ef868b6..d7efe6502 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -10,12 +10,15 @@ use codex_clawbot::ProviderKind; use codex_clawbot::ProviderRuntimeState; use codex_clawbot::ProviderSession; use codex_clawbot::ProviderSessionRef; +use codex_protocol::ThreadId; use super::App; use crate::app_event::AppEvent; use crate::app_event::ClawbotFeishuConfigField; use crate::app_event::ClawbotForwardingChannel; +use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; +use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; @@ -700,41 +703,64 @@ fn clawbot_session_item( } else { format!("unbound · {} unread", session.unread_count) }; - let selected_description = match active_thread_id { - Some(thread_id) if is_current => { + let jump_target = session + .bound_thread_id + .as_deref() + .and_then(|thread_id| ThreadId::from_string(thread_id).ok()); + let selected_description = match (active_thread_id, session.bound_thread_id.as_deref()) { + (Some(thread_id), Some(bound_thread_id)) if thread_id == bound_thread_id => { format!( - "Thread {thread_id} is already bound to Feishu session {}.", + "Current thread {thread_id} is already bound to Feishu session {}.", session.session_id ) } - Some(thread_id) => match session.bound_thread_id.as_deref() { - Some(bound_thread_id) if bound_thread_id != thread_id => { - format!( - "Bind current thread {thread_id} to Feishu session {} and move it from thread {bound_thread_id}.", - session.session_id - ) - } - _ => format!( - "Bind current thread {thread_id} directly to Feishu session {}.", + (_, Some(bound_thread_id)) if jump_target.is_some() => { + format!( + "Jump to bound thread {bound_thread_id} to continue or manage Feishu session {}.", session.session_id - ), - }, - None => format!( + ) + } + (_, Some(bound_thread_id)) => format!( + "Feishu session {} is bound to invalid thread id {bound_thread_id}.", + session.session_id + ), + (Some(thread_id), None) if is_current => { + format!( + "Thread {thread_id} is already bound to Feishu session {}.", + session.session_id + ) + } + (Some(thread_id), None) => format!( + "Bind current thread {thread_id} directly to Feishu session {}.", + session.session_id + ), + (None, None) => format!( "Open a Codex thread before binding Feishu session {}.", session.session_id ), }; let session_id = session.session_id.clone(); + let actions: Vec = if let Some(thread_id) = jump_target { + vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::SelectAgentThread(thread_id)); + })] + } else { + vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::SaveClawbotManualBindSessionId { + session_id: session_id.clone(), + }); + })] + }; + let is_disabled = match session.bound_thread_id.as_deref() { + Some(bound_thread_id) => active_thread_id == Some(bound_thread_id) || jump_target.is_none(), + None => active_thread_id.is_none() || is_current, + }; SelectionItem { name: session_title(session), description: Some(description), selected_description: Some(selected_description), - is_disabled: active_thread_id.is_none() || is_current, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::SaveClawbotManualBindSessionId { - session_id: session_id.clone(), - }); - })], + is_disabled, + actions, dismiss_on_select: false, ..Default::default() } @@ -794,3 +820,100 @@ fn truncate_value(value: &str, max_chars: usize) -> String { .collect::(); format!("{prefix}…") } + +#[cfg(test)] +mod tests { + use codex_clawbot::ProviderKind; + use codex_clawbot::ProviderSession; + use codex_protocol::ThreadId; + use insta::assert_snapshot; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + use super::clawbot_session_item; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::ListSelectionView; + use crate::bottom_pane::SelectionViewParams; + use crate::render::renderable::Renderable; + + fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|frame| { + let area = Rect::new(0, 0, width, height); + view.render(area, frame.buffer_mut()); + }) + .expect("draw popup"); + format!("{:?}", terminal.backend()) + } + + #[test] + fn bound_session_item_jumps_to_bound_thread() { + let item = clawbot_session_item( + &ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_bound".to_string(), + display_name: Some("tracker".to_string()), + unread_count: 2, + last_message_at: None, + status: codex_clawbot::SessionStatus::Bound, + bound_thread_id: Some("019d607a-cf72-72e1-a5b7-0dc17ad019ad".to_string()), + }, + Some("019d607a-cf72-72e1-a5b7-0dc17ad019ae"), + None, + ); + + assert!(!item.is_disabled); + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + (item.actions[0])(&tx); + + assert!( + matches!( + rx.try_recv().expect("event"), + AppEvent::SelectAgentThread(thread_id) + if thread_id + == ThreadId::from_string("019d607a-cf72-72e1-a5b7-0dc17ad019ad") + .expect("thread id") + ), + "expected bound session item to jump to the bound thread" + ); + } + + #[test] + fn bound_session_jump_item_snapshot() { + let item = clawbot_session_item( + &ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_bound".to_string(), + display_name: Some("tracker".to_string()), + unread_count: 2, + last_message_at: None, + status: codex_clawbot::SessionStatus::Bound, + bound_thread_id: Some("019d607a-cf72-72e1-a5b7-0dc17ad019ad".to_string()), + }, + Some("019d607a-cf72-72e1-a5b7-0dc17ad019ae"), + None, + ); + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Clawbot".to_string()), + subtitle: Some("Session Jump".to_string()), + items: vec![item], + initial_selected_idx: Some(0), + ..Default::default() + }, + tx, + ); + + assert_snapshot!( + "bound_session_jump_item", + render_selection_popup(&view, 92, 14) + ); + } +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__clawbot_controls__tests__bound_session_jump_item.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__clawbot_controls__tests__bound_session_jump_item.snap new file mode 100644 index 000000000..8d849783c --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__clawbot_controls__tests__bound_session_jump_item.snap @@ -0,0 +1,36 @@ +--- +source: tui/src/app/clawbot_controls.rs +expression: "render_selection_popup(&view, 92, 14)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 14 }, + content: [ + " ", + " Clawbot ", + " Session Jump ", + " ", + "› 1. tracker Jump to bound thread 019d607a-cf72-72e1-a5b7-0dc17ad019ad to continue or ", + " manage Feishu session chat_bound. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 9, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 86, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 47, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } From 9951bec11ea64e21e446fb7933875ffb2d7e3b2e Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 01:05:50 +0800 Subject: [PATCH 42/60] fix: restore clawbot bound session jump for unloaded threads --- codex-rs/tui/src/app.rs | 105 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 96 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 06eb88673..20bf2b143 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -199,6 +199,12 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue." const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; const PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); +#[derive(Clone, Copy)] +enum ThreadLivenessRefreshMode { + Picker, + Selection, +} + enum ThreadInteractiveRequest { Approval(ApprovalRequest), McpServerElicitation(McpServerElicitationFormRequest), @@ -3176,7 +3182,11 @@ impl App { } for thread_id in thread_ids { if !self - .refresh_agent_picker_thread_liveness(app_server, thread_id) + .refresh_agent_picker_thread_liveness( + app_server, + thread_id, + ThreadLivenessRefreshMode::Picker, + ) .await { continue; @@ -3268,6 +3278,18 @@ impl App { Self::is_terminal_thread_read_error(err) || existing_is_closed.unwrap_or(false) } + fn closed_state_for_thread_read_status( + status: &codex_app_server_protocol::ThreadStatus, + mode: ThreadLivenessRefreshMode, + ) -> bool { + match mode { + ThreadLivenessRefreshMode::Picker => { + matches!(status, codex_app_server_protocol::ThreadStatus::NotLoaded) + } + ThreadLivenessRefreshMode::Selection => false, + } + } + fn can_fallback_from_include_turns_error(err: &color_eyre::Report) -> bool { err.chain().any(|cause| { let message = cause.to_string(); @@ -3310,6 +3332,7 @@ impl App { &mut self, app_server: &mut AppServerSession, thread_id: ThreadId, + mode: ThreadLivenessRefreshMode, ) -> bool { let existing_entry = self.agent_navigation.get(&thread_id).cloned(); let has_replay_channel = self.thread_event_channels.contains_key(&thread_id); @@ -3330,17 +3353,29 @@ impl App { .as_ref() .and_then(|entry| entry.agent_role.clone()) }), - matches!( - thread.status, - codex_app_server_protocol::ThreadStatus::NotLoaded - ), + Self::closed_state_for_thread_read_status(&thread.status, mode), ); true } Err(err) => { if Self::is_terminal_thread_read_error(&err) && !has_replay_channel { - self.agent_navigation.remove(thread_id); - return false; + match mode { + ThreadLivenessRefreshMode::Picker => { + self.agent_navigation.remove(thread_id); + return false; + } + ThreadLivenessRefreshMode::Selection => { + if let Some(entry) = existing_entry { + self.upsert_agent_picker_thread( + thread_id, + entry.agent_nickname, + entry.agent_role, + /*is_closed*/ false, + ); + } + return true; + } + } } let is_closed = Self::closed_state_for_thread_read_error( &err, @@ -3505,7 +3540,11 @@ impl App { } if !self - .refresh_agent_picker_thread_liveness(app_server, thread_id) + .refresh_agent_picker_thread_liveness( + app_server, + thread_id, + ThreadLivenessRefreshMode::Selection, + ) .await { self.chat_widget @@ -8253,6 +8292,22 @@ mod tests { )); } + #[test] + fn closed_state_for_thread_read_status_marks_not_loaded_picker_threads_closed() { + assert!(App::closed_state_for_thread_read_status( + &codex_app_server_protocol::ThreadStatus::NotLoaded, + ThreadLivenessRefreshMode::Picker, + )); + } + + #[test] + fn closed_state_for_thread_read_status_keeps_not_loaded_selection_threads_open() { + assert!(!App::closed_state_for_thread_read_status( + &codex_app_server_protocol::ThreadStatus::NotLoaded, + ThreadLivenessRefreshMode::Selection, + )); + } + #[test] fn include_turns_fallback_detection_handles_unmaterialized_and_ephemeral_threads() { let unmaterialized = color_eyre::eyre::eyre!( @@ -8400,7 +8455,11 @@ mod tests { ); let is_available = app - .refresh_agent_picker_thread_liveness(&mut app_server, thread_id) + .refresh_agent_picker_thread_liveness( + &mut app_server, + thread_id, + ThreadLivenessRefreshMode::Picker, + ) .await; assert!(!is_available); @@ -8409,6 +8468,34 @@ mod tests { Ok(()) } + #[tokio::test] + async fn refresh_agent_picker_thread_liveness_keeps_unloaded_selection_targets_attachable() + -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let thread_id = started.session.thread_id; + app_server.thread_unsubscribe(thread_id).await?; + + let is_available = app + .refresh_agent_picker_thread_liveness( + &mut app_server, + thread_id, + ThreadLivenessRefreshMode::Selection, + ) + .await; + + assert!(is_available); + assert!(app.agent_navigation.get(&thread_id).is_none()); + assert!(app.should_attach_live_thread_for_selection(thread_id)); + Ok(()) + } + #[tokio::test] async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; From 38c8895f92aeeddab42ea1b580b25c69c36eadde Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 01:44:29 +0800 Subject: [PATCH 43/60] codex: fix CI failure on PR #21 --- .github/workflows/rust-ci.yml | 11 +- .../src/provider/feishu/runtime_loop.rs | 4 +- codex-rs/deny.toml | 1 + codex-rs/tui/src/app.rs | 584 +----------------- codex-rs/tui/src/app/clawbot.rs | 69 ++- codex-rs/tui/src/app/clawbot_controls.rs | 14 +- codex-rs/tui/src/app/tests/btw_tests.rs | 86 +++ codex-rs/tui/src/app/tests/clawbot_tests.rs | 455 ++++++++++++++ ...wbot_tests__clawbot_management_popup.snap} | 3 +- codex-rs/tui/src/bottom_pane/mod.rs | 2 +- 10 files changed, 624 insertions(+), 605 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/btw_tests.rs create mode 100644 codex-rs/tui/src/app/tests/clawbot_tests.rs rename codex-rs/tui/src/{snapshots/codex_tui__app__tests__clawbot_management_popup.snap => app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap} (93%) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index b26e63502..0065827eb 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -146,15 +146,15 @@ jobs: shell: bash run: | sudo DEBIAN_FRONTEND=noninteractive apt-get update - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Run argument comment lint on codex-rs via source wrapper + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev protobuf-compiler libprotobuf-dev + - name: Run argument comment lint on codex-rs via prebuilt wrapper if: ${{ runner.os == 'Linux' && github.repository != 'openai/codex' }} shell: bash run: | heartbeat() { while true; do sleep 60 - echo "argument comment lint still running via source wrapper ($(date -u '+%Y-%m-%dT%H:%M:%SZ'))" + echo "argument comment lint still running via prebuilt wrapper ($(date -u '+%Y-%m-%dT%H:%M:%SZ'))" done } @@ -171,16 +171,15 @@ jobs: --component rustc-dev \ --component rust-src \ --no-self-update - cargo install --locked cargo-dylint dylint-link set +e timeout --foreground --signal=TERM --kill-after=30s 110m \ - ./tools/argument-comment-lint/run.py + ./tools/argument-comment-lint/run-prebuilt-linter.py status=$? set -e if [[ $status -eq 124 ]]; then - echo "argument comment lint source-wrapper step exceeded 110 minutes; failing explicitly before the 120-minute job timeout so shared CI follow-up can diagnose it." + echo "argument comment lint prebuilt-wrapper step exceeded 110 minutes; failing explicitly before the 120-minute job timeout so shared CI follow-up can diagnose it." fi exit "$status" diff --git a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs index ba5ad5521..8ecb32185 100644 --- a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs +++ b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs @@ -83,7 +83,7 @@ async fn run_once( ) -> Result<()> { let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( ConnectionStatus::Connecting, - None, + /*last_error*/ None, )?)); let ws_config = Arc::new(build_websocket_config(config)?); @@ -118,7 +118,7 @@ async fn run_once( ); let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( ConnectionStatus::Connected, - None, + /*last_error*/ None, )?)); let open_result = LarkWsClient::open(ws_config, event_handler).await; diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 3dd27c8f9..1fe4f3627 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -83,6 +83,7 @@ ignore = [ # TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities. { id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, { id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, + { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is pulled in only by PR5's clawbot/feishu tail via openlark; upstream stable openlark has not removed it yet, so this advisory is temporarily ignored until that dependency can be upgraded separately" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 20bf2b143..16138679c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -169,9 +169,9 @@ mod app_server_adapter; mod app_server_requests; mod btw; mod clawbot; +mod clawbot_controls; mod jump_navigation; mod key_chord; -mod clawbot_controls; mod loaded_threads; mod pending_interactive_replay; mod profile_management; @@ -6944,6 +6944,9 @@ impl Drop for App { #[cfg(test)] mod tests { use super::*; + mod btw_tests; + mod clawbot_tests; + use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; @@ -6962,8 +6965,6 @@ mod tests { use assert_matches::assert_matches; use codex_app_server_client::AppServerEvent; - use crate::app_event::ClawbotForwardingChannel; - use crate::render::renderable::Renderable; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; @@ -7005,16 +7006,6 @@ mod tests { use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as AppServerUserInput; - use codex_clawbot::ClawbotRuntime; - use codex_clawbot::ClawbotTurnMode; - use codex_clawbot::ProviderEvent as ClawbotProviderEvent; - use codex_clawbot::ProviderKind as ClawbotProviderKind; - use codex_clawbot::ProviderMessageRef; - use codex_clawbot::ProviderOutboundReaction; - use codex_clawbot::ProviderOutboundTextMessage; - use codex_clawbot::ProviderSession; - use codex_clawbot::ProviderSessionRef; - use codex_clawbot::SessionStatus as ClawbotSessionStatus; use codex_config::types::ModelAvailabilityNuxConfig; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -7048,10 +7039,6 @@ mod tests { use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - use crate::render::renderable::Renderable; - use pretty_assertions::assert_eq; use ratatui::prelude::Line; use std::path::Path; use std::path::PathBuf; @@ -10083,68 +10070,6 @@ jobs: )?; Ok(()) } - async fn bind_test_clawbot_session( - app: &mut App, - app_server: &mut AppServerSession, - session_id: &str, - ) -> Result<(ThreadId, ProviderSessionRef)> { - let started = app_server - .start_thread(app.chat_widget.config_ref()) - .await - .expect("start thread"); - let thread_id = started.session.thread_id; - let session = ProviderSessionRef::new(ClawbotProviderKind::Feishu, session_id); - let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - runtime - .persist_session(ProviderSession { - provider: ClawbotProviderKind::Feishu, - session_id: session_id.to_string(), - display_name: Some("Alice".to_string()), - unread_count: 0, - last_message_at: None, - status: ClawbotSessionStatus::Discovered, - bound_thread_id: None, - }) - .expect("persist session"); - runtime - .connect_session_to_thread(&session, thread_id.to_string()) - .expect("connect session"); - app.sync_clawbot_workspace(app_server).await; - Ok((thread_id, session)) - } - - fn render_bottom_popup(chat: &crate::chatwidget::ChatWidget, width: u16) -> String { - let height = chat.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - chat.render(area, &mut buf); - - let mut lines: Vec = (0..area.height) - .map(|row| { - let mut line = String::new(); - for col in 0..area.width { - let symbol = buf[(area.x + col, area.y + row)].symbol(); - if symbol.is_empty() { - line.push(' '); - } else { - line.push_str(symbol); - } - } - line.trim_end().to_string() - }) - .collect(); - - while lines.first().is_some_and(|line| line.trim().is_empty()) { - lines.remove(0); - } - while lines.last().is_some_and(|line| line.trim().is_empty()) { - lines.pop(); - } - - lines.join("\n") - } - fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { thread_id, @@ -12109,231 +12034,6 @@ model = "gpt-5.2" ); } - async fn clawbot_inbound_message_resumes_bound_thread_and_starts_turn() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let tempdir = tempdir()?; - app.config.cwd = tempdir.path().to_path_buf().abs(); - - let (thread_id, session) = - bind_test_clawbot_session(&mut app, &mut app_server, "chat_resume").await?; - - app.handle_clawbot_provider_event( - &mut app_server, - ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { - session: session.clone(), - message_id: "msg_1".to_string(), - text: "hello from feishu".to_string(), - received_at: 1, - }), - ) - .await - .expect("handle clawbot inbound message"); - - assert!(app.thread_event_channels.contains_key(&thread_id)); - assert_eq!( - app.clawbot_outbound_reactions, - vec![ProviderOutboundReaction { - target: ProviderMessageRef::new( - ClawbotProviderKind::Feishu, - "chat_resume", - "msg_1" - ), - emoji_type: "TONGUE".to_string(), - }] - ); - assert_eq!( - app.clawbot_pending_turns - .get(&thread_id) - .map(std::collections::VecDeque::len), - Some(1) - ); - assert!(app.active_turn_id_for_thread(thread_id).await.is_some()); - - Ok(()) - } - - #[tokio::test] - async fn noninteractive_clawbot_request_user_input_builds_auto_response() { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - app.clawbot_pending_turns.insert( - thread_id, - VecDeque::from([PendingClawbotTurn { - turn_id: "turn-1".to_string(), - session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), - turn_mode: ClawbotTurnMode::NonInteractive, - }]), - ); - let request = ServerRequest::ToolRequestUserInput { - request_id: AppServerRequestId::Integer(1), - params: ToolRequestUserInputParams { - thread_id: thread_id.to_string(), - turn_id: "turn-1".to_string(), - item_id: "call-1".to_string(), - questions: Vec::new(), - }, - }; - - let op = app - .clawbot_auto_response_op_for_server_request(thread_id, &request) - .expect("auto response op"); - - match op.view() { - crate::app_command::AppCommandView::UserInputAnswer { id, response } => { - assert_eq!(id, "turn-1"); - assert_eq!( - response, - &codex_protocol::request_user_input::RequestUserInputResponse { - answers: HashMap::new(), - } - ); - } - _ => panic!("expected UserInputAnswer"), - } - } - - #[tokio::test] - async fn noninteractive_clawbot_permissions_request_builds_auto_response() { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - app.clawbot_pending_turns.insert( - thread_id, - VecDeque::from([PendingClawbotTurn { - turn_id: "turn-1".to_string(), - session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), - turn_mode: ClawbotTurnMode::NonInteractive, - }]), - ); - let request = ServerRequest::PermissionsRequestApproval { - request_id: AppServerRequestId::Integer(7), - params: PermissionsRequestApprovalParams { - thread_id: thread_id.to_string(), - turn_id: "turn-1".to_string(), - item_id: "call-approval".to_string(), - reason: Some("Need access".to_string()), - permissions: codex_app_server_protocol::RequestPermissionProfile { - network: None, - file_system: None, - }, - }, - }; - - let op = app - .clawbot_auto_response_op_for_server_request(thread_id, &request) - .expect("auto response op"); - - match op.view() { - crate::app_command::AppCommandView::RequestPermissionsResponse { id, response } => { - assert_eq!(id, "call-approval"); - assert_eq!( - response, - &codex_protocol::request_permissions::RequestPermissionsResponse { - permissions: Default::default(), - scope: codex_protocol::request_permissions::PermissionGrantScope::Turn, - } - ); - } - _ => panic!("expected RequestPermissionsResponse"), - } - } - - #[tokio::test] - async fn clawbot_turn_completed_forwards_reply_and_drains_next_message() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let tempdir = tempdir()?; - app.config.cwd = tempdir.path().to_path_buf().abs(); - - let (thread_id, session) = - bind_test_clawbot_session(&mut app, &mut app_server, "chat_reply").await?; - - app.handle_clawbot_provider_event( - &mut app_server, - ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { - session: session.clone(), - message_id: "msg_1".to_string(), - text: "first".to_string(), - received_at: 1, - }), - ) - .await - .expect("handle first clawbot inbound"); - app.handle_clawbot_provider_event( - &mut app_server, - ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { - session: session.clone(), - message_id: "msg_2".to_string(), - text: "second".to_string(), - received_at: 2, - }), - ) - .await - .expect("handle second clawbot inbound"); - - let first_turn_id = app - .clawbot_pending_turns - .get(&thread_id) - .and_then(|queue| queue.front()) - .map(|pending| pending.turn_id.clone()) - .expect("first pending turn"); - let queued_runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - assert_eq!(queued_runtime.snapshot().unread_message_count, 1); - - app.enqueue_thread_notification( - thread_id, - turn_completed_notification_with_agent_message( - thread_id, - &first_turn_id, - TurnStatus::Completed, - "forwarded reply", - ), - ) - .await?; - app.handle_clawbot_turn_completed( - &mut app_server, - thread_id, - test_turn( - &first_turn_id, - TurnStatus::Completed, - vec![ThreadItem::AgentMessage { - id: "agent-1".to_string(), - text: "forwarded reply".to_string(), - phase: None, - memory_citation: None, - }], - ), - ) - .await - .expect("handle clawbot turn completion"); - - assert_eq!( - app.clawbot_outbound_messages, - vec![ProviderOutboundTextMessage { - session: session.clone(), - text: "forwarded reply".to_string(), - }] - ); - assert_eq!( - app.clawbot_pending_turns - .get(&thread_id) - .map(std::collections::VecDeque::len), - Some(1) - ); - let drained_runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - assert_eq!(drained_runtime.snapshot().unread_message_count, 0); - - Ok(()) - } - #[tokio::test] async fn shutting_down_primary_thread_stops_background_workflow_runs() { let mut app = make_test_app().await; @@ -12386,58 +12086,6 @@ model = "gpt-5.2" assert!(app.queued_trigger_labels().is_empty()); } - #[tokio::test] - async fn clawbot_manual_bind_replays_cached_unread_messages() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let tempdir = tempdir()?; - app.config.cwd = tempdir.path().to_path_buf().abs(); - - let started = app_server - .start_thread(app.chat_widget.config_ref()) - .await - .expect("start thread"); - let thread_id = started.session.thread_id; - app.active_thread_id = Some(thread_id); - - let session = ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_bind"); - let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - runtime - .apply_provider_event(ClawbotProviderEvent::InboundMessage( - codex_clawbot::ProviderInboundMessage { - session: session.clone(), - message_id: "msg_1".to_string(), - text: "queued before bind".to_string(), - received_at: 1, - }, - )) - .expect("queue unread"); - - app.bind_clawbot_session_to_current_thread(&mut app_server, "chat_bind".to_string()) - .await - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - - let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - assert_eq!( - runtime - .bound_session_for_thread(&thread_id.to_string()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, - Some(session) - ); - assert_eq!( - app.clawbot_pending_turns - .get(&thread_id) - .map(std::collections::VecDeque::len), - Some(1) - ); - Ok(()) - } - #[tokio::test] async fn before_turn_workflow_augments_primary_user_turn() -> Result<()> { let mut app = make_test_app().await; @@ -12847,230 +12495,6 @@ model = "gpt-5.2" Ok(()) } - #[tokio::test] - async fn clawbot_current_thread_controls_update_binding_state() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let tempdir = tempdir()?; - app.config.cwd = tempdir.path().to_path_buf().abs(); - - let (thread_id, _session) = - bind_test_clawbot_session(&mut app, &mut app_server, "chat_controls").await?; - app.active_thread_id = Some(thread_id); - - app.clawbot_set_current_thread_forwarding(ClawbotForwardingChannel::Inbound, false) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - app.clawbot_set_current_thread_forwarding(ClawbotForwardingChannel::Outbound, false) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - - let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - let binding = runtime - .load_binding_for_thread(&thread_id.to_string()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))? - .expect("binding"); - assert!(!binding.inbound_forwarding_enabled); - assert!(!binding.outbound_forwarding_enabled); - - app.clawbot_disconnect_current_thread() - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - assert_eq!( - runtime - .load_binding_for_thread(&thread_id.to_string()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, - None - ); - Ok(()) - } - - #[tokio::test] - async fn clawbot_management_popup_snapshot() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let tempdir = tempdir()?; - app.config.cwd = tempdir.path().to_path_buf().abs(); - - let (thread_id, _session) = - bind_test_clawbot_session(&mut app, &mut app_server, "chat_snapshot").await?; - app.active_thread_id = Some(thread_id); - - let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - runtime - .update_feishu_config(Some(codex_clawbot::FeishuConfig { - app_id: "cli_app_123".to_string(), - app_secret: "secret_value_4567".to_string(), - verification_token: Some("verify_token".to_string()), - encrypt_key: None, - bot_open_id: Some("ou_bot_open_id".to_string()), - bot_user_id: None, - })) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - runtime - .persist_session(ProviderSession { - provider: ClawbotProviderKind::Feishu, - session_id: "chat_discovered".to_string(), - display_name: Some("Bob".to_string()), - unread_count: 0, - last_message_at: None, - status: ClawbotSessionStatus::Discovered, - bound_thread_id: None, - }) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - runtime - .apply_provider_event(ClawbotProviderEvent::InboundMessage( - codex_clawbot::ProviderInboundMessage { - session: ProviderSessionRef::new( - ClawbotProviderKind::Feishu, - "chat_discovered", - ), - message_id: "msg_discovered".to_string(), - text: "hello".to_string(), - received_at: 10, - }, - )) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - - app.open_clawbot_management_popup(); - - let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); - assert_snapshot!("clawbot_management_popup", popup); - Ok(()) - } - - #[tokio::test] - async fn clawbot_rebinds_discovered_session_from_management_actions() -> Result<()> { - let mut app = make_test_app().await; - let mut app_server = - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) - .await - .expect("embedded app server"); - let tempdir = tempdir()?; - app.config.cwd = tempdir.path().to_path_buf().abs(); - - let started = app_server - .start_thread(app.chat_widget.config_ref()) - .await - .expect("start thread"); - let target_thread_id = started.session.thread_id; - app.active_thread_id = Some(target_thread_id); - - let (source_thread_id, session) = - bind_test_clawbot_session(&mut app, &mut app_server, "chat_rebind").await?; - - app.bind_clawbot_session_to_current_thread(&mut app_server, "chat_rebind".to_string()) - .await - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - - let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - assert_eq!( - runtime - .bound_session_for_thread(&target_thread_id.to_string()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, - Some(session.clone()) - ); - assert_eq!( - runtime - .bound_session_for_thread(&source_thread_id.to_string()) - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, - None - ); - Ok(()) - } - - #[tokio::test] - async fn btw_completion_notification_emits_completion_event_and_is_swallowed() { - let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - let thread_id = ThreadId::new(); - app.btw_session = Some(BtwSessionState { - thread_id, - final_message: None, - last_status: None, - }); - - let swallowed = app.handle_btw_notification( - thread_id, - &turn_completed_notification_with_agent_message( - thread_id, - "turn-btw", - TurnStatus::Completed, - "Temporary answer", - ), - ); - - assert!(swallowed); - match app_event_rx.try_recv() { - Ok(AppEvent::BtwCompleted { - thread_id: actual_thread_id, - result: Ok(message), - }) => { - assert_eq!(actual_thread_id, thread_id); - assert_eq!(message, "Temporary answer"); - } - other => panic!("expected BtwCompleted event, got {other:?}"), - } - } - - #[tokio::test] - async fn btw_loading_popup_surfaces_hidden_hook_status() { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - app.btw_session = Some(BtwSessionState { - thread_id, - final_message: None, - last_status: None, - }); - - let swallowed = app - .handle_btw_notification(thread_id, &hook_started_notification(thread_id, "turn-btw")); - - assert!(swallowed); - let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); - assert!( - popup.contains("Current hidden status:") - && popup.contains("Running UserPromptSubmit hook: checking") - && popup.contains("go-workflow input policy"), - "expected hidden hook status in /btw popup: {popup}" - ); - } - - #[tokio::test] - async fn btw_request_user_input_opens_failure_popup_instead_of_hanging() { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - app.btw_session = Some(BtwSessionState { - thread_id, - final_message: None, - last_status: None, - }); - - let reason = app.reject_btw_request( - thread_id, - &request_user_input_request(thread_id, "turn-btw", "call-1"), - ); - - assert_eq!( - reason, - Some( - "the hidden temporary discussion asked for interactive user input. Run the prompt in the main thread instead.".to_string() - ) - ); - let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); - assert!( - popup.contains("asked for interactive user input"), - "expected /btw failure popup for hidden request_user_input: {popup}" - ); - } - #[tokio::test] async fn btw_insert_summary_appends_to_existing_composer() -> Result<()> { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs index 1798d93bc..23edb88de 100644 --- a/codex-rs/tui/src/app/clawbot.rs +++ b/codex-rs/tui/src/app/clawbot.rs @@ -324,7 +324,7 @@ impl App { serde_json::json!({ "session_id": session.session_id, "thread_id": thread_id, - "turn_id": turn_id.clone(), + "turn_id": turn_id, "message_id": message.message_id.clone(), "text": message.text.clone(), }), @@ -425,11 +425,11 @@ impl App { session.sandbox_policy, session.model, session.reasoning_effort, - None, + /*summary*/ None, Some(session.service_tier), - None, + /*collaboration_mode*/ None, self.config.personality, - None, + /*output_schema*/ None, ) .await .map_err(|err| { @@ -468,7 +468,7 @@ impl App { serde_json::json!({ "reason": "missing_binding", "session_id": session.session_id, - "text": text.clone(), + "text": text, }), ); return Ok(()); @@ -481,7 +481,7 @@ impl App { "reason": "outbound_forwarding_disabled", "session_id": session.session_id, "thread_id": binding.thread_id, - "text": text.clone(), + "text": text, }), ); return Ok(()); @@ -704,13 +704,22 @@ fn clawbot_outbound_text_for_turn(turn: &codex_app_server_protocol::Turn) -> Opt .as_ref() .map(|error| feishu_failure_reply_text(&error.message)), codex_app_server_protocol::TurnStatus::Interrupted => { - super::last_agent_message_for_turn(turn) - .or_else(|| Some("Request interrupted.".to_string())) + last_agent_message_for_turn(turn).or_else(|| Some("Request interrupted.".to_string())) } codex_app_server_protocol::TurnStatus::InProgress => None, } } +fn last_agent_message_for_turn(turn: &codex_app_server_protocol::Turn) -> Option { + turn.items.iter().rev().find_map(|item| { + let codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } = item else { + return None; + }; + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} + fn next_unread_message_for_session( runtime: &ClawbotRuntime, session: &ProviderSessionRef, @@ -727,3 +736,47 @@ fn next_unread_message_for_session( .then(left.message_id.cmp(&right.message_id)) }) } + +#[cfg(test)] +mod tests { + use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::Turn; + use codex_app_server_protocol::TurnStatus; + use pretty_assertions::assert_eq; + + use super::clawbot_outbound_text_for_turn; + + #[test] + fn interrupted_turn_uses_last_agent_message_for_reply() { + let turn = Turn { + id: "turn-1".to_string(), + status: TurnStatus::Interrupted, + items: vec![ + ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "first reply".to_string(), + phase: None, + memory_citation: None, + }, + ThreadItem::AgentMessage { + id: "agent-2".to_string(), + text: " ".to_string(), + phase: None, + memory_citation: None, + }, + ThreadItem::AgentMessage { + id: "agent-3".to_string(), + text: "last reply".to_string(), + phase: None, + memory_citation: None, + }, + ], + error: None, + }; + + assert_eq!( + clawbot_outbound_text_for_turn(&turn), + Some("last reply".to_string()) + ); + } +} diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index d7efe6502..eacbf2e19 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -58,7 +58,7 @@ impl ClawbotFeishuConfigField { if self.is_secret() { format!("Configured: {}", mask_secret(&value)) } else { - format!("Configured: {}", truncate_value(&value, 28)) + format!("Configured: {}", truncate_value(&value, /*max_chars*/ 28)) } } @@ -81,7 +81,7 @@ impl ClawbotFeishuConfigField { format!("Current: {}", mask_secret(&value)) } Some(value) => { - format!("Current: {}", truncate_value(&value, 40)) + format!("Current: {}", truncate_value(&value, /*max_chars*/ 40)) } None => "Current: not set".to_string(), } @@ -684,7 +684,7 @@ fn connection_description(state: &ProviderRuntimeState) -> String { }; match state.last_error.as_deref() { Some(error) if !error.trim().is_empty() => { - format!("{status}: {}", truncate_value(error, 48)) + format!("{status}: {}", truncate_value(error, /*max_chars*/ 48)) } _ => status.to_string(), } @@ -835,8 +835,8 @@ mod tests { use super::clawbot_session_item; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; - use crate::bottom_pane::ListSelectionView; use crate::bottom_pane::SelectionViewParams; + use crate::bottom_pane::list_selection_view::ListSelectionView; use crate::render::renderable::Renderable; fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String { @@ -863,7 +863,7 @@ mod tests { bound_thread_id: Some("019d607a-cf72-72e1-a5b7-0dc17ad019ad".to_string()), }, Some("019d607a-cf72-72e1-a5b7-0dc17ad019ae"), - None, + /*current_binding_session_id*/ None, ); assert!(!item.is_disabled); @@ -896,7 +896,7 @@ mod tests { bound_thread_id: Some("019d607a-cf72-72e1-a5b7-0dc17ad019ad".to_string()), }, Some("019d607a-cf72-72e1-a5b7-0dc17ad019ae"), - None, + /*current_binding_session_id*/ None, ); let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); @@ -913,7 +913,7 @@ mod tests { assert_snapshot!( "bound_session_jump_item", - render_selection_popup(&view, 92, 14) + render_selection_popup(&view, /*width*/ 92, /*height*/ 14) ); } } diff --git a/codex-rs/tui/src/app/tests/btw_tests.rs b/codex-rs/tui/src/app/tests/btw_tests.rs new file mode 100644 index 000000000..e7fa582ce --- /dev/null +++ b/codex-rs/tui/src/app/tests/btw_tests.rs @@ -0,0 +1,86 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn btw_completion_notification_emits_completion_event_and_is_swallowed() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.btw_session = Some(BtwSessionState { + thread_id, + final_message: None, + last_status: None, + }); + + let swallowed = app.handle_btw_notification( + thread_id, + &turn_completed_notification_with_agent_message( + thread_id, + "turn-btw", + TurnStatus::Completed, + "Temporary answer", + ), + ); + + assert!(swallowed); + match app_event_rx.try_recv() { + Ok(AppEvent::BtwCompleted { + thread_id: actual_thread_id, + result: Ok(message), + }) => { + assert_eq!(actual_thread_id, thread_id); + assert_eq!(message, "Temporary answer"); + } + other => panic!("expected BtwCompleted event, got {other:?}"), + } +} + +#[tokio::test] +async fn btw_loading_popup_surfaces_hidden_hook_status() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.btw_session = Some(BtwSessionState { + thread_id, + final_message: None, + last_status: None, + }); + + let swallowed = + app.handle_btw_notification(thread_id, &hook_started_notification(thread_id, "turn-btw")); + + assert!(swallowed); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!( + popup.contains("Current hidden status:") + && popup.contains("Running UserPromptSubmit hook: checking") + && popup.contains("go-workflow input policy"), + "expected hidden hook status in /btw popup: {popup}" + ); +} + +#[tokio::test] +async fn btw_request_user_input_opens_failure_popup_instead_of_hanging() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.btw_session = Some(BtwSessionState { + thread_id, + final_message: None, + last_status: None, + }); + + let reason = app.reject_btw_request( + thread_id, + &request_user_input_request(thread_id, "turn-btw", "call-1"), + ); + + assert_eq!( + reason, + Some( + "the hidden temporary discussion asked for interactive user input. Run the prompt in the main thread instead.".to_string() + ) + ); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!( + popup.contains("asked for interactive user input"), + "expected /btw failure popup for hidden request_user_input: {popup}" + ); +} diff --git a/codex-rs/tui/src/app/tests/clawbot_tests.rs b/codex-rs/tui/src/app/tests/clawbot_tests.rs new file mode 100644 index 000000000..fc2d2754f --- /dev/null +++ b/codex-rs/tui/src/app/tests/clawbot_tests.rs @@ -0,0 +1,455 @@ +use super::*; +use crate::app_event::ClawbotForwardingChannel; +use codex_clawbot::ClawbotRuntime; +use codex_clawbot::ClawbotTurnMode; +use codex_clawbot::ProviderEvent as ClawbotProviderEvent; +use codex_clawbot::ProviderKind as ClawbotProviderKind; +use codex_clawbot::ProviderMessageRef; +use codex_clawbot::ProviderOutboundReaction; +use codex_clawbot::ProviderOutboundTextMessage; +use codex_clawbot::ProviderSession; +use codex_clawbot::ProviderSessionRef; +use codex_clawbot::SessionStatus as ClawbotSessionStatus; +use pretty_assertions::assert_eq; + +async fn bind_test_clawbot_session( + app: &mut App, + app_server: &mut AppServerSession, + session_id: &str, +) -> Result<(ThreadId, ProviderSessionRef)> { + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + let session = ProviderSessionRef::new(ClawbotProviderKind::Feishu, session_id); + let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .persist_session(ProviderSession { + provider: ClawbotProviderKind::Feishu, + session_id: session_id.to_string(), + display_name: Some("Alice".to_string()), + unread_count: 0, + last_message_at: None, + status: ClawbotSessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("persist session"); + runtime + .connect_session_to_thread(&session, thread_id.to_string()) + .expect("connect session"); + app.sync_clawbot_workspace(app_server).await; + Ok((thread_id, session)) +} + +#[tokio::test] +async fn clawbot_inbound_message_resumes_bound_thread_and_starts_turn() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_resume").await?; + + app.handle_clawbot_provider_event( + &mut app_server, + ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "hello from feishu".to_string(), + received_at: 1, + }), + ) + .await + .expect("handle clawbot inbound message"); + + assert!(app.thread_event_channels.contains_key(&thread_id)); + assert_eq!( + app.clawbot_outbound_reactions, + vec![ProviderOutboundReaction { + target: ProviderMessageRef::new(ClawbotProviderKind::Feishu, "chat_resume", "msg_1"), + emoji_type: "TONGUE".to_string(), + }] + ); + assert_eq!( + app.clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + assert!(app.active_turn_id_for_thread(thread_id).await.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn noninteractive_clawbot_request_user_input_builds_auto_response() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.clawbot_pending_turns.insert( + thread_id, + VecDeque::from([PendingClawbotTurn { + turn_id: "turn-1".to_string(), + session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), + turn_mode: ClawbotTurnMode::NonInteractive, + }]), + ); + let request = ServerRequest::ToolRequestUserInput { + request_id: AppServerRequestId::Integer(1), + params: ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + questions: Vec::new(), + }, + }; + + let op = app + .clawbot_auto_response_op_for_server_request(thread_id, &request) + .expect("auto response op"); + + match op.view() { + crate::app_command::AppCommandView::UserInputAnswer { id, response } => { + assert_eq!(id, "turn-1"); + assert_eq!( + response, + &codex_protocol::request_user_input::RequestUserInputResponse { + answers: HashMap::new(), + } + ); + } + _ => panic!("expected UserInputAnswer"), + } +} + +#[tokio::test] +async fn noninteractive_clawbot_permissions_request_builds_auto_response() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.clawbot_pending_turns.insert( + thread_id, + VecDeque::from([PendingClawbotTurn { + turn_id: "turn-1".to_string(), + session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), + turn_mode: ClawbotTurnMode::NonInteractive, + }]), + ); + let request = ServerRequest::PermissionsRequestApproval { + request_id: AppServerRequestId::Integer(7), + params: PermissionsRequestApprovalParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-approval".to_string(), + reason: Some("Need access".to_string()), + permissions: codex_app_server_protocol::RequestPermissionProfile { + network: None, + file_system: None, + }, + }, + }; + + let op = app + .clawbot_auto_response_op_for_server_request(thread_id, &request) + .expect("auto response op"); + + match op.view() { + crate::app_command::AppCommandView::RequestPermissionsResponse { id, response } => { + assert_eq!(id, "call-approval"); + assert_eq!( + response, + &codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: Default::default(), + scope: codex_protocol::request_permissions::PermissionGrantScope::Turn, + } + ); + } + _ => panic!("expected RequestPermissionsResponse"), + } +} + +#[tokio::test] +async fn clawbot_turn_completed_forwards_reply_and_drains_next_message() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_reply").await?; + + app.handle_clawbot_provider_event( + &mut app_server, + ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "first".to_string(), + received_at: 1, + }), + ) + .await + .expect("handle first clawbot inbound"); + app.handle_clawbot_provider_event( + &mut app_server, + ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_2".to_string(), + text: "second".to_string(), + received_at: 2, + }), + ) + .await + .expect("handle second clawbot inbound"); + + let first_turn_id = app + .clawbot_pending_turns + .get(&thread_id) + .and_then(|queue| queue.front()) + .map(|pending| pending.turn_id.clone()) + .expect("first pending turn"); + let queued_runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!(queued_runtime.snapshot().unread_message_count, 1); + + app.enqueue_thread_notification( + thread_id, + turn_completed_notification_with_agent_message( + thread_id, + &first_turn_id, + TurnStatus::Completed, + "forwarded reply", + ), + ) + .await?; + app.handle_clawbot_turn_completed( + &mut app_server, + thread_id, + test_turn( + &first_turn_id, + TurnStatus::Completed, + vec![ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "forwarded reply".to_string(), + phase: None, + memory_citation: None, + }], + ), + ) + .await + .expect("handle clawbot turn completion"); + + assert_eq!( + app.clawbot_outbound_messages, + vec![ProviderOutboundTextMessage { + session: session.clone(), + text: "forwarded reply".to_string(), + }] + ); + assert_eq!( + app.clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + let drained_runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!(drained_runtime.snapshot().unread_message_count, 0); + + Ok(()) +} + +#[tokio::test] +async fn clawbot_manual_bind_replays_cached_unread_messages() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.active_thread_id = Some(thread_id); + + let session = ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_bind"); + let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .apply_provider_event(ClawbotProviderEvent::InboundMessage( + codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "queued before bind".to_string(), + received_at: 1, + }, + )) + .expect("queue unread"); + + app.bind_clawbot_session_to_current_thread(&mut app_server, "chat_bind".to_string()) + .await + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!( + runtime + .bound_session_for_thread(&thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + Some(session) + ); + assert_eq!( + app.clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + Ok(()) +} + +#[tokio::test] +async fn clawbot_current_thread_controls_update_binding_state() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, _session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_controls").await?; + app.active_thread_id = Some(thread_id); + + app.clawbot_set_current_thread_forwarding( + ClawbotForwardingChannel::Inbound, + /*enabled*/ false, + ) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + app.clawbot_set_current_thread_forwarding( + ClawbotForwardingChannel::Outbound, + /*enabled*/ false, + ) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let binding = runtime + .load_binding_for_thread(&thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))? + .expect("binding"); + assert!(!binding.inbound_forwarding_enabled); + assert!(!binding.outbound_forwarding_enabled); + + app.clawbot_disconnect_current_thread() + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!( + runtime + .load_binding_for_thread(&thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + None + ); + Ok(()) +} + +#[tokio::test] +async fn clawbot_management_popup_snapshot() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, _session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_snapshot").await?; + app.active_thread_id = Some(thread_id); + + let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .update_feishu_config(Some(codex_clawbot::FeishuConfig { + app_id: "cli_app_123".to_string(), + app_secret: "secret_value_4567".to_string(), + verification_token: Some("verify_token".to_string()), + encrypt_key: None, + bot_open_id: Some("ou_bot_open_id".to_string()), + bot_user_id: None, + })) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .persist_session(ProviderSession { + provider: ClawbotProviderKind::Feishu, + session_id: "chat_discovered".to_string(), + display_name: Some("Bob".to_string()), + unread_count: 0, + last_message_at: None, + status: ClawbotSessionStatus::Discovered, + bound_thread_id: None, + }) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .apply_provider_event(ClawbotProviderEvent::InboundMessage( + codex_clawbot::ProviderInboundMessage { + session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_discovered"), + message_id: "msg_discovered".to_string(), + text: "hello".to_string(), + received_at: 10, + }, + )) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + app.open_clawbot_management_popup(); + + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert_snapshot!("clawbot_management_popup", popup); + Ok(()) +} + +#[tokio::test] +async fn clawbot_rebinds_discovered_session_from_management_actions() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let target_thread_id = started.session.thread_id; + app.active_thread_id = Some(target_thread_id); + + let (source_thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_rebind").await?; + + app.bind_clawbot_session_to_current_thread(&mut app_server, "chat_rebind".to_string()) + .await + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!( + runtime + .bound_session_for_thread(&target_thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + Some(session.clone()) + ); + assert_eq!( + runtime + .bound_session_for_thread(&source_thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + None + ); + Ok(()) +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap similarity index 93% rename from codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap rename to codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap index cd5936e6c..5109eb0bf 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clawbot_management_popup.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap @@ -1,5 +1,6 @@ --- -source: tui/src/app.rs +source: tui/src/app/tests/clawbot_tests.rs +assertion_line: 413 expression: popup --- Clawbot diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 0c4b1efcb..666f6a532 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -80,7 +80,7 @@ pub mod custom_prompt_view; mod experimental_features_view; mod file_search_popup; mod footer; -mod list_selection_view; +pub(crate) mod list_selection_view; mod prompt_args; mod skill_popup; mod skills_toggle_view; From cbd9e0d7d2e9262253c0779b97af3a85c162bbaa Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 09:16:24 +0800 Subject: [PATCH 44/60] python: restore codex-enhanced pypi release --- .github/workflows/pypi-release.yml | 234 ++++++++++++++++++ sdk/python-runtime-enhanced/README.md | 33 +++ sdk/python-runtime-enhanced/hatch_build.py | 17 ++ sdk/python-runtime-enhanced/pyproject.toml | 45 ++++ .../src/codex_enhanced/__init__.py | 19 ++ .../src/codex_enhanced/__main__.py | 21 ++ sdk/python/scripts/update_sdk_artifacts.py | 44 +++- .../test_artifact_workflow_and_binaries.py | 82 +++++- 8 files changed, 485 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/pypi-release.yml create mode 100644 sdk/python-runtime-enhanced/README.md create mode 100644 sdk/python-runtime-enhanced/hatch_build.py create mode 100644 sdk/python-runtime-enhanced/pyproject.toml create mode 100644 sdk/python-runtime-enhanced/src/codex_enhanced/__init__.py create mode 100644 sdk/python-runtime-enhanced/src/codex_enhanced/__main__.py diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml new file mode 100644 index 000000000..88a023f6a --- /dev/null +++ b/.github/workflows/pypi-release.yml @@ -0,0 +1,234 @@ +name: pypi-release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + release_tag: + description: Existing tag to build and publish, for example v0.1.12 + required: true + type: string + artifact_run_id: + description: Existing pypi-release run id whose wheel artifacts should be reused for publish + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name || inputs.release_tag }} + cancel-in-progress: true + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.meta.outputs.release_tag }} + release_version: ${{ steps.meta.outputs.release_version }} + checkout_ref: ${{ steps.meta.outputs.checkout_ref }} + artifact_run_id: ${{ steps.meta.outputs.artifact_run_id }} + reuse_artifacts: ${{ steps.meta.outputs.reuse_artifacts }} + steps: + - name: Resolve release metadata + id: meta + shell: bash + run: | + set -euo pipefail + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + release_tag="${{ inputs.release_tag }}" + checkout_ref="refs/tags/${release_tag}" + else + release_tag="${GITHUB_REF_NAME}" + checkout_ref="${GITHUB_REF}" + fi + + [[ "${release_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + || { echo "Release tag ${release_tag} is not in the expected format."; exit 1; } + + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "release_version=${release_tag#v}" >> "$GITHUB_OUTPUT" + echo "checkout_ref=${checkout_ref}" >> "$GITHUB_OUTPUT" + artifact_run_id="${{ inputs.artifact_run_id }}" + if [[ -n "${artifact_run_id}" ]]; then + echo "artifact_run_id=${artifact_run_id}" >> "$GITHUB_OUTPUT" + echo "reuse_artifacts=true" >> "$GITHUB_OUTPUT" + else + echo "artifact_run_id=" >> "$GITHUB_OUTPUT" + echo "reuse_artifacts=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ steps.meta.outputs.checkout_ref }} + + - name: Validate tag matches codex-enhanced runtime version + shell: bash + run: | + set -euo pipefail + runtime_ver="$(grep -m1 '^version' sdk/python-runtime-enhanced/pyproject.toml | sed -E 's/version *= *"([^"]+)".*/\1/')" + tag_ver="${{ steps.meta.outputs.release_version }}" + [[ "${tag_ver}" == "${runtime_ver}" ]] \ + || { echo "Tag version ${tag_ver} does not match sdk/python-runtime-enhanced ${runtime_ver}."; exit 1; } + + build: + needs: prepare + name: build-wheel-${{ matrix.target }} + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }} + runs-on: ${{ matrix.runner }} + permissions: + contents: read + env: + CARGO_INCREMENTAL: "0" + RUSTC_WRAPPER: "" + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + target: aarch64-apple-darwin + binary_name: codex + - runner: macos-15-intel + target: x86_64-apple-darwin + binary_name: codex + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + binary_name: codex + - runner: windows-2022 + target: x86_64-pc-windows-msvc + binary_name: codex.exe + steps: + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - uses: dtolnay/rust-toolchain@1.93.0 + with: + targets: ${{ matrix.target }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Python build dependencies + shell: bash + run: python -m pip install --upgrade build hatchling packaging + + - name: Clear workspace build rustflags for CI release builds + shell: bash + run: | + set -euo pipefail + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + + - name: Use default macOS linker + if: ${{ runner.os == 'macOS' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS=" >> "$GITHUB_ENV" + + - name: Install Linux build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y libcap-dev pkg-config protobuf-compiler + + - name: Install macOS build dependencies + if: ${{ runner.os == 'macOS' }} + shell: bash + run: | + set -euo pipefail + if ! command -v protoc >/dev/null 2>&1; then + brew install protobuf + fi + + - name: Install Windows build dependencies + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + if (-not (Get-Command protoc -ErrorAction SilentlyContinue)) { + choco install protoc -y --no-progress + } + + - name: Build release codex binary + shell: bash + working-directory: codex-rs + run: | + set -euo pipefail + cargo build --locked --release --target "${{ matrix.target }}" --bin codex + + - name: Stage codex-enhanced runtime package + shell: bash + run: | + set -euo pipefail + python sdk/python/scripts/update_sdk_artifacts.py \ + stage-runtime \ + "${RUNNER_TEMP}/codex-enhanced" \ + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/${{ matrix.binary_name }}" \ + --runtime-version "${{ needs.prepare.outputs.release_version }}" \ + --runtime-package enhanced + + - name: Build wheel + shell: bash + run: python -m build --wheel "${RUNNER_TEMP}/codex-enhanced" + + - name: Upload wheel artifact + uses: actions/upload-artifact@v4 + with: + name: pypi-wheel-${{ matrix.target }} + path: ${{ runner.temp }}/codex-enhanced/dist/* + if-no-files-found: error + + publish: + needs: + - prepare + - build + if: ${{ always() && needs.prepare.result == 'success' && (needs.build.result == 'success' || needs.build.result == 'skipped') }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + id-token: write + steps: + - name: Download wheel artifacts from current run + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }} + uses: actions/download-artifact@v4 + with: + pattern: pypi-wheel-* + path: dist + merge-multiple: true + + - name: Download wheel artifacts from an earlier run + if: ${{ needs.prepare.outputs.reuse_artifacts == 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p dist + work_dir="$(mktemp -d)" + gh run download "${{ needs.prepare.outputs.artifact_run_id }}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir "${work_dir}" + find "${work_dir}" -type f -name '*.whl' -exec mv {} dist/ \; + if ! find dist -maxdepth 1 -type f -name '*.whl' | grep -q .; then + echo "No wheel artifacts were downloaded from run ${{ needs.prepare.outputs.artifact_run_id }}." + exit 1 + fi + + - name: Publish codex-enhanced wheels to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true + verbose: true diff --git a/sdk/python-runtime-enhanced/README.md b/sdk/python-runtime-enhanced/README.md new file mode 100644 index 000000000..a08425cfd --- /dev/null +++ b/sdk/python-runtime-enhanced/README.md @@ -0,0 +1,33 @@ +# Codex Enhanced + +`codex-enhanced` packages the Codex CLI as a platform-specific Python wheel. +It is intended for users who want to install the enhanced CLI with `pip` and +run it directly without managing a separate native binary release. + +Website: `https://codex-enhanced.com` + +## Highlights + +- Loop automation with clear context modes: + - `embed`: submit the loop prompt into the main thread as a normal user turn + - `ephemeral`: fork compacted context for one run, then discard it + - `persistent`: keep a private retained context and refresh it with recent + main-thread messages +- Feishu clawbot integration for bound sessions, automatic inbound handling, and + plain-text outbound replies +- Fast `respawn` support so the CLI can restart and resume the current session + +## Install + +```bash +pip install codex-enhanced +``` + +## Run + +```bash +codex-enhanced +``` + +Each wheel includes the native `codex` binary for its target platform. This +package is wheel-only and is not intended to publish a source distribution. diff --git a/sdk/python-runtime-enhanced/hatch_build.py b/sdk/python-runtime-enhanced/hatch_build.py new file mode 100644 index 000000000..6f34d58ea --- /dev/null +++ b/sdk/python-runtime-enhanced/hatch_build.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from packaging.tags import sys_tags + + +class RuntimeBuildHook(BuildHookInterface): + def initialize(self, version: str, build_data: dict[str, object]) -> None: + del version + if self.target_name == "sdist": + raise RuntimeError( + "codex-enhanced is wheel-only; build and publish platform wheels only." + ) + + platform_tag = next(sys_tags()).platform + build_data["pure_python"] = False + build_data["tag"] = f"py3-none-{platform_tag}" diff --git a/sdk/python-runtime-enhanced/pyproject.toml b/sdk/python-runtime-enhanced/pyproject.toml new file mode 100644 index 000000000..bc10e94ea --- /dev/null +++ b/sdk/python-runtime-enhanced/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "codex-enhanced" +version = "0.1.22" +description = "Enhanced Codex CLI with loop automation, Feishu clawbot support, and fast session respawn" +readme = "README.md" +requires-python = ">=3" +license = { text = "Apache-2.0" } +authors = [{ name = "Piping" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", +] + +[project.urls] +Homepage = "https://codex-enhanced.com" +Repository = "https://github.com/Piping/codex-enhanced" +Issues = "https://github.com/Piping/codex-enhanced/issues" + +[project.scripts] +codex-enhanced = "codex_enhanced.__main__:main" + +[tool.hatch.build] +exclude = [ + ".venv/**", + ".pytest_cache/**", + "dist/**", + "build/**", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/codex_enhanced"] +include = ["src/codex_enhanced/bin/**"] + +[tool.hatch.build.targets.wheel.hooks.custom] + +[tool.hatch.build.targets.sdist] + +[tool.hatch.build.targets.sdist.hooks.custom] diff --git a/sdk/python-runtime-enhanced/src/codex_enhanced/__init__.py b/sdk/python-runtime-enhanced/src/codex_enhanced/__init__.py new file mode 100644 index 000000000..a640f6b15 --- /dev/null +++ b/sdk/python-runtime-enhanced/src/codex_enhanced/__init__.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import os +from pathlib import Path + +PACKAGE_NAME = "codex-enhanced" + + +def bundled_codex_path() -> Path: + exe = "codex.exe" if os.name == "nt" else "codex" + path = Path(__file__).resolve().parent / "bin" / exe + if not path.is_file(): + raise FileNotFoundError( + f"{PACKAGE_NAME} is installed but missing its packaged codex binary at {path}" + ) + return path + + +__all__ = ["PACKAGE_NAME", "bundled_codex_path"] diff --git a/sdk/python-runtime-enhanced/src/codex_enhanced/__main__.py b/sdk/python-runtime-enhanced/src/codex_enhanced/__main__.py new file mode 100644 index 000000000..0f2300f00 --- /dev/null +++ b/sdk/python-runtime-enhanced/src/codex_enhanced/__main__.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import os +import subprocess +import sys + +from . import bundled_codex_path + + +def main() -> int: + codex_path = bundled_codex_path() + argv = [str(codex_path), *sys.argv[1:]] + if os.name != "nt": + os.execv(argv[0], argv) + raise AssertionError("os.execv returned unexpectedly") + + return subprocess.call(argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 6685fd099..3507bdf15 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -26,8 +26,20 @@ def sdk_root() -> Path: return repo_root() / "sdk" / "python" -def python_runtime_root() -> Path: - return repo_root() / "sdk" / "python-runtime" +def python_runtime_root(runtime_package: str = "default") -> Path: + if runtime_package == "default": + return repo_root() / "sdk" / "python-runtime" + if runtime_package == "enhanced": + return repo_root() / "sdk" / "python-runtime-enhanced" + raise RuntimeError(f"Unsupported runtime package: {runtime_package}") + + +def runtime_python_package_dir(runtime_package: str = "default") -> str: + if runtime_package == "default": + return "codex_cli_bin" + if runtime_package == "enhanced": + return "codex_enhanced" + raise RuntimeError(f"Unsupported runtime package: {runtime_package}") def schema_bundle_path() -> Path: @@ -53,8 +65,14 @@ def runtime_binary_name() -> str: return "codex.exe" if _is_windows() else "codex" -def staged_runtime_bin_path(root: Path) -> Path: - return root / "src" / "codex_cli_bin" / "bin" / runtime_binary_name() +def staged_runtime_bin_path(root: Path, runtime_package: str = "default") -> Path: + return ( + root + / "src" + / runtime_python_package_dir(runtime_package) + / "bin" + / runtime_binary_name() + ) def run(cmd: list[str], cwd: Path) -> None: @@ -141,16 +159,19 @@ def stage_python_sdk_package( def stage_python_runtime_package( - staging_dir: Path, runtime_version: str, binary_path: Path + staging_dir: Path, + runtime_version: str, + binary_path: Path, + runtime_package: str = "default", ) -> Path: - _copy_package_tree(python_runtime_root(), staging_dir) + _copy_package_tree(python_runtime_root(runtime_package), staging_dir) pyproject_path = staging_dir / "pyproject.toml" pyproject_path.write_text( _rewrite_project_version(pyproject_path.read_text(), runtime_version) ) - out_bin = staged_runtime_bin_path(staging_dir) + out_bin = staged_runtime_bin_path(staging_dir, runtime_package) out_bin.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(binary_path, out_bin) if not _is_windows(): @@ -559,7 +580,7 @@ class PublicFieldSpec: class CliOps: generate_types: Callable[[], None] stage_python_sdk_package: Callable[[Path, str, str], Path] - stage_python_runtime_package: Callable[[Path, str, Path], Path] + stage_python_runtime_package: Callable[[Path, str, Path, str], Path] current_sdk_version: Callable[[], str] @@ -954,6 +975,12 @@ def build_parser() -> argparse.ArgumentParser: required=True, help="Version to write into the staged runtime package", ) + stage_runtime_parser.add_argument( + "--runtime-package", + choices=["default", "enhanced"], + default="default", + help="Runtime package template to stage", + ) return parser @@ -985,6 +1012,7 @@ def run_command(args: argparse.Namespace, ops: CliOps) -> None: args.staging_dir, args.runtime_version, args.runtime_binary.resolve(), + args.runtime_package, ) diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index b19dc745a..690fff033 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -263,6 +263,29 @@ def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) -> assert 'version = "1.2.3"' in (staged / "pyproject.toml").read_text() +def test_stage_enhanced_runtime_release_copies_binary_and_sets_version( + tmp_path: Path, +) -> None: + script = _load_update_script_module() + fake_binary = tmp_path / script.runtime_binary_name() + fake_binary.write_text("fake codex\n") + + staged = script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "1.2.3", + fake_binary, + "enhanced", + ) + + assert staged == tmp_path / "runtime-stage" + assert ( + script.staged_runtime_bin_path(staged, "enhanced").read_text() == "fake codex\n" + ) + pyproject = (staged / "pyproject.toml").read_text() + assert 'name = "codex-enhanced"' in pyproject + assert 'version = "1.2.3"' in pyproject + + def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> None: script = _load_update_script_module() staging_dir = tmp_path / "runtime-stage" @@ -329,7 +352,10 @@ def fake_stage_sdk_package( return tmp_path / "sdk-stage" def fake_stage_runtime_package( - _staging_dir: Path, _runtime_version: str, _runtime_binary: Path + _staging_dir: Path, + _runtime_version: str, + _runtime_binary: Path, + _runtime_package: str, ) -> Path: raise AssertionError("runtime staging should not run for stage-sdk") @@ -372,7 +398,10 @@ def fake_stage_sdk_package( raise AssertionError("sdk staging should not run for stage-runtime") def fake_stage_runtime_package( - _staging_dir: Path, _runtime_version: str, _runtime_binary: Path + _staging_dir: Path, + _runtime_version: str, + _runtime_binary: Path, + _runtime_package: str, ) -> Path: calls.append("stage_runtime") return tmp_path / "runtime-stage" @@ -392,6 +421,55 @@ def fake_current_sdk_version() -> str: assert calls == ["stage_runtime"] +def test_stage_enhanced_runtime_passes_package_choice(tmp_path: Path) -> None: + script = _load_update_script_module() + fake_binary = tmp_path / script.runtime_binary_name() + fake_binary.write_text("fake codex\n") + calls: list[tuple[str, str]] = [] + args = script.parse_args( + [ + "stage-runtime", + str(tmp_path / "runtime-stage"), + str(fake_binary), + "--runtime-version", + "1.2.3", + "--runtime-package", + "enhanced", + ] + ) + + def fake_generate_types() -> None: + raise AssertionError("type generation should not run for stage-runtime") + + def fake_stage_sdk_package( + _staging_dir: Path, _sdk_version: str, _runtime_version: str + ) -> Path: + raise AssertionError("sdk staging should not run for stage-runtime") + + def fake_stage_runtime_package( + _staging_dir: Path, + _runtime_version: str, + _runtime_binary: Path, + runtime_package: str, + ) -> Path: + calls.append(("stage_runtime", runtime_package)) + return tmp_path / "runtime-stage" + + def fake_current_sdk_version() -> str: + return "0.2.0" + + ops = script.CliOps( + generate_types=fake_generate_types, + stage_python_sdk_package=fake_stage_sdk_package, + stage_python_runtime_package=fake_stage_runtime_package, + current_sdk_version=fake_current_sdk_version, + ) + + script.run_command(args, ops) + + assert calls == [("stage_runtime", "enhanced")] + + def test_default_runtime_is_resolved_from_installed_runtime_package( tmp_path: Path, ) -> None: From 5b9d0af78cb490cb6a65b19f68c2ea6b0d89f9c8 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 12:13:12 +0800 Subject: [PATCH 45/60] tui: expand resume picker and custom prompts --- codex-rs/tui/src/app.rs | 25 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 352 +++++++++- codex-rs/tui/src/bottom_pane/command_popup.rs | 80 ++- .../tui/src/bottom_pane/custom_prompts.rs | 153 +++++ codex-rs/tui/src/bottom_pane/mod.rs | 9 + codex-rs/tui/src/bottom_pane/prompt_args.rs | 650 ++++++++++++++++++ .../src/bottom_pane/request_user_input/mod.rs | 8 +- ...ser__tests__slash_popup_custom_prompt.snap | 11 + codex-rs/tui/src/chatwidget.rs | 11 +- codex-rs/tui/src/history_cell.rs | 12 +- codex-rs/tui/src/lib.rs | 94 ++- codex-rs/tui/src/multi_agents.rs | 4 +- codex-rs/tui/src/resume_picker.rs | 390 ++++++++--- codex-rs/tui/src/text_formatting.rs | 2 +- 14 files changed, 1615 insertions(+), 186 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/custom_prompts.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_custom_prompt.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 16138679c..8b2245d57 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -678,10 +678,10 @@ impl ThreadEventStore { ServerNotification::TurnStarted(turn) => { self.active_turn_id = Some(turn.turn.id.clone()); } - ServerNotification::TurnCompleted(turn) => { - if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) { - self.active_turn_id = None; - } + ServerNotification::TurnCompleted(turn) + if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) => + { + self.active_turn_id = None; } ServerNotification::ThreadClosed(_) => { self.active_turn_id = None; @@ -4499,8 +4499,9 @@ impl App { match crate::resume_picker::run_resume_picker_with_app_server( tui, &self.config, - /*show_all*/ false, - /*include_non_interactive*/ false, + crate::resume_picker::SessionPickerOrder::LocalGroupFirst, + crate::resume_picker::SessionPickerProviderScope::AllProviders, + /*include_non_interactive*/ true, picker_app_server, ) .await? @@ -6680,14 +6681,10 @@ impl App { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. - } => { - if self.chat_widget.is_normal_backtrack_mode() - && self.chat_widget.composer_is_empty() - { - self.handle_backtrack_esc_key(tui); - } else { - self.chat_widget.handle_key_event(key_event); - } + } if self.chat_widget.is_normal_backtrack_mode() + && self.chat_widget.composer_is_empty() => + { + self.handle_backtrack_esc_key(tui); } // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. KeyEvent { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f11d3da89..35b1161a0 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -170,8 +170,16 @@ use super::skill_popup::MentionItem; use super::skill_popup::SkillPopup; use super::slash_commands; use super::slash_commands::BuiltinCommandFlags; +use crate::bottom_pane::CustomPrompt; +use crate::bottom_pane::PROMPTS_CMD_PREFIX; use crate::bottom_pane::paste_burst::FlushResult; +use crate::bottom_pane::prompt_args::PromptSelectionAction; +use crate::bottom_pane::prompt_args::PromptSelectionMode; +use crate::bottom_pane::prompt_args::expand_custom_prompt; +use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; use crate::bottom_pane::prompt_args::parse_slash_name; +use crate::bottom_pane::prompt_args::prompt_argument_names; +use crate::bottom_pane::prompt_args::prompt_selection_action; use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; @@ -305,6 +313,7 @@ pub(crate) struct ChatComposer { paste_burst: PasteBurst, // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, + custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, remote_image_urls: Vec, @@ -429,6 +438,7 @@ impl ChatComposer { input_disabled_placeholder: None, paste_burst: PasteBurst::default(), disable_paste_burst: false, + custom_prompts: Vec::new(), footer_mode: FooterMode::ComposerEmpty, footer_hint_override: None, remote_image_urls: Vec::new(), @@ -479,6 +489,13 @@ impl ChatComposer { self.skills = skills; } + pub fn set_custom_prompts(&mut self, prompts: Vec) { + self.custom_prompts = prompts.clone(); + if let ActivePopup::Command(popup) = &mut self.active_popup { + popup.set_prompts(prompts); + } + } + pub fn set_plugin_mentions(&mut self, plugins: Option>) { self.plugins = plugins; self.sync_popups(); @@ -1270,26 +1287,48 @@ impl ChatComposer { KeyEvent { code: KeyCode::Tab, .. } => { - // Ensure popup filtering/selection reflects the latest composer text - // before applying completion. let first_line = self.textarea.text().lines().next().unwrap_or(""); - popup.on_composer_text_change(first_line.to_string()); - if let Some(sel) = popup.selected_item() { - let CommandItem::Builtin(cmd) = sel; - if cmd == SlashCommand::Skills { - self.textarea.set_text_clearing_elements(""); - return (InputResult::Command(cmd), true); - } + let text_elements = self.textarea.text_elements(); + let selected = { + popup.on_composer_text_change(first_line.to_string()); + popup.selected_item() + }; + if let Some(selection) = selected { + match selection { + CommandItem::Builtin(cmd) => { + if cmd == SlashCommand::Skills { + self.textarea.set_text_clearing_elements(""); + return (InputResult::Command(cmd), true); + } - let starts_with_cmd = first_line - .trim_start() - .starts_with(&format!("/{}", cmd.command())); - if !starts_with_cmd { - self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); - } - if !self.textarea.text().is_empty() { - self.textarea.set_cursor(self.textarea.text().len()); + let starts_with_cmd = first_line + .trim_start() + .starts_with(&format!("/{}", cmd.command())); + if !starts_with_cmd { + self.textarea + .set_text_clearing_elements(&format!("/{} ", cmd.command())); + } + if !self.textarea.text().is_empty() { + self.textarea.set_cursor(self.textarea.text().len()); + } + } + CommandItem::UserPrompt(index) => { + if let Some(prompt) = popup.prompt(index).cloned() { + match prompt_selection_action( + &prompt, + first_line, + PromptSelectionMode::Completion, + &text_elements, + ) { + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or(text.len()); + self.textarea.set_text_clearing_elements(&text); + self.textarea.set_cursor(target); + } + PromptSelectionAction::Submit { .. } => {} + } + } + } } } (InputResult::None, true) @@ -1299,10 +1338,82 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - if let Some(sel) = popup.selected_item() { - let CommandItem::Builtin(cmd) = sel; + let mut text = self.textarea.text().to_string(); + let mut text_elements = self.textarea.text_elements(); + if !self.pending_pastes.is_empty() { + let (expanded, expanded_elements) = + Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + text = expanded; + text_elements = expanded_elements; + } + let first_line = text.lines().next().unwrap_or(""); + if let Some((name, _rest, _rest_offset)) = parse_slash_name(first_line) + && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) + && let Some(prompt) = self + .custom_prompts + .iter() + .find(|prompt| prompt.name == prompt_name) + && let Some(expanded) = + expand_if_numeric_with_positional_args(prompt, first_line, &text_elements) + { + self.prune_attached_images_for_submission( + &expanded.text, + &expanded.text_elements, + ); + self.pending_pastes.clear(); self.textarea.set_text_clearing_elements(""); - return (InputResult::Command(cmd), true); + return ( + InputResult::Submitted { + text: expanded.text, + text_elements: expanded.text_elements, + }, + true, + ); + } + + if let Some(selection) = popup.selected_item() { + match selection { + CommandItem::Builtin(cmd) => { + self.textarea.set_text_clearing_elements(""); + return (InputResult::Command(cmd), true); + } + CommandItem::UserPrompt(index) => { + if let Some(prompt) = popup.prompt(index).cloned() { + match prompt_selection_action( + &prompt, + first_line, + PromptSelectionMode::Submit, + &text_elements, + ) { + PromptSelectionAction::Submit { + text, + text_elements, + } => { + self.prune_attached_images_for_submission( + &text, + &text_elements, + ); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text, + text_elements, + }, + true, + ); + } + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or(text.len()); + self.textarea.set_text_clearing_elements(&text); + self.textarea.set_cursor(target); + return (InputResult::None, true); + } + } + } + self.active_popup = ActivePopup::None; + return (InputResult::None, true); + } + } } // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) @@ -2090,7 +2201,15 @@ impl ChatComposer { let is_builtin = slash_commands::find_builtin_command(name, self.builtin_command_flags()) .is_some(); - if !is_builtin { + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + let is_known_prompt = + name.strip_prefix(&prompt_prefix) + .is_some_and(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }); + if !is_builtin && !is_known_prompt { let message = format!( r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# ); @@ -2110,6 +2229,31 @@ impl ChatComposer { } } + if self.slash_commands_enabled() { + let expanded_prompt = + match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.set_text_content_with_mention_bindings( + original_input.clone(), + original_text_elements, + original_local_image_paths, + original_mention_bindings, + ); + self.pending_pastes.clone_from(&original_pending_pastes); + self.textarea.set_cursor(original_input.len()); + return None; + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded.text; + text_elements = expanded.text_elements; + } + } + let actual_chars = text.chars().count(); if actual_chars > MAX_USER_INPUT_TEXT_CHARS { let message = user_input_too_large_message(actual_chars); @@ -2176,6 +2320,10 @@ impl ChatComposer { return (result, true); } + if self.try_insert_bare_custom_prompt_for_editing() { + return (InputResult::None, true); + } + // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst // and accumulate it rather than submitting or inserting immediately. // Do not treat as paste inside a slash-command context. @@ -2277,6 +2425,44 @@ impl ChatComposer { } } + pub(crate) fn try_insert_bare_custom_prompt_for_editing(&mut self) -> bool { + if !self.slash_commands_enabled() { + return false; + } + + let composer_text = self.textarea.text().to_string(); + let first_line = composer_text.lines().next().unwrap_or(""); + if composer_text.trim() != first_line.trim() { + return false; + } + + let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) else { + return false; + }; + if !rest.is_empty() { + return false; + } + + let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return false; + }; + let Some(prompt) = self + .custom_prompts + .iter() + .find(|prompt| prompt.name == prompt_name) + else { + return false; + }; + if !prompt_argument_names(&prompt.content).is_empty() { + return false; + } + + self.textarea.set_text_clearing_elements(&prompt.content); + self.textarea.set_cursor(prompt.content.len()); + self.active_popup = ActivePopup::None; + true + } + /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_slash_command_with_args(&mut self) -> Option { @@ -2976,7 +3162,18 @@ impl ChatComposer { } fn is_known_slash_name(&self, name: &str) -> bool { - slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some() + let is_builtin = + slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some(); + if is_builtin { + return true; + } + name.strip_prefix(PROMPTS_CMD_PREFIX) + .and_then(|rest| rest.strip_prefix(':')) + .is_some_and(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }) } /// If the cursor is currently within a slash command on the first line, @@ -3018,7 +3215,17 @@ impl ChatComposer { return rest_after_name.is_empty(); } - slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) + if slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) { + return true; + } + + let name_lower = name.to_ascii_lowercase(); + self.custom_prompts.iter().any(|prompt| { + prompt.name.to_ascii_lowercase().starts_with(&name_lower) + || format!("{PROMPTS_CMD_PREFIX}:{}", prompt.name) + .to_ascii_lowercase() + .starts_with(&name_lower) + }) } /// Synchronize `self.command_popup` with the current text in the @@ -3078,6 +3285,7 @@ impl ChatComposer { audio_device_selection_enabled, windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, }); + command_popup.set_prompts(self.custom_prompts.clone()); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -6023,6 +6231,9 @@ mod tests { Some(CommandItem::Builtin(cmd)) => { assert_eq!(cmd.command(), "model") } + Some(CommandItem::UserPrompt(_)) => { + panic!("expected builtin command selection for '/mo'") + } None => panic!("no selected command for '/mo'"), }, _ => panic!("slash popup not active after typing '/mo'"), @@ -6076,12 +6287,105 @@ mod tests { Some(CommandItem::Builtin(cmd)) => { assert_eq!(cmd.command(), "resume") } + Some(CommandItem::UserPrompt(_)) => { + panic!("expected builtin command selection for '/res'") + } None => panic!("no selected command for '/res'"), }, _ => panic!("slash popup not active after typing '/res'"), } } + #[test] + fn slash_popup_custom_prompt_snapshot() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_custom_prompts(vec![CustomPrompt { + name: "specialist".to_string(), + path: "/tmp/specialist.md".into(), + content: "Act as a specialist".to_string(), + description: Some("saved prompt".to_string()), + argument_hint: None, + }]); + type_chars_humanlike(&mut composer, &['/', 's', 'p', 'e', 'c']); + + let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_custom_prompt", terminal.backend()); + } + + #[test] + fn bare_custom_prompt_enter_inserts_prompt_for_editing() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_custom_prompts(vec![CustomPrompt { + name: "review".to_string(), + path: "/tmp/review.md".into(), + content: "Review these changes".to_string(), + description: Some("saved prompt".to_string()), + argument_hint: None, + }]); + composer.handle_paste("/prompts:review".to_string()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert_eq!(composer.textarea.text(), "Review these changes"); + } + + #[test] + fn custom_prompt_arguments_expand_on_submit() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_custom_prompts(vec![CustomPrompt { + name: "elegant".to_string(), + path: "/tmp/elegant.md".into(), + content: "Echo: $ARGUMENTS".to_string(), + description: Some("saved prompt".to_string()), + argument_hint: None, + }]); + composer.handle_paste("/prompts:elegant hello world".to_string()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + result, + InputResult::Submitted { + text: "Echo: hello world".to_string(), + text_elements: Vec::new(), + } + ); + } + fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool { std::thread::sleep(PasteBurst::recommended_active_flush_delay()); composer.flush_paste_burst_if_due() diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index eeb374a54..4049f077e 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -7,9 +7,12 @@ use super::scroll_state::ScrollState; use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::render_rows; use super::slash_commands; +use crate::bottom_pane::CustomPrompt; +use crate::bottom_pane::PROMPTS_CMD_PREFIX; use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; +use std::collections::HashSet; // Hide alias commands in the default popup list so each unique action appears once. // `quit` is an alias of `exit`, so we skip `quit` here. @@ -20,11 +23,13 @@ const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Appr #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { Builtin(SlashCommand), + UserPrompt(usize), } pub(crate) struct CommandPopup { command_filter: String, builtins: Vec<(&'static str, SlashCommand)>, + prompts: Vec, state: ScrollState, } @@ -67,10 +72,26 @@ impl CommandPopup { Self { command_filter: String::new(), builtins, + prompts: Vec::new(), state: ScrollState::new(), } } + pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { + let exclude: HashSet = self + .builtins + .iter() + .map(|(name, _)| (*name).to_string()) + .collect(); + prompts.retain(|prompt| !exclude.contains(&prompt.name)); + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + self.prompts = prompts; + } + + pub(crate) fn prompt(&self, index: usize) -> Option<&CustomPrompt> { + self.prompts.get(index) + } + /// Update the filter string based on the current composer text. The text /// passed in is expected to start with a leading '/'. Everything after the /// *first* '/' on the *first* line becomes the active filter that is used @@ -124,6 +145,9 @@ impl CommandPopup { } out.push((CommandItem::Builtin(*cmd), None)); } + for index in 0..self.prompts.len() { + out.push((CommandItem::UserPrompt(index), None)); + } return out; } @@ -131,6 +155,7 @@ impl CommandPopup { let filter_chars = filter.chars().count(); let mut exact: Vec<(CommandItem, Option>)> = Vec::new(); let mut prefix: Vec<(CommandItem, Option>)> = Vec::new(); + let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1; let indices_for = |offset| Some((offset..offset + filter_chars).collect()); let mut push_match = @@ -157,6 +182,15 @@ impl CommandPopup { for (_, cmd) in self.builtins.iter() { push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); } + for (index, prompt) in self.prompts.iter().enumerate() { + let display = format!("{PROMPTS_CMD_PREFIX}:{}", prompt.name); + push_match( + CommandItem::UserPrompt(index), + &display, + Some(&prompt.name), + prompt_prefix_len, + ); + } out.extend(exact); out.extend(prefix); @@ -174,9 +208,21 @@ impl CommandPopup { matches .into_iter() .map(|(item, indices)| { - let CommandItem::Builtin(cmd) = item; - let name = format!("/{}", cmd.command()); - let description = cmd.description().to_string(); + let (name, description) = match item { + CommandItem::Builtin(cmd) => { + (format!("/{}", cmd.command()), cmd.description().to_string()) + } + CommandItem::UserPrompt(index) => { + let prompt = &self.prompts[index]; + ( + format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name), + prompt + .description + .clone() + .unwrap_or_else(|| "send saved prompt".to_string()), + ) + } + }; GenericDisplayRow { name, name_prefix_spans: Vec::new(), @@ -249,6 +295,7 @@ mod tests { let matches = popup.filtered_items(); let has_init = matches.iter().any(|item| match item { CommandItem::Builtin(cmd) => cmd.command() == "init", + CommandItem::UserPrompt(_) => false, }); assert!( has_init, @@ -266,6 +313,9 @@ mod tests { let selected = popup.selected_item(); match selected { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), + Some(CommandItem::UserPrompt(_)) => { + panic!("expected a built-in command for exact match") + } None => panic!("expected a selected command for exact match"), } } @@ -277,6 +327,9 @@ mod tests { let matches = popup.filtered_items(); match matches.first() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), + Some(CommandItem::UserPrompt(_)) => { + panic!("expected a built-in command for '/mo'") + } None => panic!("expected at least one match for '/mo'"), } } @@ -291,6 +344,7 @@ mod tests { .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); assert_eq!(cmds, vec!["model", "mention", "mcp"]); @@ -306,6 +360,7 @@ mod tests { .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); assert!( @@ -336,6 +391,7 @@ mod tests { .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); assert!( @@ -407,6 +463,7 @@ mod tests { .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); assert!( @@ -454,6 +511,7 @@ mod tests { .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); @@ -471,6 +529,7 @@ mod tests { .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); @@ -479,4 +538,19 @@ mod tests { "expected no /debug* command in popup menu, got {cmds:?}" ); } + + #[test] + fn custom_prompt_matches_by_name() { + let mut popup = CommandPopup::new(CommandPopupFlags::default()); + popup.set_prompts(vec![CustomPrompt { + name: "specialist".to_string(), + path: "/tmp/specialist.md".into(), + content: "Act as a specialist".to_string(), + description: Some("saved prompt".to_string()), + argument_hint: None, + }]); + popup.on_composer_text_change("/spec".to_string()); + + assert_eq!(popup.selected_item(), Some(CommandItem::UserPrompt(0))); + } } diff --git a/codex-rs/tui/src/bottom_pane/custom_prompts.rs b/codex-rs/tui/src/bottom_pane/custom_prompts.rs new file mode 100644 index 000000000..ea61359be --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/custom_prompts.rs @@ -0,0 +1,153 @@ +use std::collections::HashSet; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +/// Base namespace for custom prompt slash commands (without trailing colon). +pub(crate) const PROMPTS_CMD_PREFIX: &str = "prompts"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CustomPrompt { + pub(crate) name: String, + pub(crate) path: PathBuf, + pub(crate) content: String, + pub(crate) description: Option, + pub(crate) argument_hint: Option, +} + +pub(crate) fn discover_prompts_in(dir: &Path) -> Vec { + discover_prompts_in_excluding(dir, &HashSet::new()) +} + +fn discover_prompts_in_excluding(dir: &Path, exclude: &HashSet) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + + let mut prompts = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let is_md = path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")); + if !is_md { + continue; + } + let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + if exclude.contains(name) { + continue; + } + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + let (description, argument_hint, body) = parse_frontmatter(&content); + prompts.push(CustomPrompt { + name: name.to_string(), + path, + content: body, + description, + argument_hint, + }); + } + + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + prompts +} + +fn parse_frontmatter(content: &str) -> (Option, Option, String) { + let mut segments = content.split_inclusive('\n'); + let Some(first_segment) = segments.next() else { + return (None, None, String::new()); + }; + let first_line = first_segment.trim_end_matches(['\r', '\n']); + if first_line.trim() != "---" { + return (None, None, content.to_string()); + } + + let mut description = None; + let mut argument_hint = None; + let mut frontmatter_closed = false; + let mut consumed = first_segment.len(); + + for segment in segments { + let line = segment.trim_end_matches(['\r', '\n']); + let trimmed = line.trim(); + + if trimmed == "---" { + frontmatter_closed = true; + consumed += segment.len(); + break; + } + + if trimmed.is_empty() || trimmed.starts_with('#') { + consumed += segment.len(); + continue; + } + + if let Some((key, value)) = trimmed.split_once(':') { + let normalized_key = key.trim().to_ascii_lowercase(); + let mut normalized_value = value.trim().to_string(); + if normalized_value.len() >= 2 { + let bytes = normalized_value.as_bytes(); + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { + normalized_value = normalized_value[1..normalized_value.len() - 1].to_string(); + } + } + match normalized_key.as_str() { + "description" => description = Some(normalized_value), + "argument-hint" | "argument_hint" => argument_hint = Some(normalized_value), + _ => {} + } + } + + consumed += segment.len(); + } + + if !frontmatter_closed { + return (None, None, content.to_string()); + } + + let body = if consumed >= content.len() { + String::new() + } else { + content[consumed..].to_string() + }; + (description, argument_hint, body) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn parse_frontmatter_extracts_description_and_hint() { + let content = "---\n\ +description: review prompt\n\ +argument-hint: USER=\"...\"\n\ +---\n\ +Body text\n"; + + let (description, argument_hint, body) = parse_frontmatter(content); + assert_eq!(description, Some("review prompt".to_string())); + assert_eq!(argument_hint, Some("USER=\"...\"".to_string())); + assert_eq!(body, "Body text\n".to_string()); + } + + #[test] + fn parse_frontmatter_leaves_plain_markdown_unchanged() { + let content = "# Prompt\n\nBody\n"; + let (description, argument_hint, body) = parse_frontmatter(content); + assert_eq!(description, None); + assert_eq!(argument_hint, None); + assert_eq!(body, content.to_string()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 666f6a532..3bee52817 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -77,6 +77,7 @@ mod chat_composer; mod chat_composer_history; mod command_popup; pub mod custom_prompt_view; +mod custom_prompts; mod experimental_features_view; mod file_search_popup; mod footer; @@ -149,6 +150,9 @@ pub(crate) use chat_composer::InputResult; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; +pub(crate) use custom_prompts::CustomPrompt; +pub(crate) use custom_prompts::PROMPTS_CMD_PREFIX; +pub(crate) use custom_prompts::discover_prompts_in; pub(crate) use experimental_features_view::ExperimentalFeatureItem; pub(crate) use experimental_features_view::ExperimentalFeaturesView; #[cfg(test)] @@ -251,6 +255,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_custom_prompts(&mut self, prompts: Vec) { + self.composer.set_custom_prompts(prompts); + self.request_redraw(); + } + /// Update image-paste behavior for the active composer and repaint immediately. /// /// Callers use this to keep composer affordances aligned with model capabilities. diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index 7c816d5ee..5f3085c25 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -1,3 +1,18 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::LazyLock; + +use regex_lite::Regex; +use shlex::Shlex; + +use super::custom_prompts::CustomPrompt; +use super::custom_prompts::PROMPTS_CMD_PREFIX; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; + +static PROMPT_ARG_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort())); + /// Parse a first-line slash command of the form `/name `. /// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/` /// and contains a non-empty name; otherwise returns `None`. @@ -23,3 +38,638 @@ pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> { let rest_offset = rest_start_in_stripped + 1; Some((name, rest, rest_offset)) } + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PromptArg { + pub(crate) text: String, + pub(crate) text_elements: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PromptExpansion { + pub(crate) text: String, + pub(crate) text_elements: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PromptSelectionMode { + Completion, + Submit, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum PromptSelectionAction { + Insert { + text: String, + cursor: Option, + }, + Submit { + text: String, + text_elements: Vec, + }, +} + +#[derive(Debug)] +pub(crate) enum PromptArgsError { + MissingAssignment { token: String }, + MissingKey { token: String }, +} + +impl PromptArgsError { + fn describe(&self, command: &str) -> String { + match self { + PromptArgsError::MissingAssignment { token } => format!( + "Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces." + ), + PromptArgsError::MissingKey { token } => { + format!("Could not parse {command}: expected a name before '=' in '{token}'.") + } + } + } +} + +#[derive(Debug)] +pub(crate) enum PromptExpansionError { + Args { + command: String, + error: PromptArgsError, + }, + MissingArgs { + command: String, + missing: Vec, + }, +} + +impl PromptExpansionError { + pub(crate) fn user_message(&self) -> String { + match self { + PromptExpansionError::Args { command, error } => error.describe(command), + PromptExpansionError::MissingArgs { command, missing } => { + let list = missing.join(", "); + format!( + "Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)." + ) + } + } + } +} + +pub(crate) fn prompt_argument_names(content: &str) -> Vec { + let mut seen = HashSet::new(); + let mut names = Vec::new(); + for matched in PROMPT_ARG_REGEX.find_iter(content) { + if matched.start() > 0 && content.as_bytes()[matched.start() - 1] == b'$' { + continue; + } + let name = &content[matched.start() + 1..matched.end()]; + if name == "ARGUMENTS" { + continue; + } + let name = name.to_string(); + if seen.insert(name.clone()) { + names.push(name); + } + } + names +} + +pub(crate) fn prompt_has_numeric_placeholders(content: &str) -> bool { + if content.contains("$ARGUMENTS") { + return true; + } + let bytes = content.as_bytes(); + let mut index = 0; + while index + 1 < bytes.len() { + if bytes[index] == b'$' && (b'1'..=b'9').contains(&bytes[index + 1]) { + return true; + } + index += 1; + } + false +} + +pub(crate) fn prompt_selection_action( + prompt: &CustomPrompt, + first_line: &str, + mode: PromptSelectionMode, + text_elements: &[TextElement], +) -> PromptSelectionAction { + let named_args = prompt_argument_names(&prompt.content); + let has_numeric = prompt_has_numeric_placeholders(&prompt.content); + + match mode { + PromptSelectionMode::Completion => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); + PromptSelectionAction::Insert { + cursor: Some(text.len()), + text, + } + } + PromptSelectionMode::Submit => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + if has_numeric { + if let Some(expanded) = + expand_if_numeric_with_positional_args(prompt, first_line, text_elements) + { + return PromptSelectionAction::Submit { + text: expanded.text, + text_elements: expanded.text_elements, + }; + } + return PromptSelectionAction::Insert { + text: format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name), + cursor: None, + }; + } + PromptSelectionAction::Insert { + text: prompt.content.clone(), + cursor: Some(prompt.content.len()), + } + } + } +} + +pub(crate) fn expand_custom_prompt( + text: &str, + text_elements: &[TextElement], + custom_prompts: &[CustomPrompt], +) -> Result, PromptExpansionError> { + let Some((name, rest, rest_offset)) = parse_slash_name(text) else { + return Ok(None); + }; + let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Ok(None); + }; + let Some(prompt) = custom_prompts + .iter() + .find(|prompt| prompt.name == prompt_name) + else { + return Ok(None); + }; + + let required = prompt_argument_names(&prompt.content); + let local_elements: Vec = text_elements + .iter() + .filter_map(|element| { + let mut shifted = shift_text_element_left(element, rest_offset)?; + if shifted.byte_range.start >= rest.len() { + return None; + } + shifted.byte_range.end = shifted.byte_range.end.min(rest.len()); + (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) + }) + .collect(); + + if !required.is_empty() { + let inputs = parse_prompt_inputs(rest, &local_elements).map_err(|error| { + PromptExpansionError::Args { + command: format!("/{name}"), + error, + } + })?; + let missing: Vec = required + .into_iter() + .filter(|key| !inputs.contains_key(key)) + .collect(); + if !missing.is_empty() { + return Err(PromptExpansionError::MissingArgs { + command: format!("/{name}"), + missing, + }); + } + let (expanded_text, expanded_elements) = + expand_named_placeholders_with_elements(&prompt.content, &inputs); + return Ok(Some(PromptExpansion { + text: expanded_text, + text_elements: expanded_elements, + })); + } + + let positional_args = parse_positional_args(rest, &local_elements); + Ok(Some(expand_numeric_placeholders( + &prompt.content, + &positional_args, + ))) +} + +pub(crate) fn expand_if_numeric_with_positional_args( + prompt: &CustomPrompt, + first_line: &str, + text_elements: &[TextElement], +) -> Option { + if !prompt_argument_names(&prompt.content).is_empty() { + return None; + } + if !prompt_has_numeric_placeholders(&prompt.content) { + return None; + } + let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements); + if args.is_empty() { + return None; + } + Some(expand_numeric_placeholders(&prompt.content, &args)) +} + +fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec { + parse_tokens_with_elements(rest, text_elements) +} + +fn parse_prompt_inputs( + rest: &str, + text_elements: &[TextElement], +) -> Result, PromptArgsError> { + let mut inputs = HashMap::new(); + if rest.trim().is_empty() { + return Ok(inputs); + } + + for token in parse_tokens_with_elements(rest, text_elements) { + let Some((key, value)) = token.text.split_once('=') else { + return Err(PromptArgsError::MissingAssignment { token: token.text }); + }; + if key.is_empty() { + return Err(PromptArgsError::MissingKey { token: token.text }); + } + let value_start = key.len() + 1; + let value_elements = token + .text_elements + .iter() + .filter_map(|element| shift_text_element_left(element, value_start)) + .collect(); + inputs.insert( + key.to_string(), + PromptArg { + text: value.to_string(), + text_elements: value_elements, + }, + ); + } + Ok(inputs) +} + +fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec { + let mut elements = text_elements.to_vec(); + elements.sort_by_key(|element| element.byte_range.start); + let (text_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements); + Shlex::new(&text_for_shlex) + .map(|token| apply_replacements_to_token(token, &replacements)) + .collect() +} + +fn replace_text_elements_with_sentinels( + rest: &str, + elements: &[TextElement], +) -> (String, Vec) { + let mut out = String::with_capacity(rest.len()); + let mut replacements = Vec::new(); + let mut cursor = 0; + + for (index, element) in elements.iter().enumerate() { + let start = element.byte_range.start; + let end = element.byte_range.end; + out.push_str(&rest[cursor..start]); + let mut sentinel = format!("__CODEX_ELEM_{index}__"); + while rest.contains(&sentinel) { + sentinel.push('_'); + } + out.push_str(&sentinel); + replacements.push(ElementReplacement { + sentinel, + text: rest[start..end].to_string(), + placeholder: element.placeholder(rest).map(str::to_string), + }); + cursor = end; + } + + out.push_str(&rest[cursor..]); + (out, replacements) +} + +fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg { + if replacements.is_empty() { + return PromptArg { + text: token, + text_elements: Vec::new(), + }; + } + + let mut out = String::with_capacity(token.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + + while cursor < token.len() { + let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else { + out.push_str(&token[cursor..]); + break; + }; + let start_in_token = cursor + offset; + out.push_str(&token[cursor..start_in_token]); + let start = out.len(); + out.push_str(&replacement.text); + let end = out.len(); + if start < end { + out_elements.push(TextElement::new( + ByteRange { start, end }, + replacement.placeholder.clone(), + )); + } + cursor = start_in_token + replacement.sentinel.len(); + } + + PromptArg { + text: out, + text_elements: out_elements, + } +} + +fn next_replacement<'a>( + token: &str, + cursor: usize, + replacements: &'a [ElementReplacement], +) -> Option<(usize, &'a ElementReplacement)> { + let slice = &token[cursor..]; + let mut best = None; + for replacement in replacements { + if let Some(position) = slice.find(&replacement.sentinel) { + match best { + Some((best_position, _)) if best_position <= position => {} + _ => best = Some((position, replacement)), + } + } + } + best +} + +fn expand_named_placeholders_with_elements( + content: &str, + args: &HashMap, +) -> (String, Vec) { + let mut out = String::with_capacity(content.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + + for matched in PROMPT_ARG_REGEX.find_iter(content) { + let start = matched.start(); + let end = matched.end(); + if start > 0 && content.as_bytes()[start - 1] == b'$' { + out.push_str(&content[cursor..end]); + cursor = end; + continue; + } + out.push_str(&content[cursor..start]); + cursor = end; + let key = &content[start + 1..end]; + if let Some(arg) = args.get(key) { + append_arg_with_elements(&mut out, &mut out_elements, arg); + } else { + out.push_str(&content[start..end]); + } + } + + out.push_str(&content[cursor..]); + (out, out_elements) +} + +fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion { + let mut out = String::with_capacity(content.len()); + let mut out_elements = Vec::new(); + let mut index = 0; + + while let Some(offset) = content[index..].find('$') { + let placeholder_start = index + offset; + out.push_str(&content[index..placeholder_start]); + let rest = &content[placeholder_start..]; + let bytes = rest.as_bytes(); + if bytes.len() >= 2 { + match bytes[1] { + b'$' => { + out.push_str("$$"); + index = placeholder_start + 2; + continue; + } + b'1'..=b'9' => { + let arg_index = (bytes[1] - b'1') as usize; + if let Some(arg) = args.get(arg_index) { + append_arg_with_elements(&mut out, &mut out_elements, arg); + } + index = placeholder_start + 2; + continue; + } + _ => {} + } + } + if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { + if !args.is_empty() { + append_joined_args_with_elements(&mut out, &mut out_elements, args); + } + index = placeholder_start + 1 + "ARGUMENTS".len(); + continue; + } + out.push('$'); + index = placeholder_start + 1; + } + + out.push_str(&content[index..]); + PromptExpansion { + text: out, + text_elements: out_elements, + } +} + +fn extract_positional_args_for_prompt_line( + line: &str, + prompt_name: &str, + text_elements: &[TextElement], +) -> Vec { + let trimmed = line.trim_start(); + let trim_offset = line.len() - trimmed.len(); + let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else { + return Vec::new(); + }; + let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Vec::new(); + }; + if after_prefix != prompt_name { + return Vec::new(); + } + let rest_trimmed_start = rest.trim_start(); + let args_str = rest_trimmed_start.trim_end(); + if args_str.is_empty() { + return Vec::new(); + } + let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len()); + let local_elements: Vec = text_elements + .iter() + .filter_map(|element| { + let mut shifted = shift_text_element_left(element, args_offset)?; + if shifted.byte_range.start >= args_str.len() { + return None; + } + shifted.byte_range.end = shifted.byte_range.end.min(args_str.len()); + (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) + }) + .collect(); + parse_positional_args(args_str, &local_elements) +} + +fn shift_text_element_left(element: &TextElement, offset: usize) -> Option { + if element.byte_range.end <= offset { + return None; + } + let start = element.byte_range.start.saturating_sub(offset); + let end = element.byte_range.end.saturating_sub(offset); + (start < end).then_some(element.map_range(|_| ByteRange { start, end })) +} + +fn append_arg_with_elements( + out: &mut String, + out_elements: &mut Vec, + arg: &PromptArg, +) { + let start = out.len(); + out.push_str(&arg.text); + if arg.text_elements.is_empty() { + return; + } + out_elements.extend(arg.text_elements.iter().map(|element| { + element.map_range(|range| ByteRange { + start: start + range.start, + end: start + range.end, + }) + })); +} + +fn append_joined_args_with_elements( + out: &mut String, + out_elements: &mut Vec, + args: &[PromptArg], +) { + for (index, arg) in args.iter().enumerate() { + if index > 0 { + out.push(' '); + } + append_arg_with_elements(out, out_elements, arg); + } +} + +fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) { + let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}"); + let mut cursor = text.len(); + for (index, arg) in args.iter().enumerate() { + text.push_str(format!(" {arg}=\"\"").as_str()); + if index == 0 { + cursor = text.len() - 1; + } + } + (text, cursor) +} + +#[derive(Debug, Clone)] +struct ElementReplacement { + sentinel: String, + text: String, + placeholder: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn prompt(name: &str, content: &str) -> CustomPrompt { + CustomPrompt { + name: name.to_string(), + path: format!("/tmp/{name}.md").into(), + content: content.to_string(), + description: None, + argument_hint: None, + } + } + + #[test] + fn expand_named_arguments() { + let prompts = vec![prompt("review", "Review $USER changes on $BRANCH")]; + let expanded = + expand_custom_prompt("/prompts:review USER=Alice BRANCH=main", &[], &prompts) + .expect("expand custom prompt"); + assert_eq!( + expanded, + Some(PromptExpansion { + text: "Review Alice changes on main".to_string(), + text_elements: Vec::new(), + }) + ); + } + + #[test] + fn expand_numeric_arguments() { + let prompts = vec![prompt("rewrite", "Rewrite $1 as $2")]; + let expanded = expand_custom_prompt("/prompts:rewrite draft polished", &[], &prompts) + .expect("expand custom prompt"); + assert_eq!( + expanded, + Some(PromptExpansion { + text: "Rewrite draft as polished".to_string(), + text_elements: Vec::new(), + }) + ); + } + + #[test] + fn missing_required_args_reports_error() { + let prompts = vec![prompt("review", "Review $USER changes on $BRANCH")]; + let err = expand_custom_prompt("/prompts:review USER=Alice", &[], &prompts) + .expect_err("missing args should fail") + .user_message(); + assert!(err.contains("BRANCH")); + } + + #[test] + fn prompt_selection_submit_inserts_plain_prompt_body() { + let action = prompt_selection_action( + &prompt("rewrite", "Please rewrite this draft"), + "/prompts:rewrite", + PromptSelectionMode::Submit, + &[], + ); + assert_eq!( + action, + PromptSelectionAction::Insert { + text: "Please rewrite this draft".to_string(), + cursor: Some("Please rewrite this draft".len()), + } + ); + } + + #[test] + fn prompt_selection_completion_for_named_args_inserts_placeholders() { + let action = prompt_selection_action( + &prompt("review", "Review $USER changes on $BRANCH"), + "/review", + PromptSelectionMode::Completion, + &[], + ); + assert_eq!( + action, + PromptSelectionAction::Insert { + text: "/prompts:review USER=\"\" BRANCH=\"\"".to_string(), + cursor: Some("/prompts:review USER=\"".len()), + } + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index f7db7c65d..cd571ccca 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1110,11 +1110,9 @@ impl BottomPaneView for RequestUserInputOverlay { KeyCode::Backspace | KeyCode::Delete => { self.clear_selection(); } - KeyCode::Tab => { - if self.selected_option_index().is_some() { - self.focus = Focus::Notes; - self.ensure_selected_for_notes(); - } + KeyCode::Tab if self.selected_option_index().is_some() => { + self.focus = Focus::Notes; + self.ensure_selected_for_notes(); } KeyCode::Enter => { let has_selection = self.selected_option_index().is_some(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_custom_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_custom_prompt.snap new file mode 100644 index 000000000..24523ba06 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_custom_prompt.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 6340 +expression: terminal.backend() +--- +" " +"› /spec " +" " +" " +" " +" /prompts:specialist saved prompt " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 68d19d598..5fd3f839a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -319,6 +319,7 @@ use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; +use crate::bottom_pane::discover_prompts_in; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::clipboard_text; @@ -4838,6 +4839,10 @@ impl ChatWidget { widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + let prompts_dir = widget.config.codex_home.join("prompts"); + widget + .bottom_pane + .set_custom_prompts(discover_prompts_in(&prompts_dir)); widget.refresh_status_surfaces(); widget @@ -6837,10 +6842,8 @@ impl ChatWidget { reasoning_effort, agents_states, }), - ThreadItem::EnteredReviewMode { review, .. } => { - if !from_replay { - self.enter_review_mode_with_hint(review, /*from_replay*/ false); - } + ThreadItem::EnteredReviewMode { review, .. } if !from_replay => { + self.enter_review_mode_with_hint(review, /*from_replay*/ false); } _ => {} } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 6cecb148a..3337f3dfe 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1189,7 +1189,7 @@ fn with_border_internal( let span_count = line.spans.len(); let mut spans: Vec> = Vec::with_capacity(span_count + 4); spans.push(Span::from("│ ").dim()); - spans.extend(line.into_iter()); + spans.extend(line); if used_width < content_width { spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); } @@ -1975,7 +1975,7 @@ pub(crate) fn new_mcp_tools_output( let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); let effective_servers = mcp_manager.effective_servers(config, /*auth*/ None); let mut servers: Vec<_> = effective_servers.iter().collect(); - servers.sort_by(|(a, _), (b, _)| a.cmp(b)); + servers.sort_by_key(|(a, _)| *a); for (server, cfg) in servers { let prefix = qualified_mcp_tool_name_prefix(server); @@ -2041,7 +2041,7 @@ pub(crate) fn new_mcp_tools_output( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(a, _)| *a); let display = pairs .into_iter() .map(|(name, _)| format!("{name}=*****")) @@ -2053,7 +2053,7 @@ pub(crate) fn new_mcp_tools_output( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(a, _)| *a); let display = pairs .into_iter() .map(|(name, var)| format!("{name}={var}")) @@ -2208,7 +2208,7 @@ pub(crate) fn new_mcp_tools_output_from_statuses( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(a, _)| *a); let display = pairs .into_iter() .map(|(name, _)| format!("{name}=*****")) @@ -2220,7 +2220,7 @@ pub(crate) fn new_mcp_tools_output_from_statuses( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(a, _)| *a); let display = pairs .into_iter() .map(|(name, var)| format!("{name}={var}")) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index be9f975fc..ec76cfd4e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -548,29 +548,49 @@ async fn lookup_latest_session_target_with_app_server( cwd_filter: Option<&Path>, include_non_interactive: bool, ) -> color_eyre::Result> { - let response = app_server - .thread_list(latest_session_lookup_params( - app_server.is_remote(), - config, - cwd_filter, - include_non_interactive, - )) - .await?; - Ok(response - .data - .into_iter() - .find_map(session_target_from_app_server_thread)) + let mut cursor = None; + let mut fallback = None; + + loop { + let response = app_server + .thread_list(latest_session_lookup_params( + cursor.clone(), + app_server.is_remote(), + config, + include_non_interactive, + )) + .await?; + + for thread in response.data { + let matches_cwd = + cwd_filter.is_some_and(|cwd| app_server_thread_matches_cwd(&thread, cwd)); + let Some(target) = session_target_from_app_server_thread(thread) else { + continue; + }; + if fallback.is_none() { + fallback = Some(target.clone()); + } + if matches_cwd { + return Ok(Some(target)); + } + } + + if response.next_cursor.is_none() { + return Ok(fallback); + } + cursor = response.next_cursor; + } } fn latest_session_lookup_params( + cursor: Option, is_remote: bool, config: &Config, - cwd_filter: Option<&Path>, include_non_interactive: bool, ) -> ThreadListParams { ThreadListParams { - cursor: None, - limit: Some(1), + cursor, + limit: Some(100), sort_key: Some(AppServerThreadSortKey::UpdatedAt), model_providers: if is_remote { None @@ -580,11 +600,21 @@ fn latest_session_lookup_params( source_kinds: (!include_non_interactive) .then_some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]), archived: Some(false), - cwd: cwd_filter.map(|cwd| cwd.to_string_lossy().to_string()), + cwd: None, search_term: None, } } +fn app_server_thread_matches_cwd(thread: &AppServerThread, cwd_filter: &Path) -> bool { + if let (Ok(thread_cwd), Ok(filter_cwd)) = ( + path_utils::normalize_for_path_comparison(&thread.cwd), + path_utils::normalize_for_path_comparison(cwd_filter), + ) { + return thread_cwd == filter_cwd; + } + thread.cwd == cwd_filter +} + fn config_cwd_for_app_server_target( cwd: Option<&Path>, app_server_target: &AppServerTarget, @@ -1202,7 +1232,11 @@ async fn run_ratatui_app( match resume_picker::run_fork_picker_with_app_server( &mut tui, &config, - cli.fork_show_all, + if cli.fork_show_all { + resume_picker::SessionPickerOrder::GlobalTime + } else { + resume_picker::SessionPickerOrder::LocalGroupFirst + }, app_server, ) .await? @@ -1263,7 +1297,12 @@ async fn run_ratatui_app( match resume_picker::run_resume_picker_with_app_server( &mut tui, &config, - cli.resume_show_all, + if cli.resume_show_all { + resume_picker::SessionPickerOrder::GlobalTime + } else { + resume_picker::SessionPickerOrder::LocalGroupFirst + }, + resume_picker::SessionPickerProviderScope::CurrentProfile, cli.resume_include_non_interactive, app_server, ) @@ -1832,21 +1871,18 @@ mod tests { } #[tokio::test] - async fn latest_session_lookup_params_keep_local_filters_for_embedded_sessions() + async fn latest_session_lookup_params_keep_local_provider_filter_for_embedded_sessions() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let config = build_config(&temp_dir).await?; - let cwd = temp_dir.path().join("project"); let params = latest_session_lookup_params( - /*is_remote*/ false, - &config, - Some(cwd.as_path()), + /*cursor*/ None, /*is_remote*/ false, &config, /*include_non_interactive*/ false, ); assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); - assert_eq!(params.cwd, Some(cwd.to_string_lossy().to_string())); + assert_eq!(params.cwd, None); Ok(()) } @@ -1857,7 +1893,7 @@ mod tests { let config = build_config(&temp_dir).await?; let params = latest_session_lookup_params( - /*is_remote*/ true, &config, /*cwd_filter*/ None, + /*cursor*/ None, /*is_remote*/ true, &config, /*include_non_interactive*/ false, ); @@ -1867,21 +1903,21 @@ mod tests { } #[tokio::test] - async fn latest_session_lookup_params_keep_explicit_cwd_filter_for_remote_sessions() + async fn latest_session_lookup_params_omit_explicit_cwd_filter_for_remote_sessions() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let config = build_config(&temp_dir).await?; - let cwd = Path::new("repo/on/server"); let params = latest_session_lookup_params( + /*cursor*/ Some(String::from("cursor-1")), /*is_remote*/ true, &config, - Some(cwd), /*include_non_interactive*/ false, ); + assert_eq!(params.cursor, Some(String::from("cursor-1"))); assert_eq!(params.model_providers, None); - assert_eq!(params.cwd.as_deref(), Some("repo/on/server")); + assert_eq!(params.cwd, None); Ok(()) } diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 293c80fcf..4c3ab659f 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -493,7 +493,7 @@ fn wait_complete_lines( status: status.clone(), }) .collect::>(); - entries.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.sort_by_key(|left| left.thread_id.to_string()); entries } else { let mut entries = agent_statuses.to_vec(); @@ -511,7 +511,7 @@ fn wait_complete_lines( status: status.clone(), }) .collect::>(); - extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + extras.sort_by_key(|left| left.thread_id.to_string()); entries.extend(extras); entries }; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index a415a2552..736489488 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -108,12 +108,18 @@ struct PageLoadRequest { sort_key: ThreadSortKey, } -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] enum ProviderFilter { Any, MatchDefault(String), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SessionPickerProviderScope { + AllProviders, + CurrentProfile, +} + type PageLoader = Arc; enum BackgroundEvent { @@ -124,11 +130,17 @@ enum BackgroundEvent { }, } -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] enum PageCursor { #[allow(dead_code)] Rollout(Cursor), - AppServer(String), + AppServer(AppServerPageCursor), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum AppServerPageCursor { + Local(Option), + Global(Option), } struct PickerPage { @@ -151,39 +163,62 @@ struct PickerPage { /// new sessions appear during pagination. /// /// Filtering happens in two layers: -/// 1. Provider and source filtering at the backend (only interactive CLI sessions -/// for the current model provider). -/// 2. Working-directory filtering at the picker (unless `--all` is passed). +/// 1. Provider and source filtering at the backend. +/// 2. Query filtering at the picker, while presentation order determines whether +/// current-working-directory sessions are grouped ahead of the rest of global +/// history. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SessionPickerOrder { + GlobalTime, + LocalGroupFirst, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PickerFilters { + provider_filter: ProviderFilter, + filter_cwd: Option, +} + #[allow(dead_code)] pub async fn run_resume_picker( tui: &mut Tui, config: &Config, - show_all: bool, + order: SessionPickerOrder, ) -> Result { - run_session_picker(tui, config, show_all, SessionPickerAction::Resume).await + run_session_picker(tui, config, order, SessionPickerAction::Resume).await } pub async fn run_resume_picker_with_app_server( tui: &mut Tui, config: &Config, - show_all: bool, + order: SessionPickerOrder, + provider_scope: SessionPickerProviderScope, include_non_interactive: bool, app_server: AppServerSession, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let is_remote = app_server.is_remote(); - let cwd_filter = if show_all { - None - } else { - app_server.remote_cwd_override().map(Path::to_path_buf) + let filters = PickerFilters { + provider_filter: provider_filter_for_scope( + is_remote, + &config.model_provider_id, + provider_scope, + ), + filter_cwd: picker_filter_cwd(config, is_remote), }; run_session_picker_with_loader( tui, config, - show_all, + order, SessionPickerAction::Resume, - is_remote, - spawn_app_server_page_loader(app_server, cwd_filter, include_non_interactive, bg_tx), + filters.clone(), + spawn_app_server_page_loader( + app_server, + order, + filters.filter_cwd.clone(), + include_non_interactive, + bg_tx, + ), bg_rx, ) .await @@ -192,24 +227,31 @@ pub async fn run_resume_picker_with_app_server( pub async fn run_fork_picker_with_app_server( tui: &mut Tui, config: &Config, - show_all: bool, + order: SessionPickerOrder, app_server: AppServerSession, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let is_remote = app_server.is_remote(); - let cwd_filter = if show_all { - None - } else { - app_server.remote_cwd_override().map(Path::to_path_buf) + let filters = PickerFilters { + provider_filter: provider_filter_for_scope( + is_remote, + &config.model_provider_id, + SessionPickerProviderScope::CurrentProfile, + ), + filter_cwd: picker_filter_cwd(config, is_remote), }; run_session_picker_with_loader( tui, config, - show_all, + order, SessionPickerAction::Fork, - is_remote, + filters.clone(), spawn_app_server_page_loader( - app_server, cwd_filter, /*include_non_interactive*/ false, bg_tx, + app_server, + order, + filters.filter_cwd.clone(), + /*include_non_interactive*/ false, + bg_tx, ), bg_rx, ) @@ -220,16 +262,23 @@ pub async fn run_fork_picker_with_app_server( async fn run_session_picker( tui: &mut Tui, config: &Config, - show_all: bool, + order: SessionPickerOrder, action: SessionPickerAction, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); run_session_picker_with_loader( tui, config, - show_all, + order, action, - /*is_remote*/ false, + PickerFilters { + provider_filter: provider_filter_for_scope( + /*is_remote*/ false, + &config.model_provider_id, + SessionPickerProviderScope::CurrentProfile, + ), + filter_cwd: picker_filter_cwd(config, /*is_remote*/ false), + }, spawn_rollout_page_loader(config, bg_tx), bg_rx, ) @@ -239,35 +288,22 @@ async fn run_session_picker( async fn run_session_picker_with_loader( tui: &mut Tui, config: &Config, - show_all: bool, + order: SessionPickerOrder, action: SessionPickerAction, - is_remote: bool, + filters: PickerFilters, page_loader: PageLoader, bg_rx: mpsc::UnboundedReceiver, ) -> Result { let alt = AltScreenGuard::enter(tui); - let provider_filter = if is_remote { - ProviderFilter::Any - } else { - ProviderFilter::MatchDefault(config.model_provider_id.to_string()) - }; let codex_home = config.codex_home.as_path(); - let filter_cwd = if show_all || is_remote { - // Remote sessions live in the server's filesystem namespace, so the client - // process cwd is not a meaningful row filter. If the user provided an - // explicit remote --cd, filtering is handled server-side in thread/list. - None - } else { - std::env::current_dir().ok() - }; let mut state = PickerState::new( codex_home.to_path_buf(), alt.tui.frame_requester(), page_loader, - provider_filter, - show_all, - filter_cwd, + filters.provider_filter, + order, + filters.filter_cwd, action, ); state.start_initial_load(); @@ -353,7 +389,8 @@ fn spawn_rollout_page_loader( fn spawn_app_server_page_loader( app_server: AppServerSession, - cwd_filter: Option, + order: SessionPickerOrder, + filter_cwd: Option, include_non_interactive: bool, bg_tx: mpsc::UnboundedSender, ) -> PageLoader { @@ -369,8 +406,9 @@ fn spawn_app_server_page_loader( }; let page = load_app_server_page( &mut app_server, + order, + filter_cwd.as_deref(), cursor, - cwd_filter.as_deref(), request.provider_filter, request.sort_key, include_non_interactive, @@ -434,7 +472,7 @@ struct PickerState { page_loader: PageLoader, view_rows: Option, provider_filter: ProviderFilter, - show_all: bool, + order: SessionPickerOrder, filter_cwd: Option, action: SessionPickerAction, sort_key: ThreadSortKey, @@ -480,12 +518,14 @@ impl LoadingState { async fn load_app_server_page( app_server: &mut AppServerSession, - cursor: Option, - cwd_filter: Option<&Path>, + order: SessionPickerOrder, + filter_cwd: Option<&Path>, + cursor: Option, provider_filter: ProviderFilter, sort_key: ThreadSortKey, include_non_interactive: bool, ) -> std::io::Result { + let (cursor, cwd_filter, scope) = app_server_page_request(order, filter_cwd, cursor.as_ref()); let response = app_server .thread_list(thread_list_params( cursor, @@ -504,12 +544,73 @@ async fn load_app_server_page( .into_iter() .filter_map(row_from_app_server_thread) .collect(), - next_cursor: response.next_cursor.map(PageCursor::AppServer), + next_cursor: next_app_server_page_cursor(scope, response.next_cursor), num_scanned_files, reached_scan_cap: false, }) } +fn picker_filter_cwd(config: &Config, is_remote: bool) -> Option { + if is_remote { + // Remote sessions live in the server's filesystem namespace, so the client + // process cwd is not a meaningful local-priority hint. + None + } else { + Some(config.cwd.to_path_buf()) + } +} + +fn provider_filter_for_scope( + is_remote: bool, + default_provider: &str, + provider_scope: SessionPickerProviderScope, +) -> ProviderFilter { + if is_remote || provider_scope == SessionPickerProviderScope::AllProviders { + ProviderFilter::Any + } else { + ProviderFilter::MatchDefault(default_provider.to_string()) + } +} + +fn app_server_page_request<'a>( + order: SessionPickerOrder, + filter_cwd: Option<&'a Path>, + cursor: Option<&AppServerPageCursor>, +) -> (Option, Option<&'a Path>, AppServerPageScope) { + match cursor { + Some(AppServerPageCursor::Local(cursor)) => { + (cursor.clone(), filter_cwd, AppServerPageScope::Local) + } + Some(AppServerPageCursor::Global(cursor)) => { + (cursor.clone(), None, AppServerPageScope::Global) + } + None if order == SessionPickerOrder::LocalGroupFirst && filter_cwd.is_some() => { + (None, filter_cwd, AppServerPageScope::Local) + } + None => (None, None, AppServerPageScope::Global), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AppServerPageScope { + Local, + Global, +} + +fn next_app_server_page_cursor( + scope: AppServerPageScope, + next_cursor: Option, +) -> Option { + match scope { + AppServerPageScope::Local => Some(PageCursor::AppServer(match next_cursor { + Some(cursor) => AppServerPageCursor::Local(Some(cursor)), + None => AppServerPageCursor::Global(None), + })), + AppServerPageScope::Global => next_cursor + .map(|cursor| PageCursor::AppServer(AppServerPageCursor::Global(Some(cursor)))), + } +} + impl SearchState { fn active_token(&self) -> Option { match self { @@ -572,7 +673,7 @@ impl PickerState { requester: FrameRequester, page_loader: PageLoader, provider_filter: ProviderFilter, - show_all: bool, + order: SessionPickerOrder, filter_cwd: Option, action: SessionPickerAction, ) -> Self { @@ -597,7 +698,7 @@ impl PickerState { page_loader, view_rows: None, provider_filter, - show_all, + order, filter_cwd, action, sort_key: ThreadSortKey::UpdatedAt, @@ -674,8 +775,8 @@ impl PickerState { self.request_frame(); } } - KeyCode::PageDown => { - if !self.filtered_rows.is_empty() { + KeyCode::PageDown + if !self.filtered_rows.is_empty() => { let step = self.view_rows.unwrap_or(10).max(1); let max_index = self.filtered_rows.len().saturating_sub(1); self.selected = (self.selected + step).min(max_index); @@ -683,7 +784,6 @@ impl PickerState { self.maybe_load_more_for_scroll(); self.request_frame(); } - } KeyCode::Tab => { self.toggle_sort_key(); self.request_frame(); @@ -693,18 +793,17 @@ impl PickerState { new_query.pop(); self.set_query(new_query); } - KeyCode::Char(c) => { + KeyCode::Char(c) // basic text input for search if !key .modifiers .contains(crossterm::event::KeyModifiers::CONTROL) && !key.modifiers.contains(crossterm::event::KeyModifiers::ALT) - { + => { let mut new_query = self.query.clone(); new_query.push(c); self.set_query(new_query); } - } _ => {} } Ok(None) @@ -847,15 +946,23 @@ impl PickerState { } fn apply_filter(&mut self) { - let base_iter = self - .all_rows - .iter() - .filter(|row| self.row_matches_filter(row)); if self.query.is_empty() { - self.filtered_rows = base_iter.cloned().collect(); + self.filtered_rows = self.all_rows.clone(); } else { let q = self.query.to_lowercase(); - self.filtered_rows = base_iter.filter(|r| r.matches_query(&q)).cloned().collect(); + self.filtered_rows = self + .all_rows + .iter() + .filter(|row| row.matches_query(&q)) + .cloned() + .collect(); + } + if self.order == SessionPickerOrder::LocalGroupFirst { + let filter_cwd = self.filter_cwd.clone(); + self.filtered_rows.sort_by_key(|row| { + let is_local = Self::row_matches_local_cwd(row, filter_cwd.as_deref()); + !is_local + }); } if self.selected >= self.filtered_rows.len() { self.selected = self.filtered_rows.len().saturating_sub(1); @@ -867,12 +974,9 @@ impl PickerState { self.request_frame(); } - fn row_matches_filter(&self, row: &Row) -> bool { - if self.show_all { - return true; - } - let Some(filter_cwd) = self.filter_cwd.as_ref() else { - return true; + fn row_matches_local_cwd(row: &Row, filter_cwd: Option<&Path>) -> bool { + let Some(filter_cwd) = filter_cwd else { + return false; }; let Some(row_cwd) = row.cwd.as_ref() else { return false; @@ -1130,7 +1234,9 @@ fn thread_list_params( ThreadSortKey::UpdatedAt => AppServerThreadSortKey::UpdatedAt, }), model_providers: match provider_filter { - ProviderFilter::Any => None, + // App-server interprets `modelProviders = null` as "current provider only"; + // send an explicit empty list to request unfiltered local history. + ProviderFilter::Any => Some(vec![]), ProviderFilter::MatchDefault(default_provider) => Some(vec![default_provider]), }, source_kinds: (!include_non_interactive) @@ -1186,7 +1292,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { // Search line frame.render_widget_ref(search_line(state), search); - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + let metrics = calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true); // Column headers and list render_column_headers(frame, columns, &metrics, state.sort_key); @@ -1850,22 +1956,22 @@ mod tests { } #[test] - fn remote_thread_list_params_omit_provider_filter() { + fn any_provider_thread_list_params_request_unfiltered_history() { let params = thread_list_params( Some(String::from("cursor-1")), - Some(Path::new("repo/on/server")), + /*cwd_filter*/ None, ProviderFilter::Any, ThreadSortKey::UpdatedAt, /*include_non_interactive*/ false, ); assert_eq!(params.cursor, Some(String::from("cursor-1"))); - assert_eq!(params.model_providers, None); + assert_eq!(params.model_providers, Some(vec![])); assert_eq!( params.source_kinds, Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]) ); - assert_eq!(params.cwd.as_deref(), Some("repo/on/server")); + assert_eq!(params.cwd, None); } #[test] @@ -1879,22 +1985,108 @@ mod tests { ); assert_eq!(params.cursor, Some(String::from("cursor-1"))); - assert_eq!(params.model_providers, None); + assert_eq!(params.model_providers, Some(vec![])); assert_eq!(params.source_kinds, None); } #[test] - fn remote_picker_does_not_filter_rows_by_local_cwd() { + fn provider_filter_scope_can_include_all_local_sessions() { + assert_eq!( + provider_filter_for_scope( + /*is_remote*/ false, + "daz2", + SessionPickerProviderScope::AllProviders + ), + ProviderFilter::Any + ); + } + + #[test] + fn provider_filter_scope_keeps_current_profile_filter_for_local_sessions() { + assert_eq!( + provider_filter_for_scope( + /*is_remote*/ false, + "daz2", + SessionPickerProviderScope::CurrentProfile + ), + ProviderFilter::MatchDefault(String::from("daz2")) + ); + } + + #[test] + fn local_cwd_rows_are_prioritized_without_hiding_global_rows() { let loader: PageLoader = Arc::new(|_| {}); - let state = PickerState::new( + let mut state = PickerState::new( PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, - ProviderFilter::Any, - /*show_all*/ false, - /*filter_cwd*/ None, + ProviderFilter::MatchDefault(String::from("openai")), + SessionPickerOrder::LocalGroupFirst, + Some(PathBuf::from("/repo/local")), SessionPickerAction::Resume, ); + state.all_rows = vec![ + Row { + path: Some(PathBuf::from("/tmp/global.jsonl")), + preview: String::from("global"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: Some(PathBuf::from("/repo/elsewhere")), + git_branch: None, + }, + Row { + path: Some(PathBuf::from("/tmp/local.jsonl")), + preview: String::from("local"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: Some(PathBuf::from("/repo/local")), + git_branch: None, + }, + ]; + + state.apply_filter(); + + let previews: Vec<&str> = state + .filtered_rows + .iter() + .map(|row| row.preview.as_str()) + .collect(); + assert_eq!(previews, vec!["local", "global"]); + } + + #[test] + fn app_server_page_request_starts_with_local_scope_when_local_priority_has_cwd() { + let (cursor, cwd_filter, scope) = app_server_page_request( + SessionPickerOrder::LocalGroupFirst, + Some(Path::new("/repo/local")), + None, + ); + + assert_eq!(cursor, None); + assert_eq!(cwd_filter, Some(Path::new("/repo/local"))); + assert_eq!(scope, AppServerPageScope::Local); + } + + #[test] + fn next_app_server_page_cursor_transitions_from_local_to_global() { + assert_eq!( + next_app_server_page_cursor(AppServerPageScope::Local, None), + Some(PageCursor::AppServer(AppServerPageCursor::Global(None))) + ); + assert_eq!( + next_app_server_page_cursor(AppServerPageScope::Local, Some(String::from("cursor-2"))), + Some(PageCursor::AppServer(AppServerPageCursor::Local(Some( + String::from("cursor-2") + )))) + ); + } + + #[test] + fn row_matches_local_cwd_returns_false_without_filter_cwd() { let row = Row { path: None, preview: String::from("remote session"), @@ -1906,7 +2098,9 @@ mod tests { git_branch: None, }; - assert!(state.row_matches_filter(&row)); + assert!(!PickerState::row_matches_local_cwd( + &row, /*filter_cwd*/ None + )); } #[test] @@ -1922,7 +2116,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -1967,7 +2161,7 @@ mod tests { state.scroll_top = 0; state.update_view_rows(/*rows*/ 3); - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + let metrics = calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true); let width: u16 = 80; let height: u16 = 6; @@ -2000,7 +2194,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2236,7 +2430,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2273,7 +2467,7 @@ mod tests { state.update_thread_names().await; - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + let metrics = calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true); let width: u16 = 80; let height: u16 = 5; @@ -2317,7 +2511,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2354,7 +2548,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2423,7 +2617,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2504,7 +2698,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2534,7 +2728,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2582,7 +2776,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2622,7 +2816,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2692,7 +2886,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2740,7 +2934,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); diff --git a/codex-rs/tui/src/text_formatting.rs b/codex-rs/tui/src/text_formatting.rs index ae93890bb..a89f392eb 100644 --- a/codex-rs/tui/src/text_formatting.rs +++ b/codex-rs/tui/src/text_formatting.rs @@ -284,7 +284,7 @@ pub(crate) fn center_truncate_path(path: &str, max_width: usize) -> String { } }; - for (left_count, right_count) in prioritized.into_iter().chain(fallback.into_iter()) { + for (left_count, right_count) in prioritized.into_iter().chain(fallback) { let mut segments: Vec> = raw_segments[..left_count] .iter() .map(|seg| Segment { From ee7ff5e54b1bbbf0777eda9f210c9428a5ad61f2 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 14:59:57 +0800 Subject: [PATCH 46/60] fix workflow schedule deadlock issue fix workflow /stop --- .gitignore | 5 + AGENTS.md | 14 +-- codex-rs/.gitignore | 2 + codex-rs/tui/src/app.rs | 91 +++++++++++++++---- codex-rs/tui/src/app/app_server_adapter.rs | 10 +- codex-rs/tui/src/app/workflow_runtime.rs | 76 +++++++--------- codex-rs/tui/src/app_event.rs | 11 --- codex-rs/tui/src/chatwidget.rs | 2 +- .../src/chatwidget/tests/slash_commands.rs | 14 ++- ...nds__slash_stop_confirmation_snapshot.snap | 6 ++ codex-rs/tui/src/slash_command.rs | 4 +- 11 files changed, 150 insertions(+), 85 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__slash_stop_confirmation_snapshot.snap diff --git a/.gitignore b/.gitignore index 82269594b..993277dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,8 @@ CHANGELOG.ignore.md __pycache__/ *.pyc +.codex/ +.desloppify/ +scorecard.png +site/ +docs/assets/ diff --git a/AGENTS.md b/AGENTS.md index c8d989fe9..ee34034d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,20 +16,13 @@ In the codex-rs folder where the rust code lives: - Use an exact `/*param_name*/` comment before opaque literal arguments such as `None`, booleans, and numeric literals when passing them by position. - Do not add these comments for string or char literals unless the comment adds real clarity; those literals are intentionally exempt from the lint. - The parameter name in the comment must exactly match the callee signature. - - You can run `just argument-comment-lint` to run the lint check locally. This is powered by Bazel, so running it the first time can be slow if Bazel is not warmed up, though incremental invocations should take <15s. Most of the time, it is best to update the PR and let CI take responsibility for checking this (or run it asynchronously in the background after submitting the PR). Note CI checks all three platforms, which the local run does not. + - Do not run bazel related commands unless in CI environement + - Do not run 'just argument-comment-lint' unless in CI environment - When possible, make `match` statements exhaustive and avoid wildcard arms. - Newly added traits should include doc comments that explain their role and how implementations are expected to use them. - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. -- If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the - repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change. -- After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught - locally before CI. -- Bazel does not automatically make source-tree files available to compile-time Rust file access. If - you add `include_str!`, `include_bytes!`, `sqlx::migrate!`, or similar build-time file or - directory reads, update the crate's `BUILD.bazel` (`compile_data`, `build_script_data`, or test - data) or Bazel may fail even when Cargo passes. - Do not create small helper methods that are referenced only once. - Avoid large modules: - Prefer adding new modules instead of growing existing ones. @@ -207,3 +200,6 @@ These guidelines apply to app-server protocol work in `codex-rs`, especially: - Validate with `cargo test -p codex-app-server-protocol`. - Avoid boilerplate tests that only assert experimental field markers for individual request fields in `common.rs`; rely on schema generation/tests and behavioral coverage instead. +- cargo run --bin codex -- -p 总是 加 -p pig ; it allows api usage +- 修改代码后总是执行 cargo build -p codex-cli; 然后执行交互式终端 (PTY) 验证要完成的特性是否实现; +- 在跑 codex 命令之前, 总是用sudo codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime 来给二进制签名 diff --git a/codex-rs/.gitignore b/codex-rs/.gitignore index e31566047..b8563bddf 100644 --- a/codex-rs/.gitignore +++ b/codex-rs/.gitignore @@ -6,3 +6,5 @@ # Value of CARGO_TARGET_DIR when using .devcontainer/devcontainer.json. /target-arm64/ + +.codex/ diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8b2245d57..a2a9ad060 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1102,8 +1102,7 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, - workflow_thread_notification_channels: - HashMap>, + workflow_thread_notification_channels: workflow_runtime::WorkflowThreadNotificationChannels, workflow_scheduler: WorkflowSchedulerState, workflow_history: WorkflowHistoryState, btw_session: Option, @@ -2710,6 +2709,11 @@ impl App { app_server .thread_background_terminals_clean(thread_id) .await?; + let stopped_workflow_runs = + self.workflow_scheduler.stop_active_workflow_runs().await; + if stopped_workflow_runs > 0 { + self.sync_background_workflow_status(); + } Ok(true) } AppCommandView::RealtimeConversationStart(params) => { @@ -4229,7 +4233,9 @@ impl App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), - workflow_thread_notification_channels: HashMap::new(), + workflow_thread_notification_channels: Arc::new( + tokio::sync::Mutex::new(HashMap::new()), + ), workflow_scheduler: WorkflowSchedulerState::default(), workflow_history: WorkflowHistoryState::default(), btw_session: None, @@ -4744,19 +4750,6 @@ impl App { self.insert_visible_history_cell(tui, cell); } } - AppEvent::RegisterWorkflowThreadNotificationForwarder { - thread_id, - sender, - ready_tx, - } => { - self.workflow_thread_notification_channels - .insert(thread_id, sender); - let _ = ready_tx.send(()); - } - AppEvent::UnregisterWorkflowThreadNotificationForwarder { thread_id } => { - self.workflow_thread_notification_channels - .remove(&thread_id); - } AppEvent::StartBtwDiscussion { prompt } => { self.start_btw_discussion(app_server, prompt).await; } @@ -9902,7 +9895,9 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), - workflow_thread_notification_channels: HashMap::new(), + workflow_thread_notification_channels: Arc::new( + tokio::sync::Mutex::new(HashMap::new()), + ), workflow_scheduler: WorkflowSchedulerState::default(), workflow_history: WorkflowHistoryState::default(), btw_session: None, @@ -9971,7 +9966,9 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), - workflow_thread_notification_channels: HashMap::new(), + workflow_thread_notification_channels: Arc::new(tokio::sync::Mutex::new( + HashMap::new(), + )), workflow_scheduler: WorkflowSchedulerState::default(), workflow_history: WorkflowHistoryState::default(), btw_session: None, @@ -12342,6 +12339,62 @@ model = "gpt-5.2" Ok(()) } + #[tokio::test] + async fn clean_background_terminals_stops_background_workflows() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + app.primary_session_configured = Some(started.session); + app.active_thread_id = Some(thread_id); + + let run_id = app + .workflow_scheduler + .next_background_run_id("director", "review_backlog"); + let cancellation = CancellationToken::new(); + let cancellation_for_task = cancellation.clone(); + app.workflow_scheduler.register_background_workflow_run( + run_id, + workflow_runtime::BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }, + cancellation, + tokio::spawn(async move { + cancellation_for_task.cancelled().await; + }), + ); + app.workflow_scheduler + .enqueue_trigger_run("director".to_string(), "triage".to_string()); + app.sync_background_workflow_status(); + + assert_eq!( + app.background_workflow_labels(), + vec!["director · review_backlog".to_string()] + ); + assert_eq!( + app.queued_trigger_labels(), + vec!["director · triage".to_string()] + ); + + let handled = app + .try_submit_active_thread_op_via_app_server( + &mut app_server, + thread_id, + &AppCommand::clean_background_terminals(), + ) + .await?; + + assert!(handled); + assert!(app.background_workflow_labels().is_empty()); + assert!(app.queued_trigger_labels().is_empty()); + Ok(()) + } + #[tokio::test] async fn manual_triggers_use_a_global_fifo_queue() { let mut app = make_test_app().await; @@ -12459,6 +12512,8 @@ model = "gpt-5.2" let thread_id = ThreadId::new(); let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); app.workflow_thread_notification_channels + .lock() + .await .insert(thread_id, sender); let notification = ServerNotification::ItemCompleted(ItemCompletedNotification { diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index c7d91b047..3b9807247 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -224,10 +224,18 @@ impl App { ) .await; } - if let Some(sender) = self.workflow_thread_notification_channels.get(&thread_id) + let workflow_sender = self + .workflow_thread_notification_channels + .lock() + .await + .get(&thread_id) + .cloned(); + if let Some(sender) = workflow_sender && sender.send(notification.clone()).is_err() { self.workflow_thread_notification_channels + .lock() + .await .remove(&thread_id); } if let Some(turn) = clawbot_turn { diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index 47e7bf66f..8a88c61c9 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -10,7 +10,6 @@ use super::workflow_definition::ordered_jobs_for_roots; use super::workflow_history::WorkflowReplySource; use super::workflow_history::workflow_result_cell; use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::history_cell; use crate::history_cell::HistoryCell; @@ -58,6 +57,8 @@ const WORKFLOW_POLL_INTERVAL: Duration = Duration::from_millis(50); const WORKFLOW_INTERRUPT_SETTLE_TIMEOUT: Duration = Duration::from_secs(1); type BoxFuture<'a, T> = Pin + Send + 'a>>; +pub(crate) type WorkflowThreadNotificationChannels = + Arc>>>; #[cfg_attr(not(test), allow(dead_code))] #[derive(Debug, Clone, PartialEq, Eq)] @@ -213,7 +214,7 @@ trait WorkflowRuntimeClient: Send + Sync { pub(crate) struct AppServerWorkflowRuntimeClient { request_handle: AppServerRequestHandle, - app_event_tx: AppEventSender, + workflow_thread_notification_channels: WorkflowThreadNotificationChannels, config: Config, primary_thread_id: Option, is_remote: bool, @@ -223,13 +224,13 @@ pub(crate) struct AppServerWorkflowRuntimeClient { impl AppServerWorkflowRuntimeClient { pub(crate) fn new( app_server: &AppServerSession, - app_event_tx: AppEventSender, + workflow_thread_notification_channels: WorkflowThreadNotificationChannels, config: Config, primary_thread_id: Option, ) -> Self { Self { request_handle: app_server.request_handle(), - app_event_tx, + workflow_thread_notification_channels, config, primary_thread_id, is_remote: app_server.is_remote(), @@ -284,16 +285,10 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { ) })?; let (sender, receiver) = mpsc::unbounded_channel(); - let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); - self.app_event_tx - .send(AppEvent::RegisterWorkflowThreadNotificationForwarder { - thread_id, - sender, - ready_tx, - }); - ready_rx.await.map_err(|_| { - "workflow thread notification forwarder setup was cancelled".to_string() - })?; + self.workflow_thread_notification_channels + .lock() + .await + .insert(thread_id, sender); return Ok(WorkflowThreadSession { thread_id: response.thread.id, cwd: response.cwd, @@ -320,16 +315,10 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { ) })?; let (sender, receiver) = mpsc::unbounded_channel(); - let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); - self.app_event_tx - .send(AppEvent::RegisterWorkflowThreadNotificationForwarder { - thread_id, - sender, - ready_tx, - }); - ready_rx.await.map_err(|_| { - "workflow thread notification forwarder setup was cancelled".to_string() - })?; + self.workflow_thread_notification_channels + .lock() + .await + .insert(thread_id, sender); Ok(WorkflowThreadSession { thread_id: response.thread.id, cwd: response.cwd, @@ -452,10 +441,10 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { .await .map_err(|err| format!("failed to unsubscribe workflow thread: {err}")); if let Ok(parsed_thread_id) = ThreadId::from_string(&thread_id) { - self.app_event_tx - .send(AppEvent::UnregisterWorkflowThreadNotificationForwarder { - thread_id: parsed_thread_id, - }); + self.workflow_thread_notification_channels + .lock() + .await + .remove(&parsed_thread_id); } result.map(|_| ()) }) @@ -474,7 +463,7 @@ impl App { .map_err(|error| format!("failed to load workflows: {error}"))?; let client = AppServerWorkflowRuntimeClient::new( app_server, - self.app_event_tx.clone(), + self.workflow_thread_notification_channels.clone(), self.config.clone(), self.primary_thread_id, ); @@ -695,7 +684,7 @@ impl App { .next_background_run_id(target.workflow_name(), target.slot_key()); let runtime_client = AppServerWorkflowRuntimeClient::new( app_server, - self.app_event_tx.clone(), + self.workflow_thread_notification_channels.clone(), self.config.clone(), self.primary_thread_id, ); @@ -1563,10 +1552,9 @@ jobs: let app_server = crate::start_embedded_app_server_for_picker(&config) .await .expect("embedded app server"); - let (app_event_tx, _app_event_rx) = tokio::sync::mpsc::unbounded_channel(); let client = AppServerWorkflowRuntimeClient::new( &app_server, - crate::app_event_sender::AppEventSender::new(app_event_tx), + Arc::new(tokio::sync::Mutex::new(HashMap::new())), config, /*primary_thread_id*/ None, ); @@ -1638,19 +1626,12 @@ jobs: .as_ref() .is_some_and(|path| !path.exists()) ); - let (app_event_tx, mut app_event_rx) = tokio::sync::mpsc::unbounded_channel(); - tokio::spawn(async move { - let Some(AppEvent::RegisterWorkflowThreadNotificationForwarder { ready_tx, .. }) = - app_event_rx.recv().await - else { - panic!("expected workflow notification registration event"); - }; - let _ = ready_tx.send(()); - }); + let workflow_thread_notification_channels = + Arc::new(tokio::sync::Mutex::new(HashMap::new())); let client = AppServerWorkflowRuntimeClient::new( &app_server, - crate::app_event_sender::AppEventSender::new(app_event_tx), + workflow_thread_notification_channels.clone(), config, Some(primary.session.thread_id), ); @@ -1663,5 +1644,16 @@ jobs: workflow_thread.thread_id, primary.session.thread_id.to_string() ); + let registered_thread_ids = workflow_thread_notification_channels + .lock() + .await + .keys() + .copied() + .collect::>(); + assert_eq!(registered_thread_ids.len(), 1); + assert_eq!( + registered_thread_ids[0].to_string(), + workflow_thread.thread_id + ); } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 91918c810..ddcf2a0ca 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -16,7 +16,6 @@ use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; -use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::Turn as AppServerTurn; use codex_chatgpt::connectors::AppInfo; use codex_clawbot::ClawbotTurnMode; @@ -333,16 +332,6 @@ pub(crate) enum AppEvent { run_id: String, result: Box, }, - - RegisterWorkflowThreadNotificationForwarder { - thread_id: ThreadId, - sender: tokio::sync::mpsc::UnboundedSender, - ready_tx: tokio::sync::oneshot::Sender<()>, - }, - - UnregisterWorkflowThreadNotificationForwarder { - thread_id: ThreadId, - }, OpenWorkflowControls, StartManualWorkflowTrigger { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5fd3f839a..4b253c0cf 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -7638,7 +7638,7 @@ impl ChatWidget { fn clean_background_terminals(&mut self) { self.submit_op(AppCommand::clean_background_terminals()); self.add_info_message( - "Stopping all background terminals.".to_string(), + "Stopping all background tasks.".to_string(), /*hint*/ None, ); } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 2ec75349f..258280f86 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -388,11 +388,23 @@ async fn slash_stop_submits_background_terminal_cleanup() { assert_eq!(cells.len(), 1, "expected cleanup confirmation message"); let rendered = lines_to_single_string(&cells[0]); assert!( - rendered.contains("Stopping all background terminals."), + rendered.contains("Stopping all background tasks."), "expected cleanup confirmation, got {rendered:?}" ); } +#[tokio::test] +async fn slash_stop_confirmation_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Stop); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + insta::assert_snapshot!(rendered); +} + #[tokio::test] async fn slash_clear_requests_ui_clear_when_idle() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__slash_stop_confirmation_snapshot.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__slash_stop_confirmation_snapshot.snap new file mode 100644 index 000000000..d8a09084d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__slash_stop_confirmation_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +assertion_line: 405 +expression: rendered +--- +• Stopping all background tasks. diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 6dea03223..41e21ae3b 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -97,8 +97,8 @@ impl SlashCommand { SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", SlashCommand::Theme => "choose a syntax highlighting theme", - SlashCommand::Ps => "list background terminals", - SlashCommand::Stop => "stop all background terminals", + SlashCommand::Ps => "list background tasks", + SlashCommand::Stop => "stop all background tasks", SlashCommand::MemoryDrop => "DO NOT USE", SlashCommand::MemoryUpdate => "DO NOT USE", SlashCommand::Model => "choose what model and reasoning effort to use", From 8b55eaa77078d3baaecf4854778fa070557d3d0a Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 16:19:21 +0800 Subject: [PATCH 47/60] update workflow panel setup --- codex-rs/tui/src/app.rs | 42 +- ..._controls__tests__workflow_file_popup.snap | 14 + ...w_controls__tests__workflow_job_popup.snap | 18 + ..._controls__tests__workflow_jobs_popup.snap | 14 + ...tests__workflow_manual_triggers_popup.snap | 15 + ...ols__tests__workflow_root_popup_empty.snap | 14 + codex-rs/tui/src/app/workflow_controls.rs | 1301 +++++++++++++++-- codex-rs/tui/src/app/workflow_definition.rs | 7 +- codex-rs/tui/src/app/workflow_editor.rs | 399 +++++ codex-rs/tui/src/app_event.rs | 56 + codex-rs/tui/src/external_editor.rs | 52 +- ...__app__tests__workflow_controls_popup.snap | 14 +- 12 files changed, 1819 insertions(+), 127 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_file_popup.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_job_popup.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_jobs_popup.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap create mode 100644 codex-rs/tui/src/app/workflow_editor.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a2a9ad060..94738da84 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -178,6 +178,7 @@ mod profile_management; mod thread_menu; mod workflow_controls; mod workflow_definition; +mod workflow_editor; mod workflow_history; pub(crate) mod workflow_runtime; mod workflow_scheduler; @@ -4784,6 +4785,45 @@ impl App { AppEvent::OpenWorkflowControls => { self.open_workflow_controls_popup(); } + AppEvent::OpenWorkflowControlView { destination } => { + self.open_workflow_control_view(destination); + } + AppEvent::CreateDefaultWorkflowTemplate => { + self.create_default_workflow_template_from_ui(tui).await; + } + AppEvent::EditWorkflowFile { + workflow_path, + reopen, + } => { + self.edit_workflow_file_from_ui(tui, workflow_path, reopen) + .await; + } + AppEvent::ToggleWorkflowJobEnabled { + workflow_path, + job_name, + } => { + self.toggle_workflow_job_enabled_from_ui(workflow_path, job_name); + } + AppEvent::CycleWorkflowJobContext { + workflow_path, + job_name, + } => { + self.cycle_workflow_job_context_from_ui(workflow_path, job_name); + } + AppEvent::CycleWorkflowJobResponse { + workflow_path, + job_name, + } => { + self.cycle_workflow_job_response_from_ui(workflow_path, job_name); + } + AppEvent::EditWorkflowJobField { + workflow_path, + job_name, + field, + } => { + self.edit_workflow_job_field_from_ui(tui, workflow_path, job_name, field) + .await; + } AppEvent::StartManualWorkflowTrigger { workflow_name, trigger_id, @@ -9846,7 +9886,7 @@ guardian_approval = true assert_snapshot!("clear_ui_header_fast_status_gpt54_only", rendered); } - async fn make_test_app() -> App { + pub(super) async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); let display_preferences = DisplayPreferences::from_config(&config); diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_file_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_file_popup.snap new file mode 100644 index 000000000..e489bba30 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_file_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow + director · manual.yaml + + Type to search workflow actions +› Back Return to the previous workflow menu. + Edit workflow.yaml Open manual.yaml in your editor. + Jobs 2 jobs + Manual Triggers 2 manual triggers + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_job_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_job_popup.snap new file mode 100644 index 000000000..7bee3688d --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_job_popup.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow Job + director · notify · manual.yaml + + Type to search job actions +› Back Return to the previous workflow menu. + Edit workflow.yaml Open manual.yaml in your editor. + Run Now Run this job immediately in a background workflow thread. + Disable Job Prevent this job from running until it is enabled again. + Context: Ephemeral Toggle between embed and ephemeral execution context. + Response: User Toggle whether this job replies as assistant output or a user follow-up. + Edit Needs 1 dependency + Edit Steps 1 step + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_jobs_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_jobs_popup.snap new file mode 100644 index 000000000..d990b86b3 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_jobs_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow Jobs + director · manual.yaml + + Type to search jobs +› Back Return to the previous workflow menu. + Edit workflow.yaml Open manual.yaml in your editor. + summarize Enabled · Embed · Assistant · Ready + notify Enabled · Ephemeral · User · Ready + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap new file mode 100644 index 000000000..b59af18e0 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Manual Triggers + director · manual.yaml + + Type to search triggers +› Back Return to the previous workflow menu. + Edit workflow.yaml Open manual.yaml in your editor. + review_backlog Enabled · Ready + triage (disabled) Disabled · Ready (disabled: Enable this trigger in workflow.yaml before + running it.) + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap new file mode 100644 index 000000000..6ef881a2a --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow + Manage workflow files, browse jobs, and inspect current background status. + + Type to search workflows +› Background Tasks Insert a background task snapshot into the transcript. /ps shows the same + live workflow state. + Create workflow.yaml Create a starter template under .codex/workflows and open it in your + editor. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/workflow_controls.rs b/codex-rs/tui/src/app/workflow_controls.rs index cc370ba19..8d22f299b 100644 --- a/codex-rs/tui/src/app/workflow_controls.rs +++ b/codex-rs/tui/src/app/workflow_controls.rs @@ -1,33 +1,76 @@ use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; use crate::app_event::AppEvent; +use crate::app_event::WorkflowControlsDestination; +use crate::app_event::WorkflowJobEditableField; +use crate::app_server_session::AppServerSession; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::external_editor; +use crate::history_cell; use crate::history_cell::HistoryCell; +use crate::tui; use super::App; +use super::workflow_definition::LoadedWorkflowFile; +use super::workflow_definition::LoadedWorkflowJob; use super::workflow_definition::LoadedWorkflowRegistry; +use super::workflow_definition::WorkflowContextMode; +use super::workflow_definition::WorkflowResponseMode; use super::workflow_definition::WorkflowTriggerKind; use super::workflow_definition::load_workflow_registry; -use crate::app_server_session::AppServerSession; +use super::workflow_editor; const WORKFLOW_CONTROLS_VIEW_ID: &str = "workflow-controls"; +#[derive(Clone)] +struct WorkflowMenuState { + files: Vec, + registry_error: Option, +} + +#[derive(Clone)] +struct WorkflowFileSummary { + workflow_path: PathBuf, + workflow_name: Option, + display_name: String, + filename: String, + manual_trigger_count: usize, + job_count: usize, +} + impl App { pub(crate) fn open_workflow_controls_popup(&mut self) { - let initial_selected_idx = self + self.open_workflow_control_view(WorkflowControlsDestination::Root); + } + + pub(crate) fn open_workflow_control_view(&mut self, destination: WorkflowControlsDestination) { + let active_selected_idx = self .chat_widget .selected_index_for_active_view(WORKFLOW_CONTROLS_VIEW_ID); - let params = self.workflow_controls_popup_params(initial_selected_idx); - if !self - .chat_widget - .replace_selection_view_if_active(WORKFLOW_CONTROLS_VIEW_ID, params) - { - self.chat_widget - .show_selection_view(self.workflow_controls_popup_params(initial_selected_idx)); - } + let params = match destination { + WorkflowControlsDestination::Root => { + self.workflow_root_popup_params(active_selected_idx) + } + WorkflowControlsDestination::File { workflow_path } => { + self.workflow_file_popup_params(workflow_path.as_path(), Some(0)) + } + WorkflowControlsDestination::Jobs { workflow_path } => { + self.workflow_jobs_popup_params(workflow_path.as_path(), Some(0)) + } + WorkflowControlsDestination::Job { + workflow_path, + job_name, + } => self.workflow_job_popup_params(workflow_path.as_path(), &job_name, Some(0)), + WorkflowControlsDestination::ManualTriggers { workflow_path } => { + self.workflow_manual_triggers_popup_params(workflow_path.as_path(), Some(0)) + } + }; + self.show_workflow_popup(params, active_selected_idx.is_some()); } pub(crate) fn refresh_workflow_controls_if_active(&mut self) { @@ -37,9 +80,9 @@ impl App { else { return; }; - let _ = self.chat_widget.replace_selection_view_if_active( - WORKFLOW_CONTROLS_VIEW_ID, - self.workflow_controls_popup_params(Some(initial_selected_idx)), + self.show_workflow_popup( + self.workflow_root_popup_params(Some(initial_selected_idx)), + /*replace_if_active*/ true, ); } @@ -65,12 +108,236 @@ impl App { cell } - fn workflow_controls_popup_params( + pub(crate) async fn create_default_workflow_template_from_ui(&mut self, tui: &mut tui::Tui) { + match workflow_editor::create_default_workflow_template(self.config.cwd.as_path()) { + Ok(workflow_path) => { + self.chat_widget.add_info_message( + format!("Created workflow template at {}.", workflow_path.display()), + /*hint*/ None, + ); + self.edit_workflow_file_from_ui( + tui, + workflow_path.clone(), + WorkflowControlsDestination::File { workflow_path }, + ) + .await; + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + + pub(crate) async fn edit_workflow_file_from_ui( + &mut self, + tui: &mut tui::Tui, + workflow_path: PathBuf, + reopen: WorkflowControlsDestination, + ) { + let Ok(editor_cmd) = self.resolve_editor_command_for_workflows() else { + return; + }; + + let edit_result = tui + .with_restored(tui::RestoreMode::KeepRaw, || async { + external_editor::edit_file(workflow_path.as_path(), &editor_cmd).await + }) + .await; + match edit_result { + Ok(()) => { + self.open_workflow_control_view(reopen); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + } + } + tui.frame_requester().schedule_frame(); + } + + pub(crate) async fn edit_workflow_job_field_from_ui( + &mut self, + tui: &mut tui::Tui, + workflow_path: PathBuf, + job_name: String, + field: WorkflowJobEditableField, + ) { + let seed = match workflow_editor::job_field_seed(workflow_path.as_path(), &job_name, field) + { + Ok(seed) => seed, + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + return; + } + }; + let Ok(editor_cmd) = self.resolve_editor_command_for_workflows() else { + return; + }; + let suffix = match field { + WorkflowJobEditableField::Needs => ".yaml", + WorkflowJobEditableField::Steps => ".yaml", + }; + let edited = tui + .with_restored(tui::RestoreMode::KeepRaw, || async { + external_editor::run_editor_with_suffix(&seed, &editor_cmd, suffix).await + }) + .await; + match edited { + Ok(updated) => match workflow_editor::write_job_field( + workflow_path.as_path(), + &job_name, + field, + &updated, + ) { + Ok(()) => { + self.chat_widget.add_info_message( + format!( + "Updated `{}` for workflow job `{job_name}`.", + workflow_job_field_label(field) + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + }, + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + } + } + tui.frame_requester().schedule_frame(); + } + + pub(crate) fn toggle_workflow_job_enabled_from_ui( + &mut self, + workflow_path: PathBuf, + job_name: String, + ) { + match workflow_editor::toggle_job_enabled(workflow_path.as_path(), &job_name) { + Ok(enabled) => { + self.chat_widget.add_info_message( + format!( + "{} workflow job `{job_name}`.", + if enabled { "Enabled" } else { "Disabled" } + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + + pub(crate) fn cycle_workflow_job_context_from_ui( + &mut self, + workflow_path: PathBuf, + job_name: String, + ) { + match workflow_editor::cycle_job_context(workflow_path.as_path(), &job_name) { + Ok(context) => { + self.chat_widget.add_info_message( + format!( + "Workflow job `{job_name}` now uses {} context.", + workflow_context_label(context) + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + + pub(crate) fn cycle_workflow_job_response_from_ui( + &mut self, + workflow_path: PathBuf, + job_name: String, + ) { + match workflow_editor::cycle_job_response(workflow_path.as_path(), &job_name) { + Ok(response) => { + self.chat_widget.add_info_message( + format!( + "Workflow job `{job_name}` now delivers a {} response.", + workflow_response_label(response) + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + + fn resolve_editor_command_for_workflows(&mut self) -> Result, ()> { + match external_editor::resolve_editor_command() { + Ok(cmd) => Ok(cmd), + Err(external_editor::EditorError::MissingEditor) => { + self.chat_widget + .add_to_history(history_cell::new_error_event( + "Cannot open external editor: set $VISUAL or $EDITOR before starting Codex." + .to_string(), + )); + Err(()) + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + Err(()) + } + } + } + + fn show_workflow_popup(&mut self, params: SelectionViewParams, replace_if_active: bool) { + if replace_if_active { + let _ = self + .chat_widget + .replace_selection_view_if_active(WORKFLOW_CONTROLS_VIEW_ID, params); + return; + } + self.chat_widget.show_selection_view(params); + } + + fn workflow_root_popup_params( &self, initial_selected_idx: Option, ) -> SelectionViewParams { let running_labels = self.background_workflow_labels(); let queued_labels = self.queued_trigger_labels(); + let state = workflow_menu_state(self.config.cwd.as_path()); + let mut items = vec![SelectionItem { name: "Background Tasks".to_string(), description: Some(workflow_status_summary(&running_labels, &queued_labels)), @@ -83,16 +350,79 @@ impl App { ..Default::default() }]; - match load_workflow_registry(self.config.cwd.as_path()) { - Ok(registry) => { - let mut registry_items = - workflow_registry_items(®istry, &running_labels, &queued_labels); - items.append(&mut registry_items); + match state { + Ok(state) => { + if let Some(err) = state.registry_error { + items.push(SelectionItem { + name: "Workflow Registry Error".to_string(), + description: Some(err), + selected_description: Some( + "Structured workflow actions are unavailable until the YAML parses again, but you can still open files below." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + + if state.files.is_empty() { + items.push(SelectionItem { + name: "Create workflow.yaml".to_string(), + description: Some( + "Create a starter template under .codex/workflows and open it in your editor." + .to_string(), + ), + selected_description: Some( + "Create a default workflow template, then open it in your configured editor." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::CreateDefaultWorkflowTemplate))], + dismiss_on_select: false, + ..Default::default() + }); + } else { + for file in state.files { + let workflow_path = file.workflow_path.clone(); + items.push(SelectionItem { + name: file.display_name.clone(), + description: Some(format!( + "{} · {} · {}", + file.filename, + count_label(file.manual_trigger_count, "manual trigger"), + count_label(file.job_count, "job") + )), + selected_description: Some(match file.workflow_name { + Some(_) => { + "Open this workflow file. You can edit the YAML, browse jobs, or run manual triggers." + .to_string() + } + None => { + "Open this workflow file in your editor. Structured actions will appear once the YAML parses successfully." + .to_string() + } + }), + search_value: Some(format!( + "{} {}", + file.display_name.to_ascii_lowercase(), + file.filename.to_ascii_lowercase() + )), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::File { + workflow_path: workflow_path.clone(), + }, + }); + })], + dismiss_on_select: false, + ..Default::default() + }); + } + } } - Err(error) => { + Err(err) => { items.push(SelectionItem { name: "Workflow Registry Error".to_string(), - description: Some(error.to_string()), + description: Some(err), selected_description: Some( "Fix the workflow files under .codex/workflows, then reopen /workflow." .to_string(), @@ -107,7 +437,7 @@ impl App { view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), title: Some("Workflow".to_string()), subtitle: Some( - "Trigger workspace workflows and inspect current scheduler/runtime state." + "Manage workflow files, browse jobs, and inspect current background status." .to_string(), ), footer_hint: Some(standard_popup_hint_line()), @@ -118,119 +448,695 @@ impl App { ..Default::default() } } -} -fn workflow_registry_items( - registry: &LoadedWorkflowRegistry, - running_labels: &[String], - queued_labels: &[String], -) -> Vec { - let running_set = running_labels.iter().cloned().collect::>(); - let queued_set = queued_labels.iter().cloned().collect::>(); - let mut items = Vec::new(); + fn workflow_file_popup_params( + &self, + workflow_path: &Path, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_file_view_state(self.config.cwd.as_path(), workflow_path) { + Ok(state) => { + let mut items = vec![ + workflow_back_item(WorkflowControlsDestination::Root), + workflow_edit_file_item( + workflow_path.to_path_buf(), + WorkflowControlsDestination::File { + workflow_path: workflow_path.to_path_buf(), + }, + ), + ]; - let trigger_items = registry - .files - .iter() - .flat_map(|workflow| { - workflow.triggers.iter().filter_map(|trigger| { - if !matches!(trigger.kind, WorkflowTriggerKind::Manual) { - return None; + if let Some(err) = &state.registry_error { + items.push(SelectionItem { + name: "Registry Error".to_string(), + description: Some(err.clone()), + selected_description: Some( + "Fix the YAML in your editor to restore jobs and trigger actions." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "Jobs".to_string(), + description: Some(count_label(state.summary.job_count, "job")), + selected_description: Some( + "Open the jobs menu for this workflow. From there you can drill into a job, run it, toggle it, and edit fields." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::Jobs { + workflow_path: workflow_path.clone(), + }, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + items.push(SelectionItem { + name: "Manual Triggers".to_string(), + description: Some(count_label( + state.summary.manual_trigger_count, + "manual trigger", + )), + selected_description: Some( + "Open this workflow's manual triggers. Trigger runs stay visible in the footer and /ps." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::ManualTriggers { + workflow_path: workflow_path.clone(), + }, + }); + } + })], + dismiss_on_select: false, + is_disabled: state.summary.manual_trigger_count == 0, + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Workflow".to_string()), + subtitle: Some(format!( + "{} · {}", + state.summary.display_name, state.summary.filename + )), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search workflow actions".to_string()), + initial_selected_idx, + ..Default::default() + } + } + Err(err) => workflow_error_popup_params( + "Workflow", + "Failed to open workflow file.", + err, + workflow_back_item(WorkflowControlsDestination::Root), + initial_selected_idx, + ), + } + } + + fn workflow_jobs_popup_params( + &self, + workflow_path: &Path, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_loaded_file_state(self.config.cwd.as_path(), workflow_path) { + Ok((summary, registry, workflow)) => { + let running_labels = self.background_workflow_labels(); + let queued_labels = self.queued_trigger_labels(); + let running_set = running_labels.iter().cloned().collect::>(); + let queued_set = queued_labels.iter().cloned().collect::>(); + let mut items = vec![ + workflow_back_item(WorkflowControlsDestination::File { + workflow_path: workflow_path.to_path_buf(), + }), + workflow_edit_file_item( + workflow_path.to_path_buf(), + WorkflowControlsDestination::Jobs { + workflow_path: workflow_path.to_path_buf(), + }, + ), + ]; + + let mut jobs = registry + .jobs + .values() + .filter(|job| job.workflow_path == workflow.source_path) + .cloned() + .collect::>(); + jobs.sort_by_key(|job| job.definition_index); + + if jobs.is_empty() { + items.push(SelectionItem { + name: "No jobs defined".to_string(), + description: Some( + "Edit workflow.yaml to add jobs to this workflow.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + for job in jobs { + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); + let status = workflow_target_status( + &format!("{} · {}", workflow.name, job.name), + &running_set, + &queued_set, + ); + items.push(SelectionItem { + name: job.name.clone(), + description: Some(format!( + "{} · {} · {} · {status}", + if job.config.enabled { "Enabled" } else { "Disabled" }, + workflow_context_label(job.config.context), + workflow_response_label(job.config.response) + )), + selected_description: Some( + "Open this job. You can run it now, toggle enabled state, and edit its fields." + .to_string(), + ), + search_value: Some(format!( + "{} {} {} {}", + summary.display_name, + summary.filename, + job.name, + status + )), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::Job { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + }, + }); + })], + dismiss_on_select: false, + ..Default::default() + }); + } + } + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Workflow Jobs".to_string()), + subtitle: Some(format!("{} · {}", workflow.name, summary.filename)), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search jobs".to_string()), + initial_selected_idx, + ..Default::default() } - let label = format!("{} · {}", workflow.name, trigger.id); - let status = workflow_target_status(&label, &running_set, &queued_set); - Some(SelectionItem { - name: label.clone(), - description: Some(format!("Manual trigger · {status}")), + } + Err(err) => workflow_error_popup_params( + "Workflow Jobs", + "Failed to load workflow jobs.", + err, + workflow_back_item(WorkflowControlsDestination::File { + workflow_path: workflow_path.to_path_buf(), + }), + initial_selected_idx, + ), + } + } + + fn workflow_manual_triggers_popup_params( + &self, + workflow_path: &Path, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_loaded_file_state(self.config.cwd.as_path(), workflow_path) { + Ok((summary, _registry, workflow)) => { + let running_labels = self.background_workflow_labels(); + let queued_labels = self.queued_trigger_labels(); + let running_set = running_labels.iter().cloned().collect::>(); + let queued_set = queued_labels.iter().cloned().collect::>(); + let mut items = vec![ + workflow_back_item(WorkflowControlsDestination::File { + workflow_path: workflow_path.to_path_buf(), + }), + workflow_edit_file_item( + workflow_path.to_path_buf(), + WorkflowControlsDestination::ManualTriggers { + workflow_path: workflow_path.to_path_buf(), + }, + ), + ]; + + let triggers = workflow + .triggers + .iter() + .filter(|trigger| matches!(trigger.kind, WorkflowTriggerKind::Manual)) + .collect::>(); + if triggers.is_empty() { + items.push(SelectionItem { + name: "No manual triggers defined".to_string(), + description: Some("Edit workflow.yaml to add manual triggers.".to_string()), + is_disabled: true, + ..Default::default() + }); + } else { + for trigger in triggers { + let workflow_name = workflow.name.clone(); + let trigger_id = trigger.id.clone(); + let label = format!("{} · {}", workflow.name, trigger.id); + let status = workflow_target_status(&label, &running_set, &queued_set); + let mut item = SelectionItem { + name: trigger.id.clone(), + description: Some(format!( + "{} · {status}", + if trigger.enabled { "Enabled" } else { "Disabled" } + )), + selected_description: Some( + "Run this manual trigger now. Trigger runs stay visible in the footer and /ps." + .to_string(), + ), + search_value: Some(format!( + "{} {} {} {}", + summary.display_name, + summary.filename, + trigger.id, + status + )), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::StartManualWorkflowTrigger { + workflow_name: workflow_name.clone(), + trigger_id: trigger_id.clone(), + }); + })], + dismiss_on_select: false, + ..Default::default() + }; + if !trigger.enabled { + item.is_disabled = true; + item.disabled_reason = Some( + "Enable this trigger in workflow.yaml before running it." + .to_string(), + ); + } + items.push(item); + } + } + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Manual Triggers".to_string()), + subtitle: Some(format!("{} · {}", workflow.name, summary.filename)), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search triggers".to_string()), + initial_selected_idx, + ..Default::default() + } + } + Err(err) => workflow_error_popup_params( + "Manual Triggers", + "Failed to load manual triggers.", + err, + workflow_back_item(WorkflowControlsDestination::File { + workflow_path: workflow_path.to_path_buf(), + }), + initial_selected_idx, + ), + } + } + + fn workflow_job_popup_params( + &self, + workflow_path: &Path, + job_name: &str, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_loaded_job_state(self.config.cwd.as_path(), workflow_path, job_name) { + Ok((summary, workflow, job)) => { + let mut items = vec![ + workflow_back_item(WorkflowControlsDestination::Jobs { + workflow_path: workflow_path.to_path_buf(), + }), + workflow_edit_file_item( + workflow_path.to_path_buf(), + WorkflowControlsDestination::Job { + workflow_path: workflow_path.to_path_buf(), + job_name: job_name.to_string(), + }, + ), + ]; + + if job.config.enabled { + items.push(SelectionItem { + name: "Run Now".to_string(), + description: Some("Run this job immediately in a background workflow thread.".to_string()), + selected_description: Some( + "Start this job now. Running state stays visible in the footer and /ps." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_name = workflow.name.clone(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::StartManualWorkflowJob { + workflow_name: workflow_name.clone(), + job_name: job_name.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "Run Now".to_string(), + description: Some("This job is disabled.".to_string()), + selected_description: Some( + "Enable this job first, then run it from here.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + + items.push(SelectionItem { + name: if job.config.enabled { + "Disable Job".to_string() + } else { + "Enable Job".to_string() + }, + description: Some(if job.config.enabled { + "Prevent this job from running until it is enabled again.".to_string() + } else { + "Allow this job to run again.".to_string() + }), selected_description: Some( - "Run this workflow trigger now. Running and queued state will stay visible in the footer and /ps." + "Toggle this job's enabled state in workflow.yaml.".to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::ToggleWorkflowJobEnabled { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: format!("Context: {}", workflow_context_label(job.config.context)), + description: Some( + "Toggle between embed and ephemeral execution context.".to_string(), + ), + selected_description: Some( + "Toggle this job's `context` field in workflow.yaml.".to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::CycleWorkflowJobContext { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: format!("Response: {}", workflow_response_label(job.config.response)), + description: Some( + "Toggle whether this job replies as assistant output or a user follow-up." .to_string(), ), - search_value: Some(format!("{} {}", workflow.name, trigger.id)), + selected_description: Some( + "Toggle this job's `response` field in workflow.yaml.".to_string(), + ), actions: vec![Box::new({ - let workflow_name = workflow.name.clone(); - let trigger_id = trigger.id.clone(); + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); move |tx| { - tx.send(AppEvent::StartManualWorkflowTrigger { - workflow_name: workflow_name.clone(), - trigger_id: trigger_id.clone(), + tx.send(AppEvent::CycleWorkflowJobResponse { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), }); } })], dismiss_on_select: false, ..Default::default() - }) - }) - }) - .collect::>(); + }); + + items.push(SelectionItem { + name: "Edit Needs".to_string(), + description: Some(count_label(job.config.needs.len(), "dependency")), + selected_description: Some( + "Open the `needs` field in your external editor and save the YAML list back into the workflow file." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowJobField { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + field: WorkflowJobEditableField::Needs, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); - if !trigger_items.is_empty() { - items.push(disabled_section_item( - "Manual Triggers", - trigger_items.len(), - )); - items.extend(trigger_items); + items.push(SelectionItem { + name: "Edit Steps".to_string(), + description: Some(count_label(job.config.steps.len(), "step")), + selected_description: Some( + "Open this job's `steps` field in your external editor and save the YAML back into the workflow file." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowJobField { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + field: WorkflowJobEditableField::Steps, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Workflow Job".to_string()), + subtitle: Some(format!( + "{} · {} · {}", + workflow.name, job.name, summary.filename + )), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search job actions".to_string()), + initial_selected_idx, + ..Default::default() + } + } + Err(err) => workflow_error_popup_params( + "Workflow Job", + "Failed to load workflow job.", + err, + workflow_back_item(WorkflowControlsDestination::Jobs { + workflow_path: workflow_path.to_path_buf(), + }), + initial_selected_idx, + ), + } } +} - let job_items = registry - .jobs - .values() - .map(|job| { - let label = format!("{} · {}", job.workflow_name, job.name); - let status = workflow_target_status(&label, &running_set, &queued_set); - SelectionItem { - name: label.clone(), - description: Some(format!("Job run · {status}")), - selected_description: Some( - "Run this workflow job directly. Running state will stay visible in the footer and /ps." - .to_string(), - ), - search_value: Some(format!("{} {}", job.workflow_name, job.name)), - actions: vec![Box::new({ - let workflow_name = job.workflow_name.clone(); - let job_name = job.name.clone(); - move |tx| { - tx.send(AppEvent::StartManualWorkflowJob { - workflow_name: workflow_name.clone(), - job_name: job_name.clone(), - }); +fn workflow_menu_state(cwd: &Path) -> Result { + let workflow_paths = workflow_editor::workflow_file_paths(cwd)?; + let registry = load_workflow_registry(cwd); + let registry_error = registry.as_ref().err().map(ToString::to_string); + let files = workflow_paths + .into_iter() + .map(|workflow_path| match registry.as_ref() { + Ok(registry) => { + if let Some(workflow) = registry + .files + .iter() + .find(|workflow| workflow.source_path == workflow_path) + { + WorkflowFileSummary { + workflow_path: workflow_path.clone(), + workflow_name: Some(workflow.name.clone()), + display_name: workflow.name.clone(), + filename: filename_label(&workflow_path), + manual_trigger_count: workflow + .triggers + .iter() + .filter(|trigger| matches!(trigger.kind, WorkflowTriggerKind::Manual)) + .count(), + job_count: registry + .jobs + .values() + .filter(|job| job.workflow_path == workflow.source_path) + .count(), } - })], - dismiss_on_select: false, - ..Default::default() + } else { + fallback_workflow_summary(workflow_path) + } } + Err(_) => fallback_workflow_summary(workflow_path), }) .collect::>(); - if !job_items.is_empty() { - items.push(disabled_section_item("Jobs", job_items.len())); - items.extend(job_items); - } + Ok(WorkflowMenuState { + files, + registry_error, + }) +} - if items.is_empty() { - items.push(SelectionItem { - name: "No runnable workflow targets".to_string(), - description: Some( - "Add manual triggers or jobs under .codex/workflows to make them runnable from /workflow." - .to_string(), - ), - selected_description: Some( - "Automatic before_turn / after_turn workflows still run through the existing scheduler/runtime path." - .to_string(), - ), - is_disabled: true, - ..Default::default() - }); +fn workflow_file_view_state( + cwd: &Path, + workflow_path: &Path, +) -> Result { + let state = workflow_menu_state(cwd)?; + let summary = state + .files + .iter() + .find(|file| file.workflow_path == workflow_path) + .cloned() + .ok_or_else(|| format!("workflow file `{}` does not exist", workflow_path.display()))?; + Ok(WorkflowFileViewState { + summary, + registry_error: state.registry_error, + }) +} + +fn workflow_loaded_file_state( + cwd: &Path, + workflow_path: &Path, +) -> Result< + ( + WorkflowFileSummary, + LoadedWorkflowRegistry, + LoadedWorkflowFile, + ), + String, +> { + let summary = workflow_menu_state(cwd)? + .files + .into_iter() + .find(|file| file.workflow_path == workflow_path) + .ok_or_else(|| format!("workflow file `{}` does not exist", workflow_path.display()))?; + let registry = load_workflow_registry(cwd).map_err(|err| err.to_string())?; + let workflow = registry + .files + .iter() + .find(|workflow| workflow.source_path == workflow_path) + .cloned() + .ok_or_else(|| format!("workflow `{}` is not available", workflow_path.display()))?; + Ok((summary, registry, workflow)) +} + +fn workflow_loaded_job_state( + cwd: &Path, + workflow_path: &Path, + job_name: &str, +) -> Result<(WorkflowFileSummary, LoadedWorkflowFile, LoadedWorkflowJob), String> { + let (summary, registry, workflow) = workflow_loaded_file_state(cwd, workflow_path)?; + let job = registry + .jobs + .get(job_name) + .filter(|job| job.workflow_path == workflow.source_path) + .cloned() + .ok_or_else(|| format!("workflow job `{job_name}` does not exist"))?; + Ok((summary, workflow, job)) +} + +fn fallback_workflow_summary(workflow_path: PathBuf) -> WorkflowFileSummary { + WorkflowFileSummary { + display_name: workflow_path + .file_stem() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| filename_label(&workflow_path)), + filename: filename_label(&workflow_path), + workflow_path, + workflow_name: None, + manual_trigger_count: 0, + job_count: 0, } +} - items +fn workflow_back_item(destination: WorkflowControlsDestination) -> SelectionItem { + SelectionItem { + name: "Back".to_string(), + description: Some("Return to the previous workflow menu.".to_string()), + selected_description: Some("Return to the previous workflow menu.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: destination.clone(), + }); + })], + dismiss_on_select: false, + ..Default::default() + } } -fn disabled_section_item(title: &str, count: usize) -> SelectionItem { +fn workflow_edit_file_item( + workflow_path: PathBuf, + reopen: WorkflowControlsDestination, +) -> SelectionItem { SelectionItem { - name: title.to_string(), - description: Some(format!("{count} available")), - is_disabled: true, + name: "Edit workflow.yaml".to_string(), + description: Some(format!( + "Open {} in your editor.", + filename_label(&workflow_path) + )), + selected_description: Some( + "Open the real workflow YAML file in your external editor.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::EditWorkflowFile { + workflow_path: workflow_path.clone(), + reopen: reopen.clone(), + }); + })], + dismiss_on_select: false, + ..Default::default() + } +} + +fn workflow_error_popup_params( + title: &str, + subtitle: &str, + err: String, + back_item: SelectionItem, + initial_selected_idx: Option, +) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some(title.to_string()), + subtitle: Some(subtitle.to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + back_item, + SelectionItem { + name: "Workflow Error".to_string(), + description: Some(err), + selected_description: Some( + "Fix the workflow YAML, then reopen this menu.".to_string(), + ), + is_disabled: true, + ..Default::default() + }, + ], + is_searchable: true, + search_placeholder: Some("Type to search workflow actions".to_string()), + initial_selected_idx, ..Default::default() } } @@ -256,3 +1162,182 @@ fn workflow_target_status( "Ready" } } + +fn workflow_context_label(context: WorkflowContextMode) -> &'static str { + match context { + WorkflowContextMode::Embed => "Embed", + WorkflowContextMode::Ephemeral => "Ephemeral", + } +} + +fn workflow_response_label(response: WorkflowResponseMode) -> &'static str { + match response { + WorkflowResponseMode::Assistant => "Assistant", + WorkflowResponseMode::User => "User", + } +} + +fn workflow_job_field_label(field: WorkflowJobEditableField) -> &'static str { + match field { + WorkflowJobEditableField::Needs => "needs", + WorkflowJobEditableField::Steps => "steps", + } +} + +fn filename_label(path: &Path) -> String { + path.file_name() + .map(|filename| filename.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()) +} + +fn count_label(count: usize, noun: &str) -> String { + let suffix = if count == 1 { "" } else { "s" }; + format!("{count} {noun}{suffix}") +} + +struct WorkflowFileViewState { + summary: WorkflowFileSummary, + registry_error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chatwidget::tests::render_bottom_popup; + use crate::test_support::PathBufExt; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + fn write_test_manual_workflow(workspace_cwd: &Path) { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).unwrap(); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +triggers: + - type: manual + id: review_backlog + jobs: [summarize] + - type: manual + id: triage + enabled: false + jobs: [notify] + +jobs: + summarize: + context: embed + steps: + - prompt: | + summarize the backlog + notify: + context: ephemeral + response: user + needs: [summarize] + steps: + - prompt: | + send workflow update +"#, + ) + .unwrap(); + } + + #[tokio::test] + async fn workflow_root_popup_shows_create_template_when_empty() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + + app.open_workflow_control_view(WorkflowControlsDestination::Root); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_root_popup_empty", popup); + } + + #[tokio::test] + async fn workflow_file_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::File { workflow_path }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_file_popup", popup); + } + + #[tokio::test] + async fn workflow_jobs_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::Jobs { workflow_path }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_jobs_popup", popup); + } + + #[tokio::test] + async fn workflow_job_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name: "notify".to_string(), + }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_job_popup", popup); + } + + #[tokio::test] + async fn workflow_manual_triggers_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::ManualTriggers { + workflow_path, + }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_manual_triggers_popup", popup); + } + + #[test] + fn workflow_menu_state_lists_files_even_when_registry_is_invalid() { + let dir = tempdir().unwrap(); + let workspace = dir.path().join("workspace"); + let workflows_dir = workspace.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).unwrap(); + let path = workflows_dir.join("broken.yaml"); + std::fs::write(&path, "name: [").unwrap(); + + let state = workflow_menu_state(workspace.as_path()).unwrap(); + assert_eq!(state.files.len(), 1); + assert_eq!(state.files[0].filename, "broken.yaml"); + assert!(state.registry_error.is_some()); + } +} diff --git a/codex-rs/tui/src/app/workflow_definition.rs b/codex-rs/tui/src/app/workflow_definition.rs index 1faa46775..aa4bab296 100644 --- a/codex-rs/tui/src/app/workflow_definition.rs +++ b/codex-rs/tui/src/app/workflow_definition.rs @@ -1,5 +1,6 @@ use indexmap::IndexMap; use serde::Deserialize; +use serde::Serialize; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::VecDeque; @@ -29,7 +30,7 @@ impl std::fmt::Display for WorkflowDefinitionError { impl std::error::Error for WorkflowDefinitionError {} -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum WorkflowContextMode { Embed, @@ -37,7 +38,7 @@ pub(crate) enum WorkflowContextMode { Ephemeral, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum WorkflowResponseMode { #[default] @@ -45,7 +46,7 @@ pub(crate) enum WorkflowResponseMode { User, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(untagged)] pub(crate) enum WorkflowStep { Run { run: String, retry: Option }, diff --git a/codex-rs/tui/src/app/workflow_editor.rs b/codex-rs/tui/src/app/workflow_editor.rs new file mode 100644 index 000000000..6bc1af31f --- /dev/null +++ b/codex-rs/tui/src/app/workflow_editor.rs @@ -0,0 +1,399 @@ +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use serde_yaml::Mapping; +use serde_yaml::Value as YamlValue; + +use super::workflow_definition::WorkflowContextMode; +use super::workflow_definition::WorkflowResponseMode; +use super::workflow_definition::WorkflowStep; +use crate::app_event::WorkflowJobEditableField; + +pub(crate) const DEFAULT_WORKFLOW_TEMPLATE_FILENAME: &str = "workflow.yaml"; + +const WORKFLOW_DIR_NAME: &str = "workflows"; +const DEFAULT_WORKFLOW_TEMPLATE: &str = r#"name: sample-workflow + +triggers: + - type: manual + id: run_now + jobs: [main] + +jobs: + main: + context: ephemeral + response: assistant + steps: + - prompt: | + Describe the work this workflow should do. +"#; + +pub(crate) fn workflow_file_paths(cwd: &Path) -> Result, String> { + let workflow_dir = workflow_dir(cwd); + if !workflow_dir.exists() { + return Ok(Vec::new()); + } + + let mut files = fs::read_dir(&workflow_dir) + .map_err(|err| { + format!( + "failed to read workflow directory `{}`: {err}", + workflow_dir.display() + ) + })? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "yaml") + }) + .collect::>(); + files.sort(); + Ok(files) +} + +pub(crate) fn create_default_workflow_template(cwd: &Path) -> Result { + let workflow_dir = workflow_dir(cwd); + fs::create_dir_all(&workflow_dir).map_err(|err| { + format!( + "failed to create workflow directory `{}`: {err}", + workflow_dir.display() + ) + })?; + + let mut candidate = workflow_dir.join(DEFAULT_WORKFLOW_TEMPLATE_FILENAME); + if candidate.exists() { + let mut suffix = 2_u32; + loop { + let next = workflow_dir.join(format!("workflow-{suffix}.yaml")); + if !next.exists() { + candidate = next; + break; + } + suffix = suffix.saturating_add(1); + } + } + + fs::write(&candidate, DEFAULT_WORKFLOW_TEMPLATE).map_err(|err| { + format!( + "failed to write workflow template `{}`: {err}", + candidate.display() + ) + })?; + Ok(candidate) +} + +pub(crate) fn toggle_job_enabled(workflow_path: &Path, job_name: &str) -> Result { + mutate_job(workflow_path, job_name, |job| { + let enabled = job + .get(string_key("enabled")) + .and_then(|value| serde_yaml::from_value::(value.clone()).ok()) + .unwrap_or(true); + let next_enabled = !enabled; + job.insert(string_key("enabled"), YamlValue::Bool(next_enabled)); + Ok(next_enabled) + }) +} + +pub(crate) fn cycle_job_context( + workflow_path: &Path, + job_name: &str, +) -> Result { + mutate_job(workflow_path, job_name, |job| { + let current = job + .get(string_key("context")) + .and_then(|value| serde_yaml::from_value::(value.clone()).ok()) + .unwrap_or_default(); + let next = match current { + WorkflowContextMode::Embed => WorkflowContextMode::Ephemeral, + WorkflowContextMode::Ephemeral => WorkflowContextMode::Embed, + }; + job.insert( + string_key("context"), + serde_yaml::to_value(next).map_err(|err| err.to_string())?, + ); + Ok(next) + }) +} + +pub(crate) fn cycle_job_response( + workflow_path: &Path, + job_name: &str, +) -> Result { + mutate_job(workflow_path, job_name, |job| { + let current = job + .get(string_key("response")) + .and_then(|value| serde_yaml::from_value::(value.clone()).ok()) + .unwrap_or_default(); + let next = match current { + WorkflowResponseMode::Assistant => WorkflowResponseMode::User, + WorkflowResponseMode::User => WorkflowResponseMode::Assistant, + }; + job.insert( + string_key("response"), + serde_yaml::to_value(next).map_err(|err| err.to_string())?, + ); + Ok(next) + }) +} + +pub(crate) fn job_field_seed( + workflow_path: &Path, + job_name: &str, + field: WorkflowJobEditableField, +) -> Result { + let document = load_yaml_document(workflow_path)?; + let job = workflow_job_mapping(&document, job_name)?; + match field { + WorkflowJobEditableField::Needs => { + let needs = job + .get(string_key("needs")) + .map(|value| serde_yaml::from_value::>(value.clone())) + .transpose() + .map_err(|err| err.to_string())? + .unwrap_or_default(); + serialize_yaml_fragment(&needs) + } + WorkflowJobEditableField::Steps => { + let steps = job + .get(string_key("steps")) + .map(|value| serde_yaml::from_value::>(value.clone())) + .transpose() + .map_err(|err| err.to_string())? + .unwrap_or_default(); + serialize_yaml_fragment(&steps) + } + } +} + +pub(crate) fn write_job_field( + workflow_path: &Path, + job_name: &str, + field: WorkflowJobEditableField, + text: &str, +) -> Result<(), String> { + match field { + WorkflowJobEditableField::Needs => { + let needs = match text.trim() { + "" => Vec::new(), + _ => serde_yaml::from_str::>(text).map_err(|err| err.to_string())?, + }; + mutate_job(workflow_path, job_name, |job| { + job.insert( + string_key("needs"), + serde_yaml::to_value(needs).map_err(|err| err.to_string())?, + ); + Ok(()) + }) + } + WorkflowJobEditableField::Steps => { + let steps = match text.trim() { + "" => Vec::new(), + _ => serde_yaml::from_str::>(text) + .map_err(|err| err.to_string())?, + }; + mutate_job(workflow_path, job_name, |job| { + job.insert( + string_key("steps"), + serde_yaml::to_value(steps).map_err(|err| err.to_string())?, + ); + Ok(()) + }) + } + } +} + +fn workflow_dir(cwd: &Path) -> PathBuf { + cwd.join(".codex").join(WORKFLOW_DIR_NAME) +} + +fn load_yaml_document(workflow_path: &Path) -> Result { + let contents = fs::read_to_string(workflow_path).map_err(|err| { + format!( + "failed to read workflow file `{}`: {err}", + workflow_path.display() + ) + })?; + serde_yaml::from_str(&contents).map_err(|err| { + format!( + "failed to parse workflow file `{}`: {err}", + workflow_path.display() + ) + }) +} + +fn save_yaml_document(workflow_path: &Path, document: &YamlValue) -> Result<(), String> { + let contents = serde_yaml::to_string(document).map_err(|err| { + format!( + "failed to serialize workflow file `{}`: {err}", + workflow_path.display() + ) + })?; + fs::write(workflow_path, contents).map_err(|err| { + format!( + "failed to write workflow file `{}`: {err}", + workflow_path.display() + ) + }) +} + +fn workflow_job_mapping<'a>( + document: &'a YamlValue, + job_name: &str, +) -> Result<&'a Mapping, String> { + let document = document + .as_mapping() + .ok_or_else(|| "workflow file root must be a YAML mapping".to_string())?; + let jobs = document + .get(string_key("jobs")) + .and_then(YamlValue::as_mapping) + .ok_or_else(|| "workflow file does not define a `jobs` mapping".to_string())?; + jobs.get(string_key(job_name)) + .and_then(YamlValue::as_mapping) + .ok_or_else(|| format!("workflow job `{job_name}` does not exist")) +} + +fn workflow_job_mapping_mut<'a>( + document: &'a mut YamlValue, + job_name: &str, +) -> Result<&'a mut Mapping, String> { + let document = document + .as_mapping_mut() + .ok_or_else(|| "workflow file root must be a YAML mapping".to_string())?; + let jobs = document + .get_mut(string_key("jobs")) + .and_then(YamlValue::as_mapping_mut) + .ok_or_else(|| "workflow file does not define a `jobs` mapping".to_string())?; + jobs.get_mut(string_key(job_name)) + .and_then(YamlValue::as_mapping_mut) + .ok_or_else(|| format!("workflow job `{job_name}` does not exist")) +} + +fn mutate_job( + workflow_path: &Path, + job_name: &str, + mutator: impl FnOnce(&mut Mapping) -> Result, +) -> Result { + let mut document = load_yaml_document(workflow_path)?; + let result = mutator(workflow_job_mapping_mut(&mut document, job_name)?)?; + save_yaml_document(workflow_path, &document)?; + Ok(result) +} + +fn serialize_yaml_fragment(value: &impl serde::Serialize) -> Result { + let text = serde_yaml::to_string(value).map_err(|err| err.to_string())?; + Ok(text.trim_start_matches("---\n").trim_end().to_string()) +} + +fn string_key(value: &str) -> YamlValue { + YamlValue::String(value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + fn write_workflow(path: &Path) { + fs::create_dir_all(path.parent().expect("parent")).unwrap(); + fs::write( + path, + r#"name: director + +triggers: + - type: manual + id: review + jobs: [notify] + +jobs: + notify: + response: assistant + steps: + - prompt: | + send an update +"#, + ) + .unwrap(); + } + + #[test] + fn create_default_workflow_template_writes_new_file() { + let dir = tempdir().unwrap(); + let created = create_default_workflow_template(dir.path()).unwrap(); + + assert_eq!( + created.file_name().unwrap().to_string_lossy(), + DEFAULT_WORKFLOW_TEMPLATE_FILENAME + ); + let text = fs::read_to_string(created).unwrap(); + assert!(text.contains("sample-workflow")); + assert!(text.contains("run_now")); + } + + #[test] + fn toggle_job_enabled_writes_explicit_bool() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".codex/workflows/workflow.yaml"); + write_workflow(&path); + + let enabled = toggle_job_enabled(&path, "notify").unwrap(); + assert!(!enabled); + let disabled = fs::read_to_string(&path).unwrap(); + assert!(disabled.contains("enabled: false")); + + let enabled_again = toggle_job_enabled(&path, "notify").unwrap(); + assert!(enabled_again); + let enabled_text = fs::read_to_string(&path).unwrap(); + assert!(enabled_text.contains("enabled: true")); + } + + #[test] + fn cycle_job_context_and_response_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".codex/workflows/workflow.yaml"); + write_workflow(&path); + + assert_eq!( + cycle_job_context(&path, "notify").unwrap(), + WorkflowContextMode::Embed + ); + assert_eq!( + cycle_job_response(&path, "notify").unwrap(), + WorkflowResponseMode::User + ); + } + + #[test] + fn edit_needs_and_steps_fields_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".codex/workflows/workflow.yaml"); + write_workflow(&path); + + write_job_field( + &path, + "notify", + WorkflowJobEditableField::Needs, + "- prepare\n- publish\n", + ) + .unwrap(); + write_job_field( + &path, + "notify", + WorkflowJobEditableField::Steps, + r#"- prompt: | + summarize the changes +- run: cargo test -p codex-tui +"#, + ) + .unwrap(); + + assert_eq!( + job_field_seed(&path, "notify", WorkflowJobEditableField::Needs).unwrap(), + "- prepare\n- publish" + ); + let steps = job_field_seed(&path, "notify", WorkflowJobEditableField::Steps).unwrap(); + assert!(steps.contains("summarize the changes")); + assert!(steps.contains("cargo test -p codex-tui")); + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index ddcf2a0ca..53b76f35c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -97,6 +97,30 @@ pub(crate) enum WindowsSandboxEnableMode { Legacy, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WorkflowControlsDestination { + Root, + File { + workflow_path: PathBuf, + }, + Jobs { + workflow_path: PathBuf, + }, + Job { + workflow_path: PathBuf, + job_name: String, + }, + ManualTriggers { + workflow_path: PathBuf, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkflowJobEditableField { + Needs, + Steps, +} + #[derive(Debug, Clone)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) struct ConnectorsSnapshot { @@ -346,6 +370,38 @@ pub(crate) enum AppEvent { ShowWorkflowBackgroundTasks, + OpenWorkflowControlView { + destination: WorkflowControlsDestination, + }, + + CreateDefaultWorkflowTemplate, + + EditWorkflowFile { + workflow_path: PathBuf, + reopen: WorkflowControlsDestination, + }, + + ToggleWorkflowJobEnabled { + workflow_path: PathBuf, + job_name: String, + }, + + CycleWorkflowJobContext { + workflow_path: PathBuf, + job_name: String, + }, + + CycleWorkflowJobResponse { + workflow_path: PathBuf, + job_name: String, + }, + + EditWorkflowJobField { + workflow_path: PathBuf, + job_name: String, + field: WorkflowJobEditableField, + }, + StartBtwDiscussion { prompt: String, }, diff --git a/codex-rs/tui/src/external_editor.rs b/codex-rs/tui/src/external_editor.rs index 2818503d0..603db0b75 100644 --- a/codex-rs/tui/src/external_editor.rs +++ b/codex-rs/tui/src/external_editor.rs @@ -1,5 +1,6 @@ use std::env; use std::fs; +use std::path::Path; use std::process::Stdio; use color_eyre::eyre::Report; @@ -52,14 +53,37 @@ pub(crate) fn resolve_editor_command() -> std::result::Result, Edito /// Write `seed` to a temp file, launch the editor command, and return the updated content. pub(crate) async fn run_editor(seed: &str, editor_cmd: &[String]) -> Result { + run_editor_with_suffix(seed, editor_cmd, ".md").await +} + +/// Write `seed` to a temp file with a custom suffix, launch the editor command, +/// and return the updated content. +pub(crate) async fn run_editor_with_suffix( + seed: &str, + editor_cmd: &[String], + suffix: &str, +) -> Result { if editor_cmd.is_empty() { return Err(Report::msg("editor command is empty")); } // Convert to TempPath immediately so no file handle stays open on Windows. - let temp_path = Builder::new().suffix(".md").tempfile()?.into_temp_path(); + let temp_path = Builder::new().suffix(suffix).tempfile()?.into_temp_path(); fs::write(&temp_path, seed)?; + launch_editor_for_path(&temp_path, editor_cmd).await?; + let contents = fs::read_to_string(&temp_path)?; + Ok(contents) +} + +/// Launch the editor against an existing file path. +pub(crate) async fn edit_file(path: &Path, editor_cmd: &[String]) -> Result<()> { + if editor_cmd.is_empty() { + return Err(Report::msg("editor command is empty")); + } + launch_editor_for_path(path, editor_cmd).await +} +async fn launch_editor_for_path(path: &Path, editor_cmd: &[String]) -> Result<()> { let mut cmd = { #[cfg(windows)] { @@ -75,7 +99,7 @@ pub(crate) async fn run_editor(seed: &str, editor_cmd: &[String]) -> Result Result \"$1\"\n").unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + + let file_path = dir.path().join("workflow.yaml"); + fs::write(&file_path, "seed").unwrap(); + + let cmd = vec![script_path.to_string_lossy().to_string()]; + edit_file(&file_path, &cmd).await.unwrap(); + assert_eq!(fs::read_to_string(&file_path).unwrap(), "updated"); + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap index 9b0e34413..dae147d38 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap @@ -1,19 +1,13 @@ --- source: tui/src/app.rs -assertion_line: 12532 expression: popup --- Workflow - Trigger workspace workflows and inspect current scheduler/runtime state. + Manage workflow files, browse jobs, and inspect current background status. Type to search workflows -› Background Tasks Insert a background task snapshot into the transcript. /ps shows the - same live workflow state. - Manual Triggers 2 available - director · review_backlog Manual trigger · Running - director · triage Manual trigger · Queued - Jobs 2 available - director · notify Job run · Ready - director · summarize Job run · Ready +› Background Tasks Insert a background task snapshot into the transcript. /ps shows the same live + workflow state. + director manual.yaml · 2 manual triggers · 2 jobs Press enter to confirm or esc to go back From 2e6946e473ef9c9c390f5cda138f8a84132f46d4 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 17:47:27 +0800 Subject: [PATCH 48/60] update workflow UI menu --- codex-rs/tui/src/app.rs | 6 + ...w_controls__tests__workflow_job_popup.snap | 2 +- ..._tests__workflow_manual_trigger_popup.snap | 15 + ...tests__workflow_manual_triggers_popup.snap | 3 +- ...ols__tests__workflow_root_popup_empty.snap | 2 +- codex-rs/tui/src/app/workflow_controls.rs | 477 +++++++++++++++--- codex-rs/tui/src/app/workflow_editor.rs | 72 +++ codex-rs/tui/src/app_event.rs | 9 + codex-rs/tui/src/profile_router.rs | 2 + ...__app__tests__workflow_controls_popup.snap | 12 +- 10 files changed, 512 insertions(+), 88 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_trigger_popup.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 94738da84..2c842d0d3 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4798,6 +4798,12 @@ impl App { self.edit_workflow_file_from_ui(tui, workflow_path, reopen) .await; } + AppEvent::ToggleWorkflowTriggerEnabled { + workflow_path, + trigger_id, + } => { + self.toggle_workflow_trigger_enabled_from_ui(workflow_path, trigger_id); + } AppEvent::ToggleWorkflowJobEnabled { workflow_path, job_name, diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_job_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_job_popup.snap index 7bee3688d..246786dfe 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_job_popup.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_job_popup.snap @@ -9,7 +9,7 @@ expression: popup › Back Return to the previous workflow menu. Edit workflow.yaml Open manual.yaml in your editor. Run Now Run this job immediately in a background workflow thread. - Disable Job Prevent this job from running until it is enabled again. + Disable Job Prevent triggers from including this job until it is enabled again. Context: Ephemeral Toggle between embed and ephemeral execution context. Response: User Toggle whether this job replies as assistant output or a user follow-up. Edit Needs 1 dependency diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_trigger_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_trigger_popup.snap new file mode 100644 index 000000000..fbb2ef0c0 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_trigger_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow Trigger + director · review_backlog · manual.yaml + + Type to search trigger actions +› Back Return to the previous workflow menu. + Edit workflow.yaml Open manual.yaml in your editor. + Run Now Run this manual trigger immediately in a background workflow thread. + Disable Trigger Prevent this trigger from starting until it is enabled again. + Target Jobs 1 job + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap index b59af18e0..9ba46fa45 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap @@ -9,7 +9,6 @@ expression: popup › Back Return to the previous workflow menu. Edit workflow.yaml Open manual.yaml in your editor. review_backlog Enabled · Ready - triage (disabled) Disabled · Ready (disabled: Enable this trigger in workflow.yaml before - running it.) + triage Disabled · Ready Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap index 6ef881a2a..be32ee242 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap @@ -3,7 +3,7 @@ source: tui/src/app/workflow_controls.rs expression: popup --- Workflow - Manage workflow files, browse jobs, and inspect current background status. + Manage workflow files, jobs, and manual triggers directly. Type to search workflows › Background Tasks Insert a background task snapshot into the transcript. /ps shows the same diff --git a/codex-rs/tui/src/app/workflow_controls.rs b/codex-rs/tui/src/app/workflow_controls.rs index 8d22f299b..0c9e45d6c 100644 --- a/codex-rs/tui/src/app/workflow_controls.rs +++ b/codex-rs/tui/src/app/workflow_controls.rs @@ -39,10 +39,18 @@ struct WorkflowFileSummary { workflow_name: Option, display_name: String, filename: String, + jobs: Vec, + manual_triggers: Vec, manual_trigger_count: usize, job_count: usize, } +#[derive(Clone)] +struct WorkflowTriggerSummary { + id: String, + enabled: bool, +} + impl App { pub(crate) fn open_workflow_controls_popup(&mut self) { self.open_workflow_control_view(WorkflowControlsDestination::Root); @@ -69,6 +77,14 @@ impl App { WorkflowControlsDestination::ManualTriggers { workflow_path } => { self.workflow_manual_triggers_popup_params(workflow_path.as_path(), Some(0)) } + WorkflowControlsDestination::ManualTrigger { + workflow_path, + trigger_id, + } => self.workflow_manual_trigger_popup_params( + workflow_path.as_path(), + &trigger_id, + Some(0), + ), }; self.show_workflow_popup(params, active_selected_idx.is_some()); } @@ -247,6 +263,32 @@ impl App { } } + pub(crate) fn toggle_workflow_trigger_enabled_from_ui( + &mut self, + workflow_path: PathBuf, + trigger_id: String, + ) { + match workflow_editor::toggle_trigger_enabled(workflow_path.as_path(), &trigger_id) { + Ok(enabled) => { + self.chat_widget.add_info_message( + format!( + "{} workflow trigger `{trigger_id}`.", + if enabled { "Enabled" } else { "Disabled" } + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::ManualTrigger { + workflow_path, + trigger_id, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + pub(crate) fn cycle_workflow_job_context_from_ui( &mut self, workflow_path: PathBuf, @@ -382,40 +424,107 @@ impl App { }); } else { for file in state.files { - let workflow_path = file.workflow_path.clone(); + let workflow_prefix = file.display_name.clone(); + let workflow_filename = file.filename.clone(); + items.push(SelectionItem { - name: file.display_name.clone(), - description: Some(format!( - "{} · {} · {}", - file.filename, - count_label(file.manual_trigger_count, "manual trigger"), - count_label(file.job_count, "job") - )), + name: format!("{workflow_prefix} - Edit workflow.yaml"), + description: Some(format!("Open {workflow_filename} in your editor.")), selected_description: Some(match file.workflow_name { Some(_) => { - "Open this workflow file. You can edit the YAML, browse jobs, or run manual triggers." + "Open the real workflow YAML file in your external editor." .to_string() } None => { - "Open this workflow file in your editor. Structured actions will appear once the YAML parses successfully." + "Open this workflow file in your editor so you can fix the YAML." .to_string() } }), search_value: Some(format!( - "{} {}", - file.display_name.to_ascii_lowercase(), - file.filename.to_ascii_lowercase() + "{} {} edit workflow yaml", + workflow_prefix.to_ascii_lowercase(), + workflow_filename.to_ascii_lowercase() )), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWorkflowControlView { - destination: WorkflowControlsDestination::File { + actions: vec![Box::new({ + let workflow_path = file.workflow_path.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowFile { workflow_path: workflow_path.clone(), - }, - }); + reopen: WorkflowControlsDestination::Root, + }); + } })], dismiss_on_select: false, ..Default::default() }); + + if file.workflow_name.is_some() { + for job_name in &file.jobs { + items.push(SelectionItem { + name: format!("{workflow_prefix} - {job_name}"), + description: Some("Workflow job".to_string()), + selected_description: Some( + "Open this job directly. From there you can run it, toggle it, and edit its fields." + .to_string(), + ), + search_value: Some(format!( + "{} {} {} job", + workflow_prefix.to_ascii_lowercase(), + workflow_filename.to_ascii_lowercase(), + job_name.to_ascii_lowercase() + )), + actions: vec![Box::new({ + let workflow_path = file.workflow_path.clone(); + let job_name = job_name.clone(); + move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::Job { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + }, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + } + + for trigger in &file.manual_triggers { + items.push(SelectionItem { + name: format!("{workflow_prefix} - {}", trigger.id), + description: Some(format!( + "Manual trigger · {}", + if trigger.enabled { "Enabled" } else { "Disabled" } + )), + selected_description: Some( + "Open this trigger directly. From there you can run it now or toggle its enabled state." + .to_string(), + ), + search_value: Some(format!( + "{} {} {} manual trigger", + workflow_prefix.to_ascii_lowercase(), + workflow_filename.to_ascii_lowercase(), + trigger.id.to_ascii_lowercase() + )), + actions: vec![Box::new({ + let workflow_path = file.workflow_path.clone(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: + WorkflowControlsDestination::ManualTrigger { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + }, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + } + } } } } @@ -437,8 +546,7 @@ impl App { view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), title: Some("Workflow".to_string()), subtitle: Some( - "Manage workflow files, browse jobs, and inspect current background status." - .to_string(), + "Manage workflow files, jobs, and manual triggers directly.".to_string(), ), footer_hint: Some(standard_popup_hint_line()), items, @@ -561,9 +669,7 @@ impl App { let running_set = running_labels.iter().cloned().collect::>(); let queued_set = queued_labels.iter().cloned().collect::>(); let mut items = vec![ - workflow_back_item(WorkflowControlsDestination::File { - workflow_path: workflow_path.to_path_buf(), - }), + workflow_back_item(WorkflowControlsDestination::Root), workflow_edit_file_item( workflow_path.to_path_buf(), WorkflowControlsDestination::Jobs { @@ -647,9 +753,7 @@ impl App { "Workflow Jobs", "Failed to load workflow jobs.", err, - workflow_back_item(WorkflowControlsDestination::File { - workflow_path: workflow_path.to_path_buf(), - }), + workflow_back_item(WorkflowControlsDestination::Root), initial_selected_idx, ), } @@ -667,9 +771,7 @@ impl App { let running_set = running_labels.iter().cloned().collect::>(); let queued_set = queued_labels.iter().cloned().collect::>(); let mut items = vec![ - workflow_back_item(WorkflowControlsDestination::File { - workflow_path: workflow_path.to_path_buf(), - }), + workflow_back_item(WorkflowControlsDestination::Root), workflow_edit_file_item( workflow_path.to_path_buf(), WorkflowControlsDestination::ManualTriggers { @@ -692,18 +794,18 @@ impl App { }); } else { for trigger in triggers { - let workflow_name = workflow.name.clone(); + let workflow_path = workflow_path.to_path_buf(); let trigger_id = trigger.id.clone(); let label = format!("{} · {}", workflow.name, trigger.id); let status = workflow_target_status(&label, &running_set, &queued_set); - let mut item = SelectionItem { + items.push(SelectionItem { name: trigger.id.clone(), description: Some(format!( "{} · {status}", if trigger.enabled { "Enabled" } else { "Disabled" } )), selected_description: Some( - "Run this manual trigger now. Trigger runs stay visible in the footer and /ps." + "Open this trigger. From there you can run it now or toggle its enabled state." .to_string(), ), search_value: Some(format!( @@ -714,22 +816,16 @@ impl App { status )), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::StartManualWorkflowTrigger { - workflow_name: workflow_name.clone(), - trigger_id: trigger_id.clone(), + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::ManualTrigger { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + }, }); })], dismiss_on_select: false, ..Default::default() - }; - if !trigger.enabled { - item.is_disabled = true; - item.disabled_reason = Some( - "Enable this trigger in workflow.yaml before running it." - .to_string(), - ); - } - items.push(item); + }); } } @@ -749,50 +845,53 @@ impl App { "Manual Triggers", "Failed to load manual triggers.", err, - workflow_back_item(WorkflowControlsDestination::File { - workflow_path: workflow_path.to_path_buf(), - }), + workflow_back_item(WorkflowControlsDestination::Root), initial_selected_idx, ), } } - fn workflow_job_popup_params( + fn workflow_manual_trigger_popup_params( &self, workflow_path: &Path, - job_name: &str, + trigger_id: &str, initial_selected_idx: Option, ) -> SelectionViewParams { - match workflow_loaded_job_state(self.config.cwd.as_path(), workflow_path, job_name) { - Ok((summary, workflow, job)) => { + match workflow_loaded_manual_trigger_state( + self.config.cwd.as_path(), + workflow_path, + trigger_id, + ) { + Ok((summary, workflow, trigger)) => { let mut items = vec![ - workflow_back_item(WorkflowControlsDestination::Jobs { - workflow_path: workflow_path.to_path_buf(), - }), + workflow_back_item(WorkflowControlsDestination::Root), workflow_edit_file_item( workflow_path.to_path_buf(), - WorkflowControlsDestination::Job { + WorkflowControlsDestination::ManualTrigger { workflow_path: workflow_path.to_path_buf(), - job_name: job_name.to_string(), + trigger_id: trigger.id.clone(), }, ), ]; - if job.config.enabled { + if trigger.enabled { items.push(SelectionItem { name: "Run Now".to_string(), - description: Some("Run this job immediately in a background workflow thread.".to_string()), + description: Some( + "Run this manual trigger immediately in a background workflow thread." + .to_string(), + ), selected_description: Some( - "Start this job now. Running state stays visible in the footer and /ps." + "Start this trigger now. Running state stays visible in the footer and /ps." .to_string(), ), actions: vec![Box::new({ let workflow_name = workflow.name.clone(); - let job_name = job.name.clone(); + let trigger_id = trigger.id.clone(); move |tx| { - tx.send(AppEvent::StartManualWorkflowJob { + tx.send(AppEvent::StartManualWorkflowTrigger { workflow_name: workflow_name.clone(), - job_name: job_name.clone(), + trigger_id: trigger_id.clone(), }); } })], @@ -802,15 +901,128 @@ impl App { } else { items.push(SelectionItem { name: "Run Now".to_string(), - description: Some("This job is disabled.".to_string()), + description: Some("This trigger is disabled.".to_string()), selected_description: Some( - "Enable this job first, then run it from here.".to_string(), + "Enable this trigger first, then run it from here.".to_string(), ), is_disabled: true, ..Default::default() }); } + items.push(SelectionItem { + name: if trigger.enabled { + "Disable Trigger".to_string() + } else { + "Enable Trigger".to_string() + }, + description: Some(if trigger.enabled { + "Prevent this trigger from starting until it is enabled again.".to_string() + } else { + "Allow this trigger to run again.".to_string() + }), + selected_description: Some( + "Toggle this trigger's enabled state in workflow.yaml.".to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::ToggleWorkflowTriggerEnabled { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Target Jobs".to_string(), + description: Some(count_label( + workflow + .triggers + .iter() + .find(|candidate| candidate.id == trigger.id) + .map_or(0, |candidate| candidate.jobs.len()), + "job", + )), + selected_description: Some( + "Edit workflow.yaml if you want to change which jobs this trigger runs." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Workflow Trigger".to_string()), + subtitle: Some(format!( + "{} · {} · {}", + workflow.name, trigger.id, summary.filename + )), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search trigger actions".to_string()), + initial_selected_idx, + ..Default::default() + } + } + Err(err) => workflow_error_popup_params( + "Workflow Trigger", + "Failed to load workflow trigger.", + err, + workflow_back_item(WorkflowControlsDestination::Root), + initial_selected_idx, + ), + } + } + + fn workflow_job_popup_params( + &self, + workflow_path: &Path, + job_name: &str, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_loaded_job_state(self.config.cwd.as_path(), workflow_path, job_name) { + Ok((summary, workflow, job)) => { + let mut items = vec![ + workflow_back_item(WorkflowControlsDestination::Root), + workflow_edit_file_item( + workflow_path.to_path_buf(), + WorkflowControlsDestination::Job { + workflow_path: workflow_path.to_path_buf(), + job_name: job_name.to_string(), + }, + ), + ]; + + items.push(SelectionItem { + name: "Run Now".to_string(), + description: Some( + "Run this job immediately in a background workflow thread.".to_string(), + ), + selected_description: Some( + "Start this job now. Job `enabled` only affects trigger-driven runs; manual runs are always allowed." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_name = workflow.name.clone(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::StartManualWorkflowJob { + workflow_name: workflow_name.clone(), + job_name: job_name.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + items.push(SelectionItem { name: if job.config.enabled { "Disable Job".to_string() @@ -818,12 +1030,14 @@ impl App { "Enable Job".to_string() }, description: Some(if job.config.enabled { - "Prevent this job from running until it is enabled again.".to_string() + "Prevent triggers from including this job until it is enabled again." + .to_string() } else { - "Allow this job to run again.".to_string() + "Allow triggers to include this job again.".to_string() }), selected_description: Some( - "Toggle this job's enabled state in workflow.yaml.".to_string(), + "Toggle whether trigger-driven workflow runs may include this job." + .to_string(), ), actions: vec![Box::new({ let workflow_path = workflow_path.to_path_buf(); @@ -947,9 +1161,7 @@ impl App { "Workflow Job", "Failed to load workflow job.", err, - workflow_back_item(WorkflowControlsDestination::Jobs { - workflow_path: workflow_path.to_path_buf(), - }), + workflow_back_item(WorkflowControlsDestination::Root), initial_selected_idx, ), } @@ -969,21 +1181,31 @@ fn workflow_menu_state(cwd: &Path) -> Result { .iter() .find(|workflow| workflow.source_path == workflow_path) { + let mut jobs = registry + .jobs + .values() + .filter(|job| job.workflow_path == workflow.source_path) + .cloned() + .collect::>(); + jobs.sort_by_key(|job| job.definition_index); + let manual_triggers = workflow + .triggers + .iter() + .filter(|trigger| matches!(trigger.kind, WorkflowTriggerKind::Manual)) + .map(|trigger| WorkflowTriggerSummary { + id: trigger.id.clone(), + enabled: trigger.enabled, + }) + .collect::>(); WorkflowFileSummary { workflow_path: workflow_path.clone(), workflow_name: Some(workflow.name.clone()), display_name: workflow.name.clone(), filename: filename_label(&workflow_path), - manual_trigger_count: workflow - .triggers - .iter() - .filter(|trigger| matches!(trigger.kind, WorkflowTriggerKind::Manual)) - .count(), - job_count: registry - .jobs - .values() - .filter(|job| job.workflow_path == workflow.source_path) - .count(), + job_count: jobs.len(), + jobs: jobs.into_iter().map(|job| job.name).collect(), + manual_trigger_count: manual_triggers.len(), + manual_triggers, } } else { fallback_workflow_summary(workflow_path) @@ -1057,6 +1279,33 @@ fn workflow_loaded_job_state( Ok((summary, workflow, job)) } +fn workflow_loaded_manual_trigger_state( + cwd: &Path, + workflow_path: &Path, + trigger_id: &str, +) -> Result< + ( + WorkflowFileSummary, + LoadedWorkflowFile, + WorkflowTriggerSummary, + ), + String, +> { + let (summary, _registry, workflow) = workflow_loaded_file_state(cwd, workflow_path)?; + let trigger = workflow + .triggers + .iter() + .find(|trigger| { + matches!(trigger.kind, WorkflowTriggerKind::Manual) && trigger.id == trigger_id + }) + .map(|trigger| WorkflowTriggerSummary { + id: trigger.id.clone(), + enabled: trigger.enabled, + }) + .ok_or_else(|| format!("workflow trigger `{trigger_id}` does not exist"))?; + Ok((summary, workflow, trigger)) +} + fn fallback_workflow_summary(workflow_path: PathBuf) -> WorkflowFileSummary { WorkflowFileSummary { display_name: workflow_path @@ -1066,6 +1315,8 @@ fn fallback_workflow_summary(workflow_path: PathBuf) -> WorkflowFileSummary { filename: filename_label(&workflow_path), workflow_path, workflow_name: None, + jobs: Vec::new(), + manual_triggers: Vec::new(), manual_trigger_count: 0, job_count: 0, } @@ -1242,6 +1493,31 @@ jobs: .unwrap(); } + fn write_test_disabled_job_workflow(workspace_cwd: &Path) { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).unwrap(); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +triggers: + - type: manual + id: review_backlog + jobs: [notify] + +jobs: + notify: + enabled: false + context: ephemeral + response: user + steps: + - prompt: | + send workflow update +"#, + ) + .unwrap(); + } + #[tokio::test] async fn workflow_root_popup_shows_create_template_when_empty() { let mut app = super::super::tests::make_test_app().await; @@ -1307,6 +1583,27 @@ jobs: insta::assert_snapshot!("workflow_job_popup", popup); } + #[tokio::test] + async fn disabled_job_popup_still_offers_run_now() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_disabled_job_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name: "notify".to_string(), + }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!(popup.contains("Run Now Run this job immediately")); + assert!(!popup.contains("This job is disabled.")); + } + #[tokio::test] async fn workflow_manual_triggers_popup_snapshot() { let mut app = super::super::tests::make_test_app().await; @@ -1326,6 +1623,26 @@ jobs: insta::assert_snapshot!("workflow_manual_triggers_popup", popup); } + #[tokio::test] + async fn workflow_manual_trigger_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::ManualTrigger { + workflow_path, + trigger_id: "review_backlog".to_string(), + }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_manual_trigger_popup", popup); + } + #[test] fn workflow_menu_state_lists_files_even_when_registry_is_invalid() { let dir = tempdir().unwrap(); diff --git a/codex-rs/tui/src/app/workflow_editor.rs b/codex-rs/tui/src/app/workflow_editor.rs index 6bc1af31f..e2672c8c8 100644 --- a/codex-rs/tui/src/app/workflow_editor.rs +++ b/codex-rs/tui/src/app/workflow_editor.rs @@ -95,6 +95,21 @@ pub(crate) fn toggle_job_enabled(workflow_path: &Path, job_name: &str) -> Result }) } +pub(crate) fn toggle_trigger_enabled( + workflow_path: &Path, + trigger_id: &str, +) -> Result { + mutate_trigger(workflow_path, trigger_id, |trigger| { + let enabled = trigger + .get(string_key("enabled")) + .and_then(|value| serde_yaml::from_value::(value.clone()).ok()) + .unwrap_or(true); + let next_enabled = !enabled; + trigger.insert(string_key("enabled"), YamlValue::Bool(next_enabled)); + Ok(next_enabled) + }) +} + pub(crate) fn cycle_job_context( workflow_path: &Path, job_name: &str, @@ -269,6 +284,35 @@ fn workflow_job_mapping_mut<'a>( .ok_or_else(|| format!("workflow job `{job_name}` does not exist")) } +fn workflow_trigger_mapping_mut<'a>( + document: &'a mut YamlValue, + trigger_id: &str, +) -> Result<&'a mut Mapping, String> { + let document = document + .as_mapping_mut() + .ok_or_else(|| "workflow file root must be a YAML mapping".to_string())?; + let triggers = document + .get_mut(string_key("triggers")) + .and_then(YamlValue::as_sequence_mut) + .ok_or_else(|| "workflow file does not define a `triggers` sequence".to_string())?; + + for (index, trigger) in triggers.iter_mut().enumerate() { + let Some(trigger_mapping) = trigger.as_mapping_mut() else { + continue; + }; + let candidate_id = trigger_mapping + .get(string_key("id")) + .and_then(YamlValue::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| format!("trigger-{}", index + 1)); + if candidate_id == trigger_id { + return Ok(trigger_mapping); + } + } + + Err(format!("workflow trigger `{trigger_id}` does not exist")) +} + fn mutate_job( workflow_path: &Path, job_name: &str, @@ -280,6 +324,17 @@ fn mutate_job( Ok(result) } +fn mutate_trigger( + workflow_path: &Path, + trigger_id: &str, + mutator: impl FnOnce(&mut Mapping) -> Result, +) -> Result { + let mut document = load_yaml_document(workflow_path)?; + let result = mutator(workflow_trigger_mapping_mut(&mut document, trigger_id)?)?; + save_yaml_document(workflow_path, &document)?; + Ok(result) +} + fn serialize_yaml_fragment(value: &impl serde::Serialize) -> Result { let text = serde_yaml::to_string(value).map_err(|err| err.to_string())?; Ok(text.trim_start_matches("---\n").trim_end().to_string()) @@ -348,6 +403,23 @@ jobs: assert!(enabled_text.contains("enabled: true")); } + #[test] + fn toggle_trigger_enabled_writes_explicit_bool() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".codex/workflows/workflow.yaml"); + write_workflow(&path); + + let enabled = toggle_trigger_enabled(&path, "review").unwrap(); + assert!(!enabled); + let disabled = fs::read_to_string(&path).unwrap(); + assert!(disabled.contains("enabled: false")); + + let enabled_again = toggle_trigger_enabled(&path, "review").unwrap(); + assert!(enabled_again); + let enabled_text = fs::read_to_string(&path).unwrap(); + assert!(enabled_text.contains("enabled: true")); + } + #[test] fn cycle_job_context_and_response_round_trip() { let dir = tempdir().unwrap(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 53b76f35c..32c6b4eca 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -113,6 +113,10 @@ pub(crate) enum WorkflowControlsDestination { ManualTriggers { workflow_path: PathBuf, }, + ManualTrigger { + workflow_path: PathBuf, + trigger_id: String, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -381,6 +385,11 @@ pub(crate) enum AppEvent { reopen: WorkflowControlsDestination, }, + ToggleWorkflowTriggerEnabled { + workflow_path: PathBuf, + trigger_id: String, + }, + ToggleWorkflowJobEnabled { workflow_path: PathBuf, job_name: String, diff --git a/codex-rs/tui/src/profile_router.rs b/codex-rs/tui/src/profile_router.rs index a7a3c24f0..b37854ae7 100644 --- a/codex-rs/tui/src/profile_router.rs +++ b/codex-rs/tui/src/profile_router.rs @@ -1,4 +1,5 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +#[cfg(test)] use codex_protocol::protocol::CodexErrorInfo; use serde::Deserialize; use serde::Serialize; @@ -131,6 +132,7 @@ impl ProfileRouterStore { } } +#[cfg(test)] pub(crate) fn core_profile_fallback_action(info: &CodexErrorInfo) -> Option { match info { CodexErrorInfo::UsageLimitExceeded | CodexErrorInfo::Unauthorized => { diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap index dae147d38..9b603f6f2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap @@ -3,11 +3,15 @@ source: tui/src/app.rs expression: popup --- Workflow - Manage workflow files, browse jobs, and inspect current background status. + Manage workflow files, jobs, and manual triggers directly. Type to search workflows -› Background Tasks Insert a background task snapshot into the transcript. /ps shows the same live - workflow state. - director manual.yaml · 2 manual triggers · 2 jobs +› Background Tasks Insert a background task snapshot into the transcript. /ps shows + the same live workflow state. + director - Edit workflow.yaml Open manual.yaml in your editor. + director - summarize Workflow job + director - notify Workflow job + director - review_backlog Manual trigger · Enabled + director - triage Manual trigger · Enabled Press enter to confirm or esc to go back From d915862df1526da96da9178847875ac54f90e6a9 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 19:38:50 +0800 Subject: [PATCH 49/60] fix setting ui hide/show --- codex-rs/tui/src/chatwidget.rs | 72 +++++++++---- ...tests__realtime_audio_selection_popup.snap | 3 +- ...realtime_audio_selection_popup_narrow.snap | 3 +- .../tui/src/chatwidget/tests/exec_flow.rs | 30 ++++++ codex-rs/tui/src/display_preferences_menu.rs | 8 +- codex-rs/tui/src/exec_cell/model.rs | 16 +++ codex-rs/tui/src/exec_cell/render.rs | 9 ++ codex-rs/tui/src/history_cell.rs | 100 ++++++++++++++++-- codex-rs/tui/src/pager_overlay.rs | 1 + ...menu__tests__display_preferences_menu.snap | 10 +- 10 files changed, 214 insertions(+), 38 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4b253c0cf..28c247eaa 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1712,9 +1712,11 @@ impl ChatWidget { return; }; self.needs_final_message_separator = true; - let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new()); - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(cell))); + self.add_to_history(history_cell::new_unified_exec_interaction( + wait.command_display, + String::new(), + self.display_preferences.clone(), + )); self.restore_reasoning_status_header(); } @@ -3546,13 +3548,19 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); - self.terminal_title_status_kind = TerminalTitleStatusKind::WaitingForBackgroundTerminal; - self.set_status( - "Waiting for background terminal".to_string(), - command_display.clone(), - StatusDetailsCapitalization::Preserve, - /*details_max_lines*/ 1, - ); + if self.display_preferences.show_tool_results() { + self.terminal_title_status_kind = + TerminalTitleStatusKind::WaitingForBackgroundTerminal; + self.set_status( + "Waiting for background terminal".to_string(), + command_display.clone(), + StatusDetailsCapitalization::Preserve, + /*details_max_lines*/ 1, + ); + } else { + self.terminal_title_status_kind = TerminalTitleStatusKind::Working; + self.set_status_header(String::from("Working")); + } match &mut self.unified_exec_wait_streak { Some(wait) if wait.process_id == ev.process_id => { wait.update_command_display(command_display); @@ -3579,6 +3587,7 @@ impl ChatWidget { self.add_to_history(history_cell::new_unified_exec_interaction( command_display, ev.stdin, + self.display_preferences.clone(), )); } } @@ -3744,6 +3753,7 @@ impl ChatWidget { ev.call_id, String::new(), self.config.animations, + self.display_preferences.clone(), ))); self.bump_active_cell_revision(); self.request_redraw(); @@ -3771,7 +3781,12 @@ impl ChatWidget { } if !handled { - self.add_to_history(history_cell::new_web_search_call(call_id, query, action)); + self.add_to_history(history_cell::new_web_search_call( + call_id, + query, + action, + self.display_preferences.clone(), + )); } self.had_work_activity = true; } @@ -4348,6 +4363,7 @@ impl ChatWidget { source, ev.interaction_input.clone(), self.config.animations, + self.display_preferences.clone(), ); let completed = orphan.complete_call(&ev.call_id, output, ev.duration); debug_assert!( @@ -4355,9 +4371,7 @@ impl ChatWidget { "new orphan exec cell should contain {}", ev.call_id ); - self.needs_final_message_separator = true; - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(orphan))); + self.insert_boxed_history_without_flushing(Box::new(orphan)); self.request_redraw(); } ExecEndTarget::NewCell => { @@ -4369,6 +4383,7 @@ impl ChatWidget { source, ev.interaction_input.clone(), self.config.animations, + self.display_preferences.clone(), ); let completed = cell.complete_call(&ev.call_id, output, ev.duration); debug_assert!(completed, "new exec cell should contain {}", ev.call_id); @@ -4561,6 +4576,7 @@ impl ChatWidget { ev.source, interaction_input, self.config.animations, + self.display_preferences.clone(), ))); self.bump_active_cell_revision(); } @@ -5682,8 +5698,7 @@ impl ChatWidget { fn flush_active_cell(&mut self) { if let Some(active) = self.active_cell.take() { - self.needs_final_message_separator = true; - self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); + self.insert_boxed_history_without_flushing(active); } } @@ -5691,7 +5706,24 @@ impl ChatWidget { self.add_boxed_history(Box::new(cell)); } + fn insert_boxed_history_without_flushing(&mut self, cell: Box) { + let has_visible_display_lines = !cell.display_lines(u16::MAX).is_empty(); + let has_visible_transcript_lines = !cell.transcript_lines(u16::MAX).is_empty(); + if !has_visible_display_lines && !has_visible_transcript_lines { + return; + } + + self.needs_final_message_separator = true; + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + } + fn add_boxed_history(&mut self, cell: Box) { + let has_visible_display_lines = !cell.display_lines(u16::MAX).is_empty(); + let has_visible_transcript_lines = !cell.transcript_lines(u16::MAX).is_empty(); + if !has_visible_display_lines && !has_visible_transcript_lines { + return; + } + // Keep the placeholder session header as the active cell until real session info arrives, // so we can merge headers instead of committing a duplicate box to history. let keep_placeholder_header_active = !self.is_session_configured() @@ -5700,12 +5732,11 @@ impl ChatWidget { .as_ref() .is_some_and(|c| c.as_any().is::()); - if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() { + if !keep_placeholder_header_active && has_visible_display_lines { // Only break exec grouping if the cell renders visible lines. self.flush_active_cell(); - self.needs_final_message_separator = true; } - self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + self.insert_boxed_history_without_flushing(cell); } fn queue_user_message(&mut self, user_message: UserMessage) { @@ -7947,7 +7978,8 @@ impl ChatWidget { let mut items = vec![SelectionItem { name: "UI".to_string(), description: Some( - "Configure local TUI-only transcript visibility for raw reasoning.".to_string(), + "Configure local TUI-only transcript visibility for reasoning, tool activity, and diffs." + .to_string(), ), actions: vec![Box::new(|tx| { tx.send(AppEvent::OpenDisplayPreferencesPanel) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap index 6dde00449..71f1c3bec 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap @@ -5,6 +5,7 @@ expression: popup Settings Configure UI and realtime settings for Codex. -› 1. UI Configure local TUI-only transcript visibility for raw reasoning. +› 1. UI Configure local TUI-only transcript visibility for reasoning, tool + activity, and diffs. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap index db286e7b1..7f9698726 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap @@ -6,6 +6,7 @@ expression: popup Configure UI and realtime settings for Codex. › 1. UI Configure local TUI-only transcript - visibility for raw reasoning. + visibility for reasoning, tool activity, and + diffs. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3c..913aac479 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -732,6 +732,36 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { assert_eq!(status.details(), Some("sleep 5")); } +#[tokio::test] +async fn unified_exec_wait_status_hides_tool_activity_when_disabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + /*enabled*/ false, + ); + chat.on_task_started(); + chat.unified_exec_processes.push(UnifiedExecProcessSummary { + key: "proc-1".to_string(), + call_id: "call-1".to_string(), + command_display: "sleep 5".to_string(), + recent_chunks: Vec::new(), + }); + + chat.on_terminal_interaction(TerminalInteractionEvent { + call_id: "call-1".to_string(), + process_id: "proc-1".to_string(), + stdin: String::new(), + }); + + assert_eq!(chat.current_status.header, "Working"); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Working"); + assert_eq!(status.details(), None); +} + #[tokio::test] async fn unified_exec_waiting_multiple_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/display_preferences_menu.rs b/codex-rs/tui/src/display_preferences_menu.rs index 30e3021d4..44f26501f 100644 --- a/codex-rs/tui/src/display_preferences_menu.rs +++ b/codex-rs/tui/src/display_preferences_menu.rs @@ -65,12 +65,12 @@ fn display_preference_item( "Currently hidden. Reveal raw reasoning text in this TUI only.", ), (DisplayPreferenceKey::ToolResults, true) => ( - "Hide Tool Results", - "Currently visible. Collapse MCP/custom tool result bodies in transcript cells.", + "Hide Tool Activity", + "Currently visible. Hide tool calls and result details in transcript cells.", ), (DisplayPreferenceKey::ToolResults, false) => ( - "Show Tool Results", - "Currently hidden. Reveal MCP/custom tool result bodies in transcript cells.", + "Show Tool Activity", + "Currently hidden. Reveal tool calls and result details in transcript cells.", ), (DisplayPreferenceKey::PatchDiffs, true) => ( "Hide Patch Diffs", diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 878d42c71..f99f96151 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -8,6 +8,7 @@ use std::time::Duration; use std::time::Instant; +use crate::display_preferences::DisplayPreferences; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::ExecCommandSource; @@ -36,6 +37,7 @@ pub(crate) struct ExecCall { pub(crate) struct ExecCell { pub(crate) calls: Vec, animations_enabled: bool, + display_preferences: DisplayPreferences, } impl ExecCell { @@ -43,9 +45,18 @@ impl ExecCell { Self { calls: vec![call], animations_enabled, + display_preferences: DisplayPreferences::default(), } } + pub(crate) fn with_display_preferences( + mut self, + display_preferences: DisplayPreferences, + ) -> Self { + self.display_preferences = display_preferences; + self + } + pub(crate) fn with_added_call( &self, call_id: String, @@ -68,6 +79,7 @@ impl ExecCell { Some(Self { calls: [self.calls.clone(), vec![call]].concat(), animations_enabled: self.animations_enabled, + display_preferences: self.display_preferences.clone(), }) } else { None @@ -135,6 +147,10 @@ impl ExecCell { self.animations_enabled } + pub(crate) fn show_tool_results(&self) -> bool { + self.display_preferences.show_tool_results() + } + pub(crate) fn iter_calls(&self) -> impl Iterator { self.calls.iter() } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 075b5a7ad..81fd82fce 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -3,6 +3,7 @@ use std::time::Instant; use super::model::CommandOutput; use super::model::ExecCall; use super::model::ExecCell; +use crate::display_preferences::DisplayPreferences; use crate::exec_command::strip_bash_lc_and_escape; use crate::history_cell::HistoryCell; use crate::render::highlight::highlight_bash_to_lines; @@ -44,6 +45,7 @@ pub(crate) fn new_active_exec_command( source: ExecCommandSource, interaction_input: Option, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> ExecCell { ExecCell::new( ExecCall { @@ -58,6 +60,7 @@ pub(crate) fn new_active_exec_command( }, animations_enabled, ) + .with_display_preferences(display_preferences) } fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String { @@ -197,6 +200,9 @@ pub(crate) fn spinner(start_time: Option, animations_enabled: bool) -> impl HistoryCell for ExecCell { fn display_lines(&self, width: u16) -> Vec> { + if !self.show_tool_results() { + return Vec::new(); + } if self.is_exploring_cell() { self.exploring_display_lines(width) } else { @@ -205,6 +211,9 @@ impl HistoryCell for ExecCell { } fn transcript_lines(&self, width: u16) -> Vec> { + if !self.show_tool_results() { + return Vec::new(); + } let mut lines: Vec> = vec![]; for (i, call) in self.iter_calls().enumerate() { if i > 0 { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 3337f3dfe..906bba987 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -619,19 +619,28 @@ impl HistoryCell for PrefixedWrappedHistoryCell { pub(crate) struct UnifiedExecInteractionCell { command_display: Option, stdin: String, + display_preferences: DisplayPreferences, } impl UnifiedExecInteractionCell { - pub(crate) fn new(command_display: Option, stdin: String) -> Self { + pub(crate) fn new( + command_display: Option, + stdin: String, + display_preferences: DisplayPreferences, + ) -> Self { Self { command_display, stdin, + display_preferences, } } } impl HistoryCell for UnifiedExecInteractionCell { fn display_lines(&self, width: u16) -> Vec> { + if !self.display_preferences.show_tool_results() { + return Vec::new(); + } if width == 0 { return Vec::new(); } @@ -679,8 +688,9 @@ impl HistoryCell for UnifiedExecInteractionCell { pub(crate) fn new_unified_exec_interaction( command_display: Option, stdin: String, + display_preferences: DisplayPreferences, ) -> UnifiedExecInteractionCell { - UnifiedExecInteractionCell::new(command_display, stdin) + UnifiedExecInteractionCell::new(command_display, stdin, display_preferences) } #[derive(Debug)] @@ -1755,6 +1765,7 @@ pub(crate) struct WebSearchCell { start_time: Instant, completed: bool, animations_enabled: bool, + display_preferences: DisplayPreferences, } impl WebSearchCell { @@ -1763,6 +1774,7 @@ impl WebSearchCell { query: String, action: Option, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> Self { Self { call_id, @@ -1771,6 +1783,7 @@ impl WebSearchCell { start_time: Instant::now(), completed: false, animations_enabled, + display_preferences, } } @@ -1790,6 +1803,9 @@ impl WebSearchCell { impl HistoryCell for WebSearchCell { fn display_lines(&self, width: u16) -> Vec> { + if !self.display_preferences.show_tool_results() { + return Vec::new(); + } let bullet = if self.completed { "•".dim() } else { @@ -1810,20 +1826,29 @@ pub(crate) fn new_active_web_search_call( call_id: String, query: String, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> WebSearchCell { - WebSearchCell::new(call_id, query, /*action*/ None, animations_enabled) + WebSearchCell::new( + call_id, + query, + /*action*/ None, + animations_enabled, + display_preferences, + ) } pub(crate) fn new_web_search_call( call_id: String, query: String, action: WebSearchAction, + display_preferences: DisplayPreferences, ) -> WebSearchCell { let mut cell = WebSearchCell::new( call_id, query, Some(action), /*animations_enabled*/ false, + display_preferences, ); cell.complete(); cell @@ -3139,8 +3164,11 @@ mod tests { #[test] fn unified_exec_interaction_cell_renders_input() { - let cell = - new_unified_exec_interaction(Some("echo hello".to_string()), "ls\npwd".to_string()); + let cell = new_unified_exec_interaction( + Some("echo hello".to_string()), + "ls\npwd".to_string(), + DisplayPreferences::default(), + ); let lines = render_transcript(&cell); assert_eq!( lines, @@ -3154,11 +3182,32 @@ mod tests { #[test] fn unified_exec_interaction_cell_renders_wait() { - let cell = new_unified_exec_interaction(/*command_display*/ None, String::new()); + let cell = new_unified_exec_interaction( + /*command_display*/ None, + String::new(), + DisplayPreferences::default(), + ); let lines = render_transcript(&cell); assert_eq!(lines, vec!["• Waited for background terminal"]); } + #[test] + fn unified_exec_interaction_cell_hides_when_tool_activity_is_disabled() { + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + /*enabled*/ false, + ); + let cell = new_unified_exec_interaction( + Some("echo hello".to_string()), + "ls\npwd".to_string(), + display_preferences, + ); + + assert!(cell.display_lines(/*width*/ 80).is_empty()); + assert!(cell.transcript_lines(/*width*/ 80).is_empty()); + } + #[test] fn final_message_separator_hides_short_worked_label_and_includes_runtime_metrics() { let summary = RuntimeMetricsSummary { @@ -3574,7 +3623,11 @@ mod tests { fn unified_exec_interaction_cell_does_not_split_url_like_stdin_token() { let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; - let cell = UnifiedExecInteractionCell::new(Some("true".to_string()), url_like.to_string()); + let cell = UnifiedExecInteractionCell::new( + Some("true".to_string()), + url_like.to_string(), + DisplayPreferences::default(), + ); let rendered = render_lines(&cell.display_lines(/*width*/ 24)); assert_eq!( @@ -3630,6 +3683,7 @@ mod tests { let cell: Box = Box::new(UnifiedExecInteractionCell::new( Some("true".to_string()), url_like.to_string(), + DisplayPreferences::default(), )); let width: u16 = 24; @@ -3671,6 +3725,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 64)).join("\n"); @@ -3688,6 +3743,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 64)); @@ -3710,6 +3766,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 64)); @@ -3727,6 +3784,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.transcript_lines(/*width*/ 64)).join("\n"); @@ -4350,6 +4408,34 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn exec_cell_hides_when_tool_activity_is_disabled() { + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + /*enabled*/ false, + ); + let call_id = "c1".to_string(); + let mut cell = ExecCell::new( + ExecCall { + call_id: call_id.clone(), + command: vec!["echo".into(), "ok".into()], + parsed: Vec::new(), + output: None, + source: ExecCommandSource::Agent, + start_time: Some(Instant::now()), + duration: None, + interaction_input: None, + }, + /*animations_enabled*/ true, + ) + .with_display_preferences(display_preferences); + cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1)); + + assert!(cell.display_lines(/*width*/ 80).is_empty()); + assert!(cell.transcript_lines(/*width*/ 80).is_empty()); + } + #[test] fn multiline_command_wraps_with_extra_indent_on_subsequent_lines() { // Create a completed exec cell with a multiline command diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 6edf28852..86ae8fa3a 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -1033,6 +1033,7 @@ mod tests { ExecCommandSource::Agent, /*interaction_input*/ None, /*animations_enabled*/ true, + crate::display_preferences::DisplayPreferences::default(), ); exec_cell.complete_call( "exec-1", diff --git a/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap b/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap index c9b730000..cb5ba1bc6 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap @@ -15,11 +15,11 @@ expression: render_lines(&view) Reveal raw reasoning text in this TUI only. - 3. Hide Tool Results Currently visible. - Collapse MCP/ - custom tool result - bodies in - transcript cells. + 3. Hide Tool Activity Currently visible. + Hide tool calls + and result details + in transcript + cells. 4. Hide Patch Diffs Currently visible. Collapse patch/ edit diff From 1098c2c9f7b3bc515424299a5c625d6bba64bab6 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 19:52:21 +0800 Subject: [PATCH 50/60] python: release codex-enhanced v0.1.23 --- sdk/python-runtime-enhanced/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python-runtime-enhanced/pyproject.toml b/sdk/python-runtime-enhanced/pyproject.toml index bc10e94ea..a39eebdaa 100644 --- a/sdk/python-runtime-enhanced/pyproject.toml +++ b/sdk/python-runtime-enhanced/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "codex-enhanced" -version = "0.1.22" +version = "0.1.23" description = "Enhanced Codex CLI with loop automation, Feishu clawbot support, and fast session respawn" readme = "README.md" requires-python = ">=3" From b30d46c3b6fb616cab4fce7374f5b9bfaa45b0ae Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 10:19:29 +0800 Subject: [PATCH 51/60] polish workflow controls and pypi release flow --- .github/workflows/pypi-release-artifacts.yml | 165 ++++++ .github/workflows/pypi-release.yml | 168 +++--- codex-rs/tui/src/app.rs | 16 + ..._controls__tests__workflow_file_popup.snap | 2 +- ..._tests__workflow_manual_trigger_popup.snap | 13 +- ...tests__workflow_manual_triggers_popup.snap | 7 +- ..._controls__tests__workflow_root_popup.snap | 18 + ...ols__tests__workflow_root_popup_empty.snap | 2 +- ...s__tests__workflow_trigger_type_popup.snap | 17 + codex-rs/tui/src/app/workflow_controls.rs | 495 ++++++++++++++++-- codex-rs/tui/src/app/workflow_editor.rs | 233 +++++++++ codex-rs/tui/src/app/workflow_runtime.rs | 51 +- codex-rs/tui/src/app_event.rs | 33 ++ .../src/chatwidget/tests/slash_commands.rs | 1 + ...__app__tests__workflow_controls_popup.snap | 17 +- docs/slash_commands.md | 2 + docs/workflows.md | 139 +++++ 17 files changed, 1245 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/pypi-release-artifacts.yml create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_trigger_type_popup.snap create mode 100644 docs/workflows.md diff --git a/.github/workflows/pypi-release-artifacts.yml b/.github/workflows/pypi-release-artifacts.yml new file mode 100644 index 000000000..e2b0e86ee --- /dev/null +++ b/.github/workflows/pypi-release-artifacts.yml @@ -0,0 +1,165 @@ +name: pypi-release-artifacts + +on: + workflow_call: + inputs: + checkout_ref: + required: true + type: string + release_tag: + required: true + type: string + +concurrency: + group: ${{ github.workflow }}-${{ inputs.release_tag }} + cancel-in-progress: true + +jobs: + build: + name: build-artifact-${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: + contents: read + env: + CARGO_INCREMENTAL: "0" + RUSTC_WRAPPER: "" + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + target: aarch64-apple-darwin + runtime_binary_name: codex + release_asset_name: codex-aarch64-apple-darwin.tar.gz + build_args: --bin codex + - runner: macos-15-intel + target: x86_64-apple-darwin + runtime_binary_name: codex + release_asset_name: codex-x86_64-apple-darwin.tar.gz + build_args: --bin codex + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + runtime_binary_name: codex + release_asset_name: codex-x86_64-unknown-linux-gnu.tar.gz + build_args: --bin codex + - runner: windows-2022 + target: x86_64-pc-windows-msvc + runtime_binary_name: codex.exe + release_asset_name: codex-x86_64-pc-windows-msvc.zip + build_args: --bin codex --bin codex-windows-sandbox-setup --bin codex-command-runner + steps: + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ inputs.checkout_ref }} + + - uses: dtolnay/rust-toolchain@1.93.0 + with: + targets: ${{ matrix.target }} + + - name: Clear workspace build rustflags for CI release builds + shell: bash + run: | + set -euo pipefail + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + + - name: Use default macOS linker + if: ${{ runner.os == 'macOS' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS=" >> "$GITHUB_ENV" + + - name: Install Linux build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y libcap-dev pkg-config protobuf-compiler + + - name: Install macOS build dependencies + if: ${{ runner.os == 'macOS' }} + shell: bash + run: | + set -euo pipefail + if ! command -v protoc >/dev/null 2>&1; then + brew install protobuf + fi + + - name: Install Windows build dependencies + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + if (-not (Get-Command protoc -ErrorAction SilentlyContinue)) { + choco install protoc -y --no-progress + } + + - name: Build release binaries + shell: bash + working-directory: codex-rs + run: | + set -euo pipefail + cargo build --locked --release --target "${{ matrix.target }}" ${{ matrix.build_args }} + + - name: Stage runtime artifact + shell: bash + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/runtime-artifact" + cp "codex-rs/target/${{ matrix.target }}/release/${{ matrix.runtime_binary_name }}" \ + "${RUNNER_TEMP}/runtime-artifact/${{ matrix.runtime_binary_name }}" + + - name: Stage release asset + shell: bash + run: | + set -euo pipefail + release_dir="${RUNNER_TEMP}/release-asset" + mkdir -p "${release_dir}" + + if [[ "${{ runner.os }}" == "Windows" ]]; then + cp "codex-rs/target/${{ matrix.target }}/release/codex.exe" "${release_dir}/codex.exe" + cp "codex-rs/target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe" "${release_dir}/codex-windows-sandbox-setup.exe" + cp "codex-rs/target/${{ matrix.target }}/release/codex-command-runner.exe" "${release_dir}/codex-command-runner.exe" + else + cp "codex-rs/target/${{ matrix.target }}/release/codex" "${release_dir}/codex" + fi + + - name: Archive Unix release asset + if: ${{ runner.os != 'Windows' }} + shell: bash + run: | + set -euo pipefail + release_dir="${RUNNER_TEMP}/release-asset" + archive_dir="${RUNNER_TEMP}/release-archive" + mkdir -p "${archive_dir}" + tar -C "${release_dir}" -czf "${archive_dir}/${{ matrix.release_asset_name }}" codex + + - name: Archive Windows release asset + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + $releaseDir = Join-Path $env:RUNNER_TEMP "release-asset" + $archiveDir = Join-Path $env:RUNNER_TEMP "release-archive" + New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null + Compress-Archive -Path (Join-Path $releaseDir '*') -DestinationPath (Join-Path $archiveDir '${{ matrix.release_asset_name }}') -Force + + - name: Upload runtime binary artifact + uses: actions/upload-artifact@v4 + with: + name: pypi-runtime-${{ matrix.target }} + path: ${{ runner.temp }}/runtime-artifact/* + if-no-files-found: error + + - name: Upload GitHub release asset + uses: actions/upload-artifact@v4 + with: + name: pypi-release-asset-${{ matrix.target }} + path: ${{ runner.temp }}/release-archive/* + if-no-files-found: error diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 88a023f6a..c182054ce 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -11,7 +11,7 @@ on: required: true type: string artifact_run_id: - description: Existing pypi-release run id whose wheel artifacts should be reused for publish + description: Existing pypi-release run id whose wheel and release artifacts should be reused required: false type: string @@ -72,31 +72,42 @@ jobs: [[ "${tag_ver}" == "${runtime_ver}" ]] \ || { echo "Tag version ${tag_ver} does not match sdk/python-runtime-enhanced ${runtime_ver}."; exit 1; } - build: + release-assets: needs: prepare - name: build-wheel-${{ matrix.target }} if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }} + uses: ./.github/workflows/pypi-release-artifacts.yml + with: + checkout_ref: ${{ needs.prepare.outputs.checkout_ref }} + release_tag: ${{ needs.prepare.outputs.release_tag }} + + build: + needs: + - prepare + - release-assets + name: build-wheel-${{ matrix.target }} + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' && needs.release-assets.result == 'success' }} runs-on: ${{ matrix.runner }} permissions: contents: read - env: - CARGO_INCREMENTAL: "0" - RUSTC_WRAPPER: "" strategy: fail-fast: false matrix: include: - runner: macos-14 target: aarch64-apple-darwin + runtime_artifact_name: pypi-runtime-aarch64-apple-darwin binary_name: codex - runner: macos-15-intel target: x86_64-apple-darwin + runtime_artifact_name: pypi-runtime-x86_64-apple-darwin binary_name: codex - runner: ubuntu-24.04 target: x86_64-unknown-linux-gnu + runtime_artifact_name: pypi-runtime-x86_64-unknown-linux-gnu binary_name: codex - runner: windows-2022 target: x86_64-pc-windows-msvc + runtime_artifact_name: pypi-runtime-x86_64-pc-windows-msvc binary_name: codex.exe steps: - name: Checkout release ref @@ -104,10 +115,6 @@ jobs: with: ref: ${{ needs.prepare.outputs.checkout_ref }} - - uses: dtolnay/rust-toolchain@1.93.0 - with: - targets: ${{ matrix.target }} - - uses: actions/setup-python@v5 with: python-version: "3.13" @@ -116,56 +123,11 @@ jobs: shell: bash run: python -m pip install --upgrade build hatchling packaging - - name: Clear workspace build rustflags for CI release builds - shell: bash - run: | - set -euo pipefail - echo "RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" - - - name: Use default macOS linker - if: ${{ runner.os == 'macOS' }} - shell: bash - run: | - set -euo pipefail - echo "RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS=" >> "$GITHUB_ENV" - - - name: Install Linux build dependencies - if: ${{ runner.os == 'Linux' }} - shell: bash - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y libcap-dev pkg-config protobuf-compiler - - - name: Install macOS build dependencies - if: ${{ runner.os == 'macOS' }} - shell: bash - run: | - set -euo pipefail - if ! command -v protoc >/dev/null 2>&1; then - brew install protobuf - fi - - - name: Install Windows build dependencies - if: ${{ runner.os == 'Windows' }} - shell: pwsh - run: | - if (-not (Get-Command protoc -ErrorAction SilentlyContinue)) { - choco install protoc -y --no-progress - } - - - name: Build release codex binary - shell: bash - working-directory: codex-rs - run: | - set -euo pipefail - cargo build --locked --release --target "${{ matrix.target }}" --bin codex + - name: Download release binary artifact + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.runtime_artifact_name }} + path: ${{ runner.temp }}/runtime-artifact - name: Stage codex-enhanced runtime package shell: bash @@ -174,7 +136,7 @@ jobs: python sdk/python/scripts/update_sdk_artifacts.py \ stage-runtime \ "${RUNNER_TEMP}/codex-enhanced" \ - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/${{ matrix.binary_name }}" \ + "${RUNNER_TEMP}/runtime-artifact/${{ matrix.binary_name }}" \ --runtime-version "${{ needs.prepare.outputs.release_version }}" \ --runtime-package enhanced @@ -192,8 +154,9 @@ jobs: publish: needs: - prepare + - release-assets - build - if: ${{ always() && needs.prepare.result == 'success' && (needs.build.result == 'success' || needs.build.result == 'skipped') }} + if: ${{ always() && needs.prepare.result == 'success' && ((needs.prepare.outputs.reuse_artifacts == 'true' && needs.build.result == 'skipped') || (needs.prepare.outputs.reuse_artifacts != 'true' && needs.release-assets.result == 'success' && needs.build.result == 'success')) }} runs-on: ubuntu-latest permissions: actions: read @@ -232,3 +195,84 @@ jobs: packages-dir: dist skip-existing: true verbose: true + + github-release: + needs: + - prepare + - release-assets + - build + - publish + if: ${{ always() && needs.prepare.result == 'success' && needs.publish.result == 'success' && ((needs.prepare.outputs.reuse_artifacts == 'true' && needs.release-assets.result == 'skipped' && needs.build.result == 'skipped') || (needs.prepare.outputs.reuse_artifacts != 'true' && needs.release-assets.result == 'success' && needs.build.result == 'success')) }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - name: Download release assets from current run + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }} + uses: actions/download-artifact@v4 + with: + pattern: pypi-release-asset-* + path: release-dist + merge-multiple: true + + - name: Download wheel artifacts from current run + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }} + uses: actions/download-artifact@v4 + with: + pattern: pypi-wheel-* + path: release-dist + merge-multiple: true + + - name: Download release assets and wheels from an earlier run + if: ${{ needs.prepare.outputs.reuse_artifacts == 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p release-dist + work_dir="$(mktemp -d)" + gh run download "${{ needs.prepare.outputs.artifact_run_id }}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir "${work_dir}" + find "${work_dir}" -type f \( -name '*.whl' -o -name '*.tar.gz' -o -name '*.zip' \) -exec mv {} release-dist/ \; + if ! find release-dist -maxdepth 1 -type f | grep -q .; then + echo "No release assets were downloaded from run ${{ needs.prepare.outputs.artifact_run_id }}." + exit 1 + fi + + - name: Generate GitHub Release notes + shell: bash + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} + run: | + set -euo pipefail + commit="$(git rev-parse HEAD^{commit})" + notes_path="${RUNNER_TEMP}/release-notes.md" + pypi_url="https://pypi.org/project/codex-enhanced/${{ needs.prepare.outputs.release_version }}/" + + git log -1 --format=%B "${commit}" > "${notes_path}" + echo >> "${notes_path}" + { + echo "## PyPI" + echo + echo "- https://pypi.org/project/codex-enhanced/" + echo "- ${pypi_url}" + } >> "${notes_path}" + + echo "RELEASE_NOTES_PATH=${notes_path}" >> "$GITHUB_ENV" + + - name: Publish GitHub Release + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + with: + name: ${{ needs.prepare.outputs.release_version }} + tag_name: ${{ needs.prepare.outputs.release_tag }} + body_path: ${{ env.RELEASE_NOTES_PATH }} + files: release-dist/* + overwrite_files: true + prerelease: ${{ contains(needs.prepare.outputs.release_version, '-') }} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2c842d0d3..7cf167474 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4830,6 +4830,21 @@ impl App { self.edit_workflow_job_field_from_ui(tui, workflow_path, job_name, field) .await; } + AppEvent::SetWorkflowTriggerType { + workflow_path, + trigger_id, + trigger_type, + } => { + self.set_workflow_trigger_type_from_ui(workflow_path, trigger_id, trigger_type); + } + AppEvent::EditWorkflowTriggerField { + workflow_path, + trigger_id, + field, + } => { + self.edit_workflow_trigger_field_from_ui(tui, workflow_path, trigger_id, field) + .await; + } AppEvent::StartManualWorkflowTrigger { workflow_name, trigger_id, @@ -12616,6 +12631,7 @@ model = "gpt-5.2" assert!(app.btw_session.is_none()); Ok(()) } + #[tokio::test] async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() { let mut app = make_test_app().await; let thread_id = ThreadId::new(); diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_file_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_file_popup.snap index e489bba30..32c5de55c 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_file_popup.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_file_popup.snap @@ -9,6 +9,6 @@ expression: popup › Back Return to the previous workflow menu. Edit workflow.yaml Open manual.yaml in your editor. Jobs 2 jobs - Manual Triggers 2 manual triggers + Triggers 3 triggers Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_trigger_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_trigger_popup.snap index fbb2ef0c0..ada45a6fd 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_trigger_popup.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_trigger_popup.snap @@ -6,10 +6,13 @@ expression: popup director · review_backlog · manual.yaml Type to search trigger actions -› Back Return to the previous workflow menu. - Edit workflow.yaml Open manual.yaml in your editor. - Run Now Run this manual trigger immediately in a background workflow thread. - Disable Trigger Prevent this trigger from starting until it is enabled again. - Target Jobs 1 job +› Back Return to the previous workflow menu. + Edit workflow.yaml Open manual.yaml in your editor. + Run Now Run this trigger immediately in a background workflow thread. + Disable Trigger Prevent this trigger from starting until it is enabled again. + Type: Manual Choose which trigger type this workflow entry should use. + Edit Trigger ID Rename this trigger id. + Edit Target Jobs 1 job + No Trigger Parameter This trigger type does not require an extra schedule parameter. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap index 9ba46fa45..dedbc0372 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap @@ -2,13 +2,14 @@ source: tui/src/app/workflow_controls.rs expression: popup --- - Manual Triggers + Workflow Triggers director · manual.yaml Type to search triggers › Back Return to the previous workflow menu. Edit workflow.yaml Open manual.yaml in your editor. - review_backlog Enabled · Ready - triage Disabled · Ready + review_backlog Manual · Enabled · Ready + triage Manual · Disabled · Ready + pulse Interval (30m) · Enabled · Ready Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup.snap new file mode 100644 index 000000000..9821e62e9 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow + Manage workflow files, jobs, and triggers directly. + + Type to search workflows +› Background Tasks Insert a background task snapshot into the transcript. /ps + shows the same live workflow state. + director - edit yaml Open manual.yaml in your editor. + director - job - summarize Workflow job + director - job - notify Workflow job + director - trigger - review_backlog Manual · Enabled + director - trigger - triage Manual · Disabled + director - trigger - pulse Interval (30m) · Enabled + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap index be32ee242..7773603d2 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap @@ -3,7 +3,7 @@ source: tui/src/app/workflow_controls.rs expression: popup --- Workflow - Manage workflow files, jobs, and manual triggers directly. + Manage workflow files, jobs, and triggers directly. Type to search workflows › Background Tasks Insert a background task snapshot into the transcript. /ps shows the same diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_trigger_type_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_trigger_type_popup.snap new file mode 100644 index 000000000..2a0c69cc4 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_trigger_type_popup.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Trigger Type + director · pulse · manual.yaml + + Type to search trigger types +› Back Return to the previous workflow menu. + Manual Run only when triggered from the workflow menu. + Before Turn Run automatically before the next user turn. + After Turn Run automatically after the current turn finishes. + Idle Run after the workspace has been idle for a duration. + Interval Current type + Cron Run on a cron schedule. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/workflow_controls.rs b/codex-rs/tui/src/app/workflow_controls.rs index 0c9e45d6c..fc123cfa4 100644 --- a/codex-rs/tui/src/app/workflow_controls.rs +++ b/codex-rs/tui/src/app/workflow_controls.rs @@ -6,6 +6,8 @@ use std::sync::Arc; use crate::app_event::AppEvent; use crate::app_event::WorkflowControlsDestination; use crate::app_event::WorkflowJobEditableField; +use crate::app_event::WorkflowTriggerEditableField; +use crate::app_event::WorkflowTriggerType; use crate::app_server_session::AppServerSession; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; @@ -40,8 +42,8 @@ struct WorkflowFileSummary { display_name: String, filename: String, jobs: Vec, - manual_triggers: Vec, - manual_trigger_count: usize, + triggers: Vec, + trigger_count: usize, job_count: usize, } @@ -49,6 +51,7 @@ struct WorkflowFileSummary { struct WorkflowTriggerSummary { id: String, enabled: bool, + kind: WorkflowTriggerKind, } impl App { @@ -85,6 +88,14 @@ impl App { &trigger_id, Some(0), ), + WorkflowControlsDestination::TriggerType { + workflow_path, + trigger_id, + } => self.workflow_trigger_type_popup_params( + workflow_path.as_path(), + &trigger_id, + Some(0), + ), }; self.show_workflow_popup(params, active_selected_idx.is_some()); } @@ -237,6 +248,73 @@ impl App { tui.frame_requester().schedule_frame(); } + pub(crate) async fn edit_workflow_trigger_field_from_ui( + &mut self, + tui: &mut tui::Tui, + workflow_path: PathBuf, + trigger_id: String, + field: WorkflowTriggerEditableField, + ) { + let seed = match workflow_editor::trigger_field_seed( + workflow_path.as_path(), + &trigger_id, + field, + ) { + Ok(seed) => seed, + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + return; + } + }; + let Ok(editor_cmd) = self.resolve_editor_command_for_workflows() else { + return; + }; + let suffix = if matches!(field, WorkflowTriggerEditableField::Jobs) { + ".yaml" + } else { + ".txt" + }; + let edited = tui + .with_restored(tui::RestoreMode::KeepRaw, || async { + external_editor::run_editor_with_suffix(&seed, &editor_cmd, suffix).await + }) + .await; + match edited { + Ok(updated) => match workflow_editor::write_trigger_field( + workflow_path.as_path(), + &trigger_id, + field, + &updated, + ) { + Ok(next_trigger_id) => { + self.chat_widget.add_info_message( + format!( + "Updated `{}` for workflow trigger `{next_trigger_id}`.", + workflow_trigger_field_label(field) + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::ManualTrigger { + workflow_path, + trigger_id: next_trigger_id, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + }, + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + } + } + tui.frame_requester().schedule_frame(); + } + pub(crate) fn toggle_workflow_job_enabled_from_ui( &mut self, workflow_path: PathBuf, @@ -289,6 +367,34 @@ impl App { } } + pub(crate) fn set_workflow_trigger_type_from_ui( + &mut self, + workflow_path: PathBuf, + trigger_id: String, + trigger_type: WorkflowTriggerType, + ) { + match workflow_editor::set_trigger_type(workflow_path.as_path(), &trigger_id, trigger_type) + { + Ok(next_trigger_id) => { + self.chat_widget.add_info_message( + format!( + "Workflow trigger `{next_trigger_id}` now uses {}.", + workflow_trigger_type_label(trigger_type) + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::ManualTrigger { + workflow_path, + trigger_id: next_trigger_id, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + pub(crate) fn cycle_workflow_job_context_from_ui( &mut self, workflow_path: PathBuf, @@ -428,7 +534,7 @@ impl App { let workflow_filename = file.filename.clone(); items.push(SelectionItem { - name: format!("{workflow_prefix} - Edit workflow.yaml"), + name: format!("{workflow_prefix} - edit yaml"), description: Some(format!("Open {workflow_filename} in your editor.")), selected_description: Some(match file.workflow_name { Some(_) => { @@ -461,7 +567,7 @@ impl App { if file.workflow_name.is_some() { for job_name in &file.jobs { items.push(SelectionItem { - name: format!("{workflow_prefix} - {job_name}"), + name: format!("{workflow_prefix} - job - {job_name}"), description: Some("Workflow job".to_string()), selected_description: Some( "Open this job directly. From there you can run it, toggle it, and edit its fields." @@ -490,22 +596,24 @@ impl App { }); } - for trigger in &file.manual_triggers { + for trigger in &file.triggers { items.push(SelectionItem { - name: format!("{workflow_prefix} - {}", trigger.id), + name: format!("{workflow_prefix} - trigger - {}", trigger.id), description: Some(format!( - "Manual trigger · {}", + "{} · {}", + workflow_trigger_kind_display(&trigger.kind), if trigger.enabled { "Enabled" } else { "Disabled" } )), selected_description: Some( - "Open this trigger directly. From there you can run it now or toggle its enabled state." + "Open this trigger directly. From there you can run it, toggle it, change its type, and edit its parameters." .to_string(), ), search_value: Some(format!( - "{} {} {} manual trigger", + "{} {} {} trigger {}", workflow_prefix.to_ascii_lowercase(), workflow_filename.to_ascii_lowercase(), - trigger.id.to_ascii_lowercase() + trigger.id.to_ascii_lowercase(), + workflow_trigger_kind_display(&trigger.kind).to_ascii_lowercase() )), actions: vec![Box::new({ let workflow_path = file.workflow_path.clone(); @@ -545,9 +653,7 @@ impl App { SelectionViewParams { view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), title: Some("Workflow".to_string()), - subtitle: Some( - "Manage workflow files, jobs, and manual triggers directly.".to_string(), - ), + subtitle: Some("Manage workflow files, jobs, and triggers directly.".to_string()), footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, @@ -607,13 +713,13 @@ impl App { ..Default::default() }); items.push(SelectionItem { - name: "Manual Triggers".to_string(), + name: "Triggers".to_string(), description: Some(count_label( - state.summary.manual_trigger_count, - "manual trigger", + state.summary.trigger_count, + "trigger", )), selected_description: Some( - "Open this workflow's manual triggers. Trigger runs stay visible in the footer and /ps." + "Open this workflow's triggers. Trigger runs stay visible in the footer and /ps." .to_string(), ), actions: vec![Box::new({ @@ -627,7 +733,7 @@ impl App { } })], dismiss_on_select: false, - is_disabled: state.summary.manual_trigger_count == 0, + is_disabled: state.summary.trigger_count == 0, ..Default::default() }); } @@ -780,15 +886,11 @@ impl App { ), ]; - let triggers = workflow - .triggers - .iter() - .filter(|trigger| matches!(trigger.kind, WorkflowTriggerKind::Manual)) - .collect::>(); + let triggers = workflow.triggers.iter().collect::>(); if triggers.is_empty() { items.push(SelectionItem { - name: "No manual triggers defined".to_string(), - description: Some("Edit workflow.yaml to add manual triggers.".to_string()), + name: "No triggers defined".to_string(), + description: Some("Edit workflow.yaml to add triggers.".to_string()), is_disabled: true, ..Default::default() }); @@ -801,11 +903,12 @@ impl App { items.push(SelectionItem { name: trigger.id.clone(), description: Some(format!( - "{} · {status}", + "{} · {} · {status}", + workflow_trigger_kind_display(&trigger.kind), if trigger.enabled { "Enabled" } else { "Disabled" } )), selected_description: Some( - "Open this trigger. From there you can run it now or toggle its enabled state." + "Open this trigger. From there you can run it, toggle it, change its type, and edit its parameters." .to_string(), ), search_value: Some(format!( @@ -831,7 +934,7 @@ impl App { SelectionViewParams { view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), - title: Some("Manual Triggers".to_string()), + title: Some("Workflow Triggers".to_string()), subtitle: Some(format!("{} · {}", workflow.name, summary.filename)), footer_hint: Some(standard_popup_hint_line()), items, @@ -842,8 +945,8 @@ impl App { } } Err(err) => workflow_error_popup_params( - "Manual Triggers", - "Failed to load manual triggers.", + "Workflow Triggers", + "Failed to load workflow triggers.", err, workflow_back_item(WorkflowControlsDestination::Root), initial_selected_idx, @@ -857,11 +960,7 @@ impl App { trigger_id: &str, initial_selected_idx: Option, ) -> SelectionViewParams { - match workflow_loaded_manual_trigger_state( - self.config.cwd.as_path(), - workflow_path, - trigger_id, - ) { + match workflow_loaded_trigger_state(self.config.cwd.as_path(), workflow_path, trigger_id) { Ok((summary, workflow, trigger)) => { let mut items = vec![ workflow_back_item(WorkflowControlsDestination::Root), @@ -878,7 +977,7 @@ impl App { items.push(SelectionItem { name: "Run Now".to_string(), description: Some( - "Run this manual trigger immediately in a background workflow thread." + "Run this trigger immediately in a background workflow thread." .to_string(), ), selected_description: Some( @@ -939,7 +1038,54 @@ impl App { }); items.push(SelectionItem { - name: "Target Jobs".to_string(), + name: format!("Type: {}", workflow_trigger_kind_display(&trigger.kind)), + description: Some( + "Choose which trigger type this workflow entry should use.".to_string(), + ), + selected_description: Some( + "Open the trigger type picker, then choose the new trigger type." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::TriggerType { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + }, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Edit Trigger ID".to_string(), + description: Some("Rename this trigger id.".to_string()), + selected_description: Some( + "Open the trigger id in your external editor and save the updated value back into workflow.yaml." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowTriggerField { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + field: WorkflowTriggerEditableField::Id, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Edit Target Jobs".to_string(), description: Some(count_label( workflow .triggers @@ -949,13 +1095,27 @@ impl App { "job", )), selected_description: Some( - "Edit workflow.yaml if you want to change which jobs this trigger runs." + "Open this trigger's `jobs` field in your external editor and save the YAML list back into the workflow file." .to_string(), ), - is_disabled: true, + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowTriggerField { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + field: WorkflowTriggerEditableField::Jobs, + }); + } + })], + dismiss_on_select: false, ..Default::default() }); + let parameter_item = workflow_trigger_parameter_item(workflow_path, &trigger); + items.push(parameter_item); + SelectionViewParams { view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), title: Some("Workflow Trigger".to_string()), @@ -981,6 +1141,82 @@ impl App { } } + fn workflow_trigger_type_popup_params( + &self, + workflow_path: &Path, + trigger_id: &str, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_loaded_trigger_state(self.config.cwd.as_path(), workflow_path, trigger_id) { + Ok((summary, workflow, trigger)) => { + let mut items = vec![workflow_back_item( + WorkflowControlsDestination::ManualTrigger { + workflow_path: workflow_path.to_path_buf(), + trigger_id: trigger.id.clone(), + }, + )]; + + for trigger_type in [ + WorkflowTriggerType::Manual, + WorkflowTriggerType::BeforeTurn, + WorkflowTriggerType::AfterTurn, + WorkflowTriggerType::Idle, + WorkflowTriggerType::Interval, + WorkflowTriggerType::Cron, + ] { + let is_active = workflow_trigger_matches_type(&trigger.kind, trigger_type); + items.push(SelectionItem { + name: workflow_trigger_type_label(trigger_type).to_string(), + description: Some(if is_active { + "Current type".to_string() + } else { + workflow_trigger_type_description(trigger_type).to_string() + }), + selected_description: Some( + "Write the selected trigger type back into workflow.yaml.".to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::SetWorkflowTriggerType { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + trigger_type, + }); + } + })], + dismiss_on_select: false, + is_disabled: is_active, + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Trigger Type".to_string()), + subtitle: Some(format!( + "{} · {} · {}", + workflow.name, trigger.id, summary.filename + )), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search trigger types".to_string()), + initial_selected_idx, + ..Default::default() + } + } + Err(err) => workflow_error_popup_params( + "Trigger Type", + "Failed to load workflow trigger type picker.", + err, + workflow_back_item(WorkflowControlsDestination::Root), + initial_selected_idx, + ), + } + } + fn workflow_job_popup_params( &self, workflow_path: &Path, @@ -1188,13 +1424,13 @@ fn workflow_menu_state(cwd: &Path) -> Result { .cloned() .collect::>(); jobs.sort_by_key(|job| job.definition_index); - let manual_triggers = workflow + let triggers = workflow .triggers .iter() - .filter(|trigger| matches!(trigger.kind, WorkflowTriggerKind::Manual)) .map(|trigger| WorkflowTriggerSummary { id: trigger.id.clone(), enabled: trigger.enabled, + kind: trigger.kind.clone(), }) .collect::>(); WorkflowFileSummary { @@ -1204,8 +1440,8 @@ fn workflow_menu_state(cwd: &Path) -> Result { filename: filename_label(&workflow_path), job_count: jobs.len(), jobs: jobs.into_iter().map(|job| job.name).collect(), - manual_trigger_count: manual_triggers.len(), - manual_triggers, + trigger_count: triggers.len(), + triggers, } } else { fallback_workflow_summary(workflow_path) @@ -1279,7 +1515,7 @@ fn workflow_loaded_job_state( Ok((summary, workflow, job)) } -fn workflow_loaded_manual_trigger_state( +fn workflow_loaded_trigger_state( cwd: &Path, workflow_path: &Path, trigger_id: &str, @@ -1295,17 +1531,58 @@ fn workflow_loaded_manual_trigger_state( let trigger = workflow .triggers .iter() - .find(|trigger| { - matches!(trigger.kind, WorkflowTriggerKind::Manual) && trigger.id == trigger_id - }) + .find(|trigger| trigger.id == trigger_id) .map(|trigger| WorkflowTriggerSummary { id: trigger.id.clone(), enabled: trigger.enabled, + kind: trigger.kind.clone(), }) .ok_or_else(|| format!("workflow trigger `{trigger_id}` does not exist"))?; Ok((summary, workflow, trigger)) } +fn workflow_trigger_parameter_item( + workflow_path: &Path, + trigger: &WorkflowTriggerSummary, +) -> SelectionItem { + let Some((label, description)) = workflow_trigger_parameter_metadata(&trigger.kind) else { + return SelectionItem { + name: "No Trigger Parameter".to_string(), + description: Some( + "This trigger type does not require an extra schedule parameter.".to_string(), + ), + selected_description: Some( + "Change the trigger type if you need a schedule parameter such as `after`, `every`, or `cron`." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }; + }; + + SelectionItem { + name: format!("Edit {label}"), + description: Some(description.to_string()), + selected_description: Some( + "Open this trigger parameter in your external editor and save it back into workflow.yaml." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowTriggerField { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + field: WorkflowTriggerEditableField::Parameter, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + } +} + fn fallback_workflow_summary(workflow_path: PathBuf) -> WorkflowFileSummary { WorkflowFileSummary { display_name: workflow_path @@ -1316,8 +1593,8 @@ fn fallback_workflow_summary(workflow_path: PathBuf) -> WorkflowFileSummary { workflow_path, workflow_name: None, jobs: Vec::new(), - manual_triggers: Vec::new(), - manual_trigger_count: 0, + triggers: Vec::new(), + trigger_count: 0, job_count: 0, } } @@ -1421,6 +1698,82 @@ fn workflow_context_label(context: WorkflowContextMode) -> &'static str { } } +fn workflow_trigger_type_label(trigger_type: WorkflowTriggerType) -> &'static str { + match trigger_type { + WorkflowTriggerType::Manual => "Manual", + WorkflowTriggerType::BeforeTurn => "Before Turn", + WorkflowTriggerType::AfterTurn => "After Turn", + WorkflowTriggerType::Idle => "Idle", + WorkflowTriggerType::Interval => "Interval", + WorkflowTriggerType::Cron => "Cron", + } +} + +fn workflow_trigger_kind_display(kind: &WorkflowTriggerKind) -> String { + match kind { + WorkflowTriggerKind::Manual => "Manual".to_string(), + WorkflowTriggerKind::BeforeTurn => "Before Turn".to_string(), + WorkflowTriggerKind::AfterTurn => "After Turn".to_string(), + WorkflowTriggerKind::Idle { after } => format!("Idle ({after})"), + WorkflowTriggerKind::Interval { every } => format!("Interval ({every})"), + WorkflowTriggerKind::Cron { cron } => format!("Cron ({cron})"), + } +} + +fn workflow_trigger_type_description(trigger_type: WorkflowTriggerType) -> &'static str { + match trigger_type { + WorkflowTriggerType::Manual => "Run only when triggered from the workflow menu.", + WorkflowTriggerType::BeforeTurn => "Run automatically before the next user turn.", + WorkflowTriggerType::AfterTurn => "Run automatically after the current turn finishes.", + WorkflowTriggerType::Idle => "Run after the workspace has been idle for a duration.", + WorkflowTriggerType::Interval => "Run on a fixed repeating interval.", + WorkflowTriggerType::Cron => "Run on a cron schedule.", + } +} + +fn workflow_trigger_matches_type( + kind: &WorkflowTriggerKind, + trigger_type: WorkflowTriggerType, +) -> bool { + matches!( + (kind, trigger_type), + (&WorkflowTriggerKind::Manual, WorkflowTriggerType::Manual) + | ( + &WorkflowTriggerKind::BeforeTurn, + WorkflowTriggerType::BeforeTurn + ) + | ( + &WorkflowTriggerKind::AfterTurn, + WorkflowTriggerType::AfterTurn + ) + | (&WorkflowTriggerKind::Idle { .. }, WorkflowTriggerType::Idle) + | ( + &WorkflowTriggerKind::Interval { .. }, + WorkflowTriggerType::Interval + ) + | (&WorkflowTriggerKind::Cron { .. }, WorkflowTriggerType::Cron) + ) +} + +fn workflow_trigger_parameter_metadata( + kind: &WorkflowTriggerKind, +) -> Option<(&'static str, String)> { + match kind { + WorkflowTriggerKind::Idle { after } => { + Some(("Idle Delay", format!("Current `after`: `{after}`."))) + } + WorkflowTriggerKind::Interval { every } => { + Some(("Interval", format!("Current `every`: `{every}`."))) + } + WorkflowTriggerKind::Cron { cron } => { + Some(("Cron Schedule", format!("Current `cron`: `{cron}`."))) + } + WorkflowTriggerKind::Manual + | WorkflowTriggerKind::BeforeTurn + | WorkflowTriggerKind::AfterTurn => None, + } +} + fn workflow_response_label(response: WorkflowResponseMode) -> &'static str { match response { WorkflowResponseMode::Assistant => "Assistant", @@ -1435,6 +1788,14 @@ fn workflow_job_field_label(field: WorkflowJobEditableField) -> &'static str { } } +fn workflow_trigger_field_label(field: WorkflowTriggerEditableField) -> &'static str { + match field { + WorkflowTriggerEditableField::Id => "id", + WorkflowTriggerEditableField::Jobs => "jobs", + WorkflowTriggerEditableField::Parameter => "parameter", + } +} + fn filename_label(path: &Path) -> String { path.file_name() .map(|filename| filename.to_string_lossy().to_string()) @@ -1474,6 +1835,10 @@ triggers: id: triage enabled: false jobs: [notify] + - type: interval + id: pulse + every: 30m + jobs: [summarize] jobs: summarize: @@ -1546,6 +1911,18 @@ jobs: insta::assert_snapshot!("workflow_file_popup", popup); } + #[tokio::test] + async fn workflow_root_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + + app.open_workflow_control_view(WorkflowControlsDestination::Root); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_root_popup", popup); + } + #[tokio::test] async fn workflow_jobs_popup_snapshot() { let mut app = super::super::tests::make_test_app().await; @@ -1643,6 +2020,26 @@ jobs: insta::assert_snapshot!("workflow_manual_trigger_popup", popup); } + #[tokio::test] + async fn workflow_trigger_type_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::TriggerType { + workflow_path, + trigger_id: "pulse".to_string(), + }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_trigger_type_popup", popup); + } + #[test] fn workflow_menu_state_lists_files_even_when_registry_is_invalid() { let dir = tempdir().unwrap(); diff --git a/codex-rs/tui/src/app/workflow_editor.rs b/codex-rs/tui/src/app/workflow_editor.rs index e2672c8c8..4ddebdd10 100644 --- a/codex-rs/tui/src/app/workflow_editor.rs +++ b/codex-rs/tui/src/app/workflow_editor.rs @@ -9,6 +9,8 @@ use super::workflow_definition::WorkflowContextMode; use super::workflow_definition::WorkflowResponseMode; use super::workflow_definition::WorkflowStep; use crate::app_event::WorkflowJobEditableField; +use crate::app_event::WorkflowTriggerEditableField; +use crate::app_event::WorkflowTriggerType; pub(crate) const DEFAULT_WORKFLOW_TEMPLATE_FILENAME: &str = "workflow.yaml"; @@ -110,6 +112,29 @@ pub(crate) fn toggle_trigger_enabled( }) } +pub(crate) fn set_trigger_type( + workflow_path: &Path, + trigger_id: &str, + trigger_type: WorkflowTriggerType, +) -> Result { + mutate_trigger(workflow_path, trigger_id, |trigger| { + let current_parameter = trigger_parameter_seed_from_mapping(trigger); + clear_trigger_type_fields(trigger); + trigger.insert( + string_key("type"), + YamlValue::String(trigger_type_key(trigger_type).to_string()), + ); + if let Some((parameter_key, default_value)) = trigger_type_parameter_defaults(trigger_type) + { + trigger.insert( + string_key(parameter_key), + YamlValue::String(current_parameter.unwrap_or_else(|| default_value.to_string())), + ); + } + Ok(trigger_id.to_string()) + }) +} + pub(crate) fn cycle_job_context( workflow_path: &Path, job_name: &str, @@ -218,6 +243,87 @@ pub(crate) fn write_job_field( } } +pub(crate) fn trigger_field_seed( + workflow_path: &Path, + trigger_id: &str, + field: WorkflowTriggerEditableField, +) -> Result { + let document = load_yaml_document(workflow_path)?; + let trigger = workflow_trigger_mapping(&document, trigger_id)?; + match field { + WorkflowTriggerEditableField::Id => trigger + .get(string_key("id")) + .and_then(YamlValue::as_str) + .map(ToString::to_string) + .ok_or_else(|| format!("workflow trigger `{trigger_id}` does not define an `id`")), + WorkflowTriggerEditableField::Jobs => { + let jobs = trigger + .get(string_key("jobs")) + .map(|value| serde_yaml::from_value::>(value.clone())) + .transpose() + .map_err(|err| err.to_string())? + .unwrap_or_default(); + serialize_yaml_fragment(&jobs) + } + WorkflowTriggerEditableField::Parameter => trigger_parameter_seed_from_mapping(trigger) + .ok_or_else(|| format!("workflow trigger `{trigger_id}` has no editable parameter")), + } +} + +pub(crate) fn write_trigger_field( + workflow_path: &Path, + trigger_id: &str, + field: WorkflowTriggerEditableField, + text: &str, +) -> Result { + match field { + WorkflowTriggerEditableField::Id => { + let next_trigger_id = text.trim(); + if next_trigger_id.is_empty() { + return Err("workflow trigger id cannot be empty".to_string()); + } + mutate_trigger(workflow_path, trigger_id, |trigger| { + trigger.insert( + string_key("id"), + YamlValue::String(next_trigger_id.to_string()), + ); + Ok(next_trigger_id.to_string()) + }) + } + WorkflowTriggerEditableField::Jobs => { + let jobs = match text.trim() { + "" => Vec::new(), + _ => serde_yaml::from_str::>(text).map_err(|err| err.to_string())?, + }; + mutate_trigger(workflow_path, trigger_id, |trigger| { + trigger.insert( + string_key("jobs"), + serde_yaml::to_value(jobs).map_err(|err| err.to_string())?, + ); + Ok(trigger_id.to_string()) + }) + } + WorkflowTriggerEditableField::Parameter => { + let next_value = text.trim(); + if next_value.is_empty() { + return Err("workflow trigger parameter cannot be empty".to_string()); + } + mutate_trigger(workflow_path, trigger_id, |trigger| { + let Some(parameter_key) = trigger_parameter_key_from_mapping(trigger) else { + return Err(format!( + "workflow trigger `{trigger_id}` has no editable parameter" + )); + }; + trigger.insert( + string_key(parameter_key), + YamlValue::String(next_value.to_string()), + ); + Ok(trigger_id.to_string()) + }) + } + } +} + fn workflow_dir(cwd: &Path) -> PathBuf { cwd.join(".codex").join(WORKFLOW_DIR_NAME) } @@ -284,6 +390,35 @@ fn workflow_job_mapping_mut<'a>( .ok_or_else(|| format!("workflow job `{job_name}` does not exist")) } +fn workflow_trigger_mapping<'a>( + document: &'a YamlValue, + trigger_id: &str, +) -> Result<&'a Mapping, String> { + let document = document + .as_mapping() + .ok_or_else(|| "workflow file root must be a YAML mapping".to_string())?; + let triggers = document + .get(string_key("triggers")) + .and_then(YamlValue::as_sequence) + .ok_or_else(|| "workflow file does not define a `triggers` sequence".to_string())?; + + for (index, trigger) in triggers.iter().enumerate() { + let Some(trigger_mapping) = trigger.as_mapping() else { + continue; + }; + let candidate_id = trigger_mapping + .get(string_key("id")) + .and_then(YamlValue::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| format!("trigger-{}", index + 1)); + if candidate_id == trigger_id { + return Ok(trigger_mapping); + } + } + + Err(format!("workflow trigger `{trigger_id}` does not exist")) +} + fn workflow_trigger_mapping_mut<'a>( document: &'a mut YamlValue, trigger_id: &str, @@ -340,6 +475,54 @@ fn serialize_yaml_fragment(value: &impl serde::Serialize) -> Result &'static str { + match trigger_type { + WorkflowTriggerType::Manual => "manual", + WorkflowTriggerType::BeforeTurn => "before_turn", + WorkflowTriggerType::AfterTurn => "after_turn", + WorkflowTriggerType::Idle => "idle", + WorkflowTriggerType::Interval => "interval", + WorkflowTriggerType::Cron => "cron", + } +} + +fn trigger_type_parameter_defaults( + trigger_type: WorkflowTriggerType, +) -> Option<(&'static str, &'static str)> { + match trigger_type { + WorkflowTriggerType::Idle => Some(("after", "5m")), + WorkflowTriggerType::Interval => Some(("every", "5m")), + WorkflowTriggerType::Cron => Some(("cron", "0 * * * *")), + WorkflowTriggerType::Manual + | WorkflowTriggerType::BeforeTurn + | WorkflowTriggerType::AfterTurn => None, + } +} + +fn trigger_parameter_seed_from_mapping(trigger: &Mapping) -> Option { + let parameter_key = trigger_parameter_key_from_mapping(trigger)?; + trigger + .get(string_key(parameter_key)) + .and_then(YamlValue::as_str) + .map(ToString::to_string) +} + +fn trigger_parameter_key_from_mapping(trigger: &Mapping) -> Option<&'static str> { + match trigger.get(string_key("type")).and_then(YamlValue::as_str) { + Some("idle") => Some("after"), + Some("interval") => Some("every"), + Some("cron") => Some("cron"), + Some("manual" | "before_turn" | "after_turn") | None => None, + Some(_) => None, + } +} + fn string_key(value: &str) -> YamlValue { YamlValue::String(value.to_string()) } @@ -360,6 +543,10 @@ triggers: - type: manual id: review jobs: [notify] + - type: interval + id: pulse + every: 30m + jobs: [notify] jobs: notify: @@ -468,4 +655,50 @@ jobs: assert!(steps.contains("summarize the changes")); assert!(steps.contains("cargo test -p codex-tui")); } + + #[test] + fn edit_trigger_fields_and_type_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".codex/workflows/workflow.yaml"); + write_workflow(&path); + + assert_eq!( + trigger_field_seed(&path, "review", WorkflowTriggerEditableField::Id).unwrap(), + "review" + ); + assert_eq!( + trigger_field_seed(&path, "pulse", WorkflowTriggerEditableField::Parameter).unwrap(), + "30m" + ); + + let next_trigger_id = write_trigger_field( + &path, + "review", + WorkflowTriggerEditableField::Id, + "review_now", + ) + .unwrap(); + assert_eq!(next_trigger_id, "review_now"); + write_trigger_field( + &path, + "review_now", + WorkflowTriggerEditableField::Jobs, + "- notify\n- review_now\n", + ) + .unwrap(); + set_trigger_type(&path, "review_now", WorkflowTriggerType::Cron).unwrap(); + write_trigger_field( + &path, + "review_now", + WorkflowTriggerEditableField::Parameter, + "*/15 * * * *", + ) + .unwrap(); + + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("id: review_now")); + assert!(text.contains("type: cron")); + assert!(text.contains("cron: '*/15 * * * *'") || text.contains("cron: \"*/15 * * * *\"")); + assert!(text.contains("- review_now")); + } } diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index 8a88c61c9..efdf53875 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -772,11 +772,6 @@ async fn run_background_workflow_selection( "workflow trigger `{workflow_name}/{trigger_id}` is disabled" ))); } - if !matches!(trigger.kind, WorkflowTriggerKind::Manual) { - return Err(WorkflowRunError::Failed(format!( - "workflow trigger `{workflow_name}/{trigger_id}` is not runnable as a queued manual trigger" - ))); - } run_workflow_jobs( client, ®istry, @@ -1481,6 +1476,52 @@ jobs: ); } + #[tokio::test] + async fn non_manual_trigger_can_run_now_from_workflow_ui() { + let tempdir = tempdir().expect("tempdir"); + let workflows_dir = tempdir.path().join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).expect("workflow dir"); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +triggers: + - type: after_turn + id: follow_up + jobs: [review_backlog] + +jobs: + review_backlog: + steps: + - prompt: summarize the backlog +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(vec![Ok(WorkflowTurnState { + status: TurnStatus::Completed, + error: None, + last_agent_message: Some("workflow reply".to_string()), + })]); + let result = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Trigger { + workflow_name: "director".to_string(), + trigger_id: "follow_up".to_string(), + }, + CancellationToken::new(), + ) + .await; + + match result.outcome { + BackgroundWorkflowRunOutcome::Completed(results) => { + assert_eq!(results.len(), 1); + assert_eq!(results[0].message.as_deref(), Some("workflow reply")); + } + other => panic!("expected completed run, got {other:?}"), + } + } + #[tokio::test] async fn cancellation_interrupts_active_workflow_turn() { let tempdir = tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 32c6b4eca..03aa7986e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -117,6 +117,10 @@ pub(crate) enum WorkflowControlsDestination { workflow_path: PathBuf, trigger_id: String, }, + TriggerType { + workflow_path: PathBuf, + trigger_id: String, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -125,6 +129,23 @@ pub(crate) enum WorkflowJobEditableField { Steps, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkflowTriggerEditableField { + Id, + Jobs, + Parameter, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkflowTriggerType { + Manual, + BeforeTurn, + AfterTurn, + Idle, + Interval, + Cron, +} + #[derive(Debug, Clone)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) struct ConnectorsSnapshot { @@ -411,6 +432,18 @@ pub(crate) enum AppEvent { field: WorkflowJobEditableField, }, + SetWorkflowTriggerType { + workflow_path: PathBuf, + trigger_id: String, + trigger_type: WorkflowTriggerType, + }, + + EditWorkflowTriggerField { + workflow_path: PathBuf, + trigger_id: String, + field: WorkflowTriggerEditableField, + }, + StartBtwDiscussion { prompt: String, }, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 258280f86..9e554c52c 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -465,6 +465,7 @@ async fn slash_mcp_requests_inventory_via_app_server() { assert_matches!(rx.try_recv(), Ok(AppEvent::FetchMcpInventory)); assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); } +#[tokio::test] async fn slash_workflow_opens_controls_popup() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap index 9b603f6f2..6dec9134c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap @@ -3,15 +3,16 @@ source: tui/src/app.rs expression: popup --- Workflow - Manage workflow files, jobs, and manual triggers directly. + Manage workflow files, jobs, and triggers directly. Type to search workflows -› Background Tasks Insert a background task snapshot into the transcript. /ps shows - the same live workflow state. - director - Edit workflow.yaml Open manual.yaml in your editor. - director - summarize Workflow job - director - notify Workflow job - director - review_backlog Manual trigger · Enabled - director - triage Manual trigger · Enabled +› Background Tasks Insert a background task snapshot into the transcript. /ps + shows the same live workflow state. + director - edit yaml Open manual.yaml in your editor. + director - job - summarize Workflow job + director - job - notify Workflow job + director - trigger - review_backlog Manual · Enabled + director - trigger - triage Manual · Enabled + director - trigger - followup After Turn · Enabled Press enter to confirm or esc to go back diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 4db63f7f6..c5f922257 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -1,3 +1,5 @@ # Slash commands For an overview of Codex CLI slash commands, see [this documentation](https://developers.openai.com/codex/cli/slash-commands). + +For TUI workflow management with `/workflow`, see [Workflows](workflows.md). diff --git a/docs/workflows.md b/docs/workflows.md new file mode 100644 index 000000000..f733710d6 --- /dev/null +++ b/docs/workflows.md @@ -0,0 +1,139 @@ +# Workflows + +Codex TUI can load workflow definitions from `.codex/workflows/*.yaml` and manage them directly from the `/workflow` menu. + +## Files + +- Workflow files live under `.codex/workflows/`. +- If no workflow file exists yet, `/workflow` offers `Create workflow.yaml`. +- Creating the template writes a starter file and opens it in the configured external editor. + +## Root Menu + +Running `/workflow` opens a flattened menu. Each workflow contributes entries in this shape: + +- ` - edit yaml` +- ` - job - ` +- ` - trigger - ` + +The root menu also shows: + +- `Background Tasks` + +`Background Tasks` shows the same running and queued workflow state as `/ps`. + +## Workflow File Entry + +` - edit yaml` opens the real workflow file in the external editor. + +This is the fastest path for: + +- creating or removing jobs +- creating or removing triggers +- repairing invalid YAML +- editing fields that are not exposed as structured menu actions + +## Job Entry + +` - job - ` opens the job management page. + +The job page can: + +- `Run Now` +- enable or disable the job flag in YAML +- edit common structured fields such as `context`, `response`, `needs`, and `steps` +- open the workflow YAML directly + +Behavior notes: + +- `Run Now` still works even if the job has `enabled: false`. +- Job `enabled: false` only affects workflow-controlled selection logic. It does not block an explicit manual `Run Now` from the menu. + +## Trigger Entry + +` - trigger - ` opens the trigger management page. + +The trigger page can: + +- `Run Now` +- `Enable Trigger` or `Disable Trigger` +- change `Type` +- edit `Trigger ID` +- edit `Target Jobs` +- edit the trigger-specific parameter +- open the workflow YAML directly + +Behavior notes: + +- Trigger `enabled: false` disables the trigger itself. +- A disabled trigger cannot be started from `Run Now` until it is enabled again. +- `Run Now` is available for any enabled trigger type, not only `manual`. + +## Trigger Types + +The `Type` picker supports: + +- `Manual` +- `Before Turn` +- `After Turn` +- `Idle` +- `Interval` +- `Cron` + +Changing the type updates the structured trigger fields in YAML: + +- `Idle` uses `after` +- `Interval` uses `every` +- `Cron` uses `cron` +- `Manual`, `Before Turn`, and `After Turn` do not require an extra schedule parameter + +When the current type has a parameter, the trigger page exposes a matching action: + +- `Edit Idle Delay` +- `Edit Interval` +- `Edit Cron Schedule` + +## Typical Flow + +1. Run `/workflow`. +2. Pick a flattened root entry such as `director - trigger - review_backlog`. +3. Use structured actions for small changes like enable/disable, `Run Now`, type changes, or parameter edits. +4. Use `edit yaml` when the change is broader than the structured menu supports. + +## Example + +Given this workflow: + +```yaml +name: director + +triggers: + - id: pulse + type: interval + every: 30m + enabled: true + jobs: [notify] + +jobs: + notify: + enabled: false + context: ephemeral + response: assistant + steps: + - prompt: | + Send a concise update. +``` + +The root menu includes: + +- `director - edit yaml` +- `director - job - notify` +- `director - trigger - pulse` + +From `director - trigger - pulse`, you can: + +- run it immediately +- disable the trigger +- switch `interval` to `idle` +- change the parameter from `every: 30m` to `after: 30m` + From f0be84255fadf5eb838871e9fc8ea3e2d16567f5 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 10:25:42 +0800 Subject: [PATCH 52/60] python: bump codex-enhanced runtime to v0.1.24 --- sdk/python-runtime-enhanced/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python-runtime-enhanced/pyproject.toml b/sdk/python-runtime-enhanced/pyproject.toml index a39eebdaa..70fa04589 100644 --- a/sdk/python-runtime-enhanced/pyproject.toml +++ b/sdk/python-runtime-enhanced/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "codex-enhanced" -version = "0.1.23" +version = "0.1.24" description = "Enhanced Codex CLI with loop automation, Feishu clawbot support, and fast session respawn" readme = "README.md" requires-python = ">=3" From 4194a80f15403ae2dc70c4ff52114f3e01458e86 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 10:32:25 +0800 Subject: [PATCH 53/60] ci: avoid pypi release concurrency deadlock --- .github/workflows/pypi-release-artifacts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pypi-release-artifacts.yml b/.github/workflows/pypi-release-artifacts.yml index e2b0e86ee..ea176a2ed 100644 --- a/.github/workflows/pypi-release-artifacts.yml +++ b/.github/workflows/pypi-release-artifacts.yml @@ -11,7 +11,7 @@ on: type: string concurrency: - group: ${{ github.workflow }}-${{ inputs.release_tag }} + group: pypi-release-artifacts-${{ inputs.release_tag }} cancel-in-progress: true jobs: From 6365857b213e46335ae8e3a7d2fefb4f3c35dd28 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 10:57:40 +0800 Subject: [PATCH 54/60] ci: make fork main pushes manual-only --- .github/workflows/README.md | 16 +++++++++++----- .github/workflows/bazel.yml | 3 --- .github/workflows/cargo-deny.yml | 4 +--- .github/workflows/ci.yml | 2 +- .github/workflows/codespell.yml | 3 +-- .github/workflows/rust-ci-full.yml | 3 --- .github/workflows/sdk.yml | 3 +-- .github/workflows/v8-canary.yml | 13 ------------- 8 files changed, 15 insertions(+), 32 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d14817f00..66a92a5e8 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,6 +1,7 @@ # Workflow Strategy -The workflows in this directory are split so that pull requests get fast, review-friendly signal while `main` still gets the full cross-platform verification pass. +This fork keeps pushes to `main` quiet. Heavier validation runs stay available through +`workflow_dispatch`, while pull requests can still use targeted review-time checks. ## Pull Requests @@ -14,18 +15,23 @@ The workflows in this directory are split so that pull requests get fast, review - `argument-comment-lint` on Linux, macOS, and Windows - `tools/argument-comment-lint` package tests when the lint or its workflow wiring changes -## Post-Merge On `main` +## Manual Verification -- `bazel.yml` also runs on pushes to `main`. - This re-verifies the merged Bazel path and helps keep the BuildBuddy caches warm. +- `bazel.yml` is available as a manual verification path when the fork needs a full + Bazel pass. - `rust-ci-full.yml` is the full Cargo-native verification workflow. - It keeps the heavier checks off the PR path while still validating them after merge: + It keeps the heavier checks off the PR path while still providing an on-demand + validation path: - the full Cargo `clippy` matrix - the full Cargo `nextest` matrix - release-profile Cargo builds - cross-platform `argument-comment-lint` - Linux remote-env tests +Other repo-level checks that used to run on `push(main)` in upstream, such as +`ci.yml`, `cargo-deny.yml`, `codespell.yml`, `sdk.yml`, and `v8-canary.yml`, are also +manual-only in this fork so routine sync pushes do not fan out into unrelated CI. + ## Rule Of Thumb - If a build/test/clippy check can be expressed in Bazel, prefer putting the PR-time version in `bazel.yml`. diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index eeefcdada..307780546 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -5,9 +5,6 @@ name: Bazel on: pull_request: {} - push: - branches: - - main workflow_dispatch: concurrency: diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index 5294d0c7c..b118046b5 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -2,9 +2,7 @@ name: cargo-deny on: pull_request: - push: - branches: - - main + workflow_dispatch: jobs: cargo-deny: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fef2f9b3..2ca07b2b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: ci on: pull_request: {} - push: { branches: [main] } + workflow_dispatch: jobs: build-test: diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 8e9f701ee..11815a01e 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -3,10 +3,9 @@ name: Codespell on: - push: - branches: [main] pull_request: branches: [main] + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 146e57524..5368ceca5 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -1,8 +1,5 @@ name: rust-ci-full on: - push: - branches: - - main workflow_dispatch: # CI builds in debug (dev) for faster signal. diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 45c983ac1..0da6755fc 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -1,9 +1,8 @@ name: sdk on: - push: - branches: [main] pull_request: {} + workflow_dispatch: jobs: sdks: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 0dc7dc005..38ceaad87 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -12,19 +12,6 @@ on: - "patches/BUILD.bazel" - "patches/v8_*.patch" - "third_party/v8/**" - push: - branches: - - main - paths: - - ".github/scripts/rusty_v8_bazel.py" - - ".github/workflows/rusty-v8-release.yml" - - ".github/workflows/v8-canary.yml" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "codex-rs/Cargo.toml" - - "patches/BUILD.bazel" - - "patches/v8_*.patch" - - "third_party/v8/**" workflow_dispatch: concurrency: From 3de5afe0e74faf59aced211fa1f4e4f5b9411733 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 14:48:43 +0800 Subject: [PATCH 55/60] update profile fallback management with editor --- codex-rs/tui/src/app.rs | 3 + codex-rs/tui/src/app/profile_management.rs | 593 ++++++++++++++++-- ...ment__tests__profile_management_popup.snap | 46 +- ...le_management_popup_no_named_profiles.snap | 51 ++ codex-rs/tui/src/app/workflow_controls.rs | 2 +- codex-rs/tui/src/app/workflow_editor.rs | 2 +- codex-rs/tui/src/app_event.rs | 3 + .../src/bottom_pane/selection_popup_common.rs | 17 +- ...sts__realtime_microphone_picker_popup.snap | 13 +- .../tui/src/chatwidget/tests/permissions.rs | 8 +- 10 files changed, 637 insertions(+), 101 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup_no_named_profiles.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7cf167474..c42c80c49 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4613,6 +4613,9 @@ impl App { AppEvent::OpenProfileManagementPanel => { self.open_profile_management_panel(); } + AppEvent::EditProfileFallbackConfig => { + self.edit_profile_fallback_config_from_ui(tui).await; + } AppEvent::OpenThreadPanel => { self.open_thread_panel(); } diff --git a/codex-rs/tui/src/app/profile_management.rs b/codex-rs/tui/src/app/profile_management.rs index bf76dd9dc..166fd319c 100644 --- a/codex-rs/tui/src/app/profile_management.rs +++ b/codex-rs/tui/src/app/profile_management.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use toml::Value as TomlValue; use super::App; @@ -8,9 +10,12 @@ use crate::app_server_session::AppServerSession; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::external_editor; +use crate::history_cell; use crate::profile_router::DefaultProfileRouter; use crate::profile_router::PROFILE_ROUTER_STATE_RELATIVE_PATH; use crate::profile_router::ProfileFallbackAction; +use crate::profile_router::ProfileRouteEntry; use crate::profile_router::ProfileRouterState; use crate::profile_router::ProfileRouterStore; use crate::tui; @@ -54,31 +59,104 @@ impl App { pub(crate) fn open_profile_management_panel(&mut self) { let router_state = self.profile_router_store().load().unwrap_or_default(); - let initial_selected_idx = self + let active_selected_idx = self .chat_widget .selected_index_for_active_view(PROFILE_MANAGEMENT_VIEW_ID); - let params = profile_management_panel_params( + let profiles = self.routed_profile_summaries(&router_state); + let params = profile_management_root_params( self.active_profile.as_deref(), &self.default_profile_summary(), - &self.routed_profile_summaries(&router_state), - router_state.routes.len(), + &profiles, + &router_state, self.chat_widget.is_task_running(), - initial_selected_idx, + active_selected_idx, ); - if !self - .chat_widget - .replace_selection_view_if_active(PROFILE_MANAGEMENT_VIEW_ID, params) - { - self.chat_widget - .show_selection_view(profile_management_panel_params( - self.active_profile.as_deref(), - &self.default_profile_summary(), - &self.routed_profile_summaries(&router_state), - router_state.routes.len(), - self.chat_widget.is_task_running(), - initial_selected_idx, - )); + if active_selected_idx.is_some() { + let _ = self + .chat_widget + .replace_selection_view_if_active(PROFILE_MANAGEMENT_VIEW_ID, params); + } else { + self.chat_widget.show_selection_view(params); + } + } + + pub(crate) async fn edit_profile_fallback_config_from_ui(&mut self, tui: &mut tui::Tui) { + let router_state = self.profile_router_store().load().unwrap_or_default(); + let profiles = self.routed_profile_summaries(&router_state); + if profiles.is_empty() { + match self + .profile_router_store() + .update(|state| *state = ProfileRouterState::default()) + { + Ok(_) => { + self.chat_widget.add_info_message( + "Cleared the fallback route because no named profiles are defined." + .to_string(), + /*hint*/ None, + ); + self.open_profile_management_panel(); + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to update {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + } + } + return; + } + + let seed = fallback_route_editor_seed(&profiles, &router_state); + let Ok(editor_cmd) = self.resolve_editor_command_for_profiles() else { + return; + }; + let edited = tui + .with_restored(tui::RestoreMode::KeepRaw, || async { + external_editor::run_editor_with_suffix(&seed, &editor_cmd, ".txt").await + }) + .await; + + match edited { + Ok(contents) => { + let current_profile_ids = profiles + .iter() + .map(|profile| profile.id.clone()) + .collect::>(); + match parse_fallback_route_editor_contents(&contents, ¤t_profile_ids) { + Ok(ordered_profile_ids) => { + let next_state = rewritten_router_state(&router_state, ordered_profile_ids); + match self + .profile_router_store() + .update(|state| *state = next_state.clone()) + { + Ok(_) => { + self.chat_widget.add_info_message( + format!( + "Updated fallback route in {PROFILE_ROUTER_STATE_RELATIVE_PATH}." + ), + /*hint*/ None, + ); + self.open_profile_management_panel(); + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to update {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + } + } + } + Err(err) => { + self.chat_widget.add_error_message(err); + } + } + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + } } + tui.frame_requester().schedule_frame(); } fn default_profile_summary(&self) -> DefaultProfileSummary { @@ -152,6 +230,27 @@ impl App { .collect() } + fn resolve_editor_command_for_profiles(&mut self) -> Result, ()> { + match external_editor::resolve_editor_command() { + Ok(cmd) => Ok(cmd), + Err(external_editor::EditorError::MissingEditor) => { + self.chat_widget + .add_to_history(history_cell::new_error_event( + "Cannot open external editor: set $VISUAL or $EDITOR before starting Codex." + .to_string(), + )); + Err(()) + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + Err(()) + } + } + } + fn provider_label_and_base_url(&self, provider_id: &str) -> (String, Option) { if provider_id == self.config.model_provider_id { ( @@ -361,26 +460,45 @@ impl App { } } -fn profile_management_panel_params( +fn profile_management_root_params( active_profile: Option<&str>, default_profile: &DefaultProfileSummary, profiles: &[RoutedProfileSummary], - routed_count: usize, + router_state: &ProfileRouterState, task_running: bool, initial_selected_idx: Option, ) -> SelectionViewParams { - let mut items = vec![profile_selection_item( - "Default Config".to_string(), - default_profile_description(default_profile), - active_profile.is_none(), - task_running, - RuntimeProfileTarget::Default, - )]; + let mut items = vec![ + profile_selection_item( + "Default Config".to_string(), + default_profile_description(default_profile), + active_profile.is_none(), + task_running, + RuntimeProfileTarget::Default, + ), + SelectionItem { + name: "Fallback Config".to_string(), + description: Some(root_fallback_summary(router_state)), + selected_description: Some( + "Open your external editor and reorder all named profiles. Saving rewrites the fallback route file from scratch." + .to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::EditProfileFallbackConfig); + })], + dismiss_on_select: false, + search_value: Some("fallback config route reorder edit".to_string()), + ..Default::default() + }, + ]; if profiles.is_empty() { items.push(SelectionItem { name: "No named profiles".to_string(), - description: Some("Add `[profiles.]` entries in config.toml to route API traffic through alternate endpoints.".to_string()), + description: Some( + "Add `[profiles.]` entries in config.toml to route API traffic through alternate endpoints." + .to_string(), + ), is_disabled: true, ..Default::default() }); @@ -400,11 +518,9 @@ fn profile_management_panel_params( view_id: Some(PROFILE_MANAGEMENT_VIEW_ID), title: Some("Profiles".to_string()), subtitle: Some(format!( - "Current: {} · {} named profile(s) · {} routed fallback entr{}.", + "Current runtime: {} · {} named profile(s).", active_profile.unwrap_or("default"), profiles.len(), - routed_count, - if routed_count == 1 { "y" } else { "ies" }, )), footer_hint: Some(standard_popup_hint_line()), items, @@ -467,6 +583,17 @@ fn default_profile_description(profile: &DefaultProfileSummary) -> String { } fn routed_profile_description(profile: &RoutedProfileSummary) -> String { + let mut parts = vec![profile_endpoint_description(profile)]; + parts.push( + profile + .route_position + .map(|position| format!("fallback #{position}")) + .unwrap_or_else(|| "not in fallback route".to_string()), + ); + parts.join(" · ") +} + +fn profile_endpoint_description(profile: &RoutedProfileSummary) -> String { let mut parts = vec![format!("provider: {}", profile.provider_label)]; if let Some(base_url) = &profile.base_url { parts.push(base_url.clone()); @@ -474,15 +601,121 @@ fn routed_profile_description(profile: &RoutedProfileSummary) -> String { if let Some(model) = &profile.model { parts.push(format!("model: {model}")); } - parts.push( - profile - .route_position - .map(|position| format!("fallback #{position}")) - .unwrap_or_else(|| "not in fallback route".to_string()), - ); parts.join(" · ") } +fn root_fallback_summary(router_state: &ProfileRouterState) -> String { + if router_state.routes.is_empty() { + "No profiles in the fallback route.".to_string() + } else { + format!( + "{} profile(s) in route · active fallback: {}", + router_state.routes.len(), + router_state.active_profile_id.as_deref().unwrap_or("none") + ) + } +} + +fn fallback_route_editor_seed( + profiles: &[RoutedProfileSummary], + router_state: &ProfileRouterState, +) -> String { + let mut ordered_ids = Vec::with_capacity(profiles.len()); + let current_profile_ids = profiles + .iter() + .map(|profile| profile.id.as_str()) + .collect::>(); + + for route in &router_state.routes { + let profile_id = route.profile_id.as_str(); + if current_profile_ids.contains(profile_id) + && !ordered_ids.iter().any(|id| id == profile_id) + { + ordered_ids.push(profile_id.to_string()); + } + } + for profile in profiles { + if !ordered_ids.iter().any(|id| id == &profile.id) { + ordered_ids.push(profile.id.clone()); + } + } + + let mut seed = [ + "# Reorder fallback profiles, one id per line.", + "# Keep every current profile exactly once.", + "# Blank lines and lines starting with # are ignored.", + ] + .join("\n"); + seed.push_str("\n\n"); + seed.push_str(&ordered_ids.join("\n")); + seed.push('\n'); + seed +} + +fn parse_fallback_route_editor_contents( + contents: &str, + current_profile_ids: &[String], +) -> Result, String> { + let expected_ids = current_profile_ids + .iter() + .map(std::string::String::as_str) + .collect::>(); + let mut seen_ids = HashSet::with_capacity(current_profile_ids.len()); + let mut ordered_ids = Vec::with_capacity(current_profile_ids.len()); + + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if !expected_ids.contains(line) { + return Err(format!( + "Unknown profile `{line}` in fallback config. Keep only profiles currently defined in config.toml." + )); + } + if !seen_ids.insert(line.to_string()) { + return Err(format!( + "Duplicate profile `{line}` in fallback config. Each profile must appear exactly once." + )); + } + ordered_ids.push(line.to_string()); + } + + if ordered_ids.len() != current_profile_ids.len() { + let missing = current_profile_ids + .iter() + .filter(|id| !seen_ids.contains(id.as_str())) + .cloned() + .collect::>(); + return Err(format!( + "Fallback config must list every current profile exactly once. Missing: {}.", + missing.join(", ") + )); + } + + Ok(ordered_ids) +} + +fn rewritten_router_state( + previous_state: &ProfileRouterState, + ordered_profile_ids: Vec, +) -> ProfileRouterState { + let active_profile_id = previous_state + .active_profile_id + .as_ref() + .filter(|profile_id| ordered_profile_ids.iter().any(|id| id == *profile_id)) + .cloned(); + + ProfileRouterState { + active_profile_id, + routes: ordered_profile_ids + .into_iter() + .map(|profile_id| ProfileRouteEntry { profile_id }) + .collect(), + ..ProfileRouterState::default() + } +} + #[cfg(test)] mod tests { use insta::assert_snapshot; @@ -494,10 +727,15 @@ mod tests { use super::DefaultProfileSummary; use super::RoutedProfileSummary; - use super::profile_management_panel_params; + use super::fallback_route_editor_seed; + use super::parse_fallback_route_editor_contents; + use super::profile_management_root_params; + use super::rewritten_router_state; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ListSelectionView; + use crate::profile_router::ProfileRouteEntry; + use crate::profile_router::ProfileRouterState; use crate::render::renderable::Renderable; fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String { @@ -511,11 +749,8 @@ mod tests { format!("{:?}", terminal.backend()) } - #[test] - fn profile_management_popup_snapshot() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let profiles = vec![ + fn test_profiles() -> Vec { + vec![ RoutedProfileSummary { id: "primary".to_string(), provider_label: "OpenAI".to_string(), @@ -530,17 +765,45 @@ mod tests { base_url: Some("https://api.secondary.example/v1".to_string()), route_position: Some(2), }, - ]; + RoutedProfileSummary { + id: "tertiary".to_string(), + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.tertiary.example/v1".to_string()), + route_position: None, + }, + ] + } + + fn test_router_state() -> ProfileRouterState { + ProfileRouterState { + version: 1, + active_profile_id: Some("primary".to_string()), + routes: vec![ + ProfileRouteEntry { + profile_id: "primary".to_string(), + }, + ProfileRouteEntry { + profile_id: "secondary".to_string(), + }, + ], + } + } + + #[test] + fn profile_management_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); let view = ListSelectionView::new( - profile_management_panel_params( + profile_management_root_params( Some("primary"), &DefaultProfileSummary { provider_label: "OpenAI".to_string(), model: Some("gpt-5".to_string()), base_url: Some("https://api.openai.com/v1".to_string()), }, - &profiles, - /*routed_count*/ 2, + &test_profiles(), + &test_router_state(), /*task_running*/ false, /*initial_selected_idx*/ None, ), @@ -553,9 +816,230 @@ mod tests { ); } + #[test] + fn fallback_route_editor_seed_uses_current_profiles_only() { + let seed = fallback_route_editor_seed(&test_profiles(), &test_router_state()); + + assert_eq!( + seed, + concat!( + "# Reorder fallback profiles, one id per line.\n", + "# Keep every current profile exactly once.\n", + "# Blank lines and lines starting with # are ignored.\n", + "\n", + "primary\n", + "secondary\n", + "tertiary\n" + ) + ); + } + + #[test] + fn fallback_route_parser_requires_complete_unique_profile_list() { + let current_profile_ids = vec![ + "primary".to_string(), + "secondary".to_string(), + "tertiary".to_string(), + ]; + + let ordered = parse_fallback_route_editor_contents( + "# comment\nsecondary\nprimary\ntertiary\n", + ¤t_profile_ids, + ) + .expect("valid fallback route"); + assert_eq!( + ordered, + vec![ + "secondary".to_string(), + "primary".to_string(), + "tertiary".to_string() + ] + ); + + let missing = + parse_fallback_route_editor_contents("secondary\nprimary\n", ¤t_profile_ids) + .expect_err("missing profile should fail"); + assert_eq!( + missing, + "Fallback config must list every current profile exactly once. Missing: tertiary." + ); + + let duplicate = parse_fallback_route_editor_contents( + "secondary\nprimary\nprimary\ntertiary\n", + ¤t_profile_ids, + ) + .expect_err("duplicate profile should fail"); + assert_eq!( + duplicate, + "Duplicate profile `primary` in fallback config. Each profile must appear exactly once." + ); + + let unknown = parse_fallback_route_editor_contents( + "secondary\nunknown\nprimary\ntertiary\n", + ¤t_profile_ids, + ) + .expect_err("unknown profile should fail"); + assert_eq!( + unknown, + "Unknown profile `unknown` in fallback config. Keep only profiles currently defined in config.toml." + ); + } + + #[test] + fn rewritten_router_state_drops_stale_entries_and_preserves_active_profile() { + let state = rewritten_router_state( + &ProfileRouterState { + version: 1, + active_profile_id: Some("secondary".to_string()), + routes: vec![ + ProfileRouteEntry { + profile_id: "stale".to_string(), + }, + ProfileRouteEntry { + profile_id: "primary".to_string(), + }, + ], + }, + vec![ + "tertiary".to_string(), + "secondary".to_string(), + "primary".to_string(), + ], + ); + + assert_eq!( + state, + ProfileRouterState { + version: 1, + active_profile_id: Some("secondary".to_string()), + routes: vec![ + ProfileRouteEntry { + profile_id: "tertiary".to_string(), + }, + ProfileRouteEntry { + profile_id: "secondary".to_string(), + }, + ProfileRouteEntry { + profile_id: "primary".to_string(), + }, + ], + } + ); + } + + #[test] + fn rewritten_router_state_clears_missing_active_profile() { + let state = rewritten_router_state( + &ProfileRouterState { + version: 1, + active_profile_id: Some("stale".to_string()), + routes: Vec::new(), + }, + vec!["primary".to_string()], + ); + + assert_eq!( + state, + ProfileRouterState { + version: 1, + active_profile_id: None, + routes: vec![ProfileRouteEntry { + profile_id: "primary".to_string(), + }], + } + ); + } + + #[test] + fn profile_management_popup_fallback_item_opens_editor_flow() { + let params = profile_management_root_params( + Some("primary"), + &DefaultProfileSummary { + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.openai.com/v1".to_string()), + }, + &test_profiles(), + &test_router_state(), + /*task_running*/ false, + /*initial_selected_idx*/ None, + ); + + assert_eq!(params.items[1].name, "Fallback Config"); + assert_eq!( + params.items[1].selected_description.as_deref(), + Some( + "Open your external editor and reorder all named profiles. Saving rewrites the fallback route file from scratch." + ) + ); + assert_eq!(params.items[1].dismiss_on_select, false); + let (tx, mut rx) = unbounded_channel::(); + (params.items[1].actions[0])(&AppEventSender::new(tx)); + assert!(matches!( + rx.try_recv().ok(), + Some(AppEvent::EditProfileFallbackConfig) + )); + } + + #[test] + fn profile_management_popup_shows_no_named_profiles() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + profile_management_root_params( + None, + &DefaultProfileSummary { + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.openai.com/v1".to_string()), + }, + &[], + &ProfileRouterState::default(), + /*task_running*/ false, + /*initial_selected_idx*/ None, + ), + tx, + ); + + assert_snapshot!( + "profile_management_popup_no_named_profiles", + render_selection_popup(&view, /*width*/ 96, /*height*/ 20) + ); + } + + #[test] + fn profile_management_popup_fallback_item_stays_enabled_while_task_running() { + let params = profile_management_root_params( + Some("primary"), + &DefaultProfileSummary { + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: None, + }, + &[RoutedProfileSummary { + id: "primary".to_string(), + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: None, + route_position: Some(1), + }], + &ProfileRouterState { + version: 1, + active_profile_id: Some("primary".to_string()), + routes: vec![ProfileRouteEntry { + profile_id: "primary".to_string(), + }], + }, + /*task_running*/ true, + /*initial_selected_idx*/ None, + ); + + assert_eq!(params.items[1].is_disabled, false); + } + #[test] fn profile_management_panel_disables_switches_while_task_running() { - let params = profile_management_panel_params( + let params = profile_management_root_params( Some("primary"), &DefaultProfileSummary { provider_label: "OpenAI".to_string(), @@ -569,19 +1053,26 @@ mod tests { base_url: None, route_position: Some(1), }], - /*routed_count*/ 1, + &ProfileRouterState { + version: 1, + active_profile_id: Some("primary".to_string()), + routes: vec![ProfileRouteEntry { + profile_id: "primary".to_string(), + }], + }, /*task_running*/ true, /*initial_selected_idx*/ None, ); assert_eq!(params.items[0].is_disabled, true); - assert_eq!(params.items[1].is_disabled, true); + assert_eq!(params.items[1].is_disabled, false); + assert_eq!(params.items[2].is_disabled, true); assert_eq!( params.items[0].disabled_reason.as_deref(), Some("Wait for the current task to finish before switching profiles.") ); assert_eq!( - params.items[1].disabled_reason.as_deref(), + params.items[2].disabled_reason.as_deref(), Some("Already active.") ); } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup.snap index 74b0bcd64..f2268b215 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup.snap @@ -7,18 +7,18 @@ TestBackend { buffer: Buffer { content: [ " ", " Profiles ", - " Current: primary · 2 named profile(s) · 2 routed fallback entries. ", + " Current runtime: primary · 3 named profile(s). ", " ", " Type to search profiles ", - "› Default Config Reload the current session with this profile while preserving ", - " input continuity. ", - " primary (current) (disabled) provider: OpenAI · https://api.primary.example/v1 · model: ", - " gpt-5 · fallback #1 (disabled: Already active.) ", - " secondary provider: OpenAI · https://api.secondary.example/v1 · model: ", - " gpt-5 · fallback #2 ", - " ", - " ", - " ", + "› Default Config Reload the current session with this profile while preserving input ", + " continuity. ", + " Fallback Config 2 profile(s) in route · active fallback: primary ", + " primary (current) provider: OpenAI · https://api.primary.example/v1 · model: gpt-5 · ", + " fallback #1 ", + " secondary provider: OpenAI · https://api.secondary.example/v1 · model: gpt-5 · ", + " fallback #2 ", + " tertiary provider: OpenAI · https://api.tertiary.example/v1 · model: gpt-5 · not ", + " in fallback route ", " ", " ", " ", @@ -33,21 +33,27 @@ TestBackend { buffer: Buffer { x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, x: 10, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 68, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 48, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 25, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, - x: 93, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 88, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, - x: 49, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 90, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 32, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 69, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 79, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 32, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 92, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 32, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 87, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 32, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 89, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 32, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 92, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 21, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, ] }, scrollback: Buffer { diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup_no_named_profiles.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup_no_named_profiles.snap new file mode 100644 index 000000000..f74973aaa --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup_no_named_profiles.snap @@ -0,0 +1,51 @@ +--- +source: tui/src/app/profile_management.rs +expression: "render_selection_popup(&view, 96, 20)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 96, height: 20 }, + content: [ + " ", + " Profiles ", + " Current runtime: default · 0 named profile(s). ", + " ", + " Type to search profiles ", + "› Default Config (current) Reload the current session with this profile while preserving ", + " input continuity. ", + " Fallback Config No profiles in the fallback route. ", + " No named profiles Add `[profiles.]` entries in config.toml to route API ", + " traffic through alternate endpoints. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 10, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 48, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 25, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 89, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 28, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 62, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 87, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 64, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 96, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/workflow_controls.rs b/codex-rs/tui/src/app/workflow_controls.rs index fc123cfa4..e9fa5fd8e 100644 --- a/codex-rs/tui/src/app/workflow_controls.rs +++ b/codex-rs/tui/src/app/workflow_controls.rs @@ -1562,7 +1562,7 @@ fn workflow_trigger_parameter_item( SelectionItem { name: format!("Edit {label}"), - description: Some(description.to_string()), + description: Some(description), selected_description: Some( "Open this trigger parameter in your external editor and save it back into workflow.yaml." .to_string(), diff --git a/codex-rs/tui/src/app/workflow_editor.rs b/codex-rs/tui/src/app/workflow_editor.rs index 4ddebdd10..57e527f18 100644 --- a/codex-rs/tui/src/app/workflow_editor.rs +++ b/codex-rs/tui/src/app/workflow_editor.rs @@ -477,7 +477,7 @@ fn serialize_yaml_fragment(value: &impl serde::Serialize) -> Result Line<'static> { - let combined_description = match (&row.description, &row.disabled_reason) { - (Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")), - (Some(desc), None) => Some(desc.clone()), - (None, Some(reason)) => Some(format!("disabled: {reason}")), - (None, None) => None, - }; + let combined_description = row.description.clone(); // Enforce single-line name: allow at most desc_col - 2 cells for name, // reserving two spaces before the description column. @@ -469,10 +458,6 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { name_spans.push("…".into()); } - if row.disabled_reason.is_some() { - name_spans.push(" (disabled)".dim()); - } - let this_name_width = name_prefix_width + Line::from(name_spans.clone()).width(); let mut full_spans: Vec = row.name_prefix_spans.clone(); full_spans.extend(name_spans); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap index 00392bc9d..5f27c1d62 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap @@ -1,17 +1,14 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Select Microphone Saved devices apply to realtime voice only. - 1. System default Use your operating system - default device. -› Unavailable: Studio Mic (current) (disabled) Configured device is not - currently available. - (disabled: Reconnect the - device or choose another - one.) + 1. System default Use your operating system default + device. +› Unavailable: Studio Mic (current) Configured device is not currently + available. 3. Built-in Mic 4. USB Mic diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index d9ad9d9a3..8665fe4fc 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -176,12 +176,12 @@ async fn approvals_popup_shows_disabled_presets() { let screen = terminal.backend().vt100().screen().contents(); let collapsed = screen.split_whitespace().collect::>().join(" "); assert!( - collapsed.contains("(disabled)"), - "disabled preset label should be shown" + !collapsed.contains("(disabled)"), + "disabled preset label should stay hidden" ); assert!( - collapsed.contains("this message should be printed in the description"), - "disabled preset reason should be shown" + !collapsed.contains("this message should be printed in the description"), + "disabled preset reason should stay hidden" ); } From 59eb320f81bdf2cf976997fa79b09526bd4bd5ea Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 17:08:40 +0800 Subject: [PATCH 56/60] fix workflow issue with start_background_workflow_run --- codex-rs/tui/src/app.rs | 81 +++++- codex-rs/tui/src/app/profile_management.rs | 8 +- codex-rs/tui/src/app/workflow_controls.rs | 8 +- codex-rs/tui/src/app/workflow_history.rs | 70 ++--- codex-rs/tui/src/app/workflow_runtime.rs | 287 ++++++++++++++++----- codex-rs/tui/src/app/workflow_scheduler.rs | 10 +- codex-rs/tui/src/external_editor.rs | 203 ++++++++++----- docs/workflows.md | 2 +- 8 files changed, 475 insertions(+), 194 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c42c80c49..89cf995b2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1821,6 +1821,7 @@ impl App { workflow_runtime::BackgroundWorkflowRunTarget::Trigger { workflow_name, trigger_id: target_name, + phase_context: workflow_runtime::OwnedWorkflowPhaseContext::default(), } } else { workflow_runtime::BackgroundWorkflowRunTarget::Job { @@ -1845,8 +1846,11 @@ impl App { trigger_id: String, ) -> Option { if self.workflow_scheduler.has_running_trigger_run() { - self.workflow_scheduler - .enqueue_trigger_run(workflow_name, trigger_id); + self.workflow_scheduler.enqueue_trigger_run( + workflow_name, + trigger_id, + workflow_runtime::OwnedWorkflowPhaseContext::default(), + ); self.sync_background_workflow_status(); None } else { @@ -6533,12 +6537,12 @@ impl App { } async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { - let editor_cmd = match external_editor::resolve_editor_command() { - Ok(cmd) => cmd, + let editor_cmds = match external_editor::resolve_editor_commands() { + Ok(cmds) => cmds, Err(external_editor::EditorError::MissingEditor) => { self.chat_widget .add_to_history(history_cell::new_error_event( - "Cannot open external editor: set $VISUAL or $EDITOR before starting Codex." + "Cannot open external editor: no usable editor found in $VISUAL, $EDITOR, or `vim`." .to_string(), )); self.reset_external_editor_state(tui); @@ -6557,7 +6561,7 @@ impl App { let seed = self.chat_widget.composer_text_with_pending(); let editor_result = tui .with_restored(tui::RestoreMode::KeepRaw, || async { - external_editor::run_editor(&seed, &editor_cmd).await + external_editor::run_editor(&seed, &editor_cmds).await }) .await; self.reset_external_editor_state(tui); @@ -10081,6 +10085,7 @@ jobs: triggers: - type: after_turn + id: followup jobs: [followup] jobs: @@ -12266,7 +12271,38 @@ model = "gpt-5.2" Some("final reply".to_string()), ) .await; - assert_eq!(visible_cells.len(), 2); + assert_eq!(visible_cells.len(), 1); + let rendered_cells: Vec = visible_cells + .iter() + .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + .collect(); + assert!(rendered_cells[0].contains("Workflow trigger started")); + assert_eq!( + app.background_workflow_labels(), + vec!["director · followup".to_string()] + ); + + let (run_id, result) = loop { + match tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv()) + .await? + .expect("expected background workflow event") + { + AppEvent::BackgroundWorkflowRunCompleted { run_id, result } => { + break (run_id, result); + } + other => panic!("expected background workflow completion event, got {other:?}"), + } + }; + let completion_cells = app + .finish_background_workflow_run(&app_server, run_id, *result) + .await; + let rendered_completion: Vec = completion_cells + .iter() + .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + .collect(); + assert_eq!(rendered_completion.len(), 2); + assert!(rendered_completion[0].contains("Workflow trigger completed")); + assert!(rendered_completion[1].contains("Workflow reply")); match app_event_rx .try_recv() @@ -12323,6 +12359,30 @@ model = "gpt-5.2" ) .await; + let (run_id, result) = loop { + match tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv()) + .await? + .expect("expected inactive primary background workflow event") + { + AppEvent::BackgroundWorkflowRunCompleted { run_id, result } => { + break (run_id, result); + } + AppEvent::ClawbotTurnCompleted { .. } + | AppEvent::InsertHistoryCell(_) + | AppEvent::ReplayWorkflowHistory { .. } => { + continue; + } + other => panic!("expected background workflow completion event, got {other:?}"), + } + }; + let visible_cells = app + .finish_background_workflow_run(&app_server, run_id, *result) + .await; + assert!( + visible_cells.is_empty(), + "inactive primary thread should record workflow cells without rendering them immediately" + ); + match app_event_rx .try_recv() .expect("expected inactive primary workflow follow-up submission") @@ -12432,8 +12492,11 @@ model = "gpt-5.2" cancellation_for_task.cancelled().await; }), ); - app.workflow_scheduler - .enqueue_trigger_run("director".to_string(), "triage".to_string()); + app.workflow_scheduler.enqueue_trigger_run( + "director".to_string(), + "triage".to_string(), + workflow_runtime::OwnedWorkflowPhaseContext::default(), + ); app.sync_background_workflow_status(); assert_eq!( diff --git a/codex-rs/tui/src/app/profile_management.rs b/codex-rs/tui/src/app/profile_management.rs index 166fd319c..62a6ca815 100644 --- a/codex-rs/tui/src/app/profile_management.rs +++ b/codex-rs/tui/src/app/profile_management.rs @@ -230,13 +230,13 @@ impl App { .collect() } - fn resolve_editor_command_for_profiles(&mut self) -> Result, ()> { - match external_editor::resolve_editor_command() { - Ok(cmd) => Ok(cmd), + fn resolve_editor_command_for_profiles(&mut self) -> Result>, ()> { + match external_editor::resolve_editor_commands() { + Ok(cmds) => Ok(cmds), Err(external_editor::EditorError::MissingEditor) => { self.chat_widget .add_to_history(history_cell::new_error_event( - "Cannot open external editor: set $VISUAL or $EDITOR before starting Codex." + "Cannot open external editor: no usable editor found in $VISUAL, $EDITOR, or `vim`." .to_string(), )); Err(()) diff --git a/codex-rs/tui/src/app/workflow_controls.rs b/codex-rs/tui/src/app/workflow_controls.rs index e9fa5fd8e..915e10a05 100644 --- a/codex-rs/tui/src/app/workflow_controls.rs +++ b/codex-rs/tui/src/app/workflow_controls.rs @@ -447,13 +447,13 @@ impl App { } } - fn resolve_editor_command_for_workflows(&mut self) -> Result, ()> { - match external_editor::resolve_editor_command() { - Ok(cmd) => Ok(cmd), + fn resolve_editor_command_for_workflows(&mut self) -> Result>, ()> { + match external_editor::resolve_editor_commands() { + Ok(cmds) => Ok(cmds), Err(external_editor::EditorError::MissingEditor) => { self.chat_widget .add_to_history(history_cell::new_error_event( - "Cannot open external editor: set $VISUAL or $EDITOR before starting Codex." + "Cannot open external editor: no usable editor found in $VISUAL, $EDITOR, or `vim`." .to_string(), )); Err(()) diff --git a/codex-rs/tui/src/app/workflow_history.rs b/codex-rs/tui/src/app/workflow_history.rs index b14b4ecf4..83ad1e96a 100644 --- a/codex-rs/tui/src/app/workflow_history.rs +++ b/codex-rs/tui/src/app/workflow_history.rs @@ -1,7 +1,8 @@ use super::App; use super::workflow_runtime::WorkflowOutputDelivery; use super::workflow_runtime::WorkflowPhaseContext; -use super::workflow_runtime::WorkflowRunPhase; +use crate::app::workflow_definition::WorkflowTriggerKind; +use crate::app::workflow_definition::load_workflow_registry; use crate::app_command::AppCommand; use crate::app_command::AppCommandView; use crate::app_event::AppEvent; @@ -105,9 +106,8 @@ impl App { let mut cells: Vec> = Vec::new(); match self - .run_phase_workflows( + .run_before_turn_workflows( app_server, - WorkflowRunPhase::BeforeTurn, WorkflowPhaseContext { current_user_turn: Some(current_user_turn.as_str()), last_assistant_message: None, @@ -308,56 +308,36 @@ impl App { return Vec::new(); }; - let results = match self - .run_phase_workflows( - app_server, - WorkflowRunPhase::AfterTurn, - WorkflowPhaseContext { - current_user_turn: None, - last_assistant_message: last_agent_message.as_deref(), - }, - ) - .await - { - Ok(results) => results, + let registry = match load_workflow_registry(self.config.cwd.as_path()) { + Ok(registry) => registry, Err(error) => { - self.chat_widget - .add_error_message(format!("Workflow after_turn failed: {error}")); + self.chat_widget.add_error_message(format!( + "Workflow after_turn failed: failed to load workflows: {error}" + )); return Vec::new(); } }; let mut visible_cells = Vec::new(); - for result in results { - let source = - WorkflowReplySource::new(workflow_job_source_hint(&result), /*action*/ None); - let completed_cell: Arc = Arc::new(history_cell::new_info_event( - "Workflow job completed".to_string(), - Some(source.hint()), - )); - let visible_completed = - self.record_workflow_history_cell(primary_thread_id, completed_cell); - - if let Some(cell) = visible_completed { - visible_cells.push(cell); - } - - let Some(message) = result.message.filter(|message| !message.trim().is_empty()) else { - continue; - }; - - let next_visible = match result.delivery { - WorkflowOutputDelivery::AssistantCell => { - let cell: Arc = - Arc::new(workflow_result_cell(&message, self.config.cwd.as_path())); - self.record_workflow_history_cell(primary_thread_id, cell) + let phase_context = WorkflowPhaseContext { + current_user_turn: None, + last_assistant_message: last_agent_message.as_deref(), + }; + for workflow in ®istry.files { + for trigger in &workflow.triggers { + if !trigger.enabled || !matches!(trigger.kind, WorkflowTriggerKind::AfterTurn) { + continue; } - WorkflowOutputDelivery::MainThreadInput | WorkflowOutputDelivery::UserFollowup => { - self.queue_workflow_followup_to_primary(message, source) + + let cell = self.start_scheduled_workflow_trigger_run( + app_server, + workflow.name.clone(), + trigger.id.clone(), + phase_context, + ); + if let Some(cell) = self.record_workflow_history_cell(primary_thread_id, cell) { + visible_cells.push(cell); } - }; - if let Some(cell) = next_visible { - visible_cells.push(cell); } } visible_cells diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index efdf53875..f45f5f540 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -29,7 +29,6 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; -use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; @@ -66,6 +65,7 @@ pub(crate) enum BackgroundWorkflowRunTarget { Trigger { workflow_name: String, trigger_id: String, + phase_context: OwnedWorkflowPhaseContext, }, Job { workflow_name: String, @@ -132,18 +132,36 @@ pub(crate) enum WorkflowOutputDelivery { UserFollowup, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum WorkflowRunPhase { - BeforeTurn, - AfterTurn, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct WorkflowPhaseContext<'a> { pub(crate) current_user_turn: Option<&'a str>, pub(crate) last_assistant_message: Option<&'a str>, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct OwnedWorkflowPhaseContext { + pub(crate) current_user_turn: Option, + pub(crate) last_assistant_message: Option, +} + +impl OwnedWorkflowPhaseContext { + fn borrowed(&self) -> WorkflowPhaseContext<'_> { + WorkflowPhaseContext { + current_user_turn: self.current_user_turn.as_deref(), + last_assistant_message: self.last_assistant_message.as_deref(), + } + } +} + +impl From> for OwnedWorkflowPhaseContext { + fn from(value: WorkflowPhaseContext<'_>) -> Self { + Self { + current_user_turn: value.current_user_turn.map(str::to_owned), + last_assistant_message: value.last_assistant_message.map(str::to_owned), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WorkflowJobRunResult { pub(crate) delivery: WorkflowOutputDelivery, @@ -376,36 +394,92 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { turn_id: String, ) -> BoxFuture<'a, Result> { Box::pin(async move { - let mut notifications = thread.notifications.lock().await; let mut last_agent_message = None; loop { - match notifications.recv().await { - Some(ServerNotification::ItemCompleted(notification)) - if notification.thread_id == thread.thread_id - && notification.turn_id == turn_id => - { - update_last_workflow_agent_message(&mut last_agent_message, ¬ification); + { + let mut notifications = thread.notifications.lock().await; + loop { + match notifications.try_recv() { + Ok(ServerNotification::ItemCompleted(notification)) + if notification.thread_id == thread.thread_id + && notification.turn_id == turn_id => + { + update_last_workflow_agent_message( + &mut last_agent_message, + ¬ification, + ); + } + Ok(ServerNotification::TurnCompleted(notification)) + if notification.thread_id == thread.thread_id + && notification.turn.id == turn_id => + { + let status = notification.turn.status.clone(); + let error = + notification.turn.error.clone().map(|error| error.message); + return Ok(WorkflowTurnState { + status, + error, + last_agent_message: last_agent_message.or_else(|| { + last_agent_message_for_turn_items( + notification.turn.items.as_slice(), + ) + }), + }); + } + Ok(_) => {} + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + | Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, + } } - Some(ServerNotification::TurnCompleted(notification)) - if notification.thread_id == thread.thread_id - && notification.turn.id == turn_id => + } + + let response: ThreadReadResponse = match self + .request_handle + .request_typed(ClientRequest::ThreadRead { + request_id: request_id(), + params: ThreadReadParams { + thread_id: thread.thread_id.clone(), + include_turns: true, + }, + }) + .await + { + Ok(response) => response, + Err(err) + if { + let message = err.to_string(); + message + .contains("includeTurns is unavailable before first user message") + || message.contains("ephemeral threads do not support includeTurns") + } => { - let status = notification.turn.status.clone(); - let error = notification.turn.error.clone().map(|error| error.message); - return Ok(WorkflowTurnState { - status, - error, - last_agent_message: last_agent_message - .or_else(|| last_agent_message_for_turn_completed(¬ification)), - }); + sleep(WORKFLOW_POLL_INTERVAL).await; + continue; } - Some(_) => {} - None => { - return Err(format!( - "workflow notification stream closed before turn `{turn_id}` completed" - )); + Err(err) => { + return Err(format!("failed to read workflow turn `{turn_id}`: {err}")); } + }; + let Some(turn) = response + .thread + .turns + .into_iter() + .find(|turn| turn.id == turn_id) + else { + sleep(WORKFLOW_POLL_INTERVAL).await; + continue; + }; + if let Some(message) = last_agent_message_for_turn_items(turn.items.as_slice()) { + last_agent_message = Some(message); + } + if !matches!(turn.status, TurnStatus::InProgress) { + return Ok(WorkflowTurnState { + status: turn.status, + error: turn.error.map(|error| error.message), + last_agent_message, + }); } + sleep(WORKFLOW_POLL_INTERVAL).await; } }) } @@ -453,10 +527,9 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { #[allow(dead_code)] impl App { - pub(crate) async fn run_phase_workflows( + pub(crate) async fn run_before_turn_workflows( &self, app_server: &AppServerSession, - phase: WorkflowRunPhase, phase_context: WorkflowPhaseContext<'_>, ) -> Result, String> { let registry = load_workflow_registry(self.config.cwd.as_path()) @@ -470,7 +543,7 @@ impl App { let mut results = Vec::new(); for workflow in ®istry.files { for trigger in &workflow.triggers { - if !trigger.enabled || !workflow_trigger_matches_phase(&trigger.kind, phase) { + if !trigger.enabled || !matches!(trigger.kind, WorkflowTriggerKind::BeforeTurn) { continue; } results.extend( @@ -501,7 +574,12 @@ impl App { trigger_id: String, ) -> Arc { let label = format!("{workflow_name} · {trigger_id}"); - match self.queue_or_start_trigger_run(app_server, workflow_name, trigger_id) { + match self.queue_or_start_trigger_run( + app_server, + workflow_name, + trigger_id, + OwnedWorkflowPhaseContext::default(), + ) { TriggerRunDispatch::Started => Arc::new(history_cell::new_info_event( "Workflow trigger started".to_string(), Some(label), @@ -536,8 +614,24 @@ impl App { app_server: &AppServerSession, workflow_name: String, trigger_id: String, - ) { - let _ = self.queue_or_start_trigger_run(app_server, workflow_name, trigger_id); + phase_context: WorkflowPhaseContext<'_>, + ) -> Arc { + let label = format!("{workflow_name} · {trigger_id}"); + match self.queue_or_start_trigger_run( + app_server, + workflow_name, + trigger_id, + phase_context.into(), + ) { + TriggerRunDispatch::Started => Arc::new(history_cell::new_info_event( + "Workflow trigger started".to_string(), + Some(label), + )), + TriggerRunDispatch::Queued => Arc::new(history_cell::new_info_event( + "Workflow trigger queued".to_string(), + Some(label), + )), + } } pub(crate) fn dispatch_next_queued_trigger_run(&mut self, app_server: &AppServerSession) { @@ -552,6 +646,7 @@ impl App { BackgroundWorkflowRunTarget::Trigger { workflow_name: next.workflow_name, trigger_id: next.trigger_id, + phase_context: next.phase_context, }, ); } @@ -656,10 +751,11 @@ impl App { app_server: &AppServerSession, workflow_name: String, trigger_id: String, + phase_context: OwnedWorkflowPhaseContext, ) -> TriggerRunDispatch { if self.workflow_scheduler.has_running_trigger_run() { self.workflow_scheduler - .enqueue_trigger_run(workflow_name, trigger_id); + .enqueue_trigger_run(workflow_name, trigger_id, phase_context); self.sync_background_workflow_status(); return TriggerRunDispatch::Queued; } @@ -669,6 +765,7 @@ impl App { BackgroundWorkflowRunTarget::Trigger { workflow_name, trigger_id, + phase_context, }, ); TriggerRunDispatch::Started @@ -752,6 +849,7 @@ async fn run_background_workflow_selection( BackgroundWorkflowRunTarget::Trigger { workflow_name, trigger_id, + phase_context, } => { let workflow = registry .files @@ -778,10 +876,7 @@ async fn run_background_workflow_selection( workflow_name, trigger_id, &trigger.jobs, - WorkflowPhaseContext { - current_user_turn: None, - last_assistant_message: None, - }, + phase_context.borrowed(), Some(cancellation), ) .await @@ -815,16 +910,6 @@ async fn run_background_workflow_selection( } } -fn workflow_trigger_matches_phase(trigger: &WorkflowTriggerKind, phase: WorkflowRunPhase) -> bool { - matches!( - (trigger, phase), - ( - &WorkflowTriggerKind::BeforeTurn, - WorkflowRunPhase::BeforeTurn - ) | (&WorkflowTriggerKind::AfterTurn, WorkflowRunPhase::AfterTurn) - ) -} - async fn run_workflow_jobs( client: &dyn WorkflowRuntimeClient, registry: &LoadedWorkflowRegistry, @@ -1276,20 +1361,14 @@ fn update_last_workflow_agent_message( } } -fn last_agent_message_for_turn_completed( - notification: &TurnCompletedNotification, -) -> Option { - notification - .turn - .items - .iter() - .fold(None, |_, item| match item { - ThreadItem::AgentMessage { text, .. } => { - let trimmed = text.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) - } - _ => None, - }) +fn last_agent_message_for_turn_items(items: &[ThreadItem]) -> Option { + items.iter().fold(None, |_, item| match item { + ThreadItem::AgentMessage { text, .. } => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + _ => None, + }) } #[derive(Debug)] @@ -1301,6 +1380,7 @@ enum WorkflowRunError { #[cfg(test)] mod tests { use super::*; + use codex_app_server_protocol::TurnCompletedNotification; use codex_core::config::ConfigBuilder; use pretty_assertions::assert_eq; use std::collections::VecDeque; @@ -1508,6 +1588,7 @@ jobs: BackgroundWorkflowRunTarget::Trigger { workflow_name: "director".to_string(), trigger_id: "follow_up".to_string(), + phase_context: OwnedWorkflowPhaseContext::default(), }, CancellationToken::new(), ) @@ -1649,6 +1730,82 @@ jobs: ); } + #[tokio::test] + async fn read_turn_retries_include_turns_errors_for_ephemeral_threads() { + let temp_dir = tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let workflow_thread_notification_channels = + Arc::new(tokio::sync::Mutex::new(HashMap::new())); + let app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("embedded app server"); + let client = AppServerWorkflowRuntimeClient::new( + &app_server, + workflow_thread_notification_channels.clone(), + config, + /*primary_thread_id*/ None, + ); + let thread = client + .start_workflow_thread() + .await + .expect("start workflow thread"); + + let sender = workflow_thread_notification_channels + .lock() + .await + .get(&ThreadId::from_string(&thread.thread_id).expect("workflow thread id")) + .cloned() + .expect("workflow notification sender"); + + let read_turn = { + let client = client; + let thread = thread.clone(); + tokio::spawn(async move { client.read_turn(&thread, "turn-1".to_string()).await }) + }; + + sleep(Duration::from_millis(10)).await; + sender + .send(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "workflow reply".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: thread.thread_id.clone(), + turn_id: "turn-1".to_string(), + }, + )) + .expect("item completed notification"); + sender + .send(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: thread.thread_id.clone(), + turn: codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: Vec::new(), + error: None, + status: TurnStatus::Completed, + }, + }, + )) + .expect("turn completed notification"); + + let state = read_turn + .await + .expect("join read_turn task") + .expect("read workflow turn"); + assert_eq!( + state, + WorkflowTurnState { + status: TurnStatus::Completed, + error: None, + last_agent_message: Some("workflow reply".to_string()), + } + ); + } + #[tokio::test] async fn start_workflow_thread_starts_fresh_thread_when_primary_thread_is_unmaterialized() { let temp_dir = tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/app/workflow_scheduler.rs b/codex-rs/tui/src/app/workflow_scheduler.rs index 89425457b..c456df963 100644 --- a/codex-rs/tui/src/app/workflow_scheduler.rs +++ b/codex-rs/tui/src/app/workflow_scheduler.rs @@ -6,6 +6,7 @@ use tokio::time::timeout; use tokio_util::sync::CancellationToken; use super::workflow_runtime::BackgroundWorkflowRunTarget; +use super::workflow_runtime::OwnedWorkflowPhaseContext; pub(crate) struct BackgroundWorkflowRunState { pub(crate) label: String, @@ -19,6 +20,7 @@ pub(crate) struct BackgroundWorkflowRunState { pub(crate) struct QueuedWorkflowTriggerRun { pub(crate) workflow_name: String, pub(crate) trigger_id: String, + pub(crate) phase_context: OwnedWorkflowPhaseContext, } #[derive(Default)] @@ -91,11 +93,17 @@ impl WorkflowSchedulerState { } #[cfg_attr(not(test), allow(dead_code))] - pub(crate) fn enqueue_trigger_run(&mut self, workflow_name: String, trigger_id: String) { + pub(crate) fn enqueue_trigger_run( + &mut self, + workflow_name: String, + trigger_id: String, + phase_context: OwnedWorkflowPhaseContext, + ) { self.queued_trigger_runs .push_back(QueuedWorkflowTriggerRun { workflow_name, trigger_id, + phase_context, }); } diff --git a/codex-rs/tui/src/external_editor.rs b/codex-rs/tui/src/external_editor.rs index 603db0b75..a27c7a92c 100644 --- a/codex-rs/tui/src/external_editor.rs +++ b/codex-rs/tui/src/external_editor.rs @@ -11,7 +11,7 @@ use tokio::process::Command; #[derive(Debug, Error)] pub(crate) enum EditorError { - #[error("neither VISUAL nor EDITOR is set")] + #[error("no usable editor found in VISUAL, EDITOR, or vim")] MissingEditor, #[cfg(not(windows))] #[error("failed to parse editor command")] @@ -29,87 +29,128 @@ fn resolve_windows_program(program: &str) -> std::path::PathBuf { which::which(program).unwrap_or_else(|_| std::path::PathBuf::from(program)) } -/// Resolve the editor command from environment variables. -/// Prefers `VISUAL` over `EDITOR`. -pub(crate) fn resolve_editor_command() -> std::result::Result, EditorError> { - let raw = env::var("VISUAL") - .or_else(|_| env::var("EDITOR")) - .map_err(|_| EditorError::MissingEditor)?; - let parts = { - #[cfg(windows)] - { - winsplit::split(&raw) +/// Resolve editor commands from environment variables, then fall back to `vim`. +/// Prefers `VISUAL` over `EDITOR`, but will try each configured editor before `vim`. +pub(crate) fn resolve_editor_commands() -> std::result::Result>, EditorError> { + let mut commands = Vec::new(); + let mut last_error = None; + + for key in ["VISUAL", "EDITOR"] { + let Ok(raw) = env::var(key) else { + continue; + }; + let parts = { + #[cfg(windows)] + { + winsplit::split(&raw) + } + #[cfg(not(windows))] + { + match shlex::split(&raw) { + Some(parts) => parts, + None => { + last_error = Some(EditorError::ParseFailed); + continue; + } + } + } + }; + if parts.is_empty() { + last_error = Some(EditorError::EmptyCommand); + continue; } - #[cfg(not(windows))] - { - shlex::split(&raw).ok_or(EditorError::ParseFailed)? + if !commands.contains(&parts) { + commands.push(parts); } - }; - if parts.is_empty() { - return Err(EditorError::EmptyCommand); } - Ok(parts) + + let vim = vec!["vim".to_string()]; + if !commands.contains(&vim) { + commands.push(vim); + } + + if commands.is_empty() { + return Err(last_error.unwrap_or(EditorError::MissingEditor)); + } + + Ok(commands) } -/// Write `seed` to a temp file, launch the editor command, and return the updated content. -pub(crate) async fn run_editor(seed: &str, editor_cmd: &[String]) -> Result { - run_editor_with_suffix(seed, editor_cmd, ".md").await +/// Write `seed` to a temp file, launch an editor, and return the updated content. +pub(crate) async fn run_editor(seed: &str, editor_cmds: &[Vec]) -> Result { + run_editor_with_suffix(seed, editor_cmds, ".md").await } -/// Write `seed` to a temp file with a custom suffix, launch the editor command, +/// Write `seed` to a temp file with a custom suffix, launch an editor, /// and return the updated content. pub(crate) async fn run_editor_with_suffix( seed: &str, - editor_cmd: &[String], + editor_cmds: &[Vec], suffix: &str, ) -> Result { - if editor_cmd.is_empty() { - return Err(Report::msg("editor command is empty")); + if editor_cmds.is_empty() { + return Err(Report::msg("editor command list is empty")); } // Convert to TempPath immediately so no file handle stays open on Windows. let temp_path = Builder::new().suffix(suffix).tempfile()?.into_temp_path(); fs::write(&temp_path, seed)?; - launch_editor_for_path(&temp_path, editor_cmd).await?; + launch_editor_for_path(&temp_path, editor_cmds).await?; let contents = fs::read_to_string(&temp_path)?; Ok(contents) } /// Launch the editor against an existing file path. -pub(crate) async fn edit_file(path: &Path, editor_cmd: &[String]) -> Result<()> { - if editor_cmd.is_empty() { - return Err(Report::msg("editor command is empty")); +pub(crate) async fn edit_file(path: &Path, editor_cmds: &[Vec]) -> Result<()> { + if editor_cmds.is_empty() { + return Err(Report::msg("editor command list is empty")); } - launch_editor_for_path(path, editor_cmd).await + launch_editor_for_path(path, editor_cmds).await } -async fn launch_editor_for_path(path: &Path, editor_cmd: &[String]) -> Result<()> { - let mut cmd = { - #[cfg(windows)] - { - // handles .cmd/.bat shims - Command::new(resolve_windows_program(&editor_cmd[0])) +async fn launch_editor_for_path(path: &Path, editor_cmds: &[Vec]) -> Result<()> { + let mut failures = Vec::new(); + + for editor_cmd in editor_cmds { + if editor_cmd.is_empty() { + failures.push("editor command is empty".to_string()); + continue; } - #[cfg(not(windows))] + + let mut cmd = { + #[cfg(windows)] + { + // handles .cmd/.bat shims + Command::new(resolve_windows_program(&editor_cmd[0])) + } + #[cfg(not(windows))] + { + Command::new(&editor_cmd[0]) + } + }; + if editor_cmd.len() > 1 { + cmd.args(&editor_cmd[1..]); + } + let command = shlex::try_join(editor_cmd.iter().map(String::as_str)) + .unwrap_or_else(|_| editor_cmd.join(" ")); + match cmd + .arg(path) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await { - Command::new(&editor_cmd[0]) + Ok(status) if status.success() => return Ok(()), + Ok(status) => failures.push(format!("`{command}` exited with status {status}")), + Err(err) => failures.push(format!("`{command}`: {err}")), } - }; - if editor_cmd.len() > 1 { - cmd.args(&editor_cmd[1..]); - } - let status = cmd - .arg(path) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .await?; - - if !status.success() { - return Err(Report::msg(format!("editor exited with status {status}"))); } - Ok(()) + + Err(Report::msg(format!( + "failed to open any editor: {}", + failures.join("; ") + ))) } #[cfg(test)] @@ -117,12 +158,12 @@ mod tests { use super::*; use pretty_assertions::assert_eq; use serial_test::serial; - #[cfg(unix)] use tempfile::tempdir; struct EnvGuard { visual: Option, editor: Option, + path: Option, } impl EnvGuard { @@ -130,6 +171,7 @@ mod tests { Self { visual: env::var("VISUAL").ok(), editor: env::var("EDITOR").ok(), + path: env::var("PATH").ok(), } } } @@ -138,6 +180,7 @@ mod tests { fn drop(&mut self) { restore_env("VISUAL", self.visual.take()); restore_env("EDITOR", self.editor.take()); + restore_env("PATH", self.path.take()); } } @@ -156,22 +199,20 @@ mod tests { env::set_var("VISUAL", "vis"); env::set_var("EDITOR", "ed"); } - let cmd = resolve_editor_command().unwrap(); - assert_eq!(cmd, vec!["vis".to_string()]); + let commands = resolve_editor_commands().unwrap(); + assert_eq!(commands[0], vec!["vis".to_string()]); } #[test] #[serial] - fn resolve_editor_errors_when_unset() { + fn resolve_editor_falls_back_to_vim_when_unset() { let _guard = EnvGuard::new(); unsafe { env::remove_var("VISUAL"); env::remove_var("EDITOR"); } - assert!(matches!( - resolve_editor_command(), - Err(EditorError::MissingEditor) - )); + let commands = resolve_editor_commands().unwrap(); + assert_eq!(commands, vec![vec!["vim".to_string()]]); } #[tokio::test] @@ -186,8 +227,8 @@ mod tests { perms.set_mode(0o755); fs::set_permissions(&script_path, perms).unwrap(); - let cmd = vec![script_path.to_string_lossy().to_string()]; - let result = run_editor("seed", &cmd).await.unwrap(); + let commands = vec![vec![script_path.to_string_lossy().to_string()]]; + let result = run_editor("seed", &commands).await.unwrap(); assert_eq!(result, "edited".to_string()); } @@ -206,8 +247,40 @@ mod tests { let file_path = dir.path().join("workflow.yaml"); fs::write(&file_path, "seed").unwrap(); - let cmd = vec![script_path.to_string_lossy().to_string()]; - edit_file(&file_path, &cmd).await.unwrap(); + let commands = vec![vec![script_path.to_string_lossy().to_string()]]; + edit_file(&file_path, &commands).await.unwrap(); assert_eq!(fs::read_to_string(&file_path).unwrap(), "updated"); } + + #[tokio::test] + #[cfg(unix)] + #[serial] + async fn run_editor_falls_back_when_primary_editor_fails() { + use std::os::unix::fs::PermissionsExt; + + let _guard = EnvGuard::new(); + let dir = tempdir().unwrap(); + + let failing_editor = dir.path().join("broken-editor.sh"); + fs::write(&failing_editor, "#!/bin/sh\nexit 1\n").unwrap(); + let mut perms = fs::metadata(&failing_editor).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&failing_editor, perms).unwrap(); + + let vim_path = dir.path().join("vim"); + fs::write(&vim_path, "#!/bin/sh\nprintf \"edited\" > \"$1\"\n").unwrap(); + let mut perms = fs::metadata(&vim_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&vim_path, perms).unwrap(); + + unsafe { + env::set_var("VISUAL", failing_editor.as_os_str()); + env::remove_var("EDITOR"); + env::set_var("PATH", dir.path()); + } + + let commands = resolve_editor_commands().unwrap(); + let result = run_editor("seed", &commands).await.unwrap(); + assert_eq!(result, "edited"); + } } diff --git a/docs/workflows.md b/docs/workflows.md index f733710d6..7495620ba 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -68,6 +68,7 @@ Behavior notes: - Trigger `enabled: false` disables the trigger itself. - A disabled trigger cannot be started from `Run Now` until it is enabled again. - `Run Now` is available for any enabled trigger type, not only `manual`. +- `After Turn` runs are dispatched as background workflow tasks after the turn finishes, so the main thread stays responsive and the transcript shows workflow start/completion cells separately. ## Trigger Types @@ -136,4 +137,3 @@ From `director - trigger - pulse`, you can: - disable the trigger - switch `interval` to `idle` - change the parameter from `every: 30m` to `after: 30m` - From 4c0520bc77c90a9e366dcd23b4ee10c06a404b79 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 17:14:26 +0800 Subject: [PATCH 57/60] fix profile fallback (allow skip) --- codex-rs/tui/src/app/profile_management.rs | 28 +++++++--------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/codex-rs/tui/src/app/profile_management.rs b/codex-rs/tui/src/app/profile_management.rs index 62a6ca815..868ff9529 100644 --- a/codex-rs/tui/src/app/profile_management.rs +++ b/codex-rs/tui/src/app/profile_management.rs @@ -642,7 +642,8 @@ fn fallback_route_editor_seed( let mut seed = [ "# Reorder fallback profiles, one id per line.", - "# Keep every current profile exactly once.", + "# Omitted profiles are allowed.", + "# Keep only profiles currently defined in config.toml, at most once each.", "# Blank lines and lines starting with # are ignored.", ] .join("\n"); @@ -681,18 +682,6 @@ fn parse_fallback_route_editor_contents( ordered_ids.push(line.to_string()); } - if ordered_ids.len() != current_profile_ids.len() { - let missing = current_profile_ids - .iter() - .filter(|id| !seen_ids.contains(id.as_str())) - .cloned() - .collect::>(); - return Err(format!( - "Fallback config must list every current profile exactly once. Missing: {}.", - missing.join(", ") - )); - } - Ok(ordered_ids) } @@ -824,7 +813,8 @@ mod tests { seed, concat!( "# Reorder fallback profiles, one id per line.\n", - "# Keep every current profile exactly once.\n", + "# Omitted profiles are allowed.\n", + "# Keep only profiles currently defined in config.toml, at most once each.\n", "# Blank lines and lines starting with # are ignored.\n", "\n", "primary\n", @@ -835,7 +825,7 @@ mod tests { } #[test] - fn fallback_route_parser_requires_complete_unique_profile_list() { + fn fallback_route_parser_accepts_partial_unique_profile_list() { let current_profile_ids = vec![ "primary".to_string(), "secondary".to_string(), @@ -856,12 +846,12 @@ mod tests { ] ); - let missing = + let partial = parse_fallback_route_editor_contents("secondary\nprimary\n", ¤t_profile_ids) - .expect_err("missing profile should fail"); + .expect("omitted profiles should be allowed"); assert_eq!( - missing, - "Fallback config must list every current profile exactly once. Missing: tertiary." + partial, + vec!["secondary".to_string(), "primary".to_string()] ); let duplicate = parse_fallback_route_editor_contents( From 472a365352e20d79bf40758bc7bf0f516f55dd6b Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 18:34:15 +0800 Subject: [PATCH 58/60] fix yaml multiline/file trigger/profile retry continue --- codex-rs/Cargo.lock | 1 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 100 ++++++++ codex-rs/tui/src/app/profile_management.rs | 23 +- ...s__tests__workflow_trigger_type_popup.snap | 1 + codex-rs/tui/src/app/workflow_controls.rs | 13 +- codex-rs/tui/src/app/workflow_definition.rs | 1 + codex-rs/tui/src/app/workflow_editor.rs | 22 +- codex-rs/tui/src/app/workflow_file_watch.rs | 91 +++++++ codex-rs/tui/src/app/workflow_runtime.rs | 205 ++++++++++++++- codex-rs/tui/src/app/workflow_scheduler.rs | 19 ++ codex-rs/tui/src/app/workflow_yaml.rs | 242 ++++++++++++++++++ codex-rs/tui/src/app_event.rs | 5 + codex-rs/tui/src/chatwidget.rs | 14 +- .../chatwidget/tests/composer_submission.rs | 15 ++ docs/workflows.md | 9 +- 16 files changed, 717 insertions(+), 45 deletions(-) create mode 100644 codex-rs/tui/src/app/workflow_file_watch.rs create mode 100644 codex-rs/tui/src/app/workflow_yaml.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6ef6805bb..bc69630f5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2840,6 +2840,7 @@ dependencies = [ "itertools 0.14.0", "lazy_static", "libc", + "notify", "pathdiff", "pretty_assertions", "pulldown-cmark", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index a6f389507..f7aada896 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -67,6 +67,7 @@ image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] } indexmap = { workspace = true, features = ["serde"] } itertools = { workspace = true } lazy_static = { workspace = true } +notify = { workspace = true } pathdiff = { workspace = true } pulldown-cmark = { workspace = true } rand = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 89cf995b2..972c876d5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -179,9 +179,11 @@ mod thread_menu; mod workflow_controls; mod workflow_definition; mod workflow_editor; +mod workflow_file_watch; mod workflow_history; pub(crate) mod workflow_runtime; mod workflow_scheduler; +mod workflow_yaml; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; @@ -193,6 +195,7 @@ use self::key_chord::KeyChordResolution; use self::key_chord::KeyChordState; use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::pending_interactive_replay::PendingInteractiveReplayState; +use self::workflow_file_watch::WorkflowFileWatchState; use self::workflow_history::WorkflowHistoryState; use self::workflow_scheduler::WorkflowSchedulerState; @@ -1104,6 +1107,7 @@ pub(crate) struct App { pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, workflow_thread_notification_channels: workflow_runtime::WorkflowThreadNotificationChannels, + workflow_file_watch: Option, workflow_scheduler: WorkflowSchedulerState, workflow_history: WorkflowHistoryState, btw_session: Option, @@ -1822,6 +1826,7 @@ impl App { workflow_name, trigger_id: target_name, phase_context: workflow_runtime::OwnedWorkflowPhaseContext::default(), + overlap_behavior: workflow_runtime::WorkflowTriggerOverlapBehavior::Queue, } } else { workflow_runtime::BackgroundWorkflowRunTarget::Job { @@ -4241,6 +4246,7 @@ impl App { workflow_thread_notification_channels: Arc::new( tokio::sync::Mutex::new(HashMap::new()), ), + workflow_file_watch: None, workflow_scheduler: WorkflowSchedulerState::default(), workflow_history: WorkflowHistoryState::default(), btw_session: None, @@ -4253,6 +4259,14 @@ impl App { #[cfg(test)] clawbot_outbound_reactions: Vec::new(), }; + match WorkflowFileWatchState::new(app.config.cwd.as_path(), app.app_event_tx.clone()) { + Ok(state) => { + app.workflow_file_watch = Some(state); + } + Err(err) => { + tracing::warn!("failed to start workflow file watcher: {err}"); + } + } if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) .await?; @@ -4852,6 +4866,26 @@ impl App { self.edit_workflow_trigger_field_from_ui(tui, workflow_path, trigger_id, field) .await; } + AppEvent::WorkflowWorkspaceFilesChanged { changed_paths } => { + let relevant_paths = changed_paths + .into_iter() + .filter(|path| { + workflow_file_watch::is_relevant_workspace_change( + self.config.cwd.as_path(), + path.as_path(), + ) + }) + .collect::>(); + if !relevant_paths.is_empty() { + let cells = self.handle_workspace_file_changes_for_workflows( + app_server, + relevant_paths.as_slice(), + ); + for cell in cells { + self.insert_visible_history_cell(tui, cell); + } + } + } AppEvent::StartManualWorkflowTrigger { workflow_name, trigger_id, @@ -9966,6 +10000,7 @@ guardian_approval = true workflow_thread_notification_channels: Arc::new( tokio::sync::Mutex::new(HashMap::new()), ), + workflow_file_watch: None, workflow_scheduler: WorkflowSchedulerState::default(), workflow_history: WorkflowHistoryState::default(), btw_session: None, @@ -10037,6 +10072,7 @@ guardian_approval = true workflow_thread_notification_channels: Arc::new(tokio::sync::Mutex::new( HashMap::new(), )), + workflow_file_watch: None, workflow_scheduler: WorkflowSchedulerState::default(), workflow_history: WorkflowHistoryState::default(), btw_session: None, @@ -10133,6 +10169,29 @@ jobs: )?; Ok(()) } + + fn write_test_file_watch_workflow(workspace_cwd: &Path) -> Result<()> { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir)?; + std::fs::write( + workflows_dir.join("file_watch.yaml"), + r#"name: watcher + +triggers: + - type: file_watch + id: refresh + jobs: [summarize] + +jobs: + summarize: + context: embed + steps: + - prompt: | + summarize the latest file changes +"#, + )?; + Ok(()) + } fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { thread_id, @@ -12552,6 +12611,47 @@ model = "gpt-5.2" assert!(app.queued_trigger_labels().is_empty()); } + #[tokio::test] + async fn file_watch_triggers_share_the_global_queue_and_skip_duplicates() -> Result<()> { + let mut app = make_test_app().await; + write_test_file_watch_workflow(app.config.cwd.as_path())?; + + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + app.primary_session_configured = Some(started.session); + app.active_thread_id = Some(thread_id); + + let running = + app.start_test_manual_workflow_trigger_run("director".to_string(), "slow".to_string()); + assert!(running.is_some()); + + let first = app.handle_workspace_file_changes_for_workflows( + &app_server, + &[app.config.cwd.as_path().join("src")], + ); + let second = app.handle_workspace_file_changes_for_workflows( + &app_server, + &[app.config.cwd.as_path().join("src/lib.rs")], + ); + + assert_eq!(first.len(), 1); + assert!(second.is_empty()); + assert_eq!( + app.background_workflow_labels(), + vec!["director · slow".to_string()] + ); + assert_eq!( + app.queued_trigger_labels(), + vec!["watcher · refresh".to_string()] + ); + Ok(()) + } + #[tokio::test] async fn workflow_follow_up_completion_submits_to_primary_thread() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/profile_management.rs b/codex-rs/tui/src/app/profile_management.rs index 868ff9529..0fe63d813 100644 --- a/codex-rs/tui/src/app/profile_management.rs +++ b/codex-rs/tui/src/app/profile_management.rs @@ -371,7 +371,6 @@ impl App { self.chat_widget.add_error_message(error_message); return; } - let retry_user_message = self.chat_widget.last_submitted_user_turn(); match action { ProfileFallbackAction::RetrySameProfileFirst => { @@ -411,14 +410,9 @@ impl App { )); return; } - if let Some(user_message) = retry_user_message.clone() { - self.chat_widget.submit_profile_fallback_retry( - user_message, - format!("Retrying the last turn with profile `{next_profile_id}`."), - ); - } else { - self.chat_widget.add_error_message(error_message); - } + self.chat_widget.submit_profile_fallback_retry(format!( + "Retrying the last turn with profile `{next_profile_id}`." + )); } } ProfileFallbackAction::SwitchProfileImmediately => { @@ -447,14 +441,9 @@ impl App { )); return; } - if let Some(user_message) = retry_user_message { - self.chat_widget.submit_profile_fallback_retry( - user_message, - format!("Retrying the last turn with profile `{next_profile_id}`."), - ); - } else { - self.chat_widget.add_error_message(error_message); - } + self.chat_widget.submit_profile_fallback_retry(format!( + "Retrying the last turn with profile `{next_profile_id}`." + )); } } } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_trigger_type_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_trigger_type_popup.snap index 2a0c69cc4..881066694 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_trigger_type_popup.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_trigger_type_popup.snap @@ -10,6 +10,7 @@ expression: popup Manual Run only when triggered from the workflow menu. Before Turn Run automatically before the next user turn. After Turn Run automatically after the current turn finishes. + File Watch Run automatically when workspace files change. Overlapping runs are skipped. Idle Run after the workspace has been idle for a duration. Interval Current type Cron Run on a cron schedule. diff --git a/codex-rs/tui/src/app/workflow_controls.rs b/codex-rs/tui/src/app/workflow_controls.rs index 915e10a05..df71300f5 100644 --- a/codex-rs/tui/src/app/workflow_controls.rs +++ b/codex-rs/tui/src/app/workflow_controls.rs @@ -1160,6 +1160,7 @@ impl App { WorkflowTriggerType::Manual, WorkflowTriggerType::BeforeTurn, WorkflowTriggerType::AfterTurn, + WorkflowTriggerType::FileWatch, WorkflowTriggerType::Idle, WorkflowTriggerType::Interval, WorkflowTriggerType::Cron, @@ -1703,6 +1704,7 @@ fn workflow_trigger_type_label(trigger_type: WorkflowTriggerType) -> &'static st WorkflowTriggerType::Manual => "Manual", WorkflowTriggerType::BeforeTurn => "Before Turn", WorkflowTriggerType::AfterTurn => "After Turn", + WorkflowTriggerType::FileWatch => "File Watch", WorkflowTriggerType::Idle => "Idle", WorkflowTriggerType::Interval => "Interval", WorkflowTriggerType::Cron => "Cron", @@ -1714,6 +1716,7 @@ fn workflow_trigger_kind_display(kind: &WorkflowTriggerKind) -> String { WorkflowTriggerKind::Manual => "Manual".to_string(), WorkflowTriggerKind::BeforeTurn => "Before Turn".to_string(), WorkflowTriggerKind::AfterTurn => "After Turn".to_string(), + WorkflowTriggerKind::FileWatch => "File Watch".to_string(), WorkflowTriggerKind::Idle { after } => format!("Idle ({after})"), WorkflowTriggerKind::Interval { every } => format!("Interval ({every})"), WorkflowTriggerKind::Cron { cron } => format!("Cron ({cron})"), @@ -1725,6 +1728,9 @@ fn workflow_trigger_type_description(trigger_type: WorkflowTriggerType) -> &'sta WorkflowTriggerType::Manual => "Run only when triggered from the workflow menu.", WorkflowTriggerType::BeforeTurn => "Run automatically before the next user turn.", WorkflowTriggerType::AfterTurn => "Run automatically after the current turn finishes.", + WorkflowTriggerType::FileWatch => { + "Run automatically when workspace files change. Overlapping runs are skipped." + } WorkflowTriggerType::Idle => "Run after the workspace has been idle for a duration.", WorkflowTriggerType::Interval => "Run on a fixed repeating interval.", WorkflowTriggerType::Cron => "Run on a cron schedule.", @@ -1746,6 +1752,10 @@ fn workflow_trigger_matches_type( &WorkflowTriggerKind::AfterTurn, WorkflowTriggerType::AfterTurn ) + | ( + &WorkflowTriggerKind::FileWatch, + WorkflowTriggerType::FileWatch + ) | (&WorkflowTriggerKind::Idle { .. }, WorkflowTriggerType::Idle) | ( &WorkflowTriggerKind::Interval { .. }, @@ -1770,7 +1780,8 @@ fn workflow_trigger_parameter_metadata( } WorkflowTriggerKind::Manual | WorkflowTriggerKind::BeforeTurn - | WorkflowTriggerKind::AfterTurn => None, + | WorkflowTriggerKind::AfterTurn + | WorkflowTriggerKind::FileWatch => None, } } diff --git a/codex-rs/tui/src/app/workflow_definition.rs b/codex-rs/tui/src/app/workflow_definition.rs index aa4bab296..886639db8 100644 --- a/codex-rs/tui/src/app/workflow_definition.rs +++ b/codex-rs/tui/src/app/workflow_definition.rs @@ -101,6 +101,7 @@ pub(crate) enum WorkflowTriggerKind { Manual, BeforeTurn, AfterTurn, + FileWatch, Idle { after: String }, Interval { every: String }, Cron { cron: String }, diff --git a/codex-rs/tui/src/app/workflow_editor.rs b/codex-rs/tui/src/app/workflow_editor.rs index 57e527f18..21585964e 100644 --- a/codex-rs/tui/src/app/workflow_editor.rs +++ b/codex-rs/tui/src/app/workflow_editor.rs @@ -8,6 +8,7 @@ use serde_yaml::Value as YamlValue; use super::workflow_definition::WorkflowContextMode; use super::workflow_definition::WorkflowResponseMode; use super::workflow_definition::WorkflowStep; +use super::workflow_yaml::serialize_yaml_value; use crate::app_event::WorkflowJobEditableField; use crate::app_event::WorkflowTriggerEditableField; use crate::app_event::WorkflowTriggerType; @@ -344,7 +345,7 @@ fn load_yaml_document(workflow_path: &Path) -> Result { } fn save_yaml_document(workflow_path: &Path, document: &YamlValue) -> Result<(), String> { - let contents = serde_yaml::to_string(document).map_err(|err| { + let contents = serialize_yaml_value(document).map_err(|err| { format!( "failed to serialize workflow file `{}`: {err}", workflow_path.display() @@ -471,8 +472,9 @@ fn mutate_trigger( } fn serialize_yaml_fragment(value: &impl serde::Serialize) -> Result { - let text = serde_yaml::to_string(value).map_err(|err| err.to_string())?; - Ok(text.trim_start_matches("---\n").trim_end().to_string()) + let value = serde_yaml::to_value(value).map_err(|err| err.to_string())?; + let text = serialize_yaml_value(&value)?; + Ok(text.trim_end().to_string()) } fn clear_trigger_type_fields(trigger: &mut Mapping) { @@ -486,6 +488,7 @@ fn trigger_type_key(trigger_type: WorkflowTriggerType) -> &'static str { WorkflowTriggerType::Manual => "manual", WorkflowTriggerType::BeforeTurn => "before_turn", WorkflowTriggerType::AfterTurn => "after_turn", + WorkflowTriggerType::FileWatch => "file_watch", WorkflowTriggerType::Idle => "idle", WorkflowTriggerType::Interval => "interval", WorkflowTriggerType::Cron => "cron", @@ -501,7 +504,8 @@ fn trigger_type_parameter_defaults( WorkflowTriggerType::Cron => Some(("cron", "0 * * * *")), WorkflowTriggerType::Manual | WorkflowTriggerType::BeforeTurn - | WorkflowTriggerType::AfterTurn => None, + | WorkflowTriggerType::AfterTurn + | WorkflowTriggerType::FileWatch => None, } } @@ -518,7 +522,7 @@ fn trigger_parameter_key_from_mapping(trigger: &Mapping) -> Option<&'static str> Some("idle") => Some("after"), Some("interval") => Some("every"), Some("cron") => Some("cron"), - Some("manual" | "before_turn" | "after_turn") | None => None, + Some("manual" | "before_turn" | "after_turn" | "file_watch") | None => None, Some(_) => None, } } @@ -588,6 +592,8 @@ jobs: assert!(enabled_again); let enabled_text = fs::read_to_string(&path).unwrap(); assert!(enabled_text.contains("enabled: true")); + assert!(enabled_text.contains("prompt: |")); + assert!(enabled_text.contains("send an update")); } #[test] @@ -652,6 +658,7 @@ jobs: "- prepare\n- publish" ); let steps = job_field_seed(&path, "notify", WorkflowJobEditableField::Steps).unwrap(); + assert!(steps.contains("prompt: |")); assert!(steps.contains("summarize the changes")); assert!(steps.contains("cargo test -p codex-tui")); } @@ -700,5 +707,10 @@ jobs: assert!(text.contains("type: cron")); assert!(text.contains("cron: '*/15 * * * *'") || text.contains("cron: \"*/15 * * * *\"")); assert!(text.contains("- review_now")); + + set_trigger_type(&path, "review_now", WorkflowTriggerType::FileWatch).unwrap(); + let file_watch_text = fs::read_to_string(&path).unwrap(); + assert!(file_watch_text.contains("type: file_watch")); + assert!(!file_watch_text.contains("cron:")); } } diff --git a/codex-rs/tui/src/app/workflow_file_watch.rs b/codex-rs/tui/src/app/workflow_file_watch.rs new file mode 100644 index 000000000..2f5c33d0b --- /dev/null +++ b/codex-rs/tui/src/app/workflow_file_watch.rs @@ -0,0 +1,91 @@ +use std::path::Path; +use std::path::PathBuf; + +use notify::Event; +use notify::EventKind; +use notify::RecommendedWatcher; +use notify::RecursiveMode; +use notify::Watcher; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +pub(crate) struct WorkflowFileWatchState { + _watcher: RecommendedWatcher, +} + +impl WorkflowFileWatchState { + pub(crate) fn new(cwd: &Path, app_event_tx: AppEventSender) -> Result { + let mut watcher = + notify::recommended_watcher(move |result: notify::Result| match result { + Ok(event) => { + if !should_forward_event(&event) { + return; + } + let changed_paths = event + .paths + .into_iter() + .filter(|path| path.is_absolute()) + .collect::>(); + if changed_paths.is_empty() { + return; + } + app_event_tx.send(AppEvent::WorkflowWorkspaceFilesChanged { changed_paths }); + } + Err(err) => { + tracing::warn!("workflow file watcher event failed: {err}"); + } + }) + .map_err(|err| format!("failed to create watcher: {err}"))?; + watcher + .watch(cwd, RecursiveMode::Recursive) + .map_err(|err| format!("failed to watch `{}`: {err}", cwd.display()))?; + Ok(Self { _watcher: watcher }) + } +} + +fn should_forward_event(event: &Event) -> bool { + matches!( + event.kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any + ) +} + +pub(crate) fn is_relevant_workspace_change(cwd: &Path, path: &Path) -> bool { + if !path.is_absolute() { + return false; + } + let Ok(relative_path) = path.strip_prefix(cwd) else { + return false; + }; + if relative_path.as_os_str().is_empty() { + return true; + } + !relative_path.starts_with(".git") && !relative_path.starts_with(".codex") +} + +#[cfg(test)] +mod tests { + use super::is_relevant_workspace_change; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn relevant_workspace_change_accepts_regular_file_and_directory_paths() { + let tempdir = tempdir().expect("tempdir"); + let cwd = tempdir.path(); + let file_path = cwd.join("src/main.rs"); + let directory_path = cwd.join("src"); + + assert_eq!(is_relevant_workspace_change(cwd, &file_path), true); + assert_eq!(is_relevant_workspace_change(cwd, &directory_path), true); + assert_eq!( + is_relevant_workspace_change(cwd, cwd.join(".git/index").as_path()), + false, + ); + assert_eq!( + is_relevant_workspace_change(cwd, cwd.join(".codex/workflows/test.yaml").as_path()), + false, + ); + } +} diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index f45f5f540..6f9b8e0b2 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -59,6 +59,12 @@ type BoxFuture<'a, T> = Pin + Send + 'a>>; pub(crate) type WorkflowThreadNotificationChannels = Arc>>>; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkflowTriggerOverlapBehavior { + Queue, + Skip, +} + #[cfg_attr(not(test), allow(dead_code))] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum BackgroundWorkflowRunTarget { @@ -66,6 +72,7 @@ pub(crate) enum BackgroundWorkflowRunTarget { workflow_name: String, trigger_id: String, phase_context: OwnedWorkflowPhaseContext, + overlap_behavior: WorkflowTriggerOverlapBehavior, }, Job { workflow_name: String, @@ -579,6 +586,7 @@ impl App { workflow_name, trigger_id, OwnedWorkflowPhaseContext::default(), + WorkflowTriggerOverlapBehavior::Queue, ) { TriggerRunDispatch::Started => Arc::new(history_cell::new_info_event( "Workflow trigger started".to_string(), @@ -588,6 +596,10 @@ impl App { "Workflow trigger queued".to_string(), Some(label), )), + TriggerRunDispatch::Skipped => Arc::new(history_cell::new_info_event( + "Workflow trigger skipped".to_string(), + Some(label), + )), } } @@ -622,6 +634,7 @@ impl App { workflow_name, trigger_id, phase_context.into(), + WorkflowTriggerOverlapBehavior::Queue, ) { TriggerRunDispatch::Started => Arc::new(history_cell::new_info_event( "Workflow trigger started".to_string(), @@ -631,9 +644,83 @@ impl App { "Workflow trigger queued".to_string(), Some(label), )), + TriggerRunDispatch::Skipped => Arc::new(history_cell::new_info_event( + "Workflow trigger skipped".to_string(), + Some(label), + )), + } + } + + pub(crate) fn start_file_watch_workflow_trigger_run( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + trigger_id: String, + ) -> Option> { + let label = format!("{workflow_name} · {trigger_id}"); + match self.queue_or_start_trigger_run( + app_server, + workflow_name, + trigger_id, + OwnedWorkflowPhaseContext::default(), + WorkflowTriggerOverlapBehavior::Skip, + ) { + TriggerRunDispatch::Started => Some(Arc::new(history_cell::new_info_event( + "Workflow trigger started".to_string(), + Some(label), + ))), + TriggerRunDispatch::Queued => Some(Arc::new(history_cell::new_info_event( + "Workflow trigger queued".to_string(), + Some(label), + ))), + TriggerRunDispatch::Skipped => None, } } + pub(crate) fn handle_workspace_file_changes_for_workflows( + &mut self, + app_server: &AppServerSession, + changed_paths: &[PathBuf], + ) -> Vec> { + let Some(primary_thread_id) = self.primary_thread_id else { + return Vec::new(); + }; + if changed_paths.is_empty() { + return Vec::new(); + } + + let registry = match load_workflow_registry(self.config.cwd.as_path()) { + Ok(registry) => registry, + Err(error) => { + self.chat_widget.add_error_message(format!( + "Workflow file_watch failed: failed to load workflows: {error}" + )); + return Vec::new(); + } + }; + + let mut visible_cells = Vec::new(); + for workflow in ®istry.files { + for trigger in &workflow.triggers { + if !trigger.enabled || !matches!(trigger.kind, WorkflowTriggerKind::FileWatch) { + continue; + } + + let Some(cell) = self.start_file_watch_workflow_trigger_run( + app_server, + workflow.name.clone(), + trigger.id.clone(), + ) else { + continue; + }; + if let Some(cell) = self.record_workflow_history_cell(primary_thread_id, cell) { + visible_cells.push(cell); + } + } + } + visible_cells + } + pub(crate) fn dispatch_next_queued_trigger_run(&mut self, app_server: &AppServerSession) { if self.workflow_scheduler.has_running_trigger_run() { return; @@ -647,6 +734,7 @@ impl App { workflow_name: next.workflow_name, trigger_id: next.trigger_id, phase_context: next.phase_context, + overlap_behavior: WorkflowTriggerOverlapBehavior::Queue, }, ); } @@ -752,7 +840,19 @@ impl App { workflow_name: String, trigger_id: String, phase_context: OwnedWorkflowPhaseContext, + overlap_behavior: WorkflowTriggerOverlapBehavior, ) -> TriggerRunDispatch { + if matches!(overlap_behavior, WorkflowTriggerOverlapBehavior::Skip) + && (self + .workflow_scheduler + .has_active_trigger_run(&workflow_name, &trigger_id) + || self + .workflow_scheduler + .has_queued_trigger_run(&workflow_name, &trigger_id)) + { + return TriggerRunDispatch::Skipped; + } + if self.workflow_scheduler.has_running_trigger_run() { self.workflow_scheduler .enqueue_trigger_run(workflow_name, trigger_id, phase_context); @@ -766,6 +866,7 @@ impl App { workflow_name, trigger_id, phase_context, + overlap_behavior, }, ); TriggerRunDispatch::Started @@ -819,6 +920,7 @@ impl App { enum TriggerRunDispatch { Started, Queued, + Skipped, } async fn run_background_workflow( @@ -850,6 +952,7 @@ async fn run_background_workflow_selection( workflow_name, trigger_id, phase_context, + overlap_behavior: _, } => { let workflow = registry .files @@ -994,9 +1097,10 @@ async fn run_workflow_job( let mut step_outputs = Vec::new(); let mut last_prompt_response = None; for step in &job.config.steps { - let attempts = step.retry_attempts(); - let mut last_error = None; - for attempt in 1..=attempts { + let configured_attempts = step.retry_attempts(); + let mut attempt = 1; + let mut used_capacity_retry = false; + let step_error = loop { if cancellation.is_some_and(CancellationToken::is_cancelled) { if let Some(thread) = thread.as_ref() { let _ = client.unsubscribe_thread(thread.thread_id.clone()).await; @@ -1018,24 +1122,29 @@ async fn run_workflow_job( last_prompt_response = Some(output.clone()); } step_outputs.push(output); - last_error = None; - break; + break None; } Ok(None) => { - last_error = None; - break; + break None; } Err(error) => { - last_error = Some(error); - if attempt < attempts - && !matches!(last_error, Some(WorkflowRunError::Cancelled)) - { + let should_retry = !matches!(error, WorkflowRunError::Cancelled) + && (attempt < configured_attempts + || (!used_capacity_retry + && should_retry_selected_model_capacity_error(&error))); + if should_retry { + if attempt >= configured_attempts { + used_capacity_retry = true; + } sleep(retry_backoff_delay(attempt)).await; + attempt = attempt.saturating_add(1); + continue; } + break Some(error); } } - } - if let Some(error) = last_error { + }; + if let Some(error) = step_error { if let Some(thread) = thread.as_ref() { let _ = client.unsubscribe_thread(thread.thread_id.clone()).await; } @@ -1349,6 +1458,14 @@ fn retry_backoff_delay(attempt: u32) -> Duration { Duration::from_secs(seconds.max(1)) } +fn should_retry_selected_model_capacity_error(error: &WorkflowRunError) -> bool { + matches!( + error, + WorkflowRunError::Failed(message) + if message.contains("Selected model is at capacity. Please try a different model.") + ) +} + fn update_last_workflow_agent_message( last_agent_message: &mut Option, notification: &ItemCompletedNotification, @@ -1556,6 +1673,67 @@ jobs: ); } + #[tokio::test] + async fn prompt_workflow_job_retries_selected_model_capacity_once() { + let tempdir = tempdir().expect("tempdir"); + let workflows_dir = tempdir.path().join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).expect("workflow dir"); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +jobs: + review_backlog: + steps: + - prompt: summarize the backlog +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(vec![ + Ok(WorkflowTurnState { + status: TurnStatus::Failed, + error: Some( + "Selected model is at capacity. Please try a different model.".to_string(), + ), + last_agent_message: None, + }), + Ok(WorkflowTurnState { + status: TurnStatus::Completed, + error: None, + last_agent_message: Some("workflow reply".to_string()), + }), + ]); + let result = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }, + CancellationToken::new(), + ) + .await; + + match result.outcome { + BackgroundWorkflowRunOutcome::Completed(results) => { + assert_eq!(results.len(), 1); + assert_eq!(results[0].message.as_deref(), Some("workflow reply")); + } + other => panic!("expected completed run, got {other:?}"), + } + assert_eq!( + client.calls.lock().expect("calls lock").clone(), + vec![ + "start_workflow_thread".to_string(), + "start_turn:thr_workflow:Workflow: director\nTrigger: job:review_backlog\nJob: review_backlog\n\nCurrent workflow prompt:\nsummarize the backlog".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "start_turn:thr_workflow:Workflow: director\nTrigger: job:review_backlog\nJob: review_backlog\n\nCurrent workflow prompt:\nsummarize the backlog".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "unsubscribe_thread:thr_workflow".to_string(), + ] + ); + } + #[tokio::test] async fn non_manual_trigger_can_run_now_from_workflow_ui() { let tempdir = tempdir().expect("tempdir"); @@ -1589,6 +1767,7 @@ jobs: workflow_name: "director".to_string(), trigger_id: "follow_up".to_string(), phase_context: OwnedWorkflowPhaseContext::default(), + overlap_behavior: WorkflowTriggerOverlapBehavior::Queue, }, CancellationToken::new(), ) diff --git a/codex-rs/tui/src/app/workflow_scheduler.rs b/codex-rs/tui/src/app/workflow_scheduler.rs index c456df963..33d8710a2 100644 --- a/codex-rs/tui/src/app/workflow_scheduler.rs +++ b/codex-rs/tui/src/app/workflow_scheduler.rs @@ -92,6 +92,25 @@ impl WorkflowSchedulerState { self.running_workflows.values().any(|run| run.is_trigger) } + pub(crate) fn has_active_trigger_run(&self, workflow_name: &str, trigger_id: &str) -> bool { + self.running_workflows.values().any(|run| { + matches!( + &run.target, + BackgroundWorkflowRunTarget::Trigger { + workflow_name: active_workflow_name, + trigger_id: active_trigger_id, + .. + } if active_workflow_name == workflow_name && active_trigger_id == trigger_id + ) + }) + } + + pub(crate) fn has_queued_trigger_run(&self, workflow_name: &str, trigger_id: &str) -> bool { + self.queued_trigger_runs + .iter() + .any(|run| run.workflow_name == workflow_name && run.trigger_id == trigger_id) + } + #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn enqueue_trigger_run( &mut self, diff --git a/codex-rs/tui/src/app/workflow_yaml.rs b/codex-rs/tui/src/app/workflow_yaml.rs new file mode 100644 index 000000000..a2ae39b45 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_yaml.rs @@ -0,0 +1,242 @@ +use serde_yaml::Mapping; +use serde_yaml::Value as YamlValue; + +pub(crate) fn serialize_yaml_value(value: &YamlValue) -> Result { + let mut output = String::new(); + write_yaml_value(&mut output, value, 0)?; + Ok(output) +} + +fn write_yaml_value(output: &mut String, value: &YamlValue, indent: usize) -> Result<(), String> { + match value { + YamlValue::Mapping(mapping) => write_mapping(output, mapping, indent), + YamlValue::Sequence(sequence) => write_sequence(output, sequence, indent), + YamlValue::String(text) if text.contains('\n') => { + write_block_scalar(output, "", text, indent); + Ok(()) + } + _ => { + output.push_str(&" ".repeat(indent)); + output.push_str(&serialize_inline_value(value)?); + output.push('\n'); + Ok(()) + } + } +} + +fn write_mapping(output: &mut String, mapping: &Mapping, indent: usize) -> Result<(), String> { + for (key, value) in mapping { + write_mapping_entry(output, key, value, indent)?; + } + Ok(()) +} + +fn write_mapping_entry( + output: &mut String, + key: &YamlValue, + value: &YamlValue, + indent: usize, +) -> Result<(), String> { + let prefix = format!("{}{}:", " ".repeat(indent), serialize_yaml_key(key)?); + match value { + YamlValue::Mapping(mapping) if mapping.is_empty() => { + output.push_str(&prefix); + output.push_str(" {}\n"); + } + YamlValue::Sequence(sequence) if sequence.is_empty() => { + output.push_str(&prefix); + output.push_str(" []\n"); + } + YamlValue::Mapping(mapping) => { + output.push_str(&prefix); + output.push('\n'); + write_mapping(output, mapping, indent + 2)?; + } + YamlValue::Sequence(sequence) => { + output.push_str(&prefix); + output.push('\n'); + write_sequence(output, sequence, indent + 2)?; + } + YamlValue::String(text) if text.contains('\n') => { + write_block_scalar(output, &prefix, text, indent + 2); + } + _ => { + output.push_str(&prefix); + output.push(' '); + output.push_str(&serialize_inline_value(value)?); + output.push('\n'); + } + } + Ok(()) +} + +fn write_sequence( + output: &mut String, + sequence: &[YamlValue], + indent: usize, +) -> Result<(), String> { + for value in sequence { + match value { + YamlValue::Mapping(mapping) => write_sequence_mapping_item(output, mapping, indent)?, + YamlValue::Sequence(sequence) if sequence.is_empty() => { + output.push_str(&" ".repeat(indent)); + output.push_str("- []\n"); + } + YamlValue::Sequence(sequence) => { + output.push_str(&" ".repeat(indent)); + output.push_str("-\n"); + write_sequence(output, sequence, indent + 2)?; + } + YamlValue::String(text) if text.contains('\n') => { + write_block_scalar( + output, + &format!("{}-", " ".repeat(indent)), + text, + indent + 2, + ); + } + _ => { + output.push_str(&" ".repeat(indent)); + output.push_str("- "); + output.push_str(&serialize_inline_value(value)?); + output.push('\n'); + } + } + } + Ok(()) +} + +fn write_sequence_mapping_item( + output: &mut String, + mapping: &Mapping, + indent: usize, +) -> Result<(), String> { + if mapping.is_empty() { + output.push_str(&" ".repeat(indent)); + output.push_str("- {}\n"); + return Ok(()); + } + + let mut entries = mapping.iter(); + let Some((first_key, first_value)) = entries.next() else { + return Ok(()); + }; + let prefix = format!( + "{}- {}:", + " ".repeat(indent), + serialize_yaml_key(first_key)? + ); + match first_value { + YamlValue::Mapping(mapping) if mapping.is_empty() => { + output.push_str(&prefix); + output.push_str(" {}\n"); + } + YamlValue::Sequence(sequence) if sequence.is_empty() => { + output.push_str(&prefix); + output.push_str(" []\n"); + } + YamlValue::Mapping(mapping) => { + output.push_str(&prefix); + output.push('\n'); + write_mapping(output, mapping, indent + 4)?; + } + YamlValue::Sequence(sequence) => { + output.push_str(&prefix); + output.push('\n'); + write_sequence(output, sequence, indent + 4)?; + } + YamlValue::String(text) if text.contains('\n') => { + write_block_scalar(output, &prefix, text, indent + 4); + } + _ => { + output.push_str(&prefix); + output.push(' '); + output.push_str(&serialize_inline_value(first_value)?); + output.push('\n'); + } + } + + for (key, value) in entries { + write_mapping_entry(output, key, value, indent + 2)?; + } + + Ok(()) +} + +fn write_block_scalar(output: &mut String, prefix: &str, text: &str, content_indent: usize) { + if !prefix.is_empty() { + output.push_str(prefix); + output.push(' '); + } + output.push_str(block_scalar_header(text)); + output.push('\n'); + + let trailing_newlines = text.chars().rev().take_while(|ch| *ch == '\n').count(); + let content = if trailing_newlines == 0 { + text + } else { + &text[..text.len() - trailing_newlines] + }; + + if content.is_empty() { + output.push_str(&" ".repeat(content_indent)); + output.push('\n'); + return; + } + + for line in content.split('\n') { + output.push_str(&" ".repeat(content_indent)); + output.push_str(line); + output.push('\n'); + } +} + +fn block_scalar_header(text: &str) -> &'static str { + let trailing_newlines = text.chars().rev().take_while(|ch| *ch == '\n').count(); + match trailing_newlines { + 0 => "|-", + 1 => "|", + _ => "|+", + } +} + +fn serialize_yaml_key(value: &YamlValue) -> Result { + match value { + YamlValue::String(text) if text.contains('\n') => { + Err("workflow yaml keys cannot be multiline strings".to_string()) + } + _ => serialize_inline_value(value), + } +} + +fn serialize_inline_value(value: &YamlValue) -> Result { + let rendered = serde_yaml::to_string(value).map_err(|err| err.to_string())?; + Ok(rendered.trim_start_matches("---\n").trim_end().to_string()) +} + +#[cfg(test)] +mod tests { + use super::serialize_yaml_value; + use pretty_assertions::assert_eq; + use serde_yaml::Value as YamlValue; + + #[test] + fn serializes_multiline_strings_as_block_scalars() { + let value: YamlValue = serde_yaml::from_str( + r#"steps: + - prompt: "line one\nline two" +"#, + ) + .expect("yaml"); + + assert_eq!( + serialize_yaml_value(&value).expect("serialize"), + concat!( + "steps:\n", + " - prompt: |-\n", + " line one\n", + " line two\n", + ) + ); + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index ec33c5e87..a3085165d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -141,6 +141,7 @@ pub(crate) enum WorkflowTriggerType { Manual, BeforeTurn, AfterTurn, + FileWatch, Idle, Interval, Cron, @@ -447,6 +448,10 @@ pub(crate) enum AppEvent { field: WorkflowTriggerEditableField, }, + WorkflowWorkspaceFilesChanged { + changed_paths: Vec, + }, + StartBtwDiscussion { prompt: String, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 28c247eaa..44a9c990e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -10819,6 +10819,7 @@ impl ChatWidget { self.last_submitted_user_turn.is_some() } + #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn last_submitted_user_turn(&self) -> Option { self.last_submitted_user_turn.clone() } @@ -10830,9 +10831,9 @@ impl ChatWidget { if self.profile_retry_attempted { return false; } - let Some(user_message) = self.last_submitted_user_turn.clone() else { + if self.last_submitted_user_turn.is_none() { return false; - }; + } self.profile_retry_attempted = true; self.submit_pending_steers_after_interrupt = false; @@ -10841,15 +10842,12 @@ impl ChatWidget { history_message, /*hint*/ None, )); - self.submit_user_message(user_message); + self.submit_user_message(UserMessage::from("continue")); true } - pub(crate) fn submit_profile_fallback_retry( - &mut self, - user_message: UserMessage, - history_message: String, - ) { + pub(crate) fn submit_profile_fallback_retry(&mut self, history_message: String) { + let user_message = UserMessage::from("continue"); self.last_submitted_user_turn = Some(user_message.clone()); self.profile_retry_attempted = true; self.submit_pending_steers_after_interrupt = false; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index bb2f040df..f15ddb27d 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -869,6 +869,21 @@ async fn capture_and_restore_thread_input_state_preserves_profile_fallback_retry assert!(chat.retry_last_user_turn_for_profile_fallback( "Retrying the last turn on profile `primary`.".to_string() )); + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + items, + vec![UserInput::Text { + text: "continue".to_string(), + text_elements: Vec::new(), + }] + ); + assert_eq!( + chat.last_submitted_user_turn(), + Some(UserMessage::from("continue")) + ); } #[tokio::test] diff --git a/docs/workflows.md b/docs/workflows.md index 7495620ba..0d3490485 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -77,16 +77,18 @@ The `Type` picker supports: - `Manual` - `Before Turn` - `After Turn` +- `File Watch` - `Idle` - `Interval` - `Cron` Changing the type updates the structured trigger fields in YAML: +- `File Watch` watches the current workspace recursively and fires when a regular file or a directory changes - `Idle` uses `after` - `Interval` uses `every` - `Cron` uses `cron` -- `Manual`, `Before Turn`, and `After Turn` do not require an extra schedule parameter +- `Manual`, `Before Turn`, `After Turn`, and `File Watch` do not require an extra schedule parameter When the current type has a parameter, the trigger page exposes a matching action: @@ -94,6 +96,11 @@ When the current type has a parameter, the trigger page exposes a matching actio - `Edit Interval` - `Edit Cron Schedule` +Behavior notes: + +- `File Watch` uses the same global trigger queue as the other trigger types. +- If the same `file_watch` trigger is already running or already queued, new matching file events are skipped (`overlap=skip`) instead of enqueueing duplicates. + ## Typical Flow 1. Run `/workflow`. From a22d45d637760ef7ec59333dc3661ddcd884fbc3 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 19:19:00 +0800 Subject: [PATCH 59/60] update README --- README.md | 159 +++++++++++++++++++++++++++++++++++------------- README.zh-CN.md | 121 ++++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 41 deletions(-) create mode 100644 README.zh-CN.md diff --git a/README.md b/README.md index 1e44875f2..6fbf930ec 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,137 @@ -

npm i -g @openai/codex
or brew install --cask codex

-

Codex CLI is a coding agent from OpenAI that runs locally on your computer. -

- Codex CLI splash -

-
-If you want Codex in your code editor (VS Code, Cursor, Windsurf), install in your IDE. -
If you want the desktop app experience, run codex app or visit the Codex App page. -
If you are looking for the cloud-based agent from OpenAI, Codex Web, go to chatgpt.com/codex.

+# Codex Enhanced ---- +[中文版本](./README.zh-CN.md) -## Quickstart +> The Codex distribution built for real 24/7 use. -### Installing and running Codex CLI +`codex-enhanced` is a Codex distribution built on top of the OpenAI Codex CLI Rust stack. The goal is not to wrap the agent in more prompt theater. The goal is to turn Codex into an operator surface that can keep running across accounts, sessions, workflows, and external message channels. -Install globally with your preferred package manager: +Website: `https://codex-enhanced.com` -```shell -# Install using npm -npm install -g @openai/codex -``` +## Why codex-enhanced + +Most AI CLIs compete on three things: + +- models +- UI polish + +This distribution moves in the opposite direction: + +- assume the base agent is already strong enough +- invest in multi-account routing, profile switching, long-session recovery, task orchestration, Feishu entrypoints, and cleaner operator UX +- make Codex feel less like a terminal chatbot and more like a control surface that can receive messages, pick up tasks, and stay aligned over time + +If you need a Codex you can actually keep online and keep using, that is the point of this distribution. + +## Examples + +### 1. Multi-subscription and multi-profile operation, not manual env switching + +`/profile` opens a dedicated management panel with support for: + +- named profiles +- runtime switching +- fallback routes +- profile switching policies for rate limits, auth failures, and service overload + +### 2. Conversations can be orchestrated as jobs + +`/workflow` manages `.codex/workflows/*.yaml` directly, with support for: + +- `before_turn` +- `after_turn` +- `manual` +- `file_watch` +- `idle` +- `interval` +- `cron` + +That makes Codex more than a prompt-response loop. It becomes a node inside a repeatable workflow. + +Docs: + +- [`docs/workflows.md`](./docs/workflows.md) + +### 3. Any session can be resumed instead of restarted -```shell -# Install using Homebrew -brew install --cask codex +`/resume` and the thread/session plumbing let you reconnect to saved work instead of rebuilding context from scratch every time. + +### 4. The terminal is not the only entrypoint + +`/clawbot` connects workspace-local Feishu sessions, thread binding, unread message queues, and reply forwarding into the same loop. A Feishu chat can be bound to the current thread, external messages can enter Codex, and final replies can be sent back out. + +## Install + +The primary install path for this distribution is PyPI: + +```bash +pip3 install -U codex-enhanced +codex-enhanced ``` -Then simply run `codex` to get started. +## Capability Boundaries and Layering + +### What it is built to solve + +This distribution is strong at connecting the agent to real operating workflows, not at reinventing the underlying model platform. + +Current strengths: + +- multi-subscription account management +- multi-profile API routing and fallback +- long-session recovery and continuity +- workspace-local workflow orchestration +- Feishu clawbot integration +- local TUI information shaping and visibility control +- stronger alignment flows via `question` and keyboard chord support + +### What it is not trying to be + +- a replacement for every official hosted or distributed Codex surface +- a general-purpose IM automation hub beyond the current Feishu focus +- a zero-configuration black box for business workflow automation + +## Shipping Today + +These are already implemented in the repository: + +- multi-subscription account management and runtime account display +- multi-profile API management and `/profile` route switching +- `/workflow` task orchestration +- `/resume` for arbitrary saved sessions +- `/settings` for UI information control +- `/clawbot` for Feishu send and receive flows +- `pypi-release` distribution pipeline +- stronger `question`-based alignment interactions +- chord shortcut support + +## How It Works + +The system is designed to stay inspectable: -
-You can also go to the latest GitHub Release and download the appropriate binary for your platform. +- profile routing state is persisted in `accounts/profile-router.json` +- workflows live directly in `.codex/workflows/*.yaml` +- Feishu clawbot sessions, bindings, and unread queues are managed by a workspace-local runtime +- the `question` tool collects structured answers in the TUI instead of falling back to guesswork in free text +- UI and interaction changes are heavily covered by snapshot tests for direct review -Each GitHub Release contains many executables, but in practice, you likely want one of these: +Useful docs and examples: -- macOS - - Apple Silicon/arm64: `codex-aarch64-apple-darwin.tar.gz` - - x86_64 (older Mac hardware): `codex-x86_64-apple-darwin.tar.gz` -- Linux - - x86_64: `codex-x86_64-unknown-linux-musl.tar.gz` - - arm64: `codex-aarch64-unknown-linux-musl.tar.gz` +- [`docs/workflows.md`](./docs/workflows.md) +- [`docs/tui-request-user-input.md`](./docs/tui-request-user-input.md) -Each archive contains a single entry with the platform baked into the name (e.g., `codex-x86_64-unknown-linux-musl`), so you likely want to rename it to `codex` after extracting it. +## Project Attribution -
+This project does not start from zero. It builds on the OpenAI Codex CLI Rust, TUI, and app-server foundation, then pushes harder on the parts that matter in sustained use: account operations, session continuity, workflows, Feishu entrypoints, lower-noise UI, and operator ergonomics. -### Using Codex with your ChatGPT plan +## Closing -Run `codex` and select **Sign in with ChatGPT**. We recommend signing into your ChatGPT account to use Codex as part of your Plus, Pro, Team, Edu, or Enterprise plan. [Learn more about what's included in your ChatGPT plan](https://help.openai.com/en/articles/11369540-codex-in-chatgpt). +If you only need a Codex that chats in a terminal, the official distribution is already enough. -You can also use Codex with an API key, but this requires [additional setup](https://developers.openai.com/codex/auth#sign-in-with-an-api-key). +If you need a Codex that can stay online across accounts, inputs, tasks, and long-running sessions, the opening line still applies: -## Docs +**The Codex distribution built for real 24/7 use.** -- [**Codex Documentation**](https://developers.openai.com/codex) -- [**Contributing**](./docs/contributing.md) -- [**Installing & building**](./docs/install.md) -- [**Open source fund**](./docs/open-source-fund.md) +## License -This repository is licensed under the [Apache-2.0 License](LICENSE). +Apache-2.0 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 000000000..913ec9c9c --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,121 @@ +# Codex Enhanced + +[English](./README.md) + +> 真正 24/7 使用的 Codex 发行版。 + +`codex-enhanced` 是一个基于 OpenAI Codex CLI Rust 技术栈继续演进的 Codex 发行版。它的重点不是再包一层 prompt,而是把 Codex 变成一个更适合长期在线、多入口接入、跨账户运转的工作台 + +网站: `https://codex-enhanced.com` + +## 定位 + +大多数 AI CLI 在卷三件事: + +- 模型 +- UI + +这个发行版反过来做: + +- 默认假设底层 agent 已经够强 +- 把工程重点放在多账户路由、多 profile 切换、长会话恢复、任务编排、飞书入口和更低噪音的操作体验 +- 让 Codex 从“终端里的聊天框”变成“可以接消息、接任务、接上下文的控制台” + +如果你要的是一个可以真正长期在线使用的 Codex,这就是这个发行版的目标。 + +## 效果示例 + +### 1. 多 subscription / 多 profile,不是手动改环境变量 + +`/profile` 会打开独立管理面板,支持: + +- 命名 profile +- 当前 runtime 切换 +- fallback route +- 在限流、鉴权失败、服务过载时按策略切换 profile + +### 2. 对话不只是聊天,而是可编排任务 + +`/workflow` 可以直接管理 `.codex/workflows/*.yaml`,支持: + +- `before_turn` +- `after_turn` +- `manual` +- `file_watch` +- `idle` +- `interval` +- `cron` + +这让 Codex 不只是“问一句答一句”,而是可以成为工作流里的一个节点。 + +文档: + +- [`docs/workflows.md`](./docs/workflows.md) + +### 3. 不是反复重开,而是随时续上任意 session + +`/resume` 和对应的 thread/session 基础设施允许你把保存过的工作重新接起来,而不是每次都重建上下文。 + +### 4. 终端不是唯一入口,飞书也能接进来 + +`/clawbot` 把 workspace-local 的 Feishu 会话、线程绑定、未读消息队列和回复回传串到同一个闭环里。你可以把飞书会话绑定到当前线程,让外部消息进入 Codex,再把最终回复发回飞书。 + + +## 安装 + +这个发行版当前主推的安装方式是 PyPI: + +```bash +pip3 install -U codex-enhanced +codex-enhanced +``` + +## 能力边界 + +### 它主要解决什么 + +这个发行版的强项是把 agent 接进真实工作流,而不是重造底层模型平台。 + +当前重点能力: + +- 多 subscription 账户管理 +- 多 profile API 路由和 fallback +- 长会话恢复和连续性 +- workspace-local workflow orchestration +- Feishu clawbot 接入 +- 本地 TUI 信息展示裁剪和控制 +- 通过 `question` 和 chord 快捷键增强对齐与操作效率 + +### 它不打算解决什么 + +- 替代官方原版的全部托管和分发形态 +- 在 Feishu 之外直接变成通用 IM 自动化中台 +- 把业务流程自动化做成零配置黑盒 + +## 已有成果 + +下面这些能力都已经在仓库里落地: + +- 多 subscription 账户管理和 runtime account 展示 +- 多 profile API 管理和 `/profile` 路由切换 +- `/workflow` 任务编排 +- `/resume` 恢复任意保存会话 +- `/settings` 控制 UI 展示信息 +- `/clawbot` 对接飞书收发消息 +- `pypi-release` 分发流水线 +- 更强的 `question` 式对齐交互 +- chord 快捷键支持 + +## 最后 + +这个项目不是从零开始。它建立在 OpenAI Codex CLI 的 Rust、TUI 和 app-server 基础之上,然后把精力进一步放到长期使用更痛的部分: 账户运营、session continuity、workflow、飞书入口、更低噪音的 UI 和 operator ergonomics。 + +如果你只需要一个在终端里聊天的 Codex,官方原版已经够用。 + +如果你需要一个能长期在线,跨账户、跨入口、跨任务持续运转的 Codex,那开头那句话就是结论: + +**真正 24/7 使用的 Codex 发行版。** + +## License + +Apache-2.0 From 3ec6887b8c573244e0468d4bf7dc9b33b6094fee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:28:23 +0000 Subject: [PATCH 60/60] build(deps): bump unicode-segmentation from 1.12.0 to 1.13.2 in /codex-rs Dependabot couldn't find the original pull request head commit, 7621ef6b4eaa004e64aa3f83aac90261b2b47ca5. --- codex-rs/Cargo.lock | 4 ++-- codex-rs/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bc69630f5..efa063688 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -11391,9 +11391,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 0504ac247..af5d3de90 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -342,7 +342,7 @@ tree-sitter-bash = "0.25" ts-rs = "11" tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] } uds_windows = "1.1.0" -unicode-segmentation = "1.12.0" +unicode-segmentation = "1.13.2" unicode-width = "0.2" url = "2" urlencoding = "2.1"