From e76c6dee24a9500803cc2edebfa38b023b89eb65 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 02:13:25 +0800 Subject: [PATCH 01/83] 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 8c745106c..e0ad982ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,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 }} @@ -56,6 +57,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 4da750b7e..baac704a7 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -136,7 +136,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 b7809c7464d4af424ee1c9a2f401bc56f37e32c5 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 02:16:16 +0800 Subject: [PATCH 02/83] 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 baac704a7..109ba0934 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -136,23 +136,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 20846e4e6625d8ec5a446f79bbc268ee2228aa46 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 03:00:06 +0800 Subject: [PATCH 03/83] 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 109ba0934..654539f99 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -140,7 +140,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 f13a386f283ccc3756a564a4e9917925d295dacb Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 05:19:39 +0800 Subject: [PATCH 04/83] 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 654539f99..e6c6c29ab 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -154,7 +154,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 81da514cfa9a72e201ef38b38cd2b9637ec2e78c Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 06:42:29 +0800 Subject: [PATCH 05/83] 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 a1d42a057..d858ac1f5 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -241,7 +241,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 e6c6c29ab..807e2d9a5 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -157,6 +157,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 562db014d90e8b17a2db46e8bca6f4c17f977f61 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 07:03:42 +0800 Subject: [PATCH 06/83] 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 3a49a22c2..085bfc8a7 100644 --- a/.bazelrc +++ b/.bazelrc @@ -137,6 +137,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 d858ac1f5..ed6706681 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 @@ -248,6 +253,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 d06df85d910f78753e29bab002557a7336aea7ce Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 07:26:55 +0800 Subject: [PATCH 07/83] 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 807e2d9a5..f289752ba 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -153,19 +153,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 } @@ -176,24 +171,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 56fb950dd77f43cb3ebdc05f25f61065c8ed4cb6 Mon Sep 17 00:00:00 2001 From: piping Date: Sat, 4 Apr 2026 22:46:10 +0800 Subject: [PATCH 08/83] 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 | 2 + codex-rs/tui/src/profile_router.rs | 246 +++++++++++++++++++ 8 files changed, 652 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 f421db8af..d2e2a1553 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 ab20b2214..0d7a27323 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -54,6 +54,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; @@ -1173,6 +1177,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, @@ -4529,6 +4724,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 934baac23..6ce6be5b0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -31,6 +31,7 @@ use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; use crate::history_cell::HistoryCell; use crate::legacy_core::plugins::PluginCapabilitySummary; +use crate::profile_router::ProfileFallbackAction; use codex_config::types::ApprovalsReviewer; use codex_features::Feature; @@ -297,6 +298,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 e0812e488..6e0221d5d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -68,6 +68,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; @@ -613,6 +617,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 { @@ -981,6 +992,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 @@ -2879,6 +2892,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) @@ -4967,6 +4995,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 @@ -5396,6 +5426,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. @@ -6730,6 +6770,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) @@ -10199,6 +10254,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() @@ -10468,6 +10527,90 @@ impl ChatWidget { self.config.memories = config.memories.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 9e365afa7..55c0fa5f1 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -292,6 +292,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 a5efcd1ea..429efce02 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -142,6 +142,8 @@ pub(crate) mod onboarding; mod oss_selection; mod pager_overlay; pub(crate) mod public_widgets; +mod profile_router; +pub(crate) mod public_widgets; mod render; mod resume_picker; mod selection_list; 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 d755fb633822ac837a145f136b46efe2e877d428 Mon Sep 17 00:00:00 2001 From: piping Date: Sat, 4 Apr 2026 23:11:45 +0800 Subject: [PATCH 09/83] 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 0d7a27323..e414ebf6e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1162,6 +1162,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(()) @@ -1181,6 +1182,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, @@ -1228,6 +1236,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, @@ -2178,6 +2220,7 @@ impl App { async fn submit_active_thread_op( &mut self, + tui: &mut tui::Tui, app_server: &mut AppServerSession, op: AppCommand, ) -> Result<()> { @@ -2187,11 +2230,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, @@ -2209,6 +2253,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? @@ -4770,10 +4820,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 } => { @@ -10961,6 +11012,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 6e0221d5d..aefa9e1f3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1079,6 +1079,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, @@ -3285,6 +3287,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(), @@ -3339,6 +3343,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(); @@ -3352,6 +3358,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 8cafb54dc..39343f4de 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -797,6 +797,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, @@ -814,6 +816,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 c42ec4fba..7f23f0f12 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -361,6 +361,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 8e206869bc54a105fc37f75bf663df44ee136a21 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 23:20:32 +0800 Subject: [PATCH 10/83] 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 e414ebf6e..4a1bc8b48 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -180,6 +180,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), @@ -1212,18 +1213,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); @@ -1236,6 +1249,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, @@ -11802,6 +11870,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 c03758d8b6cf7dec22b3849de6af41d7f2fca898 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 02:04:25 +0800 Subject: [PATCH 11/83] 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 32fb25ad3..c7ec615a4 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::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; @@ -502,6 +503,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 { @@ -510,6 +533,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; @@ -523,6 +547,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!(); @@ -688,6 +873,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(), @@ -695,7 +887,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( @@ -834,16 +1026,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, @@ -860,16 +1057,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( @@ -1565,6 +1767,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 { @@ -1753,6 +1961,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, } } @@ -1764,12 +1973,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 4a1bc8b48..46c3b967c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -287,6 +287,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 @@ -299,6 +323,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, } @@ -309,6 +334,7 @@ impl AppExitInfo { thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::Fatal(message.into()), } } @@ -323,6 +349,7 @@ pub(crate) enum AppRunControl { #[derive(Debug, Clone)] pub enum ExitReason { UserRequested, + RespawnRequested, Fatal(String), } @@ -972,6 +999,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, }); } @@ -4098,6 +4126,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_settings( @@ -4491,6 +4520,7 @@ impl App { thread_id: resumable_thread.as_ref().map(|thread| thread.thread_id), thread_name: resumable_thread.and_then(|thread| thread.thread_name), update_action: app.pending_update_action, + respawn_with_yolo: should_respawn_with_yolo(&app.config), exit_reason, }) } @@ -6215,6 +6245,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) + } } } @@ -11845,6 +11879,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 6ce6be5b0..c1ac05ce0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -648,6 +648,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 429efce02..1d0b9246f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1067,6 +1067,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: Some(action), + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1144,6 +1145,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1188,6 +1190,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." )), @@ -1255,6 +1258,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1322,6 +1326,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1367,6 +1372,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } From b4f1e368f38406e584c88cfbeb80290ad1a0ac2f Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 02:09:58 +0800 Subject: [PATCH 12/83] 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 76188da5b..be94fda55 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2975,6 +2975,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 65f35b665..7d75738ad 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -55,6 +55,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 1d0b9246f..836cb0baf 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1035,8 +1035,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 @@ -1046,6 +1044,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 8f6d5ba1a..05562b53a 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -125,11 +125,13 @@ pub(crate) mod announcement { use chrono::NaiveDate; use chrono::Utc; use codex_protocol::account::PlanType; + 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(); const CURRENT_OS: TargetOs = TargetOs::current(); @@ -207,16 +209,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( From 19e31c4b9e13cff605927f1ef052cc6b768d1cbf Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 08:24:33 +0800 Subject: [PATCH 13/83] 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 | 172 +++++++++++--- .../src/request_user_input_tool_tests.rs | 222 +++++++++++++++++- codex-rs/tools/src/tool_registry_plan.rs | 35 ++- .../tools/src/tool_registry_plan_tests.rs | 20 +- 13 files changed, 622 insertions(+), 125 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2fece4454..8b29aef44 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1096,9 +1096,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 e8682d732..712124abc 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -839,7 +839,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(()) } @@ -924,7 +928,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 69d222aa8..912df0a10 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -7,8 +7,9 @@ use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use codex_protocol::protocol::SessionSource; 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 { @@ -27,6 +28,7 @@ impl ToolHandler for RequestUserInputHandler { session, turn, call_id, + tool_name, payload, .. } = invocation; @@ -35,7 +37,7 @@ 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" ))); } }; @@ -47,28 +49,28 @@ impl ToolHandler for RequestUserInputHandler { } 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 9de9c81a9..3d0ce8c94 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -414,6 +414,7 @@ async fn test_build_specs_gpt5_codex_default() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -439,6 +440,7 @@ async fn test_build_specs_gpt51_codex_default() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -466,6 +468,7 @@ async fn test_build_specs_gpt5_codex_unified_exec_web_search() { "exec_command", "write_stdin", "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -493,6 +496,7 @@ async fn test_build_specs_gpt51_codex_unified_exec_web_search() { "exec_command", "write_stdin", "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -518,6 +522,7 @@ async fn test_gpt_5_1_codex_max_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -543,6 +548,7 @@ async fn test_codex_5_1_mini_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -568,6 +574,7 @@ async fn test_gpt_5_defaults() { "shell", &[ "update_plan", + "question", "request_user_input", "web_search", "image_generation", @@ -592,6 +599,7 @@ async fn test_gpt_5_1_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -619,6 +627,7 @@ async 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 b7c6a57d2..c4e483f4b 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -75,9 +75,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 ad0d4db55..6f931733b 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(), @@ -22,14 +66,15 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { ), ]); - let options_schema = JsonSchema::array(JsonSchema::object( +<<<<<<< HEAD + let options_schema = JsonSchema::array( + JsonSchema::object( option_props, Some(vec!["label".to_string(), "description".to_string()]), Some(false.into()), - ), 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(), - )); + ), + Some(schema.options_description.to_string()), + ); let question_props = BTreeMap::from([ ( @@ -46,9 +91,7 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { ), ( "question".to_string(), - JsonSchema::string(Some( - "Single-sentence prompt shown to the user.".to_string(), - )), + JsonSchema::string(Some(schema.prompt_description.to_string())), ), ("options".to_string(), options_schema), ]); @@ -56,21 +99,26 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { let questions_schema = JsonSchema::array( JsonSchema::object( question_props, - Some(vec![ - "id".to_string(), - "header".to_string(), - "question".to_string(), - "options".to_string(), - ]), + Some({ + let mut required = vec![ + "id".to_string(), + "header".to_string(), + "question".to_string(), + ]; + if schema.options_required { + required.push("options".to_string()); + } + required + }), Some(false.into()), ), - Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()), + Some(schema.questions_description.to_string()), ); 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, @@ -83,6 +131,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, @@ -97,28 +149,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, ) } @@ -127,10 +232,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 68c2639e4..ef1115913 100644 --- a/codex-rs/tools/src/request_user_input_tool_tests.rs +++ b/codex-rs/tools/src/request_user_input_tool_tests.rs @@ -1,15 +1,117 @@ use super::*; use crate::JsonSchema; 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, @@ -89,6 +191,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!( @@ -128,6 +244,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!( @@ -139,3 +267,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 ac11a2c4b..a6b98f1c6 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::ResponsesApiNamespace; use crate::ResponsesApiNamespaceTool; @@ -33,6 +34,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; @@ -58,6 +60,7 @@ use crate::create_write_stdin_tool; use crate::default_namespace_description; 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; @@ -230,17 +233,27 @@ pub fn build_tool_registry_plan( plan.register_handler("js_repl_reset", ToolHandlerKind::JsReplReset); } - plan.push_spec( - create_request_user_input_tool(request_user_input_tool_description( - config.default_mode_request_user_input, - )), - /*supports_parallel_tool_calls*/ false, - config.code_mode_enabled, - ); - plan.register_handler( - REQUEST_USER_INPUT_TOOL_NAME, - ToolHandlerKind::RequestUserInput, - ); + 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, + )), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + plan.register_handler( + REQUEST_USER_INPUT_TOOL_NAME, + ToolHandlerKind::RequestUserInput, + ); + } if config.request_permissions_tool_enabled { plan.push_spec( diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index cc8c05dd9..65f27bf6a 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -88,6 +88,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 { @@ -506,13 +507,14 @@ fn test_build_specs_agent_job_worker_tools_enabled() { "close_agent", "spawn_agents_on_csv", "report_agent_job_result", - REQUEST_USER_INPUT_TOOL_NAME, ], ); + 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(); @@ -532,6 +534,11 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { /*deferred_mcp_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, @@ -555,6 +562,11 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { /*deferred_mcp_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, @@ -2101,6 +2113,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 dd58a640025e9ba324ba1869bce874c305b24da8 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 09:04:32 +0800 Subject: [PATCH 14/83] 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 | 34 ++ codex-rs/tools/src/lib.rs | 2 + codex-rs/tools/src/tool_registry_plan.rs | 33 +- .../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, 1156 insertions(+), 1 deletion(-) 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 6b48680b8..88bcb6a7c 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(crate) 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; @@ -37,12 +39,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 1a1a9f2e5..a3a50fe1b 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -80,12 +80,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; @@ -206,6 +208,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()); } @@ -227,6 +232,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 3d0ce8c94..c952e95ce 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -456,6 +456,40 @@ async fn test_build_specs_gpt51_codex_default() { .await; } +#[tokio::test] +async fn test_build_specs_gpt5_codex_unified_exec_web_search() { +#[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)); +} + #[tokio::test] async 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 c4e483f4b..615f8a334 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -144,7 +144,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 a6b98f1c6..73f8e1f18 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -26,6 +26,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; @@ -35,6 +36,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; @@ -325,7 +327,36 @@ pub fn build_tool_registry_plan( && config .experimental_supported_tools .iter() - .any(|tool| tool == "list_dir") + .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.has_environment + && 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 + .has_environment + && config + .experimental_supported_tools + .iter() + .any(|tool| tool == "list_dir") { plan.push_spec( create_list_dir_tool(), diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 65f27bf6a..e5b49f07b 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -1072,6 +1072,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 b9d66a0c2..deb19e6ad 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 b0f93c972..928808a82 100644 --- a/codex-rs/tools/src/utility_tool.rs +++ b/codex-rs/tools/src/utility_tool.rs @@ -39,6 +39,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 2984d02f4..b1f69b38c 100644 --- a/codex-rs/tools/src/utility_tool_tests.rs +++ b/codex-rs/tools/src/utility_tool_tests.rs @@ -47,6 +47,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 a2ba589852d651ee2c379fba156444bd2c7c106c Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 20:06:16 +0800 Subject: [PATCH 15/83] 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 523b1ed35..fc76e32ca 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -80,6 +80,14 @@ impl ToolHandler for Handler { .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 @@ -182,6 +190,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 9c2740d48..a6cc6d011 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 10f41d97e..a416081d0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -46,6 +46,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; @@ -637,6 +638,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; @@ -828,6 +927,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 03287b20a..51c44a5c5 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 @@ -90,6 +90,14 @@ impl ToolHandler for Handler { .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() { @@ -234,6 +242,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 aa1699a4e..a6caf4382 100644 --- a/codex-rs/tools/src/agent_tool.rs +++ b/codex-rs/tools/src/agent_tool.rs @@ -538,6 +538,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/83] 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 5b464fcc817595efc754288f85249de956f71396 Mon Sep 17 00:00:00 2001 From: piping Date: Sat, 4 Apr 2026 23:44:13 +0800 Subject: [PATCH 17/83] 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 46c3b967c..a5e8e56bb 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -171,12 +171,14 @@ mod app_server_adapter; pub(crate) 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; @@ -1077,6 +1079,7 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, + workflow_scheduler: WorkflowSchedulerState, } #[derive(Default)] @@ -2016,15 +2019,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(); @@ -4354,6 +4445,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) @@ -10086,6 +10178,7 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + workflow_scheduler: WorkflowSchedulerState::default(), } } @@ -10143,6 +10236,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, @@ -11833,6 +11927,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 aee3b2bed..05b1cefb0 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -887,17 +887,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 aefa9e1f3..916d5d80f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -823,6 +823,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) @@ -3809,7 +3811,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. @@ -4929,6 +4942,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, last_agent_markdown: None, @@ -7386,7 +7401,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 55c0fa5f1..936b9a9f8 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -221,6 +221,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 f0611e968..ab7bf5b25 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -662,11 +662,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, + } } } @@ -685,14 +704,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 = " [...]"; @@ -786,6 +854,7 @@ impl HistoryCell for UnifiedExecProcessesCell { } } +#[cfg(test)] pub(crate) fn new_unified_exec_processes_output( processes: Vec, ) -> CompositeHistoryCell { @@ -794,6 +863,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} ..."), @@ -3287,6 +3370,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 f1c3c33f8e62bf3318688a5a72b8d3719fb35a69 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 00:32:13 +0800 Subject: [PATCH 18/83] 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 be94fda55..128931c97 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2988,6 +2988,7 @@ dependencies = [ "dirs", "dunce", "image", + "indexmap 2.13.0", "insta", "itertools 0.14.0", "lazy_static", @@ -3003,6 +3004,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 7d75738ad..b3962310e 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -66,6 +66,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 } @@ -83,6 +84,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 a5e8e56bb..1f12d47e2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -164,6 +164,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; @@ -171,6 +173,8 @@ mod app_server_adapter; pub(crate) 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; @@ -2050,6 +2054,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, @@ -2060,14 +2088,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(); @@ -4940,28 +4978,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 c1ac05ce0..c37433da1 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; @@ -298,6 +299,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 1f886d3e0558a0b0054ea1e53c5520ae17b18fd9 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 00:57:07 +0800 Subject: [PATCH 19/83] 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 1f12d47e2..cb16afde2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -174,6 +174,7 @@ pub(crate) 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; @@ -182,6 +183,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."; @@ -1084,6 +1086,7 @@ pub(crate) struct App { pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, workflow_scheduler: WorkflowSchedulerState, + workflow_history: WorkflowHistoryState, } #[derive(Default)] @@ -3397,6 +3400,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 { @@ -4202,6 +4206,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 @@ -4484,6 +4491,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) @@ -4980,6 +4988,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) @@ -10203,6 +10224,7 @@ guardian_approval = true pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), workflow_scheduler: WorkflowSchedulerState::default(), + workflow_history: WorkflowHistoryState::default(), } } @@ -10261,6 +10283,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, @@ -11669,6 +11692,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; @@ -12033,6 +12180,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 c37433da1..049a077aa 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -299,6 +299,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 1e58f716f9216b2fdba910f95df9fd901f853df0 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 01:30:23 +0800 Subject: [PATCH 20/83] 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 cb16afde2..13ad229d4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -261,6 +261,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>, @@ -2468,6 +2498,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? { @@ -2495,6 +2528,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(()); } @@ -3907,7 +3945,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(()) @@ -4132,7 +4170,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(()); }; @@ -4140,7 +4182,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; @@ -6540,7 +6607,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(); } @@ -7075,6 +7162,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 crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; @@ -7150,6 +7238,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; @@ -10290,6 +10379,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, @@ -10348,6 +10481,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(), @@ -12150,6 +12304,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 f2490ca6a..67bd72f4b 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 { @@ -196,14 +196,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 e94dced05..0e6d1ef08 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -264,6 +264,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 5be52bab8193257a0c651b0e339d285c4e8a0c5d Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 01:42:08 +0800 Subject: [PATCH 21/83] 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 a122f9a5fe723811efac47a0e5b422b67e0ce40a Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 16:40:37 +0800 Subject: [PATCH 22/83] 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 | 359 ++++++++++++++++++ .../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, 796 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 13ad229d4..6814e944f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -173,6 +173,7 @@ mod app_server_adapter; pub(crate) 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; @@ -2085,6 +2086,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) { @@ -5088,6 +5090,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(); @@ -7166,6 +7206,7 @@ mod tests { use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; + use crate::render::renderable::Renderable; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; @@ -7237,6 +7278,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; @@ -10423,6 +10466,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, @@ -12506,6 +12584,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 049a077aa..580006309 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -310,6 +310,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 916d5d80f..258ed1225 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5302,6 +5302,365 @@ impl ChatWidget { self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); } + fn dispatch_command(&mut self, cmd: SlashCommand) { + if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + let message = format!( + "'/{}' is disabled while a task is in progress.", + cmd.command() + ); + self.add_to_history(history_cell::new_error_event(message)); + self.bottom_pane.drain_pending_submission_state(); + self.request_redraw(); + return; + } + match cmd { + SlashCommand::Feedback => { + if !self.config.feedback_enabled { + let params = crate::bottom_pane::feedback_disabled_params(); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + return; + } + // Step 1: pick a category (UI built in feedback_view) + let params = + crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + SlashCommand::New => { + self.app_event_tx.send(AppEvent::NewSession); + } + SlashCommand::Clear => { + self.app_event_tx.send(AppEvent::ClearUi); + } + SlashCommand::Resume => { + self.app_event_tx.send(AppEvent::OpenResumePicker); + } + SlashCommand::Fork => { + self.app_event_tx.send(AppEvent::ForkCurrentSession); + } + SlashCommand::Init => { + let init_target = match self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME) { + Ok(path) => path, + Err(err) => { + self.add_error_message(format!( + "Failed to prepare {DEFAULT_PROJECT_DOC_FILENAME}: {err}", + )); + return; + } + }; + if init_target.exists() { + let message = format!( + "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." + ); + self.add_info_message(message, /*hint*/ None); + return; + } + const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); + self.submit_user_message(INIT_PROMPT.to_string().into()); + } + SlashCommand::Compact => { + self.clear_token_usage(); + if !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + } + self.app_event_tx.compact(); + } + SlashCommand::Review => { + self.open_review_popup(); + } + SlashCommand::Rename => { + self.session_telemetry + .counter("codex.thread.rename", /*inc*/ 1, &[]); + self.show_rename_prompt(); + } + SlashCommand::Model => { + self.open_model_popup(); + } + SlashCommand::Fast => { + let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { + None + } else { + Some(ServiceTier::Fast) + }; + self.set_service_tier_selection(next_tier); + } + SlashCommand::Realtime => { + if !self.realtime_conversation_enabled() { + return; + } + if self.realtime_conversation.is_live() { + self.stop_realtime_conversation_from_ui(); + } else { + self.start_realtime_conversation(); + } + } + SlashCommand::Settings => { + if !self.realtime_audio_device_selection_enabled() { + return; + } + self.open_realtime_audio_popup(); + } + SlashCommand::Personality => { + self.open_personality_popup(); + } + SlashCommand::Plan => { + if !self.collaboration_modes_enabled() { + self.add_info_message( + "Collaboration modes are disabled.".to_string(), + Some("Enable collaboration modes to use /plan.".to_string()), + ); + return; + } + if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { + self.set_collaboration_mask(mask); + } else { + self.add_info_message( + "Plan mode unavailable right now.".to_string(), + /*hint*/ None, + ); + } + } + SlashCommand::Collab => { + if !self.collaboration_modes_enabled() { + self.add_info_message( + "Collaboration modes are disabled.".to_string(), + Some("Enable collaboration modes to use /collab.".to_string()), + ); + return; + } + self.open_collaboration_modes_popup(); + } + SlashCommand::Agent | SlashCommand::MultiAgents => { + self.app_event_tx.send(AppEvent::OpenAgentPicker); + } + SlashCommand::Approvals => { + self.open_permissions_popup(); + } + SlashCommand::Permissions => { + self.open_permissions_popup(); + } + SlashCommand::ElevateSandbox => { + #[cfg(target_os = "windows")] + { + let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + let windows_degraded_sandbox_enabled = + matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); + if !windows_degraded_sandbox_enabled + || !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + { + // This command should not be visible/recognized outside degraded mode, + // but guard anyway in case something dispatches it directly. + return; + } + + let Some(preset) = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + else { + // Avoid panicking in interactive UI; treat this as a recoverable + // internal error. + self.add_error_message( + "Internal error: missing the 'auto' approval preset.".to_string(), + ); + return; + }; + + if let Err(err) = self + .config + .permissions + .approval_policy + .can_set(&preset.approval) + { + self.add_error_message(err.to_string()); + return; + } + + self.session_telemetry.counter( + "codex.windows_sandbox.setup_elevated_sandbox_command", + /*inc*/ 1, + &[], + ); + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); + } + #[cfg(not(target_os = "windows"))] + { + let _ = &self.session_telemetry; + // Not supported; on non-Windows this command should never be reachable. + }; + } + SlashCommand::SandboxReadRoot => { + self.add_error_message( + "Usage: /sandbox-add-read-dir ".to_string(), + ); + } + SlashCommand::Experimental => { + self.open_experimental_popup(); + } + SlashCommand::Quit | SlashCommand::Exit => { + self.request_quit_without_confirmation(); + } + SlashCommand::Logout => { + if let Err(e) = codex_login::logout( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { + tracing::error!("failed to logout: {e}"); + } + self.request_quit_without_confirmation(); + } + // SlashCommand::Undo => { + // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); + // } + SlashCommand::Diff => { + self.add_diff_in_progress(); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let text = match get_git_diff().await { + Ok((is_git_repo, diff_text)) => { + if is_git_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() + } + } + Err(e) => format!("Failed to compute diff: {e}"), + }; + tx.send(AppEvent::DiffResult(text)); + }); + } + 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}")) + } + } + } + SlashCommand::Mention => { + self.insert_str("@"); + } + SlashCommand::Skills => { + self.open_skills_menu(); + } + SlashCommand::Status => { + if self.should_prefetch_rate_limits() { + let request_id = self.next_status_refresh_request_id; + self.next_status_refresh_request_id = + self.next_status_refresh_request_id.wrapping_add(1); + self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + self.app_event_tx + .send(AppEvent::RefreshRateLimits { request_id }); + } else { + self.add_status_output( + /*refreshing_rate_limits*/ false, /*request_id*/ None, + ); + } + } + SlashCommand::DebugConfig => { + self.add_debug_config_output(); + } + SlashCommand::Title => { + self.open_terminal_title_setup(); + } + SlashCommand::Statusline => { + self.open_status_line_setup(); + } + SlashCommand::Theme => { + self.open_theme_picker(); + } + SlashCommand::Ps => { + self.add_ps_output(); + } + SlashCommand::Stop => { + self.clean_background_terminals(); + } + SlashCommand::MemoryDrop => { + self.add_app_server_stub_message("Memory maintenance"); + } + SlashCommand::MemoryUpdate => { + self.add_app_server_stub_message("Memory maintenance"); + } + SlashCommand::Mcp => { + self.add_mcp_output(); + } + SlashCommand::Apps => { + self.add_connectors_output(); + } + 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( + format!("Current rollout path: {}", path.display()), + /*hint*/ None, + ); + } else { + self.add_info_message( + "Rollout path is not available yet.".to_string(), + /*hint*/ None, + ); + } + } + SlashCommand::TestApproval => { + use std::collections::HashMap; + + use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; + use codex_protocol::protocol::FileChange; + + self.on_apply_patch_approval_request( + "1".to_string(), + ApplyPatchApprovalRequestEvent { + call_id: "1".to_string(), + turn_id: "turn-1".to_string(), + changes: HashMap::from([ + ( + PathBuf::from("/tmp/test.txt"), + FileChange::Add { + content: "test".to_string(), + }, + ), + ( + PathBuf::from("/tmp/test2.txt"), + FileChange::Update { + unified_diff: "+test\n-test2".to_string(), + move_path: None, + }, + ), + ]), + reason: None, + grant_root: Some(PathBuf::from("/tmp")), + }, + ); + } + } + } + /// Inner implementation with an injectable clipboard backend for testing. fn copy_last_agent_markdown_with( &mut self, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c7caacc2e..9d4614f07 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -565,6 +565,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_memories_opens_memory_menu() { diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 759bedbad..3989a2cb4 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -45,6 +45,7 @@ pub enum SlashCommand { Mcp, Apps, Plugins, + Workflow, Logout, Quit, Exit, @@ -114,6 +115,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", @@ -175,6 +177,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 a1f597e317a126837fb44520d338d905a7c2bf70 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 00:10:02 +0800 Subject: [PATCH 23/83] 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 6814e944f..d5decf17c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1116,6 +1116,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, } @@ -4559,6 +4561,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(), }; @@ -5078,6 +5081,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, @@ -7223,6 +7239,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; @@ -10355,6 +10372,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(), } @@ -10414,6 +10432,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(), }, @@ -12750,6 +12769,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 67bd72f4b..77856ad80 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -219,6 +219,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 580006309..ce654ed1f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -17,6 +17,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_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; @@ -310,6 +311,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 bec3803fa28f891c6d2acd4f965ca2655b7d358b Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 01:55:04 +0800 Subject: [PATCH 24/83] codex: fix CI failure on PR #18 --- codex-rs/tui/src/app.rs | 17 ++--------------- 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(+), 19 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d5decf17c..fb912b57b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5131,19 +5131,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(); @@ -7209,6 +7196,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::chatwidget::tests::set_fast_mode_test_catalog; use crate::file_search::FileSearchManager; @@ -7295,8 +7283,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 258ed1225..d702642bc 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -10323,6 +10323,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 e92a809e8..105b9dd9a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -269,6 +269,7 @@ 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(crate) use helpers::set_fast_mode_test_catalog; 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 936b9a9f8..c50699482 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -770,7 +770,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 6374fd95f6088c13110748421e2ed777e1787336 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 09:36:05 +0800 Subject: [PATCH 25/83] 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 | 62 +++++- ...__tests__footer_mode_shortcut_overlay.snap | 14 +- ...shortcuts_collaboration_modes_enabled.snap | 13 +- ...tests__footer_shortcuts_shift_and_esc.snap | 14 +- codex-rs/tui/src/chatwidget.rs | 60 ++--- 8 files changed, 507 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 fb912b57b..28b27e837 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -171,6 +171,7 @@ use uuid::Uuid; mod agent_navigation; mod app_server_adapter; pub(crate) mod app_server_requests; +mod key_chord; mod loaded_threads; mod pending_interactive_replay; mod workflow_controls; @@ -182,6 +183,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; @@ -1081,6 +1085,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 @@ -4542,6 +4547,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, @@ -6805,12 +6811,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 @@ -6929,12 +6980,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); } _ => { @@ -10340,6 +10385,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, @@ -10398,6 +10444,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, @@ -11852,6 +11899,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 2852fbc37..2840ca4f8 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -461,6 +461,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(); @@ -509,8 +528,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 958f5c2ea..8e5df64f6 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -764,6 +764,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut external_editor = Line::from(""); let mut edit_previous = Line::from(""); let mut history_search = 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(""); @@ -780,6 +783,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::ExternalEditor => external_editor = text, ShortcutId::EditPrevious => edit_previous = text, ShortcutId::HistorySearch => history_search = 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, @@ -797,6 +803,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { external_editor, edit_previous, history_search, + undo_last_message, + copy_latest_output, + respawn_current_session, quit, ]; if change_mode.width() > 0 { @@ -880,6 +889,9 @@ enum ShortcutId { ExternalEditor, EditPrevious, HistorySearch, + UndoLastMessage, + CopyLatestOutput, + RespawnCurrentSession, Quit, ShowTranscript, ChangeMode, @@ -922,6 +934,7 @@ struct ShortcutDescriptor { id: ShortcutId, bindings: &'static [ShortcutBinding], prefix: &'static str, + display_label: Option<&'static str>, label: &'static str, } @@ -932,7 +945,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 { @@ -959,6 +977,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " for commands", }, ShortcutDescriptor { @@ -968,6 +987,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " for shell commands", }, ShortcutDescriptor { @@ -983,6 +1003,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ }, ], prefix: "", + display_label: None, label: " for newline", }, ShortcutDescriptor { @@ -992,6 +1013,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to queue message", }, ShortcutDescriptor { @@ -1001,6 +1023,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " for file paths", }, ShortcutDescriptor { @@ -1018,6 +1041,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ }, ], prefix: "", + display_label: None, label: " to paste images", }, ShortcutDescriptor { @@ -1027,6 +1051,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to edit in external editor", }, ShortcutDescriptor { @@ -1036,6 +1061,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: "", }, ShortcutDescriptor { @@ -1045,8 +1071,39 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " search history", }, + 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 { @@ -1054,6 +1111,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to exit", }, ShortcutDescriptor { @@ -1063,6 +1121,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to view transcript", }, ShortcutDescriptor { @@ -1072,6 +1131,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 8db427fe3..a7df49bc9 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,11 @@ 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 + r search history 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 + r search history 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 b2b7f1349..813e2cd63 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,10 +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 + r search history ctrl + c to exit " -" shift + tab to change mode " +" / 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 + r search history ctrl + x then ctrl + u to u" +" ctrl + x then ctrl + y to copy last output ctrl + x then ctrl + r to r" +" 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 e955715ec..906d25198 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,11 @@ 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 + r search history 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 + r search history ctrl + x then ctrl + u to u" +" ctrl + x then ctrl + y to copy last output ctrl + x then ctrl + r to r" +" 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 d702642bc..57699103c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5302,6 +5302,39 @@ impl ChatWidget { self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); } + 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!( @@ -5531,32 +5564,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 ebb407a6108dff5b0ae76ca243be21221aefdce5 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 10:15:11 +0800 Subject: [PATCH 26/83] 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 | 272 +++++++++++++++--- ...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, 613 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 28b27e837..75a4ee38c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -26,6 +26,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; @@ -1086,6 +1091,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 @@ -1202,6 +1208,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. @@ -1241,6 +1248,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(()) } @@ -3627,6 +3635,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:")) @@ -4408,6 +4432,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 = @@ -4420,6 +4445,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( @@ -4454,6 +4480,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( @@ -4493,6 +4520,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( @@ -4532,6 +4560,7 @@ impl App { app_event_tx, chat_widget, config, + display_preferences, active_profile, cli_kv_overrides, harness_overrides, @@ -5000,6 +5029,9 @@ impl App { } } } + AppEvent::OpenDisplayPreferencesPanel => { + self.open_display_preferences_panel(); + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", @@ -6088,6 +6120,27 @@ impl App { AppEvent::ResetMemories => { self.reset_memories_with_app_server(app_server).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; } @@ -7735,6 +7788,7 @@ mod tests { let model = crate::legacy_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( @@ -10360,6 +10414,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 = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -10370,6 +10425,7 @@ guardian_approval = true app_event_tx, chat_widget, config, + display_preferences, active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), @@ -10418,6 +10474,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 = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -10429,6 +10486,7 @@ guardian_approval = true app_event_tx, chat_widget, config, + display_preferences, active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), @@ -12264,6 +12322,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 ce654ed1f..0824cc1c2 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::legacy_core::plugins::PluginCapabilitySummary; use crate::profile_router::ProfileFallbackAction; @@ -131,6 +132,9 @@ pub(crate) enum AppEvent { /// Resume a thread by UUID or thread name inside the running TUI session. ResumeSessionByIdOrName(String), + /// Open the local TUI display preferences panel. + OpenDisplayPreferencesPanel, + /// Fork the current session into a new thread. ForkCurrentSession, @@ -515,6 +519,9 @@ pub(crate) enum AppEvent { /// Clear all persisted local memory artifacts via the app-server. ResetMemories, + /// 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 5b39a0f01..8ba019816 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -409,7 +409,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 29da003dc..12cc6b92b 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 05b1cefb0..38d8a0287 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -157,6 +157,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 57699103c..86c378a16 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -333,6 +333,7 @@ use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; 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; @@ -562,6 +563,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, @@ -856,10 +858,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. @@ -2316,6 +2323,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(), @@ -2323,8 +2332,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(); } @@ -2333,9 +2352,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; @@ -2366,6 +2391,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(); } @@ -4847,6 +4874,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, @@ -4962,8 +4990,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(), active_hook_cell: None, @@ -5197,6 +5228,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 { @@ -5276,6 +5309,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() } @@ -5429,10 +5480,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(); @@ -5694,6 +5742,137 @@ impl ChatWidget { self.request_redraw(); } + fn dispatch_command_with_args( + &mut self, + cmd: SlashCommand, + args: String, + _text_elements: Vec, + ) { + if !cmd.supports_inline_args() { + self.dispatch_command(cmd); + return; + } + if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + let message = format!( + "'/{}' is disabled while a task is in progress.", + cmd.command() + ); + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + return; + } + + let trimmed = args.trim(); + match cmd { + SlashCommand::Fast => { + if trimmed.is_empty() { + self.dispatch_command(cmd); + return; + } + match trimmed.to_ascii_lowercase().as_str() { + "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), + "off" => self.set_service_tier_selection(/*service_tier*/ None), + "status" => { + let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) + { + "on" + } else { + "off" + }; + self.add_info_message( + format!("Fast mode is {status}."), + /*hint*/ None, + ); + } + _ => { + self.add_error_message("Usage: /fast [on|off|status]".to_string()); + } + } + } + SlashCommand::Rename if !trimmed.is_empty() => { + self.session_telemetry + .counter("codex.thread.rename", /*inc*/ 1, &[]); + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else { + self.add_error_message("Thread name cannot be empty.".to_string()); + return; + }; + let cell = Self::rename_confirmation_cell(&name, self.thread_id); + self.add_boxed_history(Box::new(cell)); + self.request_redraw(); + self.app_event_tx.set_thread_name(name); + self.bottom_pane.drain_pending_submission_state(); + } + SlashCommand::Plan if !trimmed.is_empty() => { + self.dispatch_command(cmd); + if self.active_mode_kind() != ModeKind::Plan { + return; + } + let Some((prepared_args, prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ true) + else { + return; + }; + let local_images = self + .bottom_pane + .take_recent_submission_images_with_placeholders(); + let remote_image_urls = self.take_remote_image_urls(); + let user_message = UserMessage { + text: prepared_args, + local_images, + remote_image_urls, + text_elements: prepared_elements, + mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), + }; + 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 { + self.queue_user_message(user_message); + } + } + SlashCommand::Review if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.submit_op(AppCommand::review(ReviewRequest { + target: ReviewTarget::Custom { + instructions: prepared_args, + }, + user_facing_hint: None, + })); + self.bottom_pane.drain_pending_submission_state(); + } + SlashCommand::SandboxReadRoot 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::BeginWindowsSandboxGrantReadRoot { + path: prepared_args, + }); + self.bottom_pane.drain_pending_submission_state(); + } + _ => self.dispatch_command(cmd), + } + } + #[cfg(test)] pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { self.last_agent_markdown.as_deref() @@ -6257,10 +6436,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(); @@ -6583,9 +6760,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) => { @@ -7121,13 +7296,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(), @@ -8085,33 +8262,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 c50699482..7860bc2e9 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -180,6 +180,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), @@ -239,8 +240,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(), active_hook_cell: None, retry_status_header: None, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 71987e590..8ae6994bc 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -590,7 +590,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 { @@ -637,7 +636,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 { @@ -671,12 +673,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 f3746fdbf..287fb23ab 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1211,6 +1211,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 10e3451d0..bcb131942 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -63,6 +63,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, @@ -1621,7 +1622,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); @@ -1631,7 +1632,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 a31b93bb4..9722b87be 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -121,6 +121,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 ab7bf5b25..10391e37e 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; @@ -453,6 +454,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>, @@ -2766,6 +2797,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". /// @@ -4880,6 +4923,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 836cb0baf..817c35e88 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -116,6 +116,8 @@ pub use custom_terminal::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 3989a2cb4..554c20cd8 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -100,7 +100,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 8db9c85ab9063899aeff3d6deece3942d7d0395e Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 17:22:40 +0800 Subject: [PATCH 27/83] 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 | 4 + 10 files changed, 954 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 75a4ee38c..7cabdb115 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -153,6 +153,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; @@ -176,6 +177,7 @@ use uuid::Uuid; mod agent_navigation; mod app_server_adapter; pub(crate) mod app_server_requests; +mod btw; mod key_chord; mod loaded_threads; mod pending_interactive_replay; @@ -188,6 +190,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; @@ -1131,6 +1134,8 @@ pub(crate) struct App { HashMap>, workflow_scheduler: WorkflowSchedulerState, workflow_history: WorkflowHistoryState, + btw_session: Option, + btw_closing_thread_ids: HashSet, } #[derive(Default)] @@ -4599,6 +4604,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) @@ -5132,6 +5139,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, @@ -10464,6 +10490,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(), } } @@ -10524,9 +10552,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, @@ -13054,6 +13084,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 77856ad80..fa40cdd4b 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -193,6 +193,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 0824cc1c2..dd7837b47 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -337,6 +337,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 86c378a16..e09f8d70a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5670,6 +5670,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( @@ -5869,6 +5872,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 9d4614f07..a31db8e38 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -238,6 +238,40 @@ async fn no_op_stub_slash_command_is_available_from_local_recall() { assert_eq!(recall_latest_after_clearing(&mut chat), "/debug-m-drop"); } +#[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 554c20cd8..82933559d 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -46,6 +46,7 @@ pub enum SlashCommand { Apps, Plugins, Workflow, + Btw, Logout, Quit, Exit, @@ -116,6 +117,7 @@ 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::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", SlashCommand::TestApproval => "test approval request", @@ -137,6 +139,7 @@ impl SlashCommand { | SlashCommand::Plan | SlashCommand::Fast | SlashCommand::Resume + | SlashCommand::Btw | SlashCommand::SandboxReadRoot ) } @@ -161,6 +164,7 @@ impl SlashCommand { | SlashCommand::Memories | SlashCommand::Review | SlashCommand::Plan + | SlashCommand::Btw | SlashCommand::Clear | SlashCommand::Logout | SlashCommand::MemoryDrop From c5b2f2773cdc85d157930fa9d030ad75b093384b Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 17:44:59 +0800 Subject: [PATCH 28/83] 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 | 3 + 11 files changed, 612 insertions(+), 2 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 7cabdb115..f68da4870 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -178,6 +178,7 @@ mod agent_navigation; mod app_server_adapter; pub(crate) mod app_server_requests; mod btw; +mod jump_navigation; mod key_chord; mod loaded_threads; mod pending_interactive_replay; @@ -186,6 +187,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; @@ -5039,6 +5041,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", @@ -5101,6 +5122,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 dd7837b47..4820be491 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -135,9 +135,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 e09f8d70a..f3e681af5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5423,6 +5423,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 a31db8e38..d1e97ec74 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -253,6 +253,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 82933559d..c094ca16d 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -28,6 +28,7 @@ pub enum SlashCommand { New, Resume, Fork, + Thread, Init, Compact, Plan, @@ -82,6 +83,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::Copy => "copy last response as markdown", @@ -182,6 +184,7 @@ impl SlashCommand { | SlashCommand::Apps | SlashCommand::Plugins | SlashCommand::Workflow + | SlashCommand::Thread | SlashCommand::Feedback | SlashCommand::Quit | SlashCommand::Exit => true, From ed61c8589c9187172ed3aaecb088da38b61d0247 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 18:03:45 +0800 Subject: [PATCH 29/83] 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 f68da4870..69c21844c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -6,6 +6,7 @@ use crate::app_event::ExitMode; use crate::app_event::FeedbackCategory; use crate::app_event::RateLimitRefreshOrigin; 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; @@ -187,6 +188,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; @@ -1291,6 +1293,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); @@ -1327,9 +1330,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()); @@ -1428,20 +1431,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 { @@ -1568,7 +1570,6 @@ impl App { } } } - async fn rebuild_config_for_resume_or_fallback( &mut self, current_cwd: &Path, @@ -5041,6 +5042,9 @@ impl App { AppEvent::OpenDisplayPreferencesPanel => { self.open_display_preferences_panel(); } + AppEvent::OpenProfileManagementPanel => { + self.open_profile_management_panel(); + } AppEvent::OpenThreadPanel => { self.open_thread_panel(); } @@ -5125,6 +5129,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 4820be491..f81642512 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -69,6 +69,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 { @@ -135,6 +141,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, @@ -152,6 +161,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 f3e681af5..24f0d3671 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5426,6 +5426,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 d1e97ec74..841f5fc87 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -262,6 +262,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 c094ca16d..f59a6f2b2 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -29,6 +29,7 @@ pub enum SlashCommand { Resume, Fork, Thread, + Profile, Init, Compact, Plan, @@ -84,6 +85,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::Copy => "copy last response as markdown", @@ -185,6 +187,7 @@ impl SlashCommand { | SlashCommand::Plugins | SlashCommand::Workflow | SlashCommand::Thread + | SlashCommand::Profile | SlashCommand::Feedback | SlashCommand::Quit | SlashCommand::Exit => true, From 2b8c8a5b6c30ac957e8d9838ad0d1b37b7a4000c Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 19:05:51 +0800 Subject: [PATCH 30/83] 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 f190da70071a4dd91c13f8fb44dc7c3e02c1aeb7 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 19:33:47 +0800 Subject: [PATCH 31/83] 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 | 10 + 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, 427 insertions(+), 16 deletions(-) diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index b3576cb3c..2985754c8 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -530,6 +530,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)] @@ -582,6 +605,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 eb44f5c8f..0c9805073 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1912,6 +1912,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": [ { @@ -1977,6 +1989,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 5bdf76881..e932dc968 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -43,6 +43,7 @@ use codex_config::types::SandboxWorkspaceWrite; use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestDiscoverableType; use codex_config::types::Tui; +use codex_config::types::TuiDisplayPreferences; use codex_config::types::TuiNotificationSettings; use codex_exec_server::LOCAL_FS; use codex_features::Feature; @@ -377,6 +378,7 @@ fn config_toml_deserializes_model_availability_nux() { ("gpt-foo".to_string(), 2), ]), }, + display_preferences: TuiDisplayPreferences::default(), } ); } @@ -395,6 +397,31 @@ async 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] @@ -1175,6 +1202,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { terminal_title: None, theme: None, model_availability_nux: ModelAvailabilityNuxConfig::default(), + display_preferences: TuiDisplayPreferences::default(), } ); } @@ -4842,6 +4870,7 @@ async 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(), @@ -4992,6 +5021,7 @@ async 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(), @@ -5140,6 +5170,7 @@ async 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(), @@ -5273,6 +5304,7 @@ async 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 47a8bdf30..61eb57ee5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -45,6 +45,8 @@ use codex_config::types::ShellEnvironmentPolicy; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::TuiNotificationSettings; +use codex_config::types::Tui; +use codex_config::types::TuiDisplayPreferences; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_exec_server::ExecutorFileSystem; @@ -339,6 +341,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. @@ -2210,6 +2215,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 24f0d3671..70cf77e63 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3604,7 +3604,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, .. @@ -3744,6 +3747,7 @@ impl ChatWidget { self.add_to_history(history_cell::new_patch_event( event.changes, &self.config.cwd, + self.display_preferences.clone(), )); } @@ -4825,6 +4829,7 @@ impl ChatWidget { ev.call_id, ev.invocation, self.config.animations, + self.display_preferences.clone(), ))); self.bump_active_cell_revision(); self.request_redraw(); @@ -4852,6 +4857,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 10391e37e..a9b2d2b4c 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1060,19 +1060,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( @@ -1145,10 +1152,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) } } @@ -1156,9 +1173,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()] } } @@ -1609,6 +1630,7 @@ pub(crate) struct McpToolCallCell { duration: Option, result: Option>, animations_enabled: bool, + display_preferences: DisplayPreferences, } impl McpToolCallCell { @@ -1616,6 +1638,7 @@ impl McpToolCallCell { call_id: String, invocation: McpInvocation, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> Self { Self { call_id, @@ -1624,6 +1647,7 @@ impl McpToolCallCell { duration: None, result: None, animations_enabled, + display_preferences, } } @@ -1636,8 +1660,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 @@ -1729,7 +1756,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() { @@ -1790,8 +1819,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 { @@ -1898,6 +1928,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() @@ -1906,7 +1937,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. @@ -2694,10 +2728,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, } } @@ -3812,6 +3848,7 @@ mod tests { "call-1".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n"); @@ -3848,6 +3885,7 @@ mod tests { "call-2".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_millis(1420), Ok(result)) @@ -3883,6 +3921,7 @@ mod tests { "call-image".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); let extra_cell = cell .complete(Duration::from_millis(25), Ok(result)) @@ -3914,6 +3953,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)) @@ -3923,6 +3963,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 { @@ -3944,6 +4019,7 @@ mod tests { "call-image-2".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); let extra_cell = cell .complete(Duration::from_millis(25), Ok(result)) @@ -3968,6 +4044,7 @@ mod tests { "call-3".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_secs(2), Err("network timeout".into())) @@ -3979,6 +4056,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 { @@ -4011,6 +4129,7 @@ mod tests { "call-4".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_millis(640), Ok(result)) @@ -4022,6 +4141,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 { @@ -4046,6 +4202,7 @@ mod tests { "call-5".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_millis(1280), Ok(result)) @@ -4082,6 +4239,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 09c1ec7e67a0ebbf922e73020887066f2140e159 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 00:14:30 +0800 Subject: [PATCH 32/83] 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 69c21844c..bcd6c497d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -10910,6 +10910,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); @@ -13150,6 +13166,7 @@ model = "gpt-5.2" app.btw_session = Some(BtwSessionState { thread_id, final_message: None, + last_status: None, }); let swallowed = app.handle_btw_notification( @@ -13175,6 +13192,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; @@ -13184,6 +13252,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()); @@ -13197,7 +13266,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 fa40cdd4b..a78bef620 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -278,6 +278,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 b8f91a1f3cc3a68efca943954484f4c3dc798b68 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 02:12:41 +0800 Subject: [PATCH 33/83] 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 c7ec615a4..5ed19e7af 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -532,8 +532,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 bcd6c497d..e950693cf 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -60,10 +60,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; @@ -183,13 +180,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; @@ -1272,17 +1269,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, @@ -1343,61 +1329,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, @@ -1431,145 +1362,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, @@ -7430,6 +7222,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; @@ -10612,11 +10405,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, @@ -12815,7 +12608,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 a78bef620..46c79e764 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -288,12 +288,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 70cf77e63..332f22883 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -10550,19 +10550,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 8ae6994bc..f08168f66 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -638,7 +638,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 a9b2d2b4c..fdc966c0b 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -3983,7 +3983,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(), @@ -4077,7 +4077,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(), @@ -4153,7 +4153,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); @@ -4166,7 +4166,7 @@ mod tests { let display_preferences = DisplayPreferences::default(); display_preferences.set_enabled( crate::display_preferences::DisplayPreferenceKey::PatchDiffs, - false, + /*enabled*/ false, ); let cell = @@ -5098,7 +5098,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 d2cbbd87d693c4beac6c84adfbda5442c65f7cfe Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 11:03:40 +0800 Subject: [PATCH 34/83] replay: restore feishu clawbot runtime bridge --- MODULE.bazel.lock | 77 ++-- 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 + 18 files changed, 2212 insertions(+), 46 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 7a1acc381..87c647b00 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -228,8 +228,6 @@ "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/MODULE.bazel": "b573395fe63aef4299ba095173e2f62ccfee5ad9bbf7acaa95dba73af9fc2b38", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/source.json": "3f3fbaeafecaf629877ad152a2c9def21f8d330d91aa94c5dc75bbb98c10b8b8", - "https://bcr.bazel.build/modules/xz/5.4.5.bcr.8/MODULE.bazel": "e48a69bd54053c2ec5fffc2a29fb70122afd3e83ab6c07068f63bc6553fa57cc", - "https://bcr.bazel.build/modules/xz/5.4.5.bcr.8/source.json": "bd7e928ccd63505b44f4784f7bbf12cc11f9ff23bf3ca12ff2c91cd74846099e", "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.8/MODULE.bazel": "772c674bb78a0342b8caf32ab5c25085c493ca4ff08398208dcbe4375fe9f776", @@ -678,7 +676,6 @@ "bytes_1.11.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"extra-platforms\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.60\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "bytestring_1.5.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"}],\"features\":{\"serde\":[\"dep:serde_core\"]}}", "bzip2-sys_0.1.13+1.0.8": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"}],\"features\":{\"__disabled\":[],\"static\":[]}}", - "bzip2_0.4.4": "{\"dependencies\":[{\"name\":\"bzip2-sys\",\"req\":\"^0.1.11\"},{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"features\":[\"quickcheck\"],\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck6\",\"package\":\"quickcheck\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tokio-core\",\"req\":\"^0.1\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"static\":[\"bzip2-sys/static\"],\"tokio\":[\"tokio-io\",\"futures\"]}}", "bzip2_0.5.2": "{\"dependencies\":[{\"name\":\"bzip2-sys\",\"optional\":true,\"req\":\"^0.1.13\"},{\"default_features\":false,\"features\":[\"rust-allocator\",\"semver-prefix\"],\"name\":\"libbz2-rs-sys\",\"optional\":true,\"req\":\"^0.1.3\"},{\"features\":[\"quickcheck1\"],\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.5.4\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"default\":[\"dep:bzip2-sys\"],\"libbz2-rs-sys\":[\"dep:libbz2-rs-sys\",\"bzip2-sys?/__disabled\"],\"static\":[\"bzip2-sys?/static\"]}}", "cached_0.56.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.6\"},{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"cached_proc_macro\",\"optional\":true,\"req\":\"^0.25.0\"},{\"name\":\"cached_proc_macro_types\",\"optional\":true,\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"copy_dir\",\"req\":\"^0.1.3\"},{\"name\":\"directories\",\"optional\":true,\"req\":\"^6.0\"},{\"default_features\":false,\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"googletest\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"features\":[\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"r2d2\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"r2d2\"],\"name\":\"redis\",\"optional\":true,\"req\":\"^0.32\"},{\"name\":\"rmp-serde\",\"optional\":true,\"req\":\"^1.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"name\":\"sled\",\"optional\":true,\"req\":\"^0.34\"},{\"kind\":\"dev\",\"name\":\"smartstring\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"macros\",\"time\",\"sync\",\"parking_lot\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"web-time\",\"req\":\"^1.1.0\"}],\"features\":{\"ahash\":[\"dep:ahash\",\"hashbrown/default\"],\"async\":[\"futures\",\"tokio\",\"async-trait\"],\"async_tokio_rt_multi_thread\":[\"async\",\"tokio/rt-multi-thread\"],\"default\":[\"proc_macro\",\"ahash\"],\"disk_store\":[\"sled\",\"serde\",\"rmp-serde\",\"directories\"],\"proc_macro\":[\"cached_proc_macro\",\"cached_proc_macro_types\"],\"redis_ahash\":[\"redis_store\",\"redis/ahash\"],\"redis_async_std\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/async-std-comp\",\"redis/tls\",\"redis/async-std-tls-comp\"],\"redis_connection_manager\":[\"redis_store\",\"redis/connection-manager\"],\"redis_store\":[\"redis\",\"r2d2\",\"serde\",\"serde_json\"],\"redis_tokio\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/tokio-comp\",\"redis/tls\",\"redis/tokio-native-tls-comp\"],\"wasm\":[]}}", "cached_proc_macro_0.25.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.20.8\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.49\"},{\"name\":\"quote\",\"req\":\"^1.0.6\"},{\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", @@ -694,7 +691,6 @@ "cc_1.2.56": "{\"dependencies\":[{\"name\":\"find-msvc-tools\",\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"jobserver\",\"optional\":true,\"req\":\"^0.1.30\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.62\",\"target\":\"cfg(unix)\"},{\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"jobserver\":[],\"parallel\":[\"dep:libc\",\"dep:jobserver\"]}}", "cesu8_1.1.0": "{\"dependencies\":[],\"features\":{\"unstable\":[]}}", "cexpr_0.6.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"clang-sys\",\"req\":\">=0.13.0, <0.29.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"nom\",\"req\":\"^7\"}],\"features\":{}}", - "cfg-expr_0.20.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.7\"},{\"name\":\"smallvec\",\"req\":\"^1.15\"},{\"name\":\"target-lexicon\",\"optional\":true,\"req\":\"=0.13.3\"}],\"features\":{\"default\":[],\"targets\":[\"target-lexicon\"]}}", "cfg-if_1.0.4": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"rustc-dep-of-std\":[\"core\"]}}", "cfg_aliases_0.1.1": "{\"dependencies\":[],\"features\":{}}", "cfg_aliases_0.2.1": "{\"dependencies\":[],\"features\":{}}", @@ -713,7 +709,6 @@ "clipboard-win_5.4.1": "{\"dependencies\":[{\"name\":\"error-code\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-win\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(windows)\"}],\"features\":{\"monitor\":[\"windows-win\"],\"std\":[\"error-code/std\"]}}", "cmake_0.1.57": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.2.46\"}],\"features\":{}}", "cmp_any_0.8.1": "{\"dependencies\":[],\"features\":{}}", - "codespan-reporting_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.6.3\"},{\"kind\":\"dev\",\"name\":\"peg\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"pico-args\",\"req\":\"^0.5.0\"},{\"kind\":\"dev\",\"name\":\"rustyline\",\"req\":\"^6\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1.0.4\"},{\"name\":\"unicode-width\",\"req\":\">=0.1, <0.3\"},{\"kind\":\"dev\",\"name\":\"unindent\",\"req\":\"^0.1\"}],\"features\":{\"ascii-only\":[],\"default\":[\"std\",\"termcolor\"],\"serialization\":[\"serde\"],\"std\":[\"serde?/std\"],\"termcolor\":[\"std\",\"dep:termcolor\"]}}", "color-eyre_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ansi-parser\",\"req\":\"^0.8.0\"},{\"name\":\"backtrace\",\"req\":\"^0.3.59\"},{\"name\":\"color-spantrace\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"eyre\",\"req\":\"^0.6\"},{\"name\":\"indenter\",\"req\":\"^0.3.0\"},{\"name\":\"once_cell\",\"req\":\"^1.18.0\"},{\"name\":\"owo-colors\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.13\"},{\"name\":\"tracing-error\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.15\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"capture-spantrace\":[\"tracing-error\",\"color-spantrace\"],\"default\":[\"track-caller\",\"capture-spantrace\"],\"issue-url\":[\"url\"],\"track-caller\":[]}}", "color-spantrace_0.3.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ansi-parser\",\"req\":\"^0.8\"},{\"name\":\"once_cell\",\"req\":\"^1.18.0\"},{\"name\":\"owo-colors\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.29\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.21\"},{\"name\":\"tracing-error\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.4\"}],\"features\":{}}", "color_quant_1.1.0": "{\"dependencies\":[],\"features\":{}}", @@ -728,7 +723,6 @@ "const-oid_0.9.6": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"}],\"features\":{\"db\":[],\"std\":[]}}", "const_format_0.2.35": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.7.0\"},{\"name\":\"const_format_proc_macros\",\"req\":\"=0.2.34\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^1.3.5\"},{\"default_features\":false,\"name\":\"konst\",\"optional\":true,\"req\":\"^0.2.13\"}],\"features\":{\"__debug\":[\"const_format_proc_macros/debug\"],\"__docsrs\":[],\"__inline_const_pat_tests\":[\"__test\",\"fmt\"],\"__only_new_tests\":[\"__test\"],\"__test\":[],\"all\":[\"fmt\",\"derive\",\"rust_1_64\",\"assert\"],\"assert\":[\"assertc\"],\"assertc\":[\"fmt\",\"assertcp\"],\"assertcp\":[\"rust_1_51\"],\"const_generics\":[\"rust_1_51\"],\"constant_time_as_str\":[\"fmt\"],\"default\":[],\"derive\":[\"fmt\",\"const_format_proc_macros/derive\"],\"fmt\":[\"rust_1_83\"],\"more_str_macros\":[\"rust_1_64\"],\"nightly_const_generics\":[\"const_generics\"],\"rust_1_51\":[],\"rust_1_64\":[\"rust_1_51\",\"konst\",\"konst/rust_1_64\"],\"rust_1_83\":[\"rust_1_64\"]}}", "const_format_proc_macros_0.2.34": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^1.3.4\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.19\"},{\"name\":\"quote\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\"],\"name\":\"syn\",\"optional\":true,\"req\":\"^1.0.38\"},{\"name\":\"unicode-xid\",\"req\":\"^0.2\"}],\"features\":{\"all\":[\"derive\"],\"debug\":[\"syn/extra-traits\"],\"default\":[],\"derive\":[\"syn\",\"syn/derive\",\"syn/printing\"]}}", - "constant_time_eq_0.1.5": "{\"dependencies\":[],\"features\":{}}", "constant_time_eq_0.3.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"count_instructions\",\"req\":\"^0.1.3\"},{\"features\":[\"cargo_bench_support\",\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"}],\"features\":{\"count_instructions_test\":[]}}", "convert_case_0.10.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1.9.0\"}],\"features\":{}}", "convert_case_0.6.0": "{\"dependencies\":[{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.18.0\"},{\"kind\":\"dev\",\"name\":\"strum_macros\",\"req\":\"^0.18.0\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1.9.0\"}],\"features\":{\"random\":[\"rand\"]}}", @@ -762,11 +756,6 @@ "ctor_0.6.3": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.7\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", "curve25519-dalek-derive_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.66\"},{\"name\":\"quote\",\"req\":\"^1.0.31\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.27\"}],\"features\":{}}", "curve25519-dalek_4.1.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2.6\",\"target\":\"cfg(target_arch = \\\"x86_64\\\")\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"curve25519-dalek-derive\",\"req\":\"^0.1\",\"target\":\"cfg(all(not(curve25519_dalek_backend = \\\"fiat\\\"), not(curve25519_dalek_backend = \\\"serial\\\"), target_arch = \\\"x86_64\\\"))\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"ff\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"fiat-crypto\",\"req\":\"^0.2.1\",\"target\":\"cfg(curve25519_dalek_backend = \\\"fiat\\\")\"},{\"default_features\":false,\"name\":\"group\",\"optional\":true,\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"features\":[\"getrandom\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"zeroize?/alloc\"],\"default\":[\"alloc\",\"precomputed-tables\",\"zeroize\"],\"group\":[\"dep:group\",\"rand_core\"],\"group-bits\":[\"group\",\"ff/bits\"],\"legacy_compatibility\":[],\"precomputed-tables\":[]}}", - "cxx-build_1.0.194": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.101\"},{\"name\":\"codespan-reporting\",\"req\":\"^0.13.1\"},{\"kind\":\"dev\",\"name\":\"cxx\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"cxx-gen\",\"req\":\"^0.7\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"kind\":\"dev\",\"name\":\"pkg-config\",\"req\":\"^0.3.27\"},{\"default_features\":false,\"features\":[\"span-locations\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"scratch\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"full\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{\"parallel\":[\"cc/parallel\"]}}", - "cxx_1.0.194": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.101\"},{\"kind\":\"dev\",\"name\":\"cc\",\"req\":\"^1.0.101\"},{\"kind\":\"build\",\"name\":\"cxx-build\",\"req\":\"=1.0.194\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"cxx-build\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"cxx-gen\",\"req\":\"=0.7.194\"},{\"kind\":\"dev\",\"name\":\"cxx-test-suite\",\"req\":\"^0\"},{\"kind\":\"build\",\"name\":\"cxxbridge-cmd\",\"req\":\"=1.0.194\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"cxxbridge-flags\",\"req\":\"=1.0.194\"},{\"name\":\"cxxbridge-macro\",\"req\":\"=1.0.194\"},{\"default_features\":false,\"name\":\"foldhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2\"},{\"name\":\"link-cplusplus\",\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.95\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.40\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"kind\":\"dev\",\"name\":\"scratch\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"target-triple\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"alloc\":[],\"c++14\":[\"cxxbridge-flags/c++14\"],\"c++17\":[\"cxxbridge-flags/c++17\"],\"c++20\":[\"cxxbridge-flags/c++20\"],\"default\":[\"std\",\"cxxbridge-flags/default\"],\"std\":[\"alloc\",\"foldhash/std\"]}}", - "cxxbridge-cmd_1.0.194": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"error-context\",\"help\",\"std\",\"suggestions\",\"usage\"],\"name\":\"clap\",\"req\":\"^4.3.11\"},{\"name\":\"codespan-reporting\",\"req\":\"^0.13.1\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"default_features\":false,\"features\":[\"span-locations\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"full\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}", - "cxxbridge-flags_1.0.194": "{\"dependencies\":[],\"features\":{\"c++14\":[],\"c++17\":[],\"c++20\":[],\"default\":[]}}", - "cxxbridge-macro_1.0.194": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cxx\",\"req\":\"^1.0\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.35\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}", "darling_0.20.11": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.20.11\"},{\"name\":\"darling_macro\",\"req\":\"=0.20.11\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"suggestions\":[\"darling_core/suggestions\"]}}", "darling_0.21.3": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.21.3\"},{\"name\":\"darling_macro\",\"req\":\"=0.21.3\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"serde\":[\"darling_core/serde\"],\"suggestions\":[\"darling_core/suggestions\"]}}", "darling_0.23.0": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.23.0\"},{\"name\":\"darling_macro\",\"req\":\"=0.23.0\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"serde\":[\"darling_core/serde\"],\"suggestions\":[\"darling_core/suggestions\"]}}", @@ -785,7 +774,6 @@ "debugid_0.8.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.85\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.37\"},{\"name\":\"uuid\",\"req\":\"^1.0.0\"}],\"features\":{}}", "debugserver-types_0.5.0": "{\"dependencies\":[{\"name\":\"schemafy\",\"req\":\"^0.5.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", "deflate64_0.1.10": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{}}", - "deno_core_icudata_0.77.0": "{\"dependencies\":[],\"features\":{}}", "der-parser_10.0.0": "{\"dependencies\":[{\"name\":\"asn1-rs\",\"req\":\"^0.7\"},{\"name\":\"bitvec\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"cookie-factory\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"nom\",\"req\":\"^7.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3.0\"}],\"features\":{\"as_bitvec\":[\"bitvec\"],\"bigint\":[\"num-bigint\"],\"default\":[\"std\"],\"serialize\":[\"std\",\"cookie-factory\"],\"std\":[],\"unstable\":[]}}", "der_0.7.10": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.9.2\"},{\"name\":\"der_derive\",\"optional\":true,\"req\":\"^0.7.2\"},{\"name\":\"flagset\",\"optional\":true,\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"features\":[\"alloc\"],\"name\":\"pem-rfc7468\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[\"zeroize?/alloc\"],\"arbitrary\":[\"dep:arbitrary\",\"const-oid?/arbitrary\",\"std\"],\"bytes\":[\"dep:bytes\",\"alloc\"],\"derive\":[\"dep:der_derive\"],\"oid\":[\"dep:const-oid\"],\"pem\":[\"dep:pem-rfc7468\",\"alloc\",\"zeroize\"],\"real\":[],\"std\":[\"alloc\"]}}", "deranged_0.5.5": "{\"dependencies\":[{\"name\":\"deranged-macros\",\"optional\":true,\"req\":\"=0.3.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.15\"},{\"default_features\":false,\"name\":\"powerfmt\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"rand08\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"rand08\",\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"rand09\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand09\",\"package\":\"rand\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.86\"}],\"features\":{\"alloc\":[],\"default\":[],\"macros\":[\"dep:deranged-macros\"],\"num\":[\"dep:num-traits\"],\"powerfmt\":[\"dep:powerfmt\"],\"quickcheck\":[\"dep:quickcheck\",\"alloc\"],\"rand\":[\"rand08\",\"rand09\"],\"rand08\":[\"dep:rand08\"],\"rand09\":[\"dep:rand09\"],\"serde\":[\"dep:serde_core\"]}}", @@ -822,8 +810,6 @@ "dylint_linting_5.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_cmd\",\"req\":\"^2.0\"},{\"name\":\"cargo_metadata\",\"req\":\"^0.23\"},{\"features\":[\"config\"],\"name\":\"dylint_internal\",\"req\":\"=5.0.0\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustc_version\",\"req\":\"^0.4\"},{\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.23\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"name\":\"toml\",\"req\":\"^0.9\"},{\"kind\":\"build\",\"name\":\"toml\",\"req\":\"^0.9\"}],\"features\":{\"constituent\":[]}}", "dylint_testing_5.0.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"cargo_metadata\",\"req\":\"^0.23\"},{\"name\":\"compiletest_rs\",\"req\":\"^0.11\"},{\"name\":\"dylint\",\"req\":\"=5.0.0\"},{\"name\":\"dylint_internal\",\"req\":\"=5.0.0\"},{\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"once_cell\",\"req\":\"^1.21\"},{\"name\":\"regex\",\"req\":\"^1.11\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"tempfile\",\"req\":\"^3.23\"}],\"features\":{\"default\":[],\"deny_warnings\":[]}}", "dyn-clone_1.0.20": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.66\"}],\"features\":{}}", - "ed25519-dalek_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"blake2\",\"req\":\"^0.10\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"features\":[\"digest\"],\"name\":\"curve25519-dalek\",\"req\":\"^4\"},{\"default_features\":false,\"features\":[\"digest\",\"rand_core\"],\"kind\":\"dev\",\"name\":\"curve25519-dalek\",\"req\":\"^4\"},{\"default_features\":false,\"name\":\"ed25519\",\"req\":\">=2.2, <2.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"merlin\",\"optional\":true,\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"signature\",\"optional\":true,\"req\":\">=2.0, <2.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"static_secrets\"],\"kind\":\"dev\",\"name\":\"x25519-dalek\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[\"curve25519-dalek/alloc\",\"ed25519/alloc\",\"serde?/alloc\",\"zeroize/alloc\"],\"asm\":[\"sha2/asm\"],\"batch\":[\"alloc\",\"merlin\",\"rand_core\"],\"default\":[\"fast\",\"std\",\"zeroize\"],\"digest\":[\"signature/digest\"],\"fast\":[\"curve25519-dalek/precomputed-tables\"],\"hazmat\":[],\"legacy_compatibility\":[\"curve25519-dalek/legacy_compatibility\"],\"pem\":[\"alloc\",\"ed25519/pem\",\"pkcs8\"],\"pkcs8\":[\"ed25519/pkcs8\"],\"rand_core\":[\"dep:rand_core\"],\"serde\":[\"dep:serde\",\"ed25519/serde\"],\"std\":[\"alloc\",\"ed25519/std\",\"serde?/std\",\"sha2/std\"],\"zeroize\":[\"dep:zeroize\",\"curve25519-dalek/zeroize\"]}}", - "ed25519_2.2.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"features\":[\"rand_core\"],\"kind\":\"dev\",\"name\":\"ed25519-dalek\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"signature\"],\"kind\":\"dev\",\"name\":\"ring-compat\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"serde_bytes\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"signature\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"pkcs8?/alloc\"],\"default\":[\"std\"],\"pem\":[\"alloc\",\"pkcs8/pem\"],\"serde_bytes\":[\"serde\",\"dep:serde_bytes\"],\"std\":[\"pkcs8?/std\",\"signature/std\"]}}", "either_1.15.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\",\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.95\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[],\"use_std\":[\"std\"]}}", "ena_0.14.3": "{\"dependencies\":[{\"name\":\"dogged\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"log\",\"req\":\"^0.4\"}],\"features\":{\"bench\":[],\"persistent\":[\"dogged\"]}}", "encode_unicode_1.0.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ascii\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.0\",\"target\":\"cfg(unix)\"},{\"features\":[\"https-native\"],\"kind\":\"dev\",\"name\":\"minreq\",\"req\":\"^2.6\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -875,7 +861,6 @@ "foreign-types-shared_0.1.1": "{\"dependencies\":[],\"features\":{}}", "foreign-types_0.3.2": "{\"dependencies\":[{\"name\":\"foreign-types-shared\",\"req\":\"^0.1\"}],\"features\":{}}", "form_urlencoded_1.2.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"}],\"features\":{\"alloc\":[\"percent-encoding/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"percent-encoding/std\"]}}", - "fs2_0.4.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.30\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3\"},{\"features\":[\"handleapi\",\"processthreadsapi\",\"winerror\",\"fileapi\",\"winbase\",\"std\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "fs_extra_1.3.0": "{\"dependencies\":[],\"features\":{}}", "fsevent-sys_4.1.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.68\"}],\"features\":{}}", "fslock_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.66\",\"target\":\"cfg(unix)\"},{\"features\":[\"minwindef\",\"minwinbase\",\"winbase\",\"errhandlingapi\",\"winerror\",\"winnt\",\"synchapi\",\"handleapi\",\"fileapi\",\"processthreadsapi\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -900,27 +885,17 @@ "getrandom_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), not(any(all(target_os = \\\"linux\\\", target_env = \\\"\\\"), getrandom_backend = \\\"custom\\\", getrandom_backend = \\\"linux_raw\\\", getrandom_backend = \\\"rdrand\\\", getrandom_backend = \\\"rndr\\\"))))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"hurd\\\", target_os = \\\"illumos\\\", target_os = \\\"cygwin\\\", all(target_os = \\\"horizon\\\", target_arch = \\\"arm\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"haiku\\\", target_os = \\\"redox\\\", target_os = \\\"nto\\\", target_os = \\\"aix\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\", target_os = \\\"tvos\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"openbsd\\\", target_os = \\\"vita\\\", target_os = \\\"emscripten\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"netbsd\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"solaris\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"vxworks\\\")\"},{\"default_features\":false,\"name\":\"r-efi\",\"req\":\"^6\",\"target\":\"cfg(all(target_os = \\\"uefi\\\", getrandom_backend = \\\"efi_rng\\\"))\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"wasip2\",\"req\":\"^1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p2\\\"))\"},{\"name\":\"wasip3\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p3\\\"))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"}],\"features\":{\"std\":[],\"sys_rng\":[\"dep:rand_core\"],\"wasm_js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"]}}", "gif_0.14.1": "{\"dependencies\":[{\"name\":\"color_quant\",\"optional\":true,\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"png\",\"req\":\"^0.18.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.10.0\"},{\"name\":\"weezl\",\"req\":\"^0.1.10\"}],\"features\":{\"color_quant\":[\"dep:color_quant\"],\"default\":[\"raii_no_panic\",\"std\",\"color_quant\"],\"raii_no_panic\":[],\"std\":[]}}", "gimli_0.32.3": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"test-assembler\",\"req\":\"^0.1.3\"}],\"features\":{\"default\":[\"read-all\",\"write\"],\"endian-reader\":[\"read\",\"dep:stable_deref_trait\"],\"fallible-iterator\":[\"dep:fallible-iterator\"],\"read\":[\"read-core\"],\"read-all\":[\"read\",\"std\",\"fallible-iterator\",\"endian-reader\"],\"read-core\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\"],\"std\":[\"fallible-iterator?/std\",\"stable_deref_trait?/std\"],\"write\":[\"dep:indexmap\"]}}", - "gio-sys_0.21.5": "{\"dependencies\":[{\"name\":\"glib-sys\",\"req\":\"^0.21\"},{\"name\":\"gobject-sys\",\"req\":\"^0.21\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"shell-words\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"system-deps\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"v2_58\":[],\"v2_60\":[\"v2_58\"],\"v2_62\":[\"v2_60\"],\"v2_64\":[\"v2_62\"],\"v2_66\":[\"v2_64\"],\"v2_68\":[\"v2_66\"],\"v2_70\":[\"v2_68\"],\"v2_72\":[\"v2_70\"],\"v2_74\":[\"v2_72\"],\"v2_76\":[\"v2_74\"],\"v2_78\":[\"v2_76\"],\"v2_80\":[\"v2_78\"],\"v2_82\":[\"v2_80\"],\"v2_84\":[\"v2_82\"],\"v2_86\":[\"v2_84\"]}}", "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81_runfiles": "{\"dependencies\":[],\"features\":{},\"strip_prefix\":\"\"}", "git+https://github.com/helix-editor/nucleo.git?rev=4253de9faabb4e5c6d81d946a5e35a90f87347ee#4253de9faabb4e5c6d81d946a5e35a90f87347ee_nucleo": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"nucleo-matcher\",\"optional\":false},{\"default_features\":true,\"features\":[\"send_guard\",\"arc_lock\"],\"name\":\"parking_lot\",\"optional\":false},{\"name\":\"rayon\"}],\"features\":{},\"strip_prefix\":\"\"}", "git+https://github.com/helix-editor/nucleo.git?rev=4253de9faabb4e5c6d81d946a5e35a90f87347ee#4253de9faabb4e5c6d81d946a5e35a90f87347ee_nucleo-matcher": "{\"dependencies\":[{\"name\":\"memchr\"},{\"default_features\":true,\"features\":[],\"name\":\"unicode-segmentation\",\"optional\":true}],\"features\":{\"default\":[\"unicode-normalization\",\"unicode-casefold\",\"unicode-segmentation\"],\"unicode-casefold\":[],\"unicode-normalization\":[],\"unicode-segmentation\":[\"dep:unicode-segmentation\"]},\"strip_prefix\":\"matcher\"}", - "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_libwebrtc": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"livekit-protocol\",\"optional\":false},{\"name\":\"log\"},{\"features\":[\"derive\"],\"name\":\"serde\"},{\"name\":\"serde_json\"},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"glib\",\"optional\":true,\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"freebsd\\\"))\"},{\"name\":\"cxx\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"lazy_static\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":true,\"features\":[],\"name\":\"livekit-runtime\",\"optional\":false,\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"parking_lot\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rtrb\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"sync\"],\"name\":\"tokio\",\"optional\":false,\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":true,\"features\":[],\"name\":\"webrtc-sys\",\"optional\":false,\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":true,\"features\":[\"MessageEvent\",\"RtcPeerConnection\",\"RtcSignalingState\",\"RtcSdpType\",\"RtcSessionDescriptionInit\",\"RtcPeerConnectionIceEvent\",\"RtcIceCandidate\",\"RtcDataChannel\",\"RtcDataChannelEvent\",\"RtcDataChannelState\",\"EventTarget\",\"WebGlRenderingContext\",\"WebGlTexture\"],\"name\":\"web-sys\",\"optional\":false,\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"jni\",\"target\":\"cfg(target_os = \\\"android\\\")\"}],\"features\":{\"default\":[\"glib-main-loop\"],\"glib-main-loop\":[\"dep:glib\"]},\"strip_prefix\":\"libwebrtc\"}", - "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_livekit-protocol": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\"],\"name\":\"futures-util\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"livekit-runtime\",\"optional\":false},{\"name\":\"parking_lot\"},{\"name\":\"pbjson\"},{\"name\":\"pbjson-types\"},{\"name\":\"prost\"},{\"name\":\"serde\"},{\"name\":\"thiserror\"},{\"default_features\":false,\"features\":[\"macros\",\"rt\",\"sync\"],\"name\":\"tokio\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"livekit-protocol\"}", - "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_livekit-runtime": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"async-io\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"async-std\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"async-task\",\"optional\":true},{\"name\":\"futures\",\"optional\":true},{\"default_features\":false,\"features\":[\"net\",\"rt\",\"rt-multi-thread\",\"time\"],\"name\":\"tokio\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"tokio-stream\",\"optional\":true}],\"features\":{\"async\":[\"dep:async-std\",\"dep:futures\",\"dep:async-io\"],\"default\":[\"tokio\"],\"dispatcher\":[\"dep:futures\",\"dep:async-io\",\"dep:async-std\",\"dep:async-task\"],\"tokio\":[\"dep:tokio\",\"dep:tokio-stream\"]},\"strip_prefix\":\"livekit-runtime\"}", - "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_webrtc-sys": "{\"dependencies\":[{\"name\":\"cxx\"},{\"name\":\"log\"},{\"kind\":\"build\",\"name\":\"cc\"},{\"kind\":\"build\",\"name\":\"cxx-build\"},{\"kind\":\"build\",\"name\":\"glob\"},{\"kind\":\"build\",\"name\":\"pkg-config\"},{\"default_features\":true,\"features\":[],\"kind\":\"build\",\"name\":\"webrtc-sys-build\",\"optional\":false}],\"features\":{\"default\":[]},\"strip_prefix\":\"webrtc-sys\"}", - "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_webrtc-sys-build": "{\"dependencies\":[{\"name\":\"anyhow\"},{\"name\":\"fs2\"},{\"name\":\"regex\"},{\"default_features\":false,\"features\":[\"rustls-tls-native-roots\",\"blocking\"],\"name\":\"reqwest\",\"optional\":false},{\"name\":\"scratch\"},{\"name\":\"semver\"},{\"name\":\"zip\"}],\"features\":{},\"strip_prefix\":\"webrtc-sys/build\"}", - "git+https://github.com/nornagon/crossterm?rev=87db8bfa6dc99427fd3b071681b07fc31c6ce995#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", - "git+https://github.com/nornagon/ratatui?rev=9b2ad1298408c45918ee9f8241a6f95498cdbed2#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}", "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=132f5b39c862e3a970f731d709608b3e6276d5f6#132f5b39c862e3a970f731d709608b3e6276d5f6_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"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\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/rust-lang/rust-clippy?rev=20ce69b9a63bcd2756cd906fe0964d1e901e042a#20ce69b9a63bcd2756cd906fe0964d1e901e042a_clippy_utils": "{\"dependencies\":[{\"default_features\":false,\"features\":[],\"name\":\"arrayvec\",\"optional\":false},{\"name\":\"itertools\"},{\"name\":\"rustc_apfloat\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"clippy_utils\"}", "git2_0.20.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.13\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"libgit2-sys\",\"req\":\"^0.18.3\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"openssl-probe\",\"optional\":true,\"req\":\"^0.1\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.45\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"formatting\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"name\":\"url\",\"req\":\"^2.5.4\"}],\"features\":{\"default\":[\"ssh\",\"https\"],\"https\":[\"libgit2-sys/https\",\"openssl-sys\",\"openssl-probe\"],\"ssh\":[\"libgit2-sys/ssh\"],\"unstable\":[],\"vendored-libgit2\":[\"libgit2-sys/vendored\"],\"vendored-openssl\":[\"openssl-sys/vendored\",\"libgit2-sys/vendored-openssl\"],\"zlib-ng-compat\":[\"libgit2-sys/zlib-ng-compat\"]}}", - "glib-macros_0.21.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"glib\",\"req\":\"^0.21\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"proc-macro-crate\",\"req\":\"^3.3\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.104\"},{\"kind\":\"dev\",\"name\":\"trybuild2\",\"req\":\"^1.2\"}],\"features\":{}}", - "glib-sys_0.21.5": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"shell-words\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"system-deps\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"v2_58\":[],\"v2_60\":[\"v2_58\"],\"v2_62\":[\"v2_60\"],\"v2_64\":[\"v2_62\"],\"v2_66\":[\"v2_64\"],\"v2_68\":[\"v2_66\"],\"v2_70\":[\"v2_68\"],\"v2_72\":[\"v2_70\"],\"v2_74\":[\"v2_72\"],\"v2_76\":[\"v2_74\"],\"v2_78\":[\"v2_76\"],\"v2_80\":[\"v2_78\"],\"v2_82\":[\"v2_80\"],\"v2_84\":[\"v2_82\"],\"v2_86\":[\"v2_84\"]}}", - "glib_0.21.5": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.9\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"name\":\"futures-channel\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"name\":\"futures-executor\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"gio-sys\",\"optional\":true,\"req\":\"^0.21\"},{\"kind\":\"dev\",\"name\":\"gir-format-check\",\"req\":\"^0.1\"},{\"name\":\"glib-macros\",\"req\":\"^0.21\"},{\"name\":\"glib-sys\",\"req\":\"^0.21\"},{\"name\":\"gobject-sys\",\"req\":\"^0.21\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"memchr\",\"req\":\"^2.7.5\"},{\"name\":\"rs-log\",\"optional\":true,\"package\":\"log\",\"req\":\"^0.4\"},{\"features\":[\"union\",\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"req\":\"^1.15\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"trybuild2\",\"req\":\"^1\"}],\"features\":{\"compiletests\":[],\"default\":[\"gio\"],\"gio\":[\"gio-sys\"],\"log\":[\"rs-log\"],\"log_macros\":[\"log\"],\"v2_58\":[\"glib-sys/v2_58\",\"gobject-sys/v2_58\"],\"v2_60\":[\"v2_58\",\"glib-sys/v2_60\"],\"v2_62\":[\"v2_60\",\"glib-sys/v2_62\",\"gobject-sys/v2_62\"],\"v2_64\":[\"v2_62\",\"glib-sys/v2_64\"],\"v2_66\":[\"v2_64\",\"glib-sys/v2_66\",\"gobject-sys/v2_66\"],\"v2_68\":[\"v2_66\",\"glib-sys/v2_68\",\"gobject-sys/v2_68\"],\"v2_70\":[\"v2_68\",\"glib-sys/v2_70\",\"gobject-sys/v2_70\"],\"v2_72\":[\"v2_70\",\"glib-sys/v2_72\",\"gobject-sys/v2_72\"],\"v2_74\":[\"v2_72\",\"glib-sys/v2_74\",\"gobject-sys/v2_74\"],\"v2_76\":[\"v2_74\",\"glib-sys/v2_76\",\"gobject-sys/v2_76\"],\"v2_78\":[\"v2_76\",\"glib-sys/v2_78\",\"gobject-sys/v2_78\"],\"v2_80\":[\"v2_78\",\"glib-sys/v2_80\",\"gobject-sys/v2_80\"],\"v2_82\":[\"v2_80\",\"glib-sys/v2_82\",\"gobject-sys/v2_82\"],\"v2_84\":[\"v2_82\",\"glib-sys/v2_84\",\"gobject-sys/v2_84\"],\"v2_86\":[\"v2_84\",\"glib-sys/v2_86\",\"gobject-sys/v2_86\"]}}", "glob_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3\"}],\"features\":{}}", "globset_0.4.18": "{\"dependencies\":[{\"name\":\"aho-corasick\",\"req\":\"^1.1.1\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"features\":[\"std\",\"perf\",\"syntax\",\"meta\",\"nfa\",\"hybrid\"],\"name\":\"regex-automata\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-syntax\",\"req\":\"^0.8.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.188\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.107\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"log\"],\"serde1\":[\"serde\"],\"simd-accel\":[]}}", - "gobject-sys_0.21.5": "{\"dependencies\":[{\"name\":\"glib-sys\",\"req\":\"^0.21\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"shell-words\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"system-deps\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"v2_58\":[],\"v2_62\":[\"v2_58\"],\"v2_66\":[\"v2_62\"],\"v2_68\":[\"v2_66\"],\"v2_70\":[\"v2_68\"],\"v2_72\":[\"v2_70\"],\"v2_74\":[\"v2_72\"],\"v2_76\":[\"v2_74\"],\"v2_78\":[\"v2_74\"],\"v2_80\":[\"v2_78\"],\"v2_82\":[\"v2_80\"],\"v2_84\":[\"v2_82\"],\"v2_86\":[\"v2_84\"]}}", "gzip-header_1.0.0": "{\"dependencies\":[{\"name\":\"crc32fast\",\"req\":\"^1.2.1\"}],\"features\":{}}", "h2_0.4.13": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"req\":\"^1.0.0\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"name\":\"slab\",\"req\":\"^0.4.2\"},{\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\",\"sync\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"features\":[\"codec\",\"io\"],\"name\":\"tokio-util\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"stream\":[],\"unstable\":[]}}", "half_2.7.1": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.4.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"crunchy\",\"req\":\"^0.2.2\",\"target\":\"cfg(target_arch = \\\"spirv\\\")\"},{\"kind\":\"dev\",\"name\":\"crunchy\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.16\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"rand_distr\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"simd\"],\"name\":\"zerocopy\",\"req\":\"^0.8.26\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"nightly\":[],\"rand_distr\":[\"dep:rand\",\"dep:rand_distr\"],\"std\":[\"alloc\"],\"use-intrinsics\":[],\"zerocopy\":[]}}", @@ -931,7 +906,6 @@ "hashlink_0.10.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"serde_impl\":[\"serde\"]}}", "headers-core_0.3.0": "{\"dependencies\":[{\"name\":\"http\",\"req\":\"^1.0.0\"}],\"features\":{}}", "headers_0.4.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"headers-core\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"httpdate\",\"req\":\"^1\"},{\"name\":\"mime\",\"req\":\"^0.3.14\"},{\"name\":\"sha1\",\"req\":\"^0.10\"}],\"features\":{\"nightly\":[]}}", - "heck_0.4.1": "{\"dependencies\":[{\"name\":\"unicode-segmentation\",\"optional\":true,\"req\":\"^1.2.0\"}],\"features\":{\"default\":[],\"unicode\":[\"unicode-segmentation\"]}}", "heck_0.5.0": "{\"dependencies\":[],\"features\":{}}", "hermit-abi_0.5.2": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[],\"rustc-dep-of-std\":[\"core\",\"alloc\"]}}", "hex_0.4.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"faster-hex\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^2.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", @@ -1004,7 +978,6 @@ "is_ci_1.2.0": "{\"dependencies\":[],\"features\":{}}", "is_terminal_polyfill_1.70.2": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "itertools_0.10.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"= 0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", - "itertools_0.11.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", "itertools_0.12.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", "itertools_0.13.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", "itertools_0.14.0": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", @@ -1026,6 +999,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\"]}}", @@ -1040,7 +1014,6 @@ "libssh2-sys_0.3.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.25\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"req\":\"^0.9.35\",\"target\":\"cfg(unix)\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.35\",\"target\":\"cfg(windows)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.11\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2\",\"target\":\"cfg(target_env = \\\"msvc\\\")\"}],\"features\":{\"openssl-on-win32\":[\"openssl-sys\"],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\"]}}", "libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "libz-sys_1.1.25": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", - "link-cplusplus_1.0.12": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1\"}],\"features\":{\"default\":[],\"libc++\":[],\"libcxx\":[\"libc++\"],\"libstdc++\":[],\"libstdcxx\":[\"libstdc++\"],\"nothing\":[]}}", "linked-hash-map_0.5.6": "{\"dependencies\":[{\"name\":\"heapsize\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"heapsize_impl\":[\"heapsize\"],\"nightly\":[],\"serde_impl\":[\"serde\"]}}", "linux-keyutils_0.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"default_features\":false,\"features\":[\"std\",\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.11\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.132\"},{\"kind\":\"dev\",\"name\":\"zeroize\",\"req\":\"^1.5.7\"}],\"features\":{\"default\":[],\"std\":[\"bitflags/std\"]}}", "linux-raw-sys_0.11.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"auxvec\":[],\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"image\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"no_std\"],\"std\":[],\"system\":[],\"xdp\":[]}}", @@ -1134,6 +1107,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\":{}}", @@ -1156,16 +1149,11 @@ "parking_2.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{}}", "parking_lot_0.12.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.3\"},{\"name\":\"lock_api\",\"req\":\"^0.4.14\"},{\"name\":\"parking_lot_core\",\"req\":\"^0.9.12\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"}],\"features\":{\"arc_lock\":[\"lock_api/arc_lock\"],\"deadlock_detection\":[\"parking_lot_core/deadlock_detection\"],\"default\":[],\"hardware-lock-elision\":[],\"nightly\":[\"parking_lot_core/nightly\",\"lock_api/nightly\"],\"owning_ref\":[\"lock_api/owning_ref\"],\"send_guard\":[],\"serde\":[\"lock_api/serde\"]}}", "parking_lot_core_0.9.12": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.60\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"libc\",\"req\":\"^0.2.95\",\"target\":\"cfg(unix)\"},{\"name\":\"petgraph\",\"optional\":true,\"req\":\"^0.6.0\"},{\"name\":\"redox_syscall\",\"req\":\"^0.5\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"name\":\"smallvec\",\"req\":\"^1.6.1\"},{\"name\":\"windows-link\",\"req\":\"^0.2.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"deadlock_detection\":[\"petgraph\",\"backtrace\"],\"nightly\":[]}}", - "password-hash_0.4.2": "{\"dependencies\":[{\"name\":\"base64ct\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"}],\"features\":{\"alloc\":[\"base64ct/alloc\"],\"default\":[\"rand_core\"],\"std\":[\"alloc\",\"base64ct/std\",\"rand_core/std\"]}}", "paste_1.0.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"paste-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", "pastey_0.2.1": "{\"dependencies\":[],\"features\":{}}", "path-absolutize_3.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"path-dedot\",\"req\":\"^3.1.1\"},{\"kind\":\"dev\",\"name\":\"slash-formatter\",\"req\":\"^3\",\"target\":\"cfg(windows)\"}],\"features\":{\"lazy_static_cache\":[\"path-dedot/lazy_static_cache\"],\"once_cell_cache\":[\"path-dedot/once_cell_cache\"],\"unsafe_cache\":[\"path-dedot/unsafe_cache\"],\"use_unix_paths_on_wasm\":[\"path-dedot/use_unix_paths_on_wasm\"]}}", "path-dedot_3.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"lazy_static\",\"optional\":true,\"req\":\"^1.4\"},{\"name\":\"once_cell\",\"req\":\"^1.4\"}],\"features\":{\"lazy_static_cache\":[\"lazy_static\"],\"once_cell_cache\":[],\"unsafe_cache\":[],\"use_unix_paths_on_wasm\":[]}}", "pathdiff_0.2.3": "{\"dependencies\":[{\"name\":\"camino\",\"optional\":true,\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1.0.0\"}],\"features\":{}}", - "pbjson-build_0.6.2": "{\"dependencies\":[{\"name\":\"heck\",\"req\":\"^0.4\"},{\"name\":\"itertools\",\"req\":\"^0.11\"},{\"name\":\"prost\",\"req\":\"^0.12\"},{\"name\":\"prost-types\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1\"}],\"features\":{}}", - "pbjson-types_0.6.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"pbjson\",\"req\":\"^0.6\"},{\"kind\":\"build\",\"name\":\"pbjson-build\",\"req\":\"^0.6\"},{\"name\":\"prost\",\"req\":\"^0.12\"},{\"kind\":\"build\",\"name\":\"prost-build\",\"req\":\"^0.12\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", - "pbjson_0.6.0": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.21\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"}],\"features\":{}}", - "pbkdf2_0.11.0": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"hmac\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"rand_core\"],\"name\":\"password-hash\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"sha1\",\"optional\":true,\"package\":\"sha-1\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"package\":\"sha-1\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"simple\"],\"parallel\":[\"rayon\",\"std\"],\"simple\":[\"hmac\",\"password-hash\",\"sha2\"],\"std\":[\"password-hash/std\"]}}", "pbkdf2_0.12.2": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"hmac\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"rand_core\"],\"name\":\"password-hash\",\"optional\":true,\"req\":\"^0.5\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.7\"},{\"default_features\":false,\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"hmac\"],\"parallel\":[\"rayon\",\"std\"],\"simple\":[\"hmac\",\"password-hash\",\"sha2\"],\"std\":[\"password-hash/std\"]}}", "pem-rfc7468_0.7.0": "{\"dependencies\":[{\"name\":\"base64ct\",\"req\":\"^1.4\"}],\"features\":{\"alloc\":[\"base64ct/alloc\"],\"std\":[\"alloc\",\"base64ct/std\"]}}", "pem_3.0.6": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde_core\"],\"std\":[\"base64/std\",\"serde_core?/std\"]}}", @@ -1208,10 +1196,12 @@ "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-build_0.14.3": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"heck\",\"req\":\">=0.4, <=0.5\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.14\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"multimap\",\"req\":\">=0.8, <=0.10\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"petgraph\",\"req\":\"^0.8\"},{\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"prost\",\"req\":\"^0.14.3\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.14.3\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"pulldown-cmark-to-cmark\",\"optional\":true,\"req\":\"^22\"},{\"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-types_0.14.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.4\"},{\"default_features\":false,\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"prost\",\"req\":\"^0.14.3\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"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\":[]}}", @@ -1220,6 +1210,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\"]}}", @@ -1246,7 +1237,7 @@ "rama-unix_0.3.0-alpha.4": "{\"dependencies\":[{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"name\":\"rama-core\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"rama-net\",\"req\":\"^0.3.0-alpha.4\"},{\"features\":[\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.48\"}],\"features\":{\"default\":[]}}", "rama-utils_0.3.0-alpha.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"name\":\"const_format\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"name\":\"rama-macros\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"regex\",\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"},{\"features\":[\"write\",\"serde\",\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"req\":\"^1.15\"},{\"name\":\"smol_str\",\"req\":\"^0.3\"},{\"features\":[\"time\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"name\":\"wildcard\",\"req\":\"^0.3\"}],\"features\":{}}", "rand_0.8.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.2.1\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.22\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"features\":[\"into_bits\"],\"name\":\"packed_simd\",\"optional\":true,\"package\":\"packed_simd_2\",\"req\":\"^0.3.7\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"rand_core\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"}],\"features\":{\"alloc\":[\"rand_core/alloc\"],\"default\":[\"std\",\"std_rng\"],\"getrandom\":[\"rand_core/getrandom\"],\"min_const_gen\":[],\"nightly\":[],\"serde1\":[\"serde\",\"rand_core/serde1\"],\"simd_support\":[\"packed_simd\"],\"small_rng\":[],\"std\":[\"rand_core/std\",\"rand_chacha/std\",\"alloc\",\"getrandom\",\"libc\"],\"std_rng\":[\"rand_chacha\"]}}", - "rand_0.9.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.2.1\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"optional\":true,\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"std_rng\",\"os_rng\",\"small_rng\",\"thread_rng\"],\"log\":[],\"nightly\":[],\"os_rng\":[\"rand_core/os_rng\"],\"serde\":[\"dep:serde\",\"rand_core/serde\"],\"simd_support\":[],\"small_rng\":[],\"std\":[\"rand_core/std\",\"rand_chacha?/std\",\"alloc\"],\"std_rng\":[\"dep:rand_chacha\"],\"thread_rng\":[\"std\",\"std_rng\",\"os_rng\"],\"unbiased\":[]}}", + "rand_0.9.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.2.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"optional\":true,\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"std_rng\",\"os_rng\",\"small_rng\",\"thread_rng\"],\"log\":[\"dep:log\"],\"nightly\":[],\"os_rng\":[\"rand_core/os_rng\"],\"serde\":[\"dep:serde\",\"rand_core/serde\"],\"simd_support\":[],\"small_rng\":[],\"std\":[\"rand_core/std\",\"rand_chacha?/std\",\"alloc\"],\"std_rng\":[\"dep:rand_chacha\"],\"thread_rng\":[\"std\",\"std_rng\",\"os_rng\"],\"unbiased\":[]}}", "rand_chacha_0.3.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"ppv-lite86\",\"req\":\"^0.2.8\"},{\"name\":\"rand_core\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde1\":[\"serde\"],\"simd\":[],\"std\":[\"ppv-lite86/std\"]}}", "rand_chacha_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"ppv-lite86\",\"req\":\"^0.2.14\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"features\":[\"os_rng\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"os_rng\":[\"rand_core/os_rng\"],\"serde\":[\"dep:serde\"],\"std\":[\"ppv-lite86/std\",\"rand_core/std\"]}}", "rand_core_0.6.4": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"serde1\":[\"serde\"],\"std\":[\"alloc\",\"getrandom\",\"getrandom/std\"]}}", @@ -1277,7 +1268,6 @@ "rmcp-macros_0.15.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "rmcp_0.15.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"reqwest\"],\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls-tls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-tls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"dep:axum\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"]}}", "rsa_0.9.10": "{\"dependencies\":[{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"base64ct\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"oid\"],\"name\":\"digest\",\"req\":\"^0.10.5\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"default_features\":false,\"features\":[\"i128\",\"prime\",\"zeroize\"],\"name\":\"num-bigint\",\"package\":\"num-bigint-dig\",\"req\":\"^0.8.6\"},{\"default_features\":false,\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"req\":\"^0.2.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"pkcs8\"],\"name\":\"pkcs1\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"pkcs8\",\"req\":\"^0.10.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.184\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.89\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"digest\",\"rand_core\"],\"name\":\"signature\",\"req\":\">2.0, <2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"spki\",\"req\":\"^0.7.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.1.1\"},{\"features\":[\"alloc\"],\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"pem\",\"u64_digit\"],\"getrandom\":[\"rand_core/getrandom\"],\"hazmat\":[],\"nightly\":[\"num-bigint/nightly\"],\"pem\":[\"pkcs1/pem\",\"pkcs8/pem\"],\"pkcs5\":[\"pkcs8/encryption\"],\"serde\":[\"dep:serde\",\"num-bigint/serde\"],\"std\":[\"digest/std\",\"pkcs1/std\",\"pkcs8/std\",\"rand_core/std\",\"signature/std\"],\"u64_digit\":[\"num-bigint/u64_digit\"]}}", - "rtrb_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "rust-embed-impl_8.11.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rust-embed-utils\",\"req\":\"^8.11.0\"},{\"name\":\"shellexpand\",\"optional\":true,\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"proc-macro\",\"printing\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"compression\":[],\"debug-embed\":[],\"deterministic-timestamps\":[],\"include-exclude\":[\"rust-embed-utils/include-exclude\"],\"interpolate-folder-path\":[\"shellexpand\"],\"mime-guess\":[\"rust-embed-utils/mime-guess\"]}}", "rust-embed-utils_8.11.0": "{\"dependencies\":[{\"name\":\"globset\",\"optional\":true,\"req\":\"^0.4.8\"},{\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0.4\"},{\"name\":\"sha2\",\"req\":\"^0.10.5\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"debug-embed\":[],\"include-exclude\":[\"globset\"],\"mime-guess\":[\"mime_guess\"]}}", "rust-embed_8.11.0": "{\"dependencies\":[{\"name\":\"actix-web\",\"optional\":true,\"req\":\"^4\"},{\"default_features\":false,\"features\":[\"http1\",\"tokio\"],\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"hex\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"include-flate\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0.5\"},{\"default_features\":false,\"features\":[\"server\"],\"name\":\"poem\",\"optional\":true,\"req\":\"^1.3.30\"},{\"default_features\":false,\"name\":\"rocket\",\"optional\":true,\"req\":\"^0.5.0-rc.2\"},{\"name\":\"rust-embed-impl\",\"req\":\"^8.9.0\"},{\"name\":\"rust-embed-utils\",\"req\":\"^8.9.0\"},{\"default_features\":false,\"name\":\"salvo\",\"optional\":true,\"req\":\"^0.16\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"default_features\":false,\"name\":\"warp\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"actix\":[\"actix-web\",\"mime_guess\"],\"axum-ex\":[\"axum\",\"tokio\",\"mime_guess\"],\"compression\":[\"rust-embed-impl/compression\",\"include-flate\"],\"debug-embed\":[\"rust-embed-impl/debug-embed\",\"rust-embed-utils/debug-embed\"],\"deterministic-timestamps\":[\"rust-embed-impl/deterministic-timestamps\"],\"include-exclude\":[\"rust-embed-impl/include-exclude\",\"rust-embed-utils/include-exclude\"],\"interpolate-folder-path\":[\"rust-embed-impl/interpolate-folder-path\"],\"mime-guess\":[\"rust-embed-impl/mime-guess\",\"rust-embed-utils/mime-guess\"],\"poem-ex\":[\"poem\",\"tokio\",\"mime_guess\",\"hex\"],\"salvo-ex\":[\"salvo\",\"tokio\",\"mime_guess\",\"hex\"],\"warp-ex\":[\"warp\",\"tokio\",\"mime_guess\"]}}", @@ -1292,9 +1282,11 @@ "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.12": "{\"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-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\"]}}", "rustversion_1.0.22": "{\"dependencies\":[{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", "rustyline_14.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.2\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"default_features\":false,\"name\":\"buffer-redux\",\"optional\":true,\"req\":\"^1.0\",\"target\":\"cfg(unix)\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"clipboard-win\",\"req\":\"^5.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"fd-lock\",\"optional\":true,\"req\":\"^4.0.0\"},{\"name\":\"home\",\"optional\":true,\"req\":\"^0.5.4\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"fs\",\"ioctl\",\"poll\",\"signal\",\"term\"],\"name\":\"nix\",\"req\":\"^0.28\",\"target\":\"cfg(unix)\"},{\"name\":\"radix_trie\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"default_features\":false,\"features\":[\"bundled\",\"backup\"],\"name\":\"rusqlite\",\"optional\":true,\"req\":\"^0.31.0\"},{\"name\":\"rustyline-derive\",\"optional\":true,\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"signal-hook\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"skim\",\"optional\":true,\"req\":\"^0.10\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"name\":\"termios\",\"optional\":true,\"req\":\"^0.3.3\",\"target\":\"cfg(unix)\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1.0\"},{\"name\":\"unicode-width\",\"req\":\"^0.1\"},{\"name\":\"utf8parse\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Security\",\"Win32_System_Threading\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.52.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"case_insensitive_history_search\":[\"regex\"],\"custom-bindings\":[\"radix_trie\"],\"default\":[\"custom-bindings\",\"with-dirs\",\"with-file-history\"],\"derive\":[\"rustyline-derive\"],\"with-dirs\":[\"home\"],\"with-file-history\":[\"fd-lock\"],\"with-fuzzy\":[\"skim\"],\"with-sqlite-history\":[\"rusqlite\"]}}", @@ -1313,7 +1305,6 @@ "schemars_derive_1.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.2.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"serde_derive_internals\",\"req\":\"^0.29.1\"},{\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"extra-traits\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", "scoped-tls_1.0.1": "{\"dependencies\":[],\"features\":{}}", "scopeguard_1.2.0": "{\"dependencies\":[],\"features\":{\"default\":[\"use_std\"],\"use_std\":[]}}", - "scratch_1.0.9": "{\"dependencies\":[],\"features\":{}}", "scrypt_0.11.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"rand_core\"],\"name\":\"password-hash\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"rand_core\"],\"kind\":\"dev\",\"name\":\"password-hash\",\"req\":\"^0.5\"},{\"name\":\"pbkdf2\",\"req\":\"^0.12\"},{\"default_features\":false,\"name\":\"salsa20\",\"req\":\"^0.10.2\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"simple\",\"std\"],\"simple\":[\"password-hash\"],\"std\":[\"password-hash/std\"]}}", "sdd_3.0.10": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.6\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"}],\"features\":{}}", "seccompiler_0.5.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.153\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.27\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.9\"}],\"features\":{\"json\":[\"serde\",\"serde_json\"]}}", @@ -1412,10 +1403,8 @@ "sys-locale_0.3.2": "{\"dependencies\":[{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"},{\"features\":[\"Window\",\"WorkerGlobalScope\",\"Navigator\",\"WorkerNavigator\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"}],\"features\":{\"js\":[\"js-sys\",\"wasm-bindgen\",\"web-sys\"]}}", "system-configuration-sys_0.6.0": "{\"dependencies\":[{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2.149\"}],\"features\":{}}", "system-configuration_0.7.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"core-foundation\",\"req\":\"^0.9\"},{\"name\":\"system-configuration-sys\",\"req\":\"^0.6\"}],\"features\":{}}", - "system-deps_7.0.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5\"},{\"features\":[\"targets\"],\"name\":\"cfg-expr\",\"req\":\">=0.17, <0.21\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"pkg-config\",\"req\":\"^0.3.25\"},{\"default_features\":false,\"features\":[\"parse\",\"std\"],\"name\":\"toml\",\"req\":\"^0.9\"},{\"name\":\"version-compare\",\"req\":\"^0.2\"}],\"features\":{}}", "tagptr_0.2.0": "{\"dependencies\":[],\"features\":{}}", "tar_0.4.44": "{\"dependencies\":[{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", - "target-lexicon_0.13.3": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"arch_z80\":[],\"arch_zkasm\":[],\"default\":[],\"serde_support\":[\"serde\",\"std\"],\"std\":[]}}", "tempfile_3.24.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.3\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", "tempfile_3.27.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\">=0.3.0, <0.5\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.4\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", "temporal_capi_0.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"unstable\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.1.2\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", @@ -1453,6 +1442,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\"]}}", @@ -1490,6 +1480,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\":[]}}", @@ -1526,7 +1517,6 @@ "v8_146.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"align-data\",\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"kind\":\"dev\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"name\":\"bitflags\",\"req\":\"^2.5\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"build\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"build\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"zoneinfo64\"],\"name\":\"temporal_capi\",\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.96\"},{\"kind\":\"build\",\"name\":\"which\",\"req\":\"^6\"},{\"kind\":\"dev\",\"name\":\"which\",\"req\":\"^6\"}],\"features\":{\"default\":[\"use_custom_libcxx\"],\"use_custom_libcxx\":[],\"v8_enable_pointer_compression\":[],\"v8_enable_sandbox\":[\"v8_enable_pointer_compression\"],\"v8_enable_v8_checks\":[]}}", "valuable_0.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"valuable-derive\",\"optional\":true,\"req\":\"=0.1.1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"derive\":[\"valuable-derive\"],\"std\":[\"alloc\"]}}", "vcpkg_0.2.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3.7\"}],\"features\":{}}", - "version-compare_0.2.1": "{\"dependencies\":[],\"features\":{}}", "version_check_0.9.5": "{\"dependencies\":[],\"features\":{}}", "vt100_0.16.2": "{\"dependencies\":[{\"name\":\"itoa\",\"req\":\"^1.0.15\"},{\"features\":[\"term\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.30.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"},{\"kind\":\"dev\",\"name\":\"terminal_size\",\"req\":\"^0.4.2\"},{\"name\":\"unicode-width\",\"req\":\"^0.2.1\"},{\"name\":\"vte\",\"req\":\"^0.15.0\"}],\"features\":{}}", "vte_0.15.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arrayvec\",\"req\":\"^0.7.2\"},{\"default_features\":false,\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.3.3\"},{\"default_features\":false,\"name\":\"cursor-icon\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.7.4\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.160\"}],\"features\":{\"ansi\":[\"log\",\"cursor-icon\",\"bitflags\"],\"default\":[\"std\"],\"serde\":[\"dep:serde\"],\"std\":[\"memchr/std\"]}}", @@ -1670,16 +1660,13 @@ "zerotrie_0.2.3": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"litemap\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[],\"databake\":[\"dep:databake\",\"zerovec?/databake\"],\"default\":[],\"litemap\":[\"dep:litemap\",\"alloc\"],\"serde\":[\"dep:serde_core\",\"dep:litemap\",\"alloc\",\"litemap/serde\",\"zerovec?/serde\"],\"yoke\":[\"dep:yoke\"],\"zerofrom\":[\"dep:zerofrom\"]}}", "zerovec-derive_0.11.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.28\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.21\"}],\"features\":{}}", "zerovec_0.11.5": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.1\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", - "zip_0.6.6": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8.2\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.8\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0.23\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.5\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.7\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.7\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.11.2\"}],\"features\":{\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate\",\"time\",\"zstd\"],\"deflate\":[\"flate2/rust_backend\"],\"deflate-miniz\":[\"flate2/default\"],\"deflate-zlib\":[\"flate2/zlib\"],\"unreserved\":[]}}", "zip_2.4.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"req\":\"^1.4.1\",\"target\":\"cfg(fuzzing)\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"=4.4.18\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crc32fast\",\"req\":\"^1.4\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"name\":\"deflate64\",\"optional\":true,\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"wasm_js\",\"std\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"features\":[\"wasm_js\",\"std\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.1\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"lzma-rs\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"memchr\",\"req\":\"^2.7\"},{\"default_features\":false,\"name\":\"nt-time\",\"optional\":true,\"req\":\"^0.10.6\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.15\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.37\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5\"},{\"name\":\"xz2\",\"optional\":true,\"req\":\"^0.1.7\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"zopfli\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"_all-features\":[],\"_deflate-any\":[],\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\",\"getrandom\",\"zeroize\"],\"chrono\":[\"chrono/default\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate64\",\"deflate\",\"lzma\",\"time\",\"zstd\",\"xz\"],\"deflate\":[\"flate2/rust_backend\",\"deflate-zopfli\",\"deflate-flate2\"],\"deflate-flate2\":[\"_deflate-any\"],\"deflate-miniz\":[\"deflate\",\"deflate-flate2\"],\"deflate-zlib\":[\"flate2/zlib\",\"deflate-flate2\"],\"deflate-zlib-ng\":[\"flate2/zlib-ng\",\"deflate-flate2\"],\"deflate-zopfli\":[\"zopfli\",\"_deflate-any\"],\"lzma\":[\"lzma-rs/stream\"],\"nt-time\":[\"dep:nt-time\"],\"unreserved\":[],\"xz\":[\"dep:xz2\"]}}", "zmij_1.0.19": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", "zmij_1.0.21": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", "zoneinfo64_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.3\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.4\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"tzdb-bundle-always\",\"std\"],\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.15\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"resb\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.220\"}],\"features\":{\"chrono\":[\"dep:chrono\"]}}", "zopfli_0.8.3": "{\"dependencies\":[{\"name\":\"bumpalo\",\"req\":\"^3.19.0\"},{\"default_features\":false,\"name\":\"crc32fast\",\"optional\":true,\"req\":\"^1.5.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"proptest-derive\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"simd-adler32\",\"optional\":true,\"req\":\"^0.3.7\"}],\"features\":{\"default\":[\"gzip\",\"std\",\"zlib\"],\"gzip\":[\"dep:crc32fast\"],\"nightly\":[\"crc32fast?/nightly\"],\"std\":[\"crc32fast?/std\",\"dep:log\",\"simd-adler32?/std\"],\"zlib\":[\"dep:simd-adler32\"]}}", - "zstd-safe_5.0.2+zstd.1.5.2": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.21\"},{\"default_features\":false,\"name\":\"zstd-sys\",\"req\":\"^2.0.1\"}],\"features\":{\"arrays\":[],\"bindgen\":[\"zstd-sys/bindgen\"],\"debug\":[\"zstd-sys/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-sys/experimental\"],\"legacy\":[\"zstd-sys/legacy\"],\"no_asm\":[\"zstd-sys/no_asm\"],\"pkg-config\":[\"zstd-sys/pkg-config\"],\"std\":[\"zstd-sys/std\"],\"thin\":[\"zstd-sys/thin\"],\"zdict_builder\":[\"zstd-sys/zdict_builder\"],\"zstdmt\":[\"zstd-sys/zstdmt\"]}}", "zstd-safe_7.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"zstd-sys\",\"req\":\"^2.0.15\"}],\"features\":{\"arrays\":[],\"bindgen\":[\"zstd-sys/bindgen\"],\"debug\":[\"zstd-sys/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-sys/experimental\"],\"fat-lto\":[\"zstd-sys/fat-lto\"],\"legacy\":[\"zstd-sys/legacy\"],\"no_asm\":[\"zstd-sys/no_asm\"],\"pkg-config\":[\"zstd-sys/pkg-config\"],\"seekable\":[\"zstd-sys/seekable\"],\"std\":[\"zstd-sys/std\"],\"thin\":[\"zstd-sys/thin\"],\"thin-lto\":[\"zstd-sys/thin-lto\"],\"zdict_builder\":[\"zstd-sys/zdict_builder\"],\"zstdmt\":[\"zstd-sys/zstdmt\"]}}", "zstd-sys_2.0.16+zstd.1.5.7": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.45\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.28\"}],\"features\":{\"debug\":[],\"default\":[\"legacy\",\"zdict_builder\",\"bindgen\"],\"experimental\":[],\"fat-lto\":[],\"legacy\":[],\"no_asm\":[],\"no_wasm_shim\":[],\"non-cargo\":[],\"pkg-config\":[],\"seekable\":[],\"std\":[],\"thin\":[],\"thin-lto\":[],\"zdict_builder\":[],\"zstdmt\":[]}}", - "zstd_0.11.2+zstd.1.5.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"humansize\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"zstd-safe\",\"req\":\"^5.0.1\"}],\"features\":{\"arrays\":[\"zstd-safe/arrays\"],\"bindgen\":[\"zstd-safe/bindgen\"],\"debug\":[\"zstd-safe/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-safe/experimental\"],\"legacy\":[\"zstd-safe/legacy\"],\"no_asm\":[\"zstd-safe/no_asm\"],\"pkg-config\":[\"zstd-safe/pkg-config\"],\"thin\":[\"zstd-safe/thin\"],\"wasm\":[],\"zdict_builder\":[\"zstd-safe/zdict_builder\"],\"zstdmt\":[\"zstd-safe/zstdmt\"]}}", "zstd_0.13.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"humansize\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"zstd-safe\",\"req\":\"^7.1.0\"}],\"features\":{\"arrays\":[\"zstd-safe/arrays\"],\"bindgen\":[\"zstd-safe/bindgen\"],\"debug\":[\"zstd-safe/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-safe/experimental\"],\"fat-lto\":[\"zstd-safe/fat-lto\"],\"legacy\":[\"zstd-safe/legacy\"],\"no_asm\":[\"zstd-safe/no_asm\"],\"pkg-config\":[\"zstd-safe/pkg-config\"],\"thin\":[\"zstd-safe/thin\"],\"thin-lto\":[\"zstd-safe/thin-lto\"],\"wasm\":[],\"zdict_builder\":[\"zstd-safe/zdict_builder\"],\"zstdmt\":[\"zstd-safe/zstdmt\"]}}", "zune-core_0.4.12": "{\"dependencies\":[{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.52\"}],\"features\":{\"std\":[]}}", "zune-core_0.5.1": "{\"dependencies\":[{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"std\":[]}}", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index c8f6b9e5a..3263dddd8 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -21,6 +21,7 @@ members = [ "cloud-tasks-client", "cloud-tasks-mock-client", "cli", + "clawbot", "collaboration-mode-templates", "connectors", "config", @@ -120,6 +121,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 b3962310e..0a5fa9f04 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -31,6 +31,7 @@ codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-install-context = { workspace = true } codex-chatgpt = { workspace = true } +codex-clawbot = { workspace = true } codex-cloud-requirements = { workspace = true } codex-config = { workspace = true } codex-connectors = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e950693cf..528b790b0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -109,6 +109,8 @@ use codex_app_server_protocol::ThreadStartSource; 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_exec_server::EnvironmentManager; @@ -176,6 +178,7 @@ mod agent_navigation; mod app_server_adapter; pub(crate) mod app_server_requests; mod btw; +mod clawbot; mod jump_navigation; mod key_chord; mod loaded_threads; @@ -192,6 +195,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; @@ -1137,6 +1141,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)] @@ -3892,6 +3901,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(()) } @@ -4401,6 +4411,11 @@ 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) @@ -4416,6 +4431,7 @@ impl App { .await, "failed to load skills on startup", ); + 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")] @@ -4541,6 +4557,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"); } @@ -5047,6 +5064,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(); @@ -7229,6 +7263,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_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -10345,6 +10386,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(), } } @@ -10410,6 +10456,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, @@ -10494,7 +10545,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, @@ -12490,6 +12570,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 46c79e764..c28db2627 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -193,6 +193,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; } @@ -228,6 +234,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 f81642512..a2e08c6eb 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -18,6 +18,8 @@ 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_clawbot::ProviderEvent as ClawbotProviderEvent; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; @@ -331,6 +333,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 { @@ -385,6 +390,10 @@ pub(crate) enum AppEvent { action: ProfileFallbackAction, error_message: String, }, + ClawbotTurnCompleted { + thread_id: ThreadId, + turn: AppServerTurn, + }, /// Apply rollback semantics to local transcript cells. /// From 3e88098d9f93cc3b1c6d19255275bbe4c47c9ccf Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 16:29:13 +0800 Subject: [PATCH 35/83] 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 | 1 + 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(+), 8 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 528b790b0..9f8ceb838 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -181,6 +181,7 @@ mod btw; mod clawbot; mod jump_navigation; mod key_chord; +mod clawbot_controls; mod loaded_threads; mod pending_interactive_replay; mod profile_management; @@ -5081,6 +5082,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(); @@ -7221,6 +7265,7 @@ mod tests { use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::render::renderable::Renderable; + use crate::app_event::ClawbotForwardingChannel; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; @@ -7300,6 +7345,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; @@ -10575,6 +10623,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, @@ -12751,6 +12831,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; @@ -13160,6 +13292,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 a2e08c6eb..eb05d1db0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -77,6 +77,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 { @@ -395,6 +411,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 38d8a0287..3c7be2898 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -61,6 +61,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 332f22883..3e077f4c7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -50,6 +50,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; @@ -5315,6 +5316,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, @@ -5332,7 +5337,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() } @@ -5494,6 +5498,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 841f5fc87..c1f15fcc2 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -638,6 +638,16 @@ async fn slash_memories_opens_memory_menu() { 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 f59a6f2b2..81bff138c 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -61,6 +61,7 @@ pub enum SlashCommand { Personality, Realtime, Settings, + Clawbot, TestApproval, #[strum(serialize = "subagents")] MultiAgents, @@ -106,6 +107,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", @@ -195,6 +197,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 76e421d7fb55c513d4ed399bb387713723bf7fdc Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 18:31:45 +0800 Subject: [PATCH 36/83] 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 9f8ceb838..af2db77cd 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -110,6 +110,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; @@ -1147,6 +1149,8 @@ pub(crate) struct App { clawbot_pending_turns: HashMap>, #[cfg(test)] clawbot_outbound_messages: Vec, + #[cfg(test)] + clawbot_outbound_reactions: Vec, } #[derive(Default)] @@ -4417,6 +4421,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) @@ -5106,6 +5112,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 @@ -7266,6 +7278,7 @@ mod tests { use crate::legacy_core::config::ConfigOverrides; use crate::render::renderable::Renderable; 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; @@ -7309,8 +7322,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; @@ -7345,11 +7361,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; @@ -10439,6 +10455,8 @@ guardian_approval = true clawbot_pending_turns: HashMap::new(), #[cfg(test)] clawbot_outbound_messages: Vec::new(), + #[cfg(test)] + clawbot_outbound_reactions: Vec::new(), } } @@ -10509,6 +10527,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, @@ -12675,6 +12695,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) @@ -12686,6 +12717,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 c28db2627..144e7e104 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -298,6 +298,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 eb05d1db0..55896c18c 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::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::Turn as AppServerTurn; +use codex_clawbot::ClawbotTurnMode; use codex_clawbot::ProviderEvent as ClawbotProviderEvent; use codex_file_search::FileMatch; use codex_protocol::ThreadId; @@ -428,6 +429,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 531786b22b996015ca8364f0522a5969008fac4e Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 18:55:39 +0800 Subject: [PATCH 37/83] 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 af2db77cd..bf58b5578 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5130,6 +5130,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!( @@ -13475,6 +13488,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 55896c18c..2d032daff 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -440,6 +440,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 079f0981aa0d3cf22e94366baf46842b9c6f497c Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 5 Apr 2026 19:48:46 +0800 Subject: [PATCH 38/83] 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 bf58b5578..f9b78a350 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5105,7 +5105,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 @@ -12991,7 +12991,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()))?; @@ -13520,6 +13520,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 bae68dfae68a7ffbdab0041cc26cd7ea1bf1263d Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 00:31:23 +0800 Subject: [PATCH 39/83] 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 2f92723be6467254873999d1f5c3266bf5a2f80c Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 6 Apr 2026 22:45:40 +0800 Subject: [PATCH 40/83] 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 e477ec75bb59c20cca51f7624bcaf0ddacd557b3 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 00:33:46 +0800 Subject: [PATCH 41/83] 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 f6f607cbcb92f04510a5ae3b87d60b64e04e214a Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 01:05:50 +0800 Subject: [PATCH 42/83] 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 f9b78a350..dffdb443d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -211,6 +211,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), @@ -3385,7 +3391,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; @@ -3477,6 +3487,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(); @@ -3519,6 +3541,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); @@ -3539,17 +3562,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, @@ -3716,7 +3751,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 @@ -8769,6 +8808,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!( @@ -8916,7 +8971,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); @@ -8925,6 +8984,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 7d1318daf48f32fb22c816374fb8d27f5ae9825e Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 01:44:29 +0800 Subject: [PATCH 43/83] 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 | 583 +----------------- 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(+), 604 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 f289752ba..962a11b79 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -152,15 +152,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 } @@ -177,16 +177,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 bed4a4f2f..8b3d53225 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -79,6 +79,7 @@ ignore = [ { 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-2026-0097", reason = "rand 0.8.5 is pulled in via age v0.11.2/codex-secrets and zbus v4.4.0/keyring; no compatible rand 0.8 fixed release, remove when transitive dependencies move to rand >=0.9.3" }, + { 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 dffdb443d..1b20bb99c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -181,9 +181,9 @@ mod app_server_adapter; pub(crate) 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; @@ -7307,6 +7307,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; @@ -7328,7 +7331,6 @@ mod tests { use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; - use crate::render::renderable::Renderable; use crate::app_event::ClawbotForwardingChannel; use crate::render::renderable::Renderable; use codex_app_server_protocol::AdditionalFileSystemPermissions; @@ -7373,16 +7375,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_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -7414,10 +7406,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; @@ -10713,68 +10701,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, @@ -12770,231 +12696,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; @@ -13047,58 +12748,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; @@ -13508,230 +13157,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 3c7be2898..159c9941b 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -83,7 +83,7 @@ pub(crate) 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 memories_settings_view; mod prompt_args; mod skill_popup; From d44d415939f7d16ba23e369d950ac648c7066a60 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 09:16:24 +0800 Subject: [PATCH 44/83] 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 2141a4b3fedcb6e9a892b4e0532d979b64d19d3c Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 12:13:12 +0800 Subject: [PATCH 45/83] tui: expand resume picker and custom prompts --- codex-rs/tui/src/app.rs | 25 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 356 +++++++++- 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, 1617 insertions(+), 188 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 1b20bb99c..b81a4a927 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -719,10 +719,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; @@ -4849,8 +4849,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? @@ -7027,14 +7028,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 8ba019816..bf7f2e684 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -180,8 +180,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; @@ -326,6 +334,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, @@ -467,6 +476,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(), @@ -523,6 +533,13 @@ impl ChatComposer { self.sync_popups(); } + 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(); @@ -1384,27 +1401,49 @@ 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.stage_selected_slash_command_history(cmd); - 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.stage_selected_slash_command_history(cmd); + 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) @@ -1414,11 +1453,83 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - if let Some(sel) = popup.selected_item() { - let CommandItem::Builtin(cmd) = sel; - self.stage_selected_slash_command_history(cmd); + 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.stage_selected_slash_command_history(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) @@ -2206,7 +2317,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."# ); @@ -2226,6 +2345,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); @@ -2292,6 +2436,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. @@ -2396,6 +2544,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 { @@ -3156,7 +3342,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, @@ -3198,7 +3395,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 @@ -3258,6 +3465,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); } @@ -6300,6 +6508,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'"), @@ -6353,12 +6564,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 12cc6b92b..f34a9ad8c 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", "memories", "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 159c9941b..56b796740 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -80,6 +80,7 @@ mod chat_composer; mod chat_composer_history; mod command_popup; pub(crate) mod custom_prompt_view; +mod custom_prompts; mod experimental_features_view; mod file_search_popup; mod footer; @@ -156,6 +157,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)] @@ -258,6 +262,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 bc07c06e0..95a682ac7 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 @@ -1131,11 +1131,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 3e077f4c7..cf5541cdd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -330,6 +330,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::collaboration_modes; @@ -5091,6 +5092,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 @@ -7138,10 +7143,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 fdc966c0b..d447e825f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1240,7 +1240,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()); } @@ -2059,7 +2059,7 @@ pub(crate) fn new_mcp_tools_output( let effective_servers = config.mcp_servers.get().clone(); 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); @@ -2125,7 +2125,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}=*****")) @@ -2137,7 +2137,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}")) @@ -2294,7 +2294,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}=*****")) @@ -2306,7 +2306,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 817c35e88..ba99938a0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -592,29 +592,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 @@ -624,11 +644,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, @@ -1248,7 +1278,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? @@ -1315,7 +1349,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, ) @@ -1894,21 +1933,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(()) } @@ -1919,7 +1955,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, ); @@ -1929,21 +1965,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 6703a606c..f23d456f7 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, @@ -438,7 +476,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, @@ -484,12 +522,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, @@ -508,12 +548,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 { @@ -576,7 +677,7 @@ impl PickerState { requester: FrameRequester, page_loader: PageLoader, provider_filter: ProviderFilter, - show_all: bool, + order: SessionPickerOrder, filter_cwd: Option, action: SessionPickerAction, ) -> Self { @@ -602,7 +703,7 @@ impl PickerState { page_loader, view_rows: None, provider_filter, - show_all, + order, filter_cwd, action, sort_key: ThreadSortKey::UpdatedAt, @@ -679,8 +780,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); @@ -688,7 +789,6 @@ impl PickerState { self.maybe_load_more_for_scroll(); self.request_frame(); } - } KeyCode::Tab => { self.toggle_sort_key(); self.request_frame(); @@ -698,18 +798,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) @@ -853,15 +952,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); @@ -873,12 +980,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; @@ -1136,7 +1240,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) @@ -1188,7 +1294,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { let metrics = calculate_column_metrics( &state.filtered_rows, - state.show_all, + /*include_cwd*/ true, state.relative_time_reference.unwrap_or_else(Utc::now), ); @@ -1859,22 +1965,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] @@ -1888,22 +1994,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"), @@ -1915,7 +2107,9 @@ mod tests { git_branch: None, }; - assert!(state.row_matches_filter(&row)); + assert!(!PickerState::row_matches_local_cwd( + &row, /*filter_cwd*/ None + )); } #[test] @@ -1931,7 +2125,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -1977,7 +2171,7 @@ mod tests { state.update_view_rows(/*rows*/ 3); state.relative_time_reference = Some(now); - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all, now); + let metrics = calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true, now); let width: u16 = 80; let height: u16 = 6; @@ -2010,7 +2204,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2246,7 +2440,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2284,7 +2478,7 @@ mod tests { state.update_thread_names().await; state.relative_time_reference = Some(now); - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all, now); + let metrics = calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true, now); let width: u16 = 80; let height: u16 = 5; @@ -2328,7 +2522,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2365,7 +2559,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2434,7 +2628,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2515,7 +2709,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2545,7 +2739,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2593,7 +2787,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2633,7 +2827,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2703,7 +2897,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2751,7 +2945,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 034b86ea69971113a043ed993261810a1a0d5a43 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 14:59:57 +0800 Subject: [PATCH 46/83] fix workflow schedule deadlock issue fix workflow /stop --- .gitignore | 5 + AGENTS.md | 6 +- 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(+), 77 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 6e21be78f..50392279f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,8 @@ 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. @@ -209,3 +210,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 b81a4a927..aaed51681 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1144,8 +1144,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, @@ -2906,6 +2905,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) => { @@ -4450,7 +4454,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, @@ -5042,19 +5048,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; } @@ -10530,7 +10523,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, @@ -10602,7 +10597,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, @@ -13004,6 +13001,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; @@ -13121,6 +13174,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 144e7e104..fe547a0df 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -228,10 +228,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 2d032daff..ab1874954 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -17,7 +17,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_clawbot::ClawbotTurnMode; use codex_clawbot::ProviderEvent as ClawbotProviderEvent; @@ -364,16 +363,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 cf5541cdd..41133bce8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -7994,7 +7994,7 @@ impl ChatWidget { self.unified_exec_processes.clear(); self.sync_unified_exec_footer(); 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 c1f15fcc2..6be5bc1d6 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -552,11 +552,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 81bff138c..10d0ea124 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -98,8 +98,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 7fb6e659c4ce5c7a06332dbaaaf5e6def273809d Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 16:19:21 +0800 Subject: [PATCH 47/83] 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 aaed51681..b7d1c7cba 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -190,6 +190,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; @@ -5082,6 +5083,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, @@ -10473,7 +10513,7 @@ guardian_approval = true assert_snapshot!("clear_ui_header_fast_status_fast_capable_models", 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 ab1874954..f92e1a2d5 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -100,6 +100,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 { @@ -377,6 +401,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 7efe49cc1ad96fcff030e19e1170a72b66ce9cf0 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 17:47:27 +0800 Subject: [PATCH 48/83] 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 b7d1c7cba..04e130c8c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5096,6 +5096,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 f92e1a2d5..b1a4619c5 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -116,6 +116,10 @@ pub(crate) enum WorkflowControlsDestination { ManualTriggers { workflow_path: PathBuf, }, + ManualTrigger { + workflow_path: PathBuf, + trigger_id: String, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -412,6 +416,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 b81e1d1b1a3f01372d9e563174757f807891df94 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 19:38:50 +0800 Subject: [PATCH 49/83] 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 41133bce8..11aef5037 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1740,9 +1740,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(); } @@ -3708,13 +3710,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); @@ -3741,6 +3749,7 @@ impl ChatWidget { self.add_to_history(history_cell::new_unified_exec_interaction( command_display, ev.stdin, + self.display_preferences.clone(), )); } } @@ -3901,6 +3910,7 @@ impl ChatWidget { ev.call_id, String::new(), self.config.animations, + self.display_preferences.clone(), ))); self.bump_active_cell_revision(); self.request_redraw(); @@ -3928,7 +3938,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; } @@ -4599,6 +4614,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!( @@ -4606,9 +4622,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 => { @@ -4620,6 +4634,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); @@ -4817,6 +4832,7 @@ impl ChatWidget { ev.source, interaction_input, self.config.animations, + self.display_preferences.clone(), ))); self.bump_active_cell_revision(); } @@ -5968,8 +5984,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); } } @@ -5977,7 +5992,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() @@ -5986,12 +6018,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) { @@ -8303,7 +8334,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 53f16a39d..20a2858ad 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -740,6 +740,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 423217a6f..bd6bb1717 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; @@ -45,6 +46,7 @@ pub(crate) fn new_active_exec_command( source: ExecCommandSource, interaction_input: Option, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> ExecCell { ExecCell::new( ExecCall { @@ -59,6 +61,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 { @@ -198,6 +201,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 { @@ -206,6 +212,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 d447e825f..bfbbc77c4 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -626,19 +626,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(); } @@ -686,8 +695,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)] @@ -1840,6 +1850,7 @@ pub(crate) struct WebSearchCell { start_time: Instant, completed: bool, animations_enabled: bool, + display_preferences: DisplayPreferences, } impl WebSearchCell { @@ -1848,6 +1859,7 @@ impl WebSearchCell { query: String, action: Option, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> Self { Self { call_id, @@ -1856,6 +1868,7 @@ impl WebSearchCell { start_time: Instant::now(), completed: false, animations_enabled, + display_preferences, } } @@ -1875,6 +1888,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 { @@ -1895,20 +1911,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 @@ -3235,8 +3260,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, @@ -3250,11 +3278,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 { @@ -3674,7 +3723,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!( @@ -3730,6 +3783,7 @@ mod tests { let cell: Box = Box::new(UnifiedExecInteractionCell::new( Some("true".to_string()), url_like.to_string(), + DisplayPreferences::default(), )); let width: u16 = 24; @@ -3771,6 +3825,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 64)).join("\n"); @@ -3788,6 +3843,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 64)); @@ -3810,6 +3866,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 64)); @@ -3827,6 +3884,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.transcript_lines(/*width*/ 64)).join("\n"); @@ -4469,6 +4527,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 2243e82e1117421d387e6e7026fe56f2642a4e65 Mon Sep 17 00:00:00 2001 From: piping Date: Tue, 7 Apr 2026 19:52:21 +0800 Subject: [PATCH 50/83] 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 9368df4facb682acfc6d6c0b4e529ebc0b25f5aa Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 10:19:29 +0800 Subject: [PATCH 51/83] 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 04e130c8c..189d98236 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5128,6 +5128,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, @@ -13278,6 +13293,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 b1a4619c5..3a40d2113 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -120,6 +120,10 @@ pub(crate) enum WorkflowControlsDestination { workflow_path: PathBuf, trigger_id: String, }, + TriggerType { + workflow_path: PathBuf, + trigger_id: String, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -128,6 +132,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 { @@ -442,6 +463,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 6be5bc1d6..39598aeb3 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -629,6 +629,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 4d40c2fb4051fc851ddcbad5548400147acb577d Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 10:25:42 +0800 Subject: [PATCH 52/83] 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 cc1fc5e722c8cb2bafdce9d4eec9f4e006bac412 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 10:32:25 +0800 Subject: [PATCH 53/83] 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 3b42a0485c6e578fcb1c3a6a564f101b8d2f2a4e Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 10:57:40 +0800 Subject: [PATCH 54/83] 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 | 1 - .github/workflows/sdk.yml | 3 +-- .github/workflows/v8-canary.yml | 13 ------------- 8 files changed, 15 insertions(+), 30 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 0328ac207..90780a7ae 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 e0ad982ea..c60e56c3a 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 7cda68295..cf556e87a 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -2,7 +2,6 @@ name: rust-ci-full on: push: branches: - - main - "**full-ci**" workflow_dispatch: 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 f5aa1d7c6..05d02bf58 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 42cb029d46672dc86b1b02c2d1359352c3bbc529 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 14:48:43 +0800 Subject: [PATCH 55/83] 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 189d98236..e3c024d8c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4908,6 +4908,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. @@ -485,10 +474,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 7ef0f492d..d3a46d842 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -177,12 +177,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 97dd6019efd854a4e5e3ec84646ef541746669a4 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 17:08:40 +0800 Subject: [PATCH 56/83] 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 e3c024d8c..0a1144a42 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1965,6 +1965,7 @@ impl App { workflow_runtime::BackgroundWorkflowRunTarget::Trigger { workflow_name, trigger_id: target_name, + phase_context: workflow_runtime::OwnedWorkflowPhaseContext::default(), } } else { workflow_runtime::BackgroundWorkflowRunTarget::Job { @@ -1989,8 +1990,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 { @@ -6880,12 +6884,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); @@ -6904,7 +6908,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); @@ -10712,6 +10716,7 @@ jobs: triggers: - type: after_turn + id: followup jobs: [followup] jobs: @@ -12928,7 +12933,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() @@ -12985,6 +13021,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") @@ -13094,8 +13154,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 374e29e02bcce16f27b2a07968a19784de2bad4c Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 17:14:26 +0800 Subject: [PATCH 57/83] 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 fb4cc2598e841946d2cdd3590025b9ffe058133a Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 18:34:15 +0800 Subject: [PATCH 58/83] 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 128931c97..270863f9a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2993,6 +2993,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 0a5fa9f04..ee9709bd6 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -70,6 +70,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 0a1144a42..a690b12b9 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -191,9 +191,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; @@ -205,6 +207,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; @@ -1146,6 +1149,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, @@ -1966,6 +1970,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 { @@ -4462,6 +4467,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, @@ -4474,6 +4480,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?; @@ -5150,6 +5164,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, @@ -10594,6 +10628,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, @@ -10668,6 +10703,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, @@ -10764,6 +10800,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, @@ -13214,6 +13273,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 fc2253df9..bf3641b6d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -144,6 +144,7 @@ pub(crate) enum WorkflowTriggerType { Manual, BeforeTurn, AfterTurn, + FileWatch, Idle, Interval, Cron, @@ -478,6 +479,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 11aef5037..83f093e43 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -11215,6 +11215,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() } @@ -11226,9 +11227,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; @@ -11237,15 +11238,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 39343f4de..a7347599f 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -871,6 +871,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 eb319cf6589ad9f5106f3415dff501485706169c Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 19:19:00 +0800 Subject: [PATCH 59/83] 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 5cc7fd495..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, Business, 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 55bc59a1477697e3d492da5f110b1174f188aa48 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 19:27:48 +0800 Subject: [PATCH 60/83] update README.md with
--- README.md | 197 +++++++++++++++++++++++++++++++----------------- README.zh-CN.md | 189 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 258 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 6fbf930ec..5f876db9c 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,77 @@ -# Codex Enhanced +
-[中文版本](./README.zh-CN.md) +# Codex Enhanced > The Codex distribution built for real 24/7 use. -`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. +[中文版本](./README.zh-CN.md) · [Website](https://codex-enhanced.com) · [Workflows](./docs/workflows.md) · [Structured Input UI](./docs/tui-request-user-input.md) + +
+ +`codex-enhanced` is a Codex distribution built on top of the OpenAI Codex CLI Rust stack. It is focused less on prompt theater and more on turning Codex into an operator surface that can stay online across accounts, sessions, workflows, and external message channels. -Website: `https://codex-enhanced.com` +If you need a terminal chatbot, the base Codex experience is already strong. This distribution is for the next step: keeping Codex running as a controllable workspace system instead of a single ephemeral chat loop. ## Why codex-enhanced -Most AI CLIs compete on three things: +Most AI CLI projects compete on model access and UI polish. + +`codex-enhanced` moves the investment somewhere else: + +- multi-subscription account management instead of manual env switching +- multi-profile routing and fallback instead of a single fragile default +- resumable sessions instead of repeated context rebuilds +- workflow triggers and background jobs instead of one-turn-at-a-time operation +- Feishu bridge entrypoints instead of terminal-only interaction +- lower-noise operator UX instead of more surface-level prompt ceremony + +That is the core idea: make Codex feel less like a terminal chatbot and more like a persistent control surface. + +## What You Can Do -- models -- UI polish +| Capability | Entry point | What it enables | +| --- | --- | --- | +| Multi-profile routing | `/profile` | Switch named profiles at runtime, manage fallback routes, and recover from rate limits or auth failures without rewriting local environment state. | +| Workflow orchestration | `/workflow` | Manage `.codex/workflows/*.yaml`, run jobs manually, and attach triggers such as `before_turn`, `after_turn`, `interval`, `cron`, and `file_watch`. | +| Session continuity | `/resume` | Reconnect to saved work instead of reconstructing long-running context from scratch. | +| External message bridge | `/clawbot` | Bind workspace-local Feishu sessions to Codex threads, capture unread messages, and forward final replies back out. | +| UI and alignment control | `/settings`, `question`, keyboard chords | Reduce noise, collect structured answers in the TUI, and keep operator interactions explicit. | -This distribution moves in the opposite direction: +## Quickstart -- 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 +Install from PyPI: -If you need a Codex you can actually keep online and keep using, that is the point of this distribution. +```bash +pip3 install -U codex-enhanced +codex-enhanced +``` + +Run from source in this repository: + +```bash +just codex +``` -## Examples +`just codex` runs `cargo run --bin codex -- ...` from the `codex-rs` workspace, which is the fastest way to inspect or develop the Rust TUI locally. -### 1. Multi-subscription and multi-profile operation, not manual env switching +## Typical Operator Flows -`/profile` opens a dedicated management panel with support for: +### 1. Route traffic across multiple accounts and profiles + +`/profile` opens a dedicated management panel for: - named profiles - runtime switching - fallback routes -- profile switching policies for rate limits, auth failures, and service overload +- switching policies for rate limits, auth failures, and service overload + +This is the difference between "change a key and restart the tool" and "keep the operator surface online." -### 2. Conversations can be orchestrated as jobs +### 2. Turn conversations into jobs -`/workflow` manages `.codex/workflows/*.yaml` directly, with support for: +`/workflow` manages workflow definitions directly from `.codex/workflows/*.yaml`. + +Supported trigger families include: - `before_turn` - `after_turn` @@ -46,91 +81,113 @@ If you need a Codex you can actually keep online and keep using, that is the poi - `interval` - `cron` -That makes Codex more than a prompt-response loop. It becomes a node inside a repeatable workflow. +That lets Codex participate in repeatable automation loops instead of only answering the current prompt. -Docs: +Minimal example: -- [`docs/workflows.md`](./docs/workflows.md) +```yaml +name: director -### 3. Any session can be resumed instead of restarted +triggers: + - id: pulse + type: interval + every: 30m + enabled: true + jobs: [notify] -`/resume` and the thread/session plumbing let you reconnect to saved work instead of rebuilding context from scratch every time. +jobs: + notify: + enabled: true + context: ephemeral + response: assistant + steps: + - prompt: | + Send a concise update on the current workspace state. +``` -### 4. The terminal is not the only entrypoint +Documentation: -`/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. +- [`docs/workflows.md`](./docs/workflows.md) -## Install +### 3. Resume work instead of restarting it -The primary install path for this distribution is PyPI: +`/resume` and the underlying thread/session plumbing let you reconnect to saved work instead of paying the cost of rebuilding context every time a session is interrupted. -```bash -pip3 install -U codex-enhanced -codex-enhanced -``` +This matters once Codex is doing real work over hours or days rather than a single short exchange. -## Capability Boundaries and Layering +### 4. Bring Feishu into the same loop -### What it is built to solve +`/clawbot` connects workspace-local Feishu sessions, thread binding, unread message queues, and reply forwarding into the same runtime. -This distribution is strong at connecting the agent to real operating workflows, not at reinventing the underlying model platform. +In practice, that means: -Current strengths: +- a Feishu chat can be bound to the current Codex thread +- inbound messages can enter the active workspace loop +- final replies can be sent back to Feishu +- session and binding state stays local to the workspace runtime -- 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 +## What Ships Today -These are already implemented in the repository: +These capabilities 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 +- `/resume` for 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 +- keyboard chord support +- PyPI packaging and release flow for `codex-enhanced` -## How It Works +## Inspectable by Design -The system is designed to stay inspectable: +This project is intentionally built so the important runtime state stays visible: -- 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 +- profile routing state lives in `accounts/profile-router.json` +- workflows live in `.codex/workflows/*.yaml` +- clawbot state is stored under `.codex/clawbot/` +- structured operator answers are collected through the TUI `question` flow instead of being guessed from ambiguous free text -Useful docs and examples: +This distribution is opinionated, but it is not trying to hide the system from the operator. -- [`docs/workflows.md`](./docs/workflows.md) -- [`docs/tui-request-user-input.md`](./docs/tui-request-user-input.md) +## Repository Map -## Project Attribution +If you want to inspect or extend the project, start here: -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. +- [`codex-rs/`](./codex-rs) contains the Rust workspace, including the CLI, TUI, workflow support, app-server pieces, and clawbot integration +- [`sdk/python-runtime-enhanced/`](./sdk/python-runtime-enhanced) contains the Python wheel packaging for `codex-enhanced` +- [`docs/workflows.md`](./docs/workflows.md) explains workflow files, triggers, and job management +- [`docs/tui-request-user-input.md`](./docs/tui-request-user-input.md) explains the structured input overlay used for `question` -## Closing +## Capability Boundaries -If you only need a Codex that chats in a terminal, the official distribution is already enough. +### What it is built to solve + +This distribution is strong at connecting the agent to real operating workflows. + +Current strengths: -If you need a Codex that can stay online across accounts, inputs, tasks, and long-running sessions, the opening line still applies: +- multi-account and multi-profile routing +- long-session recovery and continuity +- workspace-local workflow orchestration +- Feishu clawbot integration +- local TUI information shaping and visibility control +- stronger alignment flows for human-in-the-loop operation + +### 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 + +## Project Attribution + +This project 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. + +If you only need a Codex that chats in a terminal, the official distribution is already enough. -**The Codex distribution built for real 24/7 use.** +If you need a Codex that can stay online across accounts, inputs, tasks, and long-running sessions, that is the point of this distribution. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 913ec9c9c..305ba354a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,42 +1,77 @@ -# Codex Enhanced +
-[English](./README.md) +# Codex Enhanced > 真正 24/7 使用的 Codex 发行版。 -`codex-enhanced` 是一个基于 OpenAI Codex CLI Rust 技术栈继续演进的 Codex 发行版。它的重点不是再包一层 prompt,而是把 Codex 变成一个更适合长期在线、多入口接入、跨账户运转的工作台 +[English](./README.md) · [Website](https://codex-enhanced.com) · [工作流文档](./docs/workflows.md) · [结构化输入 UI](./docs/tui-request-user-input.md) + +
+ +`codex-enhanced` 是一个基于 OpenAI Codex CLI Rust 技术栈继续演进的 Codex 发行版。它的重点不是再包一层 prompt,而是把 Codex 变成一个可以跨账户、跨会话、跨工作流、跨外部消息入口持续在线的 operator surface。 + +如果你只需要一个终端聊天工具,基础 Codex 体验已经足够强。这个发行版要解决的是下一层问题:让 Codex 不只是一次性的对话循环,而是一个可持续运转的工作台。 + +## 为什么是 codex-enhanced + +大多数 AI CLI 项目主要在卷模型接入和 UI 打磨。 -网站: `https://codex-enhanced.com` +`codex-enhanced` 把工程投入放在另一侧: -## 定位 +- 多 subscription 账户管理,而不是手动切环境变量 +- 多 profile 路由和 fallback,而不是只有一个脆弱默认值 +- 会话续接,而不是反复重建上下文 +- 工作流触发器和后台任务,而不是永远一问一答 +- 飞书桥接入口,而不是只接受终端输入 +- 更低噪音的 operator UX,而不是继续堆 prompt 仪式感 -大多数 AI CLI 在卷三件事: +核心目标只有一个:让 Codex 更像持续在线的控制台,而不是终端里的聊天框。 -- 模型 -- UI +## 你可以做什么 -这个发行版反过来做: +| 能力 | 入口 | 能解决什么 | +| --- | --- | --- | +| 多 profile 路由 | `/profile` | 在运行时切换命名 profile、管理 fallback route,并在限流或鉴权失败时继续运转,而不是改完配置再重启。 | +| 工作流编排 | `/workflow` | 直接管理 `.codex/workflows/*.yaml`,手动运行 job,或者挂接 `before_turn`、`after_turn`、`interval`、`cron`、`file_watch` 等触发器。 | +| 会话连续性 | `/resume` | 把保存过的工作续上,而不是每次从零重建长上下文。 | +| 外部消息桥接 | `/clawbot` | 把 workspace-local 的飞书会话绑定到 Codex thread,接收未读消息并把最终回复发回外部。 | +| UI 与对齐控制 | `/settings`、`question`、键盘 chord | 降低界面噪音,在 TUI 中收集结构化答案,并让人工参与点更明确。 | -- 默认假设底层 agent 已经够强 -- 把工程重点放在多账户路由、多 profile 切换、长会话恢复、任务编排、飞书入口和更低噪音的操作体验 -- 让 Codex 从“终端里的聊天框”变成“可以接消息、接任务、接上下文的控制台” +## Quickstart -如果你要的是一个可以真正长期在线使用的 Codex,这就是这个发行版的目标。 +通过 PyPI 安装: -## 效果示例 +```bash +pip3 install -U codex-enhanced +codex-enhanced +``` -### 1. 多 subscription / 多 profile,不是手动改环境变量 +在当前仓库里直接从源码运行: + +```bash +just codex +``` -`/profile` 会打开独立管理面板,支持: +`just codex` 实际执行的是 `codex-rs` 工作区下的 `cargo run --bin codex -- ...`,这是本地调试和验证 Rust TUI 的最快路径。 + +## 典型使用流 + +### 1. 在多个账户和 profile 之间路由流量 + +`/profile` 会打开独立管理面板,支持: - 命名 profile - 当前 runtime 切换 - fallback route - 在限流、鉴权失败、服务过载时按策略切换 profile -### 2. 对话不只是聊天,而是可编排任务 +这和“改个 key 然后重开工具”是两种完全不同的操作体验。 + +### 2. 把对话变成可编排 job -`/workflow` 可以直接管理 `.codex/workflows/*.yaml`,支持: +`/workflow` 直接管理 `.codex/workflows/*.yaml` 中的工作流定义。 + +目前支持的触发类型包括: - `before_turn` - `after_turn` @@ -46,75 +81,113 @@ - `interval` - `cron` -这让 Codex 不只是“问一句答一句”,而是可以成为工作流里的一个节点。 +这意味着 Codex 不只是响应当前输入,还可以进入可重复执行的自动化闭环。 + +最小示例: + +```yaml +name: director -文档: +triggers: + - id: pulse + type: interval + every: 30m + enabled: true + jobs: [notify] + +jobs: + notify: + enabled: true + context: ephemeral + response: assistant + steps: + - prompt: | + Send a concise update on the current workspace state. +``` + +相关文档: - [`docs/workflows.md`](./docs/workflows.md) -### 3. 不是反复重开,而是随时续上任意 session +### 3. 续上工作,而不是反复重开 -`/resume` 和对应的 thread/session 基础设施允许你把保存过的工作重新接起来,而不是每次都重建上下文。 +`/resume` 和底层的 thread/session 基础设施允许你把保存过的工作重新接起来,而不是每次都付出重建上下文的成本。 -### 4. 终端不是唯一入口,飞书也能接进来 +当 Codex 开始承担按小时或按天推进的任务时,这一点会非常重要。 -`/clawbot` 把 workspace-local 的 Feishu 会话、线程绑定、未读消息队列和回复回传串到同一个闭环里。你可以把飞书会话绑定到当前线程,让外部消息进入 Codex,再把最终回复发回飞书。 +### 4. 把飞书接进同一个闭环 +`/clawbot` 把 workspace-local 的飞书会话、线程绑定、未读消息队列和回复回传放进同一个运行时。 -## 安装 +具体来说,它支持: -这个发行版当前主推的安装方式是 PyPI: +- 把飞书会话绑定到当前 Codex thread +- 让外部消息进入当前 workspace 的执行闭环 +- 把最终回复发回飞书 +- 让 session 和 binding 状态保持在 workspace 本地 -```bash -pip3 install -U codex-enhanced -codex-enhanced -``` +## 当前已经落地的能力 + +下面这些能力都已经在仓库中实现: + +- 多 subscription 账户管理和 runtime account 展示 +- 多 profile API 管理和 `/profile` 路由切换 +- `/workflow` 任务编排 +- `/resume` 恢复已保存会话 +- `/settings` 控制 UI 展示信息 +- `/clawbot` 对接飞书收发消息 +- 更强的 `question` 式对齐交互 +- 键盘 chord 支持 +- `codex-enhanced` 的 PyPI 打包与发布流程 + +## 可观测设计 + +这个项目有意把关键状态保持为可检查、可定位的本地文件: + +- profile 路由状态保存在 `accounts/profile-router.json` +- workflow 定义直接保存在 `.codex/workflows/*.yaml` +- clawbot 相关状态保存在 `.codex/clawbot/` +- operator 的结构化回答通过 TUI 中的 `question` 流收集,而不是靠自由文本猜测 + +它是有观点的,但不是黑盒。 + +## 仓库导览 + +如果你要继续查看实现或扩展能力,可以从这些位置开始: + +- [`codex-rs/`](./codex-rs) 是 Rust 工作区,包含 CLI、TUI、workflow、app-server 和 clawbot 集成 +- [`sdk/python-runtime-enhanced/`](./sdk/python-runtime-enhanced) 是 `codex-enhanced` 的 Python wheel 打包目录 +- [`docs/workflows.md`](./docs/workflows.md) 说明 workflow 文件、trigger 和 job 管理方式 +- [`docs/tui-request-user-input.md`](./docs/tui-request-user-input.md) 说明 `question` 使用的结构化输入浮层 ## 能力边界 ### 它主要解决什么 -这个发行版的强项是把 agent 接进真实工作流,而不是重造底层模型平台。 +这个发行版的强项,是把 agent 接进真实可操作的工作流。 -当前重点能力: +当前重点能力: -- 多 subscription 账户管理 -- 多 profile API 路由和 fallback -- 长会话恢复和连续性 +- 多账户和多 profile 路由 +- 长会话恢复与连续性 - workspace-local workflow orchestration -- Feishu clawbot 接入 -- 本地 TUI 信息展示裁剪和控制 -- 通过 `question` 和 chord 快捷键增强对齐与操作效率 +- Feishu clawbot 集成 +- 本地 TUI 信息展示裁剪和可见性控制 +- 更强的人在环对齐交互 ### 它不打算解决什么 - 替代官方原版的全部托管和分发形态 -- 在 Feishu 之外直接变成通用 IM 自动化中台 +- 在飞书之外直接变成通用 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。 +这个项目不是从零开始。它建立在 OpenAI Codex CLI 的 Rust、TUI 和 app-server 基础之上,然后把精力进一步放到长期使用更痛的部分:账户运营、会话连续性、workflow、飞书入口、更低噪音的 UI 和 operator ergonomics。 如果你只需要一个在终端里聊天的 Codex,官方原版已经够用。 -如果你需要一个能长期在线,跨账户、跨入口、跨任务持续运转的 Codex,那开头那句话就是结论: - -**真正 24/7 使用的 Codex 发行版。** +如果你需要一个能长期在线,能跨账户、跨入口、跨任务持续运转的 Codex,这就是这个发行版存在的理由。 ## License From 07d3f998a7091b601bac78a43c7540faf28e1840 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 19:45:44 +0800 Subject: [PATCH 61/83] fix workflow no-op bug --- AGENTS.md | 1 + codex-rs/tui/src/app/workflow_runtime.rs | 108 ++++++++++++++++++++++- docs/workflows.md | 1 + 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 50392279f..33681af16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,3 +213,4 @@ These guidelines apply to app-server protocol work in `codex-rs`, especially: - 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/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index 6f9b8e0b2..6ce5b9fa7 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -139,6 +139,12 @@ pub(crate) enum WorkflowOutputDelivery { UserFollowup, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorkflowDisabledJobBehavior { + Skip, + RunRootJobs, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct WorkflowPhaseContext<'a> { pub(crate) current_user_turn: Option<&'a str>, @@ -561,6 +567,7 @@ impl App { &trigger.id, &trigger.jobs, phase_context, + WorkflowDisabledJobBehavior::Skip, /*cancellation*/ None, ) .await @@ -980,6 +987,7 @@ async fn run_background_workflow_selection( trigger_id, &trigger.jobs, phase_context.borrowed(), + WorkflowDisabledJobBehavior::Skip, Some(cancellation), ) .await @@ -1006,6 +1014,7 @@ async fn run_background_workflow_selection( current_user_turn: None, last_assistant_message: None, }, + WorkflowDisabledJobBehavior::RunRootJobs, Some(cancellation), ) .await @@ -1020,6 +1029,7 @@ async fn run_workflow_jobs( trigger_id: &str, root_jobs: &[String], phase_context: WorkflowPhaseContext<'_>, + disabled_job_behavior: WorkflowDisabledJobBehavior, cancellation: Option<&CancellationToken>, ) -> Result, WorkflowRunError> { let ordered = ordered_jobs_for_roots(registry, root_jobs) @@ -1033,7 +1043,11 @@ async fn run_workflow_jobs( let job = registry.jobs.get(&job_name).ok_or_else(|| { WorkflowRunError::Failed(format!("workflow job `{job_name}` does not exist")) })?; - if !job.config.enabled { + let should_run_disabled_job = matches!( + disabled_job_behavior, + WorkflowDisabledJobBehavior::RunRootJobs + ) && root_jobs.iter().any(|root_job| root_job == &job_name); + if !job.config.enabled && !should_run_disabled_job { completed.insert(job_name, false); continue; } @@ -1058,6 +1072,11 @@ async fn run_workflow_jobs( completed.insert(job.name.clone(), true); results.push(result); } + if results.is_empty() { + return Err(WorkflowRunError::Failed(format!( + "workflow `{workflow_name}/{trigger_id}` did not run any enabled jobs" + ))); + } Ok(results) } @@ -1782,6 +1801,93 @@ jobs: } } + #[tokio::test] + async fn manual_job_run_ignores_disabled_root_flag() { + 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: + enabled: false + 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::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:?}"), + } + } + + #[tokio::test] + async fn trigger_run_fails_when_all_target_jobs_are_disabled() { + 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: + enabled: false + steps: + - prompt: summarize the backlog +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(Vec::new()); + let result = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Trigger { + workflow_name: "director".to_string(), + trigger_id: "follow_up".to_string(), + phase_context: OwnedWorkflowPhaseContext::default(), + overlap_behavior: WorkflowTriggerOverlapBehavior::Queue, + }, + CancellationToken::new(), + ) + .await; + + match result.outcome { + BackgroundWorkflowRunOutcome::Failed(error) => assert_eq!( + error, + "workflow `director/follow_up` did not run any enabled jobs".to_string() + ), + other => panic!("expected failed run, got {other:?}"), + } + } + #[tokio::test] async fn cancellation_interrupts_active_workflow_turn() { let tempdir = tempdir().expect("tempdir"); diff --git a/docs/workflows.md b/docs/workflows.md index 0d3490485..7411532b9 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`. +- If a trigger resolves only to disabled or otherwise unrunnable jobs, the run fails visibly instead of silently doing nothing. - `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 From 60bb4dd67fcb8d611b9fd3316b4f28bd7a6d0415 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 8 Apr 2026 20:39:38 +0800 Subject: [PATCH 62/83] feat: add timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 隐藏工具输出时,把 patch 历史项从 `Edited N file(s)` 改成显示 `Edited files: <相对路径列表> - add timestamp seperator on completed turn --- codex-rs/tui/src/app.rs | 18 +++-- codex-rs/tui/src/chatwidget.rs | 22 ++++-- ...ests__apply_patch_hidden_diff_history.snap | 5 ++ ...t_markdown_code_blocks_vt100_snapshot.snap | 6 +- ...n_history_renders_timestamp_separator.snap | 11 +++ ...ified_exec_non_empty_then_empty_after.snap | 4 +- ...d_exec_wait_after_final_agent_message.snap | 4 +- ...ec_wait_before_streamed_agent_message.snap | 4 +- ...ied_exec_waiting_multiple_empty_after.snap | 4 +- .../tui/src/chatwidget/tests/exec_flow.rs | 40 +++++++++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../src/chatwidget/tests/status_and_layout.rs | 30 ++++++++ codex-rs/tui/src/history_cell.rs | 69 ++++++++++++++----- 13 files changed, 186 insertions(+), 32 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_hidden_diff_history.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completed_turn_history_renders_timestamp_separator.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a690b12b9..02b8071b6 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -12981,10 +12981,20 @@ model = "gpt-5.2" .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" - ); + while let Ok(event) = app_event_rx.try_recv() { + match event { + AppEvent::ClawbotTurnCompleted { .. } + | AppEvent::InsertHistoryCell(_) + | AppEvent::ReplayWorkflowHistory { .. } => { + continue; + } + other => { + panic!( + "after_turn should not run before event consumption finishes, got {other:?}" + ); + } + } + } let visible_cells = app .handle_primary_thread_turn_complete_for_workflows( diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 83f093e43..d3c33177f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -940,8 +940,8 @@ pub(crate) struct ChatWidget { needs_final_message_separator: bool, // Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). // - // This gates rendering of the "Worked for …" separator so purely conversational turns don't - // show an empty divider. It is reset when the separator is emitted. + // This only gates rendering of the "Worked for …" label on separators. It is reset when a + // work separator is emitted or when the turn completes. had_work_activity: bool, // Whether the current turn emitted a plan update. saw_plan_update_this_turn: bool, @@ -949,6 +949,8 @@ pub(crate) struct ChatWidget { // later steer. This is cleared when the user submits a steer so the plan popup only appears // if a newer proposed plan arrives afterward. saw_plan_item_this_turn: bool, + // Produces the timestamp label shown on completed-turn history separators. + history_timestamp_label_provider: Arc String + Send + Sync>, // Latest `update_plan` checklist task counts for terminal-title rendering. last_plan_progress: Option<(usize, usize)>, // Incremental buffer for streamed plan content. @@ -2440,9 +2442,10 @@ impl ChatWidget { self.collect_runtime_metrics_delta(); let runtime_metrics = (!self.turn_runtime_metrics.is_empty()).then_some(self.turn_runtime_metrics); - let show_work_separator = self.needs_final_message_separator && self.had_work_activity; - if show_work_separator || runtime_metrics.is_some() { - let elapsed_seconds = if show_work_separator { + let should_add_turn_separator = + self.needs_final_message_separator || runtime_metrics.is_some(); + if should_add_turn_separator { + let elapsed_seconds = if self.had_work_activity { self.bottom_pane .status_widget() .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) @@ -2451,6 +2454,7 @@ impl ChatWidget { None }; self.add_to_history(history_cell::FinalMessageSeparator::new( + Some(self.current_history_timestamp_label()), elapsed_seconds, runtime_metrics, )); @@ -4492,6 +4496,7 @@ impl ChatWidget { .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) .map(|current| self.worked_elapsed_from(current)); self.add_to_history(history_cell::FinalMessageSeparator::new( + None, elapsed_seconds, /*runtime_metrics*/ None, )); @@ -4526,6 +4531,10 @@ impl ChatWidget { elapsed } + fn current_history_timestamp_label(&self) -> String { + (self.history_timestamp_label_provider)() + } + /// Finalizes an exec call while preserving the active exec cell grouping contract. /// /// Exec begin/end events usually pair through `running_commands`, but unified exec can emit an @@ -5048,6 +5057,9 @@ impl ChatWidget { had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + history_timestamp_label_provider: Arc::new(|| { + Local::now().format("%Y-%m-%d %H:%M:%S %:z").to_string() + }), last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_hidden_diff_history.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_hidden_diff_history.snap new file mode 100644 index 000000000..5c59e8fe1 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_hidden_diff_history.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/exec_flow.rs +expression: lines_to_single_string(&approved_lines) +--- +• Edited files: README.md, src/lib.rs diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap index 8017384f5..015f29e36 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- @@ -34,7 +34,6 @@ expression: term.backend().vt100().screen().contents() - • -- Indented code block (4 spaces) @@ -51,3 +50,4 @@ expression: term.backend().vt100().screen().contents() "path": "C:\\Program Files\\App", "regex": "^foo.*(bar)?$" } +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completed_turn_history_renders_timestamp_separator.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completed_turn_history_renders_timestamp_separator.snap new file mode 100644 index 000000000..6d2bca972 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completed_turn_history_renders_timestamp_separator.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: combined +--- + +› Add a timestamp to history + + +• Timestamp rendered. + +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap index 952205e73..893654419 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap @@ -1,8 +1,10 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: combined --- ↳ Interacted with background terminal · just fix └ pwd • Waited for background terminal · just fix + +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap index fdbdffc5d..c54aaa733 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap @@ -1,7 +1,9 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: combined --- • Waited for background terminal · cargo test -p codex-core • Final response. + +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap index f91637e2d..ef9296ca1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap @@ -1,7 +1,9 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: combined --- • Waited for background terminal · cargo test -p codex-core • Streaming response. + +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap index 99bf8e2bd..97d24391c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap @@ -1,5 +1,7 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: combined --- • Waited for background terminal · just fix + +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 20a2858ad..710727289 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1605,6 +1605,46 @@ async fn apply_patch_manual_flow_snapshot() { ); } +#[tokio::test] +async fn apply_patch_hidden_diff_history_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::PatchDiffs, + /*enabled*/ false, + ); + + let mut apply_changes = HashMap::new(); + apply_changes.insert( + test_project_path().join("src").join("lib.rs"), + FileChange::Add { + content: "pub fn hidden() {}\n".to_string(), + }, + ); + apply_changes.insert( + test_project_path().join("README.md"), + FileChange::Add { + content: "# hidden patch\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + auto_approved: true, + changes: apply_changes, + }), + }); + let approved_lines = drain_insert_history(&mut rx) + .pop() + .expect("hidden patch summary cell"); + + assert_chatwidget_snapshot!( + "apply_patch_hidden_diff_history", + lines_to_single_string(&approved_lines) + ); +} + #[tokio::test] async fn apply_patch_approval_sends_op_with_call_id() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 7860bc2e9..d219b2e01 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -273,6 +273,7 @@ pub(super) async fn make_chatwidget_manual( had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + history_timestamp_label_provider: Arc::new(|| "2026-04-08 03:04:05 +08:00".to_string()), last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, 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 9722b87be..d591e3462 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1345,6 +1345,36 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() { ); } +#[tokio::test] +async fn completed_turn_history_renders_timestamp_separator_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + complete_user_message(&mut chat, "msg-user", "Add a timestamp to history"); + complete_assistant_message( + &mut chat, + "msg-assistant", + "Timestamp rendered.", + /*phase*/ None, + ); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("Timestamp rendered.".into()), + }), + }); + + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_chatwidget_snapshot!( + "completed_turn_history_renders_timestamp_separator", + combined + ); +} + #[tokio::test] async fn deltas_then_same_final_message_are_rendered_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index bfbbc77c4..813afb638 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1168,13 +1168,19 @@ pub(crate) struct PatchHistoryCell { 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(), - ]; + let mut files = self + .changes + .keys() + .map(|path| display_path_for(path, &self.cwd)) + .collect::>(); + files.sort_unstable(); + let summary = format!("Edited files: {}", files.join(", ")); + return PrefixedWrappedHistoryCell::new( + Line::from(Span::from(summary).dim()), + "• ".dim(), + " ".dim(), + ) + .display_lines(width); } create_diff_summary(&self.changes, &self.cwd, width as usize) } @@ -2873,20 +2879,22 @@ pub(crate) fn new_reasoning_raw_block( #[derive(Debug)] /// A visual divider between turns, optionally showing how long the assistant "worked for". /// -/// This separator is only emitted for turns that performed concrete work (e.g., running commands, -/// applying patches, making MCP tool calls), so purely conversational turns do not show an empty -/// divider. +/// Completed turns can use this to show a local timestamp, while in-turn work handoffs can omit +/// the timestamp and only show work-related labels. pub struct FinalMessageSeparator { + timestamp_label: Option, elapsed_seconds: Option, runtime_metrics: Option, } impl FinalMessageSeparator { /// Creates a separator; `elapsed_seconds` typically comes from the status indicator timer. pub(crate) fn new( + timestamp_label: Option, elapsed_seconds: Option, runtime_metrics: Option, ) -> Self { Self { + timestamp_label, elapsed_seconds, runtime_metrics, } @@ -2895,6 +2903,9 @@ impl FinalMessageSeparator { impl HistoryCell for FinalMessageSeparator { fn display_lines(&self, width: u16) -> Vec> { let mut label_parts = Vec::new(); + if let Some(timestamp_label) = self.timestamp_label.as_ref() { + label_parts.push(timestamp_label.clone()); + } if let Some(elapsed_seconds) = self .elapsed_seconds .filter(|seconds| *seconds > 60) @@ -3336,7 +3347,7 @@ mod tests { turn_ttft_ms: 0, turn_ttfm_ms: 0, }; - let cell = FinalMessageSeparator::new(Some(12), Some(summary)); + let cell = FinalMessageSeparator::new(None, Some(12), Some(summary)); let rendered = render_lines(&cell.display_lines(/*width*/ 600)); assert_eq!(rendered.len(), 1); @@ -3354,13 +3365,26 @@ mod tests { #[test] fn final_message_separator_includes_worked_label_after_one_minute() { - let cell = FinalMessageSeparator::new(Some(61), /*runtime_metrics*/ None); + let cell = FinalMessageSeparator::new(None, Some(61), /*runtime_metrics*/ None); let rendered = render_lines(&cell.display_lines(/*width*/ 200)); assert_eq!(rendered.len(), 1); assert!(rendered[0].contains("Worked for")); } + #[test] + fn final_message_separator_includes_timestamp_label() { + let cell = FinalMessageSeparator::new( + Some("2026-04-08 03:04:05 +08:00".to_string()), + /*elapsed_seconds*/ None, + /*runtime_metrics*/ None, + ); + let rendered = render_lines(&cell.display_lines(/*width*/ 80)); + + assert_eq!(rendered.len(), 1); + assert!(rendered[0].contains("2026-04-08 03:04:05 +08:00")); + } + #[test] fn ps_output_empty_snapshot() { let cell = new_unified_exec_processes_output(Vec::new()); @@ -4200,23 +4224,36 @@ mod tests { } #[test] - fn patch_event_hides_diff_summary_when_disabled() { + fn patch_event_hides_diff_summary_when_disabled_and_lists_relative_paths() { let mut changes = HashMap::new(); changes.insert( - PathBuf::from("src/lib.rs"), + test_cwd().join("src").join("lib.rs"), FileChange::Add { content: "pub fn demo() {}\n".to_string(), }, ); + changes.insert( + test_cwd().join("README.md"), + FileChange::Add { + content: "# demo\n".to_string(), + }, + ); let display_preferences = DisplayPreferences::default(); display_preferences.set_enabled( crate::display_preferences::DisplayPreferenceKey::PatchDiffs, /*enabled*/ false, ); - let cell = new_patch_event(changes, Path::new("/tmp/project"), display_preferences); + let cell = new_patch_event(changes, &test_cwd(), display_preferences); let rendered = render_lines(&cell.display_lines(/*width*/ 80)); - assert_eq!(rendered, vec!["• Edited 1 file (diff hidden)"]); + assert_eq!( + rendered, + vec![format!( + "• Edited files: {}, {}", + PathBuf::from("README.md").display(), + PathBuf::from("src").join("lib.rs").display() + )] + ); } #[test] From af23361073c4575d692069de040b940ad7a592c9 Mon Sep 17 00:00:00 2001 From: piping Date: Thu, 9 Apr 2026 08:52:33 +0800 Subject: [PATCH 63/83] add insight for session usage - mechanic --- codex-rs/tui/src/chatwidget.rs | 5 + ...s__slash_insight_report_ready_message.snap | 5 + .../src/chatwidget/tests/slash_commands.rs | 184 ++++++ codex-rs/tui/src/insight/aggregator.rs | 370 ++++++++++++ codex-rs/tui/src/insight/collector.rs | 321 +++++++++++ codex-rs/tui/src/insight/mod.rs | 214 +++++++ codex-rs/tui/src/insight/report.rs | 539 ++++++++++++++++++ ...t__report__tests__insight_report_html.snap | 289 ++++++++++ codex-rs/tui/src/insight/types.rs | 257 +++++++++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 3 + docs/slash_commands.md | 26 + 12 files changed, 2214 insertions(+) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_insight_report_ready_message.snap create mode 100644 codex-rs/tui/src/insight/aggregator.rs create mode 100644 codex-rs/tui/src/insight/collector.rs create mode 100644 codex-rs/tui/src/insight/mod.rs create mode 100644 codex-rs/tui/src/insight/report.rs create mode 100644 codex-rs/tui/src/insight/snapshots/codex_tui__insight__report__tests__insight_report_html.snap create mode 100644 codex-rs/tui/src/insight/types.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d3c33177f..a255169e7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -350,6 +350,7 @@ use crate::history_cell::HookCell; use crate::history_cell::McpToolCallCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::WebSearchCell; +use crate::insight; use crate::key_hint; use crate::key_hint::KeyBinding; #[cfg(test)] @@ -5686,6 +5687,10 @@ impl ChatWidget { ); } } + SlashCommand::Insight => { + self.add_info_message(insight::start_message(), /*hint*/ None); + insight::spawn_report_generation(self.config.clone(), self.app_event_tx.clone()); + } SlashCommand::DebugConfig => { self.add_debug_config_output(); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_insight_report_ready_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_insight_report_ready_message.snap new file mode 100644 index 000000000..18193d2cf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_insight_report_ready_message.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: sanitized +--- +• Insight report generated: $CODEX_HOME/reports/insight-.html Patterns and suggestions used local heuristics fallback in this build. diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 39598aeb3..f30e5be1a 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,5 +1,6 @@ use super::*; use pretty_assertions::assert_eq; +use std::time::Duration; fn turn_complete_event(turn_id: &str, last_agent_message: Option<&str>) -> TurnCompleteEvent { serde_json::from_value(serde_json::json!({ @@ -468,6 +469,189 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit ); } +#[tokio::test] +async fn slash_copy_state_clears_on_thread_rollback() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("Reply that will be rolled back".to_string()), + }), + }); + chat.handle_codex_event(Event { + id: "rollback-1".into(), + msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + }); + + assert_eq!(chat.last_copyable_output, None); +} + +#[tokio::test] +async fn slash_insight_generates_report_and_announces_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let temp_home = tempdir().unwrap(); + let codex_home = temp_home.path().to_path_buf(); + let sessions_dir = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + chat.config.codex_home = codex_home.clone(); + chat.config.sqlite_home = codex_home.clone(); + + let thread_id = ThreadId::new(); + let rollout_path = + sessions_dir.join("rollout-2026-04-08T12-00-00-00000000-0000-0000-0000-000000000001.jsonl"); + let rollout = [ + serde_json::to_string(&codex_protocol::protocol::RolloutLine { + timestamp: "2026-04-08T12:00:00Z".to_string(), + item: codex_protocol::protocol::RolloutItem::SessionMeta( + codex_protocol::protocol::SessionMetaLine { + meta: codex_protocol::protocol::SessionMeta { + id: thread_id, + forked_from_id: None, + timestamp: "2026-04-08T12:00:00Z".to_string(), + cwd: PathBuf::from("/repo"), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, + agent_path: None, + model_provider: Some("openai".to_string()), + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }, + git: None, + }, + ), + }) + .unwrap(), + serde_json::to_string(&codex_protocol::protocol::RolloutLine { + timestamp: "2026-04-08T12:00:05Z".to_string(), + item: codex_protocol::protocol::RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "## My request for Codex: analyze local sessions".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + }) + .unwrap(), + serde_json::to_string(&codex_protocol::protocol::RolloutLine { + timestamp: "2026-04-08T12:00:10Z".to_string(), + item: codex_protocol::protocol::RolloutItem::EventMsg(EventMsg::TokenCount( + TokenCountEvent { + info: Some(TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 80, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 5, + total_tokens: 100, + }, + last_token_usage: TokenUsage::default(), + model_context_window: Some(128000), + }), + rate_limits: None, + }, + )), + }) + .unwrap(), + serde_json::to_string(&codex_protocol::protocol::RolloutLine { + timestamp: "2026-04-08T12:00:12Z".to_string(), + item: codex_protocol::protocol::RolloutItem::EventMsg(EventMsg::ExecCommandEnd( + ExecCommandEndEvent { + call_id: "call-1".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command: vec!["rg".to_string(), "insight".to_string()], + cwd: PathBuf::from("/repo"), + parsed_cmd: Vec::new(), + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: Duration::from_secs(1), + formatted_output: String::new(), + status: CoreExecCommandStatus::Completed, + }, + )), + }) + .unwrap(), + ] + .join("\n"); + std::fs::write(&rollout_path, rollout).unwrap(); + + chat.dispatch_command(SlashCommand::Insight); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected immediate status message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Generating /insight report from local sessions"), + "unexpected initial message: {rendered:?}" + ); + + let final_cell = tokio::time::timeout(Duration::from_secs(5), async { + loop { + match rx.recv().await { + Some(AppEvent::InsertHistoryCell(cell)) => break cell, + Some(_) => continue, + None => panic!("expected report completion cell"), + } + } + }) + .await + .expect("completion event should arrive"); + let rendered = lines_to_single_string(&final_cell.display_lines(u16::MAX)); + let mut sanitized = rendered.replace(codex_home.display().to_string().as_str(), "$CODEX_HOME"); + if let Some(start) = sanitized.find("insight-") + && let Some(end) = sanitized[start..].find(".html") + { + sanitized.replace_range( + start..start + end + ".html".len(), + "insight-.html", + ); + } + assert_chatwidget_snapshot!("slash_insight_report_ready_message", sanitized); + assert!(rendered.contains("Insight report generated:")); + assert!(codex_home.join("reports").exists()); +} + +#[tokio::test] +async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_turn_complete() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_codex_event_replay(Event { + id: "turn-1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Legacy final message".into(), + phase: None, + memory_citation: None, + }), + }); + let _ = drain_insert_history(&mut rx); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + }), + }); + let _ = drain_insert_history(&mut rx); + + assert_eq!( + chat.last_agent_markdown_text(), + Some("Legacy final message") + ); +} + #[tokio::test] async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/insight/aggregator.rs b/codex-rs/tui/src/insight/aggregator.rs new file mode 100644 index 000000000..275ee458a --- /dev/null +++ b/codex-rs/tui/src/insight/aggregator.rs @@ -0,0 +1,370 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; +use std::time::Duration; + +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::ThreadId; + +use super::types::AggregateMetrics; +use super::types::CollectedThread; +use super::types::CollectionResult; +use super::types::InsightOverview; +use super::types::NarrativeMode; +use super::types::RootSessionSummary; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AggregatedInsight { + pub(crate) overview: InsightOverview, + pub(crate) roots: Vec, + pub(crate) common_patterns: Vec, + pub(crate) efficiency_suggestions: Vec, + pub(crate) narrative_mode: NarrativeMode, +} + +pub(crate) fn aggregate(collection: CollectionResult) -> AggregatedInsight { + let roots = build_root_summaries(collection.threads); + let mut metrics = AggregateMetrics::default(); + let mut earliest_event_at = None; + let mut latest_event_at = None; + let archived_threads = roots + .iter() + .flat_map(|root| root.threads.iter()) + .filter(|thread| thread.archived) + .count(); + + for root in &roots { + metrics.add_assign(&root.metrics); + earliest_event_at = min_datetime(earliest_event_at, root.earliest_event_at); + latest_event_at = max_datetime(latest_event_at, root.latest_event_at); + } + + let history_span = match (earliest_event_at, latest_event_at) { + (Some(first), Some(last)) if last > first => (last - first).to_std().unwrap_or_default(), + _ => Duration::ZERO, + }; + let overview = InsightOverview { + total_root_sessions: roots.len(), + total_threads: roots.iter().map(|root| root.threads.len()).sum(), + archived_threads, + scanned_files: collection.scanned_files, + skipped_files: collection.skipped_files, + metrics: metrics.clone(), + earliest_event_at, + latest_event_at, + history_span, + }; + + AggregatedInsight { + common_patterns: build_common_patterns(&overview, &roots), + efficiency_suggestions: build_efficiency_suggestions(&overview, &roots), + overview, + roots, + narrative_mode: NarrativeMode::LocalHeuristics, + } +} + +fn build_root_summaries(threads: Vec) -> Vec { + let thread_by_id: HashMap = threads + .into_iter() + .map(|thread| (thread.thread_id, thread)) + .collect(); + + let mut grouped = HashMap::>::new(); + for thread in thread_by_id.values() { + let root_thread_id = resolve_root_thread_id(thread, &thread_by_id); + grouped + .entry(root_thread_id) + .or_default() + .push(thread.clone()); + } + + let mut roots: Vec = grouped + .into_iter() + .filter_map(|(root_thread_id, mut threads)| { + threads.sort_by_key(|thread| (thread.depth.unwrap_or(0), thread.created_at)); + let root = threads + .iter() + .find(|thread| thread.thread_id == root_thread_id) + .cloned() + .or_else(|| threads.first().cloned())?; + + let mut metrics = AggregateMetrics::default(); + let mut earliest_event_at = None; + let mut latest_event_at = None; + for thread in &threads { + metrics.add_assign(&thread.metrics); + earliest_event_at = min_datetime(earliest_event_at, thread.first_event_at); + latest_event_at = max_datetime(latest_event_at, thread.last_event_at); + } + let wall_clock_span = match (earliest_event_at, latest_event_at) { + (Some(first), Some(last)) if last > first => { + (last - first).to_std().unwrap_or_default() + } + _ => Duration::ZERO, + }; + + Some(RootSessionSummary { + root_thread_id, + title: root.title, + cwd: if root.cwd.as_os_str().is_empty() { + PathBuf::new() + } else { + root.cwd + }, + rollout_path: root.rollout_path, + archived: root.archived, + earliest_event_at, + latest_event_at, + wall_clock_span, + metrics, + threads, + }) + }) + .collect(); + + roots.sort_by(|left, right| { + right + .metrics + .total_tokens + .cmp(&left.metrics.total_tokens) + .then_with(|| right.latest_event_at.cmp(&left.latest_event_at)) + }); + roots +} + +fn resolve_root_thread_id( + thread: &CollectedThread, + thread_by_id: &HashMap, +) -> ThreadId { + let mut current = thread; + let mut seen = HashSet::from([current.thread_id]); + while let Some(parent_thread_id) = current.parent_thread_id { + let Some(parent) = thread_by_id.get(&parent_thread_id) else { + return parent_thread_id; + }; + if !seen.insert(parent.thread_id) { + return current.thread_id; + } + current = parent; + } + current.thread_id +} + +fn build_common_patterns(overview: &InsightOverview, roots: &[RootSessionSummary]) -> Vec { + let mut patterns = Vec::new(); + + if let Some(root) = roots.first() { + patterns.push(format!( + "Token hotspot: `{}` is the heaviest root session with {} tokens across {} thread(s).", + root.title, + format_number(root.metrics.total_tokens), + root.threads.len(), + )); + } + + let subagent_roots = roots.iter().filter(|root| root.threads.len() > 1).count(); + patterns.push(format!( + "Subagent usage: {} / {} root session(s) include spawned child threads.", + subagent_roots, + roots.len(), + )); + + patterns.push(format!( + "Timing split: exact tool runtime is {}, estimated user wait is {}, and residual model/UI time is {}.", + format_duration(overview.metrics.exact_tool_runtime()), + format_duration(overview.metrics.estimated_user_wait), + format_duration(overview.metrics.residual_runtime_estimate), + )); + patterns.push(format!( + "Failure surface: {:.1}% failure rate across counted operations (exec + MCP + dynamic tools + patch + API errors).", + overview.metrics.failure_rate() * 100.0, + )); + patterns +} + +fn build_efficiency_suggestions( + overview: &InsightOverview, + roots: &[RootSessionSummary], +) -> Vec { + let mut suggestions = Vec::new(); + + if overview.metrics.exec_commands.failures > 0 + && overview.metrics.exec_commands.failures >= overview.metrics.api_error_count + { + suggestions.push( + "Exec failures dominate the observed error budget; tighten command scope and prefer read-first probes like `rg`, `sed -n`, and targeted tests before wider shell actions." + .to_string(), + ); + } + + if overview.metrics.total_tokens > 500_000 { + suggestions.push( + "Token usage is concentrated in a few long-running sessions; trigger `/compact` earlier on exploratory threads to reduce context drag." + .to_string(), + ); + } + + if overview.metrics.estimated_user_wait > overview.metrics.exact_tool_runtime() { + suggestions.push( + "Estimated user-wait time exceeds exact tool runtime; batch follow-up prompts more aggressively when a task can be specified upfront." + .to_string(), + ); + } + + if roots.iter().all(|root| root.threads.len() == 1) && !roots.is_empty() { + suggestions.push( + "No multi-thread roots were detected in this sample; for parallelizable exploration or verification work, `/agent` or subagent workflows may reduce end-to-end elapsed time." + .to_string(), + ); + } + + if overview.metrics.patches.changed_files > overview.metrics.patches.count.saturating_mul(3) { + suggestions.push( + "Patch churn is spread across many files; grouping edits by module boundary should make review and rollback cheaper." + .to_string(), + ); + } + + if suggestions.is_empty() { + suggestions.push( + "No dominant inefficiency pattern stood out from local heuristics; use the per-root drill-down to inspect the highest-token and highest-failure sessions first." + .to_string(), + ); + } + + suggestions +} + +fn min_datetime( + left: Option>, + right: Option>, +) -> Option> { + match (left, right) { + (Some(left), Some(right)) => Some(left.min(right)), + (Some(left), None) => Some(left), + (None, Some(right)) => Some(right), + (None, None) => None, + } +} + +fn max_datetime( + left: Option>, + right: Option>, +) -> Option> { + match (left, right) { + (Some(left), Some(right)) => Some(left.max(right)), + (Some(left), None) => Some(left), + (None, Some(right)) => Some(right), + (None, None) => None, + } +} + +pub(crate) fn format_duration(duration: Duration) -> String { + let total_seconds = duration.as_secs(); + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } +} + +pub(crate) fn format_number(value: i64) -> String { + let negative = value < 0; + let digits = value.abs().to_string(); + let mut formatted = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().rev().enumerate() { + if index > 0 && index % 3 == 0 { + formatted.push(','); + } + formatted.push(digit); + } + let formatted: String = formatted.chars().rev().collect(); + if negative { + format!("-{formatted}") + } else { + formatted + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::time::Duration; + + use chrono::TimeZone; + use chrono::Utc; + use pretty_assertions::assert_eq; + + use super::aggregate; + use crate::insight::types::AggregateMetrics; + use crate::insight::types::CollectedThread; + use crate::insight::types::CollectionResult; + use codex_protocol::ThreadId; + + fn thread_id(value: &str) -> ThreadId { + ThreadId::from_string(value).expect("valid thread id") + } + + fn collected_thread( + thread_id: ThreadId, + parent_thread_id: Option, + title: &str, + tokens: i64, + ) -> CollectedThread { + let created_at = Utc.with_ymd_and_hms(2026, 4, 8, 12, 0, 0).unwrap(); + let mut metrics = AggregateMetrics { + total_tokens: tokens, + cumulative_thread_span: Duration::from_secs(20), + ..AggregateMetrics::default() + }; + metrics + .exec_commands + .add_sample(false, Duration::from_secs(3)); + CollectedThread { + thread_id, + parent_thread_id, + depth: parent_thread_id.map(|_| 1), + title: title.to_string(), + cwd: PathBuf::from("/repo"), + rollout_path: PathBuf::from(format!("/tmp/{thread_id}.jsonl")), + archived: false, + source_label: "cli".to_string(), + agent_nickname: None, + agent_role: None, + agent_path: None, + created_at, + updated_at: created_at, + first_event_at: Some(created_at), + last_event_at: Some(created_at + chrono::Duration::seconds(20)), + metrics, + } + } + + #[test] + fn aggregate_rolls_children_into_root_session() { + let root = thread_id("00000000-0000-0000-0000-000000000001"); + let child = thread_id("00000000-0000-0000-0000-000000000002"); + let result = aggregate(CollectionResult { + threads: vec![ + collected_thread(root, None, "root", 120), + collected_thread(child, Some(root), "child", 30), + ], + scanned_files: 2, + skipped_files: 0, + }); + + assert_eq!(result.roots.len(), 1); + assert_eq!(result.roots[0].root_thread_id, root); + assert_eq!(result.roots[0].threads.len(), 2); + assert_eq!(result.roots[0].metrics.total_tokens, 150); + assert_eq!(result.overview.total_root_sessions, 1); + assert_eq!(result.overview.total_threads, 2); + } +} diff --git a/codex-rs/tui/src/insight/collector.rs b/codex-rs/tui/src/insight/collector.rs new file mode 100644 index 000000000..b454fb92a --- /dev/null +++ b/codex-rs/tui/src/insight/collector.rs @@ -0,0 +1,321 @@ +use std::io::ErrorKind; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use chrono::DateTime; +use chrono::Utc; +use codex_core::config::Config; +use codex_protocol::ThreadId; +use codex_protocol::protocol::DynamicToolCallResponseEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandStatus; +use codex_protocol::protocol::PatchApplyStatus; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; +use codex_rollout::SESSIONS_SUBDIR; + +use super::types::AggregateMetrics; +use super::types::CollectedThread; +use super::types::CollectionResult; + +pub(crate) async fn collect_sessions(config: &Config) -> anyhow::Result { + let mut rollout_paths = Vec::new(); + collect_rollout_paths( + config.codex_home.join(SESSIONS_SUBDIR), + /*archived*/ false, + &mut rollout_paths, + ) + .await?; + collect_rollout_paths( + config.codex_home.join(ARCHIVED_SESSIONS_SUBDIR), + /*archived*/ true, + &mut rollout_paths, + ) + .await?; + + let scanned_files = rollout_paths.len(); + let mut skipped_files = 0usize; + let mut threads = Vec::new(); + for (path, archived) in rollout_paths { + match analyze_rollout(path.clone(), archived).await { + Ok(Some(thread)) => threads.push(thread), + Ok(None) => skipped_files = skipped_files.saturating_add(1), + Err(err) => { + tracing::warn!("failed to analyze rollout {}: {err}", path.display()); + skipped_files = skipped_files.saturating_add(1); + } + } + } + + Ok(CollectionResult { + threads, + scanned_files, + skipped_files, + }) +} + +async fn collect_rollout_paths( + root: PathBuf, + archived: bool, + paths: &mut Vec<(PathBuf, bool)>, +) -> anyhow::Result<()> { + let mut pending = vec![root]; + while let Some(dir) = pending.pop() { + let mut entries = match tokio::fs::read_dir(&dir).await { + Ok(entries) => entries, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => { + return Err(err).with_context(|| format!("failed to read {}", dir.display())); + } + }; + while let Some(entry) = entries.next_entry().await? { + let entry_type = entry.file_type().await?; + let path = entry.path(); + if entry_type.is_dir() { + pending.push(path); + } else if entry_type.is_file() + && path + .extension() + .is_some_and(|extension| extension == "jsonl") + { + paths.push((path, archived)); + } + } + } + Ok(()) +} + +async fn analyze_rollout(path: PathBuf, archived: bool) -> anyhow::Result> { + let text = tokio::fs::read_to_string(&path) + .await + .with_context(|| format!("failed to read {}", path.display()))?; + if text.trim().is_empty() { + return Ok(None); + } + + let file_updated_at = tokio::fs::metadata(&path) + .await + .ok() + .and_then(|metadata| metadata.modified().ok()) + .map(DateTime::::from); + + let mut session_meta: Option = None; + let mut metrics = AggregateMetrics::default(); + let mut first_event_at = None; + let mut last_event_at = None; + let mut last_turn_complete_at = None; + let mut title = String::new(); + + for raw_line in text.lines() { + if raw_line.trim().is_empty() { + continue; + } + + let rollout_line = match serde_json::from_str::(raw_line) { + Ok(rollout_line) => rollout_line, + Err(_) => { + metrics.parse_errors = metrics.parse_errors.saturating_add(1); + continue; + } + }; + + let timestamp = parse_timestamp(rollout_line.timestamp.as_str()); + if first_event_at.is_none() { + first_event_at = timestamp; + } + if timestamp.is_some() { + last_event_at = timestamp; + } + + match rollout_line.item { + RolloutItem::SessionMeta(meta_line) => { + if session_meta.is_none() { + session_meta = Some(meta_line.meta); + } + } + RolloutItem::EventMsg(event) => match event { + EventMsg::UserMessage(user) => { + metrics.user_message_count = metrics.user_message_count.saturating_add(1); + if title.is_empty() { + let stripped = strip_user_message_prefix(user.message.as_str()); + if !stripped.is_empty() { + title = stripped.to_string(); + } + } + if let (Some(turn_complete_at), Some(user_message_at)) = + (last_turn_complete_at.take(), timestamp) + && user_message_at > turn_complete_at + { + metrics.estimated_user_wait = metrics + .estimated_user_wait + .saturating_add((user_message_at - turn_complete_at).to_std()?); + } + } + EventMsg::TurnComplete(_) => { + metrics.completed_turn_count = metrics.completed_turn_count.saturating_add(1); + last_turn_complete_at = timestamp; + } + EventMsg::TokenCount(token_count) => { + if let Some(info) = token_count.info { + metrics.total_tokens = info.total_token_usage.total_tokens.max(0); + metrics.input_tokens = info.total_token_usage.input_tokens.max(0); + metrics.output_tokens = info.total_token_usage.output_tokens.max(0); + metrics.reasoning_output_tokens = + info.total_token_usage.reasoning_output_tokens.max(0); + } + } + EventMsg::ExecCommandEnd(exec) => { + let failed = + matches!(exec.status, ExecCommandStatus::Failed) || exec.exit_code != 0; + metrics.exec_commands.add_sample(failed, exec.duration); + } + EventMsg::McpToolCallEnd(tool_call) => { + metrics + .mcp_tool_calls + .add_sample(!tool_call.is_success(), tool_call.duration); + } + EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { + success, + duration, + .. + }) => { + metrics.dynamic_tool_calls.add_sample(!success, duration); + } + EventMsg::WebSearchEnd(_) => { + metrics.web_search_count = metrics.web_search_count.saturating_add(1); + } + EventMsg::ImageGenerationEnd(_) => { + metrics.image_generation_count = + metrics.image_generation_count.saturating_add(1); + } + EventMsg::ViewImageToolCall(_) => { + metrics.view_image_count = metrics.view_image_count.saturating_add(1); + } + EventMsg::PatchApplyEnd(patch) => { + let failed = matches!(patch.status, PatchApplyStatus::Failed) || !patch.success; + metrics.patches.add_sample(failed, patch.changes.len()); + } + EventMsg::Error(_) | EventMsg::StreamError(_) => { + metrics.api_error_count = metrics.api_error_count.saturating_add(1); + } + _ => {} + }, + RolloutItem::ResponseItem(_) + | RolloutItem::Compacted(_) + | RolloutItem::TurnContext(_) => {} + } + } + + let Some(session_meta) = session_meta else { + return Ok(None); + }; + + let created_at = parse_timestamp(session_meta.timestamp.as_str()) + .or(first_event_at) + .or(file_updated_at) + .unwrap_or_else(Utc::now); + let updated_at = last_event_at.or(file_updated_at).unwrap_or(created_at); + let wall_clock_span = match (first_event_at, last_event_at) { + (Some(first), Some(last)) if last > first => (last - first).to_std()?, + _ => Duration::ZERO, + }; + metrics.cumulative_thread_span = wall_clock_span; + metrics.residual_runtime_estimate = wall_clock_span + .saturating_sub(metrics.exact_tool_runtime()) + .saturating_sub(metrics.estimated_user_wait); + + let (parent_thread_id, depth, source_label, agent_nickname, agent_role, agent_path) = + source_fields(&session_meta.source); + + Ok(Some(CollectedThread { + thread_id: session_meta.id, + parent_thread_id, + depth, + title: if title.is_empty() { + fallback_title(&path) + } else { + title + }, + cwd: session_meta.cwd, + rollout_path: path, + archived, + source_label, + agent_nickname, + agent_role, + agent_path, + created_at, + updated_at, + first_event_at, + last_event_at, + metrics, + })) +} + +fn source_fields( + source: &SessionSource, +) -> ( + Option, + Option, + String, + Option, + Option, + Option, +) { + match source { + SessionSource::Cli => (None, None, "cli".to_string(), None, None, None), + SessionSource::VSCode => (None, None, "vscode".to_string(), None, None, None), + SessionSource::Exec => (None, None, "exec".to_string(), None, None, None), + SessionSource::Mcp => (None, None, "mcp".to_string(), None, None, None), + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_nickname, + agent_role, + }) => ( + Some(*parent_thread_id), + Some(*depth), + format!("subagent/thread_spawn:d{depth}"), + agent_nickname.clone(), + agent_role.clone(), + agent_path.clone().map(|path| path.to_string()), + ), + SessionSource::SubAgent(other) => ( + None, + None, + format!("subagent/{other}"), + source.get_nickname(), + source.get_agent_role(), + source.get_agent_path().map(|path| path.to_string()), + ), + SessionSource::Custom(other) => (None, None, other.clone(), None, None, None), + SessionSource::Unknown => (None, None, "unknown".to_string(), None, None, None), + } +} + +fn parse_timestamp(timestamp: &str) -> Option> { + DateTime::parse_from_rfc3339(timestamp) + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + +fn strip_user_message_prefix(text: &str) -> &str { + match text.find(USER_MESSAGE_BEGIN) { + Some(index) => text[index + USER_MESSAGE_BEGIN.len()..].trim(), + None => text.trim(), + } +} + +fn fallback_title(path: &Path) -> String { + path.file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("untitled") + .to_string() +} diff --git a/codex-rs/tui/src/insight/mod.rs b/codex-rs/tui/src/insight/mod.rs new file mode 100644 index 000000000..4899e72ec --- /dev/null +++ b/codex-rs/tui/src/insight/mod.rs @@ -0,0 +1,214 @@ +mod aggregator; +mod collector; +mod report; +mod types; + +use std::path::Path; + +use chrono::Utc; +use codex_core::config::Config; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::history_cell; + +use self::aggregator::aggregate; +use self::collector::collect_sessions; +use self::report::write_report_html; +use self::types::InsightReportData; + +pub(crate) fn spawn_report_generation(config: Config, app_event_tx: AppEventSender) { + tokio::spawn(async move { + match generate_report(config).await { + Ok(output) => { + let message = format!("Insight report generated: {}", output.report_path.display()); + let hint = Some( + "Patterns and suggestions used local heuristics fallback in this build." + .to_string(), + ); + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, hint), + ))); + } + Err(err) => { + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(format!( + "Failed to generate /insight report: {err}" + )), + ))); + } + } + }); +} + +pub(crate) fn start_message() -> String { + "Generating /insight report from local sessions...".to_string() +} + +async fn generate_report(config: Config) -> anyhow::Result { + let collection = collect_sessions(&config).await?; + let aggregated = aggregate(collection); + let generated_at = Utc::now(); + let report_path = report_path(config.codex_home.as_path(), generated_at); + let data = InsightReportData { + generated_at, + codex_home: config.codex_home.clone(), + report_path, + overview: aggregated.overview, + roots: aggregated.roots, + common_patterns: aggregated.common_patterns, + efficiency_suggestions: aggregated.efficiency_suggestions, + narrative_mode: aggregated.narrative_mode, + }; + write_report_html(&data).await?; + Ok(data) +} + +fn report_path(codex_home: &Path, generated_at: chrono::DateTime) -> std::path::PathBuf { + codex_home.join("reports").join(format!( + "insight-{}.html", + generated_at.format("%Y%m%d-%H%M%S") + )) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + use std::time::Duration; + + use chrono::Utc; + use codex_core::config::ConfigBuilder; + use codex_protocol::ThreadId; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::ExecCommandEndEvent; + use codex_protocol::protocol::ExecCommandSource; + use codex_protocol::protocol::ExecCommandStatus; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMeta; + use codex_protocol::protocol::SessionMetaLine; + use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::TokenCountEvent; + use codex_protocol::protocol::TokenUsage; + use codex_protocol::protocol::TokenUsageInfo; + use codex_protocol::protocol::TurnCompleteEvent; + use codex_protocol::protocol::UserMessageEvent; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use super::collector::collect_sessions; + use super::generate_report; + + fn write_rollout(codex_home: &PathBuf, relative_dir: &str, thread_id: ThreadId) { + let dir = codex_home.join(relative_dir); + fs::create_dir_all(&dir).expect("create rollout dir"); + let path = + dir.join("rollout-2026-04-08T12-00-00-00000000-0000-0000-0000-000000000001.jsonl"); + let session_meta = RolloutLine { + timestamp: "2026-04-08T12:00:00Z".to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: thread_id, + forked_from_id: None, + timestamp: "2026-04-08T12:00:00Z".to_string(), + cwd: PathBuf::from("/repo"), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, + agent_path: None, + model_provider: Some("openai".to_string()), + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }, + git: None, + }), + }; + let user = RolloutLine { + timestamp: "2026-04-08T12:00:05Z".to_string(), + item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "## My request for Codex: inspect session data".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + }; + let token = RolloutLine { + timestamp: "2026-04-08T12:00:15Z".to_string(), + item: RolloutItem::EventMsg(EventMsg::TokenCount(TokenCountEvent { + info: Some(TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 100, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 10, + total_tokens: 120, + }, + last_token_usage: TokenUsage::default(), + model_context_window: Some(128000), + }), + rate_limits: None, + })), + }; + let exec = RolloutLine { + timestamp: "2026-04-08T12:00:18Z".to_string(), + item: RolloutItem::EventMsg(EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-1".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command: vec!["rg".to_string(), "todo".to_string()], + cwd: PathBuf::from("/repo"), + parsed_cmd: Vec::new(), + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: Duration::from_secs(2), + formatted_output: String::new(), + status: ExecCommandStatus::Completed, + })), + }; + let turn_complete = RolloutLine { + timestamp: "2026-04-08T12:00:20Z".to_string(), + item: RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("done".to_string()), + })), + }; + + let body = [session_meta, user, token, exec, turn_complete] + .into_iter() + .map(|line| serde_json::to_string(&line).expect("serialize")) + .collect::>() + .join("\n"); + fs::write(path, body).expect("write rollout"); + } + + #[tokio::test] + async fn collect_and_generate_report_for_sample_rollout() { + let temp_home = tempdir().expect("temp home"); + let codex_home = temp_home.path().to_path_buf(); + write_rollout(&codex_home, "sessions", ThreadId::new()); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .build() + .await + .expect("config"); + config.sqlite_home = codex_home.clone(); + + let collection = collect_sessions(&config).await.expect("collect sessions"); + assert_eq!(collection.scanned_files, 1); + assert_eq!(collection.threads.len(), 1); + + let report = generate_report(config).await.expect("generate report"); + assert_eq!(report.overview.total_threads, 1); + assert!(report.report_path.exists()); + assert!(report.report_path.starts_with(codex_home.join("reports"))); + assert!(report.generated_at <= Utc::now()); + } +} diff --git a/codex-rs/tui/src/insight/report.rs b/codex-rs/tui/src/insight/report.rs new file mode 100644 index 000000000..8dadcda06 --- /dev/null +++ b/codex-rs/tui/src/insight/report.rs @@ -0,0 +1,539 @@ +use std::path::Path; + +use anyhow::Context; +use chrono::DateTime; +use chrono::Utc; +use pathdiff::diff_paths; + +use super::aggregator::format_duration; +use super::aggregator::format_number; +use super::types::InsightReportData; +use super::types::NarrativeMode; +use super::types::RootSessionSummary; + +pub(crate) async fn write_report_html(data: &InsightReportData) -> anyhow::Result<()> { + let parent_dir = data + .report_path + .parent() + .context("report path has no parent directory")?; + tokio::fs::create_dir_all(parent_dir) + .await + .with_context(|| format!("failed to create {}", parent_dir.display()))?; + let html = render_html(data); + tokio::fs::write(&data.report_path, html) + .await + .with_context(|| format!("failed to write {}", data.report_path.display()))?; + Ok(()) +} + +pub(crate) fn render_html(data: &InsightReportData) -> String { + let generated_at = format_datetime(data.generated_at); + let earliest = data + .overview + .earliest_event_at + .map(format_datetime) + .unwrap_or_else(|| "N/A".to_string()); + let latest = data + .overview + .latest_event_at + .map(format_datetime) + .unwrap_or_else(|| "N/A".to_string()); + let narrative_mode = match data.narrative_mode { + NarrativeMode::LocalHeuristics => "Local heuristics fallback / 本地启发式回退", + }; + + let top_roots_rows = data + .roots + .iter() + .take(10) + .map(|root| { + format!( + "{title}{threads}{tokens}{wall}{failures}", + id = escape_html(root.root_thread_id.to_string().as_str()), + title = escape_html(root.title.as_str()), + threads = root.threads.len(), + tokens = format_number(root.metrics.total_tokens), + wall = format_duration(root.wall_clock_span), + failures = root.metrics.total_failures(), + ) + }) + .collect::>() + .join(""); + + let root_sections = data + .roots + .iter() + .map(|root| render_root_section(root, data.codex_home.as_path())) + .collect::>() + .join(""); + + let patterns = render_list(data.common_patterns.iter().map(std::string::String::as_str)); + let suggestions = render_list( + data.efficiency_suggestions + .iter() + .map(std::string::String::as_str), + ); + + format!( + r##" + + + + + Codex Insight Report + + + +
+
+ /insight + {narrative_mode} +

Codex Insight Report

+

Dashboard first, drill-down later. Mixed CN/EN report. Generated at {generated_at}.

+

History window: {earliest} → {latest}

+ +
+
Root Sessions{root_sessions}
+
Threads{threads}
+
Tokens{tokens}
+
Exact Tool Runtime{tool_runtime}
+
Failure Rate{failure_rate:.1}%
+
History Span{history_span}
+
+
+ +
+

Executive Summary / 概览

+

Total tokens: {tokens}. Counted operations: {counted_operations}. Failures: {failures}.

+

Scanned {scanned_files} rollout file(s); skipped {skipped_files}. Archived thread(s): {archived_threads}.

+
+ +
+

Top Sessions / 高消耗会话

+ + + + + {top_roots_rows} +
Root SessionThreadsTokensWall SpanFailures
+
+ +
+

Token Analysis / Token 分析

+
+ Total: {tokens} + Input: {input_tokens} + Output: {output_tokens} + Reasoning Output: {reasoning_output_tokens} +
+
+ +
+

Time Analysis / 时间分析

+
+ History Span: {history_span} + Cumulative Thread Span: {cumulative_thread_span} + Exact Tool Runtime: {tool_runtime} + Estimated User Wait: {estimated_user_wait} + Residual Model/UI Time: {residual_runtime} +
+

Residual time is a conservative estimate after subtracting exact persisted tool durations and estimated user idle gaps.

+
+ +
+

Failure Analysis / 失败分析

+
+ Exec failures: {exec_failures} + MCP failures: {mcp_failures} + Dynamic tool failures: {dynamic_failures} + Patch failures: {patch_failures} + API errors: {api_errors} +
+
+ +
+

Tool / Patch Analysis / 工具与补丁

+
+ Exec commands: {exec_count} + Tool calls: {tool_calls} + Patches: {patch_count} + Patched files: {patched_files} + User messages: {user_messages} + Completed turns: {completed_turns} +
+
+ +
+

Common Patterns / 共性模式

+ {patterns} +
+ +
+

Efficiency Suggestions / 效率建议

+ {suggestions} +
+ +
+

Root Session Drill-down / 根会话下钻

+ {root_sections} +
+ +
+

Methodology / 指标说明

+
    +
  • Exact: exec command, MCP tool, and dynamic tool durations come from persisted rollout events.
  • +
  • Exact: wall-clock spans come from persisted rollout timestamps.
  • +
  • Estimated: user wait is measured as the gap from a completed turn to the next user message when that gap is observable in history.
  • +
  • Estimated: residual model/UI time is the remaining thread span after subtracting exact tool runtime and estimated user wait.
  • +
  • Failure rate: failures / counted operations, where counted operations are exec + MCP + dynamic tools + patch applications + API error incidents.
  • +
  • Narrative layer: this report used {narrative_mode}, so pattern and suggestion sections are deterministic local heuristics instead of a model-generated write-up.
  • +
+
+
+ +"##, + narrative_mode = escape_html(narrative_mode), + generated_at = escape_html(generated_at.as_str()), + root_sessions = data.overview.total_root_sessions, + threads = data.overview.total_threads, + tokens = format_number(data.overview.metrics.total_tokens), + tool_runtime = format_duration(data.overview.metrics.exact_tool_runtime()), + failure_rate = data.overview.metrics.failure_rate() * 100.0, + history_span = format_duration(data.overview.history_span), + counted_operations = data.overview.metrics.counted_operations(), + failures = data.overview.metrics.total_failures(), + scanned_files = data.overview.scanned_files, + skipped_files = data.overview.skipped_files, + archived_threads = data.overview.archived_threads, + input_tokens = format_number(data.overview.metrics.input_tokens), + output_tokens = format_number(data.overview.metrics.output_tokens), + reasoning_output_tokens = format_number(data.overview.metrics.reasoning_output_tokens), + cumulative_thread_span = format_duration(data.overview.metrics.cumulative_thread_span), + estimated_user_wait = format_duration(data.overview.metrics.estimated_user_wait), + residual_runtime = format_duration(data.overview.metrics.residual_runtime_estimate), + exec_failures = data.overview.metrics.exec_commands.failures, + mcp_failures = data.overview.metrics.mcp_tool_calls.failures, + dynamic_failures = data.overview.metrics.dynamic_tool_calls.failures, + patch_failures = data.overview.metrics.patches.failures, + api_errors = data.overview.metrics.api_error_count, + exec_count = data.overview.metrics.exec_commands.count, + tool_calls = data.overview.metrics.tool_call_count(), + patch_count = data.overview.metrics.patches.count, + patched_files = data.overview.metrics.patches.changed_files, + user_messages = data.overview.metrics.user_message_count, + completed_turns = data.overview.metrics.completed_turn_count, + top_roots_rows = top_roots_rows, + patterns = patterns, + suggestions = suggestions, + root_sections = root_sections, + ) +} + +fn render_root_section(root: &RootSessionSummary, codex_home: &Path) -> String { + let relative_rollout = + diff_paths(&root.rollout_path, codex_home).unwrap_or_else(|| root.rollout_path.clone()); + let cwd = if root.cwd.as_os_str().is_empty() { + "N/A".to_string() + } else { + escape_html(root.cwd.display().to_string().as_str()) + }; + let started_at = root + .earliest_event_at + .map(format_datetime) + .unwrap_or_else(|| "N/A".to_string()); + let latest_at = root + .latest_event_at + .map(format_datetime) + .unwrap_or_else(|| "N/A".to_string()); + let rows = root + .threads + .iter() + .map(|thread| { + let thread_rollout = diff_paths(&thread.rollout_path, codex_home) + .unwrap_or_else(|| thread.rollout_path.clone()); + format!( + "{title}{source}{tokens}{wall}{tools}{patches}{rollout}", + title = escape_html(thread.title.as_str()), + source = escape_html(thread.source_label.as_str()), + tokens = format_number(thread.metrics.total_tokens), + wall = format_duration(thread.wall_clock_span()), + tools = thread.metrics.tool_call_count(), + patches = thread.metrics.patches.count, + rollout = escape_html(thread_rollout.display().to_string().as_str()), + ) + }) + .collect::>() + .join(""); + + format!( + r#"
+

{title}

+

Root thread: {id} · Rollout: {rollout} · CWD: {cwd}

+
+ Threads: {thread_count} + Tokens: {tokens} + Wall Span: {wall_span} + Exact Tool Runtime: {tool_runtime} + Failure Rate: {failure_rate:.1}% +
+

Timeline: {started_at} → {latest_at}

+ + + + + {rows} +
ThreadSourceTokensWall SpanTool CallsPatchesRollout
+
"#, + id = escape_html(root.root_thread_id.to_string().as_str()), + title = escape_html(root.title.as_str()), + rollout = escape_html(relative_rollout.display().to_string().as_str()), + cwd = cwd, + thread_count = root.threads.len(), + tokens = format_number(root.metrics.total_tokens), + wall_span = format_duration(root.wall_clock_span), + tool_runtime = format_duration(root.metrics.exact_tool_runtime()), + failure_rate = root.metrics.failure_rate() * 100.0, + started_at = escape_html(started_at.as_str()), + latest_at = escape_html(latest_at.as_str()), + rows = rows, + ) +} + +fn render_list<'a>(items: impl Iterator) -> String { + let items = items + .map(|item| format!("
  • {}
  • ", escape_html(item))) + .collect::>() + .join(""); + format!("
      {items}
    ") +} + +fn format_datetime(datetime: DateTime) -> String { + datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string() +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::time::Duration; + + use chrono::TimeZone; + use chrono::Utc; + use insta::assert_snapshot; + + use super::render_html; + use crate::insight::types::AggregateMetrics; + use crate::insight::types::InsightOverview; + use crate::insight::types::InsightReportData; + use crate::insight::types::NarrativeMode; + use crate::insight::types::RootSessionSummary; + use codex_protocol::ThreadId; + + fn thread_id(value: &str) -> ThreadId { + ThreadId::from_string(value).expect("valid thread id") + } + + #[test] + fn report_html_snapshot() { + let generated_at = Utc.with_ymd_and_hms(2026, 4, 8, 12, 30, 0).unwrap(); + let metrics = AggregateMetrics { + total_tokens: 12345, + cumulative_thread_span: Duration::from_secs(600), + estimated_user_wait: Duration::from_secs(120), + residual_runtime_estimate: Duration::from_secs(180), + ..AggregateMetrics::default() + }; + let root = RootSessionSummary { + root_thread_id: thread_id("00000000-0000-0000-0000-000000000001"), + title: "Investigate /insight".to_string(), + cwd: PathBuf::from("/repo"), + rollout_path: PathBuf::from("/tmp/codex-home/sessions/root.jsonl"), + archived: false, + earliest_event_at: Some(generated_at), + latest_event_at: Some(generated_at + chrono::Duration::seconds(600)), + wall_clock_span: Duration::from_secs(600), + metrics: metrics.clone(), + threads: Vec::new(), + }; + let html = render_html(&InsightReportData { + generated_at, + codex_home: PathBuf::from("/tmp/codex-home"), + report_path: PathBuf::from("/tmp/codex-home/reports/insight-20260408-123000.html"), + overview: InsightOverview { + total_root_sessions: 1, + total_threads: 1, + archived_threads: 0, + scanned_files: 1, + skipped_files: 0, + metrics, + earliest_event_at: Some(generated_at), + latest_event_at: Some(generated_at + chrono::Duration::seconds(600)), + history_span: Duration::from_secs(600), + }, + roots: vec![root], + common_patterns: vec!["Pattern A".to_string()], + efficiency_suggestions: vec!["Suggestion A".to_string()], + narrative_mode: NarrativeMode::LocalHeuristics, + }); + assert_snapshot!("insight_report_html", html); + } +} diff --git a/codex-rs/tui/src/insight/snapshots/codex_tui__insight__report__tests__insight_report_html.snap b/codex-rs/tui/src/insight/snapshots/codex_tui__insight__report__tests__insight_report_html.snap new file mode 100644 index 000000000..520394aa6 --- /dev/null +++ b/codex-rs/tui/src/insight/snapshots/codex_tui__insight__report__tests__insight_report_html.snap @@ -0,0 +1,289 @@ +--- +source: tui/src/insight/report.rs +expression: html +--- + + + + + + Codex Insight Report + + + +
    +
    + /insight + Local heuristics fallback / 本地启发式回退 +

    Codex Insight Report

    +

    Dashboard first, drill-down later. Mixed CN/EN report. Generated at 2026-04-08 12:30:00 UTC.

    +

    History window: 2026-04-08 12:30:00 UTC → 2026-04-08 12:40:00 UTC

    + +
    +
    Root Sessions1
    +
    Threads1
    +
    Tokens12,345
    +
    Exact Tool Runtime0s
    +
    Failure Rate0.0%
    +
    History Span10m 0s
    +
    +
    + +
    +

    Executive Summary / 概览

    +

    Total tokens: 12,345. Counted operations: 0. Failures: 0.

    +

    Scanned 1 rollout file(s); skipped 0. Archived thread(s): 0.

    +
    + +
    +

    Top Sessions / 高消耗会话

    + + + + + +
    Root SessionThreadsTokensWall SpanFailures
    Investigate /insight012,34510m 0s0
    +
    + +
    +

    Token Analysis / Token 分析

    +
    + Total: 12,345 + Input: 0 + Output: 0 + Reasoning Output: 0 +
    +
    + +
    +

    Time Analysis / 时间分析

    +
    + History Span: 10m 0s + Cumulative Thread Span: 10m 0s + Exact Tool Runtime: 0s + Estimated User Wait: 2m 0s + Residual Model/UI Time: 3m 0s +
    +

    Residual time is a conservative estimate after subtracting exact persisted tool durations and estimated user idle gaps.

    +
    + +
    +

    Failure Analysis / 失败分析

    +
    + Exec failures: 0 + MCP failures: 0 + Dynamic tool failures: 0 + Patch failures: 0 + API errors: 0 +
    +
    + +
    +

    Tool / Patch Analysis / 工具与补丁

    +
    + Exec commands: 0 + Tool calls: 0 + Patches: 0 + Patched files: 0 + User messages: 0 + Completed turns: 0 +
    +
    + +
    +

    Common Patterns / 共性模式

    +
    • Pattern A
    +
    + +
    +

    Efficiency Suggestions / 效率建议

    +
    • Suggestion A
    +
    + +
    +

    Root Session Drill-down / 根会话下钻

    +
    +

    Investigate /insight

    +

    Root thread: 00000000-0000-0000-0000-000000000001 · Rollout: sessions/root.jsonl · CWD: /repo

    +
    + Threads: 0 + Tokens: 12,345 + Wall Span: 10m 0s + Exact Tool Runtime: 0s + Failure Rate: 0.0% +
    +

    Timeline: 2026-04-08 12:30:00 UTC → 2026-04-08 12:40:00 UTC

    + + + + + +
    ThreadSourceTokensWall SpanTool CallsPatchesRollout
    +
    +
    + +
    +

    Methodology / 指标说明

    +
      +
    • Exact: exec command, MCP tool, and dynamic tool durations come from persisted rollout events.
    • +
    • Exact: wall-clock spans come from persisted rollout timestamps.
    • +
    • Estimated: user wait is measured as the gap from a completed turn to the next user message when that gap is observable in history.
    • +
    • Estimated: residual model/UI time is the remaining thread span after subtracting exact tool runtime and estimated user wait.
    • +
    • Failure rate: failures / counted operations, where counted operations are exec + MCP + dynamic tools + patch applications + API error incidents.
    • +
    • Narrative layer: this report used Local heuristics fallback / 本地启发式回退, so pattern and suggestion sections are deterministic local heuristics instead of a model-generated write-up.
    • +
    +
    +
    + + diff --git a/codex-rs/tui/src/insight/types.rs b/codex-rs/tui/src/insight/types.rs new file mode 100644 index 000000000..d16cc95c0 --- /dev/null +++ b/codex-rs/tui/src/insight/types.rs @@ -0,0 +1,257 @@ +use std::path::PathBuf; +use std::time::Duration; + +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::ThreadId; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct OperationStats { + pub(crate) count: u32, + pub(crate) failures: u32, + pub(crate) duration: Duration, +} + +impl OperationStats { + pub(crate) fn add_sample(&mut self, failed: bool, duration: Duration) { + self.count = self.count.saturating_add(1); + if failed { + self.failures = self.failures.saturating_add(1); + } + self.duration = self.duration.saturating_add(duration); + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct PatchStats { + pub(crate) count: u32, + pub(crate) failures: u32, + pub(crate) changed_files: u32, +} + +impl PatchStats { + pub(crate) fn add_sample(&mut self, failed: bool, changed_files: usize) { + self.count = self.count.saturating_add(1); + if failed { + self.failures = self.failures.saturating_add(1); + } + self.changed_files = self + .changed_files + .saturating_add(u32::try_from(changed_files).unwrap_or(u32::MAX)); + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct AggregateMetrics { + pub(crate) total_tokens: i64, + pub(crate) input_tokens: i64, + pub(crate) output_tokens: i64, + pub(crate) reasoning_output_tokens: i64, + pub(crate) exec_commands: OperationStats, + pub(crate) mcp_tool_calls: OperationStats, + pub(crate) dynamic_tool_calls: OperationStats, + pub(crate) web_search_count: u32, + pub(crate) image_generation_count: u32, + pub(crate) view_image_count: u32, + pub(crate) patches: PatchStats, + pub(crate) api_error_count: u32, + pub(crate) parse_errors: usize, + pub(crate) user_message_count: u32, + pub(crate) completed_turn_count: u32, + pub(crate) estimated_user_wait: Duration, + pub(crate) cumulative_thread_span: Duration, + pub(crate) residual_runtime_estimate: Duration, +} + +impl AggregateMetrics { + pub(crate) fn add_assign(&mut self, other: &Self) { + self.total_tokens += other.total_tokens; + self.input_tokens += other.input_tokens; + self.output_tokens += other.output_tokens; + self.reasoning_output_tokens += other.reasoning_output_tokens; + self.exec_commands.count = self + .exec_commands + .count + .saturating_add(other.exec_commands.count); + self.exec_commands.failures = self + .exec_commands + .failures + .saturating_add(other.exec_commands.failures); + self.exec_commands.duration = self + .exec_commands + .duration + .saturating_add(other.exec_commands.duration); + self.mcp_tool_calls.count = self + .mcp_tool_calls + .count + .saturating_add(other.mcp_tool_calls.count); + self.mcp_tool_calls.failures = self + .mcp_tool_calls + .failures + .saturating_add(other.mcp_tool_calls.failures); + self.mcp_tool_calls.duration = self + .mcp_tool_calls + .duration + .saturating_add(other.mcp_tool_calls.duration); + self.dynamic_tool_calls.count = self + .dynamic_tool_calls + .count + .saturating_add(other.dynamic_tool_calls.count); + self.dynamic_tool_calls.failures = self + .dynamic_tool_calls + .failures + .saturating_add(other.dynamic_tool_calls.failures); + self.dynamic_tool_calls.duration = self + .dynamic_tool_calls + .duration + .saturating_add(other.dynamic_tool_calls.duration); + self.web_search_count = self.web_search_count.saturating_add(other.web_search_count); + self.image_generation_count = self + .image_generation_count + .saturating_add(other.image_generation_count); + self.view_image_count = self.view_image_count.saturating_add(other.view_image_count); + self.patches.count = self.patches.count.saturating_add(other.patches.count); + self.patches.failures = self.patches.failures.saturating_add(other.patches.failures); + self.patches.changed_files = self + .patches + .changed_files + .saturating_add(other.patches.changed_files); + self.api_error_count = self.api_error_count.saturating_add(other.api_error_count); + self.parse_errors = self.parse_errors.saturating_add(other.parse_errors); + self.user_message_count = self + .user_message_count + .saturating_add(other.user_message_count); + self.completed_turn_count = self + .completed_turn_count + .saturating_add(other.completed_turn_count); + self.estimated_user_wait = self + .estimated_user_wait + .saturating_add(other.estimated_user_wait); + self.cumulative_thread_span = self + .cumulative_thread_span + .saturating_add(other.cumulative_thread_span); + self.residual_runtime_estimate = self + .residual_runtime_estimate + .saturating_add(other.residual_runtime_estimate); + } + + pub(crate) fn tool_call_count(&self) -> u32 { + self.mcp_tool_calls + .count + .saturating_add(self.dynamic_tool_calls.count) + .saturating_add(self.web_search_count) + .saturating_add(self.image_generation_count) + .saturating_add(self.view_image_count) + } + + pub(crate) fn exact_tool_runtime(&self) -> Duration { + self.exec_commands + .duration + .saturating_add(self.mcp_tool_calls.duration) + .saturating_add(self.dynamic_tool_calls.duration) + } + + pub(crate) fn total_failures(&self) -> u32 { + self.exec_commands + .failures + .saturating_add(self.mcp_tool_calls.failures) + .saturating_add(self.dynamic_tool_calls.failures) + .saturating_add(self.patches.failures) + .saturating_add(self.api_error_count) + } + + pub(crate) fn counted_operations(&self) -> u32 { + self.exec_commands + .count + .saturating_add(self.mcp_tool_calls.count) + .saturating_add(self.dynamic_tool_calls.count) + .saturating_add(self.patches.count) + .saturating_add(self.api_error_count) + } + + pub(crate) fn failure_rate(&self) -> f64 { + let total = self.counted_operations(); + if total == 0 { + 0.0 + } else { + self.total_failures() as f64 / total as f64 + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CollectedThread { + pub(crate) thread_id: ThreadId, + pub(crate) parent_thread_id: Option, + pub(crate) depth: Option, + pub(crate) title: String, + pub(crate) cwd: PathBuf, + pub(crate) rollout_path: PathBuf, + pub(crate) archived: bool, + pub(crate) source_label: String, + pub(crate) agent_nickname: Option, + pub(crate) agent_role: Option, + pub(crate) agent_path: Option, + pub(crate) created_at: DateTime, + pub(crate) updated_at: DateTime, + pub(crate) first_event_at: Option>, + pub(crate) last_event_at: Option>, + pub(crate) metrics: AggregateMetrics, +} + +impl CollectedThread { + pub(crate) fn wall_clock_span(&self) -> Duration { + self.metrics.cumulative_thread_span + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct CollectionResult { + pub(crate) threads: Vec, + pub(crate) scanned_files: usize, + pub(crate) skipped_files: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RootSessionSummary { + pub(crate) root_thread_id: ThreadId, + pub(crate) title: String, + pub(crate) cwd: PathBuf, + pub(crate) rollout_path: PathBuf, + pub(crate) archived: bool, + pub(crate) earliest_event_at: Option>, + pub(crate) latest_event_at: Option>, + pub(crate) wall_clock_span: Duration, + pub(crate) metrics: AggregateMetrics, + pub(crate) threads: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InsightOverview { + pub(crate) total_root_sessions: usize, + pub(crate) total_threads: usize, + pub(crate) archived_threads: usize, + pub(crate) scanned_files: usize, + pub(crate) skipped_files: usize, + pub(crate) metrics: AggregateMetrics, + pub(crate) earliest_event_at: Option>, + pub(crate) latest_event_at: Option>, + pub(crate) history_span: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum NarrativeMode { + LocalHeuristics, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InsightReportData { + pub(crate) generated_at: DateTime, + pub(crate) codex_home: PathBuf, + pub(crate) report_path: PathBuf, + pub(crate) overview: InsightOverview, + pub(crate) roots: Vec, + pub(crate) common_patterns: Vec, + pub(crate) efficiency_suggestions: Vec, + pub(crate) narrative_mode: NarrativeMode, +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ba99938a0..2f3586483 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -127,6 +127,7 @@ mod get_git_diff; mod history_cell; pub(crate) mod insert_history; pub use insert_history::insert_history_lines; +mod insight; mod key_hint; mod line_truncation; pub(crate) mod live_wrap; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 10d0ea124..406f125e9 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -40,6 +40,7 @@ pub enum SlashCommand { Diff, Mention, Status, + Insight, DebugConfig, Title, Statusline, @@ -94,6 +95,7 @@ impl SlashCommand { SlashCommand::Mention => "mention a file", SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Insight => "analyze local sessions and generate an HTML insight report", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", @@ -181,6 +183,7 @@ impl SlashCommand { | SlashCommand::Mention | SlashCommand::Skills | SlashCommand::Status + | SlashCommand::Insight | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop diff --git a/docs/slash_commands.md b/docs/slash_commands.md index c5f922257..fbd2fc024 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -3,3 +3,29 @@ 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). + +## `/insight` + +`/insight` scans local Codex session rollouts and writes an offline HTML analysis report to `~/.codex/reports/`. + +What it includes: + +- active + archived local sessions +- main threads + sub-agent threads +- roll-up from child threads into the parent/root session, with per-thread drill-down kept in the report +- token usage, wall-clock span, exec/tool/patch counts, and failure counts +- exact metrics where the rollout history persists them directly +- estimated timing breakdowns where historical rollout data only supports approximation + +Report behavior: + +- output is a single self-contained HTML file +- report is readable offline with no external assets +- dashboard sections come first, followed by deeper per-session drill-down +- common patterns and efficiency suggestions fall back to local heuristics when an AI summary layer is unavailable + +Notes on timing precision: + +- exec command, MCP tool, and dynamic tool durations are exact when present in the rollout +- wall-clock session spans are derived from persisted rollout timestamps +- model time and user-wait time are reported as estimates for historical sessions, because older rollout data does not persist a complete end-to-end timing decomposition for every turn From 91dc95f4bb58910584848f1cd76279598b5e0fe1 Mon Sep 17 00:00:00 2001 From: piping Date: Thu, 9 Apr 2026 10:23:45 +0800 Subject: [PATCH 64/83] update workflow with timeout/retry keys --- README.md | 4 +- README.zh-CN.md | 4 +- codex-rs/Cargo.lock | 7 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 58 ++++++- codex-rs/tui/src/app/workflow_definition.rs | 93 +++++++++- codex-rs/tui/src/app/workflow_editor.rs | 4 + codex-rs/tui/src/app/workflow_history.rs | 2 +- codex-rs/tui/src/app/workflow_runtime.rs | 182 ++++++++++++++++++-- codex-rs/tui/src/app_event.rs | 6 + docs/workflows.md | 4 + 11 files changed, 340 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 5f876db9c..318f0a0eb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > The Codex distribution built for real 24/7 use. -[中文版本](./README.zh-CN.md) · [Website](https://codex-enhanced.com) · [Workflows](./docs/workflows.md) · [Structured Input UI](./docs/tui-request-user-input.md) +[中文版本](./README.zh-CN.md) · [Website](https://codex-enhanced.com) · [Workflows](./docs/workflows.md) · [Slash Commands](./docs/slash_commands.md) · [Structured Input UI](./docs/tui-request-user-input.md)
    @@ -33,6 +33,7 @@ That is the core idea: make Codex feel less like a terminal chatbot and more lik | --- | --- | --- | | Multi-profile routing | `/profile` | Switch named profiles at runtime, manage fallback routes, and recover from rate limits or auth failures without rewriting local environment state. | | Workflow orchestration | `/workflow` | Manage `.codex/workflows/*.yaml`, run jobs manually, and attach triggers such as `before_turn`, `after_turn`, `interval`, `cron`, and `file_watch`. | +| Session insight report | `/insight` | Scan local Codex sessions and generate an offline HTML report under `~/.codex/reports/` for rollout analysis and drill-down. | | Session continuity | `/resume` | Reconnect to saved work instead of reconstructing long-running context from scratch. | | External message bridge | `/clawbot` | Bind workspace-local Feishu sessions to Codex threads, capture unread messages, and forward final replies back out. | | UI and alignment control | `/settings`, `question`, keyboard chords | Reduce noise, collect structured answers in the TUI, and keep operator interactions explicit. | @@ -158,6 +159,7 @@ If you want to inspect or extend the project, start here: - [`codex-rs/`](./codex-rs) contains the Rust workspace, including the CLI, TUI, workflow support, app-server pieces, and clawbot integration - [`sdk/python-runtime-enhanced/`](./sdk/python-runtime-enhanced) contains the Python wheel packaging for `codex-enhanced` - [`docs/workflows.md`](./docs/workflows.md) explains workflow files, triggers, and job management +- [`docs/slash_commands.md`](./docs/slash_commands.md) documents TUI slash commands including `/insight` - [`docs/tui-request-user-input.md`](./docs/tui-request-user-input.md) explains the structured input overlay used for `question` ## Capability Boundaries diff --git a/README.zh-CN.md b/README.zh-CN.md index 305ba354a..4d5cc61be 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ > 真正 24/7 使用的 Codex 发行版。 -[English](./README.md) · [Website](https://codex-enhanced.com) · [工作流文档](./docs/workflows.md) · [结构化输入 UI](./docs/tui-request-user-input.md) +[English](./README.md) · [Website](https://codex-enhanced.com) · [工作流文档](./docs/workflows.md) · [Slash Command 文档](./docs/slash_commands.md) · [结构化输入 UI](./docs/tui-request-user-input.md) @@ -33,6 +33,7 @@ | --- | --- | --- | | 多 profile 路由 | `/profile` | 在运行时切换命名 profile、管理 fallback route,并在限流或鉴权失败时继续运转,而不是改完配置再重启。 | | 工作流编排 | `/workflow` | 直接管理 `.codex/workflows/*.yaml`,手动运行 job,或者挂接 `before_turn`、`after_turn`、`interval`、`cron`、`file_watch` 等触发器。 | +| 会话洞察报告 | `/insight` | 扫描本地 Codex session,并在 `~/.codex/reports/` 下生成离线 HTML 分析报告,便于回看 rollout 和逐层钻取。 | | 会话连续性 | `/resume` | 把保存过的工作续上,而不是每次从零重建长上下文。 | | 外部消息桥接 | `/clawbot` | 把 workspace-local 的飞书会话绑定到 Codex thread,接收未读消息并把最终回复发回外部。 | | UI 与对齐控制 | `/settings`、`question`、键盘 chord | 降低界面噪音,在 TUI 中收集结构化答案,并让人工参与点更明确。 | @@ -158,6 +159,7 @@ jobs: - [`codex-rs/`](./codex-rs) 是 Rust 工作区,包含 CLI、TUI、workflow、app-server 和 clawbot 集成 - [`sdk/python-runtime-enhanced/`](./sdk/python-runtime-enhanced) 是 `codex-enhanced` 的 Python wheel 打包目录 - [`docs/workflows.md`](./docs/workflows.md) 说明 workflow 文件、trigger 和 job 管理方式 +- [`docs/slash_commands.md`](./docs/slash_commands.md) 说明 TUI slash commands,包括 `/insight` - [`docs/tui-request-user-input.md`](./docs/tui-request-user-input.md) 说明 `question` 使用的结构化输入浮层 ## 能力边界 diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 270863f9a..fdaa76bf5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2987,6 +2987,7 @@ dependencies = [ "diffy", "dirs", "dunce", + "humantime", "image", "indexmap 2.13.0", "insta", @@ -5427,6 +5428,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "1.8.1" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index ee9709bd6..0e6d8bc15 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -67,6 +67,7 @@ diffy = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] } +humantime = "2" indexmap = { workspace = true, features = ["serde"] } itertools = { workspace = true } lazy_static = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 02b8071b6..a21bd66b0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5326,6 +5326,10 @@ impl App { self.submit_thread_op(tui, app_server, thread_id, op.into()) .await?; } + AppEvent::SubmitWorkflowFollowup { thread_id, op } => { + self.submit_thread_op(tui, app_server, thread_id, op.into()) + .await?; + } AppEvent::ThreadHistoryEntryResponse { thread_id, event } => { self.enqueue_thread_history_entry_response(thread_id, event) .await?; @@ -13039,7 +13043,7 @@ model = "gpt-5.2" .try_recv() .expect("expected workflow follow-up submission") { - AppEvent::SubmitThreadOp { + AppEvent::SubmitWorkflowFollowup { thread_id: submit_thread_id, op: Op::UserTurn { items, .. }, } => { @@ -13118,7 +13122,7 @@ model = "gpt-5.2" .try_recv() .expect("expected inactive primary workflow follow-up submission") { - AppEvent::SubmitThreadOp { + AppEvent::SubmitWorkflowFollowup { thread_id: submit_thread_id, op: Op::UserTurn { items, .. }, } => { @@ -13385,7 +13389,7 @@ model = "gpt-5.2" .try_recv() .expect("expected queued workflow follow-up") { - AppEvent::SubmitThreadOp { + AppEvent::SubmitWorkflowFollowup { thread_id: submit_thread_id, op: Op::UserTurn { items, .. }, } => { @@ -13402,6 +13406,54 @@ model = "gpt-5.2" } } + #[tokio::test] + async fn workflow_followup_completion_retriggers_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?; + app.active_thread_id = Some(ThreadId::new()); + 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-followup", + TurnStatus::Completed, + "workflow-originated follow-up reply", + )), + ) + .await; + + assert_eq!( + app.background_workflow_labels(), + vec!["director · followup".to_string()] + ); + while let Ok(event) = app_event_rx.try_recv() { + match event { + AppEvent::ClawbotTurnCompleted { .. } + | AppEvent::InsertHistoryCell(_) + | AppEvent::ReplayWorkflowHistory { .. } => {} + other => panic!("unexpected event after workflow follow-up completion: {other:?}"), + } + } + + Ok(()) + } + #[tokio::test] async fn app_server_notifications_forward_to_workflow_thread_receivers() -> Result<()> { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/workflow_definition.rs b/codex-rs/tui/src/app/workflow_definition.rs index 886639db8..8485c8517 100644 --- a/codex-rs/tui/src/app/workflow_definition.rs +++ b/codex-rs/tui/src/app/workflow_definition.rs @@ -7,6 +7,7 @@ use std::collections::VecDeque; use std::fs; use std::path::Path; use std::path::PathBuf; +use std::time::Duration; const WORKFLOW_DIR_NAME: &str = "workflows"; @@ -49,8 +50,16 @@ pub(crate) enum WorkflowResponseMode { #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(untagged)] pub(crate) enum WorkflowStep { - Run { run: String, retry: Option }, - Prompt { prompt: String, retry: Option }, + Run { + run: String, + retry: Option, + timeout: Option, + }, + Prompt { + prompt: String, + retry: Option, + timeout: Option, + }, } impl WorkflowStep { @@ -59,6 +68,23 @@ impl WorkflowStep { Self::Run { retry, .. } | Self::Prompt { retry, .. } => retry.unwrap_or(1), } } + + pub(crate) fn timeout(&self, default_timeout: Duration) -> Result { + let timeout = match self { + Self::Run { timeout, .. } | Self::Prompt { timeout, .. } => timeout, + }; + timeout.as_ref().map_or(Ok(default_timeout), |timeout| { + humantime::parse_duration(timeout) + .map_err(|err| format!("invalid step timeout `{timeout}`: {err}")) + }) + } + + pub(crate) fn kind(&self) -> &'static str { + match self { + Self::Run { .. } => "run", + Self::Prompt { .. } => "prompt", + } + } } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -216,6 +242,15 @@ pub(crate) fn load_workflow_registry( path.display() ))); } + for step in &job.steps { + if let Err(err) = step.timeout(Duration::from_secs(30)) { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` job `{job_name}` in `{}` has invalid {} step timeout: {err}", + path.display(), + step.kind() + ))); + } + } if matches!(job.context, WorkflowContextMode::Embed) && job .steps @@ -381,3 +416,57 @@ fn pop_next_job(ready: &mut VecDeque, registry: &LoadedWorkflowRegistry) .map(|(index, _)| index)?; ready.remove(best_index) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn workflow_step_timeout_uses_default_when_unset() { + let step = WorkflowStep::Prompt { + prompt: "summarize".to_string(), + retry: None, + timeout: None, + }; + + assert_eq!( + step.timeout(Duration::from_secs(30)).unwrap(), + Duration::from_secs(30) + ); + } + + #[test] + fn load_workflow_registry_rejects_invalid_step_timeout() { + let dir = tempdir().unwrap(); + let workflows_dir = dir.path().join(".codex/workflows"); + fs::create_dir_all(&workflows_dir).unwrap(); + let workflow_path = workflows_dir.join("workflow.yaml"); + fs::write( + &workflow_path, + r#"name: director + +jobs: + notify: + steps: + - prompt: summarize the changes + timeout: not-a-duration +"#, + ) + .unwrap(); + + let error = load_workflow_registry(dir.path()).unwrap_err(); + let message = error.to_string(); + assert!( + message.contains(&format!( + "workflow `director` job `notify` in `{}` has invalid prompt step timeout", + workflow_path.display() + )), + "unexpected error message: {message}" + ); + assert!( + message.contains("invalid step timeout `not-a-duration`"), + "unexpected error message: {message}" + ); + } +} diff --git a/codex-rs/tui/src/app/workflow_editor.rs b/codex-rs/tui/src/app/workflow_editor.rs index 21585964e..c08216215 100644 --- a/codex-rs/tui/src/app/workflow_editor.rs +++ b/codex-rs/tui/src/app/workflow_editor.rs @@ -648,7 +648,9 @@ jobs: WorkflowJobEditableField::Steps, r#"- prompt: | summarize the changes + timeout: 5m - run: cargo test -p codex-tui + timeout: 2m "#, ) .unwrap(); @@ -660,7 +662,9 @@ jobs: let steps = job_field_seed(&path, "notify", WorkflowJobEditableField::Steps).unwrap(); assert!(steps.contains("prompt: |")); assert!(steps.contains("summarize the changes")); + assert!(steps.contains("timeout: 5m")); assert!(steps.contains("cargo test -p codex-tui")); + assert!(steps.contains("timeout: 2m")); } #[test] diff --git a/codex-rs/tui/src/app/workflow_history.rs b/codex-rs/tui/src/app/workflow_history.rs index 83ad1e96a..c89f825f6 100644 --- a/codex-rs/tui/src/app/workflow_history.rs +++ b/codex-rs/tui/src/app/workflow_history.rs @@ -248,7 +248,7 @@ impl App { ); return visible_cell; }; - self.app_event_tx.send(AppEvent::SubmitThreadOp { + self.app_event_tx.send(AppEvent::SubmitWorkflowFollowup { thread_id: primary_thread_id, op, }); diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index 6ce5b9fa7..83eee95a1 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -54,6 +54,7 @@ use uuid::Uuid; const WORKFLOW_POLL_INTERVAL: Duration = Duration::from_millis(50); const WORKFLOW_INTERRUPT_SETTLE_TIMEOUT: Duration = Duration::from_secs(1); +const WORKFLOW_STEP_TIMEOUT: Duration = Duration::from_secs(30); type BoxFuture<'a, T> = Pin + Send + 'a>>; pub(crate) type WorkflowThreadNotificationChannels = @@ -571,10 +572,7 @@ impl App { /*cancellation*/ None, ) .await - .map_err(|error| match error { - WorkflowRunError::Failed(message) => message, - WorkflowRunError::Cancelled => "workflow run cancelled".to_string(), - })?, + .map_err(workflow_run_error_message)?, ); } } @@ -941,7 +939,7 @@ async fn run_background_workflow( { Ok(results) => BackgroundWorkflowRunOutcome::Completed(results), Err(WorkflowRunError::Cancelled) => BackgroundWorkflowRunOutcome::Cancelled, - Err(WorkflowRunError::Failed(error)) => BackgroundWorkflowRunOutcome::Failed(error), + Err(error) => BackgroundWorkflowRunOutcome::Failed(workflow_run_error_message(error)), }; BackgroundWorkflowRunResult { target, outcome } } @@ -1117,8 +1115,16 @@ async fn run_workflow_job( let mut last_prompt_response = None; for step in &job.config.steps { let configured_attempts = step.retry_attempts(); + let step_timeout = step.timeout(WORKFLOW_STEP_TIMEOUT).map_err(|err| { + WorkflowRunError::Failed(format!( + "workflow `{workflow_name}` job `{}` has invalid {} step timeout: {err}", + job.name, + step.kind() + )) + })?; let mut attempt = 1; let mut used_capacity_retry = false; + let mut used_timeout_retry = false; let step_error = loop { if cancellation.is_some_and(CancellationToken::is_cancelled) { if let Some(thread) = thread.as_ref() { @@ -1133,8 +1139,15 @@ async fn run_workflow_job( phase_context, cancellation, }; - let result = - execute_workflow_step(client, &mut thread, context, step, &step_outputs).await; + let result = execute_workflow_step( + client, + &mut thread, + context, + step, + step_timeout, + &step_outputs, + ) + .await; match result { Ok(Some(output)) => { if matches!(step, WorkflowStep::Prompt { .. }) { @@ -1147,13 +1160,22 @@ async fn run_workflow_job( break None; } Err(error) => { + let should_retry_capacity = + !used_capacity_retry && should_retry_selected_model_capacity_error(&error); + let should_retry_timeout = + !used_timeout_retry && should_retry_workflow_timeout(&error); let should_retry = !matches!(error, WorkflowRunError::Cancelled) && (attempt < configured_attempts - || (!used_capacity_retry - && should_retry_selected_model_capacity_error(&error))); + || should_retry_capacity + || should_retry_timeout); if should_retry { if attempt >= configured_attempts { - used_capacity_retry = true; + if should_retry_capacity { + used_capacity_retry = true; + } + if should_retry_timeout { + used_timeout_retry = true; + } } sleep(retry_backoff_delay(attempt)).await; attempt = attempt.saturating_add(1); @@ -1193,11 +1215,18 @@ async fn execute_workflow_step( thread: &mut Option, context: WorkflowStepExecutionContext<'_>, step: &WorkflowStep, + step_timeout: Duration, step_outputs: &[String], ) -> Result, WorkflowRunError> { match step { WorkflowStep::Run { run, .. } => { - run_workflow_command(run, &context.job.workflow_path, context.cancellation).await + run_workflow_command( + run, + &context.job.workflow_path, + step_timeout, + context.cancellation, + ) + .await } WorkflowStep::Prompt { prompt, .. } => { let thread = match thread { @@ -1219,7 +1248,7 @@ async fn execute_workflow_step( context.phase_context, step_outputs, ); - run_workflow_prompt(client, &thread, prompt, context.cancellation).await + run_workflow_prompt(client, &thread, prompt, step_timeout, context.cancellation).await } } } @@ -1228,21 +1257,38 @@ async fn run_workflow_prompt( client: &dyn WorkflowRuntimeClient, thread: &WorkflowThreadSession, prompt: String, + step_timeout: Duration, cancellation: Option<&CancellationToken>, ) -> Result, WorkflowRunError> { let turn_id = client .start_turn(thread.thread_id.clone(), thread.cwd.clone(), prompt) .await .map_err(WorkflowRunError::Failed)?; + let deadline = tokio::time::Instant::now() + step_timeout; loop { if cancellation.is_some_and(CancellationToken::is_cancelled) { interrupt_active_workflow_turn(client, thread, turn_id.clone()).await; return Err(WorkflowRunError::Cancelled); } - let turn = client - .read_turn(thread, turn_id.clone()) + let Some(remaining) = deadline.checked_duration_since(tokio::time::Instant::now()) else { + interrupt_active_workflow_turn(client, thread, turn_id.clone()).await; + return Err(WorkflowRunError::TimedOut(format!( + "workflow prompt timed out after {}", + humantime::format_duration(step_timeout) + ))); + }; + let turn = match tokio::time::timeout(remaining, client.read_turn(thread, turn_id.clone())) .await - .map_err(WorkflowRunError::Failed)?; + { + Ok(turn) => turn.map_err(WorkflowRunError::Failed)?, + Err(_) => { + interrupt_active_workflow_turn(client, thread, turn_id.clone()).await; + return Err(WorkflowRunError::TimedOut(format!( + "workflow prompt timed out after {}", + humantime::format_duration(step_timeout) + ))); + } + }; match turn.status { TurnStatus::Completed => return Ok(turn.last_agent_message), TurnStatus::Interrupted => return Err(WorkflowRunError::Cancelled), @@ -1252,7 +1298,16 @@ async fn run_workflow_prompt( .unwrap_or_else(|| "workflow prompt turn failed".to_string()), )); } - TurnStatus::InProgress => sleep(WORKFLOW_POLL_INTERVAL).await, + TurnStatus::InProgress => { + if tokio::time::Instant::now() >= deadline { + interrupt_active_workflow_turn(client, thread, turn_id.clone()).await; + return Err(WorkflowRunError::TimedOut(format!( + "workflow prompt timed out after {}", + humantime::format_duration(step_timeout) + ))); + } + sleep(WORKFLOW_POLL_INTERVAL).await; + } } } } @@ -1277,6 +1332,7 @@ async fn interrupt_active_workflow_turn( async fn run_workflow_command( command: &str, workflow_path: &std::path::Path, + step_timeout: Duration, cancellation: Option<&CancellationToken>, ) -> Result, WorkflowRunError> { #[cfg(windows)] @@ -1316,7 +1372,13 @@ async fn run_workflow_command( std::future::pending::<()>().await; } } => return Err(WorkflowRunError::Cancelled), - output = &mut wait_with_output => output, + output = tokio::time::timeout(step_timeout, &mut wait_with_output) => output + .map_err(|_| { + WorkflowRunError::TimedOut(format!( + "workflow command `{command}` timed out after {}", + humantime::format_duration(step_timeout) + )) + })?, } .map_err(|err| { WorkflowRunError::Failed(format!("failed to run workflow command `{command}`: {err}")) @@ -1485,6 +1547,17 @@ fn should_retry_selected_model_capacity_error(error: &WorkflowRunError) -> bool ) } +fn should_retry_workflow_timeout(error: &WorkflowRunError) -> bool { + matches!(error, WorkflowRunError::TimedOut(_)) +} + +fn workflow_run_error_message(error: WorkflowRunError) -> String { + match error { + WorkflowRunError::Failed(message) | WorkflowRunError::TimedOut(message) => message, + WorkflowRunError::Cancelled => "workflow run cancelled".to_string(), + } +} + fn update_last_workflow_agent_message( last_agent_message: &mut Option, notification: &ItemCompletedNotification, @@ -1510,6 +1583,7 @@ fn last_agent_message_for_turn_items(items: &[ThreadItem]) -> Option { #[derive(Debug)] enum WorkflowRunError { Failed(String), + TimedOut(String), Cancelled, } @@ -1523,6 +1597,7 @@ mod tests { use std::sync::Mutex; use tempfile::TempDir; use tempfile::tempdir; + use tokio::time; async fn build_config(temp_dir: &TempDir) -> Config { ConfigBuilder::default() @@ -1753,6 +1828,79 @@ jobs: ); } + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn prompt_workflow_job_uses_configured_timeout_for_retry() { + 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 + timeout: 1s +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(Vec::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(), + }, + CancellationToken::new(), + ); + tokio::pin!(run); + + let configured_timeout = Duration::from_secs(1); + tokio::task::yield_now().await; + time::advance( + configured_timeout + + WORKFLOW_INTERRUPT_SETTLE_TIMEOUT + + retry_backoff_delay(1) + + configured_timeout + + WORKFLOW_INTERRUPT_SETTLE_TIMEOUT + + Duration::from_secs(1), + ) + .await; + + let result = run.await; + match result.outcome { + BackgroundWorkflowRunOutcome::Failed(error) => { + assert_eq!(error, "workflow prompt timed out after 1s".to_string()) + } + other => panic!("expected failed timeout run, got {other:?}"), + } + + let calls = client.calls.lock().expect("calls lock").clone(); + assert_eq!( + calls + .iter() + .filter(|call| call.starts_with("start_turn:")) + .count(), + 2 + ); + assert_eq!( + calls + .iter() + .filter(|call| call.starts_with("interrupt_turn:")) + .count(), + 2 + ); + assert_eq!( + calls + .iter() + .filter(|call| call.starts_with("unsubscribe_thread:")) + .count(), + 1 + ); + } + #[tokio::test] async fn non_manual_trigger_can_run_now_from_workflow_ui() { let tempdir = tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bf3641b6d..c13092b2c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -187,6 +187,12 @@ pub(crate) enum AppEvent { op: Op, }, + /// Submit a workflow-generated follow-up back to the primary thread. + SubmitWorkflowFollowup { + thread_id: ThreadId, + op: Op, + }, + /// Deliver a synthetic history lookup response to a specific thread channel. ThreadHistoryEntryResponse { thread_id: ThreadId, diff --git a/docs/workflows.md b/docs/workflows.md index 7411532b9..7cf15806b 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -70,6 +70,9 @@ Behavior notes: - `Run Now` is available for any enabled trigger type, not only `manual`. - If a trigger resolves only to disabled or otherwise unrunnable jobs, the run fails visibly instead of silently doing nothing. - `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. +- `response: user` follow-ups can recursively re-trigger `after_turn`. The chain naturally stops when the workflow returns an empty reply, because no follow-up turn is queued. +- Workflow steps default to a 30s timeout. Override this per step with `timeout`, for example `timeout: 5m`. +- Timeout failures participate in workflow step retry behavior, including one automatic timeout retry. ## Trigger Types @@ -131,6 +134,7 @@ jobs: steps: - prompt: | Send a concise update. + timeout: 2m ``` The root menu includes: From c53750ca88a73178a1fd880e0d01b056c82e86c7 Mon Sep 17 00:00:00 2001 From: piping Date: Thu, 9 Apr 2026 16:52:51 +0800 Subject: [PATCH 65/83] fix clawbot message inbound/outbound --- codex-rs/clawbot/src/config.rs | 8 +- codex-rs/clawbot/src/lib.rs | 2 + codex-rs/clawbot/src/model.rs | 11 ++ codex-rs/clawbot/src/provider/feishu.rs | 60 ++++++- codex-rs/clawbot/src/store.rs | 53 ++++++ codex-rs/tui/src/app.rs | 2 +- codex-rs/tui/src/app/clawbot.rs | 190 +++++++++++++++++--- codex-rs/tui/src/app/tests/clawbot_tests.rs | 162 ++++++++++++++++- 8 files changed, 454 insertions(+), 34 deletions(-) diff --git a/codex-rs/clawbot/src/config.rs b/codex-rs/clawbot/src/config.rs index ff40a1edb..4c07757a5 100644 --- a/codex-rs/clawbot/src/config.rs +++ b/codex-rs/clawbot/src/config.rs @@ -38,7 +38,12 @@ 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 { + pub fn is_bot_sender( + &self, + open_id: Option<&str>, + user_id: Option<&str>, + app_id: Option<&str>, + ) -> bool { self.bot_open_id .as_deref() .zip(open_id) @@ -48,6 +53,7 @@ impl FeishuConfig { .as_deref() .zip(user_id) .is_some_and(|(bot_user_id, sender_user_id)| bot_user_id == sender_user_id) + || app_id.is_some_and(|sender_app_id| sender_app_id == self.app_id) } pub fn is_empty(&self) -> bool { diff --git a/codex-rs/clawbot/src/lib.rs b/codex-rs/clawbot/src/lib.rs index b4ba835ee..92645f5cc 100644 --- a/codex-rs/clawbot/src/lib.rs +++ b/codex-rs/clawbot/src/lib.rs @@ -15,6 +15,7 @@ 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_PENDING_TURNS_RELATIVE_PATH; pub use model::CLAWBOT_RELATIVE_DIR; pub use model::CLAWBOT_RUNTIME_RELATIVE_PATH; pub use model::CLAWBOT_SESSIONS_RELATIVE_PATH; @@ -24,6 +25,7 @@ pub use model::ClawbotSnapshot; pub use model::ConnectionStatus; pub use model::ForwardingDirection; pub use model::ForwardingState; +pub use model::PendingClawbotTurn; pub use model::ProviderKind; pub use model::ProviderMessageRef; pub use model::ProviderRuntimeState; diff --git a/codex-rs/clawbot/src/model.rs b/codex-rs/clawbot/src/model.rs index 3f32e587c..a21e0ff18 100644 --- a/codex-rs/clawbot/src/model.rs +++ b/codex-rs/clawbot/src/model.rs @@ -2,12 +2,14 @@ use serde::Deserialize; use serde::Serialize; use crate::config::ClawbotConfig; +use crate::config::ClawbotTurnMode; 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_PENDING_TURNS_RELATIVE_PATH: &str = ".codex/clawbot/pending_turns.json"; 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"; @@ -174,6 +176,15 @@ impl CachedUnreadMessage { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingClawbotTurn { + pub thread_id: String, + pub turn_id: String, + pub session: ProviderSessionRef, + pub message_id: String, + pub turn_mode: ClawbotTurnMode, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct InboundMessageReceipt { pub provider: ProviderKind, diff --git a/codex-rs/clawbot/src/provider/feishu.rs b/codex-rs/clawbot/src/provider/feishu.rs index 030481248..772bb417b 100644 --- a/codex-rs/clawbot/src/provider/feishu.rs +++ b/codex-rs/clawbot/src/provider/feishu.rs @@ -329,7 +329,11 @@ fn normalize_message_receive_event( ); return None; } - if config.is_bot_sender(sender.open_id.as_deref(), sender.user_id.as_deref()) { + if config.is_bot_sender( + sender.open_id.as_deref(), + sender.user_id.as_deref(), + sender.app_id.as_deref(), + ) { let _ = append_diagnostic_event( workspace_root, "feishu.message_dropped", @@ -340,6 +344,7 @@ fn normalize_message_receive_event( "chat_type": chat_type, "sender_open_id": sender.open_id, "sender_user_id": sender.user_id, + "sender_app_id": sender.app_id, }), ); return None; @@ -370,6 +375,9 @@ fn normalize_message_receive_event( "chat_id": chat_id.clone(), "chat_type": chat_type.clone(), "message_id": message.message_id.clone(), + "sender_open_id": sender.open_id.clone(), + "sender_user_id": sender.user_id.clone(), + "sender_app_id": sender.app_id.clone(), "text": normalized_text.clone(), }), ); @@ -519,6 +527,8 @@ struct FeishuUserId { open_id: Option, user_id: Option, union_id: Option, + #[serde(default)] + app_id: Option, } #[derive(Debug, Deserialize)] @@ -634,6 +644,7 @@ mod tests { open_id: Some("ou_123".to_string()), user_id: None, union_id: None, + app_id: None, }, }, message: super::FeishuEventMessage { @@ -665,6 +676,7 @@ mod tests { open_id: Some("ou_member".to_string()), user_id: None, union_id: None, + app_id: None, }, }, message: super::FeishuEventMessage { @@ -716,6 +728,7 @@ mod tests { open_id: Some("ou_bot".to_string()), user_id: None, union_id: None, + app_id: None, }, }, message: super::FeishuEventMessage { @@ -749,6 +762,49 @@ mod tests { ); } + #[test] + fn message_receive_event_skips_app_authored_messages() { + let envelope = super::FeishuMessageReceiveEnvelope { + event: super::FeishuMessageReceiveEvent { + sender: super::FeishuEventSender { + sender_id: super::FeishuUserId { + open_id: None, + user_id: None, + union_id: None, + app_id: Some("cli_app_123".to_string()), + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_bot_app_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 { + app_id: "cli_app_123".to_string(), + ..FeishuConfig::default() + }, + Path::new("/tmp"), + ), + None + ); + } + #[test] fn message_receive_event_uses_chat_fallbacks_for_group_payloads() { let envelope = super::FeishuMessageReceiveEnvelope { @@ -758,6 +814,7 @@ mod tests { open_id: Some("ou_member".to_string()), user_id: None, union_id: None, + app_id: None, }, }, message: super::FeishuEventMessage { @@ -814,6 +871,7 @@ mod tests { open_id: Some("ou_123".to_string()), user_id: None, union_id: None, + app_id: None, }, last_message_create_time: Some("456".to_string()), }, diff --git a/codex-rs/clawbot/src/store.rs b/codex-rs/clawbot/src/store.rs index 2bddeabcd..e3ab419ea 100644 --- a/codex-rs/clawbot/src/store.rs +++ b/codex-rs/clawbot/src/store.rs @@ -12,6 +12,7 @@ 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_PENDING_TURNS_RELATIVE_PATH; use crate::model::CLAWBOT_RELATIVE_DIR; use crate::model::CLAWBOT_RUNTIME_RELATIVE_PATH; use crate::model::CLAWBOT_SESSIONS_RELATIVE_PATH; @@ -19,6 +20,7 @@ use crate::model::CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH; use crate::model::CachedUnreadMessage; use crate::model::ClawbotSnapshot; use crate::model::InboundMessageReceipt; +use crate::model::PendingClawbotTurn; use crate::model::ProviderKind; use crate::model::ProviderRuntimeState; use crate::model::ProviderSession; @@ -68,6 +70,11 @@ impl ClawbotStore { self.workspace_root.join(CLAWBOT_RUNTIME_RELATIVE_PATH) } + pub fn pending_turns_path(&self) -> PathBuf { + self.workspace_root + .join(CLAWBOT_PENDING_TURNS_RELATIVE_PATH) + } + pub fn inbound_receipts_path(&self) -> PathBuf { self.workspace_root .join(CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH) @@ -295,6 +302,52 @@ impl ClawbotStore { Ok(Some(message)) } + pub fn load_pending_turns(&self) -> Result> { + read_optional_json_file(&self.pending_turns_path()) + .with_context(|| format!("failed to load {}", self.pending_turns_path().display())) + } + + pub fn save_pending_turns(&self, pending_turns: &[PendingClawbotTurn]) -> Result<()> { + let mut sorted = pending_turns.to_vec(); + sorted.sort_by(|left, right| { + left.thread_id + .cmp(&right.thread_id) + .then(left.turn_id.cmp(&right.turn_id)) + .then(left.session.session_id.cmp(&right.session.session_id)) + .then(left.message_id.cmp(&right.message_id)) + }); + sorted.dedup_by(|left, right| { + left.thread_id == right.thread_id && left.turn_id == right.turn_id + }); + self.write_json_file(&self.pending_turns_path(), &sorted) + } + + pub fn upsert_pending_turn(&self, pending_turn: PendingClawbotTurn) -> Result<()> { + let mut pending_turns = self.load_pending_turns()?; + pending_turns.retain(|existing| { + existing.thread_id != pending_turn.thread_id || existing.turn_id != pending_turn.turn_id + }); + pending_turns.push(pending_turn); + self.save_pending_turns(&pending_turns) + } + + pub fn remove_pending_turn( + &self, + thread_id: &str, + turn_id: &str, + ) -> Result> { + let mut pending_turns = self.load_pending_turns()?; + let Some(index) = pending_turns + .iter() + .position(|pending| pending.thread_id == thread_id && pending.turn_id == turn_id) + else { + return Ok(None); + }; + let pending_turn = pending_turns.remove(index); + self.save_pending_turns(&pending_turns)?; + Ok(Some(pending_turn)) + } + 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())) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a21bd66b0..ce893fefb 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -109,6 +109,7 @@ use codex_app_server_protocol::ThreadStartSource; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; +use codex_clawbot::PendingClawbotTurn; #[cfg(test)] use codex_clawbot::ProviderOutboundReaction; #[cfg(test)] @@ -201,7 +202,6 @@ 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; diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs index 23edb88de..bd3f648a5 100644 --- a/codex-rs/tui/src/app/clawbot.rs +++ b/codex-rs/tui/src/app/clawbot.rs @@ -4,11 +4,15 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::TurnStatus; use codex_clawbot::CachedUnreadMessage; use codex_clawbot::ClawbotRuntime; +use codex_clawbot::ClawbotStore; use codex_clawbot::ClawbotTurnMode; use codex_clawbot::FeishuConfig; use codex_clawbot::FeishuProviderRuntime; +use codex_clawbot::PendingClawbotTurn; use codex_clawbot::ProviderEvent; use codex_clawbot::ProviderMessageRef; use codex_clawbot::ProviderOutboundReaction; @@ -34,13 +38,6 @@ 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 { 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 { @@ -68,6 +65,8 @@ impl App { tracing::warn!(error = %err, "failed to refresh clawbot Feishu sessions"); } } + self.restore_clawbot_pending_turns()?; + self.reconcile_clawbot_pending_turns(app_server).await?; let runtime = ClawbotRuntime::load(workspace_root)?; let sessions_to_drain = runtime @@ -85,6 +84,88 @@ impl App { Ok(()) } + fn clawbot_store(&self) -> Result { + let workspace_root = self + .clawbot_workspace_root + .clone() + .or_else(|| Some(self.config.cwd.to_path_buf())) + .context("missing clawbot workspace root")?; + Ok(ClawbotStore::new(workspace_root)) + } + + fn restore_clawbot_pending_turns(&mut self) -> Result<()> { + let pending_turns = self.clawbot_store()?.load_pending_turns()?; + self.clawbot_pending_turns.clear(); + for pending_turn in pending_turns { + let thread_id = ThreadId::from_string(&pending_turn.thread_id).with_context(|| { + format!("invalid clawbot thread id `{}`", pending_turn.thread_id) + })?; + self.clawbot_pending_turns + .entry(thread_id) + .or_default() + .push_back(pending_turn); + } + Ok(()) + } + + async fn reconcile_clawbot_pending_turns( + &mut self, + app_server: &mut AppServerSession, + ) -> Result<()> { + let pending_turns = self + .clawbot_pending_turns + .values() + .flat_map(|queue| queue.iter().cloned()) + .collect::>(); + for pending_turn in pending_turns { + let thread_id = ThreadId::from_string(&pending_turn.thread_id).with_context(|| { + format!("invalid clawbot thread id `{}`", pending_turn.thread_id) + })?; + let thread = match app_server + .thread_read(thread_id, /*include_turns*/ true) + .await + { + Ok(thread) => thread, + Err(err) => { + tracing::warn!( + thread_id = pending_turn.thread_id, + turn_id = pending_turn.turn_id, + error = %err, + "failed to reconcile clawbot pending turn; clearing stale entry" + ); + let _ = self.remove_pending_clawbot_turn(thread_id, &pending_turn.turn_id)?; + continue; + } + }; + let Some(turn) = thread + .turns + .iter() + .find(|turn| turn.id == pending_turn.turn_id) + .cloned() + else { + if !matches!(thread.status, ThreadStatus::Active { .. }) { + let _ = self.remove_pending_clawbot_turn(thread_id, &pending_turn.turn_id)?; + } + continue; + }; + match turn.status { + TurnStatus::Completed | TurnStatus::Failed | TurnStatus::Interrupted => { + self.handle_clawbot_turn_completed(app_server, thread_id, turn) + .await?; + } + TurnStatus::InProgress => { + self.attach_clawbot_bound_thread_if_needed(app_server, thread_id) + .await?; + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + store.active_turn_id = Some(turn.id); + } + } + } + } + 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(); @@ -170,7 +251,7 @@ impl App { return Ok(()); }; let next_session = if let Some(pending) = - self.take_pending_clawbot_turn(thread_id, &turn.id) + self.take_pending_clawbot_turn(thread_id, &turn.id)? { let reply_text = if let Some(text) = clawbot_outbound_text_for_turn(&turn) { Some(text) @@ -217,6 +298,18 @@ impl App { self.send_clawbot_thread_reply(workspace_root.as_path(), &pending.session, text) .await?; } + let mut runtime = ClawbotRuntime::load(workspace_root.clone())?; + let removed = runtime.take_next_unread_message(&pending.session)?; + if removed.as_ref().map(|entry| entry.message_id.as_str()) + != Some(pending.message_id.as_str()) + { + tracing::warn!( + session = pending.session.session_id, + expected = pending.message_id, + actual = removed.as_ref().map(|entry| entry.message_id.as_str()), + "clawbot unread FIFO state drifted while completing turn" + ); + } Some(pending.session) } else { let runtime = ClawbotRuntime::load(workspace_root)?; @@ -238,7 +331,7 @@ impl App { let Some(workspace_root) = self.clawbot_workspace_root.clone() else { return Ok(()); }; - let mut runtime = ClawbotRuntime::load(workspace_root.clone())?; + let runtime = ClawbotRuntime::load(workspace_root.clone())?; let Some(binding) = runtime.load_binding_for_session(session)? else { let _ = append_diagnostic_event( workspace_root.as_path(), @@ -276,6 +369,22 @@ impl App { ); return Ok(()); } + if self + .clawbot_pending_turns + .get(&thread_id) + .is_some_and(|queue| queue.iter().any(|pending| pending.session == *session)) + { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.dispatch_skipped", + serde_json::json!({ + "reason": "pending_turn", + "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( @@ -329,18 +438,13 @@ impl App { "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()) - != 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" - ); - } + self.register_pending_clawbot_turn( + thread_id, + session.clone(), + turn_id, + message.message_id.clone(), + turn_mode, + ); Ok(()) } @@ -564,24 +668,40 @@ impl App { thread_id: ThreadId, session: ProviderSessionRef, turn_id: String, + message_id: String, turn_mode: ClawbotTurnMode, ) { + let pending_turn = PendingClawbotTurn { + thread_id: thread_id.to_string(), + turn_id, + session, + message_id, + turn_mode, + }; self.clawbot_pending_turns .entry(thread_id) .or_default() - .push_back(PendingClawbotTurn { - turn_id, - session, - turn_mode, - }); + .push_back(pending_turn.clone()); + if let Err(err) = self + .clawbot_store() + .and_then(|store| store.upsert_pending_turn(pending_turn)) + { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to persist clawbot pending turn" + ); + } } fn take_pending_clawbot_turn( &mut self, thread_id: ThreadId, turn_id: &str, - ) -> Option { - let queue = self.clawbot_pending_turns.get_mut(&thread_id)?; + ) -> Result> { + let Some(queue) = self.clawbot_pending_turns.get_mut(&thread_id) else { + return Ok(None); + }; let pending = queue .iter() .position(|pending| pending.turn_id == turn_id) @@ -589,7 +709,19 @@ impl App { if queue.is_empty() { self.clawbot_pending_turns.remove(&thread_id); } - pending + if pending.is_some() { + let _ = self.remove_pending_clawbot_turn(thread_id, turn_id)?; + } + Ok(pending) + } + + fn remove_pending_clawbot_turn( + &mut self, + thread_id: ThreadId, + turn_id: &str, + ) -> Result> { + self.clawbot_store()? + .remove_pending_turn(&thread_id.to_string(), turn_id) } fn clawbot_turn_mode_for_turn( diff --git a/codex-rs/tui/src/app/tests/clawbot_tests.rs b/codex-rs/tui/src/app/tests/clawbot_tests.rs index fc2d2754f..9576bcf51 100644 --- a/codex-rs/tui/src/app/tests/clawbot_tests.rs +++ b/codex-rs/tui/src/app/tests/clawbot_tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::app_event::ClawbotForwardingChannel; use codex_clawbot::ClawbotRuntime; +use codex_clawbot::ClawbotStore; use codex_clawbot::ClawbotTurnMode; use codex_clawbot::ProviderEvent as ClawbotProviderEvent; use codex_clawbot::ProviderKind as ClawbotProviderKind; @@ -93,8 +94,10 @@ async fn noninteractive_clawbot_request_user_input_builds_auto_response() { app.clawbot_pending_turns.insert( thread_id, VecDeque::from([PendingClawbotTurn { + thread_id: thread_id.to_string(), turn_id: "turn-1".to_string(), session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), + message_id: "msg-1".to_string(), turn_mode: ClawbotTurnMode::NonInteractive, }]), ); @@ -133,8 +136,10 @@ async fn noninteractive_clawbot_permissions_request_builds_auto_response() { app.clawbot_pending_turns.insert( thread_id, VecDeque::from([PendingClawbotTurn { + thread_id: thread_id.to_string(), turn_id: "turn-1".to_string(), session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), + message_id: "msg-1".to_string(), turn_mode: ClawbotTurnMode::NonInteractive, }]), ); @@ -214,7 +219,7 @@ async fn clawbot_turn_completed_forwards_reply_and_drains_next_message() -> Resu .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); + assert_eq!(queued_runtime.snapshot().unread_message_count, 2); app.enqueue_thread_notification( thread_id, @@ -258,7 +263,160 @@ async fn clawbot_turn_completed_forwards_reply_and_drains_next_message() -> Resu ); 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); + assert_eq!(drained_runtime.snapshot().unread_message_count, 1); + + Ok(()) +} + +#[tokio::test] +async fn clawbot_restart_recovers_pending_turn_and_forwards_reply() -> 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_restart").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 after restart".to_string(), + received_at: 1, + }), + ) + .await + .expect("handle clawbot inbound"); + + let original_turn_id = app + .clawbot_pending_turns + .get(&thread_id) + .and_then(|queue| queue.front()) + .map(|pending| pending.turn_id.clone()) + .expect("pending turn"); + let store = ClawbotStore::new(app.config.cwd.to_path_buf()); + assert_eq!(store.load_pending_turns().expect("pending turns").len(), 1); + + let mut restarted_app = make_test_app().await; + restarted_app.config.cwd = tempdir.path().to_path_buf().abs(); + restarted_app.sync_clawbot_workspace(&mut app_server).await; + + assert_eq!( + restarted_app + .clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + + restarted_app + .handle_clawbot_turn_completed( + &mut app_server, + thread_id, + test_turn( + &original_turn_id, + TurnStatus::Completed, + vec![ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "restart reply".to_string(), + phase: None, + memory_citation: None, + }], + ), + ) + .await + .expect("complete restored clawbot turn"); + + assert_eq!( + restarted_app.clawbot_outbound_messages, + vec![ProviderOutboundTextMessage { + session, + text: "restart reply".to_string(), + }] + ); + + let runtime = ClawbotRuntime::load(restarted_app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!(runtime.snapshot().unread_message_count, 0); + assert_eq!( + store.load_pending_turns().expect("pending turns"), + Vec::new() + ); + + Ok(()) +} + +#[tokio::test] +async fn clawbot_sync_clears_stale_pending_turn_and_redelivers_unread() -> 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_stale").await?; + + 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: "deliver me".to_string(), + received_at: 1, + }, + )) + .expect("queue unread"); + + let store = ClawbotStore::new(app.config.cwd.to_path_buf()); + store + .upsert_pending_turn(PendingClawbotTurn { + thread_id: thread_id.to_string(), + turn_id: "stale-turn".to_string(), + session: session.clone(), + message_id: "msg_1".to_string(), + turn_mode: ClawbotTurnMode::NonInteractive, + }) + .expect("persist stale pending turn"); + app.clawbot_pending_turns.clear(); + + app.sync_clawbot_workspace(&mut app_server).await; + + assert_eq!( + app.clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + assert_eq!(app.clawbot_outbound_reactions.len(), 1); + assert_eq!( + store + .load_pending_turns() + .expect("pending turns") + .into_iter() + .map(|pending| pending.turn_id) + .collect::>(), + app.clawbot_pending_turns + .get(&thread_id) + .expect("pending queue") + .iter() + .map(|pending| pending.turn_id.clone()) + .collect::>() + ); + assert_ne!( + app.clawbot_pending_turns + .get(&thread_id) + .and_then(|queue| queue.front()) + .map(|pending| pending.turn_id.as_str()), + Some("stale-turn") + ); Ok(()) } From 99e1bbd783124edd82620c6c47b41eb4533e81a7 Mon Sep 17 00:00:00 2001 From: piping Date: Thu, 9 Apr 2026 19:00:15 +0800 Subject: [PATCH 66/83] python: release codex-enhanced v0.1.25 Release codex-enhanced v0.1.25.\n\n- upgrade clawbot openlark dependency to 0.15.0 with a narrower feature set\n- add a Feishu websocket idle watchdog to force reconnects when the runtime goes stale\n- keep the watchdog threshold at 60s so normal quiet periods do not trigger churn --- codex-rs/clawbot/Cargo.toml | 23 +----- .../src/provider/feishu/runtime_loop.rs | 71 ++++++++++++++++++- sdk/python-runtime-enhanced/pyproject.toml | 2 +- 3 files changed, 70 insertions(+), 26 deletions(-) diff --git a/codex-rs/clawbot/Cargo.toml b/codex-rs/clawbot/Cargo.toml index b76340649..f69f99455 100644 --- a/codex-rs/clawbot/Cargo.toml +++ b/codex-rs/clawbot/Cargo.toml @@ -13,28 +13,7 @@ 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", -] } +openlark = { version = "0.15.0", default-features = false, features = ["auth", "communication", "websocket"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["rt", "sync", "time"] } diff --git a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs index 8ecb32185..1e1537c6d 100644 --- a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs +++ b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs @@ -9,6 +9,9 @@ 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 tokio::sync::watch; +use tokio::time::Instant; +use tokio::time::MissedTickBehavior; use super::provider_events_from_payload; use super::runtime_state; @@ -19,6 +22,8 @@ use crate::provider::ProviderEvent; const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(2); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); +const IDLE_TIMEOUT: Duration = Duration::from_secs(60); +const IDLE_WATCHDOG_POLL_INTERVAL: Duration = Duration::from_secs(1); pub(super) async fn run_with_reconnect( workspace_root: PathBuf, @@ -88,6 +93,7 @@ async fn run_once( let ws_config = Arc::new(build_websocket_config(config)?); let (payload_tx, mut payload_rx) = mpsc::unbounded_channel::>(); + let (last_payload_at_tx, last_payload_at_rx) = watch::channel(Instant::now()); let event_handler = EventDispatcherHandler::builder() .payload_sender(payload_tx) .build(); @@ -96,6 +102,7 @@ async fn run_once( let payload_workspace_root = workspace_root.to_path_buf(); let payload_task = tokio::spawn(async move { while let Some(payload) = payload_rx.recv().await { + let _ = last_payload_at_tx.send(Instant::now()); let _ = append_diagnostic_event( payload_workspace_root.as_path(), "feishu.raw_payload", @@ -110,6 +117,7 @@ async fn run_once( } } }); + let last_payload_at_rx = last_payload_at_rx; let _ = append_diagnostic_event( workspace_root, @@ -121,9 +129,40 @@ async fn run_once( /*last_error*/ 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}")) + let mut websocket_task = + tokio::spawn(async move { LarkWsClient::open(ws_config, event_handler).await }); + let mut idle_watchdog = tokio::time::interval(IDLE_WATCHDOG_POLL_INTERVAL); + idle_watchdog.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + tokio::select! { + websocket_result = &mut websocket_task => { + payload_task.abort(); + return websocket_result + .map_err(|error| anyhow!("Feishu websocket runtime task failed: {error}"))? + .map_err(|error| anyhow!("Feishu websocket runtime failed: {error}")); + } + _ = idle_watchdog.tick() => { + let last_payload_at = *last_payload_at_rx.borrow(); + if idle_timeout_exceeded(last_payload_at, Instant::now()) { + let _ = append_diagnostic_event( + workspace_root, + "feishu.runtime_idle_timeout", + serde_json::json!({ + "idle_timeout_secs": IDLE_TIMEOUT.as_secs(), + }), + ); + websocket_task.abort(); + let _ = websocket_task.await; + payload_task.abort(); + return Err(anyhow!( + "Feishu websocket idle timeout after {}s without payloads", + IDLE_TIMEOUT.as_secs() + )); + } + } + } + } } pub(super) fn build_websocket_config(config: &FeishuConfig) -> Result { @@ -142,3 +181,29 @@ fn payload_debug_value(payload: &[u8]) -> serde_json::Value { }) }) } + +fn idle_timeout_exceeded(last_payload_at: Instant, now: Instant) -> bool { + now.duration_since(last_payload_at) >= IDLE_TIMEOUT +} + +#[cfg(test)] +mod tests { + use super::IDLE_TIMEOUT; + use super::idle_timeout_exceeded; + use std::time::Duration; + use tokio::time::Instant; + + #[test] + fn idle_timeout_triggers_at_threshold() { + let last_payload_at = Instant::now(); + + assert!(!idle_timeout_exceeded( + last_payload_at, + last_payload_at + IDLE_TIMEOUT - Duration::from_millis(1), + )); + assert!(idle_timeout_exceeded( + last_payload_at, + last_payload_at + IDLE_TIMEOUT, + )); + } +} diff --git a/sdk/python-runtime-enhanced/pyproject.toml b/sdk/python-runtime-enhanced/pyproject.toml index 70fa04589..b631d6e01 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.24" +version = "0.1.25" description = "Enhanced Codex CLI with loop automation, Feishu clawbot support, and fast session respawn" readme = "README.md" requires-python = ">=3" From 0590a3dafb7ae683897ffce021ee1417de0fe2f4 Mon Sep 17 00:00:00 2001 From: piping Date: Fri, 10 Apr 2026 11:36:26 +0800 Subject: [PATCH 67/83] feat(clawbot): improve clawbot menu --- .gitignore | 2 + codex-rs/clawbot/src/provider/feishu.rs | 164 ++- .../src/provider/feishu/runtime_loop.rs | 76 +- codex-rs/clawbot/src/runtime.rs | 126 +- codex-rs/tui/src/app.rs | 42 +- codex-rs/tui/src/app/clawbot.rs | 267 +++-- codex-rs/tui/src/app/clawbot_controls.rs | 1018 ++++++++++++----- codex-rs/tui/src/app/tests/clawbot_tests.rs | 164 ++- ...clawbot_tests__clawbot_channels_popup.snap | 17 + ...awbot_tests__clawbot_management_popup.snap | 18 +- codex-rs/tui/src/app_event.rs | 38 +- 11 files changed, 1461 insertions(+), 471 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_channels_popup.snap diff --git a/.gitignore b/.gitignore index 993277dc0..4ecaeca37 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,5 @@ __pycache__/ scorecard.png site/ docs/assets/ +# CocoIndex Code (ccc) +/.cocoindex_code/ diff --git a/codex-rs/clawbot/src/provider/feishu.rs b/codex-rs/clawbot/src/provider/feishu.rs index 772bb417b..704431ca7 100644 --- a/codex-rs/clawbot/src/provider/feishu.rs +++ b/codex-rs/clawbot/src/provider/feishu.rs @@ -14,7 +14,11 @@ 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::models::UserIdType; +use open_lark::openlark_communication::im::im::v1::message::reaction::delete::DeleteMessageReactionRequest; +use open_lark::openlark_communication::im::im::v1::message::reaction::list::ListMessageReactionsRequest; use open_lark::openlark_communication::im::im::v1::message::reaction::models::CreateMessageReactionBody; +use open_lark::openlark_communication::im::im::v1::message::reaction::models::MessageReaction; use open_lark::openlark_communication::im::im::v1::message::reaction::models::ReactionType; use open_lark::openlark_core::api::ApiRequest; use serde::Deserialize; @@ -120,6 +124,9 @@ impl FeishuProviderRuntime { )); } + let session_id = reaction.target.session_id.clone(); + let message_id = reaction.target.message_id.clone(); + let emoji_type = reaction.emoji_type.clone(); let request: ApiRequest = ApiRequest::post(format!( "{IM_V1_MESSAGES}/{}/reactions", reaction.target.message_id @@ -140,13 +147,95 @@ impl FeishuProviderRuntime { .await .map_err(|error| anyhow!("failed to add Feishu message reaction: {error}"))?; if response.is_success() { + let _ = append_diagnostic_event( + self.workspace_root.as_path(), + "feishu.add_reaction_succeeded", + serde_json::json!({ + "session_id": session_id, + "message_id": message_id, + "emoji_type": emoji_type, + }), + ); Ok(()) } else { - Err(anyhow!( - "failed to add Feishu message reaction: {}", - response.msg() - )) + let error = anyhow!("failed to add Feishu message reaction: {}", response.msg()); + let _ = append_diagnostic_event( + self.workspace_root.as_path(), + "feishu.add_reaction_failed", + serde_json::json!({ + "session_id": session_id, + "message_id": message_id, + "emoji_type": emoji_type, + "error": error.to_string(), + }), + ); + Err(error) + } + } + + pub async fn remove_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 config = self.messaging_config()?; + let matching_reactions = self + .list_message_reactions( + &config, + reaction.target.message_id.as_str(), + reaction.emoji_type.as_str(), + ) + .await?; + let reaction_ids = self.select_reaction_ids_to_remove(&reaction, &matching_reactions); + + if reaction_ids.is_empty() { + let _ = append_diagnostic_event( + self.workspace_root.as_path(), + "feishu.remove_reaction_skipped", + serde_json::json!({ + "session_id": reaction.target.session_id, + "message_id": reaction.target.message_id, + "emoji_type": reaction.emoji_type, + "bot_open_id": self.config.bot_open_id, + "bot_user_id": self.config.bot_user_id, + "matching_reaction_count": matching_reactions.len(), + "matching_reactions": matching_reactions + .iter() + .map(|item| serde_json::json!({ + "reaction_id": item.reaction_id, + "operator_id": item.operator.operator_id, + "operator_type": item.operator.operator_type, + "emoji_type": item.reaction_type.emoji_type, + })) + .collect::>(), + }), + ); + return Ok(()); + } + + for reaction_id in &reaction_ids { + DeleteMessageReactionRequest::new(config.clone()) + .message_id(reaction.target.message_id.clone()) + .reaction_id(reaction_id.clone()) + .execute() + .await + .map_err(|error| anyhow!("failed to remove Feishu message reaction: {error}"))?; } + + let _ = append_diagnostic_event( + self.workspace_root.as_path(), + "feishu.remove_reaction_succeeded", + serde_json::json!({ + "session_id": reaction.target.session_id, + "message_id": reaction.target.message_id, + "emoji_type": reaction.emoji_type, + "removed_count": reaction_ids.len(), + }), + ); + Ok(()) } pub async fn scan_sessions(&self) -> Result> { @@ -202,6 +291,73 @@ impl FeishuProviderRuntime { .websocket_config()? .build_core_config_with_token_provider()) } + + async fn list_message_reactions( + &self, + config: &open_lark::openlark_core::config::Config, + message_id: &str, + emoji_type: &str, + ) -> Result> { + let mut page_token = None; + let mut reactions = Vec::new(); + + loop { + let mut request = ListMessageReactionsRequest::new(config.clone()) + .message_id(message_id.to_string()) + .reaction_type(emoji_type.to_string()) + .page_size(50) + .user_id_type(UserIdType::OpenId); + if let Some(token) = page_token.clone() { + request = request.page_token(token); + } + let response = request + .execute() + .await + .map_err(|error| anyhow!("failed to list Feishu message reactions: {error}"))?; + reactions.extend(response.items.unwrap_or_default()); + if !response.has_more { + break; + } + let Some(token) = response.page_token.filter(|token| !token.is_empty()) else { + break; + }; + page_token = Some(token); + } + + Ok(reactions) + } + + fn select_reaction_ids_to_remove( + &self, + reaction: &ProviderOutboundReaction, + matching_reactions: &[MessageReaction], + ) -> Vec { + let bot_open_id = self.config.bot_open_id.as_deref(); + let bot_user_id = self.config.bot_user_id.as_deref(); + let exact_matches = matching_reactions + .iter() + .filter(|item| { + item.reaction_type.emoji_type == reaction.emoji_type + && (bot_open_id == Some(item.operator.operator_id.as_str()) + || bot_user_id == Some(item.operator.operator_id.as_str())) + }) + .map(|item| item.reaction_id.clone()) + .collect::>(); + if !exact_matches.is_empty() { + return exact_matches; + } + + let fallback_matches = matching_reactions + .iter() + .filter(|item| item.reaction_type.emoji_type == reaction.emoji_type) + .map(|item| item.reaction_id.clone()) + .collect::>(); + if fallback_matches.len() == 1 { + return fallback_matches; + } + + Vec::new() + } } pub fn failure_reply_text(message: &str) -> String { diff --git a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs index 1e1537c6d..563cf9380 100644 --- a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs +++ b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs @@ -9,9 +9,6 @@ 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 tokio::sync::watch; -use tokio::time::Instant; -use tokio::time::MissedTickBehavior; use super::provider_events_from_payload; use super::runtime_state; @@ -22,8 +19,6 @@ use crate::provider::ProviderEvent; const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(2); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); -const IDLE_TIMEOUT: Duration = Duration::from_secs(60); -const IDLE_WATCHDOG_POLL_INTERVAL: Duration = Duration::from_secs(1); pub(super) async fn run_with_reconnect( workspace_root: PathBuf, @@ -93,7 +88,6 @@ async fn run_once( let ws_config = Arc::new(build_websocket_config(config)?); let (payload_tx, mut payload_rx) = mpsc::unbounded_channel::>(); - let (last_payload_at_tx, last_payload_at_rx) = watch::channel(Instant::now()); let event_handler = EventDispatcherHandler::builder() .payload_sender(payload_tx) .build(); @@ -102,7 +96,6 @@ async fn run_once( let payload_workspace_root = workspace_root.to_path_buf(); let payload_task = tokio::spawn(async move { while let Some(payload) = payload_rx.recv().await { - let _ = last_payload_at_tx.send(Instant::now()); let _ = append_diagnostic_event( payload_workspace_root.as_path(), "feishu.raw_payload", @@ -117,7 +110,6 @@ async fn run_once( } } }); - let last_payload_at_rx = last_payload_at_rx; let _ = append_diagnostic_event( workspace_root, @@ -129,40 +121,14 @@ async fn run_once( /*last_error*/ None, )?)); - let mut websocket_task = - tokio::spawn(async move { LarkWsClient::open(ws_config, event_handler).await }); - let mut idle_watchdog = tokio::time::interval(IDLE_WATCHDOG_POLL_INTERVAL); - idle_watchdog.set_missed_tick_behavior(MissedTickBehavior::Delay); - - loop { - tokio::select! { - websocket_result = &mut websocket_task => { - payload_task.abort(); - return websocket_result - .map_err(|error| anyhow!("Feishu websocket runtime task failed: {error}"))? - .map_err(|error| anyhow!("Feishu websocket runtime failed: {error}")); - } - _ = idle_watchdog.tick() => { - let last_payload_at = *last_payload_at_rx.borrow(); - if idle_timeout_exceeded(last_payload_at, Instant::now()) { - let _ = append_diagnostic_event( - workspace_root, - "feishu.runtime_idle_timeout", - serde_json::json!({ - "idle_timeout_secs": IDLE_TIMEOUT.as_secs(), - }), - ); - websocket_task.abort(); - let _ = websocket_task.await; - payload_task.abort(); - return Err(anyhow!( - "Feishu websocket idle timeout after {}s without payloads", - IDLE_TIMEOUT.as_secs() - )); - } - } - } - } + let websocket_result = + tokio::spawn(async move { LarkWsClient::open(ws_config, event_handler).await }) + .await + .map_err(|error| anyhow!("Feishu websocket runtime task failed: {error}"))? + .map_err(|error| anyhow!("Feishu websocket runtime failed: {error}")); + payload_task.abort(); + let _ = payload_task.await; + websocket_result } pub(super) fn build_websocket_config(config: &FeishuConfig) -> Result { @@ -181,29 +147,3 @@ fn payload_debug_value(payload: &[u8]) -> serde_json::Value { }) }) } - -fn idle_timeout_exceeded(last_payload_at: Instant, now: Instant) -> bool { - now.duration_since(last_payload_at) >= IDLE_TIMEOUT -} - -#[cfg(test)] -mod tests { - use super::IDLE_TIMEOUT; - use super::idle_timeout_exceeded; - use std::time::Duration; - use tokio::time::Instant; - - #[test] - fn idle_timeout_triggers_at_threshold() { - let last_payload_at = Instant::now(); - - assert!(!idle_timeout_exceeded( - last_payload_at, - last_payload_at + IDLE_TIMEOUT - Duration::from_millis(1), - )); - assert!(idle_timeout_exceeded( - last_payload_at, - last_payload_at + IDLE_TIMEOUT, - )); - } -} diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs index 51abe4ea1..f4edc5837 100644 --- a/codex-rs/clawbot/src/runtime.rs +++ b/codex-rs/clawbot/src/runtime.rs @@ -87,6 +87,37 @@ impl ClawbotRuntime { self.reconcile_feishu_sessions(discovered_sessions) } + pub fn can_bind_feishu_session(&self, session: &ProviderSessionRef) -> Result { + if session.provider != ProviderKind::Feishu { + return Ok(false); + } + + if self + .snapshot + .sessions + .iter() + .any(|existing| existing.session_ref() == *session) + || self.load_binding_for_session(session)?.is_some() + || self + .store + .load_unread_messages()? + .into_iter() + .any(|message| message.session_ref() == *session) + || self + .store + .load_inbound_receipts()? + .into_iter() + .any(|receipt| receipt.session_ref() == *session) + { + return Ok(true); + } + + Ok(self.snapshot.config.feishu.as_ref().is_some_and(|config| { + config.bot_open_id.as_deref() == Some(session.session_id.as_str()) + || config.bot_user_id.as_deref() == Some(session.session_id.as_str()) + })) + } + pub fn clear_unbound_feishu_sessions(&mut self) -> Result<&ClawbotSnapshot> { let bound_sessions = self .store @@ -381,15 +412,20 @@ impl ClawbotRuntime { .iter() .map(ProviderSession::session_ref) .collect::>(); - let bindings = self + let bound_refs = self .store .load_bindings()? .into_iter() - .filter(|binding| { - binding.provider != ProviderKind::Feishu - || discovered_refs.contains(&binding.session_ref()) - }) - .collect::>(); + .filter(|binding| binding.provider == ProviderKind::Feishu) + .map(|binding| binding.session_ref()) + .collect::>(); + let unread_refs = self + .store + .load_unread_messages()? + .into_iter() + .filter(|message| message.provider == ProviderKind::Feishu) + .map(|message| message.session_ref()) + .collect::>(); let sessions = self .store .load_sessions()? @@ -397,6 +433,8 @@ impl ClawbotRuntime { .filter(|session| { session.provider != ProviderKind::Feishu || discovered_refs.contains(&session.session_ref()) + || bound_refs.contains(&session.session_ref()) + || unread_refs.contains(&session.session_ref()) }) .collect::>(); let unread_messages = self @@ -406,9 +444,9 @@ impl ClawbotRuntime { .filter(|message| { message.provider != ProviderKind::Feishu || discovered_refs.contains(&message.session_ref()) + || bound_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 { @@ -744,7 +782,7 @@ mod tests { } #[test] - fn reconcile_feishu_sessions_prunes_missing_bound_sessions_and_unread_cache() { + fn reconcile_feishu_sessions_preserves_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"); @@ -755,7 +793,7 @@ mod tests { .expect("bind stale session"); runtime .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { - session: stale_session, + session: stale_session.clone(), message_id: "msg_stale".to_string(), text: "stale".to_string(), received_at: 1, @@ -773,25 +811,75 @@ mod tests { }]) .expect("reconcile discovered sessions"); - assert_eq!(runtime.snapshot().bindings, Vec::::new()); assert_eq!( - runtime.snapshot().sessions, - vec![ProviderSession { + runtime.snapshot().bindings, + vec![SessionBinding { 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, + session_id: stale_session.session_id.clone(), + thread_id: "thread_stale".to_string(), + inbound_forwarding_enabled: true, + outbound_forwarding_enabled: true, + created_at: runtime.snapshot().bindings[0].created_at, + updated_at: runtime.snapshot().bindings[0].updated_at, }] ); + 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, + }, + ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_stale".to_string(), + display_name: None, + unread_count: 1, + last_message_at: Some(1), + status: SessionStatus::Bound, + bound_thread_id: Some("thread_stale".to_string()), + } + ] + ); assert_eq!( runtime .store() .load_unread_messages() .expect("unread messages"), - Vec::::new() + vec![CachedUnreadMessage { + provider: ProviderKind::Feishu, + session_id: "chat_stale".to_string(), + message_id: "msg_stale".to_string(), + text: "stale".to_string(), + received_at: 1, + }] + ); + } + + #[test] + fn can_bind_feishu_session_accepts_configured_bot_identity() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + runtime + .update_feishu_config(Some(FeishuConfig { + bot_open_id: Some("oc_bot_private".to_string()), + ..FeishuConfig::default() + })) + .expect("update config"); + + assert_eq!( + runtime + .can_bind_feishu_session(&ProviderSessionRef::new( + ProviderKind::Feishu, + "oc_bot_private", + )) + .expect("can bind"), + true ); } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ce893fefb..eecb4718e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2,6 +2,7 @@ use crate::app_backtrack::BacktrackState; use crate::app_command::AppCommand; use crate::app_command::AppCommandView; use crate::app_event::AppEvent; +use crate::app_event::ClawbotControlsDestination; use crate::app_event::ExitMode; use crate::app_event::FeedbackCategory; use crate::app_event::RateLimitRefreshOrigin; @@ -1154,6 +1155,7 @@ pub(crate) struct App { workflow_history: WorkflowHistoryState, btw_session: Option, btw_closing_thread_ids: HashSet, + clawbot_controls_destination: ClawbotControlsDestination, clawbot_workspace_root: Option, clawbot_provider_task: Option>, clawbot_pending_turns: HashMap>, @@ -1161,6 +1163,8 @@ pub(crate) struct App { clawbot_outbound_messages: Vec, #[cfg(test)] clawbot_outbound_reactions: Vec, + #[cfg(test)] + clawbot_removed_outbound_reactions: Vec, } #[derive(Default)] @@ -4472,6 +4476,7 @@ impl App { workflow_history: WorkflowHistoryState::default(), btw_session: None, btw_closing_thread_ids: HashSet::new(), + clawbot_controls_destination: ClawbotControlsDestination::Root, clawbot_workspace_root: None, clawbot_provider_task: None, clawbot_pending_turns: HashMap::new(), @@ -4479,6 +4484,8 @@ impl App { clawbot_outbound_messages: Vec::new(), #[cfg(test)] clawbot_outbound_reactions: Vec::new(), + #[cfg(test)] + clawbot_removed_outbound_reactions: Vec::new(), }; match WorkflowFileWatchState::new(app.config.cwd.as_path(), app.app_event_tx.clone()) { Ok(state) => { @@ -5226,6 +5233,9 @@ impl App { AppEvent::OpenClawbotManagement => { self.open_clawbot_management_popup(); } + AppEvent::OpenClawbotManagementView { destination } => { + self.open_clawbot_management_view(destination); + } AppEvent::OpenClawbotFeishuConfigPrompt { field } => { self.open_clawbot_feishu_config_prompt(field); } @@ -5235,9 +5245,6 @@ impl App { .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 .bind_clawbot_session_to_current_thread(app_server, session_id) @@ -5253,14 +5260,12 @@ impl App { .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 - .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) { + AppEvent::ClawbotSetThreadForwarding { + thread_id, + channel, + enabled, + } => { + if let Err(err) = self.clawbot_set_thread_forwarding(thread_id, channel, enabled) { self.chat_widget .add_error_message(format!("Failed to update Clawbot forwarding: {err}")); } @@ -5285,6 +5290,15 @@ impl App { )); } } + AppEvent::ClawbotDisconnectThread { thread_id } => { + if let Err(err) = self.clawbot_disconnect_thread(thread_id) { + self.chat_widget + .add_error_message(format!("Failed to disconnect Clawbot binding: {err}")); + } + } + AppEvent::EditClawbotStateFile { label, path } => { + self.edit_clawbot_state_file_from_ui(tui, label, path).await; + } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { tui.frame_requester().schedule_frame(); @@ -10637,6 +10651,7 @@ guardian_approval = true workflow_history: WorkflowHistoryState::default(), btw_session: None, btw_closing_thread_ids: HashSet::new(), + clawbot_controls_destination: ClawbotControlsDestination::Root, clawbot_workspace_root: None, clawbot_provider_task: None, clawbot_pending_turns: HashMap::new(), @@ -10644,6 +10659,8 @@ guardian_approval = true clawbot_outbound_messages: Vec::new(), #[cfg(test)] clawbot_outbound_reactions: Vec::new(), + #[cfg(test)] + clawbot_removed_outbound_reactions: Vec::new(), } } @@ -10712,6 +10729,7 @@ guardian_approval = true workflow_history: WorkflowHistoryState::default(), btw_session: None, btw_closing_thread_ids: HashSet::new(), + clawbot_controls_destination: ClawbotControlsDestination::Root, clawbot_workspace_root: None, clawbot_provider_task: None, clawbot_pending_turns: HashMap::new(), @@ -10719,6 +10737,8 @@ guardian_approval = true clawbot_outbound_messages: Vec::new(), #[cfg(test)] clawbot_outbound_reactions: Vec::new(), + #[cfg(test)] + clawbot_removed_outbound_reactions: Vec::new(), }, rx, op_rx, diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs index bd3f648a5..17db53dc0 100644 --- a/codex-rs/tui/src/app/clawbot.rs +++ b/codex-rs/tui/src/app/clawbot.rs @@ -133,7 +133,7 @@ impl App { error = %err, "failed to reconcile clawbot pending turn; clearing stale entry" ); - let _ = self.remove_pending_clawbot_turn(thread_id, &pending_turn.turn_id)?; + let _ = self.take_pending_clawbot_turn(thread_id, &pending_turn.turn_id)?; continue; } }; @@ -144,7 +144,7 @@ impl App { .cloned() else { if !matches!(thread.status, ThreadStatus::Active { .. }) { - let _ = self.remove_pending_clawbot_turn(thread_id, &pending_turn.turn_id)?; + let _ = self.take_pending_clawbot_turn(thread_id, &pending_turn.turn_id)?; } continue; }; @@ -250,71 +250,124 @@ impl App { 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)? - { - 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) + let next_session = + if let Some(pending) = self.take_pending_clawbot_turn(thread_id, &turn.id)? { + 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(), + }), + ); + let reply_result = if let Some(text) = reply_text { + self.send_clawbot_thread_reply(workspace_root.as_path(), &pending.session, text) + .await + } else { + Ok(()) + }; + let reply_succeeded = reply_result.is_ok(); + if let Err(err) = self + .remove_clawbot_auto_ack_reaction( + workspace_root.as_path(), + ProviderMessageRef::new( + pending.session.provider, + pending.session.session_id.clone(), + pending.message_id.clone(), + ), + ) .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, + session = pending.session.session_id, + message_id = pending.message_id, + error = %err, + "failed to clear clawbot auto-ack reaction" + ); + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.auto_ack_clear_failed", + serde_json::json!({ + "session_id": pending.session.session_id, + "thread_id": thread_id, + "message_id": pending.message_id, + "emoji_type": FEISHU_AUTO_ACK_EMOJI_TYPE, + "error": err.to_string(), + }), + ); + } + reply_result?; + if reply_succeeded { + let mut runtime = ClawbotRuntime::load(workspace_root.clone())?; + let removed = runtime.take_next_unread_message(&pending.session)?; + if removed.as_ref().map(|entry| entry.message_id.as_str()) + != Some(pending.message_id.as_str()) + { tracing::warn!( - thread_id = %thread_id, - turn_id = turn.id, - error = %err, - "failed to load full turn for clawbot outbound reply" + session = pending.session.session_id, + expected = pending.message_id, + actual = removed.as_ref().map(|entry| entry.message_id.as_str()), + "clawbot unread FIFO state drifted while completing turn" ); - None } } + Some(pending.session) } else { - None + let runtime = ClawbotRuntime::load(workspace_root.clone())?; + let bound_session = runtime.bound_session_for_thread(&thread_id.to_string())?; + if let Some(session) = bound_session.as_ref() + && let Some(text) = clawbot_outbound_text_for_turn(&turn) + { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.bound_thread_turn_completed", + serde_json::json!({ + "session_id": session.session_id, + "thread_id": thread_id, + "turn_id": turn.id, + "status": format!("{:?}", turn.status), + "reply_text": text.clone(), + }), + ); + self.send_clawbot_thread_reply(workspace_root.as_path(), session, text) + .await?; + } + bound_session }; - 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?; - } - let mut runtime = ClawbotRuntime::load(workspace_root.clone())?; - let removed = runtime.take_next_unread_message(&pending.session)?; - if removed.as_ref().map(|entry| entry.message_id.as_str()) - != Some(pending.message_id.as_str()) - { - tracing::warn!( - session = pending.session.session_id, - expected = pending.message_id, - actual = removed.as_ref().map(|entry| entry.message_id.as_str()), - "clawbot unread FIFO state drifted while completing turn" - ); - } - 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) @@ -401,6 +454,21 @@ impl App { let turn_mode = runtime.snapshot().config.turn_mode; self.attach_clawbot_bound_thread_if_needed(app_server, thread_id) .await?; + if self.active_thread_id.is_some() && self.active_thread_id != Some(thread_id) { + let session_title = runtime + .snapshot() + .sessions + .iter() + .find(|provider_session| provider_session.session_ref() == *session) + .map_or_else(|| session.session_id.clone(), session_title); + self.chat_widget.add_info_message( + format!( + "New Feishu message from {session_title} was imported into agent thread {}.", + self.thread_label(thread_id) + ), + Some("Open /clawbot to inspect bindings and jump.".to_string()), + ); + } if let Err(err) = self .send_clawbot_outbound_reaction(ProviderOutboundReaction { target: ProviderMessageRef::new( @@ -418,6 +486,17 @@ impl App { error = %err, "failed to auto-ack clawbot inbound message" ); + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.auto_ack_failed", + serde_json::json!({ + "session_id": session.session_id, + "thread_id": thread_id, + "message_id": message.message_id, + "emoji_type": FEISHU_AUTO_ACK_EMOJI_TYPE, + "error": err.to_string(), + }), + ); } let turn_id = self .submit_clawbot_message_to_thread( @@ -663,6 +742,32 @@ impl App { } } + async fn remove_clawbot_auto_ack_reaction( + &mut self, + workspace_root: &Path, + target: ProviderMessageRef, + ) -> Result<()> { + let reaction = ProviderOutboundReaction { + target, + emoji_type: FEISHU_AUTO_ACK_EMOJI_TYPE.to_string(), + }; + #[cfg(test)] + { + let _ = workspace_root; + self.clawbot_removed_outbound_reactions.push(reaction); + Ok(()) + } + + #[cfg(not(test))] + { + let runtime = ClawbotRuntime::load(workspace_root.to_path_buf())?; + let provider = runtime + .feishu_provider() + .context("missing Feishu config for clawbot reaction cleanup")?; + provider.remove_reaction(reaction).await + } + } + fn register_pending_clawbot_turn( &mut self, thread_id: ThreadId, @@ -824,13 +929,7 @@ fn clawbot_approval_policy( fn clawbot_outbound_text_for_turn(turn: &codex_app_server_protocol::Turn) -> Option { match turn.status { - 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::Completed => last_agent_message_for_turn(turn), codex_app_server_protocol::TurnStatus::Failed => turn .error .as_ref() @@ -869,6 +968,14 @@ fn next_unread_message_for_session( }) } +fn session_title(session: &codex_clawbot::ProviderSession) -> String { + session + .display_name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| session.session_id.clone()) +} + #[cfg(test)] mod tests { use codex_app_server_protocol::ThreadItem; @@ -878,6 +985,34 @@ mod tests { use super::clawbot_outbound_text_for_turn; + #[test] + fn completed_turn_uses_last_agent_message_for_reply() { + let turn = Turn { + id: "turn-1".to_string(), + status: TurnStatus::Completed, + items: vec![ + ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "draft reply".to_string(), + phase: None, + memory_citation: None, + }, + ThreadItem::AgentMessage { + id: "agent-2".to_string(), + text: "final reply".to_string(), + phase: None, + memory_citation: None, + }, + ], + error: None, + }; + + assert_eq!( + clawbot_outbound_text_for_turn(&turn), + Some("final reply".to_string()) + ); + } + #[test] fn interrupted_turn_uses_last_agent_message_for_reply() { let turn = Turn { diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index eacbf2e19..5c3979f26 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -1,6 +1,11 @@ +use std::fs; +use std::path::PathBuf; + use anyhow::Context; use anyhow::Result; +use codex_clawbot::CLAWBOT_DIAGNOSTICS_RELATIVE_PATH; use codex_clawbot::ClawbotRuntime; +use codex_clawbot::ClawbotStore; use codex_clawbot::ClawbotTurnMode; use codex_clawbot::ConnectionStatus; use codex_clawbot::FeishuConfig; @@ -10,10 +15,14 @@ use codex_clawbot::ProviderKind; use codex_clawbot::ProviderRuntimeState; use codex_clawbot::ProviderSession; use codex_clawbot::ProviderSessionRef; +use codex_clawbot::SessionBinding; use codex_protocol::ThreadId; +use ratatui::style::Stylize; +use ratatui::text::Line; use super::App; use crate::app_event::AppEvent; +use crate::app_event::ClawbotControlsDestination; use crate::app_event::ClawbotFeishuConfigField; use crate::app_event::ClawbotForwardingChannel; use crate::app_event_sender::AppEventSender; @@ -23,6 +32,9 @@ 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; +use crate::external_editor; +use crate::render::renderable::ColumnRenderable; +use crate::tui; const CLAWBOT_MANAGEMENT_VIEW_ID: &str = "clawbot-management"; @@ -96,13 +108,6 @@ impl ClawbotFeishuConfigField { } 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", @@ -135,19 +140,34 @@ impl ClawbotForwardingChannel { impl App { pub(crate) fn open_clawbot_management_popup(&mut self) { - let initial_selected_idx = self + self.open_clawbot_management_view(ClawbotControlsDestination::Root); + } + + pub(crate) fn open_clawbot_management_view(&mut self, destination: ClawbotControlsDestination) { + let active_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)); + let initial_selected_idx = + if active_selected_idx.is_some() && self.clawbot_controls_destination == destination { + active_selected_idx + } else { + Some(0) + }; + self.clawbot_controls_destination = destination.clone(); + let params = self.clawbot_management_popup_params(&destination, initial_selected_idx); + if active_selected_idx.is_some() { + let _ = self + .chat_widget + .replace_selection_view_if_active(CLAWBOT_MANAGEMENT_VIEW_ID, params); + } else { + self.chat_widget.show_selection_view(params); } } + fn refresh_clawbot_management_popup(&mut self) { + self.open_clawbot_management_view(self.clawbot_controls_destination.clone()); + } + pub(crate) fn open_clawbot_feishu_config_prompt(&mut self, field: ClawbotFeishuConfigField) { let config = ClawbotRuntime::load(self.config.cwd.to_path_buf()) .ok() @@ -164,39 +184,6 @@ impl App { 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, @@ -227,7 +214,7 @@ impl App { } runtime.update_feishu_config(Some(config))?; self.refresh_clawbot_provider_runtime()?; - self.open_clawbot_management_popup(); + self.refresh_clawbot_management_popup(); self.chat_widget .add_info_message(format!("Updated {}.", field.title()), /*hint*/ None); Ok(()) @@ -236,7 +223,7 @@ impl App { 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.refresh_clawbot_management_popup(); self.chat_widget.add_info_message( format!( "Clawbot turn mode set to {}.", @@ -263,12 +250,7 @@ impl App { 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) - { + if !runtime.can_bind_feishu_session(&session)? { return Err(anyhow::anyhow!( "Feishu session {trimmed} is not visible to the current bot" )); @@ -278,7 +260,7 @@ impl App { self.refresh_clawbot_provider_runtime()?; self.dispatch_next_clawbot_message(app_server, &session) .await?; - self.open_clawbot_management_popup(); + self.refresh_clawbot_management_popup(); self.chat_widget.add_info_message( format!("Bound thread {thread_id} to Feishu session {trimmed}."), /*hint*/ None, @@ -286,17 +268,34 @@ impl App { Ok(()) } + #[cfg(test)] 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")?; + self.clawbot_disconnect_thread(thread_id) + } + + #[cfg(test)] + 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")?; + self.clawbot_set_thread_forwarding(thread_id, channel, enabled) + } + + pub(crate) fn clawbot_disconnect_thread(&mut self, thread_id: ThreadId) -> Result<()> { 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" + "thread {thread_id} is not bound to a Clawbot session" )); }; - self.open_clawbot_management_popup(); + self.refresh_clawbot_management_popup(); self.chat_widget.add_info_message( format!( "Disconnected Feishu session {} from thread {thread_id}.", @@ -307,14 +306,12 @@ impl App { Ok(()) } - pub(crate) fn clawbot_set_current_thread_forwarding( + pub(crate) fn clawbot_set_thread_forwarding( &mut self, + thread_id: ThreadId, 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, @@ -327,13 +324,68 @@ impl App { 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(); + .with_context(|| format!("thread {thread_id} is not bound to a Clawbot session"))?; + self.refresh_clawbot_management_popup(); self.chat_widget .add_info_message(channel.description(enabled), /*hint*/ None); Ok(()) } + pub(crate) async fn edit_clawbot_state_file_from_ui( + &mut self, + tui: &mut tui::Tui, + label: &'static str, + path: PathBuf, + ) { + let Ok(editor_cmd) = self.resolve_editor_command_for_clawbot() else { + return; + }; + if let Some(parent) = path.parent() + && let Err(err) = fs::create_dir_all(parent) + { + self.chat_widget + .add_error_message(format!("Failed to prepare {label}: {err}")); + return; + } + if let Err(err) = fs::OpenOptions::new().create(true).append(true).open(&path) { + self.chat_widget + .add_error_message(format!("Failed to prepare {label}: {err}")); + return; + } + let edit_result = tui + .with_restored(tui::RestoreMode::KeepRaw, || async { + external_editor::edit_file(path.as_path(), &editor_cmd).await + }) + .await; + match edit_result { + Ok(()) => { + self.refresh_clawbot_management_popup(); + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to open {label}: {err}")); + } + } + tui.frame_requester().schedule_frame(); + } + + fn resolve_editor_command_for_clawbot(&mut self) -> std::result::Result>, ()> { + match external_editor::resolve_editor_commands() { + Ok(cmds) => Ok(cmds), + Err(external_editor::EditorError::MissingEditor) => { + self.chat_widget.add_error_message( + "Cannot open external editor: no usable editor found in $VISUAL, $EDITOR, or `vim`.".to_string(), + ); + Err(()) + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to open editor: {err}")); + Err(()) + } + } + } + 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 { @@ -343,7 +395,7 @@ impl App { return Err(anyhow::anyhow!("Feishu app_id and app_secret are required")); } self.refresh_clawbot_provider_runtime()?; - self.open_clawbot_management_popup(); + self.refresh_clawbot_management_popup(); self.chat_widget.add_info_message( "Restarted Feishu runtime bridge.".to_string(), /*hint*/ None, @@ -360,7 +412,7 @@ impl App { .iter() .filter(|session| session.provider == ProviderKind::Feishu) .count(); - self.open_clawbot_management_popup(); + self.refresh_clawbot_management_popup(); self.chat_widget.add_info_message( format!("Scanned Feishu sessions. {discovered} discovered."), /*hint*/ None, @@ -392,7 +444,7 @@ impl App { }) .count(); runtime.clear_unbound_feishu_sessions()?; - self.open_clawbot_management_popup(); + self.refresh_clawbot_management_popup(); self.chat_widget.add_info_message( format!( "Cleared {sessions_before} unbound Feishu sessions and {unread_before} cached unread messages." @@ -404,8 +456,16 @@ impl App { fn clawbot_management_popup_params( &self, + destination: &ClawbotControlsDestination, initial_selected_idx: Option, ) -> SelectionViewParams { + let store = ClawbotStore::new(self.config.cwd.to_path_buf()); + let bindings_state_path = store.bindings_path(); + let sessions_state_path = store.sessions_path(); + let unread_queue_path = store.unread_messages_path(); + let runtime_log_path = store + .workspace_root() + .join(CLAWBOT_DIAGNOSTICS_RELATIVE_PATH); let (snapshot, clearable_unread_count) = ClawbotRuntime::load(self.config.cwd.to_path_buf()) .map(|runtime| { @@ -434,12 +494,6 @@ impl App { .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 turn_mode = snapshot.config.turn_mode; let next_turn_mode = match turn_mode { ClawbotTurnMode::Interactive => ClawbotTurnMode::NonInteractive, @@ -459,202 +513,390 @@ impl App { .then(session_title(left).cmp(&session_title(right))) .then(left.session_id.cmp(&right.session_id)) }); - let bound_session_count = feishu_sessions + let mut feishu_bindings = snapshot + .bindings .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(), - 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() + .filter(|binding| binding.provider == ProviderKind::Feishu) + .cloned() + .collect::>(); + feishu_bindings.sort_by(|left, right| { + (active_thread_id.as_deref() == Some(left.thread_id.as_str())) + .cmp(&(active_thread_id.as_deref() == Some(right.thread_id.as_str()))) + .reverse() + .then(left.thread_id.cmp(&right.thread_id)) + .then(left.session_id.cmp(&right.session_id)) + }); + let bound_session_count = feishu_bindings.len(); + let unbound_sessions = feishu_sessions + .iter() + .filter(|session| session.bound_thread_id.is_none()) + .cloned() + .collect::>(); + let unbound_session_count = unbound_sessions.len(); + let has_api_credentials = feishu_config.is_some_and(FeishuConfig::has_api_credentials); + + let items = match destination { + ClawbotControlsDestination::Root => vec![ + submenu_item( + "Channels", + format!( + "{bound_session_count} bound · {unbound_session_count} unbound · Feishu" + ), + "Scan Feishu sessions, inspect bindings, and manage channel state." + .to_string(), + ClawbotControlsDestination::Channels, + ), + submenu_item( + "Configuration", + clawbot_config_summary(feishu_config, turn_mode), + "Edit workspace-local Feishu credentials and clawbot turn mode.".to_string(), + ClawbotControlsDestination::Configuration, + ), + submenu_item( + "Diagnostics", + connection_description(&provider_state), + "Retry the bridge and open local clawbot state files.".to_string(), + ClawbotControlsDestination::Diagnostics, + ), + ], + ClawbotControlsDestination::Channels => vec![ + clawbot_back_item(ClawbotControlsDestination::Root, "Return to the clawbot overview."), + SelectionItem { + name: "Scan Feishu Sessions".to_string(), + description: Some( + "Discover Feishu chats and bot groups using the persisted workspace credentials." + .to_string(), + ), + selected_description: Some( + "Refresh the discovered session list before binding or cleanup." + .to_string(), + ), + is_disabled: !has_api_credentials, + actions: vec![Box::new(|tx| tx.send(AppEvent::ScanClawbotFeishuSessions))], + dismiss_on_select: false, + ..Default::default() + }, + submenu_item( + "Bound Channels", + format!("{bound_session_count} active binding(s)"), + "Open a bound channel to jump threads or change forwarding.".to_string(), + ClawbotControlsDestination::BoundChannels, + ), + submenu_item( + "Unbound Sessions", + format!("{unbound_session_count} discovered"), + "Open an unbound session and bind it to the current Codex thread.".to_string(), + ClawbotControlsDestination::UnboundSessions, + ), + submenu_item( + "Cleanup", + format!("{clearable_unread_count} cached unread message(s) can be dropped"), + "Clear stale unbound session cache and unread queue entries.".to_string(), + ClawbotControlsDestination::Cleanup, + ), + ], + ClawbotControlsDestination::BoundChannels => { + let mut items = vec![clawbot_back_item( + ClawbotControlsDestination::Channels, + "Return to channel controls.", + )]; + if feishu_bindings.is_empty() { + items.push(info_item( + "No active bindings".to_string(), + Some( + "Bind a discovered Feishu session to route unread messages into a Codex thread." + .to_string(), + ), + )); + } else { + items.extend(feishu_bindings.iter().map(|binding| { + let thread_label = + ThreadId::from_string(&binding.thread_id).ok().map_or_else( + || binding.thread_id.clone(), + |thread_id| self.thread_label(thread_id), + ); + let session = snapshot + .sessions + .iter() + .find(|session| session.session_ref() == binding.session_ref()); + bound_channel_item( + binding, + thread_label, + session.map_or_else(|| binding.session_id.clone(), session_title), + session.map_or(0, |session| session.unread_count), + ) + })); + } + items + } + ClawbotControlsDestination::BoundChannel { thread_id } => { + let mut items = vec![clawbot_back_item( + ClawbotControlsDestination::BoundChannels, + "Return to bound channels.", + )]; + let binding = feishu_bindings + .iter() + .find(|binding| binding.thread_id == *thread_id); + match binding { + Some(binding) => { + let thread_label = + ThreadId::from_string(&binding.thread_id).ok().map_or_else( + || binding.thread_id.clone(), + |thread_id| self.thread_label(thread_id), + ); + let session = snapshot + .sessions + .iter() + .find(|session| session.session_ref() == binding.session_ref()); + items.extend(bound_channel_detail_items( + binding, + active_thread_id.as_deref(), + thread_label, + session.map_or_else(|| binding.session_id.clone(), session_title), + session.map_or(0, |session| session.unread_count), + )); } - ClawbotTurnMode::NonInteractive => { - "Restore normal interactive prompt handling for clawbot-originated turns.".to_string() + None => items.push(info_item( + "Binding not found".to_string(), + Some("This binding no longer exists.".to_string()), + )), + } + items + } + ClawbotControlsDestination::UnboundSessions => { + let mut items = vec![clawbot_back_item( + ClawbotControlsDestination::Channels, + "Return to channel controls.", + )]; + if unbound_sessions.is_empty() { + items.push(info_item( + "No unbound sessions".to_string(), + Some("No discovered Feishu sessions are waiting to be bound.".to_string()), + )); + } else { + items.extend( + unbound_sessions + .iter() + .map(unbound_session_item), + ); + } + items + } + ClawbotControlsDestination::UnboundSession { session_id } => { + let mut items = vec![clawbot_back_item( + ClawbotControlsDestination::UnboundSessions, + "Return to unbound sessions.", + )]; + match unbound_sessions + .iter() + .find(|session| session.session_id == *session_id) + { + Some(session) => { + items.extend(unbound_session_detail_items( + session, + active_thread_id.as_deref(), + )); } - }), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::ClawbotSetTurnMode { - mode: next_turn_mode, - }); - })], - 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(), + None => items.push(info_item( + "Session not found".to_string(), + Some("This unbound session is no longer available.".to_string()), + )), + } + items + } + ClawbotControlsDestination::Cleanup => vec![ + clawbot_back_item( + ClawbotControlsDestination::Channels, + "Return to channel controls.", ), - is_disabled: true, - ..Default::default() - }, - SelectionItem { - name: "Scan Feishu Sessions".to_string(), - description: Some( - "Discover Feishu chats and bot groups using the persisted workspace credentials." - .to_string(), + info_item( + "Unbound session cache".to_string(), + Some(format!( + "{unbound_session_count} session(s) · {clearable_unread_count} unread message(s)" + )), ), - selected_description: Some( - "Refresh the discovered session list before binding or cleanup." - .to_string(), + SelectionItem { + name: "Clear Unbound Sessions".to_string(), + description: Some( + "Remove unbound sessions and cached unread messages while keeping live bindings." + .to_string(), + ), + 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() + }, + ], + ClawbotControlsDestination::Configuration => vec![ + clawbot_back_item( + ClawbotControlsDestination::Root, + "Return to the clawbot overview.", ), - 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(), + 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), + clawbot_config_item(ClawbotFeishuConfigField::EncryptKey, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::BotOpenId, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::BotUserId, feishu_config), + ], + ClawbotControlsDestination::Diagnostics => vec![ + clawbot_back_item( + ClawbotControlsDestination::Root, + "Return to the clawbot overview.", ), - 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(), + 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: !has_api_credentials, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::RetryClawbotFeishuConnection) + })], + dismiss_on_select: false, + ..Default::default() + }, + clawbot_state_file_item("Open Runtime Log", runtime_log_path, "runtime log"), + clawbot_state_file_item( + "Open Bindings State", + bindings_state_path, + "bindings state file", ), - selected_description: Some( - "Discovered sessions appear here with their current bound or unbound state." - .to_string(), + clawbot_state_file_item( + "Open Sessions State", + sessions_state_path, + "sessions state file", ), - is_disabled: true, - ..Default::default() - }); - } else { - 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([ - 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(), + clawbot_state_file_item( + "Open Unread Queue", + unread_queue_path, + "unread queue file", ), - 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, sessions, and the current thread binding." - .to_string(), - ), + header: Box::new(clawbot_management_header( + bound_session_count, + &provider_state, + turn_mode, + )), footer_hint: Some(standard_popup_hint_line()), items, - initial_selected_idx, + initial_selected_idx: initial_selected_idx.or(Some(0)), ..Default::default() } } } +fn clawbot_management_header( + active_bindings: usize, + provider_state: &ProviderRuntimeState, + turn_mode: ClawbotTurnMode, +) -> ColumnRenderable<'static> { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Clawbot".bold())); + header.push(Line::from(format!("active bindings: {active_bindings}"))); + header.push(Line::from(format!( + "ws status: {}", + connection_description(provider_state) + ))); + header.push(Line::from(format!( + "turn mode: {}", + clawbot_turn_mode_label(turn_mode) + ))); + header +} + +fn submenu_item( + name: &str, + description: String, + selected_description: String, + destination: ClawbotControlsDestination, +) -> SelectionItem { + SelectionItem { + name: name.to_string(), + description: Some(description), + selected_description: Some(selected_description), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenClawbotManagementView { + destination: destination.clone(), + }); + })], + dismiss_on_select: false, + ..Default::default() + } +} + +fn clawbot_back_item( + destination: ClawbotControlsDestination, + selected_description: &str, +) -> SelectionItem { + SelectionItem { + name: "Back".to_string(), + description: Some("Return to the previous clawbot menu.".to_string()), + selected_description: Some(selected_description.to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenClawbotManagementView { + destination: destination.clone(), + }); + })], + dismiss_on_select: false, + ..Default::default() + } +} + +fn info_item(name: String, description: Option) -> SelectionItem { + SelectionItem { + name, + selected_description: description, + is_disabled: true, + ..Default::default() + } +} + +fn clawbot_state_file_item(name: &str, path: PathBuf, label: &'static str) -> SelectionItem { + let display_path = path.display().to_string(); + SelectionItem { + name: name.to_string(), + description: Some(display_path.clone()), + selected_description: Some(format!("Open {display_path} in your external editor.")), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::EditClawbotStateFile { + label, + path: path.clone(), + }); + })], + dismiss_on_select: false, + ..Default::default() + } +} + fn clawbot_config_item( field: ClawbotFeishuConfigField, config: Option<&FeishuConfig>, @@ -690,23 +932,248 @@ fn connection_description(state: &ProviderRuntimeState) -> String { } } +fn clawbot_config_summary(config: Option<&FeishuConfig>, turn_mode: ClawbotTurnMode) -> String { + let configured = [ + ClawbotFeishuConfigField::AppId, + ClawbotFeishuConfigField::AppSecret, + ClawbotFeishuConfigField::VerificationToken, + ClawbotFeishuConfigField::EncryptKey, + ClawbotFeishuConfigField::BotOpenId, + ClawbotFeishuConfigField::BotUserId, + ] + .into_iter() + .filter(|field| field.current_value(config).is_some()) + .count(); + format!( + "{configured}/6 configured · {}", + clawbot_turn_mode_label(turn_mode) + ) +} + +fn bound_channel_item( + binding: &SessionBinding, + thread_label: String, + session_title: String, + unread_count: usize, +) -> SelectionItem { + let thread_id = binding.thread_id.clone(); + SelectionItem { + name: format!("{thread_label} ↔ {session_title}"), + description: Some(format!( + "{} unread · inbound {} · outbound {}", + unread_count, + forwarding_state_label(binding.inbound_forwarding_enabled), + forwarding_state_label(binding.outbound_forwarding_enabled), + )), + selected_description: Some( + "Open this binding to jump threads or change forwarding state.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenClawbotManagementView { + destination: ClawbotControlsDestination::BoundChannel { + thread_id: thread_id.clone(), + }, + }); + })], + dismiss_on_select: false, + ..Default::default() + } +} + +fn bound_channel_detail_items( + binding: &SessionBinding, + active_thread_id: Option<&str>, + thread_label: String, + session_title: String, + unread_count: usize, +) -> Vec { + let jump_target = ThreadId::from_string(&binding.thread_id).ok(); + let toggle_inbound_enabled = !binding.inbound_forwarding_enabled; + let toggle_outbound_enabled = !binding.outbound_forwarding_enabled; + let mut items = vec![ + info_item( + "Thread".to_string(), + Some(format!("{thread_label} ({})", binding.thread_id)), + ), + info_item("Session".to_string(), Some(session_title)), + info_item("Session ID".to_string(), Some(binding.session_id.clone())), + info_item("Unread".to_string(), Some(unread_count.to_string())), + info_item( + "Inbound Forwarding".to_string(), + Some(forwarding_state_label(binding.inbound_forwarding_enabled).to_string()), + ), + info_item( + "Outbound Forwarding".to_string(), + Some(forwarding_state_label(binding.outbound_forwarding_enabled).to_string()), + ), + ]; + let jump_item = SelectionItem { + name: "Jump to thread".to_string(), + description: Some(if active_thread_id == Some(binding.thread_id.as_str()) { + "Already viewing this thread.".to_string() + } else { + "Switch the TUI to this bound thread.".to_string() + }), + selected_description: Some(if active_thread_id == Some(binding.thread_id.as_str()) { + format!( + "Thread {} is already the active Codex thread.", + binding.thread_id + ) + } else { + format!("Jump to thread {}.", binding.thread_id) + }), + is_disabled: active_thread_id == Some(binding.thread_id.as_str()) || jump_target.is_none(), + actions: jump_target + .map(|thread_id| { + vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::SelectAgentThread(thread_id)); + }) as SelectionAction] + }) + .unwrap_or_default(), + dismiss_on_select: false, + ..Default::default() + }; + items.push(jump_item); + if let Some(thread_id) = jump_target { + let inbound_thread_id = thread_id; + let outbound_thread_id = thread_id; + let disconnect_thread_id = thread_id; + items.extend([ + SelectionItem { + name: "Toggle inbound forwarding".to_string(), + description: Some( + ClawbotForwardingChannel::Inbound + .description(binding.inbound_forwarding_enabled), + ), + selected_description: Some( + ClawbotForwardingChannel::Inbound + .selected_description(binding.inbound_forwarding_enabled), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ClawbotSetThreadForwarding { + thread_id: inbound_thread_id, + channel: ClawbotForwardingChannel::Inbound, + enabled: toggle_inbound_enabled, + }); + })], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Toggle outbound forwarding".to_string(), + description: Some( + ClawbotForwardingChannel::Outbound + .description(binding.outbound_forwarding_enabled), + ), + selected_description: Some( + ClawbotForwardingChannel::Outbound + .selected_description(binding.outbound_forwarding_enabled), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ClawbotSetThreadForwarding { + thread_id: outbound_thread_id, + channel: ClawbotForwardingChannel::Outbound, + enabled: toggle_outbound_enabled, + }); + })], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Disconnect binding".to_string(), + description: Some("Unbind this thread from the Feishu session.".to_string()), + selected_description: Some( + "Remove this Feishu session binding without deleting cached session state." + .to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ClawbotDisconnectThread { + thread_id: disconnect_thread_id, + }); + })], + dismiss_on_select: false, + ..Default::default() + }, + ]); + } + items +} + +fn unbound_session_item(session: &ProviderSession) -> SelectionItem { + let session_id = session.session_id.clone(); + SelectionItem { + name: session_title(session), + description: Some(format!("{} unread", session.unread_count)), + selected_description: Some( + "Open this session to inspect it and bind it to the current thread.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenClawbotManagementView { + destination: ClawbotControlsDestination::UnboundSession { + session_id: session_id.clone(), + }, + }); + })], + dismiss_on_select: false, + ..Default::default() + } +} + +fn unbound_session_detail_items( + session: &ProviderSession, + active_thread_id: Option<&str>, +) -> Vec { + let session_id = session.session_id.clone(); + vec![ + info_item("Session".to_string(), Some(session_title(session))), + info_item("Session ID".to_string(), Some(session.session_id.clone())), + info_item("Unread".to_string(), Some(session.unread_count.to_string())), + SelectionItem { + name: "Bind to current thread".to_string(), + description: Some(match active_thread_id { + Some(thread_id) => format!("Target thread {thread_id}"), + None => "No active thread".to_string(), + }), + selected_description: Some(match active_thread_id { + Some(thread_id) => format!( + "Bind current thread {thread_id} directly to Feishu session {}.", + session.session_id + ), + None => format!( + "Open or switch to a Codex thread before binding Feishu session {}.", + session.session_id + ), + }), + is_disabled: active_thread_id.is_none(), + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::SaveClawbotManualBindSessionId { + session_id: session_id.clone(), + }); + })], + dismiss_on_select: false, + ..Default::default() + }, + ] +} + +fn forwarding_state_label(enabled: bool) -> &'static str { + if enabled { "on" } else { "off" } +} + +#[cfg(test)] fn clawbot_session_item( session: &ProviderSession, active_thread_id: Option<&str>, - current_binding_session_id: Option<&str>, + bound_thread_label: Option, ) -> 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) + let description = if let Some(bound_thread_label) = bound_thread_label.as_deref() { + format!( + "bound to {bound_thread_label} · {} unread", + session.unread_count + ) } else { format!("unbound · {} unread", session.unread_count) }; - 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!( @@ -714,22 +1181,10 @@ fn clawbot_session_item( session.session_id ) } - (_, 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 - ) - } (_, Some(bound_thread_id)) => format!( - "Feishu session {} is bound to invalid thread id {bound_thread_id}.", + "Jump to bound thread {bound_thread_id} to continue or manage Feishu session {}.", 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 @@ -740,6 +1195,10 @@ fn clawbot_session_item( ), }; let session_id = session.session_id.clone(); + let jump_target = session + .bound_thread_id + .as_deref() + .and_then(|thread_id| ThreadId::from_string(thread_id).ok()); let actions: Vec = if let Some(thread_id) = jump_target { vec![Box::new(move |tx: &AppEventSender| { tx.send(AppEvent::SelectAgentThread(thread_id)); @@ -751,15 +1210,16 @@ fn clawbot_session_item( }); })] }; - 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, + 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(), + }, actions, dismiss_on_select: false, ..Default::default() @@ -863,7 +1323,7 @@ mod tests { bound_thread_id: Some("019d607a-cf72-72e1-a5b7-0dc17ad019ad".to_string()), }, Some("019d607a-cf72-72e1-a5b7-0dc17ad019ae"), - /*current_binding_session_id*/ None, + Some("Inbox Agent".to_string()), ); assert!(!item.is_disabled); @@ -896,7 +1356,7 @@ mod tests { bound_thread_id: Some("019d607a-cf72-72e1-a5b7-0dc17ad019ad".to_string()), }, Some("019d607a-cf72-72e1-a5b7-0dc17ad019ae"), - /*current_binding_session_id*/ None, + Some("Inbox Agent".to_string()), ); let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); diff --git a/codex-rs/tui/src/app/tests/clawbot_tests.rs b/codex-rs/tui/src/app/tests/clawbot_tests.rs index 9576bcf51..2b1ed36f7 100644 --- a/codex-rs/tui/src/app/tests/clawbot_tests.rs +++ b/codex-rs/tui/src/app/tests/clawbot_tests.rs @@ -1,4 +1,6 @@ use super::*; +use crate::app_event::AppEvent; +use crate::app_event::ClawbotControlsDestination; use crate::app_event::ClawbotForwardingChannel; use codex_clawbot::ClawbotRuntime; use codex_clawbot::ClawbotStore; @@ -87,6 +89,60 @@ async fn clawbot_inbound_message_resumes_bound_thread_and_starts_turn() -> Resul Ok(()) } +#[tokio::test] +async fn clawbot_inbound_message_to_inactive_thread_shows_jump_hint() -> 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(); + + let current_started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("current thread"); + app.active_thread_id = Some(current_started.session.thread_id); + app.primary_thread_id = Some(current_started.session.thread_id); + + let (bound_thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_inactive_hint").await?; + app.upsert_agent_picker_thread( + bound_thread_id, + Some("Inbox Agent".to_string()), + /*agent_role*/ None, + /*is_closed*/ false, + ); + app.active_thread_id = Some(current_started.session.thread_id); + + app.handle_clawbot_provider_event( + &mut app_server, + ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { + session, + message_id: "msg_1".to_string(), + text: "hello from inactive".to_string(), + received_at: 1, + }), + ) + .await + .expect("handle clawbot inbound message"); + + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = cell + .display_lines(/*width*/ 120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(rendered.contains("imported into agent thread Inbox Agent.")); + assert!(rendered.contains("Open /clawbot to inspect bindings and jump.")); + + Ok(()) +} + #[tokio::test] async fn noninteractive_clawbot_request_user_input_builds_auto_response() { let mut app = make_test_app().await; @@ -255,6 +311,13 @@ async fn clawbot_turn_completed_forwards_reply_and_drains_next_message() -> Resu text: "forwarded reply".to_string(), }] ); + assert_eq!( + app.clawbot_removed_outbound_reactions, + vec![ProviderOutboundReaction { + target: ProviderMessageRef::new(ClawbotProviderKind::Feishu, "chat_reply", "msg_1"), + emoji_type: "TONGUE".to_string(), + }] + ); assert_eq!( app.clawbot_pending_turns .get(&thread_id) @@ -292,12 +355,6 @@ async fn clawbot_restart_recovers_pending_turn_and_forwards_reply() -> Result<() .await .expect("handle clawbot inbound"); - let original_turn_id = app - .clawbot_pending_turns - .get(&thread_id) - .and_then(|queue| queue.front()) - .map(|pending| pending.turn_id.clone()) - .expect("pending turn"); let store = ClawbotStore::new(app.config.cwd.to_path_buf()); assert_eq!(store.load_pending_turns().expect("pending turns").len(), 1); @@ -312,13 +369,19 @@ async fn clawbot_restart_recovers_pending_turn_and_forwards_reply() -> Result<() .map(std::collections::VecDeque::len), Some(1) ); + let restored_turn_id = restarted_app + .clawbot_pending_turns + .get(&thread_id) + .and_then(|queue| queue.front()) + .map(|pending| pending.turn_id.clone()) + .expect("restored pending turn"); restarted_app .handle_clawbot_turn_completed( &mut app_server, thread_id, test_turn( - &original_turn_id, + &restored_turn_id, TurnStatus::Completed, vec![ThreadItem::AgentMessage { id: "agent-1".to_string(), @@ -338,6 +401,13 @@ async fn clawbot_restart_recovers_pending_turn_and_forwards_reply() -> Result<() text: "restart reply".to_string(), }] ); + assert_eq!( + restarted_app.clawbot_removed_outbound_reactions, + vec![ProviderOutboundReaction { + target: ProviderMessageRef::new(ClawbotProviderKind::Feishu, "chat_restart", "msg_1"), + emoji_type: "TONGUE".to_string(), + }] + ); let runtime = ClawbotRuntime::load(restarted_app.config.cwd.to_path_buf()) .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; @@ -350,6 +420,54 @@ async fn clawbot_restart_recovers_pending_turn_and_forwards_reply() -> Result<() Ok(()) } +#[tokio::test] +async fn clawbot_bound_thread_completion_forwards_final_reply_without_pending_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_manual").await?; + + app.handle_clawbot_turn_completed( + &mut app_server, + thread_id, + test_turn( + "turn-manual", + TurnStatus::Completed, + vec![ + ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "draft reply".to_string(), + phase: None, + memory_citation: None, + }, + ThreadItem::AgentMessage { + id: "agent-2".to_string(), + text: "final reply".to_string(), + phase: None, + memory_citation: None, + }, + ], + ), + ) + .await + .expect("forward bound thread reply"); + + assert_eq!( + app.clawbot_outbound_messages, + vec![ProviderOutboundTextMessage { + session, + text: "final reply".to_string(), + }] + ); + + Ok(()) +} + #[tokio::test] async fn clawbot_sync_clears_stale_pending_turn_and_redelivers_unread() -> Result<()> { let mut app = make_test_app().await; @@ -530,6 +648,7 @@ async fn clawbot_management_popup_snapshot() -> Result<()> { let (thread_id, _session) = bind_test_clawbot_session(&mut app, &mut app_server, "chat_snapshot").await?; app.active_thread_id = Some(thread_id); + app.primary_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()))?; @@ -564,11 +683,42 @@ async fn clawbot_management_popup_snapshot() -> Result<()> { }, )) .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let second_started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("second thread"); + app.upsert_agent_picker_thread( + second_started.session.thread_id, + Some("Inbox Agent".to_string()), + /*agent_role*/ None, + /*is_closed*/ false, + ); + runtime + .persist_session(ProviderSession { + provider: ClawbotProviderKind::Feishu, + session_id: "chat_ops".to_string(), + display_name: Some("Ops".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 + .connect_session_to_thread( + &ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_ops"), + second_started.session.thread_id.to_string(), + ) + .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); + + app.open_clawbot_management_view(ClawbotControlsDestination::Channels); + let channels_popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert_snapshot!("clawbot_channels_popup", channels_popup); Ok(()) } diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_channels_popup.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_channels_popup.snap new file mode 100644 index 000000000..0a2283ef1 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_channels_popup.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/app/tests/clawbot_tests.rs +expression: channels_popup +--- + Clawbot + active bindings: 2 + ws status: Unconfigured + turn mode: interactive + +› 1. Back Return to the clawbot overview. + 2. Scan Feishu Sessions Discover Feishu chats and bot groups using the persisted workspace + credentials. + 3. Bound Channels 2 active binding(s) + 4. Unbound Sessions 1 discovered + 5. Cleanup 1 cached unread message(s) can be dropped + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap index 5109eb0bf..b51145d48 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap @@ -1,20 +1,14 @@ --- source: tui/src/app/tests/clawbot_tests.rs -assertion_line: 413 expression: popup --- Clawbot - Manage workspace-local Feishu credentials, sessions, and the current thread binding. + active bindings: 2 + ws status: Unconfigured + turn mode: interactive -› 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 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 - 6. Bob unbound · 1 unread - 7. Feishu App ID Configured: cli_app_123 - 8. Feishu App Secret Configured: *************4567 +› 1. Channels Scan Feishu sessions, inspect bindings, and manage channel state. + 2. Configuration 4/6 configured · interactive + 3. Diagnostics Unconfigured Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index c13092b2c..426645339 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -93,6 +93,24 @@ pub(crate) enum ClawbotForwardingChannel { Outbound, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) enum ClawbotControlsDestination { + #[default] + Root, + Channels, + BoundChannels, + BoundChannel { + thread_id: String, + }, + UnboundSessions, + UnboundSession { + session_id: String, + }, + Cleanup, + Configuration, + Diagnostics, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxEnableMode { @@ -515,6 +533,10 @@ pub(crate) enum AppEvent { OpenClawbotManagement, + OpenClawbotManagementView { + destination: ClawbotControlsDestination, + }, + OpenClawbotFeishuConfigPrompt { field: ClawbotFeishuConfigField, }, @@ -524,8 +546,6 @@ pub(crate) enum AppEvent { value: String, }, - OpenClawbotManualBindPrompt, - SaveClawbotManualBindSessionId { session_id: String, }, @@ -534,9 +554,8 @@ pub(crate) enum AppEvent { mode: ClawbotTurnMode, }, - ClawbotDisconnectCurrentThread, - - ClawbotSetCurrentThreadForwarding { + ClawbotSetThreadForwarding { + thread_id: ThreadId, channel: ClawbotForwardingChannel, enabled: bool, }, @@ -546,6 +565,15 @@ pub(crate) enum AppEvent { ClearClawbotFeishuSessions, RetryClawbotFeishuConnection, + + ClawbotDisconnectThread { + thread_id: ThreadId, + }, + + EditClawbotStateFile { + label: &'static str, + path: PathBuf, + }, /// Apply rollback semantics to local transcript cells. /// /// This is emitted when rollback was not initiated by the current From fcdcd4b058afa9c3b16b3ea09a0dde626e1c65aa Mon Sep 17 00:00:00 2001 From: piping Date: Fri, 10 Apr 2026 12:21:21 +0800 Subject: [PATCH 68/83] fix feishu reaction cancellation --- codex-rs/clawbot/src/model.rs | 1 + codex-rs/clawbot/src/provider/feishu.rs | 66 ++++++++++++++++++--- codex-rs/tui/src/app/clawbot.rs | 57 +++++++++++------- codex-rs/tui/src/app/tests/clawbot_tests.rs | 28 +++++++++ 4 files changed, 121 insertions(+), 31 deletions(-) diff --git a/codex-rs/clawbot/src/model.rs b/codex-rs/clawbot/src/model.rs index a21e0ff18..9535ac94e 100644 --- a/codex-rs/clawbot/src/model.rs +++ b/codex-rs/clawbot/src/model.rs @@ -182,6 +182,7 @@ pub struct PendingClawbotTurn { pub turn_id: String, pub session: ProviderSessionRef, pub message_id: String, + pub auto_ack_reaction_id: Option, pub turn_mode: ClawbotTurnMode, } diff --git a/codex-rs/clawbot/src/provider/feishu.rs b/codex-rs/clawbot/src/provider/feishu.rs index 704431ca7..f0beb70a0 100644 --- a/codex-rs/clawbot/src/provider/feishu.rs +++ b/codex-rs/clawbot/src/provider/feishu.rs @@ -15,7 +15,6 @@ use open_lark::openlark_communication::im::im::v1::message::create::CreateMessag 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::models::UserIdType; -use open_lark::openlark_communication::im::im::v1::message::reaction::delete::DeleteMessageReactionRequest; use open_lark::openlark_communication::im::im::v1::message::reaction::list::ListMessageReactionsRequest; use open_lark::openlark_communication::im::im::v1::message::reaction::models::CreateMessageReactionBody; use open_lark::openlark_communication::im::im::v1::message::reaction::models::MessageReaction; @@ -116,7 +115,7 @@ impl FeishuProviderRuntime { } } - pub async fn add_reaction(&self, reaction: ProviderOutboundReaction) -> Result<()> { + pub async fn add_reaction(&self, reaction: ProviderOutboundReaction) -> Result> { if reaction.target.provider != ProviderKind::Feishu { return Err(anyhow!( "cannot send {} reaction via Feishu runtime", @@ -147,6 +146,12 @@ impl FeishuProviderRuntime { .await .map_err(|error| anyhow!("failed to add Feishu message reaction: {error}"))?; if response.is_success() { + let reaction_id = response + .data + .as_ref() + .and_then(|data| data.get("reaction_id")) + .and_then(Value::as_str) + .map(str::to_owned); let _ = append_diagnostic_event( self.workspace_root.as_path(), "feishu.add_reaction_succeeded", @@ -154,9 +159,10 @@ impl FeishuProviderRuntime { "session_id": session_id, "message_id": message_id, "emoji_type": emoji_type, + "reaction_id": reaction_id, }), ); - Ok(()) + Ok(reaction_id) } else { let error = anyhow!("failed to add Feishu message reaction: {}", response.msg()); let _ = append_diagnostic_event( @@ -217,12 +223,8 @@ impl FeishuProviderRuntime { } for reaction_id in &reaction_ids { - DeleteMessageReactionRequest::new(config.clone()) - .message_id(reaction.target.message_id.clone()) - .reaction_id(reaction_id.clone()) - .execute() - .await - .map_err(|error| anyhow!("failed to remove Feishu message reaction: {error}"))?; + self.remove_reaction_by_id(reaction.clone(), reaction_id) + .await?; } let _ = append_diagnostic_event( @@ -233,6 +235,52 @@ impl FeishuProviderRuntime { "message_id": reaction.target.message_id, "emoji_type": reaction.emoji_type, "removed_count": reaction_ids.len(), + "remove_mode": "listed", + }), + ); + Ok(()) + } + + pub async fn remove_reaction_by_id( + &self, + reaction: ProviderOutboundReaction, + reaction_id: &str, + ) -> Result<()> { + if reaction.target.provider != ProviderKind::Feishu { + return Err(anyhow!( + "cannot send {} reaction via Feishu runtime", + reaction.target.provider.title() + )); + } + + let request: ApiRequest = ApiRequest::delete(format!( + "{}/{}/reactions/{}", + IM_V1_MESSAGES, reaction.target.message_id, reaction_id + )); + let response = open_lark::openlark_core::http::Transport::::request( + request, + &self.messaging_config()?, + Some(Default::default()), + ) + .await + .map_err(|error| anyhow!("failed to remove Feishu message reaction: {error}"))?; + if !response.is_success() { + return Err(anyhow!( + "failed to remove Feishu message reaction: {}", + response.msg() + )); + } + + let _ = append_diagnostic_event( + self.workspace_root.as_path(), + "feishu.remove_reaction_succeeded", + serde_json::json!({ + "session_id": reaction.target.session_id, + "message_id": reaction.target.message_id, + "emoji_type": reaction.emoji_type, + "removed_count": 1, + "reaction_id": reaction_id, + "remove_mode": "direct", }), ); Ok(()) diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs index 17db53dc0..d705bd535 100644 --- a/codex-rs/tui/src/app/clawbot.rs +++ b/codex-rs/tui/src/app/clawbot.rs @@ -308,6 +308,7 @@ impl App { pending.session.session_id.clone(), pending.message_id.clone(), ), + pending.auto_ack_reaction_id.clone(), ) .await { @@ -469,7 +470,7 @@ impl App { Some("Open /clawbot to inspect bindings and jump.".to_string()), ); } - if let Err(err) = self + let auto_ack_reaction_id = match self .send_clawbot_outbound_reaction(ProviderOutboundReaction { target: ProviderMessageRef::new( message.provider, @@ -480,24 +481,28 @@ impl App { }) .await { - tracing::warn!( - thread_id = %thread_id, - session = session.session_id, - error = %err, - "failed to auto-ack clawbot inbound message" - ); - let _ = append_diagnostic_event( - workspace_root.as_path(), - "bridge.auto_ack_failed", - serde_json::json!({ - "session_id": session.session_id, - "thread_id": thread_id, - "message_id": message.message_id, - "emoji_type": FEISHU_AUTO_ACK_EMOJI_TYPE, - "error": err.to_string(), - }), - ); - } + Ok(reaction_id) => reaction_id, + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + session = session.session_id, + error = %err, + "failed to auto-ack clawbot inbound message" + ); + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.auto_ack_failed", + serde_json::json!({ + "session_id": session.session_id, + "thread_id": thread_id, + "message_id": message.message_id, + "emoji_type": FEISHU_AUTO_ACK_EMOJI_TYPE, + "error": err.to_string(), + }), + ); + None + } + }; let turn_id = self .submit_clawbot_message_to_thread( app_server, @@ -522,6 +527,7 @@ impl App { session.clone(), turn_id, message.message_id.clone(), + auto_ack_reaction_id, turn_mode, ); Ok(()) @@ -721,11 +727,11 @@ impl App { async fn send_clawbot_outbound_reaction( &mut self, reaction: ProviderOutboundReaction, - ) -> Result<()> { + ) -> Result> { #[cfg(test)] { self.clawbot_outbound_reactions.push(reaction); - Ok(()) + Ok(None) } #[cfg(not(test))] @@ -746,6 +752,7 @@ impl App { &mut self, workspace_root: &Path, target: ProviderMessageRef, + _reaction_id: Option, ) -> Result<()> { let reaction = ProviderOutboundReaction { target, @@ -764,7 +771,11 @@ impl App { let provider = runtime .feishu_provider() .context("missing Feishu config for clawbot reaction cleanup")?; - provider.remove_reaction(reaction).await + if let Some(reaction_id) = _reaction_id { + provider.remove_reaction_by_id(reaction, &reaction_id).await + } else { + provider.remove_reaction(reaction).await + } } } @@ -774,6 +785,7 @@ impl App { session: ProviderSessionRef, turn_id: String, message_id: String, + auto_ack_reaction_id: Option, turn_mode: ClawbotTurnMode, ) { let pending_turn = PendingClawbotTurn { @@ -781,6 +793,7 @@ impl App { turn_id, session, message_id, + auto_ack_reaction_id, turn_mode, }; self.clawbot_pending_turns diff --git a/codex-rs/tui/src/app/tests/clawbot_tests.rs b/codex-rs/tui/src/app/tests/clawbot_tests.rs index 2b1ed36f7..bfc822e66 100644 --- a/codex-rs/tui/src/app/tests/clawbot_tests.rs +++ b/codex-rs/tui/src/app/tests/clawbot_tests.rs @@ -154,6 +154,7 @@ async fn noninteractive_clawbot_request_user_input_builds_auto_response() { turn_id: "turn-1".to_string(), session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), message_id: "msg-1".to_string(), + auto_ack_reaction_id: None, turn_mode: ClawbotTurnMode::NonInteractive, }]), ); @@ -196,6 +197,7 @@ async fn noninteractive_clawbot_permissions_request_builds_auto_response() { turn_id: "turn-1".to_string(), session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), message_id: "msg-1".to_string(), + auto_ack_reaction_id: None, turn_mode: ClawbotTurnMode::NonInteractive, }]), ); @@ -420,6 +422,31 @@ async fn clawbot_restart_recovers_pending_turn_and_forwards_reply() -> Result<() Ok(()) } +#[test] +fn clawbot_store_persists_auto_ack_reaction_id() -> Result<()> { + let tempdir = tempdir()?; + let store = ClawbotStore::new(tempdir.path().to_path_buf()); + let pending_turn = PendingClawbotTurn { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_store"), + message_id: "msg-1".to_string(), + auto_ack_reaction_id: Some("reaction-1".to_string()), + turn_mode: ClawbotTurnMode::NonInteractive, + }; + + store + .upsert_pending_turn(pending_turn.clone()) + .expect("persist pending turn"); + + assert_eq!( + store.load_pending_turns().expect("pending turns"), + vec![pending_turn] + ); + + Ok(()) +} + #[tokio::test] async fn clawbot_bound_thread_completion_forwards_final_reply_without_pending_turn() -> Result<()> { let mut app = make_test_app().await; @@ -500,6 +527,7 @@ async fn clawbot_sync_clears_stale_pending_turn_and_redelivers_unread() -> Resul turn_id: "stale-turn".to_string(), session: session.clone(), message_id: "msg_1".to_string(), + auto_ack_reaction_id: None, turn_mode: ClawbotTurnMode::NonInteractive, }) .expect("persist stale pending turn"); From 428334f993cc8f1e8718ba95d01aaaddcb2260c2 Mon Sep 17 00:00:00 2001 From: piping Date: Fri, 10 Apr 2026 12:34:59 +0800 Subject: [PATCH 69/83] fix(clawbot): restart feishu runtime after respawn --- codex-rs/tui/src/app/clawbot.rs | 68 ++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs index d705bd535..d636ab9c0 100644 --- a/codex-rs/tui/src/app/clawbot.rs +++ b/codex-rs/tui/src/app/clawbot.rs @@ -53,13 +53,44 @@ impl App { ) -> Result<()> { let workspace_root = self.config.cwd.to_path_buf(); let workspace_changed = self.clawbot_workspace_root.as_ref() != Some(&workspace_root); + let provider_task_missing_or_finished = self + .clawbot_provider_task + .as_ref() + .is_none_or(tokio::task::JoinHandle::is_finished); if workspace_changed { self.abort_clawbot_provider_runtime(); self.clawbot_workspace_root = Some(workspace_root.clone()); self.clawbot_pending_turns.clear(); + } + self.clawbot_workspace_root = Some(workspace_root.clone()); + let mut runtime = ClawbotRuntime::load(workspace_root.clone())?; + let should_refresh_provider_runtime = should_refresh_clawbot_provider_runtime( + workspace_changed, + runtime + .snapshot() + .config + .feishu + .as_ref() + .is_some_and(FeishuConfig::has_api_credentials), + provider_task_missing_or_finished, + ); + if should_refresh_provider_runtime { + let reason = if workspace_changed { + "workspace_changed" + } else if self.clawbot_provider_task.is_none() { + "task_missing" + } else { + "task_finished" + }; + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.provider_runtime_refresh_requested", + serde_json::json!({ + "reason": reason, + }), + ); self.refresh_clawbot_provider_runtime()?; - if let Ok(mut runtime) = ClawbotRuntime::load(workspace_root.clone()) - && runtime.snapshot().config.feishu.is_some() + if runtime.snapshot().config.feishu.is_some() && let Err(err) = runtime.scan_feishu_sessions().await { tracing::warn!(error = %err, "failed to refresh clawbot Feishu sessions"); @@ -989,6 +1020,14 @@ fn session_title(session: &codex_clawbot::ProviderSession) -> String { .unwrap_or_else(|| session.session_id.clone()) } +fn should_refresh_clawbot_provider_runtime( + workspace_changed: bool, + has_feishu_credentials: bool, + provider_task_missing_or_finished: bool, +) -> bool { + workspace_changed || (has_feishu_credentials && provider_task_missing_or_finished) +} + #[cfg(test)] mod tests { use codex_app_server_protocol::ThreadItem; @@ -997,6 +1036,7 @@ mod tests { use pretty_assertions::assert_eq; use super::clawbot_outbound_text_for_turn; + use super::should_refresh_clawbot_provider_runtime; #[test] fn completed_turn_uses_last_agent_message_for_reply() { @@ -1059,4 +1099,28 @@ mod tests { Some("last reply".to_string()) ); } + + #[test] + fn provider_runtime_refreshes_on_workspace_change() { + assert!(should_refresh_clawbot_provider_runtime( + /*workspace_changed*/ true, /*has_feishu_credentials*/ false, + /*provider_task_missing_or_finished*/ false, + )); + } + + #[test] + fn provider_runtime_refreshes_when_configured_task_is_missing() { + assert!(should_refresh_clawbot_provider_runtime( + /*workspace_changed*/ false, /*has_feishu_credentials*/ true, + /*provider_task_missing_or_finished*/ true, + )); + } + + #[test] + fn provider_runtime_does_not_refresh_for_unconfigured_missing_task() { + assert!(!should_refresh_clawbot_provider_runtime( + /*workspace_changed*/ false, /*has_feishu_credentials*/ false, + /*provider_task_missing_or_finished*/ true, + )); + } } From 9496282058efdea598d8a682ae0e080836a0b4be Mon Sep 17 00:00:00 2001 From: piping Date: Fri, 10 Apr 2026 12:57:54 +0800 Subject: [PATCH 70/83] python: release codex-enhanced v0.1.26 Release codex-enhanced v0.1.26.\n\n- fix Feishu reaction cleanup by deleting the recorded reaction id directly\n- restart the Feishu runtime automatically after Codex respawn when the provider task is missing or finished --- 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 b631d6e01..285484697 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.25" +version = "0.1.26" description = "Enhanced Codex CLI with loop automation, Feishu clawbot support, and fast session respawn" readme = "README.md" requires-python = ">=3" From ef67da26a93d9cc491b3f5819ad708ef156de190 Mon Sep 17 00:00:00 2001 From: piping Date: Sat, 11 Apr 2026 17:24:13 +0800 Subject: [PATCH 71/83] refactor features router into standalone files --- codex-rs/tui/src/app.rs | 381 +------------------- codex-rs/tui/src/app/btw.rs | 12 +- codex-rs/tui/src/app/clawbot_controller.rs | 105 ++++++ codex-rs/tui/src/app/clawbot_controls.rs | 76 ++-- codex-rs/tui/src/app/editor_helpers.rs | 89 +++++ codex-rs/tui/src/app/feature_dispatch.rs | 132 +++++++ codex-rs/tui/src/app/popup_helpers.rs | 41 +++ codex-rs/tui/src/app/profile_controller.rs | 71 ++++ codex-rs/tui/src/app/profile_management.rs | 121 ++----- codex-rs/tui/src/app/thread_controller.rs | 104 ++++++ codex-rs/tui/src/app/thread_menu.rs | 33 +- codex-rs/tui/src/app/workflow_controller.rs | 145 ++++++++ codex-rs/tui/src/app/workflow_controls.rs | 274 +++++--------- 13 files changed, 872 insertions(+), 712 deletions(-) create mode 100644 codex-rs/tui/src/app/clawbot_controller.rs create mode 100644 codex-rs/tui/src/app/editor_helpers.rs create mode 100644 codex-rs/tui/src/app/feature_dispatch.rs create mode 100644 codex-rs/tui/src/app/popup_helpers.rs create mode 100644 codex-rs/tui/src/app/profile_controller.rs create mode 100644 codex-rs/tui/src/app/thread_controller.rs create mode 100644 codex-rs/tui/src/app/workflow_controller.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index eecb4718e..e21f22d23 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -7,7 +7,6 @@ use crate::app_event::ExitMode; use crate::app_event::FeedbackCategory; use crate::app_event::RateLimitRefreshOrigin; 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; @@ -61,7 +60,6 @@ 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::PROFILE_ROUTER_STATE_RELATIVE_PATH; use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -183,13 +181,20 @@ mod app_server_adapter; pub(crate) mod app_server_requests; mod btw; mod clawbot; +mod clawbot_controller; mod clawbot_controls; +mod editor_helpers; +mod feature_dispatch; mod jump_navigation; mod key_chord; mod loaded_threads; mod pending_interactive_replay; +mod popup_helpers; +mod profile_controller; mod profile_management; +mod thread_controller; mod thread_menu; +mod workflow_controller; mod workflow_controls; mod workflow_definition; mod workflow_editor; @@ -4838,6 +4843,10 @@ impl App { app_server: &mut AppServerSession, event: AppEvent, ) -> Result { + let Some(event) = self.dispatch_feature_event(tui, app_server, event).await else { + return Ok(AppRunControl::Continue); + }; + match event { AppEvent::NewSession => { self.start_fresh_session_with_summary_hint( @@ -4930,375 +4939,10 @@ impl App { AppEvent::OpenDisplayPreferencesPanel => { self.open_display_preferences_panel(); } - AppEvent::OpenProfileManagementPanel => { - self.open_profile_management_panel(); - } - AppEvent::EditProfileFallbackConfig => { - self.edit_profile_fallback_config_from_ui(tui).await; - } - 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", - /*inc*/ 1, - &[("source", "slash_command")], - ); - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.thread_id(), - self.chat_widget.thread_name(), - self.chat_widget.rollout_path().as_deref(), - ); - self.chat_widget - .add_plain_history_lines(vec!["/fork".magenta().into()]); - if let Some(thread_id) = self.chat_widget.thread_id() { - self.refresh_in_memory_config_from_disk_best_effort("forking the thread") - .await; - match app_server.fork_thread(self.config.clone(), thread_id).await { - Ok(forked) => { - self.shutdown_current_thread(app_server).await; - match self - .replace_chat_widget_with_app_server_thread(tui, app_server, forked) - .await - { - Ok(()) => { - if let Some(summary) = summary { - let mut lines: Vec> = Vec::new(); - if let Some(usage_line) = summary.usage_line { - lines.push(usage_line.into()); - } - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); - } - } - Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to attach to forked app-server thread: {err}" - )); - } - } - } - Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to fork current session through the app server: {err}" - )); - } - } - } else { - self.chat_widget.add_error_message( - "A thread must contain at least one turn before it can be forked." - .to_string(), - ); - } - - tui.frame_requester().schedule_frame(); - } - 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); } - 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) - .await; - for cell in cells { - self.insert_visible_history_cell(tui, cell); - } - } - 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, - } => { - self.retry_last_user_turn_with_profile_fallback( - tui, - app_server, - action, - error_message, - ) - .await; - } - 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::ToggleWorkflowTriggerEnabled { - workflow_path, - trigger_id, - } => { - self.toggle_workflow_trigger_enabled_from_ui(workflow_path, trigger_id); - } - 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::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::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, - } => { - 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::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::OpenClawbotManagement => { - self.open_clawbot_management_popup(); - } - AppEvent::OpenClawbotManagementView { destination } => { - self.open_clawbot_management_view(destination); - } - 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::SaveClawbotManualBindSessionId { session_id } => { - if let Err(err) = self - .bind_clawbot_session_to_current_thread(app_server, session_id) - .await - { - self.chat_widget - .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::ClawbotSetThreadForwarding { - thread_id, - channel, - enabled, - } => { - if let Err(err) = self.clawbot_set_thread_forwarding(thread_id, channel, enabled) { - self.chat_widget - .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!( - "Failed to restart Clawbot Feishu runtime: {err}" - )); - } - } - AppEvent::ClawbotDisconnectThread { thread_id } => { - if let Err(err) = self.clawbot_disconnect_thread(thread_id) { - self.chat_widget - .add_error_message(format!("Failed to disconnect Clawbot binding: {err}")); - } - } - AppEvent::EditClawbotStateFile { label, path } => { - self.edit_clawbot_state_file_from_ui(tui, label, path).await; - } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { tui.frame_requester().schedule_frame(); @@ -6660,6 +6304,9 @@ impl App { } } } + _ => { + unreachable!("feature event should be routed before App::handle_event main match"); + } } Ok(AppRunControl::Continue) } diff --git a/codex-rs/tui/src/app/btw.rs b/codex-rs/tui/src/app/btw.rs index b962e8045..decd3c6f0 100644 --- a/codex-rs/tui/src/app/btw.rs +++ b/codex-rs/tui/src/app/btw.rs @@ -336,17 +336,7 @@ impl App { 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()); - } + self.open_selection_popup_for_view(BTW_DISCUSSION_VIEW_ID, |_app, _| build()); } fn btw_thread_cwd(&self, app_server: &AppServerSession) -> Option { diff --git a/codex-rs/tui/src/app/clawbot_controller.rs b/codex-rs/tui/src/app/clawbot_controller.rs new file mode 100644 index 000000000..fcec0411f --- /dev/null +++ b/codex-rs/tui/src/app/clawbot_controller.rs @@ -0,0 +1,105 @@ +use super::App; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::tui; + +pub(super) struct ClawbotController; + +impl ClawbotController { + pub(super) async fn handle( + app: &mut App, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + event: AppEvent, + ) { + match event { + AppEvent::ClawbotProviderEvent { event } => { + if let Err(err) = app.handle_clawbot_provider_event(app_server, event).await { + tracing::warn!(error = %err, "failed to handle clawbot provider event"); + app.chat_widget + .add_error_message(format!("Clawbot provider event failed: {err}")); + } + } + AppEvent::ClawbotTurnCompleted { thread_id, turn } => { + if let Err(err) = app + .handle_clawbot_turn_completed(app_server, thread_id, turn) + .await + { + tracing::warn!(error = %err, "failed to handle clawbot turn completion"); + app.chat_widget + .add_error_message(format!("Clawbot turn forwarding failed: {err}")); + } + } + AppEvent::OpenClawbotManagement => { + app.open_clawbot_management_popup(); + } + AppEvent::OpenClawbotManagementView { destination } => { + app.open_clawbot_management_view(destination); + } + AppEvent::OpenClawbotFeishuConfigPrompt { field } => { + app.open_clawbot_feishu_config_prompt(field); + } + AppEvent::SaveClawbotFeishuConfigValue { field, value } => { + if let Err(err) = app.save_clawbot_feishu_config_value(field, value) { + app.chat_widget + .add_error_message(format!("Failed to save Clawbot config: {err}")); + } + } + AppEvent::SaveClawbotManualBindSessionId { session_id } => { + if let Err(err) = app + .bind_clawbot_session_to_current_thread(app_server, session_id) + .await + { + app.chat_widget + .add_error_message(format!("Failed to bind Clawbot session: {err}")); + } + } + AppEvent::ClawbotSetTurnMode { mode } => { + if let Err(err) = app.save_clawbot_turn_mode(mode) { + app.chat_widget + .add_error_message(format!("Failed to save Clawbot turn mode: {err}")); + } + } + AppEvent::ClawbotSetThreadForwarding { + thread_id, + channel, + enabled, + } => { + if let Err(err) = app.clawbot_set_thread_forwarding(thread_id, channel, enabled) { + app.chat_widget + .add_error_message(format!("Failed to update Clawbot forwarding: {err}")); + } + } + AppEvent::ScanClawbotFeishuSessions => { + if let Err(err) = app.scan_clawbot_feishu_sessions().await { + app.chat_widget + .add_error_message(format!("Failed to scan Feishu sessions: {err}")); + } + } + AppEvent::ClearClawbotFeishuSessions => { + if let Err(err) = app.clear_clawbot_feishu_sessions() { + app.chat_widget.add_error_message(format!( + "Failed to clear unbound Feishu sessions: {err}" + )); + } + } + AppEvent::RetryClawbotFeishuConnection => { + if let Err(err) = app.retry_clawbot_feishu_connection() { + app.chat_widget.add_error_message(format!( + "Failed to restart Clawbot Feishu runtime: {err}" + )); + } + } + AppEvent::ClawbotDisconnectThread { thread_id } => { + if let Err(err) = app.clawbot_disconnect_thread(thread_id) { + app.chat_widget + .add_error_message(format!("Failed to disconnect Clawbot binding: {err}")); + } + } + AppEvent::EditClawbotStateFile { label, path } => { + app.edit_clawbot_state_file_from_ui(tui, label, path).await; + } + _ => unreachable!("non-clawbot event passed to clawbot controller"), + } + } +} diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index 5c3979f26..54b733650 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -21,6 +21,7 @@ use ratatui::style::Stylize; use ratatui::text::Line; use super::App; +use super::editor_helpers::ExternalEditorErrorTarget; use crate::app_event::AppEvent; use crate::app_event::ClawbotControlsDestination; use crate::app_event::ClawbotFeishuConfigField; @@ -32,7 +33,6 @@ 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; -use crate::external_editor; use crate::render::renderable::ColumnRenderable; use crate::tui; @@ -144,24 +144,20 @@ impl App { } pub(crate) fn open_clawbot_management_view(&mut self, destination: ClawbotControlsDestination) { - let active_selected_idx = self - .chat_widget - .selected_index_for_active_view(CLAWBOT_MANAGEMENT_VIEW_ID); - let initial_selected_idx = - if active_selected_idx.is_some() && self.clawbot_controls_destination == destination { - active_selected_idx - } else { - Some(0) - }; self.clawbot_controls_destination = destination.clone(); - let params = self.clawbot_management_popup_params(&destination, initial_selected_idx); - if active_selected_idx.is_some() { - let _ = self - .chat_widget - .replace_selection_view_if_active(CLAWBOT_MANAGEMENT_VIEW_ID, params); - } else { - self.chat_widget.show_selection_view(params); - } + self.open_selection_popup_for_view( + CLAWBOT_MANAGEMENT_VIEW_ID, + |app, active_selected_idx| { + let initial_selected_idx = if active_selected_idx.is_some() + && app.clawbot_controls_destination == destination + { + active_selected_idx + } else { + Some(0) + }; + app.clawbot_management_popup_params(&destination, initial_selected_idx) + }, + ); } fn refresh_clawbot_management_popup(&mut self) { @@ -337,9 +333,6 @@ impl App { label: &'static str, path: PathBuf, ) { - let Ok(editor_cmd) = self.resolve_editor_command_for_clawbot() else { - return; - }; if let Some(parent) = path.parent() && let Err(err) = fs::create_dir_all(parent) { @@ -352,37 +345,16 @@ impl App { .add_error_message(format!("Failed to prepare {label}: {err}")); return; } - let edit_result = tui - .with_restored(tui::RestoreMode::KeepRaw, || async { - external_editor::edit_file(path.as_path(), &editor_cmd).await - }) - .await; - match edit_result { - Ok(()) => { - self.refresh_clawbot_management_popup(); - } - Err(err) => { - self.chat_widget - .add_error_message(format!("Failed to open {label}: {err}")); - } - } - tui.frame_requester().schedule_frame(); - } - - fn resolve_editor_command_for_clawbot(&mut self) -> std::result::Result>, ()> { - match external_editor::resolve_editor_commands() { - Ok(cmds) => Ok(cmds), - Err(external_editor::EditorError::MissingEditor) => { - self.chat_widget.add_error_message( - "Cannot open external editor: no usable editor found in $VISUAL, $EDITOR, or `vim`.".to_string(), - ); - Err(()) - } - Err(err) => { - self.chat_widget - .add_error_message(format!("Failed to open editor: {err}")); - Err(()) - } + if self + .edit_file_with_external_editor( + tui, + ExternalEditorErrorTarget::ErrorMessage, + path.as_path(), + ) + .await + .is_ok() + { + self.refresh_clawbot_management_popup(); } } diff --git a/codex-rs/tui/src/app/editor_helpers.rs b/codex-rs/tui/src/app/editor_helpers.rs new file mode 100644 index 000000000..d22637f89 --- /dev/null +++ b/codex-rs/tui/src/app/editor_helpers.rs @@ -0,0 +1,89 @@ +use std::path::Path; + +use super::App; +use crate::external_editor; +use crate::history_cell; +use crate::tui; + +#[derive(Clone, Copy)] +pub(super) enum ExternalEditorErrorTarget { + History, + ErrorMessage, +} + +impl App { + fn report_external_editor_error(&mut self, target: ExternalEditorErrorTarget, message: String) { + match target { + ExternalEditorErrorTarget::History => self + .chat_widget + .add_to_history(history_cell::new_error_event(message)), + ExternalEditorErrorTarget::ErrorMessage => self.chat_widget.add_error_message(message), + } + } + + fn resolve_external_editor_commands( + &mut self, + target: ExternalEditorErrorTarget, + ) -> Result>, ()> { + match external_editor::resolve_editor_commands() { + Ok(cmds) => Ok(cmds), + Err(external_editor::EditorError::MissingEditor) => { + self.report_external_editor_error( + target, + "Cannot open external editor: no usable editor found in $VISUAL, $EDITOR, or `vim`." + .to_string(), + ); + Err(()) + } + Err(err) => { + self.report_external_editor_error(target, format!("Failed to open editor: {err}")); + Err(()) + } + } + } + + pub(super) async fn edit_file_with_external_editor( + &mut self, + tui: &mut tui::Tui, + target: ExternalEditorErrorTarget, + path: &Path, + ) -> Result<(), ()> { + let editor_cmd = self.resolve_external_editor_commands(target)?; + let edit_result = tui + .with_restored(tui::RestoreMode::KeepRaw, || async { + external_editor::edit_file(path, &editor_cmd).await + }) + .await; + tui.frame_requester().schedule_frame(); + match edit_result { + Ok(()) => Ok(()), + Err(err) => { + self.report_external_editor_error(target, format!("Failed to open editor: {err}")); + Err(()) + } + } + } + + pub(super) async fn edit_seed_with_external_editor( + &mut self, + tui: &mut tui::Tui, + target: ExternalEditorErrorTarget, + seed: &str, + suffix: &str, + ) -> Result { + let editor_cmd = self.resolve_external_editor_commands(target)?; + let edit_result = tui + .with_restored(tui::RestoreMode::KeepRaw, || async { + external_editor::run_editor_with_suffix(seed, &editor_cmd, suffix).await + }) + .await; + tui.frame_requester().schedule_frame(); + match edit_result { + Ok(contents) => Ok(contents), + Err(err) => { + self.report_external_editor_error(target, format!("Failed to open editor: {err}")); + Err(()) + } + } + } +} diff --git a/codex-rs/tui/src/app/feature_dispatch.rs b/codex-rs/tui/src/app/feature_dispatch.rs new file mode 100644 index 000000000..1c226468e --- /dev/null +++ b/codex-rs/tui/src/app/feature_dispatch.rs @@ -0,0 +1,132 @@ +use super::App; +use super::clawbot_controller::ClawbotController; +use super::profile_controller::ProfileController; +use super::thread_controller::ThreadController; +use super::workflow_controller::WorkflowController; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::tui; + +#[derive(Clone, Copy)] +enum FeatureRoute { + Profile, + Thread, + Btw, + Workflow, + Clawbot, +} + +impl FeatureRoute { + fn for_event(event: &AppEvent) -> Option { + match event { + AppEvent::OpenProfileManagementPanel + | AppEvent::EditProfileFallbackConfig + | AppEvent::SwitchRuntimeProfile { .. } + | AppEvent::RetryLastUserTurnWithProfileFallback { .. } => Some(Self::Profile), + AppEvent::OpenThreadPanel + | AppEvent::OpenJumpToMessagePanel + | AppEvent::JumpToTranscriptCell { .. } + | AppEvent::ForkCurrentSession + | AppEvent::UndoLastUserMessage => Some(Self::Thread), + AppEvent::StartBtwDiscussion { .. } + | AppEvent::BtwCompleted { .. } + | AppEvent::BtwInsertSummary + | AppEvent::BtwInsertFull + | AppEvent::BtwDiscard => Some(Self::Btw), + AppEvent::OpenWorkflowControls + | AppEvent::OpenWorkflowControlView { .. } + | AppEvent::CreateDefaultWorkflowTemplate + | AppEvent::EditWorkflowFile { .. } + | AppEvent::ToggleWorkflowTriggerEnabled { .. } + | AppEvent::ToggleWorkflowJobEnabled { .. } + | AppEvent::CycleWorkflowJobContext { .. } + | AppEvent::CycleWorkflowJobResponse { .. } + | AppEvent::EditWorkflowJobField { .. } + | AppEvent::SetWorkflowTriggerType { .. } + | AppEvent::EditWorkflowTriggerField { .. } + | AppEvent::WorkflowWorkspaceFilesChanged { .. } + | AppEvent::StartManualWorkflowTrigger { .. } + | AppEvent::StartManualWorkflowJob { .. } + | AppEvent::ShowWorkflowBackgroundTasks + | AppEvent::ReplayWorkflowHistory { .. } + | AppEvent::BackgroundWorkflowRunCompleted { .. } => Some(Self::Workflow), + AppEvent::ClawbotProviderEvent { .. } + | AppEvent::ClawbotTurnCompleted { .. } + | AppEvent::OpenClawbotManagement + | AppEvent::OpenClawbotManagementView { .. } + | AppEvent::OpenClawbotFeishuConfigPrompt { .. } + | AppEvent::SaveClawbotFeishuConfigValue { .. } + | AppEvent::SaveClawbotManualBindSessionId { .. } + | AppEvent::ClawbotSetTurnMode { .. } + | AppEvent::ClawbotSetThreadForwarding { .. } + | AppEvent::ScanClawbotFeishuSessions + | AppEvent::ClearClawbotFeishuSessions + | AppEvent::RetryClawbotFeishuConnection + | AppEvent::ClawbotDisconnectThread { .. } + | AppEvent::EditClawbotStateFile { .. } => Some(Self::Clawbot), + _ => None, + } + } +} + +impl App { + pub(super) async fn dispatch_feature_event( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + event: AppEvent, + ) -> Option { + let Some(route) = FeatureRoute::for_event(&event) else { + return Some(event); + }; + + match route { + FeatureRoute::Profile => { + ProfileController::handle(self, tui, app_server, event).await; + } + FeatureRoute::Thread => { + ThreadController::handle(self, tui, app_server, event).await; + } + FeatureRoute::Btw => { + self.handle_btw_feature_event(app_server, event).await; + } + FeatureRoute::Workflow => { + WorkflowController::handle(self, tui, app_server, event).await; + } + FeatureRoute::Clawbot => { + ClawbotController::handle(self, tui, app_server, event).await; + } + } + + None + } + + pub(super) async fn handle_btw_feature_event( + &mut self, + app_server: &mut AppServerSession, + event: AppEvent, + ) { + match event { + 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; + } + _ => unreachable!("non-btw event passed to btw feature dispatcher"), + } + } +} diff --git a/codex-rs/tui/src/app/popup_helpers.rs b/codex-rs/tui/src/app/popup_helpers.rs new file mode 100644 index 000000000..a70f6b671 --- /dev/null +++ b/codex-rs/tui/src/app/popup_helpers.rs @@ -0,0 +1,41 @@ +use super::App; +use crate::bottom_pane::SelectionViewParams; + +impl App { + pub(super) fn open_selection_popup_for_view( + &mut self, + view_id: &'static str, + build: F, + ) -> Option + where + F: FnOnce(&Self, Option) -> SelectionViewParams, + { + let active_selected_idx = self.chat_widget.selected_index_for_active_view(view_id); + let params = build(self, active_selected_idx); + if active_selected_idx.is_some() { + let _ = self + .chat_widget + .replace_selection_view_if_active(view_id, params); + } else { + self.chat_widget.show_selection_view(params); + } + active_selected_idx + } + + pub(super) fn refresh_selection_popup_if_active( + &mut self, + view_id: &'static str, + build: F, + ) -> bool + where + F: FnOnce(&Self, usize) -> SelectionViewParams, + { + let Some(initial_selected_idx) = self.chat_widget.selected_index_for_active_view(view_id) + else { + return false; + }; + let params = build(self, initial_selected_idx); + self.chat_widget + .replace_selection_view_if_active(view_id, params) + } +} diff --git a/codex-rs/tui/src/app/profile_controller.rs b/codex-rs/tui/src/app/profile_controller.rs new file mode 100644 index 000000000..f34a427ef --- /dev/null +++ b/codex-rs/tui/src/app/profile_controller.rs @@ -0,0 +1,71 @@ +use super::App; +use crate::app_event::AppEvent; +use crate::app_event::RuntimeProfileTarget; +use crate::app_server_session::AppServerSession; +use crate::profile_router::PROFILE_ROUTER_STATE_RELATIVE_PATH; +use crate::tui; + +pub(super) struct ProfileController; + +impl ProfileController { + pub(super) async fn handle( + app: &mut App, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + event: AppEvent, + ) { + match event { + AppEvent::OpenProfileManagementPanel => { + app.open_profile_management_panel(); + } + AppEvent::EditProfileFallbackConfig => { + app.edit_profile_fallback_config_from_ui(tui).await; + } + 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) = app + .switch_runtime_profile(tui, app_server, target_profile) + .await + { + app.chat_widget.add_error_message(format!( + "Failed to switch to profile `{target_label}`: {err}" + )); + } else if let Err(err) = app.profile_router_store().update(|state| { + state.set_runtime_active_profile(target_profile); + }) { + app.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 { + app.chat_widget.add_info_message( + "Switched to the default config profile.".to_string(), + /*hint*/ None, + ); + } else { + app.chat_widget.add_info_message( + format!("Switched to profile `{target_label}`."), + /*hint*/ None, + ); + } + } + AppEvent::RetryLastUserTurnWithProfileFallback { + action, + error_message, + } => { + app.retry_last_user_turn_with_profile_fallback( + tui, + app_server, + action, + error_message, + ) + .await; + } + _ => unreachable!("non-profile event passed to profile controller"), + } + } +} diff --git a/codex-rs/tui/src/app/profile_management.rs b/codex-rs/tui/src/app/profile_management.rs index 0fe63d813..34cbef24f 100644 --- a/codex-rs/tui/src/app/profile_management.rs +++ b/codex-rs/tui/src/app/profile_management.rs @@ -4,14 +4,13 @@ use toml::Value as TomlValue; use super::App; use super::PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT; +use super::editor_helpers::ExternalEditorErrorTarget; 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::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; @@ -59,25 +58,20 @@ impl App { pub(crate) fn open_profile_management_panel(&mut self) { let router_state = self.profile_router_store().load().unwrap_or_default(); - let active_selected_idx = self - .chat_widget - .selected_index_for_active_view(PROFILE_MANAGEMENT_VIEW_ID); let profiles = self.routed_profile_summaries(&router_state); - let params = profile_management_root_params( - self.active_profile.as_deref(), - &self.default_profile_summary(), - &profiles, - &router_state, - self.chat_widget.is_task_running(), - active_selected_idx, + self.open_selection_popup_for_view( + PROFILE_MANAGEMENT_VIEW_ID, + |app, active_selected_idx| { + profile_management_root_params( + app.active_profile.as_deref(), + &app.default_profile_summary(), + &profiles, + &router_state, + app.chat_widget.is_task_running(), + active_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) { @@ -106,57 +100,43 @@ impl App { } let seed = fallback_route_editor_seed(&profiles, &router_state); - let Ok(editor_cmd) = self.resolve_editor_command_for_profiles() else { + let Ok(contents) = self + .edit_seed_with_external_editor(tui, ExternalEditorErrorTarget::History, &seed, ".txt") + .await + 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}" - )); - } - } + 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(err); + self.chat_widget.add_error_message(format!( + "Failed to update {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); } } } Err(err) => { - self.chat_widget - .add_to_history(history_cell::new_error_event(format!( - "Failed to open editor: {err}", - ))); + self.chat_widget.add_error_message(err); } } - tui.frame_requester().schedule_frame(); } fn default_profile_summary(&self) -> DefaultProfileSummary { @@ -230,27 +210,6 @@ impl App { .collect() } - 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: no usable editor found in $VISUAL, $EDITOR, or `vim`." - .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 { ( diff --git a/codex-rs/tui/src/app/thread_controller.rs b/codex-rs/tui/src/app/thread_controller.rs new file mode 100644 index 000000000..a4d8f7c17 --- /dev/null +++ b/codex-rs/tui/src/app/thread_controller.rs @@ -0,0 +1,104 @@ +use ratatui::style::Stylize; +use ratatui::text::Line; + +use super::App; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::pager_overlay::Overlay; +use crate::tui; + +pub(super) struct ThreadController; + +impl ThreadController { + pub(super) async fn handle( + app: &mut App, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + event: AppEvent, + ) { + match event { + AppEvent::OpenThreadPanel => { + app.open_thread_panel(); + } + AppEvent::OpenJumpToMessagePanel => { + app.open_jump_to_message_panel(); + } + AppEvent::JumpToTranscriptCell { cell_index } => { + app.reset_backtrack_state(); + app.backtrack.overlay_preview_active = false; + if !matches!(app.overlay, Some(Overlay::Transcript(_))) { + app.open_transcript_overlay(tui); + } + if let Some(Overlay::Transcript(overlay)) = &mut app.overlay + && cell_index < app.transcript_cells.len() + { + overlay.set_highlight_cell(Some(cell_index)); + tui.frame_requester().schedule_frame(); + } + } + AppEvent::ForkCurrentSession => { + app.session_telemetry.counter( + "codex.thread.fork", + /*inc*/ 1, + &[("source", "slash_command")], + ); + let summary = super::session_summary( + app.chat_widget.token_usage(), + app.chat_widget.thread_id(), + app.chat_widget.thread_name(), + ); + app.chat_widget + .add_plain_history_lines(vec!["/fork".magenta().into()]); + if let Some(thread_id) = app.chat_widget.thread_id() { + app.refresh_in_memory_config_from_disk_best_effort("forking the thread") + .await; + match app_server.fork_thread(app.config.clone(), thread_id).await { + Ok(forked) => { + app.shutdown_current_thread(app_server).await; + match app + .replace_chat_widget_with_app_server_thread(tui, app_server, forked) + .await + { + Ok(()) => { + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + app.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + app.chat_widget.add_error_message(format!( + "Failed to attach to forked app-server thread: {err}" + )); + } + } + } + Err(err) => { + app.chat_widget.add_error_message(format!( + "Failed to fork current session through the app server: {err}" + )); + } + } + } else { + app.chat_widget.add_error_message( + "A thread must contain at least one turn before it can be forked." + .to_string(), + ); + } + + tui.frame_requester().schedule_frame(); + } + AppEvent::UndoLastUserMessage => { + app.undo_last_user_message(); + } + _ => unreachable!("non-thread event passed to thread controller"), + } + } +} diff --git a/codex-rs/tui/src/app/thread_menu.rs b/codex-rs/tui/src/app/thread_menu.rs index 8231f45e4..81f782b2a 100644 --- a/codex-rs/tui/src/app/thread_menu.rs +++ b/codex-rs/tui/src/app/thread_menu.rs @@ -13,36 +13,15 @@ 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, - )); - } + self.open_selection_popup_for_view(THREAD_PANEL_VIEW_ID, |app, initial_selected_idx| { + thread_panel_params(app.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, - )); - } + self.open_selection_popup_for_view(JUMP_TO_MESSAGE_VIEW_ID, |app, initial_selected_idx| { + jump_to_message_panel_params(&app.transcript_cells, initial_selected_idx) + }); } } diff --git a/codex-rs/tui/src/app/workflow_controller.rs b/codex-rs/tui/src/app/workflow_controller.rs new file mode 100644 index 000000000..46557ab63 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_controller.rs @@ -0,0 +1,145 @@ +use super::App; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::tui; + +pub(super) struct WorkflowController; + +impl WorkflowController { + pub(super) async fn handle( + app: &mut App, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + event: AppEvent, + ) { + match event { + AppEvent::OpenWorkflowControls => { + app.open_workflow_controls_popup(); + } + AppEvent::OpenWorkflowControlView { destination } => { + app.open_workflow_control_view(destination); + } + AppEvent::CreateDefaultWorkflowTemplate => { + app.create_default_workflow_template_from_ui(tui).await; + } + AppEvent::EditWorkflowFile { + workflow_path, + reopen, + } => { + app.edit_workflow_file_from_ui(tui, workflow_path, reopen) + .await; + } + AppEvent::ToggleWorkflowTriggerEnabled { + workflow_path, + trigger_id, + } => { + app.toggle_workflow_trigger_enabled_from_ui(workflow_path, trigger_id); + } + AppEvent::ToggleWorkflowJobEnabled { + workflow_path, + job_name, + } => { + app.toggle_workflow_job_enabled_from_ui(workflow_path, job_name); + } + AppEvent::CycleWorkflowJobContext { + workflow_path, + job_name, + } => { + app.cycle_workflow_job_context_from_ui(workflow_path, job_name); + } + AppEvent::CycleWorkflowJobResponse { + workflow_path, + job_name, + } => { + app.cycle_workflow_job_response_from_ui(workflow_path, job_name); + } + AppEvent::EditWorkflowJobField { + workflow_path, + job_name, + field, + } => { + app.edit_workflow_job_field_from_ui(tui, workflow_path, job_name, field) + .await; + } + AppEvent::SetWorkflowTriggerType { + workflow_path, + trigger_id, + trigger_type, + } => { + app.set_workflow_trigger_type_from_ui(workflow_path, trigger_id, trigger_type); + } + AppEvent::EditWorkflowTriggerField { + workflow_path, + trigger_id, + field, + } => { + app.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| { + super::workflow_file_watch::is_relevant_workspace_change( + app.config.cwd.as_path(), + path.as_path(), + ) + }) + .collect::>(); + if !relevant_paths.is_empty() { + let cells = app.handle_workspace_file_changes_for_workflows( + app_server, + relevant_paths.as_slice(), + ); + for cell in cells { + app.insert_visible_history_cell(tui, cell); + } + } + } + AppEvent::StartManualWorkflowTrigger { + workflow_name, + trigger_id, + } => { + let cell = app.start_manual_workflow_trigger_from_ui( + app_server, + workflow_name, + trigger_id, + ); + app.insert_visible_history_cell(tui, cell); + } + AppEvent::StartManualWorkflowJob { + workflow_name, + job_name, + } => { + let cell = + app.start_manual_workflow_job_from_ui(app_server, workflow_name, job_name); + app.insert_visible_history_cell(tui, cell); + } + AppEvent::ShowWorkflowBackgroundTasks => { + app.chat_widget.add_ps_output(); + } + AppEvent::ReplayWorkflowHistory { thread_id } => { + if app.active_thread_id == Some(thread_id) { + let lines = app.replay_workflow_history_cells_for_thread( + thread_id, + tui.terminal.last_known_screen_size.width, + ); + if app.overlay.is_some() { + app.deferred_history_lines.extend(lines); + } else if !lines.is_empty() { + tui.insert_history_lines(lines); + } + } + } + AppEvent::BackgroundWorkflowRunCompleted { run_id, result } => { + let cells = app + .finish_background_workflow_run(app_server, run_id, *result) + .await; + for cell in cells { + app.insert_visible_history_cell(tui, cell); + } + } + _ => unreachable!("non-workflow event passed to workflow controller"), + } + } +} diff --git a/codex-rs/tui/src/app/workflow_controls.rs b/codex-rs/tui/src/app/workflow_controls.rs index df71300f5..cfd7cce6f 100644 --- a/codex-rs/tui/src/app/workflow_controls.rs +++ b/codex-rs/tui/src/app/workflow_controls.rs @@ -12,12 +12,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::history_cell::HistoryCell; use crate::tui; use super::App; +use super::editor_helpers::ExternalEditorErrorTarget; use super::workflow_definition::LoadedWorkflowFile; use super::workflow_definition::LoadedWorkflowJob; use super::workflow_definition::LoadedWorkflowRegistry; @@ -60,57 +60,50 @@ impl App { } 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 = 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)) - } - WorkflowControlsDestination::ManualTrigger { - workflow_path, - trigger_id, - } => self.workflow_manual_trigger_popup_params( - workflow_path.as_path(), - &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()); + self.open_selection_popup_for_view( + WORKFLOW_CONTROLS_VIEW_ID, + |app, active_selected_idx| match destination { + WorkflowControlsDestination::Root => { + app.workflow_root_popup_params(active_selected_idx) + } + WorkflowControlsDestination::File { ref workflow_path } => { + app.workflow_file_popup_params(workflow_path.as_path(), Some(0)) + } + WorkflowControlsDestination::Jobs { ref workflow_path } => { + app.workflow_jobs_popup_params(workflow_path.as_path(), Some(0)) + } + WorkflowControlsDestination::Job { + ref workflow_path, + ref job_name, + } => app.workflow_job_popup_params(workflow_path.as_path(), job_name, Some(0)), + WorkflowControlsDestination::ManualTriggers { ref workflow_path } => { + app.workflow_manual_triggers_popup_params(workflow_path.as_path(), Some(0)) + } + WorkflowControlsDestination::ManualTrigger { + ref workflow_path, + ref trigger_id, + } => app.workflow_manual_trigger_popup_params( + workflow_path.as_path(), + trigger_id, + Some(0), + ), + WorkflowControlsDestination::TriggerType { + ref workflow_path, + ref trigger_id, + } => app.workflow_trigger_type_popup_params( + workflow_path.as_path(), + trigger_id, + Some(0), + ), + }, + ); } 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; - }; - self.show_workflow_popup( - self.workflow_root_popup_params(Some(initial_selected_idx)), - /*replace_if_active*/ true, - ); + let _ = self + .refresh_selection_popup_if_active(WORKFLOW_CONTROLS_VIEW_ID, |app, selected_idx| { + app.workflow_root_popup_params(Some(selected_idx)) + }); } pub(crate) fn start_manual_workflow_trigger_from_ui( @@ -162,27 +155,17 @@ impl App { 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}", - ))); - } + if self + .edit_file_with_external_editor( + tui, + ExternalEditorErrorTarget::History, + workflow_path.as_path(), + ) + .await + .is_ok() + { + self.open_workflow_control_view(reopen); } - tui.frame_requester().schedule_frame(); } pub(crate) async fn edit_workflow_job_field_from_ui( @@ -201,51 +184,36 @@ impl App { 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)); - } - }, + let Ok(updated) = self + .edit_seed_with_external_editor(tui, ExternalEditorErrorTarget::History, &seed, suffix) + .await + else { + return; + }; + 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(format!( - "Failed to open editor: {err}", - ))); + .add_to_history(history_cell::new_error_event(err)); } } - tui.frame_requester().schedule_frame(); } pub(crate) async fn edit_workflow_trigger_field_from_ui( @@ -267,52 +235,41 @@ impl App { 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)); - } - }, + let Ok(updated) = self + .edit_seed_with_external_editor(tui, ExternalEditorErrorTarget::History, &seed, suffix) + .await + else { + return; + }; + 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(format!( - "Failed to open editor: {err}", - ))); + .add_to_history(history_cell::new_error_event(err)); } } - tui.frame_requester().schedule_frame(); } pub(crate) fn toggle_workflow_job_enabled_from_ui( @@ -447,37 +404,6 @@ impl App { } } - 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: no usable editor found in $VISUAL, $EDITOR, or `vim`." - .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, From 9526609d3c45a64dc606d4bef799866213ec22d2 Mon Sep 17 00:00:00 2001 From: piping Date: Sun, 12 Apr 2026 14:31:15 +0800 Subject: [PATCH 72/83] allow fallback and exp backoff --- AGENTS.md | 1 - codex-rs/tui/src/app/feature_dispatch.rs | 3 +- codex-rs/tui/src/app/profile_controller.rs | 14 + codex-rs/tui/src/app/profile_management.rs | 257 +++++++++++++----- codex-rs/tui/src/app_event.rs | 5 + codex-rs/tui/src/chatwidget.rs | 52 ++-- .../chatwidget/tests/composer_submission.rs | 16 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + .../tui/src/chatwidget/tests/review_mode.rs | 2 + codex-rs/tui/src/profile_router.rs | 87 ++++-- 10 files changed, 315 insertions(+), 124 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 33681af16..5ba0015cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,7 +210,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/tui/src/app/feature_dispatch.rs b/codex-rs/tui/src/app/feature_dispatch.rs index 1c226468e..4c6a8eaf8 100644 --- a/codex-rs/tui/src/app/feature_dispatch.rs +++ b/codex-rs/tui/src/app/feature_dispatch.rs @@ -22,7 +22,8 @@ impl FeatureRoute { AppEvent::OpenProfileManagementPanel | AppEvent::EditProfileFallbackConfig | AppEvent::SwitchRuntimeProfile { .. } - | AppEvent::RetryLastUserTurnWithProfileFallback { .. } => Some(Self::Profile), + | AppEvent::RetryLastUserTurnWithProfileFallback { .. } + | AppEvent::ExecuteProfileFallbackRetry { .. } => Some(Self::Profile), AppEvent::OpenThreadPanel | AppEvent::OpenJumpToMessagePanel | AppEvent::JumpToTranscriptCell { .. } diff --git a/codex-rs/tui/src/app/profile_controller.rs b/codex-rs/tui/src/app/profile_controller.rs index f34a427ef..68f86e440 100644 --- a/codex-rs/tui/src/app/profile_controller.rs +++ b/codex-rs/tui/src/app/profile_controller.rs @@ -65,6 +65,20 @@ impl ProfileController { ) .await; } + AppEvent::ExecuteProfileFallbackRetry { + generation, + profile_id, + history_message, + } => { + app.execute_profile_fallback_retry( + tui, + app_server, + generation, + profile_id, + history_message, + ) + .await; + } _ => unreachable!("non-profile event passed to profile controller"), } } diff --git a/codex-rs/tui/src/app/profile_management.rs b/codex-rs/tui/src/app/profile_management.rs index 34cbef24f..19bce4b8d 100644 --- a/codex-rs/tui/src/app/profile_management.rs +++ b/codex-rs/tui/src/app/profile_management.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::time::Duration; use toml::Value as TomlValue; @@ -24,6 +25,38 @@ use codex_core::config::Config; use codex_protocol::ThreadId; const PROFILE_MANAGEMENT_VIEW_ID: &str = "profile-management"; +const PROFILE_FALLBACK_RETRY_MAX_DELAY: Duration = Duration::from_secs(30); + +fn profile_fallback_retry_delay(attempt: u32) -> Duration { + if attempt <= 1 { + return Duration::ZERO; + } + + let exponent = attempt.saturating_sub(2).min(5); + let seconds = 1_u64 << exponent; + Duration::from_secs(seconds).min(PROFILE_FALLBACK_RETRY_MAX_DELAY) +} + +fn profile_fallback_retry_target( + action: ProfileFallbackAction, + router_state: &ProfileRouterState, + active_profile_id: Option<&str>, + same_profile_retry_consumed: bool, +) -> Option { + if matches!(action, ProfileFallbackAction::RetrySameProfileFirst) + && !same_profile_retry_consumed + { + return active_profile_id.map(ToOwned::to_owned); + } + + DefaultProfileRouter + .next_profile(router_state, active_profile_id) + .or_else(|| active_profile_id.map(ToOwned::to_owned)) +} + +fn profile_label(profile_id: Option<&str>) -> &str { + profile_id.unwrap_or("default") +} #[derive(Clone, Debug, Eq, PartialEq)] struct RoutedProfileSummary { @@ -331,80 +364,92 @@ impl App { return; } - 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; - } - self.chat_widget.submit_profile_fallback_retry(format!( - "Retrying the last turn with profile `{next_profile_id}`." - )); - } + let generation = self.chat_widget.profile_retry_generation(); + let attempt = self + .chat_widget + .profile_retry_attempt_count() + .saturating_add(1); + let router_state = self.profile_router_store().load().unwrap_or_default(); + let profile_id = profile_fallback_retry_target( + action, + &router_state, + self.active_profile.as_deref(), + self.chat_widget.profile_retry_attempted(), + ); + let target_label = profile_label(profile_id.as_deref()).to_string(); + let history_message = format!("Retrying the last turn with profile `{target_label}`."); + let delay = profile_fallback_retry_delay(attempt); + + self.chat_widget.finish_failed_turn_for_profile_fallback(); + + if delay.is_zero() { + self.execute_profile_fallback_retry( + tui, + app_server, + generation, + profile_id, + history_message, + ) + .await; + return; + } + + self.chat_widget.add_info_message( + format!( + "{error_message} Retrying with profile `{target_label}` in {}s.", + delay.as_secs() + ), + /*hint*/ None, + ); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(delay).await; + app_event_tx.send(AppEvent::ExecuteProfileFallbackRetry { + generation, + profile_id, + history_message, + }); + }); + } + + pub(super) async fn execute_profile_fallback_retry( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + generation: u64, + profile_id: Option, + history_message: String, + ) { + if !self.chat_widget.has_retryable_user_turn() + || self.chat_widget.profile_retry_generation() != generation + { + return; + } + + if self.active_profile != profile_id { + if let Err(err) = self + .switch_runtime_profile(tui, app_server, profile_id.as_deref()) + .await + { + let profile_label = profile_label(profile_id.as_deref()); + self.chat_widget.add_error_message(format!( + "Failed to switch to fallback profile `{profile_label}`: {err}" + )); + return; } - 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; - } - self.chat_widget.submit_profile_fallback_retry(format!( - "Retrying the last turn with profile `{next_profile_id}`." + + if let Err(err) = self.profile_router_store().update(|state| { + state.set_runtime_active_profile(profile_id.as_deref()); + }) { + let profile_label = profile_label(profile_id.as_deref()); + self.chat_widget.add_error_message(format!( + "Switched to fallback profile `{profile_label}`, but failed to persist {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" )); } } + + self.chat_widget + .submit_profile_fallback_retry(history_message); } } @@ -655,6 +700,8 @@ fn rewritten_router_state( #[cfg(test)] mod tests { + use std::time::Duration; + use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::Terminal; @@ -666,11 +713,14 @@ mod tests { use super::RoutedProfileSummary; use super::fallback_route_editor_seed; use super::parse_fallback_route_editor_contents; + use super::profile_fallback_retry_delay; + use super::profile_fallback_retry_target; 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::ProfileFallbackAction; use crate::profile_router::ProfileRouteEntry; use crate::profile_router::ProfileRouterState; use crate::render::renderable::Renderable; @@ -823,6 +873,73 @@ mod tests { ); } + #[test] + fn profile_fallback_retry_delay_uses_exponential_backoff_with_cap() { + let expected = [ + Duration::ZERO, + Duration::ZERO, + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4), + Duration::from_secs(8), + Duration::from_secs(16), + Duration::from_secs(30), + Duration::from_secs(30), + ]; + + for (attempt, delay) in expected.into_iter().enumerate() { + assert_eq!(profile_fallback_retry_delay(attempt as u32), delay); + } + } + + #[test] + fn retry_same_profile_first_only_rotates_after_same_profile_retry_is_consumed() { + let router_state = test_router_state(); + + assert_eq!( + profile_fallback_retry_target( + ProfileFallbackAction::RetrySameProfileFirst, + &router_state, + Some("primary"), + /*same_profile_retry_consumed*/ false, + ), + Some("primary".to_string()) + ); + assert_eq!( + profile_fallback_retry_target( + ProfileFallbackAction::RetrySameProfileFirst, + &router_state, + Some("primary"), + /*same_profile_retry_consumed*/ true, + ), + Some("secondary".to_string()) + ); + } + + #[test] + fn switch_profile_immediately_uses_next_profile_in_route_order() { + let router_state = test_router_state(); + + assert_eq!( + profile_fallback_retry_target( + ProfileFallbackAction::SwitchProfileImmediately, + &router_state, + Some("primary"), + /*same_profile_retry_consumed*/ false, + ), + Some("secondary".to_string()) + ); + assert_eq!( + profile_fallback_retry_target( + ProfileFallbackAction::SwitchProfileImmediately, + &router_state, + None, + /*same_profile_retry_consumed*/ false, + ), + Some("primary".to_string()) + ); + } + #[test] fn rewritten_router_state_drops_stale_entries_and_preserves_active_profile() { let state = rewritten_router_state( diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 426645339..4bb7f75e9 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -526,6 +526,11 @@ pub(crate) enum AppEvent { action: ProfileFallbackAction, error_message: String, }, + ExecuteProfileFallbackRetry { + generation: u64, + profile_id: Option, + history_message: String, + }, ClawbotTurnCompleted { thread_id: ThreadId, turn: AppServerTurn, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a255169e7..829be8a62 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1008,6 +1008,8 @@ pub(crate) struct ChatWidget { last_non_retry_error: Option<(String, String)>, last_submitted_user_turn: Option, profile_retry_attempted: bool, + profile_retry_attempt_count: u32, + profile_retry_generation: u64, } /// Cached nickname and role for a collab agent thread, used to attach human-readable labels to @@ -1095,6 +1097,8 @@ pub(crate) struct ThreadInputState { queued_user_messages: VecDeque, last_submitted_user_turn: Option, profile_retry_attempted: bool, + profile_retry_attempt_count: u32, + profile_retry_generation: u64, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, task_running: bool, @@ -3327,6 +3331,8 @@ impl ChatWidget { 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, + profile_retry_attempt_count: self.profile_retry_attempt_count, + profile_retry_generation: self.profile_retry_generation, current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), task_running: self.bottom_pane.is_task_running(), @@ -3383,6 +3389,8 @@ impl ChatWidget { 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; + self.profile_retry_attempt_count = input_state.profile_retry_attempt_count; + self.profile_retry_generation = input_state.profile_retry_generation; } else { self.agent_turn_running = false; self.pending_steers.clear(); @@ -3398,6 +3406,8 @@ impl ChatWidget { self.queued_user_messages.clear(); self.last_submitted_user_turn = None; self.profile_retry_attempted = false; + self.profile_retry_attempt_count = 0; + self.profile_retry_generation = 0; } self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); @@ -5088,6 +5098,8 @@ impl ChatWidget { last_non_retry_error: None, last_submitted_user_turn: None, profile_retry_attempted: false, + profile_retry_attempt_count: 0, + profile_retry_generation: 0, }; widget @@ -6091,6 +6103,8 @@ impl ChatWidget { mention_bindings: mention_bindings.clone(), }); self.profile_retry_attempted = false; + self.profile_retry_attempt_count = 0; + self.profile_retry_generation = self.profile_retry_generation.saturating_add(1); } let mut items: Vec = Vec::new(); @@ -11220,14 +11234,18 @@ impl ChatWidget { 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 profile_retry_attempt_count(&self) -> u32 { + self.profile_retry_attempt_count + } + + pub(crate) fn profile_retry_generation(&self) -> u64 { + self.profile_retry_generation + } + pub(crate) fn has_retryable_user_turn(&self) -> bool { self.last_submitted_user_turn.is_some() } @@ -11237,38 +11255,16 @@ impl ChatWidget { 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; - } - if self.last_submitted_user_turn.is_none() { - 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(UserMessage::from("continue")); - true - } - 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; self.add_to_history(history_cell::new_info_event( history_message, /*hint*/ None, )); self.submit_user_message(user_message); + self.profile_retry_attempted = true; + self.profile_retry_attempt_count = self.profile_retry_attempt_count.saturating_add(1); } pub(crate) fn finish_failed_turn_for_profile_fallback(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a7347599f..df64b5221 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -799,6 +799,8 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { queued_user_messages: VecDeque::new(), last_submitted_user_turn: None, profile_retry_attempted: false, + profile_retry_attempt_count: 0, + profile_retry_generation: 0, current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), task_running: true, @@ -854,6 +856,8 @@ async fn capture_and_restore_thread_input_state_preserves_profile_fallback_retry Some(UserMessage::from("retry me")) ); assert!(!chat.profile_retry_attempted()); + let generation = chat.profile_retry_generation(); + assert_eq!(chat.profile_retry_attempt_count(), 0); let snapshot = chat.capture_thread_input_state(); chat.restore_thread_input_state(/*input_state*/ None); @@ -868,9 +872,9 @@ async fn capture_and_restore_thread_input_state_preserves_profile_fallback_retry 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() - )); + assert_eq!(chat.profile_retry_attempt_count(), 0); + assert_eq!(chat.profile_retry_generation(), generation); + chat.submit_profile_fallback_retry("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:?}"), @@ -886,6 +890,12 @@ async fn capture_and_restore_thread_input_state_preserves_profile_fallback_retry chat.last_submitted_user_turn(), Some(UserMessage::from("continue")) ); + assert!(chat.profile_retry_attempted()); + assert_eq!(chat.profile_retry_attempt_count(), 1); + assert_eq!( + chat.profile_retry_generation(), + generation.saturating_add(1) + ); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d219b2e01..7900508db 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -301,6 +301,8 @@ pub(super) async fn make_chatwidget_manual( last_non_retry_error: None, last_submitted_user_turn: None, profile_retry_attempted: false, + profile_retry_attempt_count: 0, + profile_retry_generation: 0, }; widget.set_model(&resolved_model); (widget, rx, op_rx) diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 7f23f0f12..d1c6e0bba 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -363,6 +363,8 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ queued_user_messages, last_submitted_user_turn: None, profile_retry_attempted: false, + profile_retry_attempt_count: 0, + profile_retry_generation: 0, current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), task_running: false, diff --git a/codex-rs/tui/src/profile_router.rs b/codex-rs/tui/src/profile_router.rs index b37854ae7..e9872fbb0 100644 --- a/codex-rs/tui/src/profile_router.rs +++ b/codex-rs/tui/src/profile_router.rs @@ -46,20 +46,6 @@ impl ProfileRouterState { .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 - } - } - 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)) @@ -77,16 +63,27 @@ impl ProfileRouterState { pub(crate) struct DefaultProfileRouter; impl DefaultProfileRouter { - pub(crate) fn fallback_profile( + pub(crate) fn next_profile( &self, state: &ProfileRouterState, active_profile_id: Option<&str>, ) -> Option { - state + let first = state.routes.first()?; + let Some(active_profile_id) = active_profile_id else { + return Some(first.profile_id.clone()); + }; + + let active_index = state .routes .iter() - .find(|route| Some(route.profile_id.as_str()) != active_profile_id) - .map(|route| route.profile_id.clone()) + .position(|route| route.profile_id == active_profile_id); + match active_index { + Some(index) => { + let next_index = (index + 1) % state.routes.len(); + Some(state.routes[next_index].profile_id.clone()) + } + None => Some(first.profile_id.clone()), + } } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -205,7 +202,7 @@ mod tests { use tempfile::TempDir; #[test] - fn fallback_profile_skips_current_profile() { + fn next_profile_skips_current_profile() { let state = ProfileRouterState { version: 1, active_profile_id: Some("primary".to_string()), @@ -219,12 +216,60 @@ mod tests { ], }; - let fallback = DefaultProfileRouter - .fallback_profile(&state, /*active_profile_id*/ Some("primary")); + let fallback = + DefaultProfileRouter.next_profile(&state, /*active_profile_id*/ Some("primary")); assert_eq!(fallback, Some("secondary".to_string())); } + #[test] + fn next_profile_round_robins_in_route_order() { + let state = ProfileRouterState { + version: 1, + active_profile_id: Some("secondary".to_string()), + routes: vec![ + ProfileRouteEntry { + profile_id: "primary".to_string(), + }, + ProfileRouteEntry { + profile_id: "secondary".to_string(), + }, + ProfileRouteEntry { + profile_id: "tertiary".to_string(), + }, + ], + }; + + assert_eq!( + DefaultProfileRouter.next_profile(&state, Some("secondary")), + Some("tertiary".to_string()) + ); + assert_eq!( + DefaultProfileRouter.next_profile(&state, Some("tertiary")), + Some("primary".to_string()) + ); + assert_eq!( + DefaultProfileRouter.next_profile(&state, None), + Some("primary".to_string()) + ); + } + + #[test] + fn next_profile_keeps_single_route_available() { + let state = ProfileRouterState { + version: 1, + active_profile_id: Some("primary".to_string()), + routes: vec![ProfileRouteEntry { + profile_id: "primary".to_string(), + }], + }; + + assert_eq!( + DefaultProfileRouter.next_profile(&state, Some("primary")), + Some("primary".to_string()) + ); + } + #[test] fn profile_router_store_defaults_when_file_is_missing() { let tempdir = TempDir::new().unwrap(); From e6c4bbef83eafbd8424e92634c2626905de8ced3 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 13 Apr 2026 15:55:48 +0800 Subject: [PATCH 73/83] python: release codex-enhanced v0.1.27 --- 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 285484697..64784d51b 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.26" +version = "0.1.27" description = "Enhanced Codex CLI with loop automation, Feishu clawbot support, and fast session respawn" readme = "README.md" requires-python = ">=3" From b218c7015b3186ddbca2e453d59d23992f324eb7 Mon Sep 17 00:00:00 2001 From: piping Date: Mon, 13 Apr 2026 19:37:38 +0800 Subject: [PATCH 74/83] fix multi codex in same directory binding issue --- codex-rs/clawbot/src/model.rs | 4 ++ codex-rs/clawbot/src/runtime.rs | 13 ++-- codex-rs/tui/src/app/clawbot.rs | 68 +++++++++++++++++++- codex-rs/tui/src/app/clawbot_controls.rs | 6 +- codex-rs/tui/src/app/tests/clawbot_tests.rs | 69 ++++++++++++++++++++- 5 files changed, 153 insertions(+), 7 deletions(-) diff --git a/codex-rs/clawbot/src/model.rs b/codex-rs/clawbot/src/model.rs index 9535ac94e..82a4f18f1 100644 --- a/codex-rs/clawbot/src/model.rs +++ b/codex-rs/clawbot/src/model.rs @@ -135,6 +135,8 @@ pub struct SessionBinding { pub provider: ProviderKind, pub session_id: String, pub thread_id: String, + #[serde(default)] + pub owner_primary_thread_id: Option, #[serde(default = "default_session_forwarding_enabled")] pub inbound_forwarding_enabled: bool, #[serde(default = "default_session_forwarding_enabled")] @@ -180,6 +182,8 @@ impl CachedUnreadMessage { pub struct PendingClawbotTurn { pub thread_id: String, pub turn_id: String, + #[serde(default)] + pub owner_primary_thread_id: Option, pub session: ProviderSessionRef, pub message_id: String, pub auto_ack_reaction_id: Option, diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs index f4edc5837..48e288e45 100644 --- a/codex-rs/clawbot/src/runtime.rs +++ b/codex-rs/clawbot/src/runtime.rs @@ -163,6 +163,7 @@ impl ClawbotRuntime { &mut self, session: &ProviderSessionRef, thread_id: String, + owner_primary_thread_id: Option, ) -> Result<&ClawbotSnapshot> { let now = unix_timestamp_now()?; let mut bindings = self.store.load_bindings()?; @@ -191,12 +192,14 @@ impl ClawbotRuntime { .find(|binding| binding.session_ref() == *session) { binding.thread_id = thread_id.clone(); + binding.owner_primary_thread_id = owner_primary_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(), + owner_primary_thread_id, inbound_forwarding_enabled: true, outbound_forwarding_enabled: true, created_at: existing_binding @@ -646,7 +649,7 @@ mod tests { }) .expect("session"); runtime - .connect_session_to_thread(&session, "thread_1".to_string()) + .connect_session_to_thread(&session, "thread_1".to_string(), None) .expect("bind session"); assert_eq!( @@ -680,7 +683,7 @@ mod tests { let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_4"); runtime - .connect_session_to_thread(&session, "thread_2".to_string()) + .connect_session_to_thread(&session, "thread_2".to_string(), None) .expect("bind session"); runtime @@ -704,6 +707,7 @@ mod tests { provider: ProviderKind::Feishu, session_id: "chat_4".to_string(), thread_id: "thread_2".to_string(), + owner_primary_thread_id: None, inbound_forwarding_enabled: false, outbound_forwarding_enabled: false, created_at: runtime.snapshot().bindings[0].created_at, @@ -720,7 +724,7 @@ mod tests { let unbound_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_unbound"); runtime - .connect_session_to_thread(&bound_session, "thread_3".to_string()) + .connect_session_to_thread(&bound_session, "thread_3".to_string(), None) .expect("bind session"); runtime .persist_session(ProviderSession { @@ -789,7 +793,7 @@ mod tests { let live_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_live"); runtime - .connect_session_to_thread(&stale_session, "thread_stale".to_string()) + .connect_session_to_thread(&stale_session, "thread_stale".to_string(), None) .expect("bind stale session"); runtime .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { @@ -817,6 +821,7 @@ mod tests { provider: ProviderKind::Feishu, session_id: stale_session.session_id.clone(), thread_id: "thread_stale".to_string(), + owner_primary_thread_id: None, inbound_forwarding_enabled: true, outbound_forwarding_enabled: true, created_at: runtime.snapshot().bindings[0].created_at, diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs index d636ab9c0..5fc67a07d 100644 --- a/codex-rs/tui/src/app/clawbot.rs +++ b/codex-rs/tui/src/app/clawbot.rs @@ -39,6 +39,12 @@ use crate::app_server_session::ThreadSessionState; const FEISHU_AUTO_ACK_EMOJI_TYPE: &str = "TONGUE"; impl App { + pub(super) fn clawbot_owner_primary_thread_id(&self) -> Option { + self.primary_thread_id + .or(self.active_thread_id) + .map(|thread_id| thread_id.to_string()) + } + 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"); @@ -125,9 +131,13 @@ impl App { } fn restore_clawbot_pending_turns(&mut self) -> Result<()> { + let owner_primary_thread_id = self.clawbot_owner_primary_thread_id(); let pending_turns = self.clawbot_store()?.load_pending_turns()?; self.clawbot_pending_turns.clear(); for pending_turn in pending_turns { + if !pending_turn_belongs_to_owner(&pending_turn, owner_primary_thread_id.as_deref()) { + continue; + } let thread_id = ThreadId::from_string(&pending_turn.thread_id).with_context(|| { format!("invalid clawbot thread id `{}`", pending_turn.thread_id) })?; @@ -380,7 +390,15 @@ impl App { Some(pending.session) } else { let runtime = ClawbotRuntime::load(workspace_root.clone())?; - let bound_session = runtime.bound_session_for_thread(&thread_id.to_string())?; + let bound_session = runtime + .load_binding_for_thread(&thread_id.to_string())? + .filter(|binding| { + binding_belongs_to_owner( + binding, + self.clawbot_owner_primary_thread_id().as_deref(), + ) + }) + .map(|binding| binding.session_ref()); if let Some(session) = bound_session.as_ref() && let Some(text) = clawbot_outbound_text_for_turn(&turn) { @@ -428,6 +446,20 @@ impl App { ); return Ok(()); }; + if !binding_belongs_to_owner(&binding, self.clawbot_owner_primary_thread_id().as_deref()) { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.dispatch_skipped", + serde_json::json!({ + "reason": "binding_owned_by_another_instance", + "session_id": session.session_id, + "thread_id": binding.thread_id, + "binding_owner_primary_thread_id": binding.owner_primary_thread_id, + "current_owner_primary_thread_id": self.clawbot_owner_primary_thread_id(), + }), + ); + return Ok(()); + } if !binding.inbound_forwarding_enabled { let _ = append_diagnostic_event( workspace_root.as_path(), @@ -560,6 +592,7 @@ impl App { message.message_id.clone(), auto_ack_reaction_id, turn_mode, + binding.owner_primary_thread_id.clone(), ); Ok(()) } @@ -693,6 +726,21 @@ impl App { ); return Ok(()); }; + if !binding_belongs_to_owner(&binding, self.clawbot_owner_primary_thread_id().as_deref()) { + let _ = append_diagnostic_event( + workspace_root, + "bridge.reply_skipped", + serde_json::json!({ + "reason": "binding_owned_by_another_instance", + "session_id": session.session_id, + "thread_id": binding.thread_id, + "text": text, + "binding_owner_primary_thread_id": binding.owner_primary_thread_id, + "current_owner_primary_thread_id": self.clawbot_owner_primary_thread_id(), + }), + ); + return Ok(()); + } if !binding.outbound_forwarding_enabled { let _ = append_diagnostic_event( workspace_root, @@ -818,10 +866,12 @@ impl App { message_id: String, auto_ack_reaction_id: Option, turn_mode: ClawbotTurnMode, + owner_primary_thread_id: Option, ) { let pending_turn = PendingClawbotTurn { thread_id: thread_id.to_string(), turn_id, + owner_primary_thread_id, session, message_id, auto_ack_reaction_id, @@ -1012,6 +1062,22 @@ fn next_unread_message_for_session( }) } +fn binding_belongs_to_owner( + binding: &codex_clawbot::SessionBinding, + owner_primary_thread_id: Option<&str>, +) -> bool { + binding.owner_primary_thread_id.as_deref() == owner_primary_thread_id + && owner_primary_thread_id.is_some() +} + +fn pending_turn_belongs_to_owner( + pending_turn: &PendingClawbotTurn, + owner_primary_thread_id: Option<&str>, +) -> bool { + pending_turn.owner_primary_thread_id.as_deref() == owner_primary_thread_id + && owner_primary_thread_id.is_some() +} + fn session_title(session: &codex_clawbot::ProviderSession) -> String { session .display_name diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index 54b733650..660327001 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -252,7 +252,11 @@ impl App { )); } } - runtime.connect_session_to_thread(&session, thread_id.to_string())?; + runtime.connect_session_to_thread( + &session, + thread_id.to_string(), + self.clawbot_owner_primary_thread_id(), + )?; self.refresh_clawbot_provider_runtime()?; self.dispatch_next_clawbot_message(app_server, &session) .await?; diff --git a/codex-rs/tui/src/app/tests/clawbot_tests.rs b/codex-rs/tui/src/app/tests/clawbot_tests.rs index bfc822e66..ff4587f6f 100644 --- a/codex-rs/tui/src/app/tests/clawbot_tests.rs +++ b/codex-rs/tui/src/app/tests/clawbot_tests.rs @@ -39,8 +39,15 @@ async fn bind_test_clawbot_session( bound_thread_id: None, }) .expect("persist session"); + if app.primary_thread_id.is_none() { + app.primary_thread_id = Some(thread_id); + } runtime - .connect_session_to_thread(&session, thread_id.to_string()) + .connect_session_to_thread( + &session, + thread_id.to_string(), + app.clawbot_owner_primary_thread_id(), + ) .expect("connect session"); app.sync_clawbot_workspace(app_server).await; Ok((thread_id, session)) @@ -152,6 +159,7 @@ async fn noninteractive_clawbot_request_user_input_builds_auto_response() { VecDeque::from([PendingClawbotTurn { thread_id: thread_id.to_string(), turn_id: "turn-1".to_string(), + owner_primary_thread_id: Some(thread_id.to_string()), session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), message_id: "msg-1".to_string(), auto_ack_reaction_id: None, @@ -195,6 +203,7 @@ async fn noninteractive_clawbot_permissions_request_builds_auto_response() { VecDeque::from([PendingClawbotTurn { thread_id: thread_id.to_string(), turn_id: "turn-1".to_string(), + owner_primary_thread_id: Some(thread_id.to_string()), session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), message_id: "msg-1".to_string(), auto_ack_reaction_id: None, @@ -362,6 +371,7 @@ async fn clawbot_restart_recovers_pending_turn_and_forwards_reply() -> Result<() let mut restarted_app = make_test_app().await; restarted_app.config.cwd = tempdir.path().to_path_buf().abs(); + restarted_app.primary_thread_id = Some(thread_id); restarted_app.sync_clawbot_workspace(&mut app_server).await; assert_eq!( @@ -422,6 +432,60 @@ async fn clawbot_restart_recovers_pending_turn_and_forwards_reply() -> Result<() Ok(()) } +#[tokio::test] +async fn clawbot_sync_ignores_binding_owned_by_another_app_instance() -> Result<()> { + let mut owner_app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(owner_app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + owner_app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, session) = + bind_test_clawbot_session(&mut owner_app, &mut app_server, "chat_owner").await?; + + let mut other_app = make_test_app().await; + other_app.config.cwd = tempdir.path().to_path_buf().abs(); + other_app.primary_thread_id = Some(ThreadId::new()); + + let mut runtime = ClawbotRuntime::load(owner_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: "owner only".to_string(), + received_at: 1, + }, + )) + .expect("queue unread"); + + other_app.sync_clawbot_workspace(&mut app_server).await; + assert_eq!(other_app.clawbot.pending_turns.get(&thread_id), None); + assert_eq!(other_app.clawbot.outbound_reactions, Vec::new()); + + owner_app.sync_clawbot_workspace(&mut app_server).await; + assert_eq!( + owner_app + .clawbot + .pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + assert_eq!( + owner_app.clawbot.outbound_reactions, + vec![ProviderOutboundReaction { + target: ProviderMessageRef::new(ClawbotProviderKind::Feishu, "chat_owner", "msg_1"), + emoji_type: "TONGUE".to_string(), + }] + ); + + Ok(()) +} + #[test] fn clawbot_store_persists_auto_ack_reaction_id() -> Result<()> { let tempdir = tempdir()?; @@ -429,6 +493,7 @@ fn clawbot_store_persists_auto_ack_reaction_id() -> Result<()> { let pending_turn = PendingClawbotTurn { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + owner_primary_thread_id: Some("owner-thread-1".to_string()), session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_store"), message_id: "msg-1".to_string(), auto_ack_reaction_id: Some("reaction-1".to_string()), @@ -525,6 +590,7 @@ async fn clawbot_sync_clears_stale_pending_turn_and_redelivers_unread() -> Resul .upsert_pending_turn(PendingClawbotTurn { thread_id: thread_id.to_string(), turn_id: "stale-turn".to_string(), + owner_primary_thread_id: app.clawbot_owner_primary_thread_id(), session: session.clone(), message_id: "msg_1".to_string(), auto_ack_reaction_id: None, @@ -736,6 +802,7 @@ async fn clawbot_management_popup_snapshot() -> Result<()> { .connect_session_to_thread( &ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_ops"), second_started.session.thread_id.to_string(), + app.clawbot_owner_primary_thread_id(), ) .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; From bcb0524d0ca518c6ef3ec0d25367cd5e6d5e85c9 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 15 Apr 2026 13:37:28 +0800 Subject: [PATCH 75/83] python: release codex-enhanced v0.1.28 --- 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 64784d51b..1cc92ed23 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.27" +version = "0.1.28" description = "Enhanced Codex CLI with loop automation, Feishu clawbot support, and fast session respawn" readme = "README.md" requires-python = ">=3" From 2bd9cac95f9d4c58c8bc747a273f2f0dde22795d Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 15 Apr 2026 15:41:37 +0800 Subject: [PATCH 76/83] Fix stream retry cache key and cranelift test support --- codex-rs/Cargo.lock | 1 - codex-rs/core/src/client.rs | 19 ++++++- codex-rs/core/tests/common/Cargo.toml | 1 - .../core/tests/common/context_snapshot.rs | 3 ++ codex-rs/core/tests/common/lib.rs | 51 +++++++++---------- codex-rs/core/tests/common/test_codex.rs | 22 +++++--- codex-rs/core/tests/common/test_codex_exec.rs | 1 + .../core/tests/suite/stream_no_completed.rs | 8 +++ 8 files changed, 71 insertions(+), 35 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fdaa76bf5..4c523cbd6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3489,7 +3489,6 @@ dependencies = [ "codex-protocol", "codex-utils-absolute-path", "codex-utils-cargo-bin", - "ctor 0.6.3", "futures", "notify", "opentelemetry", diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index bd83c81f0..4927104cb 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -211,6 +211,8 @@ pub struct ModelClient { pub struct ModelClientSession { client: ModelClient, websocket_session: WebsocketSession, + prompt_cache_key_override: Option, + prompt_cache_retry_generation: u64, /// Turn state for sticky routing. /// /// This is an `OnceLock` that stores the turn state value received from the server @@ -330,6 +332,8 @@ impl ModelClient { ModelClientSession { client: self.clone(), websocket_session: self.take_cached_websocket_session(), + prompt_cache_key_override: None, + prompt_cache_retry_generation: 0, turn_state: Arc::new(OnceLock::new()), } } @@ -807,6 +811,15 @@ impl ModelClientSession { .set_connection_reused(/*connection_reused*/ false); } + pub(crate) fn rotate_prompt_cache_key_for_retry(&mut self) { + self.prompt_cache_retry_generation += 1; + let conversation_id = self.client.state.conversation_id; + let retry_generation = self.prompt_cache_retry_generation; + self.prompt_cache_key_override = + Some(format!("{conversation_id}:stream-retry:{retry_generation}")); + self.reset_websocket_session(); + } + fn build_responses_request( &self, provider: &codex_api::Provider, @@ -852,7 +865,11 @@ impl ModelClientSession { None }; let text = create_text_param_for_request(verbosity, &prompt.output_schema); - let prompt_cache_key = Some(self.client.state.conversation_id.to_string()); + let prompt_cache_key = Some( + self.prompt_cache_key_override + .clone() + .unwrap_or_else(|| self.client.state.conversation_id.to_string()), + ); let request = ResponsesApiRequest { model: model_info.slug.clone(), instructions: instructions.clone(), diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index e2765e8be..1ce9456b3 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -24,7 +24,6 @@ codex-models-manager = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cargo-bin = { workspace = true } -ctor = { workspace = true } futures = { workspace = true } notify = { workspace = true } opentelemetry = { workspace = true } diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs index cb899969d..0c74e90c6 100644 --- a/codex-rs/core/tests/common/context_snapshot.rs +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -56,11 +56,13 @@ pub fn format_request_input_snapshot( request: &ResponsesRequest, options: &ContextSnapshotOptions, ) -> String { + crate::ensure_test_process_initialized(); let items = request.input(); format_response_items_snapshot(items.as_slice(), options) } pub fn format_response_items_snapshot(items: &[Value], options: &ContextSnapshotOptions) -> String { + crate::ensure_test_process_initialized(); items .iter() .enumerate() @@ -211,6 +213,7 @@ pub fn format_labeled_requests_snapshot( sections: &[(&str, &ResponsesRequest)], options: &ContextSnapshotOptions, ) -> String { + crate::ensure_test_process_initialized(); let sections = sections .iter() .map(|(title, request)| { diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index a11d5ee6a..f3adad51d 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -4,7 +4,6 @@ use anyhow::Context as _; use anyhow::ensure; use codex_arg0::Arg0PathEntryGuard; use codex_utils_cargo_bin::CargoBinError; -use ctor::ctor; use std::sync::OnceLock; use tempfile::TempDir; @@ -29,40 +28,37 @@ pub mod tracing; pub mod zsh_fork; static TEST_ARG0_PATH_ENTRY: OnceLock> = OnceLock::new(); +static TEST_PROCESS_INIT: OnceLock<()> = OnceLock::new(); -#[ctor] -fn enable_deterministic_unified_exec_process_ids_for_tests() { - codex_core::test_support::set_thread_manager_test_mode(/*enabled*/ true); - codex_core::test_support::set_deterministic_process_ids(/*enabled*/ true); -} - -#[ctor] -fn configure_arg0_dispatch_for_test_binaries() { - let _ = TEST_ARG0_PATH_ENTRY.get_or_init(codex_arg0::arg0_dispatch); -} +pub(crate) fn ensure_test_process_initialized() { + TEST_PROCESS_INIT.get_or_init(|| { + codex_core::test_support::set_thread_manager_test_mode(/*enabled*/ true); + codex_core::test_support::set_deterministic_process_ids(/*enabled*/ true); + let _ = TEST_ARG0_PATH_ENTRY.get_or_init(codex_arg0::arg0_dispatch); -#[ctor] -fn configure_insta_workspace_root_for_snapshot_tests() { - if std::env::var_os("INSTA_WORKSPACE_ROOT").is_some() { - return; - } + if std::env::var_os("INSTA_WORKSPACE_ROOT").is_some() { + return; + } - let workspace_root = codex_utils_cargo_bin::repo_root() - .ok() - .map(|root| root.join("codex-rs")); + let workspace_root = codex_utils_cargo_bin::repo_root() + .ok() + .map(|root| root.join("codex-rs")); - if let Some(workspace_root) = workspace_root - && let Ok(workspace_root) = workspace_root.canonicalize() - { - // Safety: this ctor runs at process startup before test threads begin. - unsafe { - std::env::set_var("INSTA_WORKSPACE_ROOT", workspace_root); + if let Some(workspace_root) = workspace_root + && let Ok(workspace_root) = workspace_root.canonicalize() + { + // Safety: guarded by OnceLock so process-global test setup runs + // once before helpers rely on the environment variable. + unsafe { + std::env::set_var("INSTA_WORKSPACE_ROOT", workspace_root); + } } - } + }); } #[track_caller] pub fn assert_regex_match<'s>(pattern: &str, actual: &'s str) -> regex_lite::Captures<'s> { + ensure_test_process_initialized(); let regex = Regex::new(pattern).unwrap_or_else(|err| { panic!("failed to compile regex {pattern:?}: {err}"); }); @@ -129,6 +125,7 @@ pub fn fetch_dotslash_file( dotslash_file: &std::path::Path, dotslash_cache: Option<&std::path::Path>, ) -> anyhow::Result { + ensure_test_process_initialized(); let mut command = std::process::Command::new("dotslash"); command.arg("--").arg("fetch").arg(dotslash_file); if let Some(dotslash_cache) = dotslash_cache { @@ -164,6 +161,7 @@ pub fn fetch_dotslash_file( /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.codex`. pub async fn load_default_config_for_test(codex_home: &TempDir) -> Config { + ensure_test_process_initialized(); ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(default_test_overrides()) @@ -189,6 +187,7 @@ fn default_test_overrides() -> ConfigOverrides { #[cfg(target_os = "linux")] pub fn find_codex_linux_sandbox_exe() -> Result { + ensure_test_process_initialized(); if let Some(path) = TEST_ARG0_PATH_ENTRY .get() .and_then(Option::as_ref) diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a5aba950c..4c596353f 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -854,12 +854,19 @@ fn custom_tool_call_output<'a>(bodies: &'a [Value], call_id: &str) -> &'a Value panic!("custom_tool_call_output {call_id} not found"); } -fn custom_tool_call_output_text(bodies: &[Value], call_id: &str) -> String { +fn try_custom_tool_call_output_text( + bodies: &[Value], + call_id: &str, +) -> std::result::Result { let output = custom_tool_call_output(bodies, call_id) .get("output") - .unwrap_or_else(|| panic!("custom_tool_call_output {call_id} missing output")); + .ok_or_else(|| format!("custom_tool_call_output {call_id} missing output"))?; output_value_to_text(output) - .unwrap_or_else(|| panic!("custom_tool_call_output {call_id} missing text output")) + .ok_or_else(|| format!("custom_tool_call_output {call_id} missing text output")) +} + +fn custom_tool_call_output_text(bodies: &[Value], call_id: &str) -> String { + try_custom_tool_call_output_text(bodies, call_id).unwrap_or_else(|err| panic!("{err}")) } fn function_call_output<'a>(bodies: &'a [Value], call_id: &str) -> &'a Value { @@ -878,6 +885,7 @@ fn function_call_output<'a>(bodies: &'a [Value], call_id: &str) -> &'a Value { } pub fn test_codex() -> TestCodexBuilder { + crate::ensure_test_process_initialized(); TestCodexBuilder { config_mutators: vec![], auth: CodexAuth::from_api_key("dummy"), @@ -908,8 +916,7 @@ mod tests { } #[test] - #[should_panic(expected = "custom_tool_call_output call-2 missing output")] - fn custom_tool_call_output_text_panics_when_output_is_missing() { + fn custom_tool_call_output_text_reports_missing_output() { let bodies = vec![json!({ "input": [{ "type": "custom_tool_call_output", @@ -917,6 +924,9 @@ mod tests { }] })]; - let _ = custom_tool_call_output_text(&bodies, "call-2"); + assert_eq!( + try_custom_tool_call_output_text(&bodies, "call-2"), + Err("custom_tool_call_output call-2 missing output".to_string()) + ); } } diff --git a/codex-rs/core/tests/common/test_codex_exec.rs b/codex-rs/core/tests/common/test_codex_exec.rs index ad32bcb02..7500e4f54 100644 --- a/codex-rs/core/tests/common/test_codex_exec.rs +++ b/codex-rs/core/tests/common/test_codex_exec.rs @@ -42,6 +42,7 @@ fn toml_string_literal(value: &str) -> String { } pub fn test_codex_exec() -> TestCodexExecBuilder { + crate::ensure_test_process_initialized(); TestCodexExecBuilder { home: TempDir::new().expect("create temp home"), cwd: TempDir::new().expect("create temp cwd"), diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 149ba1c53..56a4f97b9 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -96,6 +96,14 @@ async fn retries_on_early_close() { 2, "expected retry after incomplete SSE stream" ); + let first_request: serde_json::Value = + serde_json::from_slice(&requests[0]).expect("first request should be valid JSON"); + let second_request: serde_json::Value = + serde_json::from_slice(&requests[1]).expect("second request should be valid JSON"); + assert_ne!( + first_request["prompt_cache_key"], second_request["prompt_cache_key"], + "expected retry after incomplete stream to rotate prompt_cache_key" + ); server.shutdown().await; } From dc8ccec54e251c6e93e20684d8535e979700b117 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 15 Apr 2026 16:58:55 +0800 Subject: [PATCH 77/83] remove ctor codex-core to support cranelift build --- codex-rs/core/Cargo.toml | 1 - codex-rs/core/src/codex_tests.rs | 18 ++++ codex-rs/core/tests/common/lib.rs | 54 +++++++++-- codex-rs/core/tests/suite/mod.rs | 23 ----- .../core/tests/suite/stream_no_completed.rs | 97 +++++++++++++++++++ 5 files changed, 159 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index a0d425981..3ba2f6ca4 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -153,7 +153,6 @@ codex-otel = { workspace = true } codex-test-binary-support = { workspace = true } codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } -ctor = { workspace = true } insta = { workspace = true } maplit = { workspace = true } opentelemetry = { workspace = true } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index aa5583405..1a52b6e93 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -169,6 +169,24 @@ fn skill_message(text: &str) -> ResponseItem { } } +#[test] +fn rotate_prompt_cache_key_for_stream_disconnected_before_completion() { + let err = CodexErr::Stream( + "stream disconnected before completion: Incomplete response returned, reason: content_filter" + .to_string(), + None, + ); + + assert!(should_rotate_prompt_cache_key_for_stream_retry(&err)); +} + +#[test] +fn does_not_rotate_prompt_cache_key_for_other_stream_errors() { + let err = CodexErr::Stream("Selected model is at capacity.".to_string(), None); + + assert!(!should_rotate_prompt_cache_key_for_stream_retry(&err)); +} + #[tokio::test] async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() { let (sess, tc, rx) = make_session_and_context_with_rx().await; diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index f3adad51d..cd2acbd20 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -2,8 +2,9 @@ use anyhow::Context as _; use anyhow::ensure; -use codex_arg0::Arg0PathEntryGuard; use codex_utils_cargo_bin::CargoBinError; +#[cfg(target_os = "linux")] +use std::os::unix::fs::symlink; use std::sync::OnceLock; use tempfile::TempDir; @@ -27,14 +28,22 @@ pub mod test_codex_exec; pub mod tracing; pub mod zsh_fork; -static TEST_ARG0_PATH_ENTRY: OnceLock> = OnceLock::new(); +#[cfg(target_os = "linux")] +struct TestHelperPaths { + _temp_dir: TempDir, + codex_linux_sandbox_exe: Option, +} + +#[cfg(target_os = "linux")] +static TEST_HELPER_PATHS: OnceLock = OnceLock::new(); static TEST_PROCESS_INIT: OnceLock<()> = OnceLock::new(); pub(crate) fn ensure_test_process_initialized() { TEST_PROCESS_INIT.get_or_init(|| { codex_core::test_support::set_thread_manager_test_mode(/*enabled*/ true); codex_core::test_support::set_deterministic_process_ids(/*enabled*/ true); - let _ = TEST_ARG0_PATH_ENTRY.get_or_init(codex_arg0::arg0_dispatch); + #[cfg(target_os = "linux")] + let _ = TEST_HELPER_PATHS.get_or_init(init_test_helper_paths); if std::env::var_os("INSTA_WORKSPACE_ROOT").is_some() { return; @@ -56,6 +65,36 @@ pub(crate) fn ensure_test_process_initialized() { }); } +#[cfg(target_os = "linux")] +fn init_test_helper_paths() -> TestHelperPaths { + let direct_sandbox_exe = codex_utils_cargo_bin::cargo_bin("codex-linux-sandbox").ok(); + if direct_sandbox_exe.is_some() { + return TestHelperPaths { + _temp_dir: tempfile::Builder::new() + .prefix("codex-core-test-helpers") + .tempdir() + .expect("helper tempdir"), + codex_linux_sandbox_exe: direct_sandbox_exe, + }; + } + + let temp_dir = tempfile::Builder::new() + .prefix("codex-core-test-helpers") + .tempdir() + .expect("helper tempdir"); + let codex_linux_sandbox_exe = codex_utils_cargo_bin::cargo_bin("codex-exec") + .ok() + .and_then(|codex_exec| { + let alias_path = temp_dir.path().join("codex-linux-sandbox"); + symlink(&codex_exec, &alias_path).ok()?; + Some(alias_path) + }); + TestHelperPaths { + _temp_dir: temp_dir, + codex_linux_sandbox_exe, + } +} + #[track_caller] pub fn assert_regex_match<'s>(pattern: &str, actual: &'s str) -> regex_lite::Captures<'s> { ensure_test_process_initialized(); @@ -188,18 +227,13 @@ fn default_test_overrides() -> ConfigOverrides { #[cfg(target_os = "linux")] pub fn find_codex_linux_sandbox_exe() -> Result { ensure_test_process_initialized(); - if let Some(path) = TEST_ARG0_PATH_ENTRY + if let Some(path) = TEST_HELPER_PATHS .get() - .and_then(Option::as_ref) - .and_then(|path_entry| path_entry.paths().codex_linux_sandbox_exe.clone()) + .and_then(|paths| paths.codex_linux_sandbox_exe.clone()) { return Ok(path); } - if let Ok(path) = std::env::current_exe() { - return Ok(path); - } - codex_utils_cargo_bin::cargo_bin("codex-linux-sandbox") } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 9f8389c45..d7a0f67fd 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -1,27 +1,4 @@ // Aggregates all former standalone integration tests as modules. -use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; -use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; -use codex_test_binary_support::TestBinaryDispatchGuard; -use codex_test_binary_support::TestBinaryDispatchMode; -use codex_test_binary_support::configure_test_binary_dispatch; -use ctor::ctor; - -// This code runs before any other tests are run. -// It allows the test binary to behave like codex and dispatch to apply_patch and codex-linux-sandbox -// based on the arg0. -// NOTE: this doesn't work on ARM -#[ctor] -pub static CODEX_ALIASES_TEMP_DIR: Option = { - configure_test_binary_dispatch("codex-core-tests", |exe_name, argv1| { - if argv1 == Some(CODEX_CORE_APPLY_PATCH_ARG1) { - return TestBinaryDispatchMode::DispatchArg0Only; - } - if exe_name == CODEX_LINUX_SANDBOX_ARG0 { - return TestBinaryDispatchMode::DispatchArg0Only; - } - TestBinaryDispatchMode::InstallAliases - }) -}; #[cfg(not(target_os = "windows"))] mod abort_tasks; diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 56a4f97b9..e097286b7 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -22,6 +22,26 @@ fn sse_incomplete() -> String { load_sse_fixture(fixture) } +fn sse_incomplete_with_reason(reason: &str) -> String { + responses::sse(vec![ + responses::ev_response_created("resp_incomplete"), + responses::ev_message_item_added("msg_incomplete", "partial content"), + responses::ev_output_text_delta("continued chunk"), + serde_json::json!({ + "type": "response.incomplete", + "response": { + "id": "resp_incomplete", + "object": "response", + "status": "incomplete", + "error": null, + "incomplete_details": { + "reason": reason, + } + } + }), + ]) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn retries_on_early_close() { skip_if_no_network!(); @@ -107,3 +127,80 @@ async fn retries_on_early_close() { server.shutdown().await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn retries_on_stream_disconnected_before_completion() { + skip_if_no_network!(); + + let incomplete_sse = sse_incomplete_with_reason("content_filter"); + let completed_sse = responses::sse_completed("resp_ok"); + + let (server, _) = start_streaming_sse_server(vec![ + vec![StreamingSseChunk { + gate: None, + body: incomplete_sse, + }], + vec![StreamingSseChunk { + gate: None, + body: completed_sse, + }], + ]) + .await; + + let model_provider = ModelProviderInfo { + name: "openai".into(), + base_url: Some(format!("{}/v1", server.uri())), + env_key: Some("PATH".into()), + env_key_instructions: None, + experimental_bearer_token: None, + auth: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(1), + stream_idle_timeout_ms: Some(2000), + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: false, + }; + + let TestCodex { codex, .. } = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + }) + .build_with_streaming_server(&server) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let requests = server.requests().await; + assert_eq!( + requests.len(), + 2, + "expected retry after incomplete response stream" + ); + let first_request: serde_json::Value = + serde_json::from_slice(&requests[0]).expect("first request should be valid JSON"); + let second_request: serde_json::Value = + serde_json::from_slice(&requests[1]).expect("second request should be valid JSON"); + assert_ne!( + first_request["prompt_cache_key"], second_request["prompt_cache_key"], + "expected stream disconnected before completion retry to rotate prompt_cache_key" + ); + + server.shutdown().await; +} From b60618506f8ff7405a1046bc928c6f345e5fe3bd Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 15 Apr 2026 17:21:53 +0800 Subject: [PATCH 78/83] Add Feishu Base coordination for clawbot runtime --- codex-rs/clawbot/Cargo.toml | 2 +- .../clawbot/src/bin/feishu-bitable-smoke.rs | 73 ++ .../clawbot/src/bin/feishu-runtime-probe.rs | 122 +++ codex-rs/clawbot/src/config.rs | 64 ++ codex-rs/clawbot/src/lib.rs | 1 + codex-rs/clawbot/src/provider/feishu.rs | 1 + .../src/provider/feishu/coordination.rs | 740 ++++++++++++++++++ .../src/provider/feishu/runtime_loop.rs | 201 ++++- codex-rs/clawbot/src/runtime.rs | 2 + codex-rs/tui/src/app/clawbot_controls.rs | 112 ++- codex-rs/tui/src/app/tests/clawbot_tests.rs | 53 ++ codex-rs/tui/src/app_event.rs | 6 + docs/clawbot-feishu-base-coordination.md | 84 ++ 13 files changed, 1444 insertions(+), 17 deletions(-) create mode 100644 codex-rs/clawbot/src/bin/feishu-bitable-smoke.rs create mode 100644 codex-rs/clawbot/src/bin/feishu-runtime-probe.rs create mode 100644 codex-rs/clawbot/src/provider/feishu/coordination.rs create mode 100644 docs/clawbot-feishu-base-coordination.md diff --git a/codex-rs/clawbot/Cargo.toml b/codex-rs/clawbot/Cargo.toml index f69f99455..2b1543a52 100644 --- a/codex-rs/clawbot/Cargo.toml +++ b/codex-rs/clawbot/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [dependencies] anyhow = { workspace = true } -openlark = { version = "0.15.0", default-features = false, features = ["auth", "communication", "websocket"] } +openlark = { version = "0.15.0", default-features = false, features = ["auth", "communication", "docs-bitable", "websocket"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["rt", "sync", "time"] } diff --git a/codex-rs/clawbot/src/bin/feishu-bitable-smoke.rs b/codex-rs/clawbot/src/bin/feishu-bitable-smoke.rs new file mode 100644 index 000000000..59216adcd --- /dev/null +++ b/codex-rs/clawbot/src/bin/feishu-bitable-smoke.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use codex_clawbot::ClawbotRuntime; +use open_lark::openlark_client; +use open_lark::openlark_core::api::ApiRequest; +use open_lark::openlark_core::http::Transport; +use serde_json::Value; + +fn main() -> Result<()> { + let workspace = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("~/clawbot")); + let workspace = expand_home(workspace); + let runtime = ClawbotRuntime::load(workspace.clone()) + .with_context(|| format!("failed to load workspace {}", workspace.display()))?; + let feishu = runtime + .snapshot() + .config + .feishu + .clone() + .context("missing feishu config")?; + let coordination = feishu + .coordination + .clone() + .context("missing feishu coordination config")?; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to build tokio runtime")?; + rt.block_on(async move { + let config = openlark_client::Config::builder() + .app_id(feishu.app_id) + .app_secret(feishu.app_secret) + .timeout(Duration::from_secs(30)) + .build() + .context("failed to build websocket config")? + .build_core_config_with_token_provider(); + + let request: ApiRequest = ApiRequest::get(format!( + "/open-apis/bitable/v1/apps/{}/tables/{}/records", + coordination.base_token, coordination.heartbeat_table_id + )) + .query("page_size", "10"); + let response = Transport::::request(request, &config, Some(Default::default())) + .await + .context("request failed")?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "success": response.is_success(), + "msg": response.msg(), + "data": response.data, + }))? + ); + Ok::<(), anyhow::Error>(()) + })?; + Ok(()) +} + +fn expand_home(path: PathBuf) -> PathBuf { + if let Some(text) = path.to_str() + && let Some(stripped) = text.strip_prefix("~/") + && let Some(home) = std::env::var_os("HOME") + { + return PathBuf::from(home).join(stripped); + } + path +} diff --git a/codex-rs/clawbot/src/bin/feishu-runtime-probe.rs b/codex-rs/clawbot/src/bin/feishu-runtime-probe.rs new file mode 100644 index 000000000..9f9e91747 --- /dev/null +++ b/codex-rs/clawbot/src/bin/feishu-runtime-probe.rs @@ -0,0 +1,122 @@ +use std::env; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use codex_clawbot::ClawbotRuntime; +use codex_clawbot::FeishuProviderRuntime; +use codex_clawbot::ProviderEvent; +use tokio::runtime::Builder; +use tokio::sync::mpsc; + +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, timeout_secs) = parse_args()?; + let runtime = ClawbotRuntime::load(workspace_root.clone()) + .with_context(|| format!("failed to load workspace {}", workspace_root.display()))?; + let provider = runtime + .feishu_provider() + .context("missing [feishu] config in workspace clawbot config")?; + + eprintln!( + "probe starting: workspace={} timeout_secs={timeout_secs}", + workspace_root.display() + ); + + let (provider_event_tx, mut provider_event_rx) = mpsc::unbounded_channel(); + let provider_task = tokio::spawn(run_provider(provider, provider_event_tx)); + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); + + loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => { + eprintln!("probe timeout reached"); + break; + } + maybe_event = provider_event_rx.recv() => { + let Some(event) = maybe_event else { + eprintln!("provider event channel closed"); + break; + }; + print_event(event); + } + } + } + + provider_task.abort(); + let _ = provider_task.await; + Ok(()) +} + +async fn run_provider( + provider: FeishuProviderRuntime, + provider_event_tx: mpsc::UnboundedSender, +) { + if let Err(error) = provider.run(provider_event_tx).await { + eprintln!("provider exited with error: {error}"); + } +} + +fn print_event(event: ProviderEvent) { + match event { + ProviderEvent::RuntimeStateUpdated(state) => { + println!( + "{{\"type\":\"runtime_state\",\"connection\":\"{:?}\",\"last_error\":{},\"updated_at\":{}}}", + state.connection, + serde_json::to_string(&state.last_error).unwrap_or_else(|_| "null".to_string()), + state + .updated_at + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ); + } + ProviderEvent::SessionUpserted(session) => { + println!( + "{{\"type\":\"session_upserted\",\"session_id\":{},\"status\":\"{:?}\"}}", + serde_json::to_string(&session.session_id) + .unwrap_or_else(|_| "\"\"".to_string()), + session.status, + ); + } + ProviderEvent::SessionRemoved(session) => { + println!( + "{{\"type\":\"session_removed\",\"session_id\":{}}}", + serde_json::to_string(&session.session_id) + .unwrap_or_else(|_| "\"\"".to_string()), + ); + } + ProviderEvent::InboundMessage(message) => { + println!( + "{{\"type\":\"inbound_message\",\"session_id\":{},\"message_id\":{}}}", + serde_json::to_string(&message.session.session_id) + .unwrap_or_else(|_| "\"\"".to_string()), + serde_json::to_string(&message.message_id) + .unwrap_or_else(|_| "\"\"".to_string()), + ); + } + } +} + +fn parse_args() -> Result<(PathBuf, u64)> { + let mut args = env::args().skip(1); + let workspace_root = args + .next() + .map(PathBuf::from) + .unwrap_or(env::current_dir().context("failed to resolve current directory")?); + let timeout_secs = args + .next() + .as_deref() + .map(str::parse) + .transpose() + .context("failed to parse timeout seconds")? + .unwrap_or(60); + Ok((workspace_root, timeout_secs)) +} diff --git a/codex-rs/clawbot/src/config.rs b/codex-rs/clawbot/src/config.rs index 4c07757a5..2b8fb6076 100644 --- a/codex-rs/clawbot/src/config.rs +++ b/codex-rs/clawbot/src/config.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use serde::Serialize; +use std::time::Duration; #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -31,6 +32,65 @@ pub struct FeishuConfig { pub encrypt_key: Option, pub bot_open_id: Option, pub bot_user_id: Option, + pub coordination: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct FeishuCoordinationConfig { + pub base_token: String, + pub heartbeat_table_id: String, + pub force_table_id: String, + pub instance_id: Option, + pub owner_priority: i64, + pub heartbeat_interval_secs: u64, + pub heartbeat_ttl_secs: u64, + pub force_connect: bool, +} + +impl Default for FeishuCoordinationConfig { + fn default() -> Self { + Self { + base_token: String::new(), + heartbeat_table_id: String::new(), + force_table_id: String::new(), + instance_id: None, + owner_priority: 100, + heartbeat_interval_secs: 10, + heartbeat_ttl_secs: 30, + force_connect: false, + } + } +} + +impl FeishuCoordinationConfig { + pub fn is_configured(&self) -> bool { + !self.base_token.trim().is_empty() + && !self.heartbeat_table_id.trim().is_empty() + && !self.force_table_id.trim().is_empty() + } + + pub fn is_empty(&self) -> bool { + self.base_token.trim().is_empty() + && self.heartbeat_table_id.trim().is_empty() + && self.force_table_id.trim().is_empty() + && self + .instance_id + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && self.owner_priority == Self::default().owner_priority + && self.heartbeat_interval_secs == Self::default().heartbeat_interval_secs + && self.heartbeat_ttl_secs == Self::default().heartbeat_ttl_secs + && !self.force_connect + } + + pub fn heartbeat_interval(&self) -> Duration { + Duration::from_secs(self.heartbeat_interval_secs.max(1)) + } + + pub fn heartbeat_ttl(&self) -> Duration { + Duration::from_secs(self.heartbeat_ttl_secs.max(self.heartbeat_interval_secs.max(1) * 2)) + } } impl FeishuConfig { @@ -75,5 +135,9 @@ impl FeishuConfig { .bot_user_id .as_deref() .is_none_or(|value| value.trim().is_empty()) + && self + .coordination + .as_ref() + .is_none_or(FeishuCoordinationConfig::is_empty) } } diff --git a/codex-rs/clawbot/src/lib.rs b/codex-rs/clawbot/src/lib.rs index 92645f5cc..250e49844 100644 --- a/codex-rs/clawbot/src/lib.rs +++ b/codex-rs/clawbot/src/lib.rs @@ -9,6 +9,7 @@ mod store; pub use config::ClawbotConfig; pub use config::ClawbotTurnMode; pub use config::FeishuConfig; +pub use config::FeishuCoordinationConfig; pub use diagnostics::append_diagnostic_event; pub use events::ProviderInboundMessage; pub use model::CLAWBOT_BINDINGS_RELATIVE_PATH; diff --git a/codex-rs/clawbot/src/provider/feishu.rs b/codex-rs/clawbot/src/provider/feishu.rs index f0beb70a0..030f81f34 100644 --- a/codex-rs/clawbot/src/provider/feishu.rs +++ b/codex-rs/clawbot/src/provider/feishu.rs @@ -1,3 +1,4 @@ +mod coordination; mod runtime_loop; mod sync; diff --git a/codex-rs/clawbot/src/provider/feishu/coordination.rs b/codex-rs/clawbot/src/provider/feishu/coordination.rs new file mode 100644 index 000000000..babac1354 --- /dev/null +++ b/codex-rs/clawbot/src/provider/feishu/coordination.rs @@ -0,0 +1,740 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::Hash; +use std::hash::Hasher; +use std::path::Path; +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use open_lark::openlark_core::api::ApiRequest; +use open_lark::openlark_core::http::Transport; +use serde_json::Map; +use serde_json::Value; + +use crate::config::FeishuConfig; +use crate::config::FeishuCoordinationConfig; + +const HEARTBEAT_FIELD_KEY: &str = "key"; +const HEARTBEAT_FIELD_APP_ID: &str = "app_id"; +const HEARTBEAT_FIELD_INSTANCE_ID: &str = "instance_id"; +const HEARTBEAT_FIELD_SESSION_ID: &str = "session_id"; +const HEARTBEAT_FIELD_OWNER_PRIORITY: &str = "owner_priority"; +const HEARTBEAT_FIELD_LAST_SEEN_MS: &str = "last_seen_ms"; +const HEARTBEAT_FIELD_TTL_MS: &str = "ttl_ms"; +const HEARTBEAT_FIELD_WS_STATE: &str = "ws_state"; +const HEARTBEAT_FIELD_WORKSPACE_ROOT: &str = "workspace_root"; + +const FORCE_FIELD_KEY: &str = "key"; +const FORCE_FIELD_APP_ID: &str = "app_id"; +const FORCE_FIELD_TARGET_INSTANCE_ID: &str = "target_instance_id"; +const FORCE_FIELD_TARGET_SESSION_ID: &str = "target_session_id"; +const FORCE_FIELD_FORCE_UNTIL_MS: &str = "force_until_ms"; +const FORCE_FIELD_REQUESTED_AT_MS: &str = "requested_at_ms"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum WebsocketOwnershipState { + Idle, + Connected, + BackingOff, +} + +impl WebsocketOwnershipState { + fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Connected => "connected", + Self::BackingOff => "backing_off", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct LeadershipSnapshot { + pub is_leader: bool, + pub leader_instance_id: String, + pub leader_session_id: String, + pub forced_instance_id: Option, +} + +impl LeadershipSnapshot { + pub fn standby_message(&self) -> String { + let forced = self + .forced_instance_id + .as_deref() + .map(|instance_id| format!("; force owner {instance_id}")) + .unwrap_or_default(); + format!( + "leader is {} (session {}){forced}", + self.leader_instance_id, self.leader_session_id + ) + } +} + +#[derive(Debug, Clone)] +pub(super) struct FeishuBaseCoordinator { + client: FeishuBaseClient, + app_id: String, + workspace_root: String, + instance_id: String, + session_id: String, + owner_priority: i64, + heartbeat_interval: Duration, + heartbeat_ttl: Duration, + force_connect: bool, +} + +impl FeishuBaseCoordinator { + pub(super) fn new(workspace_root: &Path, config: &FeishuConfig) -> Result> { + let Some(coordination) = config + .coordination + .clone() + .filter(FeishuCoordinationConfig::is_configured) + else { + return Ok(None); + }; + + let core_config = super::runtime_loop::build_websocket_config(config)? + .build_core_config_with_token_provider(); + let now_ms = unix_timestamp_ms_now()?; + let workspace_root_display = workspace_root.display().to_string(); + let instance_id = coordination + .instance_id + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| default_instance_id(&workspace_root_display)); + let session_id = format!("p{}-{now_ms}", std::process::id()); + let owner_priority = coordination.owner_priority; + let heartbeat_interval = coordination.heartbeat_interval(); + let heartbeat_ttl = coordination.heartbeat_ttl(); + let force_connect = coordination.force_connect; + + Ok(Some(Self { + client: FeishuBaseClient::new(core_config, coordination), + app_id: config.app_id.clone(), + workspace_root: workspace_root_display, + instance_id, + session_id, + owner_priority, + heartbeat_interval, + heartbeat_ttl, + force_connect, + })) + } + + pub(super) fn heartbeat_interval(&self) -> Duration { + self.heartbeat_interval + } + + pub(super) async fn refresh_leadership( + &self, + websocket_state: WebsocketOwnershipState, + ) -> Result { + let now_ms = unix_timestamp_ms_now()?; + let heartbeat = HeartbeatLease { + key: heartbeat_key(&self.app_id, &self.instance_id), + app_id: self.app_id.clone(), + instance_id: self.instance_id.clone(), + session_id: self.session_id.clone(), + owner_priority: self.owner_priority, + last_seen_ms: now_ms, + ttl_ms: duration_to_millis_i64(self.heartbeat_ttl)?, + ws_state: websocket_state.as_str().to_string(), + workspace_root: self.workspace_root.clone(), + }; + self.client.upsert_heartbeat(&heartbeat).await?; + self.client + .sync_force_intent( + &self.app_id, + &self.instance_id, + &self.session_id, + now_ms, + heartbeat.ttl_ms, + self.force_connect, + ) + .await?; + + let leases = self.client.list_heartbeats(&self.app_id).await?; + let force_intent = self.client.get_force_intent(&self.app_id).await?; + select_leader(&self.instance_id, now_ms, &leases, force_intent.as_ref()) + } +} + +#[derive(Debug, Clone)] +struct FeishuBaseClient { + config: open_lark::openlark_core::config::Config, + base_token: String, + heartbeat_table_id: String, + force_table_id: String, +} + +impl FeishuBaseClient { + fn new( + config: open_lark::openlark_core::config::Config, + coordination: FeishuCoordinationConfig, + ) -> Self { + Self { + config, + base_token: coordination.base_token, + heartbeat_table_id: coordination.heartbeat_table_id, + force_table_id: coordination.force_table_id, + } + } + + async fn upsert_heartbeat(&self, heartbeat: &HeartbeatLease) -> Result<()> { + let fields = heartbeat.to_fields(); + match self.find_heartbeat_record_by_key(&heartbeat.key).await? { + Some(record) => { + self.update_record(&self.heartbeat_table_id, &record.record_id, fields) + .await + .context("failed to update Feishu Base heartbeat record")?; + } + None => { + self.create_record(&self.heartbeat_table_id, fields) + .await + .context("failed to create Feishu Base heartbeat record")?; + } + } + Ok(()) + } + + async fn list_heartbeats(&self, app_id: &str) -> Result> { + let mut heartbeats = Vec::new(); + for record in self.list_records(&self.heartbeat_table_id).await? { + if let Some(heartbeat) = HeartbeatLease::from_record(record)? && heartbeat.app_id == app_id + { + heartbeats.push(heartbeat); + } + } + Ok(heartbeats) + } + + async fn sync_force_intent( + &self, + app_id: &str, + target_instance_id: &str, + target_session_id: &str, + now_ms: i64, + ttl_ms: i64, + force_connect: bool, + ) -> Result<()> { + let key = force_key(app_id); + let existing = self.find_force_record_by_key(&key).await?; + let force_until_ms = if force_connect { now_ms + ttl_ms } else { now_ms }; + let should_write = force_connect + || existing.as_ref().is_some_and(|record| { + record.target_instance_id == target_instance_id + && record.force_until_ms > now_ms + }); + if !should_write { + return Ok(()); + } + + let fields = ForceIntentRecord { + record_id: existing.as_ref().and_then(|record| record.record_id.clone()), + key, + app_id: app_id.to_string(), + target_instance_id: target_instance_id.to_string(), + target_session_id: target_session_id.to_string(), + force_until_ms, + requested_at_ms: now_ms, + } + .to_fields(); + if let Some(existing) = existing { + self.update_record( + &self.force_table_id, + &existing + .record_id + .clone() + .context("missing Feishu Base force intent record id")?, + fields, + ) + .await + .context("failed to update Feishu Base force intent record")?; + } else { + self.create_record(&self.force_table_id, fields) + .await + .context("failed to create Feishu Base force intent record")?; + } + Ok(()) + } + + async fn get_force_intent(&self, app_id: &str) -> Result> { + self.find_force_record_by_key(&force_key(app_id)).await + } + + async fn find_force_record_by_key(&self, key: &str) -> Result> { + let mut matches = Vec::new(); + for record in self.list_records(&self.force_table_id).await? { + if let Some(force_intent) = ForceIntentRecord::from_record(record)? + && force_intent.key == key + { + matches.push(force_intent); + } + } + matches.sort_by(|left, right| { + left.requested_at_ms + .cmp(&right.requested_at_ms) + .then(left.record_id.cmp(&right.record_id)) + }); + Ok(matches.pop()) + } + + async fn find_heartbeat_record_by_key(&self, key: &str) -> Result> { + let mut matches = self + .list_records(&self.heartbeat_table_id) + .await? + .into_iter() + .filter(|record| { + record + .fields + .as_object() + .and_then(|fields| string_field(fields, HEARTBEAT_FIELD_KEY)) + .is_some_and(|record_key| record_key == key) + }) + .collect::>(); + matches.sort_by(|left, right| left.record_id.cmp(&right.record_id)); + Ok(matches.pop()) + } + + async fn list_records(&self, table_id: &str) -> Result> { + let mut page_token = None; + let mut records = Vec::new(); + loop { + let mut request: ApiRequest = ApiRequest::get(records_url( + &self.base_token, + table_id, + )) + .query("page_size", "500"); + if let Some(token) = page_token.clone() { + request = request.query("page_token", token); + } + let response = Transport::::request( + request, + &self.config, + Some(Default::default()), + ) + .await + .with_context(|| format!("failed to list Feishu Base records for table {table_id}"))?; + if !response.is_success() { + return Err(anyhow!( + "failed to list Feishu Base records for table {table_id}: {}", + response.msg() + )); + } + let data = response + .data + .as_ref() + .context("Feishu Base record list response is missing data")?; + let payload = data.get("items").is_some().then_some(data).or_else(|| data.get("data")); + let payload = payload.context("Feishu Base record list response is missing items payload")?; + if let Some(items) = payload.get("items").and_then(Value::as_array) { + for item in items { + if let Some(record) = BaseRecord::from_value(item) { + records.push(record); + } + } + } + let has_more = payload + .get("has_more") + .and_then(Value::as_bool) + .unwrap_or(false); + if !has_more { + break; + } + let Some(next_page_token) = payload + .get("page_token") + .and_then(Value::as_str) + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(str::to_owned) + else { + break; + }; + page_token = Some(next_page_token); + } + Ok(records) + } + + async fn create_record(&self, table_id: &str, fields: Value) -> Result<()> { + let request: ApiRequest = ApiRequest::post(records_url(&self.base_token, table_id)) + .json_body(&serde_json::json!({ "fields": fields })); + let response = + Transport::::request(request, &self.config, Some(Default::default())).await?; + if response.is_success() { + Ok(()) + } else { + Err(anyhow!("failed to create Feishu Base record: {}", response.msg())) + } + } + + async fn update_record(&self, table_id: &str, record_id: &str, fields: Value) -> Result<()> { + let request: ApiRequest = + ApiRequest::put(record_url(&self.base_token, table_id, record_id)) + .json_body(&serde_json::json!({ "fields": fields })); + let response = + Transport::::request(request, &self.config, Some(Default::default())).await?; + if response.is_success() { + Ok(()) + } else { + Err(anyhow!("failed to update Feishu Base record: {}", response.msg())) + } + } +} + +#[derive(Debug, Clone, PartialEq)] +struct BaseRecord { + record_id: String, + fields: Value, +} + +impl BaseRecord { + fn from_value(value: &Value) -> Option { + Some(Self { + record_id: value.get("record_id")?.as_str()?.to_string(), + fields: value.get("fields")?.clone(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct HeartbeatLease { + key: String, + app_id: String, + instance_id: String, + session_id: String, + owner_priority: i64, + last_seen_ms: i64, + ttl_ms: i64, + ws_state: String, + workspace_root: String, +} + +impl HeartbeatLease { + fn from_record(record: BaseRecord) -> Result> { + let Some(fields) = record.fields.as_object() else { + return Ok(None); + }; + let Some(app_id) = string_field(fields, HEARTBEAT_FIELD_APP_ID) else { + return Ok(None); + }; + let Some(instance_id) = string_field(fields, HEARTBEAT_FIELD_INSTANCE_ID) else { + return Ok(None); + }; + let Some(session_id) = string_field(fields, HEARTBEAT_FIELD_SESSION_ID) else { + return Ok(None); + }; + Ok(Some(Self { + key: string_field(fields, HEARTBEAT_FIELD_KEY) + .unwrap_or_else(|| heartbeat_key(&app_id, &instance_id)), + app_id, + instance_id, + session_id, + owner_priority: integer_field(fields, HEARTBEAT_FIELD_OWNER_PRIORITY).unwrap_or(0), + last_seen_ms: integer_field(fields, HEARTBEAT_FIELD_LAST_SEEN_MS).unwrap_or(0), + ttl_ms: integer_field(fields, HEARTBEAT_FIELD_TTL_MS).unwrap_or(0), + ws_state: string_field(fields, HEARTBEAT_FIELD_WS_STATE).unwrap_or_default(), + workspace_root: string_field(fields, HEARTBEAT_FIELD_WORKSPACE_ROOT) + .unwrap_or_default(), + })) + } + + fn is_active(&self, now_ms: i64) -> bool { + self.last_seen_ms + self.ttl_ms >= now_ms + } + + fn to_fields(&self) -> Value { + serde_json::json!({ + HEARTBEAT_FIELD_KEY: self.key, + HEARTBEAT_FIELD_APP_ID: self.app_id, + HEARTBEAT_FIELD_INSTANCE_ID: self.instance_id, + HEARTBEAT_FIELD_SESSION_ID: self.session_id, + HEARTBEAT_FIELD_OWNER_PRIORITY: self.owner_priority, + HEARTBEAT_FIELD_LAST_SEEN_MS: self.last_seen_ms, + HEARTBEAT_FIELD_TTL_MS: self.ttl_ms, + HEARTBEAT_FIELD_WS_STATE: self.ws_state, + HEARTBEAT_FIELD_WORKSPACE_ROOT: self.workspace_root, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ForceIntentRecord { + record_id: Option, + key: String, + app_id: String, + target_instance_id: String, + target_session_id: String, + force_until_ms: i64, + requested_at_ms: i64, +} + +impl ForceIntentRecord { + fn from_record(record: BaseRecord) -> Result> { + let Some(fields) = record.fields.as_object() else { + return Ok(None); + }; + let Some(app_id) = string_field(fields, FORCE_FIELD_APP_ID) else { + return Ok(None); + }; + let Some(target_instance_id) = string_field(fields, FORCE_FIELD_TARGET_INSTANCE_ID) else { + return Ok(None); + }; + let Some(target_session_id) = string_field(fields, FORCE_FIELD_TARGET_SESSION_ID) else { + return Ok(None); + }; + Ok(Some(Self { + record_id: Some(record.record_id), + key: string_field(fields, FORCE_FIELD_KEY).unwrap_or_else(|| force_key(&app_id)), + app_id, + target_instance_id, + target_session_id, + force_until_ms: integer_field(fields, FORCE_FIELD_FORCE_UNTIL_MS).unwrap_or(0), + requested_at_ms: integer_field(fields, FORCE_FIELD_REQUESTED_AT_MS).unwrap_or(0), + })) + } + + fn is_active(&self, now_ms: i64) -> bool { + self.force_until_ms >= now_ms + } + + fn to_fields(&self) -> Value { + serde_json::json!({ + FORCE_FIELD_KEY: self.key, + FORCE_FIELD_APP_ID: self.app_id, + FORCE_FIELD_TARGET_INSTANCE_ID: self.target_instance_id, + FORCE_FIELD_TARGET_SESSION_ID: self.target_session_id, + FORCE_FIELD_FORCE_UNTIL_MS: self.force_until_ms, + FORCE_FIELD_REQUESTED_AT_MS: self.requested_at_ms, + }) + } +} + +fn select_leader( + current_instance_id: &str, + now_ms: i64, + leases: &[HeartbeatLease], + force_intent: Option<&ForceIntentRecord>, +) -> Result { + let mut active = leases + .iter() + .filter(|lease| lease.is_active(now_ms)) + .cloned() + .collect::>(); + active.sort_by(|left, right| { + right + .owner_priority + .cmp(&left.owner_priority) + .then(left.instance_id.cmp(&right.instance_id)) + .then(left.session_id.cmp(&right.session_id)) + }); + let leader = if let Some(force_intent) = force_intent.filter(|intent| intent.is_active(now_ms)) { + if let Some(forced_lease) = active + .iter() + .find(|lease| lease.instance_id == force_intent.target_instance_id) + { + forced_lease.clone() + } else { + active + .first() + .cloned() + .ok_or_else(|| anyhow!("no active Feishu Base coordination heartbeat rows found"))? + } + } else { + active + .first() + .cloned() + .ok_or_else(|| anyhow!("no active Feishu Base coordination heartbeat rows found"))? + }; + Ok(LeadershipSnapshot { + is_leader: leader.instance_id == current_instance_id, + leader_instance_id: leader.instance_id, + leader_session_id: leader.session_id, + forced_instance_id: force_intent + .filter(|intent| intent.is_active(now_ms)) + .map(|intent| intent.target_instance_id.clone()), + }) +} + +fn default_instance_id(workspace_root: &str) -> String { + let hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "local".to_string()); + let mut hasher = DefaultHasher::new(); + workspace_root.hash(&mut hasher); + format!("{hostname}-{}-{:x}", std::process::id(), hasher.finish()) +} + +fn heartbeat_key(app_id: &str, instance_id: &str) -> String { + format!("{app_id}:{instance_id}") +} + +fn force_key(app_id: &str) -> String { + app_id.to_string() +} + +fn records_url(base_token: &str, table_id: &str) -> String { + format!("/open-apis/bitable/v1/apps/{base_token}/tables/{table_id}/records") +} + +fn record_url(base_token: &str, table_id: &str, record_id: &str) -> String { + format!("/open-apis/bitable/v1/apps/{base_token}/tables/{table_id}/records/{record_id}") +} + +fn string_field(fields: &Map, key: &str) -> Option { + fields + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn integer_field(fields: &Map, key: &str) -> Option { + let value = fields.get(key)?; + value + .as_i64() + .or_else(|| value.as_u64().and_then(|raw| i64::try_from(raw).ok())) + .or_else(|| value.as_f64().map(|raw| raw as i64)) + .or_else(|| value.as_str().and_then(|raw| raw.parse().ok())) +} + +fn duration_to_millis_i64(duration: Duration) -> Result { + i64::try_from(duration.as_millis()).context("duration does not fit into i64 milliseconds") +} + +fn unix_timestamp_ms_now() -> Result { + let elapsed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before unix epoch")?; + i64::try_from(elapsed.as_millis()).context("unix timestamp milliseconds exceed i64") +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::ForceIntentRecord; + use super::HeartbeatLease; + use super::select_leader; + + #[test] + fn select_leader_prefers_higher_priority_then_instance_id() { + let leader = select_leader( + "instance_b", + 10_000, + &[ + heartbeat("instance_b", "session_b", 200, 9_900, 500), + heartbeat("instance_a", "session_a", 200, 9_900, 500), + heartbeat("instance_c", "session_c", 100, 9_900, 500), + ], + None, + ) + .expect("leader"); + + assert_eq!( + leader, + super::LeadershipSnapshot { + is_leader: false, + leader_instance_id: "instance_a".to_string(), + leader_session_id: "session_a".to_string(), + forced_instance_id: None, + } + ); + } + + #[test] + fn select_leader_honors_active_force_intent() { + let leader = select_leader( + "instance_b", + 10_000, + &[ + heartbeat("instance_a", "session_a", 200, 9_900, 500), + heartbeat("instance_b", "session_b", 100, 9_900, 500), + ], + Some(&force("instance_b", "session_b", 10_500, 9_999)), + ) + .expect("leader"); + + assert_eq!( + leader, + super::LeadershipSnapshot { + is_leader: true, + leader_instance_id: "instance_b".to_string(), + leader_session_id: "session_b".to_string(), + forced_instance_id: Some("instance_b".to_string()), + } + ); + } + + #[test] + fn select_leader_ignores_expired_force_intent() { + let leader = select_leader( + "instance_b", + 10_000, + &[ + heartbeat("instance_a", "session_a", 200, 9_900, 500), + heartbeat("instance_b", "session_b", 100, 9_900, 500), + ], + Some(&force("instance_b", "session_b", 9_999, 9_900)), + ) + .expect("leader"); + + assert_eq!(leader.leader_instance_id, "instance_a"); + assert_eq!(leader.forced_instance_id, None); + } + + #[test] + fn select_leader_ignores_force_intent_when_target_is_inactive() { + let leader = select_leader( + "instance_b", + 10_000, + &[ + heartbeat("instance_a", "session_a", 200, 9_900, 500), + heartbeat("instance_b", "session_b", 100, 9_000, 500), + ], + Some(&force("instance_b", "session_b", 10_500, 9_999)), + ) + .expect("leader"); + + assert_eq!(leader.leader_instance_id, "instance_a"); + assert_eq!(leader.forced_instance_id, Some("instance_b".to_string())); + } + + fn heartbeat( + instance_id: &str, + session_id: &str, + owner_priority: i64, + last_seen_ms: i64, + ttl_ms: i64, + ) -> HeartbeatLease { + HeartbeatLease { + key: format!("app_test:{instance_id}"), + app_id: "app_test".to_string(), + instance_id: instance_id.to_string(), + session_id: session_id.to_string(), + owner_priority, + last_seen_ms, + ttl_ms, + ws_state: "idle".to_string(), + workspace_root: "/tmp/workspace".to_string(), + } + } + + fn force( + target_instance_id: &str, + target_session_id: &str, + force_until_ms: i64, + requested_at_ms: i64, + ) -> ForceIntentRecord { + ForceIntentRecord { + record_id: Some("rec_force".to_string()), + key: "app_test".to_string(), + app_id: "app_test".to_string(), + target_instance_id: target_instance_id.to_string(), + target_session_id: target_session_id.to_string(), + force_until_ms, + requested_at_ms, + } + } +} diff --git a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs index 563cf9380..d3aa9c1df 100644 --- a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs +++ b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs @@ -2,6 +2,7 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use std::time::Instant; use anyhow::Result; use anyhow::anyhow; @@ -9,7 +10,10 @@ 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 tokio::task::JoinHandle; +use super::coordination::FeishuBaseCoordinator; +use super::coordination::WebsocketOwnershipState; use super::provider_events_from_payload; use super::runtime_state; use crate::append_diagnostic_event; @@ -33,46 +37,137 @@ pub(super) async fn run_with_reconnect( return Err(anyhow!("missing app_id/app_secret")); } + if let Some(coordination) = FeishuBaseCoordinator::new(workspace_root.as_path(), &config)? { + return run_with_coordination(workspace_root, config, provider_event_tx, coordination).await; + } + let mut reconnect_delay = INITIAL_RECONNECT_DELAY; loop { match run_once(workspace_root.as_path(), &config, &provider_event_tx).await { Ok(()) => { + emit_reconnect_state( + workspace_root.as_path(), + &provider_event_tx, + reconnect_delay, + Ok(()), + )?; + } + Err(error) => { + emit_reconnect_state( + workspace_root.as_path(), + &provider_event_tx, + reconnect_delay, + Err(&error), + )?; + } + } + + tokio::time::sleep(reconnect_delay).await; + reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY); + } +} + +async fn run_with_coordination( + workspace_root: PathBuf, + config: FeishuConfig, + provider_event_tx: mpsc::UnboundedSender, + coordination: FeishuBaseCoordinator, +) -> Result<()> { + let mut reconnect_delay = INITIAL_RECONNECT_DELAY; + let mut reconnect_at = None::; + let mut websocket_task = None::>>; + let mut last_standby_message = None::; + + loop { + if let Some(handle) = websocket_task.as_ref() && handle.is_finished() { + let result = websocket_task + .take() + .expect("finished websocket task should still be present") + .await + .map_err(|error| anyhow!("Feishu websocket runtime task failed: {error}"))?; + emit_reconnect_state( + workspace_root.as_path(), + &provider_event_tx, + reconnect_delay, + result.as_ref().map(|_| ()).map_err(|error| error), + )?; + reconnect_at = Some(Instant::now() + reconnect_delay); + reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY); + } + + let websocket_state = websocket_ownership_state(&websocket_task, reconnect_at); + let leadership = match coordination.refresh_leadership(websocket_state).await { + Ok(leadership) => leadership, + Err(error) => { + if let Some(handle) = websocket_task.take() { + handle.abort(); + let _ = handle.await; + } let _ = append_diagnostic_event( workspace_root.as_path(), - "feishu.runtime_disconnected", + "feishu.coordination_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::Disconnected, + ConnectionStatus::Error, Some(format!( - "Feishu websocket runtime exited; reconnecting in {}s", + "Feishu Base coordination failed: {error}; retrying in {}s", reconnect_delay.as_secs() )), )?)); + last_standby_message = None; + reconnect_at = Some(Instant::now() + reconnect_delay); + reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY); + tokio::time::sleep(reconnect_delay).await; + continue; } - Err(error) => { + }; + + if leadership.is_leader { + last_standby_message = None; + if websocket_task.is_none() && backoff_elapsed(reconnect_at) { + websocket_task = Some(spawn_websocket_task( + workspace_root.clone(), + config.clone(), + provider_event_tx.clone(), + )); + reconnect_at = None; + reconnect_delay = INITIAL_RECONNECT_DELAY; + } + } else { + reconnect_delay = INITIAL_RECONNECT_DELAY; + reconnect_at = None; + if let Some(handle) = websocket_task.take() { + handle.abort(); + let _ = handle.await; let _ = append_diagnostic_event( workspace_root.as_path(), - "feishu.runtime_failed", + "feishu.runtime_leadership_released", serde_json::json!({ - "error": error.to_string(), - "reconnect_delay_secs": reconnect_delay.as_secs(), + "leader_instance_id": leadership.leader_instance_id, + "leader_session_id": leadership.leader_session_id, }), ); + } + + let standby_message = format!("Feishu standby: {}", leadership.standby_message()); + if last_standby_message.as_deref() != Some(standby_message.as_str()) { 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() - )), + ConnectionStatus::Disconnected, + Some(standby_message.clone()), )?)); + last_standby_message = Some(standby_message); } } - tokio::time::sleep(reconnect_delay).await; - reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY); + tokio::time::sleep(sleep_duration( + coordination.heartbeat_interval(), + reconnect_at, + )) + .await; } } @@ -147,3 +242,81 @@ fn payload_debug_value(payload: &[u8]) -> serde_json::Value { }) }) } + +fn spawn_websocket_task( + workspace_root: PathBuf, + config: FeishuConfig, + provider_event_tx: mpsc::UnboundedSender, +) -> JoinHandle> { + tokio::spawn(async move { run_once(workspace_root.as_path(), &config, &provider_event_tx).await }) +} + +fn emit_reconnect_state( + workspace_root: &Path, + provider_event_tx: &mpsc::UnboundedSender, + reconnect_delay: Duration, + result: Result<(), &anyhow::Error>, +) -> Result<()> { + match result { + Ok(()) => { + let _ = append_diagnostic_event( + workspace_root, + "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!( + "Feishu websocket runtime exited; reconnecting in {}s", + reconnect_delay.as_secs() + )), + )?)); + } + Err(error) => { + let _ = append_diagnostic_event( + workspace_root, + "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!( + "Feishu websocket runtime failed: {error}; reconnecting in {}s", + reconnect_delay.as_secs() + )), + )?)); + } + } + Ok(()) +} + +fn websocket_ownership_state( + websocket_task: &Option>>, + reconnect_at: Option, +) -> WebsocketOwnershipState { + if websocket_task.is_some() { + return WebsocketOwnershipState::Connected; + } + if !backoff_elapsed(reconnect_at) { + return WebsocketOwnershipState::BackingOff; + } + WebsocketOwnershipState::Idle +} + +fn backoff_elapsed(reconnect_at: Option) -> bool { + reconnect_at.is_none_or(|instant| instant <= Instant::now()) +} + +fn sleep_duration(heartbeat_interval: Duration, reconnect_at: Option) -> Duration { + if let Some(reconnect_at) = reconnect_at { + return reconnect_at + .saturating_duration_since(Instant::now()) + .min(heartbeat_interval); + } + heartbeat_interval +} diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs index 48e288e45..3b783f320 100644 --- a/codex-rs/clawbot/src/runtime.rs +++ b/codex-rs/clawbot/src/runtime.rs @@ -595,6 +595,7 @@ mod tests { encrypt_key: None, bot_open_id: None, bot_user_id: None, + coordination: None, })) .expect("save config"); assert_eq!( @@ -606,6 +607,7 @@ mod tests { encrypt_key: None, bot_open_id: None, bot_user_id: None, + coordination: None, }) ); diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index 660327001..1345dbe87 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -9,6 +9,7 @@ use codex_clawbot::ClawbotStore; use codex_clawbot::ClawbotTurnMode; use codex_clawbot::ConnectionStatus; use codex_clawbot::FeishuConfig; +use codex_clawbot::FeishuCoordinationConfig; use codex_clawbot::ForwardingDirection; use codex_clawbot::ForwardingState; use codex_clawbot::ProviderKind; @@ -47,6 +48,12 @@ impl ClawbotFeishuConfigField { Self::EncryptKey => "Feishu Encrypt Key", Self::BotOpenId => "Feishu Bot Open ID", Self::BotUserId => "Feishu Bot User ID", + Self::CoordinationBaseToken => "Coordination Base Token", + Self::CoordinationHeartbeatTableId => "Heartbeat Table ID", + Self::CoordinationForceTableId => "Force Table ID", + Self::CoordinationInstanceId => "Coordination Instance ID", + Self::CoordinationOwnerPriority => "Coordination Priority", + Self::CoordinationForceConnect => "Force Connect", } } @@ -58,6 +65,24 @@ impl ClawbotFeishuConfigField { 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()), + Self::CoordinationBaseToken => config + .and_then(|config| config.coordination.as_ref()) + .map(|coordination| coordination.base_token.clone()), + Self::CoordinationHeartbeatTableId => config + .and_then(|config| config.coordination.as_ref()) + .map(|coordination| coordination.heartbeat_table_id.clone()), + Self::CoordinationForceTableId => config + .and_then(|config| config.coordination.as_ref()) + .map(|coordination| coordination.force_table_id.clone()), + Self::CoordinationInstanceId => config + .and_then(|config| config.coordination.as_ref()) + .and_then(|coordination| coordination.instance_id.clone()), + Self::CoordinationOwnerPriority => config + .and_then(|config| config.coordination.as_ref()) + .map(|coordination| coordination.owner_priority.to_string()), + Self::CoordinationForceConnect => config + .and_then(|config| config.coordination.as_ref()) + .map(|coordination| coordination.force_connect.to_string()), }?; let trimmed = value.trim().to_string(); (!trimmed.is_empty()).then_some(trimmed) @@ -84,6 +109,24 @@ impl ClawbotFeishuConfigField { 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", + Self::CoordinationBaseToken => { + "Paste the Feishu Base token, or submit an empty value to clear it" + } + Self::CoordinationHeartbeatTableId => { + "Paste the heartbeat table_id, or submit an empty value to clear it" + } + Self::CoordinationForceTableId => { + "Paste the force table_id, or submit an empty value to clear it" + } + Self::CoordinationInstanceId => { + "Paste an optional stable instance_id, or submit an empty value to auto-generate" + } + Self::CoordinationOwnerPriority => { + "Paste the coordination priority integer, or submit an empty value for the default" + } + Self::CoordinationForceConnect => { + "Enter true or false to control whether this Codex process force-preempts leadership" + } } } @@ -102,7 +145,10 @@ impl ClawbotFeishuConfigField { fn is_secret(self) -> bool { matches!( self, - Self::AppSecret | Self::VerificationToken | Self::EncryptKey + Self::AppSecret + | Self::VerificationToken + | Self::EncryptKey + | Self::CoordinationBaseToken ) } } @@ -187,6 +233,7 @@ impl App { ) -> Result<()> { let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; let mut config = runtime.snapshot().config.feishu.clone().unwrap_or_default(); + let mut coordination = config.coordination.clone().unwrap_or_default(); let trimmed = value.trim().to_string(); match field { ClawbotFeishuConfigField::AppId => { @@ -207,7 +254,38 @@ impl App { ClawbotFeishuConfigField::BotUserId => { config.bot_user_id = (!trimmed.is_empty()).then_some(trimmed); } + ClawbotFeishuConfigField::CoordinationBaseToken => { + coordination.base_token = trimmed; + } + ClawbotFeishuConfigField::CoordinationHeartbeatTableId => { + coordination.heartbeat_table_id = trimmed; + } + ClawbotFeishuConfigField::CoordinationForceTableId => { + coordination.force_table_id = trimmed; + } + ClawbotFeishuConfigField::CoordinationInstanceId => { + coordination.instance_id = (!trimmed.is_empty()).then_some(trimmed); + } + ClawbotFeishuConfigField::CoordinationOwnerPriority => { + coordination.owner_priority = if trimmed.is_empty() { + FeishuCoordinationConfig::default().owner_priority + } else { + trimmed.parse().context("coordination priority must be an integer")? + }; + } + ClawbotFeishuConfigField::CoordinationForceConnect => { + coordination.force_connect = match trimmed.to_ascii_lowercase().as_str() { + "" | "false" | "0" | "off" | "no" => false, + "true" | "1" | "on" | "yes" => true, + _ => { + return Err(anyhow::anyhow!( + "force connect must be one of: true, false, 1, 0, on, off, yes, no" + )); + } + }; + } } + config.coordination = (!coordination.is_empty()).then_some(coordination); runtime.update_feishu_config(Some(config))?; self.refresh_clawbot_provider_runtime()?; self.refresh_clawbot_management_popup(); @@ -737,6 +815,30 @@ impl App { clawbot_config_item(ClawbotFeishuConfigField::EncryptKey, feishu_config), clawbot_config_item(ClawbotFeishuConfigField::BotOpenId, feishu_config), clawbot_config_item(ClawbotFeishuConfigField::BotUserId, feishu_config), + clawbot_config_item( + ClawbotFeishuConfigField::CoordinationBaseToken, + feishu_config, + ), + clawbot_config_item( + ClawbotFeishuConfigField::CoordinationHeartbeatTableId, + feishu_config, + ), + clawbot_config_item( + ClawbotFeishuConfigField::CoordinationForceTableId, + feishu_config, + ), + clawbot_config_item( + ClawbotFeishuConfigField::CoordinationInstanceId, + feishu_config, + ), + clawbot_config_item( + ClawbotFeishuConfigField::CoordinationOwnerPriority, + feishu_config, + ), + clawbot_config_item( + ClawbotFeishuConfigField::CoordinationForceConnect, + feishu_config, + ), ], ClawbotControlsDestination::Diagnostics => vec![ clawbot_back_item( @@ -916,12 +1018,18 @@ fn clawbot_config_summary(config: Option<&FeishuConfig>, turn_mode: ClawbotTurnM ClawbotFeishuConfigField::EncryptKey, ClawbotFeishuConfigField::BotOpenId, ClawbotFeishuConfigField::BotUserId, + ClawbotFeishuConfigField::CoordinationBaseToken, + ClawbotFeishuConfigField::CoordinationHeartbeatTableId, + ClawbotFeishuConfigField::CoordinationForceTableId, + ClawbotFeishuConfigField::CoordinationInstanceId, + ClawbotFeishuConfigField::CoordinationOwnerPriority, + ClawbotFeishuConfigField::CoordinationForceConnect, ] .into_iter() .filter(|field| field.current_value(config).is_some()) .count(); format!( - "{configured}/6 configured · {}", + "{configured}/12 configured · {}", clawbot_turn_mode_label(turn_mode) ) } diff --git a/codex-rs/tui/src/app/tests/clawbot_tests.rs b/codex-rs/tui/src/app/tests/clawbot_tests.rs index ff4587f6f..8a759a584 100644 --- a/codex-rs/tui/src/app/tests/clawbot_tests.rs +++ b/codex-rs/tui/src/app/tests/clawbot_tests.rs @@ -684,6 +684,58 @@ async fn clawbot_manual_bind_replays_cached_unread_messages() -> Result<()> { Ok(()) } +#[tokio::test] +async fn clawbot_manual_bind_allows_undiscovered_chat_id_with_configured_feishu() -> 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 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: None, + encrypt_key: None, + bot_open_id: Some("ou_bot_open_id".to_string()), + bot_user_id: None, + coordination: None, + })) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + app.bind_clawbot_session_to_current_thread( + &mut app_server, + "chat_manual_only".to_string(), + ClawbotSessionBindSource::ManualSessionId, + ) + .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(ProviderSessionRef::new( + ClawbotProviderKind::Feishu, + "chat_manual_only" + )) + ); + Ok(()) +} + #[tokio::test] async fn clawbot_current_thread_controls_update_binding_state() -> Result<()> { let mut app = make_test_app().await; @@ -754,6 +806,7 @@ async fn clawbot_management_popup_snapshot() -> Result<()> { encrypt_key: None, bot_open_id: Some("ou_bot_open_id".to_string()), bot_user_id: None, + coordination: None, })) .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; runtime diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4bb7f75e9..7baa524c4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -85,6 +85,12 @@ pub(crate) enum ClawbotFeishuConfigField { EncryptKey, BotOpenId, BotUserId, + CoordinationBaseToken, + CoordinationHeartbeatTableId, + CoordinationForceTableId, + CoordinationInstanceId, + CoordinationOwnerPriority, + CoordinationForceConnect, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/docs/clawbot-feishu-base-coordination.md b/docs/clawbot-feishu-base-coordination.md new file mode 100644 index 000000000..886e2310f --- /dev/null +++ b/docs/clawbot-feishu-base-coordination.md @@ -0,0 +1,84 @@ +# Clawbot Feishu Base Coordination + +`codex-clawbot` can use Feishu Base as a free coordination backend for embedded websocket +ownership. Every Codex process participates in leader election, but only the elected leader for a +given Feishu `app_id` should hold the websocket connection. + +## Ownership + +The runtime data should be owned and maintained by the Feishu app itself: + +- The Feishu app credentials (`app_id` / `app_secret`) write heartbeat and force-intent rows. +- `lark-cli` is only a bootstrap and repair tool for creating the Base, inspecting rows, or fixing + schema drift. +- Do not make `lark-cli` a runtime dependency for normal websocket ownership. + +## Config + +Add the coordination block under `.codex/clawbot/config.toml`: + +```toml +[feishu] +app_id = "cli_xxx" +app_secret = "xxx" + +[feishu.coordination] +base_token = "bascnxxxx" +heartbeat_table_id = "tblHeartbeat" +force_table_id = "tblForce" +owner_priority = 100 +force_connect = false +# Optional. When empty, codex-clawbot auto-generates a per-process instance id. +# instance_id = "machine-a-codex-1" +``` + +`force_connect = true` means the current Codex process continuously refreshes a force-intent row +for its own `app_id`, so it preempts other contenders as soon as they observe the update. + +## Base Schema + +Create one Base with two tables. Use ASCII field names exactly as listed here. + +### `heartbeat` + +Required fields: + +- `key` (text, primary field): `{app_id}:{instance_id}` +- `app_id` (text) +- `instance_id` (text) +- `session_id` (text) +- `owner_priority` (number) +- `last_seen_ms` (number) +- `ttl_ms` (number) +- `ws_state` (text) +- `workspace_root` (text) + +Each Codex process owns one heartbeat row per `app_id + instance_id` and updates it on every +coordination tick. + +### `force` + +Required fields: + +- `key` (text, primary field): `{app_id}` +- `app_id` (text) +- `target_instance_id` (text) +- `target_session_id` (text) +- `force_until_ms` (number) +- `requested_at_ms` (number) + +There should be at most one logical active force row per `app_id`. The runtime treats the newest +row as authoritative. + +## Election Rule + +Leader selection is deterministic: + +1. Keep only heartbeat rows whose `last_seen_ms + ttl_ms >= now`. +2. If an active force row exists and its target instance still has an active heartbeat, that target + wins. +3. Otherwise choose the highest `owner_priority`. +4. Break ties by `instance_id` ascending, then `session_id` ascending. + +When a process loses leadership, it should stop owning the websocket immediately and continue +publishing only heartbeat state until it becomes leader again. From 0a1e3385ac9d2b600a9099b2bbb5b1957de25dd5 Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 15 Apr 2026 17:41:04 +0800 Subject: [PATCH 79/83] Auto-provision clawbot Feishu coordination tables --- codex-rs/clawbot/src/config.rs | 32 +- .../src/provider/feishu/coordination.rs | 554 ++++++++++++++++-- codex-rs/tui/src/app/clawbot_controls.rs | 4 +- docs/clawbot-feishu-base-coordination.md | 18 +- 4 files changed, 549 insertions(+), 59 deletions(-) diff --git a/codex-rs/clawbot/src/config.rs b/codex-rs/clawbot/src/config.rs index 2b8fb6076..d2acb9120 100644 --- a/codex-rs/clawbot/src/config.rs +++ b/codex-rs/clawbot/src/config.rs @@ -66,8 +66,6 @@ impl Default for FeishuCoordinationConfig { impl FeishuCoordinationConfig { pub fn is_configured(&self) -> bool { !self.base_token.trim().is_empty() - && !self.heartbeat_table_id.trim().is_empty() - && !self.force_table_id.trim().is_empty() } pub fn is_empty(&self) -> bool { @@ -93,6 +91,36 @@ impl FeishuCoordinationConfig { } } +#[cfg(test)] +mod tests { + use super::FeishuCoordinationConfig; + + #[test] + fn coordination_is_configured_with_base_token_only() { + let mut config = FeishuCoordinationConfig { + base_token: "bascn_test".to_string(), + ..FeishuCoordinationConfig::default() + }; + + assert!(config.is_configured()); + + config.base_token.clear(); + assert!(!config.is_configured()); + } + + #[test] + fn coordination_ttl_is_at_least_twice_the_interval() { + let config = FeishuCoordinationConfig { + base_token: "bascn_test".to_string(), + heartbeat_interval_secs: 9, + heartbeat_ttl_secs: 5, + ..FeishuCoordinationConfig::default() + }; + + assert_eq!(config.heartbeat_ttl().as_secs(), 18); + } +} + 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/provider/feishu/coordination.rs b/codex-rs/clawbot/src/provider/feishu/coordination.rs index babac1354..766e25ca9 100644 --- a/codex-rs/clawbot/src/provider/feishu/coordination.rs +++ b/codex-rs/clawbot/src/provider/feishu/coordination.rs @@ -2,6 +2,7 @@ use std::collections::hash_map::DefaultHasher; use std::hash::Hash; use std::hash::Hasher; use std::path::Path; +use std::sync::Arc; use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -10,13 +11,21 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use open_lark::openlark_core::api::ApiRequest; +use open_lark::openlark_core::api::Response; use open_lark::openlark_core::http::Transport; use serde_json::Map; use serde_json::Value; +use tokio::sync::Mutex; use crate::config::FeishuConfig; use crate::config::FeishuCoordinationConfig; +const HEARTBEAT_TABLE_NAME: &str = "clawbot_coordination_heartbeat"; +const FORCE_TABLE_NAME: &str = "clawbot_coordination_force"; + +const FIELD_TYPE_TEXT: i64 = 1; +const FIELD_TYPE_NUMBER: i64 = 2; + const HEARTBEAT_FIELD_KEY: &str = "key"; const HEARTBEAT_FIELD_APP_ID: &str = "app_id"; const HEARTBEAT_FIELD_INSTANCE_ID: &str = "instance_id"; @@ -34,6 +43,35 @@ const FORCE_FIELD_TARGET_SESSION_ID: &str = "target_session_id"; const FORCE_FIELD_FORCE_UNTIL_MS: &str = "force_until_ms"; const FORCE_FIELD_REQUESTED_AT_MS: &str = "requested_at_ms"; +const FEISHU_CODE_WRONG_BASE_TOKEN: i32 = 1_254_003; +const FEISHU_CODE_WRONG_TABLE_ID: i32 = 1_254_004; +const FEISHU_CODE_WRONG_FIELD_ID: i32 = 1_254_009; +const FEISHU_CODE_BASE_TOKEN_NOT_FOUND: i32 = 1_254_040; +const FEISHU_CODE_TABLE_ID_NOT_FOUND: i32 = 1_254_041; +const FEISHU_CODE_FIELD_ID_NOT_FOUND: i32 = 1_254_044; +const FEISHU_CODE_PERMISSION_DENIED: i32 = 1_254_302; + +const HEARTBEAT_FIELDS: [RequiredField; 9] = [ + RequiredField::new(HEARTBEAT_FIELD_KEY, FIELD_TYPE_TEXT), + RequiredField::new(HEARTBEAT_FIELD_APP_ID, FIELD_TYPE_TEXT), + RequiredField::new(HEARTBEAT_FIELD_INSTANCE_ID, FIELD_TYPE_TEXT), + RequiredField::new(HEARTBEAT_FIELD_SESSION_ID, FIELD_TYPE_TEXT), + RequiredField::new(HEARTBEAT_FIELD_OWNER_PRIORITY, FIELD_TYPE_NUMBER), + RequiredField::new(HEARTBEAT_FIELD_LAST_SEEN_MS, FIELD_TYPE_NUMBER), + RequiredField::new(HEARTBEAT_FIELD_TTL_MS, FIELD_TYPE_NUMBER), + RequiredField::new(HEARTBEAT_FIELD_WS_STATE, FIELD_TYPE_TEXT), + RequiredField::new(HEARTBEAT_FIELD_WORKSPACE_ROOT, FIELD_TYPE_TEXT), +]; + +const FORCE_FIELDS: [RequiredField; 6] = [ + RequiredField::new(FORCE_FIELD_KEY, FIELD_TYPE_TEXT), + RequiredField::new(FORCE_FIELD_APP_ID, FIELD_TYPE_TEXT), + RequiredField::new(FORCE_FIELD_TARGET_INSTANCE_ID, FIELD_TYPE_TEXT), + RequiredField::new(FORCE_FIELD_TARGET_SESSION_ID, FIELD_TYPE_TEXT), + RequiredField::new(FORCE_FIELD_FORCE_UNTIL_MS, FIELD_TYPE_NUMBER), + RequiredField::new(FORCE_FIELD_REQUESTED_AT_MS, FIELD_TYPE_NUMBER), +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum WebsocketOwnershipState { Idle, @@ -162,12 +200,19 @@ impl FeishuBaseCoordinator { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResolvedCoordinationTables { + heartbeat_table_id: String, + force_table_id: String, +} + #[derive(Debug, Clone)] struct FeishuBaseClient { config: open_lark::openlark_core::config::Config, base_token: String, - heartbeat_table_id: String, - force_table_id: String, + configured_heartbeat_table_id: Option, + configured_force_table_id: Option, + schema: Arc>>, } impl FeishuBaseClient { @@ -178,21 +223,26 @@ impl FeishuBaseClient { Self { config, base_token: coordination.base_token, - heartbeat_table_id: coordination.heartbeat_table_id, - force_table_id: coordination.force_table_id, + configured_heartbeat_table_id: trimmed_non_empty(&coordination.heartbeat_table_id), + configured_force_table_id: trimmed_non_empty(&coordination.force_table_id), + schema: Arc::new(Mutex::new(None)), } } async fn upsert_heartbeat(&self, heartbeat: &HeartbeatLease) -> Result<()> { + let tables = self.ensure_schema().await?; let fields = heartbeat.to_fields(); - match self.find_heartbeat_record_by_key(&heartbeat.key).await? { + match self + .find_heartbeat_record_by_key(&tables.heartbeat_table_id, &heartbeat.key) + .await? + { Some(record) => { - self.update_record(&self.heartbeat_table_id, &record.record_id, fields) + self.update_record(&tables.heartbeat_table_id, &record.record_id, fields) .await .context("failed to update Feishu Base heartbeat record")?; } None => { - self.create_record(&self.heartbeat_table_id, fields) + self.create_record(&tables.heartbeat_table_id, fields) .await .context("failed to create Feishu Base heartbeat record")?; } @@ -201,9 +251,11 @@ impl FeishuBaseClient { } async fn list_heartbeats(&self, app_id: &str) -> Result> { + let tables = self.ensure_schema().await?; let mut heartbeats = Vec::new(); - for record in self.list_records(&self.heartbeat_table_id).await? { - if let Some(heartbeat) = HeartbeatLease::from_record(record)? && heartbeat.app_id == app_id + for record in self.list_records(&tables.heartbeat_table_id).await? { + if let Some(heartbeat) = HeartbeatLease::from_record(record)? + && heartbeat.app_id == app_id { heartbeats.push(heartbeat); } @@ -220,8 +272,11 @@ impl FeishuBaseClient { ttl_ms: i64, force_connect: bool, ) -> Result<()> { + let tables = self.ensure_schema().await?; let key = force_key(app_id); - let existing = self.find_force_record_by_key(&key).await?; + let existing = self + .find_force_record_by_key(&tables.force_table_id, &key) + .await?; let force_until_ms = if force_connect { now_ms + ttl_ms } else { now_ms }; let should_write = force_connect || existing.as_ref().is_some_and(|record| { @@ -244,7 +299,7 @@ impl FeishuBaseClient { .to_fields(); if let Some(existing) = existing { self.update_record( - &self.force_table_id, + &tables.force_table_id, &existing .record_id .clone() @@ -254,7 +309,7 @@ impl FeishuBaseClient { .await .context("failed to update Feishu Base force intent record")?; } else { - self.create_record(&self.force_table_id, fields) + self.create_record(&tables.force_table_id, fields) .await .context("failed to create Feishu Base force intent record")?; } @@ -262,12 +317,115 @@ impl FeishuBaseClient { } async fn get_force_intent(&self, app_id: &str) -> Result> { - self.find_force_record_by_key(&force_key(app_id)).await + let tables = self.ensure_schema().await?; + self.find_force_record_by_key(&tables.force_table_id, &force_key(app_id)) + .await + } + + async fn ensure_schema(&self) -> Result { + let mut cached = self.schema.lock().await; + if let Some(schema) = cached.clone() { + return Ok(schema); + } + + let mut tables = self.list_tables().await?; + let heartbeat_table = self + .resolve_or_create_table( + &mut tables, + self.configured_heartbeat_table_id.as_deref(), + HEARTBEAT_TABLE_NAME, + ) + .await?; + self.ensure_required_fields(&heartbeat_table, &HEARTBEAT_FIELDS) + .await?; + + let force_table = self + .resolve_or_create_table( + &mut tables, + self.configured_force_table_id.as_deref(), + FORCE_TABLE_NAME, + ) + .await?; + self.ensure_required_fields(&force_table, &FORCE_FIELDS) + .await?; + + let schema = ResolvedCoordinationTables { + heartbeat_table_id: heartbeat_table.table_id, + force_table_id: force_table.table_id, + }; + *cached = Some(schema.clone()); + Ok(schema) + } + + async fn resolve_or_create_table( + &self, + tables: &mut Vec, + configured_table_id: Option<&str>, + table_name: &str, + ) -> Result { + if let Some(table_id) = configured_table_id + && let Some(table) = tables.iter().find(|table| table.table_id == table_id) + { + return Ok(table.clone()); + } + + if let Some(table) = choose_named_table(tables, table_name) { + return Ok(table); + } + + self.create_table(table_name).await?; + *tables = self.list_tables().await?; + choose_named_table(tables, table_name).ok_or_else(|| { + anyhow!( + "Feishu Base coordination created table {table_name} but could not resolve its table_id" + ) + }) + } + + async fn ensure_required_fields( + &self, + table: &BaseTable, + required_fields: &[RequiredField], + ) -> Result<()> { + let fields = self.list_fields(&table.table_id).await?; + for required in required_fields { + match fields + .iter() + .find(|field| field.field_name == required.field_name) + { + Some(existing) if existing.field_type == required.field_type => {} + Some(existing) => { + return Err(anyhow!( + "Feishu Base coordination table {} ({}) has field {} with type {} but expected {}. Repair the table schema or clear the configured table id so clawbot can recreate it.", + table.name, + table.table_id, + required.field_name, + field_type_name(existing.field_type), + field_type_name(required.field_type), + )); + } + None => { + self.create_field(&table.table_id, required) + .await + .with_context(|| { + format!( + "failed to create Feishu Base coordination field {} on table {} ({})", + required.field_name, table.name, table.table_id + ) + })?; + } + } + } + Ok(()) } - async fn find_force_record_by_key(&self, key: &str) -> Result> { + async fn find_force_record_by_key( + &self, + table_id: &str, + key: &str, + ) -> Result> { let mut matches = Vec::new(); - for record in self.list_records(&self.force_table_id).await? { + for record in self.list_records(table_id).await? { if let Some(force_intent) = ForceIntentRecord::from_record(record)? && force_intent.key == key { @@ -282,9 +440,13 @@ impl FeishuBaseClient { Ok(matches.pop()) } - async fn find_heartbeat_record_by_key(&self, key: &str) -> Result> { + async fn find_heartbeat_record_by_key( + &self, + table_id: &str, + key: &str, + ) -> Result> { let mut matches = self - .list_records(&self.heartbeat_table_id) + .list_records(table_id) .await? .into_iter() .filter(|record| { @@ -299,37 +461,150 @@ impl FeishuBaseClient { Ok(matches.pop()) } + async fn list_tables(&self) -> Result> { + let mut page_token = None; + let mut tables = Vec::new(); + loop { + let mut request: ApiRequest = + ApiRequest::get(tables_url(&self.base_token)).query("page_size", "100"); + if let Some(token) = page_token.clone() { + request = request.query("page_token", token); + } + let response = self.request_json("list Feishu Base tables", request).await?; + let payload = paged_payload( + response + .data + .as_ref() + .context("Feishu Base table list response is missing data")?, + "table list", + )?; + if let Some(items) = payload.get("items").and_then(Value::as_array) { + for item in items { + if let Some(table) = BaseTable::from_value(item) { + tables.push(table); + } + } + } + let has_more = payload + .get("has_more") + .and_then(Value::as_bool) + .unwrap_or(false); + if !has_more { + break; + } + let Some(next_page_token) = payload + .get("page_token") + .and_then(Value::as_str) + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(str::to_owned) + else { + break; + }; + page_token = Some(next_page_token); + } + Ok(tables) + } + + async fn create_table(&self, table_name: &str) -> Result<()> { + let request: ApiRequest = ApiRequest::post(tables_url(&self.base_token)) + .json_body(&serde_json::json!({ + "table": { + "name": table_name, + "fields": [{ + "field_name": HEARTBEAT_FIELD_KEY, + "type": FIELD_TYPE_TEXT, + }], + } + })); + self.request_json(&format!("create Feishu Base table {table_name}"), request) + .await?; + Ok(()) + } + + async fn list_fields(&self, table_id: &str) -> Result> { + let mut page_token = None; + let mut fields = Vec::new(); + loop { + let mut request: ApiRequest = ApiRequest::get(fields_url(&self.base_token, table_id)) + .query("page_size", "100"); + if let Some(token) = page_token.clone() { + request = request.query("page_token", token); + } + let response = self + .request_json(&format!("list Feishu Base fields for table {table_id}"), request) + .await?; + let payload = paged_payload( + response + .data + .as_ref() + .context("Feishu Base field list response is missing data")?, + "field list", + )?; + if let Some(items) = payload.get("items").and_then(Value::as_array) { + for item in items { + if let Some(field) = BaseField::from_value(item) { + fields.push(field); + } + } + } + let has_more = payload + .get("has_more") + .and_then(Value::as_bool) + .unwrap_or(false); + if !has_more { + break; + } + let Some(next_page_token) = payload + .get("page_token") + .and_then(Value::as_str) + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(str::to_owned) + else { + break; + }; + page_token = Some(next_page_token); + } + Ok(fields) + } + + async fn create_field(&self, table_id: &str, field: &RequiredField) -> Result<()> { + let request: ApiRequest = ApiRequest::post(fields_url(&self.base_token, table_id)) + .json_body(&serde_json::json!({ + "field_name": field.field_name, + "type": field.field_type, + })); + self.request_json( + &format!( + "create Feishu Base field {} on table {table_id}", + field.field_name + ), + request, + ) + .await?; + Ok(()) + } + async fn list_records(&self, table_id: &str) -> Result> { let mut page_token = None; let mut records = Vec::new(); loop { - let mut request: ApiRequest = ApiRequest::get(records_url( - &self.base_token, - table_id, - )) - .query("page_size", "500"); + let mut request: ApiRequest = + ApiRequest::get(records_url(&self.base_token, table_id)).query("page_size", "500"); if let Some(token) = page_token.clone() { request = request.query("page_token", token); } - let response = Transport::::request( - request, - &self.config, - Some(Default::default()), - ) - .await - .with_context(|| format!("failed to list Feishu Base records for table {table_id}"))?; - if !response.is_success() { - return Err(anyhow!( - "failed to list Feishu Base records for table {table_id}: {}", - response.msg() - )); - } - let data = response - .data - .as_ref() - .context("Feishu Base record list response is missing data")?; - let payload = data.get("items").is_some().then_some(data).or_else(|| data.get("data")); - let payload = payload.context("Feishu Base record list response is missing items payload")?; + let response = self + .request_json(&format!("list Feishu Base records for table {table_id}"), request) + .await?; + let payload = paged_payload( + response + .data + .as_ref() + .context("Feishu Base record list response is missing data")?, + "record list", + )?; if let Some(items) = payload.get("items").and_then(Value::as_array) { for item in items { if let Some(record) = BaseRecord::from_value(item) { @@ -361,29 +636,87 @@ impl FeishuBaseClient { async fn create_record(&self, table_id: &str, fields: Value) -> Result<()> { let request: ApiRequest = ApiRequest::post(records_url(&self.base_token, table_id)) .json_body(&serde_json::json!({ "fields": fields })); - let response = - Transport::::request(request, &self.config, Some(Default::default())).await?; - if response.is_success() { - Ok(()) - } else { - Err(anyhow!("failed to create Feishu Base record: {}", response.msg())) - } + self.request_json( + &format!("create Feishu Base record in table {table_id}"), + request, + ) + .await?; + Ok(()) } async fn update_record(&self, table_id: &str, record_id: &str, fields: Value) -> Result<()> { let request: ApiRequest = ApiRequest::put(record_url(&self.base_token, table_id, record_id)) .json_body(&serde_json::json!({ "fields": fields })); - let response = - Transport::::request(request, &self.config, Some(Default::default())).await?; + self.request_json( + &format!("update Feishu Base record {record_id} in table {table_id}"), + request, + ) + .await?; + Ok(()) + } + + async fn request_json( + &self, + operation: &str, + request: ApiRequest, + ) -> Result> { + let response = Transport::::request(request, &self.config, Some(Default::default())) + .await + .with_context(|| format!("failed to {operation}"))?; if response.is_success() { - Ok(()) + Ok(response) } else { - Err(anyhow!("failed to update Feishu Base record: {}", response.msg())) + Err(classify_feishu_api_error(operation, &self.base_token, &response)) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RequiredField { + field_name: &'static str, + field_type: i64, +} + +impl RequiredField { + const fn new(field_name: &'static str, field_type: i64) -> Self { + Self { + field_name, + field_type, } } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct BaseTable { + table_id: String, + name: String, +} + +impl BaseTable { + fn from_value(value: &Value) -> Option { + Some(Self { + table_id: value.get("table_id")?.as_str()?.to_string(), + name: value.get("name")?.as_str()?.trim().to_string(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BaseField { + field_name: String, + field_type: i64, +} + +impl BaseField { + fn from_value(value: &Value) -> Option { + Some(Self { + field_name: value.get("field_name")?.as_str()?.trim().to_string(), + field_type: value.get("type")?.as_i64()?, + }) + } +} + #[derive(Debug, Clone, PartialEq)] struct BaseRecord { record_id: String, @@ -558,6 +891,55 @@ fn select_leader( }) } +fn choose_named_table(tables: &[BaseTable], table_name: &str) -> Option { + tables + .iter() + .filter(|table| table.name == table_name) + .cloned() + .min_by(|left, right| left.table_id.cmp(&right.table_id)) +} + +fn classify_feishu_api_error( + operation: &str, + base_token: &str, + response: &Response, +) -> anyhow::Error { + let code = response.code(); + let msg = response.msg().trim(); + let request_id = response + .raw() + .request_id + .as_deref() + .map(|id| format!(", request_id {id}")) + .unwrap_or_default(); + + match code { + FEISHU_CODE_PERMISSION_DENIED => anyhow!( + "Feishu Base coordination could not {operation}: permission denied (code {code}{request_id}): {msg}. Grant the app edit/admin access on base {base_token} or add it to a Base role with write permission." + ), + FEISHU_CODE_WRONG_BASE_TOKEN | FEISHU_CODE_BASE_TOKEN_NOT_FOUND => anyhow!( + "Feishu Base coordination could not {operation}: base_token {base_token} is invalid or inaccessible (code {code}{request_id}): {msg}. Check [feishu.coordination].base_token and make sure the app can access that Base." + ), + FEISHU_CODE_WRONG_TABLE_ID | FEISHU_CODE_TABLE_ID_NOT_FOUND => anyhow!( + "Feishu Base coordination could not {operation}: the configured table id is invalid (code {code}{request_id}): {msg}. Clear or repair heartbeat_table_id / force_table_id so clawbot can resolve or recreate its coordination tables." + ), + FEISHU_CODE_WRONG_FIELD_ID | FEISHU_CODE_FIELD_ID_NOT_FOUND => anyhow!( + "Feishu Base coordination could not {operation}: the coordination table schema is missing a required field (code {code}{request_id}): {msg}. Repair the Base schema or let clawbot recreate the coordination tables." + ), + _ => anyhow!( + "Feishu Base coordination could not {operation} (code {code}{request_id}): {msg}" + ), + } +} + +fn paged_payload<'a>(data: &'a Value, label: &str) -> Result<&'a Value> { + data.get("items") + .is_some() + .then_some(data) + .or_else(|| data.get("data")) + .with_context(|| format!("Feishu Base {label} response is missing items payload")) +} + fn default_instance_id(workspace_root: &str) -> String { let hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "local".to_string()); let mut hasher = DefaultHasher::new(); @@ -573,6 +955,14 @@ fn force_key(app_id: &str) -> String { app_id.to_string() } +fn tables_url(base_token: &str) -> String { + format!("/open-apis/bitable/v1/apps/{base_token}/tables") +} + +fn fields_url(base_token: &str, table_id: &str) -> String { + format!("/open-apis/bitable/v1/apps/{base_token}/tables/{table_id}/fields") +} + fn records_url(base_token: &str, table_id: &str) -> String { format!("/open-apis/bitable/v1/apps/{base_token}/tables/{table_id}/records") } @@ -581,6 +971,19 @@ fn record_url(base_token: &str, table_id: &str, record_id: &str) -> String { format!("/open-apis/bitable/v1/apps/{base_token}/tables/{table_id}/records/{record_id}") } +fn trimmed_non_empty(value: &str) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn field_type_name(field_type: i64) -> &'static str { + match field_type { + FIELD_TYPE_TEXT => "text", + FIELD_TYPE_NUMBER => "number", + _ => "unknown", + } +} + fn string_field(fields: &Map, key: &str) -> Option { fields .get(key) @@ -614,8 +1017,14 @@ fn unix_timestamp_ms_now() -> Result { mod tests { use pretty_assertions::assert_eq; + use super::FORCE_FIELDS; use super::ForceIntentRecord; + use super::HEARTBEAT_FIELDS; use super::HeartbeatLease; + use super::RequiredField; + use super::BaseTable; + use super::choose_named_table; + use super::classify_feishu_api_error; use super::select_leader; #[test] @@ -701,6 +1110,47 @@ mod tests { assert_eq!(leader.forced_instance_id, Some("instance_b".to_string())); } + #[test] + fn choose_named_table_is_deterministic_when_duplicates_exist() { + let tables = vec![ + BaseTable { + table_id: "tbl_b".to_string(), + name: "clawbot_coordination_heartbeat".to_string(), + }, + BaseTable { + table_id: "tbl_a".to_string(), + name: "clawbot_coordination_heartbeat".to_string(), + }, + ]; + + let chosen = choose_named_table(&tables, "clawbot_coordination_heartbeat") + .expect("named table"); + assert_eq!(chosen.table_id, "tbl_a"); + } + + #[test] + fn required_field_sets_match_expected_shape() { + assert_eq!(HEARTBEAT_FIELDS.first(), Some(&RequiredField::new("key", 1))); + assert_eq!(FORCE_FIELDS.first(), Some(&RequiredField::new("key", 1))); + } + + #[test] + fn permission_error_is_actionable() { + let response = open_lark::openlark_core::api::Response::error( + 1_254_302, + "The role has no permissions.", + ); + let message = classify_feishu_api_error( + "create Feishu Base table clawbot_coordination_heartbeat", + "bascn_test", + &response, + ) + .to_string(); + + assert!(message.contains("Grant the app edit/admin access")); + assert!(message.contains("bascn_test")); + } + fn heartbeat( instance_id: &str, session_id: &str, diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index 1345dbe87..bab4b7574 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -113,10 +113,10 @@ impl ClawbotFeishuConfigField { "Paste the Feishu Base token, or submit an empty value to clear it" } Self::CoordinationHeartbeatTableId => { - "Paste the heartbeat table_id, or submit an empty value to clear it" + "Paste an optional heartbeat table_id, or leave it empty so clawbot can auto-discover or create the table" } Self::CoordinationForceTableId => { - "Paste the force table_id, or submit an empty value to clear it" + "Paste an optional force table_id, or leave it empty so clawbot can auto-discover or create the table" } Self::CoordinationInstanceId => { "Paste an optional stable instance_id, or submit an empty value to auto-generate" diff --git a/docs/clawbot-feishu-base-coordination.md b/docs/clawbot-feishu-base-coordination.md index 886e2310f..946ab4e28 100644 --- a/docs/clawbot-feishu-base-coordination.md +++ b/docs/clawbot-feishu-base-coordination.md @@ -24,8 +24,9 @@ app_secret = "xxx" [feishu.coordination] base_token = "bascnxxxx" -heartbeat_table_id = "tblHeartbeat" -force_table_id = "tblForce" +# Optional. Leave these empty to let clawbot discover or create the tables automatically. +# heartbeat_table_id = "tblHeartbeat" +# force_table_id = "tblForce" owner_priority = 100 force_connect = false # Optional. When empty, codex-clawbot auto-generates a per-process instance id. @@ -35,9 +36,20 @@ force_connect = false `force_connect = true` means the current Codex process continuously refreshes a force-intent row for its own `app_id`, so it preempts other contenders as soon as they observe the update. +When `heartbeat_table_id` or `force_table_id` is omitted, `codex-clawbot` will: + +- list tables in the configured Base +- reuse tables named `clawbot_coordination_heartbeat` and `clawbot_coordination_force` if they exist +- create them if they do not exist +- create any missing required fields on those tables + +If you do provide table IDs, clawbot treats them as overrides and validates their schema before it +starts using them. + ## Base Schema -Create one Base with two tables. Use ASCII field names exactly as listed here. +Create one Base with two tables, or let clawbot create them automatically. Use ASCII field names +exactly as listed here. ### `heartbeat` From cd5ac0281e0b5b8eb824969ed7788f57eea106cf Mon Sep 17 00:00:00 2001 From: piping Date: Wed, 15 Apr 2026 20:47:38 +0800 Subject: [PATCH 80/83] feat: add clawbot ws preemption and hook output filter --- codex-rs/config/src/types.rs | 6 + codex-rs/core/config.schema.json | 6 + codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/tui/src/app/clawbot_controller.rs | 26 ++ codex-rs/tui/src/app/clawbot_controls.rs | 242 +++++++++++++- codex-rs/tui/src/app/feature_dispatch.rs | 3 + codex-rs/tui/src/app/tests/clawbot_tests.rs | 221 +++++++++++++ ...s__clawbot_bound_channel_detail_popup.snap | 19 ++ ...awbot_tests__clawbot_management_popup.snap | 2 +- ..._clawbot_unbound_session_detail_popup.snap | 17 + codex-rs/tui/src/app_event.rs | 14 + codex-rs/tui/src/chatwidget.rs | 6 + .../src/chatwidget/tests/status_and_layout.rs | 59 ++++ codex-rs/tui/src/display_preferences.rs | 51 +++ codex-rs/tui/src/display_preferences_menu.rs | 9 + ...menu__tests__display_preferences_menu.snap | 8 +- docs/use-cases-from-4fd5c35-to-ce67c2093.md | 300 ++++++++++++++++++ ...e-cases-from-4fd5c35-to-ce67c2093.zh-CN.md | 300 ++++++++++++++++++ docs/use-cases-from-4fd5c35.md | 286 +++++++++++++++++ sdk/python-runtime-enhanced/pyproject.toml | 2 +- 20 files changed, 1565 insertions(+), 14 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_bound_channel_detail_popup.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_unbound_session_detail_popup.snap create mode 100644 docs/use-cases-from-4fd5c35-to-ce67c2093.md create mode 100644 docs/use-cases-from-4fd5c35-to-ce67c2093.zh-CN.md create mode 100644 docs/use-cases-from-4fd5c35.md diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 2985754c8..dd2d896c1 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -538,6 +538,11 @@ pub struct TuiDisplayPreferences { #[serde(default = "default_true")] pub show_tool_results: bool, + /// Show hook lifecycle and hook output entries in the transcript. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_hook_output: bool, + /// Show patch/edit diff summaries in transcript cells. /// Defaults to `true`. #[serde(default = "default_true")] @@ -548,6 +553,7 @@ impl Default for TuiDisplayPreferences { fn default() -> Self { Self { show_tool_results: true, + show_hook_output: true, show_patch_diffs: true, } } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0c9805073..d8f1132cf 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1919,6 +1919,7 @@ } ], "default": { + "show_hook_output": true, "show_patch_diffs": true, "show_tool_results": true }, @@ -1992,6 +1993,11 @@ "TuiDisplayPreferences": { "additionalProperties": false, "properties": { + "show_hook_output": { + "default": true, + "description": "Show hook lifecycle and hook output entries in the transcript. Defaults to `true`.", + "type": "boolean" + }, "show_patch_diffs": { "default": true, "description": "Show patch/edit diff summaries in transcript cells. Defaults to `true`.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e932dc968..38d362e97 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -408,6 +408,7 @@ fn config_toml_deserializes_tui_display_preferences() { let toml = r#" [tui.display_preferences] show_tool_results = false +show_hook_output = false show_patch_diffs = false "#; let cfg: ConfigToml = @@ -419,6 +420,7 @@ show_patch_diffs = false .display_preferences, TuiDisplayPreferences { show_tool_results: false, + show_hook_output: false, show_patch_diffs: false, } ); diff --git a/codex-rs/tui/src/app/clawbot_controller.rs b/codex-rs/tui/src/app/clawbot_controller.rs index fcec0411f..4b068d30b 100644 --- a/codex-rs/tui/src/app/clawbot_controller.rs +++ b/codex-rs/tui/src/app/clawbot_controller.rs @@ -45,6 +45,25 @@ impl ClawbotController { .add_error_message(format!("Failed to save Clawbot config: {err}")); } } + AppEvent::BindClawbotDiscoveredSession { session_id } => { + if let Err(err) = app + .bind_clawbot_discovered_session_to_current_thread(app_server, session_id) + .await + { + app.chat_widget + .add_error_message(format!("Failed to bind Clawbot session: {err}")); + } + } + AppEvent::BindClawbotSessionAndPreempt { session_id } => { + if let Err(err) = app + .bind_clawbot_session_to_current_thread_and_preempt(app_server, session_id) + .await + { + app.chat_widget.add_error_message(format!( + "Failed to bind and preempt Clawbot session: {err}" + )); + } + } AppEvent::SaveClawbotManualBindSessionId { session_id } => { if let Err(err) = app .bind_clawbot_session_to_current_thread(app_server, session_id) @@ -90,6 +109,13 @@ impl ClawbotController { )); } } + AppEvent::ToggleClawbotForceConnect => { + if let Err(err) = app.toggle_clawbot_force_connect() { + app.chat_widget.add_error_message(format!( + "Failed to update Clawbot ws preemption: {err}" + )); + } + } AppEvent::ClawbotDisconnectThread { thread_id } => { if let Err(err) = app.clawbot_disconnect_thread(thread_id) { app.chat_widget diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index bab4b7574..900a7d88e 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -27,6 +27,7 @@ use crate::app_event::AppEvent; use crate::app_event::ClawbotControlsDestination; use crate::app_event::ClawbotFeishuConfigField; use crate::app_event::ClawbotForwardingChannel; +use crate::app_event::ClawbotSessionBindSource; use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::bottom_pane::SelectionAction; @@ -270,7 +271,9 @@ impl App { coordination.owner_priority = if trimmed.is_empty() { FeishuCoordinationConfig::default().owner_priority } else { - trimmed.parse().context("coordination priority must be an integer")? + trimmed + .parse() + .context("coordination priority must be an integer")? }; } ClawbotFeishuConfigField::CoordinationForceConnect => { @@ -312,6 +315,50 @@ impl App { &mut self, app_server: &mut AppServerSession, session_id: String, + ) -> Result<()> { + self.bind_clawbot_session_to_current_thread_with_options( + app_server, + session_id, + ClawbotSessionBindSource::ManualSessionId, + /*preempt_ws*/ false, + ) + .await + } + + pub(crate) async fn bind_clawbot_discovered_session_to_current_thread( + &mut self, + app_server: &mut AppServerSession, + session_id: String, + ) -> Result<()> { + self.bind_clawbot_session_to_current_thread_with_options( + app_server, + session_id, + ClawbotSessionBindSource::DiscoveredSession, + /*preempt_ws*/ false, + ) + .await + } + + pub(crate) async fn bind_clawbot_session_to_current_thread_and_preempt( + &mut self, + app_server: &mut AppServerSession, + session_id: String, + ) -> Result<()> { + self.bind_clawbot_session_to_current_thread_with_options( + app_server, + session_id, + ClawbotSessionBindSource::DiscoveredSession, + /*preempt_ws*/ true, + ) + .await + } + + async fn bind_clawbot_session_to_current_thread_with_options( + &mut self, + app_server: &mut AppServerSession, + session_id: String, + source: ClawbotSessionBindSource, + preempt_ws: bool, ) -> Result<()> { let thread_id = self .active_thread_id @@ -322,14 +369,21 @@ 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 source == ClawbotSessionBindSource::DiscoveredSession + && runtime.snapshot().config.feishu.is_some() + { + if !runtime.can_bind_feishu_session(&session)? { + runtime.scan_feishu_sessions().await?; + } if !runtime.can_bind_feishu_session(&session)? { return Err(anyhow::anyhow!( "Feishu session {trimmed} is not visible to the current bot" )); } } + if preempt_ws { + enable_clawbot_force_connect(&mut runtime)?; + } runtime.connect_session_to_thread( &session, thread_id.to_string(), @@ -340,7 +394,11 @@ impl App { .await?; self.refresh_clawbot_management_popup(); self.chat_widget.add_info_message( - format!("Bound thread {thread_id} to Feishu session {trimmed}."), + if preempt_ws { + format!("Bound thread {thread_id} to Feishu session {trimmed} and enabled ws preemption.") + } else { + format!("Bound thread {thread_id} to Feishu session {trimmed}.") + }, /*hint*/ None, ); Ok(()) @@ -457,6 +515,29 @@ impl App { Ok(()) } + pub(crate) fn toggle_clawbot_force_connect(&mut self) -> Result<()> { + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + let enabled = !runtime + .snapshot() + .config + .feishu + .as_ref() + .and_then(|config| config.coordination.as_ref()) + .is_some_and(|coordination| coordination.force_connect); + set_clawbot_force_connect(&mut runtime, enabled)?; + self.refresh_clawbot_provider_runtime()?; + self.refresh_clawbot_management_popup(); + self.chat_widget.add_info_message( + if enabled { + "Enabled Clawbot ws preemption.".to_string() + } else { + "Disabled Clawbot ws preemption.".to_string() + }, + /*hint*/ None, + ); + 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?; @@ -588,6 +669,9 @@ impl App { .collect::>(); let unbound_session_count = unbound_sessions.len(); let has_api_credentials = feishu_config.is_some_and(FeishuConfig::has_api_credentials); + let can_preempt_ws = feishu_config + .and_then(|config| config.coordination.as_ref()) + .is_some_and(FeishuCoordinationConfig::is_configured); let items = match destination { ClawbotControlsDestination::Root => vec![ @@ -708,6 +792,7 @@ impl App { thread_label, session.map_or_else(|| binding.session_id.clone(), session_title), session.map_or(0, |session| session.unread_count), + can_preempt_ws, )); } None => items.push(info_item( @@ -749,6 +834,7 @@ impl App { items.extend(unbound_session_detail_items( session, active_thread_id.as_deref(), + can_preempt_ws, )); } None => items.push(info_item( @@ -809,6 +895,7 @@ impl App { dismiss_on_select: false, ..Default::default() }, + clawbot_force_connect_toggle_item(feishu_config), clawbot_config_item(ClawbotFeishuConfigField::AppId, feishu_config), clawbot_config_item(ClawbotFeishuConfigField::AppSecret, feishu_config), clawbot_config_item(ClawbotFeishuConfigField::VerificationToken, feishu_config), @@ -835,10 +922,6 @@ impl App { ClawbotFeishuConfigField::CoordinationOwnerPriority, feishu_config, ), - clawbot_config_item( - ClawbotFeishuConfigField::CoordinationForceConnect, - feishu_config, - ), ], ClawbotControlsDestination::Diagnostics => vec![ clawbot_back_item( @@ -994,6 +1077,44 @@ fn clawbot_config_item( } } +fn clawbot_force_connect_toggle_item(config: Option<&FeishuConfig>) -> SelectionItem { + let coordination = config.and_then(|config| config.coordination.as_ref()); + let enabled = coordination.is_some_and(|coordination| coordination.force_connect); + SelectionItem { + name: if enabled { + "Disable Force Connect".to_string() + } else { + "Enable Force Connect".to_string() + }, + description: Some(match coordination { + Some(coordination) if coordination.is_configured() && enabled => { + "Currently enabled. This Codex session keeps preempting Feishu websocket leadership." + .to_string() + } + Some(coordination) if coordination.is_configured() => { + "Currently disabled. Enable ws preemption for this Codex session.".to_string() + } + _ => "Configure the coordination base token before using ws preemption.".to_string(), + }), + selected_description: Some(match coordination { + Some(coordination) if coordination.is_configured() && enabled => { + "Stop refreshing this workspace as the forced Feishu websocket owner." + .to_string() + } + Some(coordination) if coordination.is_configured() => { + "Continuously refresh this workspace as the forced Feishu websocket owner." + .to_string() + } + _ => "Add [feishu.coordination].base_token before toggling ws preemption." + .to_string(), + }), + is_disabled: !coordination.is_some_and(FeishuCoordinationConfig::is_configured), + actions: vec![Box::new(|tx| tx.send(AppEvent::ToggleClawbotForceConnect))], + dismiss_on_select: false, + ..Default::default() + } +} + fn connection_description(state: &ProviderRuntimeState) -> String { let status = match state.connection { ConnectionStatus::Unconfigured => "Unconfigured", @@ -1070,6 +1191,7 @@ fn bound_channel_detail_items( thread_label: String, session_title: String, unread_count: usize, + can_preempt_ws: bool, ) -> Vec { let jump_target = ThreadId::from_string(&binding.thread_id).ok(); let toggle_inbound_enabled = !binding.inbound_forwarding_enabled; @@ -1091,6 +1213,48 @@ fn bound_channel_detail_items( Some(forwarding_state_label(binding.outbound_forwarding_enabled).to_string()), ), ]; + let rebind_to_current_thread_item = SelectionItem { + name: "Bind current thread + preempt ws".to_string(), + description: Some(match active_thread_id { + Some(thread_id) if thread_id == binding.thread_id => { + "Already bound to current thread".to_string() + } + Some(thread_id) if can_preempt_ws => format!("Move to current thread {thread_id}"), + Some(_) => "Coordination base token required".to_string(), + None => "No active thread".to_string(), + }), + selected_description: Some(match active_thread_id { + Some(thread_id) if thread_id == binding.thread_id => format!( + "Feishu session {} is already bound to current thread {thread_id}.", + binding.session_id + ), + Some(thread_id) if can_preempt_ws => format!( + "Rebind Feishu session {} to current thread {thread_id} and force this Codex session to preempt websocket ownership.", + binding.session_id + ), + Some(_) => { + "Configure Feishu coordination with a base token before using ws preemption." + .to_string() + } + None => format!( + "Open or switch to a Codex thread before rebinding Feishu session {}.", + binding.session_id + ), + }), + is_disabled: active_thread_id.is_none() + || active_thread_id == Some(binding.thread_id.as_str()) + || !can_preempt_ws, + actions: vec![Box::new({ + let session_id = binding.session_id.clone(); + move |tx| { + tx.send(AppEvent::BindClawbotSessionAndPreempt { + session_id: session_id.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }; let jump_item = SelectionItem { name: "Jump to thread".to_string(), description: Some(if active_thread_id == Some(binding.thread_id.as_str()) { @@ -1117,6 +1281,7 @@ fn bound_channel_detail_items( dismiss_on_select: false, ..Default::default() }; + items.push(rebind_to_current_thread_item); items.push(jump_item); if let Some(thread_id) = jump_target { let inbound_thread_id = thread_id; @@ -1206,8 +1371,10 @@ fn unbound_session_item(session: &ProviderSession) -> SelectionItem { fn unbound_session_detail_items( session: &ProviderSession, active_thread_id: Option<&str>, + can_preempt_ws: bool, ) -> Vec { - let session_id = session.session_id.clone(); + let bind_session_id = session.session_id.clone(); + let preempt_session_id = session.session_id.clone(); vec![ info_item("Session".to_string(), Some(session_title(session))), info_item("Session ID".to_string(), Some(session.session_id.clone())), @@ -1230,8 +1397,38 @@ fn unbound_session_detail_items( }), is_disabled: active_thread_id.is_none(), actions: vec![Box::new(move |tx: &AppEventSender| { - tx.send(AppEvent::SaveClawbotManualBindSessionId { - session_id: session_id.clone(), + tx.send(AppEvent::BindClawbotDiscoveredSession { + session_id: bind_session_id.clone(), + }); + })], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Bind current thread + preempt ws".to_string(), + description: Some(match active_thread_id { + Some(thread_id) if can_preempt_ws => format!("Target thread {thread_id}"), + Some(_) => "Coordination base token required".to_string(), + None => "No active thread".to_string(), + }), + selected_description: Some(match active_thread_id { + Some(thread_id) if can_preempt_ws => format!( + "Bind current thread {thread_id} to Feishu session {} and force this Codex session to preempt websocket ownership.", + session.session_id + ), + Some(_) => { + "Configure Feishu coordination with a base token before using ws preemption." + .to_string() + } + None => format!( + "Open or switch to a Codex thread before binding and preempting Feishu session {}.", + session.session_id + ), + }), + is_disabled: active_thread_id.is_none() || !can_preempt_ws, + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::BindClawbotSessionAndPreempt { + session_id: preempt_session_id.clone(), }); })], dismiss_on_select: false, @@ -1240,6 +1437,29 @@ fn unbound_session_detail_items( ] } +fn enable_clawbot_force_connect(runtime: &mut ClawbotRuntime) -> Result<()> { + set_clawbot_force_connect(runtime, /*enabled*/ true) +} + +fn set_clawbot_force_connect(runtime: &mut ClawbotRuntime, enabled: bool) -> Result<()> { + let mut config = runtime + .snapshot() + .config + .feishu + .clone() + .context("Feishu credentials are not configured")?; + let mut coordination = config.coordination.unwrap_or_default(); + if !coordination.is_configured() { + return Err(anyhow::anyhow!( + "Feishu coordination base token is required to preempt websocket ownership" + )); + } + coordination.force_connect = enabled; + config.coordination = Some(coordination); + runtime.update_feishu_config(Some(config))?; + Ok(()) +} + fn forwarding_state_label(enabled: bool) -> &'static str { if enabled { "on" } else { "off" } } diff --git a/codex-rs/tui/src/app/feature_dispatch.rs b/codex-rs/tui/src/app/feature_dispatch.rs index 4c6a8eaf8..8502a222b 100644 --- a/codex-rs/tui/src/app/feature_dispatch.rs +++ b/codex-rs/tui/src/app/feature_dispatch.rs @@ -57,12 +57,15 @@ impl FeatureRoute { | AppEvent::OpenClawbotManagementView { .. } | AppEvent::OpenClawbotFeishuConfigPrompt { .. } | AppEvent::SaveClawbotFeishuConfigValue { .. } + | AppEvent::BindClawbotDiscoveredSession { .. } + | AppEvent::BindClawbotSessionAndPreempt { .. } | AppEvent::SaveClawbotManualBindSessionId { .. } | AppEvent::ClawbotSetTurnMode { .. } | AppEvent::ClawbotSetThreadForwarding { .. } | AppEvent::ScanClawbotFeishuSessions | AppEvent::ClearClawbotFeishuSessions | AppEvent::RetryClawbotFeishuConnection + | AppEvent::ToggleClawbotForceConnect | AppEvent::ClawbotDisconnectThread { .. } | AppEvent::EditClawbotStateFile { .. } => Some(Self::Clawbot), _ => None, diff --git a/codex-rs/tui/src/app/tests/clawbot_tests.rs b/codex-rs/tui/src/app/tests/clawbot_tests.rs index 8a759a584..7eda4c0dc 100644 --- a/codex-rs/tui/src/app/tests/clawbot_tests.rs +++ b/codex-rs/tui/src/app/tests/clawbot_tests.rs @@ -909,3 +909,224 @@ async fn clawbot_rebinds_discovered_session_from_management_actions() -> Result< ); Ok(()) } + +#[tokio::test] +async fn clawbot_bind_and_preempt_enables_force_connect_and_rebinds_session() -> 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); + app.primary_thread_id = Some(target_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: None, + encrypt_key: None, + bot_open_id: Some("ou_bot_open_id".to_string()), + bot_user_id: None, + coordination: Some(codex_clawbot::FeishuCoordinationConfig { + base_token: "bascn_preempt".to_string(), + force_connect: false, + ..codex_clawbot::FeishuCoordinationConfig::default() + }), + })) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + let (source_thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_preempt").await?; + + app.bind_clawbot_session_to_current_thread_and_preempt( + &mut app_server, + "chat_preempt".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) + ); + assert_eq!( + runtime + .bound_session_for_thread(&source_thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + None + ); + assert!( + runtime + .snapshot() + .config + .feishu + .as_ref() + .and_then(|config| config.coordination.as_ref()) + .is_some_and(|coordination| coordination.force_connect) + ); + Ok(()) +} + +#[tokio::test] +async fn clawbot_force_connect_toggle_updates_workspace_config() -> Result<()> { + let mut app = make_test_app().await; + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + 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: None, + encrypt_key: None, + bot_open_id: None, + bot_user_id: None, + coordination: Some(codex_clawbot::FeishuCoordinationConfig { + base_token: "bascn_toggle".to_string(), + force_connect: false, + ..codex_clawbot::FeishuCoordinationConfig::default() + }), + })) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + app.toggle_clawbot_force_connect() + .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!( + runtime + .snapshot() + .config + .feishu + .as_ref() + .and_then(|config| config.coordination.as_ref()) + .is_some_and(|coordination| coordination.force_connect) + ); + + app.toggle_clawbot_force_connect() + .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!( + runtime + .snapshot() + .config + .feishu + .as_ref() + .and_then(|config| config.coordination.as_ref()) + .is_some_and(|coordination| !coordination.force_connect) + ); + Ok(()) +} + +#[tokio::test] +async fn clawbot_session_detail_popups_show_bind_and_preempt_action() -> 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 active_thread_id = started.session.thread_id; + app.active_thread_id = Some(active_thread_id); + app.primary_thread_id = Some(active_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, + coordination: Some(codex_clawbot::FeishuCoordinationConfig { + base_token: "bascn_snapshot".to_string(), + force_connect: false, + ..codex_clawbot::FeishuCoordinationConfig::default() + }), + })) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .persist_session(ProviderSession { + provider: ClawbotProviderKind::Feishu, + session_id: "chat_discovered_detail".to_string(), + display_name: Some("Bob".to_string()), + unread_count: 1, + last_message_at: None, + status: ClawbotSessionStatus::Discovered, + bound_thread_id: None, + }) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + let second_started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("second thread"); + app.upsert_agent_picker_thread( + second_started.session.thread_id, + Some("Inbox Agent".to_string()), + /*agent_role*/ None, + /*is_closed*/ false, + ); + runtime + .persist_session(ProviderSession { + provider: ClawbotProviderKind::Feishu, + session_id: "chat_ops_detail".to_string(), + display_name: Some("Ops".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 + .connect_session_to_thread( + &ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_ops_detail"), + second_started.session.thread_id.to_string(), + app.clawbot_owner_primary_thread_id(), + ) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + app.open_clawbot_management_view(ClawbotControlsDestination::UnboundSession { + session_id: "chat_discovered_detail".to_string(), + }); + let unbound_detail_popup = render_bottom_popup(&app.chat_widget, /*width*/ 100) + .replace(&active_thread_id.to_string(), ""); + assert_snapshot!("clawbot_unbound_session_detail_popup", unbound_detail_popup); + + app.open_clawbot_management_view(ClawbotControlsDestination::BoundChannel { + thread_id: second_started.session.thread_id.to_string(), + }); + let bound_detail_popup = render_bottom_popup(&app.chat_widget, /*width*/ 100) + .replace(&active_thread_id.to_string(), "") + .replace( + &second_started.session.thread_id.to_string(), + "", + ); + assert_snapshot!("clawbot_bound_channel_detail_popup", bound_detail_popup); + + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_bound_channel_detail_popup.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_bound_channel_detail_popup.snap new file mode 100644 index 000000000..0b632237d --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_bound_channel_detail_popup.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/app/tests/clawbot_tests.rs +expression: bound_detail_popup +--- + Clawbot + active bindings: 1 + ws status: Unconfigured + turn mode: interactive + +› 1. Back Return to bound channels. + Thread + Session + Session ID + Unread + Inbound Forwarding + Outbound Forwarding + 8. Bind current thread + preempt ws Move to current thread + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap index b51145d48..1afcccac7 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap @@ -8,7 +8,7 @@ expression: popup turn mode: interactive › 1. Channels Scan Feishu sessions, inspect bindings, and manage channel state. - 2. Configuration 4/6 configured · interactive + 2. Configuration 4/12 configured · interactive 3. Diagnostics Unconfigured Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_unbound_session_detail_popup.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_unbound_session_detail_popup.snap new file mode 100644 index 000000000..59cf46b13 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_unbound_session_detail_popup.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/app/tests/clawbot_tests.rs +expression: unbound_detail_popup +--- + Clawbot + active bindings: 1 + ws status: Unconfigured + turn mode: interactive + +› 1. Back Return to unbound sessions. + Session + Session ID + Unread + 5. Bind to current thread Target thread + 6. Bind current thread + preempt ws Target thread + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 7baa524c4..19b7dd6e5 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -99,6 +99,12 @@ pub(crate) enum ClawbotForwardingChannel { Outbound, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClawbotSessionBindSource { + DiscoveredSession, + ManualSessionId, +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) enum ClawbotControlsDestination { #[default] @@ -556,7 +562,13 @@ pub(crate) enum AppEvent { field: ClawbotFeishuConfigField, value: String, }, + BindClawbotDiscoveredSession { + session_id: String, + }, + BindClawbotSessionAndPreempt { + session_id: String, + }, SaveClawbotManualBindSessionId { session_id: String, }, @@ -577,6 +589,8 @@ pub(crate) enum AppEvent { RetryClawbotFeishuConnection, + ToggleClawbotForceConnect, + ClawbotDisconnectThread { thread_id: ThreadId, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 829be8a62..d30960c39 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4212,6 +4212,9 @@ impl ChatWidget { } fn on_hook_started(&mut self, event: codex_protocol::protocol::HookStartedEvent) { + if !self.display_preferences.show_hook_output() { + return; + } self.flush_answer_stream_with_separator(); self.flush_completed_hook_output(); match self.active_hook_cell.as_mut() { @@ -4231,6 +4234,9 @@ impl ChatWidget { } fn on_hook_completed(&mut self, event: codex_protocol::protocol::HookCompletedEvent) { + if !self.display_preferences.show_hook_output() { + return; + } let completed = event.run; let completed_existing_run = self .active_hook_cell 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 d591e3462..05d80b2e1 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2075,6 +2075,65 @@ fn hook_live_and_history_snapshot(chat: &ChatWidget, phase: &str, history: &str) ) } +#[tokio::test] +async fn hook_notifications_are_hidden_when_hook_output_preference_is_disabled() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::HookOutput, + /*enabled*/ false, + ); + + chat.handle_server_notification( + ServerNotification::HookStarted(AppServerHookStartedNotification { + thread_id: ThreadId::new().to_string(), + turn_id: Some("turn-1".to_string()), + run: AppServerHookRunSummary { + id: "session-start:0:/tmp/hooks.json".to_string(), + event_name: AppServerHookEventName::SessionStart, + handler_type: AppServerHookHandlerType::Command, + execution_mode: AppServerHookExecutionMode::Sync, + scope: AppServerHookScope::Turn, + source_path: PathBuf::from("/tmp/hooks.json"), + display_order: 0, + status: AppServerHookRunStatus::Running, + status_message: Some("warming the shell".to_string()), + started_at: 1, + completed_at: None, + duration_ms: None, + entries: Vec::new(), + }, + }), + /*replay_kind*/ None, + ); + chat.handle_server_notification( + ServerNotification::HookCompleted(AppServerHookCompletedNotification { + thread_id: ThreadId::new().to_string(), + turn_id: Some("turn-1".to_string()), + run: AppServerHookRunSummary { + id: "session-start:0:/tmp/hooks.json".to_string(), + event_name: AppServerHookEventName::SessionStart, + handler_type: AppServerHookHandlerType::Command, + execution_mode: AppServerHookExecutionMode::Sync, + scope: AppServerHookScope::Turn, + source_path: PathBuf::from("/tmp/hooks.json"), + display_order: 0, + status: AppServerHookRunStatus::Completed, + status_message: Some("warming the shell".to_string()), + started_at: 1, + completed_at: Some(11), + duration_ms: Some(10), + entries: vec![AppServerHookOutputEntry { + kind: AppServerHookOutputEntryKind::Warning, + text: "Heads up from the hook".to_string(), + }], + }, + }), + /*replay_kind*/ None, + ); + + assert!(drain_insert_history(&mut rx).is_empty()); +} + // Combined visual snapshot using vt100 for history + direct buffer overlay for UI. // This renders the final visual as seen in a terminal: history above, then a blank line, // then the exec block, another blank line, the status line, a blank line, and the composer. diff --git a/codex-rs/tui/src/display_preferences.rs b/codex-rs/tui/src/display_preferences.rs index 3dc22226f..da5a9fff4 100644 --- a/codex-rs/tui/src/display_preferences.rs +++ b/codex-rs/tui/src/display_preferences.rs @@ -10,6 +10,7 @@ pub(crate) enum DisplayPreferenceKey { StartupTooltips, RawThinking, ToolResults, + HookOutput, PatchDiffs, } @@ -18,6 +19,7 @@ pub(crate) struct DisplayPreferences { show_startup_tooltips: Arc, show_raw_thinking: Arc, show_tool_results: Arc, + show_hook_output: Arc, show_patch_diffs: Arc, } @@ -27,6 +29,7 @@ impl Default for DisplayPreferences { 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_hook_output: Arc::new(AtomicBool::new(true)), show_patch_diffs: Arc::new(AtomicBool::new(true)), } } @@ -44,6 +47,7 @@ impl DisplayPreferences { DisplayPreferenceKey::StartupTooltips => self.show_startup_tooltips(), DisplayPreferenceKey::RawThinking => self.show_raw_thinking(), DisplayPreferenceKey::ToolResults => self.show_tool_results(), + DisplayPreferenceKey::HookOutput => self.show_hook_output(), DisplayPreferenceKey::PatchDiffs => self.show_patch_diffs(), } } @@ -59,6 +63,9 @@ impl DisplayPreferences { DisplayPreferenceKey::ToolResults => { self.show_tool_results.store(enabled, Ordering::Relaxed); } + DisplayPreferenceKey::HookOutput => { + self.show_hook_output.store(enabled, Ordering::Relaxed); + } DisplayPreferenceKey::PatchDiffs => { self.show_patch_diffs.store(enabled, Ordering::Relaxed); } @@ -77,6 +84,10 @@ impl DisplayPreferences { self.show_tool_results.load(Ordering::Relaxed) } + pub(crate) fn show_hook_output(&self) -> bool { + self.show_hook_output.load(Ordering::Relaxed) + } + pub(crate) fn show_patch_diffs(&self) -> bool { self.show_patch_diffs.load(Ordering::Relaxed) } @@ -90,6 +101,10 @@ impl DisplayPreferences { config.tui_display_preferences.show_tool_results, Ordering::Relaxed, ); + self.show_hook_output.store( + config.tui_display_preferences.show_hook_output, + Ordering::Relaxed, + ); self.show_patch_diffs.store( config.tui_display_preferences.show_patch_diffs, Ordering::Relaxed, @@ -115,6 +130,14 @@ pub(crate) fn display_preference_edit(key: DisplayPreferenceKey, enabled: bool) ], value: enabled.into(), }, + DisplayPreferenceKey::HookOutput => ConfigEdit::SetPath { + segments: vec![ + "tui".to_string(), + "display_preferences".to_string(), + "show_hook_output".to_string(), + ], + value: enabled.into(), + }, DisplayPreferenceKey::PatchDiffs => ConfigEdit::SetPath { segments: vec![ "tui".to_string(), @@ -137,6 +160,9 @@ pub(crate) fn set_display_preference_in_config( DisplayPreferenceKey::ToolResults => { config.tui_display_preferences.show_tool_results = enabled; } + DisplayPreferenceKey::HookOutput => { + config.tui_display_preferences.show_hook_output = enabled; + } DisplayPreferenceKey::PatchDiffs => { config.tui_display_preferences.show_patch_diffs = enabled; } @@ -175,15 +201,19 @@ mod tests { 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_hook_output = false; config.tui_display_preferences.show_patch_diffs = false; let preferences = DisplayPreferences::from_config(&config); assert!(!preferences.show_tool_results()); + assert!(!preferences.show_hook_output()); assert!(!preferences.show_patch_diffs()); preferences.set_enabled(DisplayPreferenceKey::ToolResults, /*enabled*/ true); + preferences.set_enabled(DisplayPreferenceKey::HookOutput, /*enabled*/ true); preferences.set_enabled(DisplayPreferenceKey::PatchDiffs, /*enabled*/ true); assert!(preferences.show_tool_results()); + assert!(preferences.show_hook_output()); assert!(preferences.show_patch_diffs()); set_display_preference_in_config( @@ -191,12 +221,18 @@ mod tests { DisplayPreferenceKey::ToolResults, /*enabled*/ true, ); + set_display_preference_in_config( + &mut config, + DisplayPreferenceKey::HookOutput, + /*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_hook_output); assert!(config.tui_display_preferences.show_patch_diffs); } @@ -234,6 +270,21 @@ mod tests { other => panic!("unexpected config edit: {other:?}"), } + match display_preference_edit(DisplayPreferenceKey::HookOutput, /*enabled*/ false) { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + vec![ + "tui".to_string(), + "display_preferences".to_string(), + "show_hook_output".to_string(), + ] + ); + assert_eq!(value.to_string(), "false"); + } + other => panic!("unexpected config edit: {other:?}"), + } + match display_preference_edit(DisplayPreferenceKey::PatchDiffs, /*enabled*/ false) { ConfigEdit::SetPath { segments, value } => { assert_eq!( diff --git a/codex-rs/tui/src/display_preferences_menu.rs b/codex-rs/tui/src/display_preferences_menu.rs index 44f26501f..0126e9db0 100644 --- a/codex-rs/tui/src/display_preferences_menu.rs +++ b/codex-rs/tui/src/display_preferences_menu.rs @@ -15,6 +15,7 @@ pub(crate) fn display_preferences_items( DisplayPreferenceKey::StartupTooltips, DisplayPreferenceKey::RawThinking, DisplayPreferenceKey::ToolResults, + DisplayPreferenceKey::HookOutput, DisplayPreferenceKey::PatchDiffs, ] .into_iter() @@ -72,6 +73,14 @@ fn display_preference_item( "Show Tool Activity", "Currently hidden. Reveal tool calls and result details in transcript cells.", ), + (DisplayPreferenceKey::HookOutput, true) => ( + "Hide Hook Activity", + "Currently visible. Hide hook lifecycle messages and hook output details in the transcript.", + ), + (DisplayPreferenceKey::HookOutput, false) => ( + "Show Hook Activity", + "Currently hidden. Reveal hook lifecycle messages and hook output details in the transcript.", + ), (DisplayPreferenceKey::PatchDiffs, true) => ( "Hide Patch Diffs", "Currently visible. Collapse patch/edit diff summaries in transcript cells.", 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 cb5ba1bc6..6d9e892bd 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 @@ -20,7 +20,13 @@ expression: render_lines(&view) and result details in transcript cells. - 4. Hide Patch Diffs Currently visible. + 4. Hide Hook Activity Currently visible. + Hide hook + lifecycle messages + and hook output + details in the + transcript. + 5. Hide Patch Diffs Currently visible. Collapse patch/ edit diff summaries in diff --git a/docs/use-cases-from-4fd5c35-to-ce67c2093.md b/docs/use-cases-from-4fd5c35-to-ce67c2093.md new file mode 100644 index 000000000..38480ef0e --- /dev/null +++ b/docs/use-cases-from-4fd5c35-to-ce67c2093.md @@ -0,0 +1,300 @@ +# Use Cases Derived From Commits Since `4fd5c35` + +This document summarizes the product and user use cases that can be inferred from the commits in the range: + +- base: `4fd5c35c4f6f51048f47c8680ed0f6a26c608f68` +- head: `ce67c2093` + +The analysis uses the `effective-use-cases` method. +It treats the repository as a black-box system first and derives user-goal behavior from the observed commit themes, docs, and release flow. + +## Scope And Goal Level + +- Scope: `codex-enhanced` as a persistent user surface for long-running Codex work across sessions, profiles, workflows, local memory, and external message channels. +- Goal level: mostly sea-level user-goal use cases, plus a small number of maintainer-facing operational use cases where packaging or runtime coordination changes are externally meaningful. +- Source basis: commit messages, changed docs, TUI feature surfaces, release workflow changes, and the added Feishu Base coordination behavior for `clawbot`. + +### Non-goals For This Analysis + +- It does not try to describe every refactor, replay commit, or CI repair as a separate use case. +- It does not treat internal test harness changes or alternate build-backend support as standalone end-user goals unless they change an externally meaningful guarantee. +- It does not treat every TUI control or menu item as its own use case when those controls support a larger user goal. + +## Actor And Stakeholder Map + +### Primary actors + +- Workspace user: uses Codex day-to-day in TUI/CLI to run, resume, steer, monitor, and automate work across long-lived sessions. +- External collaborator: sends messages from Feishu into a bound Codex workflow and expects a routed reply. +- Release maintainer: publishes `codex-enhanced` as a multi-platform Python wheel release. + +### Supporting actors + +- Model provider / profile endpoint: serves inference and may rate-limit, overload, or fail auth. +- Workflow scheduler and app-server runtime: executes triggers, jobs, and follow-up turns. +- Feishu platform: supplies inbound messages and accepts outbound replies, reactions, and websocket ownership. +- Feishu Base: stores shared clawbot coordination state for leadership and forced websocket preemption. +- Local filesystem and repo state: stores workflow YAML, profile routing, memory artifacts, reports, and workspace-local clawbot bindings. + +### Off-stage stakeholders and interests + +- Team members relying on continuity: want saved sessions, jump navigation, and recoverable state instead of context rebuild. +- Repository maintainers: want local memory, updated `AGENTS.md`, and reusable skills from prior work. +- PyPI consumers: want an installable wheel with the correct embedded native runtime per platform. +- Users running multiple Codex processes: want only one process to own the embedded Feishu websocket at a time, with explicit preemption when needed. +- Users under interruption: want retries, fallback, visible failures, and non-silent degradation. + +## Commit Theme Clusters + +These are the main behavioral clusters visible in the commit range. + +| Cluster | Representative commits | Inferred behavior direction | +| --- | --- | --- | +| Profile routing and session continuity | `c7d306a2a`, `d1461727f`, `ac08abeae`, `fed91c14a`, `ff71e811a`, `d6da73601` | Keep Codex online across profile failure, respawn, thread routing, and resumed work. | +| Workflow orchestration and follow-up automation | `a59a3a6a6`, `6e759655a`, `29a75c238`, `ea74b8961`, `ee7ff5e54`, `71ba01403`, `58f90f9ae`, `8049f9d1a` | Turn prompts into repeatable jobs with triggers, timeouts, retries, bound-thread routing, and non-blocking follow-up turns. | +| Feishu clawbot bridge and message routing | `55ec5cdb5`, `063dfd100`, `2ee91453f`, `ff8875c17`, `9951bec11`, `acd2b529d`, `27b0b33e9` | Bind external chats to Codex threads and keep inbound and outbound message delivery stable. | +| Feishu websocket ownership coordination | `5eabe81c2`, `ce67c2093` | Let multiple Codex processes coordinate embedded Feishu websocket ownership through Feishu Base, including force-preempt and auto-provisioned coordination tables. | +| Human-in-the-loop control and low-noise TUI | `cb099a038`, `329b4e1ae`, `406389863`, `0f571b53c`, `83ad3dfe2`, `5b9d0af78`, `415cad316` | Make the user surface navigable, structured, and less noisy during long sessions. | +| Insight and retrospective memory | `55d4e11aa`, `88e75bc1d`, `ce9700ab5`, `5b71b8ecf`, `10ab81459` | Convert session history into reports, repo memory, updated instructions, and reusable skills. | +| Packaging and release hardening | `cbd9e0d7d`, `b30d46c3b`, `4194a80f1`, `d359e3ffa`, `dae2b6356` | Ship reliable tagged releases and multi-platform runtime wheels without mismatched artifacts. | +| Runtime resilience and portable validation | `b83f6f399`, `aa67cc953` | Keep streaming behavior and validation stable when retry logic or alternate codegen backends would otherwise change runtime guarantees. | + +## Use Case Inventory + +| ID | Use case | Primary actor | Goal | +| --- | --- | --- | --- | +| UC-1 | Keep Codex running across profiles and failures | Workspace user | Continue work despite rate limits, auth failures, or provider-specific outages. | +| UC-2 | Resume and navigate long-running workspace sessions | Workspace user | Re-enter a saved thread and recover operational context quickly. | +| UC-3 | Define and manage workspace-local workflows | Workspace user | Turn recurring work into runnable jobs and triggers stored in repo-local YAML. | +| UC-4 | Run follow-up automation without blocking the main thread | Workspace user | Let turn completion or file events trigger more work while keeping the main conversation responsive. | +| UC-5 | Bridge Feishu conversations into Codex threads | External collaborator and workspace user | Accept inbound Feishu messages, bind them to the right thread, and return final replies outward. | +| UC-6 | Coordinate Feishu websocket ownership across Codex processes | Workspace user | Ensure only the intended Codex process owns the embedded Feishu websocket for a given app while allowing explicit preemption. | +| UC-7 | Keep the user in the loop with structured control | Workspace user | Give explicit answers, inspect hidden state, jump through history, and reduce UI noise during extended operation. | +| UC-8 | Inspect local session behavior offline | Workspace user | Generate an inspectable report from rollout history without relying on hosted analytics. | +| UC-9 | Convert a completed thread into reusable repo memory | Workspace user | Run `/dream` to update memory, `AGENTS.md`, and repo-local skills for future sessions. | +| UC-10 | Publish a correct multi-platform `codex-enhanced` release | Release maintainer | Build, tag, validate, and publish wheels whose embedded runtime matches the release version. | + +## Fully Dressed Priority Use Cases + +## UC-1 Keep Codex Running Across Profiles And Failures + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: workspace user +- Stakeholders and interests: + - User: ongoing work should not stop because one profile fails. + - Team relying on the user surface: runtime should remain routable without manual config surgery. + - Model provider account owner: failures should be handled explicitly, not hidden. +- Preconditions: + - The user has configured one or more profiles. + - Runtime is active in a repo or workspace session. +- Minimal guarantees: + - Failure state is surfaced. + - Existing routing and config state is preserved. + - Session continuity is not silently broken by profile switch behavior. +- Success guarantees: + - Work continues on a usable profile. + - The user can see or manage the fallback route explicitly. +- Trigger: + - A provider errors due to rate limit, auth failure, or overload, or the user wants to switch runtime profile. +- Main success scenario: + 1. User opens or uses profile routing controls. + 2. Codex detects the current profile is unsuitable or the user selects another route. + 3. Codex keeps session and runtime continuity while switching the active route. + 4. The current thread remains usable without manual environment rewriting. +- Extensions: + - 2a. No fallback route is configured: + Codex reports the issue and leaves the user in control instead of inventing a route. + - 3a. Thread unsubscribe or session handoff would break continuity: + Codex preserves the bound thread and session behavior and avoids losing the current work surface. + - 3b. The CLI respawns: + Session arguments and routing context are preserved across respawn. + +## UC-3 Define And Manage Workspace-Local Workflows + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: workspace user +- Stakeholders and interests: + - User: recurring work should become explicit YAML-backed automation, not ad hoc repeated prompts. + - Repo collaborators: workflow definitions should stay local, inspectable, and editable. + - Runtime: invalid or unrunnable workflow conditions should fail visibly. +- Preconditions: + - The workspace exists and can store `.codex/workflows/*.yaml`. + - The user can open `/workflow`. +- Minimal guarantees: + - Workflow files remain local and inspectable. + - Parse or validation failures surface without partially hidden state. + - Failed runs do not block the rest of the interactive surface indefinitely. +- Success guarantees: + - The user can create, edit, enable, disable, and run workflows. + - Supported triggers and job settings execute using the intended thread and context behavior. +- Trigger: + - The user wants to automate a recurring task or manage existing workflow jobs. +- Main success scenario: + 1. User opens `/workflow`. + 2. Codex loads workspace-local workflow definitions. + 3. User creates or edits a workflow, jobs, and triggers. + 4. Codex validates the workflow and persists the YAML locally. + 5. User runs the workflow manually or waits for a configured trigger. + 6. Codex executes the workflow without blocking unrelated user actions. +- Extensions: + - 3a. Workflow uses timeout, retry, or background execution: + Codex preserves those semantics across rounds and runtime boundaries. + - 4a. YAML is invalid or no-op: + Codex reports the failure clearly and does not pretend the workflow is active. + - 6a. Triggered work binds to a thread: + Codex routes the follow-up into the bound thread instead of spawning unrelated context. + +## UC-5 Bridge Feishu Conversations Into Codex Threads + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: external collaborator, supported by workspace user +- Stakeholders and interests: + - External collaborator: message should reach the right Codex thread and receive a reply. + - User: bindings should remain local, inspectable, and recoverable. + - Runtime: stale bindings and delivery failures should be reconciled, not compounded. +- Preconditions: + - Feishu integration is configured for the workspace. + - A Codex thread can be bound to a Feishu session or channel. +- Minimal guarantees: + - Incoming messages are not silently dropped when binding state is stale. + - Failure to send reactions or replies is surfaced and can be retried or reconciled. + - Bindings remain stored in workspace-local state. +- Success guarantees: + - Inbound message is routed into the correct Codex operational loop. + - Final reply is delivered back to Feishu. + - Session jump and bound-thread continuity remain usable even after reload or respawn. +- Trigger: + - A new Feishu message arrives for a bound conversation, or the user manages bindings from `/clawbot`. +- Main success scenario: + 1. User binds a Feishu session to a Codex thread. + 2. Collaborator sends a message in Feishu. + 3. Codex runtime receives the event and maps it to the bound workspace thread. + 4. Codex processes the inbound message in the correct execution mode. + 5. Codex sends the final reply back to Feishu and updates local state. +- Extensions: + - 3a. Binding is stale or references unloaded thread state: + Codex reconciles the binding or restores jump continuity instead of leaving the session orphaned. + - 4a. Runtime has respawned: + Clawbot runtime restarts and reconnects to the existing operational state. + - 5a. Reaction cancellation or delivery update fails: + Codex reports the failure and avoids pretending the external state was updated. + +## UC-6 Coordinate Feishu Websocket Ownership Across Codex Processes + +- Scope: `codex-clawbot` Feishu coordination within `codex-enhanced` +- Level: sea-level operational goal +- Primary actor: workspace user +- Stakeholders and interests: + - User running multiple workspaces or terminals: only the intended process should hold the embedded websocket for a given Feishu app. + - External collaborators: inbound messages should continue flowing through one live owner instead of being duplicated or lost. + - Feishu app owner: runtime ownership state should be app-owned, inspectable, and repairable without adding a separate paid coordinator. + - Runtime: stale leaders, split-brain, and schema drift should be surfaced instead of silently tolerated. +- Preconditions: + - Feishu app credentials are configured. + - `feishu.coordination.base_token` points to a Base the app can read and write. + - One or more Codex processes may contend for the same Feishu `app_id`. +- Minimal guarantees: + - A non-leader process does not continue pretending it owns the websocket. + - Expired force-intent or heartbeat state stops influencing leadership. + - Permission, schema, or table-resolution failures are surfaced with repairable guidance. + - Coordination state remains stored in Feishu Base and owned by the app credentials, not a hidden sidecar service. +- Success guarantees: + - Exactly one intended process acts as websocket owner for the active `app_id`. + - A user can deliberately preempt ownership for the current session. + - If table IDs are omitted, clawbot discovers or creates the required coordination tables and fields automatically. +- Trigger: + - A coordinated clawbot runtime starts, refreshes leadership, or the user enables forced websocket preemption from `/clawbot`. +- Main success scenario: + 1. Codex starts clawbot runtime with Feishu coordination configured. + 2. Clawbot resolves its process identity and discovers or creates the coordination tables in Feishu Base. + 3. Clawbot writes or refreshes its heartbeat row for the current `app_id` and instance. + 4. Clawbot reads active heartbeat and force-intent rows and computes the elected owner deterministically. + 5. If elected leader, this Codex process opens or keeps the websocket and handles inbound Feishu events. + 6. If not elected, this Codex process stays in follower mode and continues publishing heartbeat only. + 7. When the user enables force connect, clawbot continuously refreshes the force-intent row for the current session until disabled. + 8. Other contenders observe the updated intent and yield websocket ownership on their next coordination refresh. +- Extensions: + - 2a. The configured `base_token` is invalid or inaccessible: + Clawbot surfaces the failure and does not pretend coordination is active. + - 2b. The configured table IDs are stale or the schema drifted: + Clawbot validates the table shape, explains what is wrong, and allows repair or recreation instead of writing into an incompatible schema. + - 4a. The previous leader disappears without cleanup: + Its heartbeat expires by TTL and the next eligible live contender becomes owner. + - 4b. Contenders have equal priority: + The deterministic tie-break falls back to `instance_id` and then `session_id`. + - 7a. Force connect is disabled or the owning process stops refreshing: + The force-intent expires and normal priority-based election resumes. + +## UC-9 Convert A Completed Thread Into Reusable Repo Memory + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: workspace user +- Stakeholders and interests: + - Future user sessions: should inherit useful repo-local guidance from prior work. + - Repo maintainers: want updates to remain deterministic and confined to managed sections. + - Security-sensitive users: secrets must not be copied into generated artifacts. +- Preconditions: + - A thread with useful historical content exists. + - Repo-local storage is available. +- Minimal guarantees: + - Existing user-owned file content outside managed dream blocks is preserved. + - Writes stay repo-local. + - Output is validated and redacted before persistence. +- Success guarantees: + - Repo-local memory artifacts are written. + - Managed blocks in `AGENTS.md` and selected `SKILL.md` files are updated. + - A fresh session can start with an explicit next-session hint. +- Trigger: + - User invokes `/dream` for the current thread. +- Main success scenario: + 1. User runs `/dream`. + 2. Codex loads the current thread and relevant local context. + 3. Codex runs a dedicated retrospective prompt. + 4. Codex validates and redacts the structured result. + 5. Codex writes memory files, managed instruction blocks, and skill updates. + 6. Codex rebuilds the local memory index and starts a fresh thread with the next-session hint. +- Extensions: + - 4a. The model returns paths outside the repo: + Codex rejects them and does not write unsafe output. + - 5a. A target file does not exist: + Codex creates a small file with a managed block rather than failing outright. + - 5b. Managed markers already exist: + Codex replaces only the managed block, not the entire file. + +## Acceptance Criteria + +- Users can keep work running across profile errors without manual environment rewriting. +- Saved sessions can be resumed with enough navigation and visibility control to recover context quickly. +- Workflows are repo-local, editable, runnable, and visibly fail when misconfigured or unrunnable. +- After-turn and background workflow activity does not freeze the main interactive thread. +- Feishu session bindings survive routine runtime disruptions and support visible inbound and outbound handling. +- When multiple Codex processes share the same Feishu app, only the elected owner keeps the embedded websocket active. +- Users can explicitly preempt websocket ownership for the current session, and that preemption naturally expires when force refresh stops. +- Clawbot can discover or auto-provision the required Feishu Base coordination tables and fields when table IDs are not preconfigured. +- `/dream` produces repo-local memory updates through managed sections instead of ad hoc file rewrites. +- `/insight` generates an offline report from local rollout history. +- Tagged `codex-enhanced` releases validate version alignment before publishing wheels. + +## Implementation Slices Suggested By The Use Cases + +- Slice 1: profile-router runtime continuity, fallback policy, and respawn/session-arg preservation. +- Slice 2: session continuity UX including resume picker, jump-to-message, timestamps, and visibility preferences. +- Slice 3: workflow scheduler and runtime, trigger semantics, timeout and retry handling, and TUI workflow controls. +- Slice 4: Feishu clawbot bridge, binding store, message delivery, and session recovery flows. +- Slice 5: Feishu Base coordination backend, heartbeat and force-intent election, auto-provisioned schema management, and leader-or-follower runtime behavior. +- Slice 6: structured human-in-the-loop controls including `question`, richer file reading and search tools, and lower-noise TUI affordances. +- Slice 7: retrospective stack including `/dream`, repo-local memory storage, managed `AGENTS.md` updates, and generated skill updates. +- Slice 8: offline observability via `/insight`. +- Slice 9: release automation for Python runtime packaging, artifact reuse, and multi-platform validation. + +## Open Questions + +- Should Feishu Base coordination remain `app_id`-global, or should future ownership be partitioned more narrowly by channel or bound conversation? +- Should force-connect remain a persistent workspace setting, or should it become an explicit session-scoped lease with stronger expiry semantics? +- Should Feishu remain the only external bridge, or is the longer-term use case actually "generic external user inbox" with Feishu as the first adapter? +- Should `/dream` stay repo-root-focused, or should future iterations target nested `AGENTS.md` scopes automatically? +- Should `/insight` remain a local offline artifact only, or should it eventually feed runtime guidance and profile or workflow tuning loops? diff --git a/docs/use-cases-from-4fd5c35-to-ce67c2093.zh-CN.md b/docs/use-cases-from-4fd5c35-to-ce67c2093.zh-CN.md new file mode 100644 index 000000000..62c500468 --- /dev/null +++ b/docs/use-cases-from-4fd5c35-to-ce67c2093.zh-CN.md @@ -0,0 +1,300 @@ +# 基于 `4fd5c35` 之后 commits 推导的 Use Cases + +本文总结了从以下 commit range 中可以推导出的产品与运维侧 use cases: + +- base: `4fd5c35c4f6f51048f47c8680ed0f6a26c608f68` +- head: `ce67c2093` + +本分析采用 `effective-use-cases` 方法。 +它首先把仓库视为一个 black-box system,再从 commit themes、文档和 release flow 中反推出 user-goal behavior。 + +## Scope And Goal Level + +- Scope: `codex-enhanced`,作为一个面向长时间运行 Codex 工作的 persistent user surface,覆盖 sessions、profiles、workflows、local memory 和 external message channels。 +- Goal level: 以 sea-level 的 user-goal use cases 为主,少量补充 maintainer-facing 的 operational use cases,用于描述 packaging 或 runtime coordination 这类对外可感知的行为变化。 +- Source basis: commit messages、变更后的 docs、TUI feature surfaces、release workflow changes,以及新增的 `clawbot` Feishu Base coordination 行为。 + +### 本次分析的 Non-goals + +- 不尝试把每一个 refactor、replay commit 或 CI 修复都单独描述成一个 use case。 +- 不把 internal test harness changes 或 alternate build-backend support 当作独立 end-user goal,除非它们改变了对外可感知的保证。 +- 不把每一个 TUI control 或 menu item 都拆成单独 use case;如果它们只是服务于更高层 user goal,就并入更大的 use case。 + +## Actor And Stakeholder Map + +### Primary actors + +- Workspace user: 日常在 TUI/CLI 中使用 Codex,运行、恢复、引导、监控并自动化长生命周期工作。 +- External collaborator: 从 Feishu 向一个已绑定的 Codex workflow 发消息,并期待拿到正确路由后的回复。 +- Release maintainer: 将 `codex-enhanced` 发布为 multi-platform Python wheel。 + +### Supporting actors + +- Model provider / profile endpoint: 提供 inference,并可能出现 rate-limit、overload 或 auth failure。 +- Workflow scheduler 和 app-server runtime: 执行 triggers、jobs 和 follow-up turns。 +- Feishu platform: 提供 inbound messages,并接受 outbound replies、reactions 与 websocket ownership。 +- Feishu Base: 存储 clawbot 共享 coordination state,用于 leadership 选举和 forced websocket preemption。 +- Local filesystem 和 repo state: 存储 workflow YAML、profile routing、memory artifacts、reports 和 workspace-local clawbot bindings。 + +### Off-stage stakeholders 及其 interests + +- 依赖 continuity 的团队成员:希望有 saved sessions、jump navigation 和可恢复状态,而不是每次都重建 context。 +- Repository maintainers: 希望有 local memory、更新后的 `AGENTS.md`,以及可复用的 repo-local skills。 +- PyPI consumers: 希望拿到可安装、且内嵌 native runtime 正确匹配的平台 wheel。 +- 同时运行多个 Codex processes 的 users: 希望某个 Feishu websocket 在任一时刻只被一个 process 持有,并且在需要时可以显式 preempt。 +- 处于中断场景下的 users: 希望有 retries、fallback、可见失败,以及非静默降级。 + +## Commit Theme Clusters + +下表概括了这个 commit range 中最主要的 behavioral clusters。 + +| Cluster | Representative commits | Inferred behavior direction | +| --- | --- | --- | +| Profile routing and session continuity | `c7d306a2a`, `d1461727f`, `ac08abeae`, `fed91c14a`, `ff71e811a`, `d6da73601` | 让 Codex 能在 profile failure、respawn、thread routing 和 resumed work 场景下持续在线。 | +| Workflow orchestration and follow-up automation | `a59a3a6a6`, `6e759655a`, `29a75c238`, `ea74b8961`, `ee7ff5e54`, `71ba01403`, `58f90f9ae`, `8049f9d1a` | 把 prompts 变成可重复执行的 jobs,并支持 triggers、timeouts、retries、bound-thread routing 和 non-blocking follow-up turns。 | +| Feishu clawbot bridge and message routing | `55ec5cdb5`, `063dfd100`, `2ee91453f`, `ff8875c17`, `9951bec11`, `acd2b529d`, `27b0b33e9` | 把外部 chats 绑定到 Codex threads,并保持 inbound/outbound message delivery 稳定。 | +| Feishu websocket ownership coordination | `5eabe81c2`, `ce67c2093` | 让多个 Codex processes 通过 Feishu Base 协调 embedded Feishu websocket ownership,包括 force-preempt 与 auto-provisioned coordination tables。 | +| Human-in-the-loop control and low-noise TUI | `cb099a038`, `329b4e1ae`, `406389863`, `0f571b53c`, `83ad3dfe2`, `5b9d0af78`, `415cad316` | 让 user surface 在长会话中更可导航、更结构化、噪音更低。 | +| Insight and retrospective memory | `55d4e11aa`, `88e75bc1d`, `ce9700ab5`, `5b71b8ecf`, `10ab81459` | 把 session history 转成 reports、repo memory、更新后的 instructions 和可复用 skills。 | +| Packaging and release hardening | `cbd9e0d7d`, `b30d46c3b`, `4194a80f1`, `d359e3ffa`, `dae2b6356` | 交付可靠的 tagged releases 和 multi-platform runtime wheels,避免 artifact 不匹配。 | +| Runtime resilience and portable validation | `b83f6f399`, `aa67cc953` | 在 retry logic 或 alternate codegen backend 存在时,保持 streaming behavior 和 validation guarantees 稳定。 | + +## Use Case Inventory + +| ID | Use case | Primary actor | Goal | +| --- | --- | --- | --- | +| UC-1 | Keep Codex running across profiles and failures | Workspace user | 在 rate limits、auth failures 或 provider-specific outages 下继续工作。 | +| UC-2 | Resume and navigate long-running workspace sessions | Workspace user | 重新进入一个已保存 thread,并快速恢复 operational context。 | +| UC-3 | Define and manage workspace-local workflows | Workspace user | 把重复性工作变成保存在 repo-local YAML 中、可运行的 jobs 和 triggers。 | +| UC-4 | Run follow-up automation without blocking the main thread | Workspace user | 让 turn completion 或 file events 触发更多工作,同时保持主会话响应。 | +| UC-5 | Bridge Feishu conversations into Codex threads | External collaborator 和 workspace user | 接收来自 Feishu 的 inbound messages,把它们绑定到正确 thread,并把最终回复发回外部。 | +| UC-6 | Coordinate Feishu websocket ownership across Codex processes | Workspace user | 确保某个 Feishu app 的 embedded websocket 只由目标 Codex process 持有,并允许显式 preemption。 | +| UC-7 | Keep the user in the loop with structured control | Workspace user | 通过显式回答、隐藏状态检查、历史跳转和降噪 UI 维持操作可控。 | +| UC-8 | Inspect local session behavior offline | Workspace user | 从 rollout history 生成一个可检查的离线报告,而不是依赖 hosted analytics。 | +| UC-9 | Convert a completed thread into reusable repo memory | Workspace user | 运行 `/dream`,把 thread 结果转成 memory、`AGENTS.md` 更新和 repo-local skills。 | +| UC-10 | Publish a correct multi-platform `codex-enhanced` release | Release maintainer | 构建、打 tag、校验并发布 wheels,确保其中嵌入的 runtime 与 release version 匹配。 | + +## Fully Dressed Priority Use Cases + +## UC-1 Keep Codex Running Across Profiles And Failures + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: workspace user +- Stakeholders and interests: + - User: 不应该因为单个 profile 失败而中断当前工作。 + - 依赖 user surface 的团队成员:runtime routing 不应因为一次失败就需要手工改配置。 + - Model provider account owner: 失败必须被显式处理,而不是被隐藏。 +- Preconditions: + - User 已配置一个或多个 profiles。 + - Runtime 正在某个 repo 或 workspace session 中运行。 +- Minimal guarantees: + - Failure state 会被明确暴露。 + - 现有 routing 和 config state 会被保留。 + - Session continuity 不会因为 profile switch 而被静默破坏。 +- Success guarantees: + - 工作可以在一个可用 profile 上继续进行。 + - User 可以显式看到并管理 fallback route。 +- Trigger: + - 某个 provider 因 rate limit、auth failure 或 overload 出错,或者 user 主动想切换 runtime profile。 +- Main success scenario: + 1. User 打开或使用 profile routing controls。 + 2. Codex 发现当前 profile 不再适合,或 user 选择了另一条 route。 + 3. Codex 在切换 active route 时保持 session 和 runtime continuity。 + 4. 当前 thread 无需手工改环境变量即可继续使用。 +- Extensions: + - 2a. 没有配置 fallback route: + Codex 报告问题,并把控制权交还给 user,而不是臆造一条 route。 + - 3a. `thread_unsubscribe` 或 session handoff 会破坏 continuity: + Codex 保持 bound thread 和 session 行为,避免丢失当前工作面。 + - 3b. CLI 发生 respawn: + Session arguments 和 routing context 会跨 respawn 被保留。 + +## UC-3 Define And Manage Workspace-Local Workflows + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: workspace user +- Stakeholders and interests: + - User: 重复工作应该沉淀成显式的 YAML-backed automation,而不是反复手敲 prompt。 + - Repo collaborators: workflow definitions 应保持 local、可检查、可编辑。 + - Runtime: invalid 或不可运行的 workflow conditions 必须可见地失败。 +- Preconditions: + - Workspace 能存储 `.codex/workflows/*.yaml`。 + - User 可以打开 `/workflow`。 +- Minimal guarantees: + - Workflow files 保持 local 且可检查。 + - Parse 或 validation failures 不会被部分隐藏。 + - 失败的 workflow runs 不会无限阻塞其他 interactive surface。 +- Success guarantees: + - User 可以创建、编辑、启用、停用和运行 workflows。 + - 支持的 triggers 和 job settings 能按预期 thread/context behavior 执行。 +- Trigger: + - User 想把一类重复任务自动化,或管理已有 workflow jobs。 +- Main success scenario: + 1. User 打开 `/workflow`。 + 2. Codex 加载 workspace-local workflow definitions。 + 3. User 创建或编辑 workflow、jobs 和 triggers。 + 4. Codex 校验 workflow,并把 YAML 持久化到本地。 + 5. User 手动运行 workflow,或等待某个 trigger 触发。 + 6. Codex 执行 workflow,同时不阻塞无关的 user actions。 +- Extensions: + - 3a. Workflow 使用 timeout、retry 或 background execution: + Codex 会跨 rounds 和 runtime boundaries 保持这些语义。 + - 4a. YAML 无效或 no-op: + Codex 明确报告失败,而不是假装 workflow 已激活。 + - 6a. Triggered work 绑定到某个 thread: + Codex 会把 follow-up 路由到对应的 bound thread,而不是生成无关上下文。 + +## UC-5 Bridge Feishu Conversations Into Codex Threads + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: external collaborator,由 workspace user 支持 +- Stakeholders and interests: + - External collaborator: 消息应该到达正确的 Codex thread,并拿到回复。 + - User: bindings 应保持 local、可检查、可恢复。 + - Runtime: stale bindings 和 delivery failures 应被修复和协调,而不是持续累积。 +- Preconditions: + - Workspace 已配置 Feishu integration。 + - 一个 Codex thread 可以绑定到 Feishu session 或 channel。 +- Minimal guarantees: + - 即使 binding state 已陈旧,incoming messages 也不会被静默丢弃。 + - 发送 reactions 或 replies 失败时,错误会被暴露,并允许 retry 或 reconciliation。 + - Bindings 仍保存在 workspace-local state 中。 +- Success guarantees: + - Inbound message 会被路由进正确的 Codex operational loop。 + - Final reply 会被送回 Feishu。 + - Session jump 和 bound-thread continuity 在 reload 或 respawn 后仍可使用。 +- Trigger: + - 某个已绑定会话收到新的 Feishu message,或 user 在 `/clawbot` 中管理 bindings。 +- Main success scenario: + 1. User 把一个 Feishu session 绑定到当前 Codex thread。 + 2. Collaborator 在 Feishu 中发送消息。 + 3. Codex runtime 接收事件,并把它映射到对应的 workspace thread。 + 4. Codex 在正确的 execution mode 中处理这条 inbound message。 + 5. Codex 把 final reply 发回 Feishu,并更新本地状态。 +- Extensions: + - 3a. Binding 已陈旧,或引用了尚未加载的 thread state: + Codex 会做 binding reconciliation 或恢复 jump continuity,而不是让会话悬空。 + - 4a. Runtime 发生 respawn: + Clawbot runtime 会重启并重新连接现有 operational state。 + - 5a. Reaction cancellation 或 delivery update 失败: + Codex 会报告失败,而不是假装外部状态已经更新。 + +## UC-6 Coordinate Feishu Websocket Ownership Across Codex Processes + +- Scope: `codex-clawbot` 的 Feishu coordination,属于 `codex-enhanced` 的一部分 +- Level: sea-level operational goal +- Primary actor: workspace user +- Stakeholders and interests: + - 同时运行多个 workspaces 或 terminals 的 user: 对于同一个 Feishu app,只有目标 process 应该持有 embedded websocket。 + - External collaborators: inbound messages 应持续流向一个 live owner,而不是被重复消费或直接丢失。 + - Feishu app owner: runtime ownership state 应由 app 自身拥有、可检查、可修复,而不是依赖额外的付费协调器。 + - Runtime: stale leader、split-brain 和 schema drift 必须可见,而不是被静默容忍。 +- Preconditions: + - 已配置 Feishu app credentials。 + - `feishu.coordination.base_token` 指向一个该 app 可读可写的 Base。 + - 可能存在一个或多个 Codex processes 竞争同一个 Feishu `app_id`。 +- Minimal guarantees: + - 非 leader process 不会继续假装自己拥有 websocket。 + - 过期的 force-intent 或 heartbeat state 不再影响 leadership。 + - Permission、schema 或 table-resolution failures 会带着可修复指引被暴露出来。 + - Coordination state 存在于 Feishu Base 中,并由 app credentials 持有,而不是写进一个隐藏 sidecar service。 +- Success guarantees: + - 对于当前 `app_id`,恰好有一个预期中的 process 作为 websocket owner。 + - User 可以为当前 session 显式发起 ownership preemption。 + - 如果没有提供 table IDs,clawbot 会自动发现或创建必需的 coordination tables 和 fields。 +- Trigger: + - 一个配置了 coordination 的 clawbot runtime 启动、刷新 leadership,或 user 在 `/clawbot` 中启用 forced websocket preemption。 +- Main success scenario: + 1. Codex 以启用了 Feishu coordination 的方式启动 clawbot runtime。 + 2. Clawbot 解析自己的 process identity,并在 Feishu Base 中发现或创建 coordination tables。 + 3. Clawbot 为当前 `app_id` 和 instance 写入或刷新 heartbeat row。 + 4. Clawbot 读取 active heartbeat 和 force-intent rows,并以 deterministic 规则计算 elected owner。 + 5. 如果当前 process 被选为 leader,它就打开或保持 websocket,并处理 inbound Feishu events。 + 6. 如果未当选 leader,它就保持 follower mode,只继续发布 heartbeat。 + 7. 当 user 启用 `force connect` 时,clawbot 会持续为当前 session 刷新 force-intent row,直到该状态被关闭。 + 8. 其他 contenders 会在下一次 coordination refresh 时观察到新的 intent,并让出 websocket ownership。 +- Extensions: + - 2a. 配置的 `base_token` 无效或不可访问: + Clawbot 会暴露失败,而不是假装 coordination 已生效。 + - 2b. 配置的 table IDs 已陈旧,或 schema 出现 drift: + Clawbot 会校验 table shape,说明具体问题,并允许 repair 或 recreation,而不是写进一个不兼容 schema。 + - 4a. 上一个 leader 没有清理就消失: + 它的 heartbeat 会在 TTL 过期后失效,随后由下一个 live contender 接管 owner 身份。 + - 4b. 多个 contenders 具有相同 priority: + Deterministic tie-break 会回退到 `instance_id`,再回退到 `session_id`。 + - 7a. `force connect` 被关闭,或 owning process 停止刷新: + Force-intent 会自然过期,leader election 恢复为普通的 priority-based 模式。 + +## UC-9 Convert A Completed Thread Into Reusable Repo Memory + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: workspace user +- Stakeholders and interests: + - Future user sessions: 应能继承此前工作沉淀出的 repo-local guidance。 + - Repo maintainers: 希望更新具备确定性,并限制在 managed sections 内。 + - Security-sensitive users: secrets 不应被复制进生成产物。 +- Preconditions: + - 存在一个包含有价值历史内容的 thread。 + - Repo-local storage 可用。 +- Minimal guarantees: + - `dream` blocks 之外的用户内容不会被覆盖。 + - 写入保持 repo-local。 + - 输出在落盘前会被校验并做 redaction。 +- Success guarantees: + - Repo-local memory artifacts 被写出。 + - `AGENTS.md` 和选定 `SKILL.md` 文件中的 managed blocks 被更新。 + - 新 session 启动时可以直接拿到一个明确的 next-session hint。 +- Trigger: + - User 对当前 thread 执行 `/dream`。 +- Main success scenario: + 1. User 运行 `/dream`。 + 2. Codex 加载当前 thread 及相关 local context。 + 3. Codex 运行一个专门的 retrospective prompt。 + 4. Codex 校验并 redacts 结构化结果。 + 5. Codex 写入 memory files、managed instruction blocks 和 skill updates。 + 6. Codex 重建 local memory index,并带着 next-session hint 启动一个新 thread。 +- Extensions: + - 4a. 模型返回 repo 外部路径: + Codex 会拒绝这些路径,不写入不安全内容。 + - 5a. 目标文件不存在: + Codex 会创建一个带 managed block 的小文件,而不是直接失败。 + - 5b. Managed markers 已存在: + Codex 只替换 managed block,而不是重写整个文件。 + +## Acceptance Criteria + +- Users 可以在 profile 出错时继续工作,而不需要手工改环境。 +- Saved sessions 可以被恢复,并具有足够的 navigation 和 visibility control 来快速找回 context。 +- Workflows 是 repo-local、可编辑、可运行的,并且在 misconfigured 或 unrunnable 时会显式失败。 +- After-turn 和 background workflow activity 不会冻结主 interactive thread。 +- Feishu session bindings 能在常见 runtime disruptions 下保持可用,并支持可见的 inbound/outbound handling。 +- 当多个 Codex processes 共用同一个 Feishu app 时,只有 elected owner 会保持 embedded websocket active。 +- Users 可以为当前 session 显式 preempt websocket ownership,并且当 force refresh 停止后,这种 preemption 会自然失效。 +- 当 table IDs 未预配置时,clawbot 可以发现或 auto-provision 必需的 Feishu Base coordination tables 和 fields。 +- `/dream` 通过 managed sections 生成 repo-local memory updates,而不是做 ad hoc file rewrites。 +- `/insight` 可以从本地 rollout history 生成 offline report。 +- Tagged `codex-enhanced` releases 会在发布 wheels 之前校验 version alignment。 + +## Implementation Slices Suggested By The Use Cases + +- Slice 1: profile-router runtime continuity、fallback policy,以及 respawn/session-arg preservation。 +- Slice 2: session continuity UX,包括 resume picker、jump-to-message、timestamps 和 visibility preferences。 +- Slice 3: workflow scheduler/runtime、trigger semantics、timeout/retry handling,以及 TUI workflow controls。 +- Slice 4: Feishu clawbot bridge、binding store、message delivery 和 session recovery flows。 +- Slice 5: Feishu Base coordination backend、heartbeat/force-intent election、auto-provisioned schema management,以及 leader-or-follower runtime behavior。 +- Slice 6: structured human-in-the-loop controls,包括 `question`、更强的 file reading/search tools,以及 lower-noise TUI affordances。 +- Slice 7: retrospective stack,包括 `/dream`、repo-local memory storage、managed `AGENTS.md` updates,以及 generated skill updates。 +- Slice 8: offline observability,通过 `/insight` 实现。 +- Slice 9: Python runtime packaging 的 release automation、artifact reuse 和 multi-platform validation。 + +## Open Questions + +- Feishu Base coordination 是否应该一直保持 `app_id`-global,还是未来要按 channel 或 bound conversation 进一步切分 ownership? +- `force-connect` 应继续作为 persistent workspace setting,还是应该演进为一个显式的 session-scoped lease,并带有更强的 expiry semantics? +- Feishu 是否会一直是唯一 external bridge,还是长期目标其实是一个更通用的 “generic external user inbox”,而 Feishu 只是第一个 adapter? +- `/dream` 是否应继续以 repo-root 为中心,还是未来要自动下钻到 nested `AGENTS.md` scopes? +- `/insight` 是否应继续保持为纯本地 offline artifact,还是未来应反过来 feeding runtime guidance,以及 profile/workflow tuning loops? diff --git a/docs/use-cases-from-4fd5c35.md b/docs/use-cases-from-4fd5c35.md new file mode 100644 index 000000000..eefd6d017 --- /dev/null +++ b/docs/use-cases-from-4fd5c35.md @@ -0,0 +1,286 @@ +# Use Cases Derived From Commits Since `4fd5c35` + +This document summarizes the product and user use cases that can be inferred from the commits in the range: + +- base: `4fd5c35c4f6f51048f47c8680ed0f6a26c608f68` +- head: `dae2b6356` (`v0.1.28`) + +The analysis uses the `effective-use-cases` method. +It treats the repository as a black-box system first and derives user-goal behavior from the observed commit themes, docs, and release flow. + +## Scope And Goal Level + +- Scope: `codex-enhanced` as a persistent user surface for long-running Codex work across sessions, profiles, workflows, and external message channels. +- Goal level: mostly sea-level user-goal use cases, plus one maintainer-facing operational use case for packaging and release. +- Source basis: commit messages, changed docs, release workflow changes, and feature surfaces exposed in TUI slash commands and runtime docs. + +### Non-goals For This Analysis + +- It does not try to describe every refactor, replay commit, or CI repair as a separate use case. +- It does not treat internal module splits as user-visible goals unless they change externally meaningful behavior. +- It treats fork-CI stabilization as supporting engineering infrastructure, not as a primary end-user use case. + +## Actor And Stakeholder Map + +### Primary actors + +- Workspace user: uses Codex day-to-day in TUI/CLI to run, resume, steer, and monitor work. +- External collaborator: sends messages from Feishu into a bound Codex workflow. +- Release maintainer: publishes `codex-enhanced` as a multi-platform Python wheel release. + +### Supporting actors + +- Model provider / profile endpoint: serves inference and may rate-limit, overload, or fail auth. +- Workflow scheduler and app-server runtime: executes triggers, jobs, and follow-up turns. +- Feishu platform: supplies inbound messages and accepts outbound replies/reaction updates. +- Local filesystem and repo state: stores workflow YAML, profile routing, memory artifacts, and reports. + +### Off-stage stakeholders and interests + +- Team members relying on continuity: want saved sessions, jump navigation, and recoverable state instead of context rebuild. +- Repository maintainers: want local memory, updated `AGENTS.md`, and reusable skills from prior work. +- PyPI consumers: want an installable wheel with the correct embedded native runtime per platform. +- Users under interruption: want retries, fallback, visible failures, and non-silent degradation. + +## Commit Theme Clusters + +These are the main behavioral clusters visible in the commit range. + +| Cluster | Representative commits | Inferred behavior direction | +| --- | --- | --- | +| Profile routing and session continuity | `c7d306a2a`, `d1461727f`, `ac08abeae`, `fed91c14a`, `ff71e811a`, `d6da73601` | Keep Codex online across profile failure, respawn, thread routing, and resumed work. | +| Workflow orchestration and follow-up automation | `a59a3a6a6`, `6e759655a`, `29a75c238`, `ea74b8961`, `ee7ff5e54`, `71ba01403`, `58f90f9ae`, `8049f9d1a` | Turn prompts into repeatable jobs with triggers, timeouts, retries, bound-thread routing, and non-blocking follow-up turns. | +| Feishu clawbot bridge | `55ec5cdb5`, `063dfd100`, `2ee91453f`, `ff8875c17`, `9951bec11`, `acd2b529d`, `27b0b33e9` | Bind external chats to Codex threads and keep inbound/outbound message delivery stable. | +| Human-in-the-loop control and low-noise TUI | `cb099a038`, `329b4e1ae`, `406389863`, `0f571b53c`, `83ad3dfe2`, `5b9d0af78`, `415cad316` | Make the user surface navigable, structured, and less noisy during long sessions. | +| Insight and retrospective memory | `55d4e11aa`, `88e75bc1d`, `ce9700ab5`, `5b71b8ecf`, `10ab81459` | Convert session history into reports, repo memory, updated instructions, and reusable skills. | +| Packaging and release hardening | `cbd9e0d7d`, `b30d46c3b`, `4194a80f1`, `d359e3ffa`, `dae2b6356` | Ship reliable tagged releases and multi-platform runtime wheels without mismatched artifacts. | + +## Use Case Inventory + +| ID | Use case | Primary actor | Goal | +| --- | --- | --- | --- | +| UC-1 | Keep Codex running across profiles and failures | Workspace user | Continue work despite rate limits, auth failures, or provider-specific outages. | +| UC-2 | Resume and navigate long-running workspace sessions | Workspace user | Re-enter a saved thread and recover operational context quickly. | +| UC-3 | Define and manage workspace-local workflows | Workspace user | Turn recurring work into runnable jobs and triggers stored in repo-local YAML. | +| UC-4 | Run follow-up automation without blocking the main thread | Workspace user | Let turn completion or file events trigger more work while keeping the main conversation responsive. | +| UC-5 | Bridge Feishu conversations into Codex threads | External collaborator and workspace user | Accept inbound Feishu messages, bind them to the right thread, and return final replies outward. | +| UC-6 | Keep the user in the loop with structured control | Workspace user | Give explicit answers, inspect hidden state, jump through history, and reduce UI noise during extended operation. | +| UC-7 | Inspect local session behavior offline | Workspace user | Generate an inspectable report from rollout history without relying on hosted analytics. | +| UC-8 | Convert a completed thread into reusable repo memory | Workspace user | Run `/dream` to update memory, `AGENTS.md`, and repo-local skills for future sessions. | +| UC-9 | Publish a correct multi-platform `codex-enhanced` release | Release maintainer | Build, tag, validate, and publish wheels whose embedded runtime matches the release version. | + +## Fully Dressed Priority Use Cases + +## UC-1 Keep Codex Running Across Profiles And Failures + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: workspace user +- Stakeholders and interests: + - User: ongoing work should not stop because one profile fails. + - Team relying on the user surface: runtime should remain routable without manual config surgery. + - Model provider account owner: failures should be handled explicitly, not hidden. +- Preconditions: + - The user has configured one or more profiles. + - Runtime is active in a repo or workspace session. +- Minimal guarantees: + - Failure state is surfaced. + - Existing routing/config state is preserved. + - Session continuity is not silently broken by profile switch behavior. +- Success guarantees: + - Work continues on a usable profile. + - The user can see or manage the fallback route explicitly. +- Trigger: + - A provider errors due to rate limit, auth failure, or overload, or the user wants to switch runtime profile. +- Main success scenario: + 1. User opens or uses profile routing controls. + 2. Codex detects the current profile is unsuitable or the user selects another route. + 3. Codex keeps session/runtime continuity while switching the active route. + 4. The current thread remains usable without manual environment rewriting. +- Extensions: + - 2a. No fallback route is configured: + Codex reports the issue and leaves the user in control instead of inventing a route. + - 3a. Thread unsubscribe or session handoff would break continuity: + Codex preserves the bound thread/session behavior and avoids losing the current work surface. + - 3b. The CLI respawns: + Session arguments and routing context are preserved across respawn. + +## UC-3 Define And Manage Workspace-Local Workflows + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: workspace user +- Stakeholders and interests: + - User: recurring work should become explicit YAML-backed automation, not ad hoc repeated prompts. + - Repo collaborators: workflow definitions should stay local, inspectable, and editable. + - Runtime: invalid or unrunnable workflow conditions should fail visibly. +- Preconditions: + - The workspace exists and can store `.codex/workflows/*.yaml`. + - The user can open `/workflow`. +- Minimal guarantees: + - Workflow definitions remain local files. + - Invalid YAML or disabled/unrunnable job states are visible. + - Manual execution does not silently no-op. +- Success guarantees: + - User can create, edit, enable, disable, and run jobs/triggers. + - Trigger parameters such as `interval`, `cron`, `idle`, `file_watch`, and `bind_thread` are retained in workflow state. +- Trigger: + - The user wants repeatable background automation tied to the workspace. +- Main success scenario: + 1. User opens `/workflow`. + 2. Codex lists workflow files, jobs, triggers, and background task state. + 3. User edits YAML directly or uses structured menu actions. + 4. Codex persists the workflow definition locally. + 5. User runs a job or enables a trigger. + 6. Runtime executes the selected workflow with the configured context and response mode. +- Extensions: + - 3a. No workflow file exists: + Codex offers to create a starter file and opens it in the editor. + - 5a. Trigger resolves only to disabled or unrunnable jobs: + Codex fails visibly instead of silently discarding the run. + - 6a. A step times out: + Codex applies configured timeout/retry behavior and surfaces failure if recovery is exhausted. + - 6b. A `file_watch` trigger is already queued or running: + Duplicate events are skipped according to overlap policy instead of creating a storm. + +## UC-5 Bridge Feishu Conversations Into Codex Threads + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: external collaborator, supported by workspace user +- Stakeholders and interests: + - External collaborator: message should reach the right Codex thread and receive a reply. + - User: bindings should remain local, inspectable, and recoverable. + - Runtime: stale bindings and delivery failures should be reconciled, not compounded. +- Preconditions: + - Feishu integration is configured for the workspace. + - A Codex thread can be bound to a Feishu session/channel. +- Minimal guarantees: + - Incoming messages are not silently dropped when binding state is stale. + - Failure to send reactions or replies is surfaced and can be retried or reconciled. + - Bindings remain stored in workspace-local state. +- Success guarantees: + - Inbound message is routed into the correct Codex operational loop. + - Final reply is delivered back to Feishu. + - Session jump and bound-thread continuity remain usable even after reload or respawn. +- Trigger: + - A new Feishu message arrives for a bound conversation, or the user manages bindings from `/clawbot`. +- Main success scenario: + 1. User binds a Feishu session to a Codex thread. + 2. Collaborator sends a message in Feishu. + 3. Codex runtime receives the event and maps it to the bound workspace thread. + 4. Codex processes the inbound message in the correct execution mode. + 5. Codex sends the final reply back to Feishu and updates local state. +- Extensions: + - 3a. Binding is stale or references unloaded thread state: + Codex reconciles the binding or restores jump continuity instead of leaving the session orphaned. + - 4a. Runtime has respawned: + Clawbot runtime restarts and reconnects to the existing operational state. + - 5a. Reaction cancellation or delivery update fails: + Codex reports the failure and avoids pretending the external state was updated. + +## UC-8 Convert A Completed Thread Into Reusable Repo Memory + +- Scope: `codex-enhanced` +- Level: sea-level user goal +- Primary actor: workspace user +- Stakeholders and interests: + - Future user sessions: should inherit useful repo-local guidance from prior work. + - Repo maintainers: want updates to remain deterministic and confined to managed sections. + - Security-sensitive users: secrets must not be copied into generated artifacts. +- Preconditions: + - A thread with useful historical content exists. + - Repo-local storage is available. +- Minimal guarantees: + - Existing user-owned file content outside managed dream blocks is preserved. + - Writes stay repo-local. + - Output is validated and redacted before persistence. +- Success guarantees: + - Repo-local memory artifacts are written. + - Managed blocks in `AGENTS.md` and selected `SKILL.md` files are updated. + - A fresh session can start with an explicit next-session hint. +- Trigger: + - User invokes `/dream` for the current thread. +- Main success scenario: + 1. User runs `/dream`. + 2. Codex loads the current thread and relevant local context. + 3. Codex runs a dedicated retrospective prompt. + 4. Codex validates and redacts the structured result. + 5. Codex writes memory files, managed instruction blocks, and skill updates. + 6. Codex rebuilds the local memory index and starts a fresh thread with the next-session hint. +- Extensions: + - 4a. The model returns paths outside the repo: + Codex rejects them and does not write unsafe output. + - 5a. A target file does not exist: + Codex creates a small file with a managed block rather than failing outright. + - 5b. Managed markers already exist: + Codex replaces only the managed block, not the entire file. + +## UC-9 Publish A Correct Multi-Platform `codex-enhanced` Release + +- Scope: release workflow for `codex-enhanced` +- Level: sea-level operational goal +- Primary actor: release maintainer +- Stakeholders and interests: + - End users: published wheel should install and include the correct native binary. + - Maintainer: tag, runtime version, and embedded artifacts must match. + - Release automation: concurrency and artifact reuse should not deadlock or publish the wrong bits. +- Preconditions: + - Release version is chosen. + - Tag and source state are ready. +- Minimal guarantees: + - Version mismatch between tag and runtime package fails before publish. + - Artifact reuse and publish flow are explicit. + - Windows/macOS/Linux wheel packaging is separated and visible. +- Success guarantees: + - Tagged release produces matching wheel artifacts. + - Wheels are published to PyPI. + - GitHub release assets are attached for the tagged version. +- Trigger: + - Maintainer pushes a `v*.*.*` tag or dispatches the release workflow manually. +- Main success scenario: + 1. Maintainer creates a release tag. + 2. Workflow validates that the tag matches `sdk/python-runtime-enhanced/pyproject.toml`. + 3. Release assets and platform-specific runtime binaries are prepared. + 4. Wheels are built for supported targets. + 5. Publish step uploads wheels to PyPI. + 6. GitHub release step attaches release artifacts to the same tag. +- Extensions: + - 2a. Tag version and runtime version differ: + Workflow stops before publish. + - 3a. Earlier artifact run should be reused: + Workflow downloads the selected artifacts and validates they contain wheel files. + - 4a. Windows runtime packaging fails: + Build is isolated per target and does not obscure which platform failed. + - 5a. Concurrency would deadlock or cancel the wrong release: + Workflow uses per-tag concurrency scoping to keep release runs isolated. + +## Acceptance Criteria + +- Users can keep work running across profile errors without manual environment rewriting. +- Saved sessions can be resumed with enough navigation and visibility control to recover context quickly. +- Workflows are repo-local, editable, runnable, and visibly fail when misconfigured or unrunnable. +- After-turn and background workflow activity does not freeze the main interactive thread. +- Feishu session bindings survive routine runtime disruptions and support visible inbound/outbound handling. +- `/dream` produces repo-local memory updates through managed sections instead of ad hoc file rewrites. +- `/insight` generates an offline report from local rollout history. +- Tagged `codex-enhanced` releases validate version alignment before publishing wheels. + +## Implementation Slices Suggested By The Use Cases + +- Slice 1: profile-router runtime continuity, fallback policy, respawn/session-arg preservation. +- Slice 2: session continuity UX including resume picker, jump-to-message, timestamps, and visibility preferences. +- Slice 3: workflow scheduler/runtime, trigger semantics, timeout/retry handling, and TUI workflow controls. +- Slice 4: Feishu clawbot runtime bridge, binding store, message delivery, and admin/session recovery flows. +- Slice 5: structured human-in-the-loop controls including `question`, richer file reading/search tools, and lower-noise TUI affordances. +- Slice 6: retrospective stack including `/dream`, repo-local memory storage, managed `AGENTS.md` updates, and generated skill updates. +- Slice 7: offline observability via `/insight`. +- Slice 8: release automation for Python runtime packaging, artifact reuse, and multi-platform validation. + +## Open Questions + +- Should `/dream` stay repo-root-focused, or should future iterations target nested `AGENTS.md` scopes automatically? +- Should profile fallback become policy-driven enough to express different failure classes separately, or is the current route model sufficient? +- Should Feishu remain the only external bridge, or is the intended use case actually "generic external user inbox" with Feishu as the first adapter? +- Should `/insight` remain a local offline artifact only, or should it eventually feed runtime guidance and profile/workflow tuning loops? +- Which current TUI continuity improvements are true user-goal behavior, and which should remain implementation detail rather than product surface? diff --git a/sdk/python-runtime-enhanced/pyproject.toml b/sdk/python-runtime-enhanced/pyproject.toml index 1cc92ed23..5da25f3a8 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.28" +version = "0.1.29" description = "Enhanced Codex CLI with loop automation, Feishu clawbot support, and fast session respawn" readme = "README.md" requires-python = ">=3" From 8885e63e7af13668b9eefe08afd4d4d3cc44cf87 Mon Sep 17 00:00:00 2001 From: piping Date: Fri, 17 Apr 2026 09:55:53 +0800 Subject: [PATCH 81/83] update rebase to upstream/main 04-17-2026 --- codex-rs/Cargo.lock | 484 ++++++++++------ .../src/tools/handlers/multi_agents_tests.rs | 4 +- .../src/tools/handlers/request_user_input.rs | 8 +- .../handlers/request_user_input_tests.rs | 1 + codex-rs/core/src/tools/spec_tests.rs | 11 +- .../core/tests/suite/stream_no_completed.rs | 1 + codex-rs/tools/src/agent_tool.rs | 20 +- codex-rs/tools/src/request_user_input_tool.rs | 1 - .../src/request_user_input_tool_tests.rs | 102 ++-- codex-rs/tools/src/tool_registry_plan.rs | 11 +- .../tools/src/tool_registry_plan_tests.rs | 1 + codex-rs/tools/src/utility_tool.rs | 131 ++--- codex-rs/tools/src/utility_tool_tests.rs | 154 +++-- codex-rs/tui/src/app.rs | 188 +++---- codex-rs/tui/src/app/agent_navigation.rs | 56 +- codex-rs/tui/src/app/btw.rs | 109 +++- codex-rs/tui/src/app/clawbot.rs | 10 +- codex-rs/tui/src/app/key_chord.rs | 48 +- codex-rs/tui/src/app/profile_management.rs | 45 +- codex-rs/tui/src/app/tests/btw_tests.rs | 90 +++ codex-rs/tui/src/app/tests/clawbot_tests.rs | 19 +- codex-rs/tui/src/app/thread_controller.rs | 6 +- codex-rs/tui/src/app/workflow_history.rs | 4 +- codex-rs/tui/src/app/workflow_runtime.rs | 15 +- codex-rs/tui/src/bottom_pane/footer.rs | 34 +- ...__tests__footer_mode_shortcut_overlay.snap | 15 +- ...shortcuts_collaboration_modes_enabled.snap | 14 +- ...tests__footer_shortcuts_shift_and_esc.snap | 15 +- codex-rs/tui/src/chatwidget.rs | 526 +----------------- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 36 +- .../chatwidget/tests/composer_submission.rs | 2 +- .../src/chatwidget/tests/slash_commands.rs | 14 +- .../src/chatwidget/tests/status_and_layout.rs | 6 +- codex-rs/tui/src/display_preferences.rs | 8 +- codex-rs/tui/src/insight/collector.rs | 9 +- codex-rs/tui/src/insight/mod.rs | 11 +- codex-rs/tui/src/lib.rs | 3 +- codex-rs/tui/src/main.rs | 2 +- 38 files changed, 943 insertions(+), 1271 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4c523cbd6..4bb80940d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -449,7 +449,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -801,7 +801,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tower", "tower-layer", "tower-service", @@ -940,15 +940,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -1372,7 +1363,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width 0.2.1", ] [[package]] @@ -1428,10 +1419,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", ] @@ -1499,7 +1490,7 @@ dependencies = [ "tempfile", "time", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tokio-util", "toml 0.9.11+spec-1.1.0", "tracing", @@ -1527,7 +1518,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "toml 0.9.11+spec-1.1.0", "tracing", "url", @@ -1540,6 +1531,7 @@ dependencies = [ "anyhow", "clap", "codex-experimental-api-macros", + "codex-git-utils", "codex-protocol", "codex-shell-command", "codex-utils-absolute-path", @@ -1576,7 +1568,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "tungstenite", + "tungstenite 0.27.0", "url", "uuid", ] @@ -1657,7 +1649,6 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", - "codex-app-server-protocol", "codex-config", "codex-connectors", "codex-core", @@ -1672,6 +1663,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" @@ -1740,10 +1745,10 @@ dependencies = [ "opentelemetry", "opentelemetry_sdk", "pretty_assertions", - "rand 0.9.3", + "rand 0.9.2", "reqwest", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", @@ -1865,11 +1870,12 @@ dependencies = [ "codex-app-server-protocol", "codex-execpolicy", "codex-features", + "codex-git-utils", "codex-model-provider-info", "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", - "codex-utils-path", + "dunce", "futures", "multimap", "pretty_assertions", @@ -1967,9 +1973,7 @@ dependencies = [ "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", - "crypto_box", "csv", - "ctor 0.6.3", "dirs", "dunce", "ed25519-dalek", @@ -1990,7 +1994,7 @@ dependencies = [ "opentelemetry_sdk", "predicates", "pretty_assertions", - "rand 0.9.3", + "rand 0.9.2", "regex-lite", "reqwest", "rmcp", @@ -1998,7 +2002,6 @@ dependencies = [ "serde_json", "serial_test", "sha1", - "sha2", "shlex", "similar", "tempfile", @@ -2006,7 +2009,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", @@ -2029,7 +2032,6 @@ dependencies = [ name = "codex-core-plugins" version = "0.0.0" dependencies = [ - "chrono", "codex-app-server-protocol", "codex-config", "codex-core-skills", @@ -2164,7 +2166,7 @@ dependencies = [ "test-case", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", "uuid", ] @@ -2262,8 +2264,6 @@ name = "codex-git-utils" version = "0.0.0" dependencies = [ "assert_matches", - "codex-exec-server", - "codex-protocol", "codex-utils-absolute-path", "futures", "once_cell", @@ -2334,7 +2334,6 @@ dependencies = [ "codex-protocol", "codex-sandboxing", "codex-utils-absolute-path", - "globset", "landlock", "libc", "pkg-config", @@ -2384,7 +2383,7 @@ dependencies = [ "once_cell", "os_info", "pretty_assertions", - "rand 0.9.3", + "rand 0.9.2", "regex-lite", "reqwest", "serde", @@ -2589,7 +2588,7 @@ dependencies = [ "strum_macros 0.28.0", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -2621,13 +2620,13 @@ dependencies = [ "chrono", "codex-async-utils", "codex-execpolicy", + "codex-git-utils", "codex-network-proxy", "codex-utils-absolute-path", "codex-utils-image", "codex-utils-string", "codex-utils-template", "encoding_rs", - "globset", "http 1.4.0", "icu_decimal", "icu_locale_core", @@ -2760,7 +2759,6 @@ dependencies = [ "dunce", "libc", "pretty_assertions", - "regex-lite", "serde_json", "tempfile", "tokio", @@ -2780,7 +2778,7 @@ dependencies = [ "codex-keyring-store", "keyring", "pretty_assertions", - "rand 0.9.3", + "rand 0.9.2", "regex", "schemars 0.8.22", "serde", @@ -2901,16 +2899,11 @@ dependencies = [ "codex-rollout", "codex-state", "pretty_assertions", - "prost 0.14.3", "serde", "serde_json", "tempfile", "thiserror 2.0.18", "tokio", - "tokio-stream", - "tonic", - "tonic-prost", - "tonic-prost-build", "uuid", ] @@ -2946,10 +2939,10 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-chatgpt", + "codex-clawbot", "codex-cli", "codex-cloud-requirements", "codex-config", - "codex-connectors", "codex-exec-server", "codex-features", "codex-feedback", @@ -2998,7 +2991,7 @@ dependencies = [ "pathdiff", "pretty_assertions", "pulldown-cmark", - "rand 0.9.3", + "rand 0.9.2", "ratatui", "ratatui-macros", "regex-lite", @@ -3500,7 +3493,7 @@ dependencies = [ "shlex", "tempfile", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -3672,40 +3665,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] -[[package]] -name = "crypto_box" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009" -dependencies = [ - "aead", - "blake2", - "crypto_secretbox", - "curve25519-dalek", - "salsa20", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto_secretbox" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" -dependencies = [ - "aead", - "cipher", - "generic-array", - "poly1305", - "salsa20", - "subtle", - "zeroize", -] - [[package]] name = "csv" version = "1.4.0" @@ -4508,7 +4470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4995,7 +4957,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -5070,7 +5031,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5297,7 +5258,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.3", + "rand 0.9.2", "ring", "thiserror 2.0.18", "tinyvec", @@ -5319,7 +5280,7 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand 0.9.3", + "rand 0.9.2", "resolv-conf", "smallvec", "thiserror 2.0.18", @@ -5466,7 +5427,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls", @@ -5520,7 +5481,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -5606,7 +5567,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -6026,7 +5987,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6256,6 +6217,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" @@ -7318,6 +7290,143 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openlark" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad168187bbb6fc22aa95df6c684b4d1d3345ca36c16f342270e75dbf869b3d8" +dependencies = [ + "chrono", + "openlark-auth", + "openlark-client", + "openlark-communication", + "openlark-core", + "openlark-docs", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "openlark-auth" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fee5a868321d6307fbbda4dd3a127a50b2d90375072bdbc5cbd1e633a545db4" +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-client" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da8bcf21d0d5e5beef862a29c794679d50d5d376e9f9ea4a851e081356628910" +dependencies = [ + "chrono", + "futures-util", + "lark-websocket-protobuf", + "log", + "openlark-auth", + "openlark-communication", + "openlark-core", + "openlark-docs", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d102d0ad5f63ebbe729e7f513bce18cb0e45bd0da6efb346e8b281ea5b619b1f" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "openlark-core" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336a10748627ee7c57fc2d80d686b210065670bc7c01a17cacd6e85fd7548975" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff30823a5df1ae99b2316ffeb4548fb9c90f5c9a145d719626b77cfafa0d3e07" +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 = "openssl" version = "0.10.75" @@ -7471,7 +7580,7 @@ dependencies = [ "futures-util", "opentelemetry", "percent-encoding", - "rand 0.9.3", + "rand 0.9.2", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -7516,7 +7625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -7624,7 +7733,7 @@ dependencies = [ "heck 0.4.1", "itertools 0.11.0", "prost 0.12.6", - "prost-types 0.12.6", + "prost-types", ] [[package]] @@ -7638,7 +7747,7 @@ dependencies = [ "pbjson", "pbjson-build", "prost 0.12.6", - "prost-build 0.12.6", + "prost-build", "serde", ] @@ -8020,7 +8129,7 @@ checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" dependencies = [ "bitflags 2.10.0", "num-traits", - "rand 0.9.3", + "rand 0.9.2", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax 0.8.8", @@ -8037,6 +8146,16 @@ dependencies = [ "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" @@ -8055,46 +8174,40 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", "heck 0.5.0", - "itertools 0.11.0", + "itertools 0.10.5", "log", "multimap", "once_cell", "petgraph 0.6.5", "prettyplease", "prost 0.12.6", - "prost-types 0.12.6", + "prost-types", "regex", "syn 2.0.114", "tempfile", ] [[package]] -name = "prost-build" -version = "0.14.3" +name = "prost-derive" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ - "heck 0.5.0", - "itertools 0.14.0", - "log", - "multimap", - "petgraph 0.8.3", - "prettyplease", - "prost 0.14.3", - "prost-types 0.14.3", - "regex", + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", "syn 2.0.114", - "tempfile", ] [[package]] name = "prost-derive" -version = "0.12.6" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -8122,15 +8235,6 @@ dependencies = [ "prost 0.12.6", ] -[[package]] -name = "prost-types" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" -dependencies = [ - "prost 0.14.3", -] - [[package]] name = "psl" version = "2.1.184" @@ -8190,6 +8294,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" @@ -8203,7 +8319,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -8219,7 +8335,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.3", + "rand 0.9.2", "ring", "rustc-hash 2.1.1", "rustls", @@ -8240,9 +8356,9 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -8351,7 +8467,7 @@ dependencies = [ "rama-http-types", "rama-net", "rama-utils", - "rand 0.9.3", + "rand 0.9.2", "serde", "serde_html_form", "serde_json", @@ -8421,7 +8537,7 @@ dependencies = [ "rama-macros", "rama-net", "rama-utils", - "rand 0.9.3", + "rand 0.9.2", "serde", "sha1", ] @@ -8449,7 +8565,7 @@ dependencies = [ "rama-error", "rama-macros", "rama-utils", - "rand 0.9.3", + "rand 0.9.2", "serde", "serde_json", "sync_wrapper", @@ -8523,7 +8639,7 @@ dependencies = [ "rama-http-types", "rama-net", "rama-utils", - "rand 0.9.3", + "rand 0.9.2", "tokio", ] @@ -8540,7 +8656,7 @@ dependencies = [ "rama-utils", "rcgen", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls", @@ -8603,9 +8719,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.3" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -8845,12 +8961,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", @@ -8920,7 +9037,7 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "rand 0.9.3", + "rand 0.9.2", "reqwest", "rmcp-macros", "schemars 1.2.1", @@ -9084,7 +9201,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9103,6 +9220,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" @@ -9115,6 +9245,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" @@ -9127,9 +9266,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -9504,7 +9643,7 @@ version = "0.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26ab054c34b87f96c3e4701bea1888317cde30cc7e4a6136d2c48454ab96661c" dependencies = [ - "rand 0.9.3", + "rand 0.9.2", "sentry-types", "serde", "serde_json", @@ -9552,7 +9691,7 @@ checksum = "eecbd63e9d15a26a40675ed180d376fcb434635d2e33de1c24003f61e3e2230d" dependencies = [ "debugid", "hex", - "rand 0.9.3", + "rand 0.9.2", "serde", "serde_json", "thiserror 2.0.18", @@ -10543,7 +10682,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10929,6 +11068,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" @@ -10937,11 +11092,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]] @@ -11039,10 +11194,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" dependencies = [ "async-trait", - "axum", "base64 0.22.1", "bytes", - "h2", "http 1.4.0", "http-body", "http-body-util", @@ -11051,8 +11204,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "rustls-native-certs", - "socket2 0.6.2", + "rustls-native-certs 0.8.3", "sync_wrapper", "tokio", "tokio-rustls", @@ -11063,18 +11215,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tonic-build" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27aac809edf60b741e2d7db6367214d078856b8a5bff0087e94ff330fb97b6fc" -dependencies = [ - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "tonic-prost" version = "0.14.3" @@ -11086,22 +11226,6 @@ dependencies = [ "tonic", ] -[[package]] -name = "tonic-prost-build" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4556786613791cfef4ed134aa670b61a85cfcacf71543ef33e8d801abae988f" -dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build 0.14.3", - "prost-types 0.14.3", - "quote", - "syn 2.0.114", - "tempfile", - "tonic-build", -] - [[package]] name = "tower" version = "0.5.3" @@ -11356,6 +11480,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" @@ -11368,7 +11512,7 @@ dependencies = [ "http 1.4.0", "httparse", "log", - "rand 0.9.3", + "rand 0.9.2", "rustls", "rustls-pki-types", "sha1", 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 a416081d0..e3cbadb47 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -675,7 +675,7 @@ async fn spawn_agent_applies_requested_cwd() { .expect("spawned agent thread should exist") .config_snapshot() .await; - assert_eq!(snapshot.cwd, child_workspace); + assert_eq!(snapshot.cwd, child_workspace.abs()); } #[tokio::test] @@ -988,7 +988,7 @@ async fn multi_agent_v2_spawn_applies_requested_cwd() { .expect("spawned agent thread should exist") .config_snapshot() .await; - assert_eq!(snapshot.cwd, child_workspace); + assert_eq!(snapshot.cwd, child_workspace.abs()); } #[tokio::test] 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 912df0a10..ee5ac0105 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -49,8 +49,10 @@ impl ToolHandler for RequestUserInputHandler { } let mode = session.collaboration_mode().await.mode; - let unavailable_message = match tool_name.as_str() { - QUESTION_TOOL_NAME => question_unavailable_message(mode), + let unavailable_message = match tool_name.name.as_str() { + QUESTION_TOOL_NAME if tool_name.namespace.is_none() => { + question_unavailable_message(mode) + } _ => request_user_input_unavailable_message(mode, self.default_mode_request_user_input), }; if let Some(message) = unavailable_message { @@ -58,7 +60,7 @@ impl ToolHandler for RequestUserInputHandler { } let args: RequestUserInputArgs = parse_arguments(&arguments)?; - let args = normalize_request_user_input_args_for_tool(&tool_name, args) + let args = normalize_request_user_input_args_for_tool(&tool_name.name, args) .map_err(FunctionCallError::RespondToModel)?; let response = session .request_user_input(turn.as_ref(), call_id, args) diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs index 1dac095f1..030648034 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -5,6 +5,7 @@ use crate::tools::context::ToolPayload; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::ThreadId; use codex_protocol::protocol::SubAgentSource; +use codex_tools::REQUEST_USER_INPUT_TOOL_NAME; use pretty_assertions::assert_eq; use serde_json::json; use std::sync::Arc; diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index c952e95ce..d304ee8e0 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -457,10 +457,8 @@ async fn test_build_specs_gpt51_codex_default() { } #[tokio::test] -async fn test_build_specs_gpt5_codex_unified_exec_web_search() { -#[test] -fn experimental_read_and_grep_tools_register_handlers() { - let config = test_config(); +async fn experimental_read_and_grep_tools_register_handlers() { + let config = test_config().await; 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()]; @@ -470,6 +468,7 @@ fn experimental_read_and_grep_tools_register_handlers() { model_info: &model_info, available_models: &available_models, features: &features, + image_generation_tool_auth_allowed: true, web_search_mode: Some(WebSearchMode::Cached), session_source: SessionSource::Cli, sandbox_policy: &SandboxPolicy::DangerFullAccess, @@ -486,8 +485,8 @@ fn experimental_read_and_grep_tools_register_handlers() { 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)); + assert!(registry.has_handler(&ToolName::plain("read_file"))); + assert!(registry.has_handler(&ToolName::plain("grep_files"))); } #[tokio::test] diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index e097286b7..27ced3307 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -181,6 +181,7 @@ async fn retries_on_stream_disconnected_before_completion() { text_elements: Vec::new(), }], final_output_json_schema: None, + responsesapi_client_metadata: None, }) .await .unwrap(); diff --git a/codex-rs/tools/src/agent_tool.rs b/codex-rs/tools/src/agent_tool.rs index a6caf4382..1e2ebb583 100644 --- a/codex-rs/tools/src/agent_tool.rs +++ b/codex-rs/tools/src/agent_tool.rs @@ -540,12 +540,10 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap BTreeMap ToolSpec { let properties = BTreeMap::from([ ( "pattern".to_string(), - JsonSchema::String { - description: Some("Regular expression pattern to search for.".to_string()), - }, + JsonSchema::string(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(), - ), - }, + JsonSchema::string(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(), - ), - }, + JsonSchema::string(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(), - ), - }, + JsonSchema::number(Some( + "Maximum number of file paths to return (defaults to 100).".to_string(), + )), ), ]); @@ -83,11 +77,11 @@ pub fn create_grep_files_tool() -> ToolSpec { .to_string(), strict: false, defer_loading: None, - parameters: JsonSchema::Object { + parameters: JsonSchema::object( properties, - required: Some(vec!["pattern".to_string()]), - additional_properties: Some(false.into()), - }, + Some(vec!["pattern".to_string()]), + Some(false.into()), + ), output_schema: None, }) } @@ -96,88 +90,63 @@ 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(), - ), - }, + JsonSchema::number(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(), - ), - }, + JsonSchema::number(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(), - ), - }, + JsonSchema::boolean(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(), - ), - }, + JsonSchema::boolean(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(), - ), - }, + JsonSchema::number(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()), - }, + JsonSchema::string(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(), - ), - }, + JsonSchema::number(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()), - }, + JsonSchema::number(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(), - ), - }, + JsonSchema::string(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()), - }, + JsonSchema::object(indentation_properties, None, Some(false.into())), ), ]); @@ -188,11 +157,11 @@ pub fn create_read_file_tool() -> ToolSpec { .to_string(), strict: false, defer_loading: None, - parameters: JsonSchema::Object { + parameters: JsonSchema::object( properties, - required: Some(vec!["file_path".to_string()]), - additional_properties: Some(false.into()), - }, + Some(vec!["file_path".to_string()]), + Some(false.into()), + ), output_schema: None, }) } diff --git a/codex-rs/tools/src/utility_tool_tests.rs b/codex-rs/tools/src/utility_tool_tests.rs index b1f69b38c..429f6fb89 100644 --- a/codex-rs/tools/src/utility_tool_tests.rs +++ b/codex-rs/tools/src/utility_tool_tests.rs @@ -58,48 +58,40 @@ fn grep_files_tool_matches_expected_spec() { .to_string(), strict: false, defer_loading: None, - parameters: JsonSchema::Object { - properties: BTreeMap::from([ + parameters: JsonSchema::object( + 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(), - ), - }, + JsonSchema::string(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(), - ), - }, + JsonSchema::number(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(), - ), - }, + JsonSchema::string(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(), - ), - }, + JsonSchema::string(Some( + "Regular expression pattern to search for.".to_string(), + )), ), ]), - required: Some(vec!["pattern".to_string()]), - additional_properties: Some(false.into()), - }, + Some(vec!["pattern".to_string()]), + Some(false.into()), + ), output_schema: None, }) ); @@ -116,99 +108,81 @@ fn read_file_tool_matches_expected_spec() { .to_string(), strict: false, defer_loading: None, - parameters: JsonSchema::Object { - properties: BTreeMap::from([ + parameters: JsonSchema::object( + BTreeMap::from([ ( "file_path".to_string(), - JsonSchema::String { - description: Some("Absolute path to the file".to_string()), - }, + JsonSchema::string(Some("Absolute path to the file".to_string())), ), ( "indentation".to_string(), - JsonSchema::Object { - properties: BTreeMap::from([ + JsonSchema::object( + BTreeMap::from([ ( "anchor_line".to_string(), - JsonSchema::Number { - description: Some( - "Anchor line to center the indentation lookup on (defaults to offset)." - .to_string(), - ), - }, + JsonSchema::number(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(), - ), - }, + JsonSchema::boolean(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(), - ), - }, + JsonSchema::boolean(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(), - ), - }, + JsonSchema::number(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(), - ), - }, + JsonSchema::number(Some( + "Hard cap on the number of lines returned when using indentation mode." + .to_string(), + )), ), ]), - required: None, - additional_properties: Some(false.into()), - }, + None, + Some(false.into()), + ), ), ( "limit".to_string(), - JsonSchema::Number { - description: Some( - "The maximum number of lines to return.".to_string(), - ), - }, + JsonSchema::number(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(), - ), - }, + JsonSchema::string(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(), - ), - }, + JsonSchema::number(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()), - }, + Some(vec!["file_path".to_string()]), + Some(false.into()), + ), output_schema: None, }) ); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e21f22d23..a014203bf 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -57,8 +57,6 @@ use crate::model_migration::migration_copy_for_models; use crate::model_migration::run_model_migration_prompt; use crate::multi_agents::agent_picker_status_dot_spans; 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::read_session_model; use crate::render::highlight::highlight_bash_to_lines; @@ -219,8 +217,6 @@ use self::workflow_scheduler::WorkflowSchedulerState; 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, @@ -1315,7 +1311,10 @@ impl App { 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); + tui.set_notification_settings( + self.config.tui_notifications.method, + self.config.tui_notifications.condition, + ); self.file_search .update_search_dir(self.config.cwd.to_path_buf()); return Ok(()); @@ -1352,7 +1351,10 @@ impl App { 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); + tui.set_notification_settings( + self.config.tui_notifications.method, + self.config.tui_notifications.condition, + ); 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) @@ -3573,6 +3575,12 @@ impl App { .await { Ok(thread) => { + if matches!( + (&mode, &existing_entry, has_replay_channel), + (ThreadLivenessRefreshMode::Selection, None, false) + ) { + return true; + } self.upsert_agent_picker_thread( thread_id, thread.agent_nickname.or_else(|| { @@ -6660,16 +6668,13 @@ impl App { 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() { + KeyChordAction::SelectAgentSlot(slot) => { + if let Some(thread_id) = self + .agent_navigation + .thread_id_for_slot(self.primary_thread_id, slot) + { self.app_event_tx - .send(AppEvent::Exit(ExitMode::RespawnImmediate)); + .send(AppEvent::SelectAgentThread(thread_id)); } } } @@ -6681,7 +6686,7 @@ impl App { async fn handle_key_event( &mut self, tui: &mut tui::Tui, - app_server: &mut AppServerSession, + _app_server: &mut AppServerSession, key_event: KeyEvent, ) { let mut key_event = key_event; @@ -6696,44 +6701,6 @@ impl App { }; 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 - // editing behavior for moving across words inside a draft. - let allow_agent_word_motion_fallback = !self.enhanced_keys_supported - && self.chat_widget.composer_text_with_pending().is_empty(); - if self.overlay.is_none() - && self.chat_widget.no_modal_or_popup_active() - // Alt+Left/Right are also natural word-motion keys in the composer. Keep agent - // fast-switch available only once the draft is empty so editing behavior wins whenever - // there is text on screen. - && self.chat_widget.composer_text_with_pending().is_empty() - && previous_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) - { - if let Some(thread_id) = self - .adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Previous) - .await - { - let _ = self.select_agent_thread(tui, app_server, thread_id).await; - } - return; - } - if self.overlay.is_none() - && self.chat_widget.no_modal_or_popup_active() - // Mirror the previous-agent rule above: empty drafts may use these keys for thread - // switching, but non-empty drafts keep them for expected word-wise cursor motion. - && self.chat_widget.composer_text_with_pending().is_empty() - && next_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) - { - if let Some(thread_id) = self - .adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Next) - .await - { - let _ = self.select_agent_thread(tui, app_server, thread_id).await; - } - return; - } - match key_event { KeyEvent { code: KeyCode::Char('t'), @@ -7088,8 +7055,6 @@ mod tests { use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; - 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; @@ -11500,7 +11465,7 @@ jobs: 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.config.codex_home = codex_home.path().to_path_buf().abs(); app.active_profile = Some("stale".to_string()); std::fs::write( @@ -11895,50 +11860,26 @@ model = "gpt-5.2" } #[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, - ]; + async fn ctrl_x_digit_switches_to_agent_slot() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread"); + let agent_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread"); + app.primary_thread_id = Some(main_thread_id); + app.upsert_agent_picker_thread( + main_thread_id, + Some("main".to_string()), + Some("default".to_string()), + /*is_closed*/ false, + ); + app.upsert_agent_picker_thread( + agent_thread_id, + Some("btw".to_string()), + Some("btw".to_string()), + /*is_closed*/ false, + ); assert_eq!( app.handle_key_chord_key_event(KeyEvent::new( KeyCode::Char('x'), @@ -11947,21 +11888,15 @@ model = "gpt-5.2" None ); assert_eq!( - app.handle_key_chord_key_event(KeyEvent::new( - KeyCode::Char('u'), - KeyModifiers::CONTROL, - )), + app.handle_key_chord_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE,)), 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)); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::SelectAgentThread(selected_thread_id)) + if selected_thread_id == agent_thread_id + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); } #[tokio::test] @@ -11984,8 +11919,26 @@ model = "gpt-5.2" } #[tokio::test] - async fn ctrl_x_ctrl_y_runs_copy_action_without_inserting_y_into_the_composer() { - let mut app = make_test_app().await; + async fn ctrl_x_ctrl_digit_does_not_switch_threads() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread"); + let agent_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread"); + app.primary_thread_id = Some(main_thread_id); + app.upsert_agent_picker_thread( + main_thread_id, + Some("main".to_string()), + Some("default".to_string()), + /*is_closed*/ false, + ); + app.upsert_agent_picker_thread( + agent_thread_id, + Some("btw".to_string()), + Some("btw".to_string()), + /*is_closed*/ false, + ); assert_eq!( app.handle_key_chord_key_event(KeyEvent::new( @@ -11996,11 +11949,12 @@ model = "gpt-5.2" ); assert_eq!( app.handle_key_chord_key_event(KeyEvent::new( - KeyCode::Char('y'), + KeyCode::Char('2'), KeyModifiers::CONTROL, )), - None + Some(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::CONTROL)) ); + assert!(app_event_rx.try_recv().is_err()); assert_eq!(app.chat_widget.composer_text_with_pending(), ""); } diff --git a/codex-rs/tui/src/app/agent_navigation.rs b/codex-rs/tui/src/app/agent_navigation.rs index db9e47c5b..6e16b3e18 100644 --- a/codex-rs/tui/src/app/agent_navigation.rs +++ b/codex-rs/tui/src/app/agent_navigation.rs @@ -20,10 +20,7 @@ use crate::multi_agents::AgentPickerThreadEntry; use crate::multi_agents::format_agent_picker_item_name; -use crate::multi_agents::next_agent_shortcut; -use crate::multi_agents::previous_agent_shortcut; use codex_protocol::ThreadId; -use ratatui::text::Span; use std::collections::HashMap; /// Small state container for multi-agent picker ordering and labeling. @@ -196,6 +193,27 @@ impl AgentNavigationState { Some(ordered_threads[next_idx].0) } + /// Returns the thread id assigned to a direct-access slot. + /// + /// Slot `1` is always the primary thread. Slots `2..=9` map to non-primary tracked threads in + /// first-seen order. + pub(crate) fn thread_id_for_slot( + &self, + primary_thread_id: Option, + slot: u8, + ) -> Option { + match slot { + 1 => primary_thread_id, + 2..=9 => self + .ordered_threads() + .into_iter() + .map(|(thread_id, _)| thread_id) + .filter(|thread_id| Some(*thread_id) != primary_thread_id) + .nth((slot - 2) as usize), + _ => None, + } + } + /// Derives the contextual footer label for the currently displayed thread. /// /// This intentionally returns `None` until there is more than one tracked thread so @@ -236,12 +254,7 @@ impl AgentNavigationState { /// Keeping this text derived from the actual shortcut helpers prevents the picker copy from /// drifting if the bindings ever change on one platform. pub(crate) fn picker_subtitle() -> String { - let previous: Span<'static> = previous_agent_shortcut().into(); - let next: Span<'static> = next_agent_shortcut().into(); - format!( - "Select an agent to watch. {} previous, {} next.", - previous.content, next.content - ) + "Select an agent to watch. Use Ctrl+X then 1-9 to jump directly.".to_string() } #[cfg(test)] @@ -330,12 +343,10 @@ mod tests { #[test] fn picker_subtitle_mentions_shortcuts() { - let previous: Span<'static> = previous_agent_shortcut().into(); - let next: Span<'static> = next_agent_shortcut().into(); let subtitle = AgentNavigationState::picker_subtitle(); - assert!(subtitle.contains(previous.content.as_ref())); - assert!(subtitle.contains(next.content.as_ref())); + assert!(subtitle.contains("Ctrl+X")); + assert!(subtitle.contains("1-9")); } #[test] @@ -351,4 +362,23 @@ mod tests { Some("Main [default]".to_string()) ); } + + #[test] + fn thread_id_for_slot_maps_primary_then_spawn_ordered_subagents() { + let (state, main_thread_id, first_agent_id, second_agent_id) = populated_state(); + + assert_eq!( + state.thread_id_for_slot(Some(main_thread_id), 1), + Some(main_thread_id) + ); + assert_eq!( + state.thread_id_for_slot(Some(main_thread_id), 2), + Some(first_agent_id) + ); + assert_eq!( + state.thread_id_for_slot(Some(main_thread_id), 3), + Some(second_agent_id) + ); + assert_eq!(state.thread_id_for_slot(Some(main_thread_id), 4), None); + } } diff --git a/codex-rs/tui/src/app/btw.rs b/codex-rs/tui/src/app/btw.rs index decd3c6f0..9c246223d 100644 --- a/codex-rs/tui/src/app/btw.rs +++ b/codex-rs/tui/src/app/btw.rs @@ -11,6 +11,8 @@ 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::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStatus; @@ -29,6 +31,7 @@ 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; +use codex_app_server_client::AppServerRequestHandle; const BTW_DEVELOPER_INSTRUCTIONS: &str = concat!( "This is a hidden `/btw` discussion thread. ", @@ -56,14 +59,6 @@ impl App { 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 @@ -71,20 +66,21 @@ impl App { return; } - self.open_btw_loading_popup(/*status_message*/ None); - 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}")); + self.chat_widget + .add_error_message(format!("Failed to start `/btw`: {err}")); return; } }; - self.btw_session = Some(BtwSessionState { + self.upsert_agent_picker_thread( thread_id, - final_message: None, - last_status: None, - }); + Some(btw_agent_nickname(trimmed_prompt)), + Some("btw".to_string()), + /*is_closed*/ false, + ); + self.prime_btw_thread_channel(app_server, thread_id).await; let turn_result = app_server .turn_start( @@ -104,9 +100,13 @@ impl App { ) .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}")); + self.chat_widget + .add_error_message(format!("Failed to submit `/btw`: {err}")); + return; } + + self.app_event_tx + .send(AppEvent::SelectAgentThread(thread_id)); } pub(crate) fn finish_btw_discussion( @@ -296,7 +296,7 @@ impl App { 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() { + if let Some(thread_id) = self.btw_fork_source_thread_id(&request_handle).await? { let response: ThreadForkResponse = request_handle .request_typed(ClientRequest::ThreadFork { request_id: request_id(), @@ -319,6 +319,68 @@ impl App { } } + async fn btw_fork_source_thread_id( + &self, + request_handle: &AppServerRequestHandle, + ) -> Result, String> { + let Some(thread_id) = self.chat_widget.thread_id() else { + return Ok(None); + }; + + let response: ThreadReadResponse = match request_handle + .request_typed(ClientRequest::ThreadRead { + request_id: request_id(), + params: ThreadReadParams { + thread_id: thread_id.to_string(), + include_turns: false, + }, + }) + .await + { + Ok(response) => response, + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to inspect `/btw` source thread; starting a fresh temporary thread" + ); + return Ok(None); + } + }; + + Ok(response + .thread + .path + .as_ref() + .is_some_and(|path| path.exists()) + .then_some(thread_id)) + } + + async fn prime_btw_thread_channel( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) { + self.ensure_thread_channel(thread_id); + match app_server + .resume_thread(self.config.clone(), thread_id) + .await + { + Ok(started) => { + let channel = self.ensure_thread_channel(thread_id); + let mut store = channel.store.lock().await; + store.set_session(started.session, started.turns); + } + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to prime `/btw` thread channel before switching" + ); + } + } + } + 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())); @@ -427,6 +489,17 @@ fn config_overrides(active_profile: Option<&str>) -> Option String { + let trimmed = prompt.trim(); + let char_count = trimmed.chars().count(); + if char_count <= 32 { + return trimmed.to_string(); + } + + let truncated: String = trimmed.chars().take(31).collect(); + format!("{truncated}…") +} + fn merge_developer_instructions(existing: Option<&str>) -> String { match existing { Some(existing) if !existing.trim().is_empty() => { diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs index 5fc67a07d..dc1711b62 100644 --- a/codex-rs/tui/src/app/clawbot.rs +++ b/codex-rs/tui/src/app/clawbot.rs @@ -651,7 +651,7 @@ impl App { text: trimmed.clone(), text_elements: Vec::new(), }], - cwd: session.cwd.clone(), + cwd: session.cwd.to_path_buf(), approval_policy: clawbot_approval_policy(session.approval_policy, turn_mode), approvals_reviewer: Some(session.approvals_reviewer), sandbox_policy: session.sandbox_policy.clone(), @@ -672,7 +672,7 @@ impl App { text: trimmed, text_elements: Vec::new(), }], - session.cwd, + session.cwd.to_path_buf(), clawbot_approval_policy(session.approval_policy, turn_mode), session.approvals_reviewer, session.sandbox_policy, @@ -1124,6 +1124,9 @@ mod tests { }, ], error: None, + started_at: None, + completed_at: None, + duration_ms: None, }; assert_eq!( @@ -1158,6 +1161,9 @@ mod tests { }, ], error: None, + started_at: None, + completed_at: None, + duration_ms: None, }; assert_eq!( diff --git a/codex-rs/tui/src/app/key_chord.rs b/codex-rs/tui/src/app/key_chord.rs index 9704121df..9291ffbac 100644 --- a/codex-rs/tui/src/app/key_chord.rs +++ b/codex-rs/tui/src/app/key_chord.rs @@ -12,9 +12,7 @@ pub(crate) enum KeyChordState { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum KeyChordAction { - UndoLastUserMessage, - CopyLatestOutput, - RespawnCurrentSession, + SelectAgentSlot(u8), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -58,14 +56,9 @@ fn handle_ctrl_x_second_key(state: &mut KeyChordState, key_event: KeyEvent) -> K } 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(slot @ '1'..='9'), KeyModifiers::NONE) => { + let slot = (slot as u8) - b'0'; + KeyChordResolution::Matched(KeyChordAction::SelectAgentSlot(slot)) } (KeyCode::Char('x'), KeyModifiers::CONTROL) => KeyChordResolution::AwaitingSecondKey, (KeyCode::Esc, _) => KeyChordResolution::Cancelled, @@ -85,22 +78,7 @@ mod tests { 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() { + fn ctrl_x_digit_matches_agent_slot() { let mut state = KeyChordState::default(); assert_eq!( @@ -108,14 +86,14 @@ mod tests { KeyChordResolution::AwaitingSecondKey ); assert_eq!( - state.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)), - KeyChordResolution::Matched(KeyChordAction::CopyLatestOutput) + state.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)), + KeyChordResolution::Matched(KeyChordAction::SelectAgentSlot(2)) ); assert_eq!(state, KeyChordState::Idle); } #[test] - fn ctrl_x_ctrl_r_matches_respawn_current_session() { + fn ctrl_x_unknown_second_key_is_forwarded_and_clears_state() { let mut state = KeyChordState::default(); assert_eq!( @@ -123,14 +101,14 @@ mod tests { KeyChordResolution::AwaitingSecondKey ); assert_eq!( - state.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)), - KeyChordResolution::Matched(KeyChordAction::RespawnCurrentSession) + 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_unknown_second_key_is_forwarded_and_clears_state() { + fn ctrl_x_digit_with_ctrl_modifier_is_forwarded() { let mut state = KeyChordState::default(); assert_eq!( @@ -138,8 +116,8 @@ mod tests { KeyChordResolution::AwaitingSecondKey ); assert_eq!( - state.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)), - KeyChordResolution::Forward(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)) + state.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::CONTROL)), + KeyChordResolution::Forward(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::CONTROL)) ); assert_eq!(state, KeyChordState::Idle); } diff --git a/codex-rs/tui/src/app/profile_management.rs b/codex-rs/tui/src/app/profile_management.rs index 19bce4b8d..6757ef4ee 100644 --- a/codex-rs/tui/src/app/profile_management.rs +++ b/codex-rs/tui/src/app/profile_management.rs @@ -4,7 +4,6 @@ use std::time::Duration; use toml::Value as TomlValue; use super::App; -use super::PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT; use super::editor_helpers::ExternalEditorErrorTarget; use crate::app_event::AppEvent; use crate::app_event::RuntimeProfileTarget; @@ -12,6 +11,7 @@ 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::legacy_core::config::Config; use crate::profile_router::DefaultProfileRouter; use crate::profile_router::PROFILE_ROUTER_STATE_RELATIVE_PATH; use crate::profile_router::ProfileFallbackAction; @@ -19,9 +19,6 @@ use crate::profile_router::ProfileRouteEntry; 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"; @@ -76,7 +73,7 @@ struct DefaultProfileSummary { impl App { pub(super) fn profile_router_store(&self) -> ProfileRouterStore { - ProfileRouterStore::new(self.config.codex_home.clone()) + ProfileRouterStore::new(self.config.codex_home.to_path_buf()) } pub(super) fn routed_profile_runtime_changed( @@ -268,44 +265,6 @@ impl App { .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(()) diff --git a/codex-rs/tui/src/app/tests/btw_tests.rs b/codex-rs/tui/src/app/tests/btw_tests.rs index e7fa582ce..784250058 100644 --- a/codex-rs/tui/src/app/tests/btw_tests.rs +++ b/codex-rs/tui/src/app/tests/btw_tests.rs @@ -1,6 +1,96 @@ use super::*; use pretty_assertions::assert_eq; +#[tokio::test] +async fn start_btw_discussion_registers_thread_and_requests_switch() -> 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 started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start primary thread"); + let primary_thread_id = started.session.thread_id; + app.primary_thread_id = Some(primary_thread_id); + app.active_thread_id = Some(primary_thread_id); + + app.start_btw_discussion(&mut app_server, "compare the two approaches".to_string()) + .await; + + let btw_thread_id = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::SelectAgentThread(thread_id) => Some(thread_id), + _ => None, + }) + .expect("expected SelectAgentThread event"); + assert_ne!(btw_thread_id, primary_thread_id); + assert_eq!( + app.agent_navigation.get(&btw_thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("compare the two approaches".to_string()), + agent_role: Some("btw".to_string()), + is_closed: false, + }) + ); + assert!(app.thread_event_channels.contains_key(&btw_thread_id)); + Ok(()) +} + +#[tokio::test] +async fn start_btw_discussion_falls_back_to_fresh_thread_when_current_thread_cannot_fork() +-> 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 primary_thread_id = ThreadId::new(); + app.primary_thread_id = Some(primary_thread_id); + app.active_thread_id = Some(primary_thread_id); + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: primary_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: test_path_buf("/tmp/project").abs(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }), + }); + + app.start_btw_discussion(&mut app_server, "compare the two approaches".to_string()) + .await; + + let btw_thread_id = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::SelectAgentThread(thread_id) => Some(thread_id), + _ => None, + }) + .expect("expected SelectAgentThread event"); + assert_ne!(btw_thread_id, primary_thread_id); + assert_eq!( + app.agent_navigation.get(&btw_thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("compare the two approaches".to_string()), + agent_role: Some("btw".to_string()), + is_closed: false, + }) + ); + assert!(app.thread_event_channels.contains_key(&btw_thread_id)); + 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/tests/clawbot_tests.rs b/codex-rs/tui/src/app/tests/clawbot_tests.rs index 7eda4c0dc..8c7db6dfa 100644 --- a/codex-rs/tui/src/app/tests/clawbot_tests.rs +++ b/codex-rs/tui/src/app/tests/clawbot_tests.rs @@ -463,20 +463,19 @@ async fn clawbot_sync_ignores_binding_owned_by_another_app_instance() -> Result< .expect("queue unread"); other_app.sync_clawbot_workspace(&mut app_server).await; - assert_eq!(other_app.clawbot.pending_turns.get(&thread_id), None); - assert_eq!(other_app.clawbot.outbound_reactions, Vec::new()); + assert_eq!(other_app.clawbot_pending_turns.get(&thread_id), None); + assert_eq!(other_app.clawbot_outbound_reactions, Vec::new()); owner_app.sync_clawbot_workspace(&mut app_server).await; assert_eq!( owner_app - .clawbot - .pending_turns + .clawbot_pending_turns .get(&thread_id) .map(std::collections::VecDeque::len), Some(1) ); assert_eq!( - owner_app.clawbot.outbound_reactions, + owner_app.clawbot_outbound_reactions, vec![ProviderOutboundReaction { target: ProviderMessageRef::new(ClawbotProviderKind::Feishu, "chat_owner", "msg_1"), emoji_type: "TONGUE".to_string(), @@ -714,13 +713,9 @@ async fn clawbot_manual_bind_allows_undiscovered_chat_id_with_configured_feishu( })) .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - app.bind_clawbot_session_to_current_thread( - &mut app_server, - "chat_manual_only".to_string(), - ClawbotSessionBindSource::ManualSessionId, - ) - .await - .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + app.bind_clawbot_session_to_current_thread(&mut app_server, "chat_manual_only".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()))?; diff --git a/codex-rs/tui/src/app/thread_controller.rs b/codex-rs/tui/src/app/thread_controller.rs index a4d8f7c17..205f4e991 100644 --- a/codex-rs/tui/src/app/thread_controller.rs +++ b/codex-rs/tui/src/app/thread_controller.rs @@ -61,8 +61,10 @@ impl ThreadController { { Ok(()) => { if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; + let mut lines: Vec> = Vec::new(); + if let Some(usage_line) = summary.usage_line.clone() { + lines.push(usage_line.into()); + } if let Some(command) = summary.resume_command { let spans = vec![ "To continue this session, run ".into(), diff --git a/codex-rs/tui/src/app/workflow_history.rs b/codex-rs/tui/src/app/workflow_history.rs index c89f825f6..5f05e324b 100644 --- a/codex-rs/tui/src/app/workflow_history.rs +++ b/codex-rs/tui/src/app/workflow_history.rs @@ -264,7 +264,7 @@ impl App { 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()); + .unwrap_or_else(|| self.config.cwd.clone()); let approval_policy = session .map(|session| session.approval_policy) .unwrap_or_else(|| self.config.permissions.approval_policy.value()); @@ -285,7 +285,7 @@ impl App { text, text_elements: Vec::new(), }], - cwd, + cwd: cwd.to_path_buf(), approval_policy, approvals_reviewer, sandbox_policy, diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs index 83eee95a1..f346f3c42 100644 --- a/codex-rs/tui/src/app/workflow_runtime.rs +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -13,6 +13,7 @@ use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; use crate::history_cell; use crate::history_cell::HistoryCell; +use crate::legacy_core::config::Config; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::ClientRequest; @@ -34,7 +35,6 @@ 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; @@ -323,7 +323,7 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { .insert(thread_id, sender); return Ok(WorkflowThreadSession { thread_id: response.thread.id, - cwd: response.cwd, + cwd: response.cwd.to_path_buf(), notifications: Arc::new(tokio::sync::Mutex::new(receiver)), }); } @@ -353,7 +353,7 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { .insert(thread_id, sender); Ok(WorkflowThreadSession { thread_id: response.thread.id, - cwd: response.cwd, + cwd: response.cwd.to_path_buf(), notifications: Arc::new(tokio::sync::Mutex::new(receiver)), }) }) @@ -388,6 +388,7 @@ impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { self.config.permissions.sandbox_policy.get().clone().into(), ), model: self.config.model.clone(), + responsesapi_client_metadata: None, service_tier: None, effort: self.config.model_reasoning_effort, summary: self.config.model_reasoning_summary, @@ -1590,8 +1591,8 @@ enum WorkflowRunError { #[cfg(test)] mod tests { use super::*; + use crate::legacy_core::config::ConfigBuilder; use codex_app_server_protocol::TurnCompletedNotification; - use codex_core::config::ConfigBuilder; use pretty_assertions::assert_eq; use std::collections::VecDeque; use std::sync::Mutex; @@ -2143,6 +2144,9 @@ jobs: items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )) @@ -2220,6 +2224,9 @@ jobs: items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, }, }, )) diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 8e5df64f6..24915c26e 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -764,9 +764,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut external_editor = Line::from(""); let mut edit_previous = Line::from(""); let mut history_search = Line::from(""); - let mut undo_last_message = Line::from(""); + let mut switch_agent_thread = 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(""); @@ -783,9 +782,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::ExternalEditor => external_editor = text, ShortcutId::EditPrevious => edit_previous = text, ShortcutId::HistorySearch => history_search = text, - ShortcutId::UndoLastMessage => undo_last_message = text, + ShortcutId::SwitchAgentThread => switch_agent_thread = 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, @@ -803,9 +801,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { external_editor, edit_previous, history_search, - undo_last_message, + switch_agent_thread, copy_latest_output, - respawn_current_session, quit, ]; if change_mode.width() > 0 { @@ -889,9 +886,8 @@ enum ShortcutId { ExternalEditor, EditPrevious, HistorySearch, - UndoLastMessage, + SwitchAgentThread, CopyLatestOutput, - RespawnCurrentSession, Quit, ShowTranscript, ChangeMode, @@ -1075,35 +1071,25 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ label: " search history", }, ShortcutDescriptor { - id: ShortcutId::UndoLastMessage, + id: ShortcutId::SwitchAgentThread, bindings: &[ShortcutBinding { key: key_hint::ctrl(KeyCode::Char('x')), - condition: DisplayCondition::Always, + condition: DisplayCondition::WhenCollaborationModesEnabled, }], prefix: "", - display_label: Some("ctrl + x then ctrl + u"), - label: " to undo last message", + display_label: Some("ctrl + x then 1-9"), + label: " to switch agent threads", }, ShortcutDescriptor { id: ShortcutId::CopyLatestOutput, bindings: &[ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('x')), + key: key_hint::ctrl(KeyCode::Char('o')), condition: DisplayCondition::Always, }], prefix: "", - display_label: Some("ctrl + x then ctrl + y"), + display_label: None, 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 { 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 a7df49bc9..fc7a93d45 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,11 +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 + r search history 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 " +" / 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 + r search history " +" ctrl + o to copy last output 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 813e2cd63..3f9b5fc90 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,11 +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 me" -" ctrl + r search history ctrl + x then ctrl + u to u" -" ctrl + x then ctrl + y to copy last output ctrl + x then ctrl + r to r" -" ctrl + c to exit shift + tab to change mode " +" / 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 + r search history ctrl + x then 1-9 to switch agent t" +" ctrl + o to copy last output 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 906d25198..6de21db30 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,11 +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 " -" ctrl + r search history ctrl + x then ctrl + u to u" -" ctrl + x then ctrl + y to copy last output ctrl + x then ctrl + r to r" -" 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 + r search history " +" ctrl + o to copy last output 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 d30960c39..dab4ca281 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -350,7 +350,6 @@ use crate::history_cell::HookCell; use crate::history_cell::McpToolCallCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::WebSearchCell; -use crate::insight; use crate::key_hint; use crate::key_hint::KeyBinding; #[cfg(test)] @@ -5415,386 +5414,6 @@ impl ChatWidget { self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); } - 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!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.add_to_history(history_cell::new_error_event(message)); - self.bottom_pane.drain_pending_submission_state(); - self.request_redraw(); - return; - } - match cmd { - SlashCommand::Feedback => { - if !self.config.feedback_enabled { - let params = crate::bottom_pane::feedback_disabled_params(); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - return; - } - // Step 1: pick a category (UI built in feedback_view) - let params = - crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - } - SlashCommand::New => { - self.app_event_tx.send(AppEvent::NewSession); - } - SlashCommand::Clear => { - self.app_event_tx.send(AppEvent::ClearUi); - } - SlashCommand::Resume => { - self.app_event_tx.send(AppEvent::OpenResumePicker); - } - SlashCommand::Fork => { - self.app_event_tx.send(AppEvent::ForkCurrentSession); - } - 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, - Err(err) => { - self.add_error_message(format!( - "Failed to prepare {DEFAULT_PROJECT_DOC_FILENAME}: {err}", - )); - return; - } - }; - if init_target.exists() { - let message = format!( - "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." - ); - self.add_info_message(message, /*hint*/ None); - return; - } - const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - self.submit_user_message(INIT_PROMPT.to_string().into()); - } - SlashCommand::Compact => { - self.clear_token_usage(); - if !self.bottom_pane.is_task_running() { - self.bottom_pane.set_task_running(/*running*/ true); - } - self.app_event_tx.compact(); - } - SlashCommand::Review => { - self.open_review_popup(); - } - SlashCommand::Rename => { - self.session_telemetry - .counter("codex.thread.rename", /*inc*/ 1, &[]); - self.show_rename_prompt(); - } - SlashCommand::Model => { - self.open_model_popup(); - } - SlashCommand::Fast => { - let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { - None - } else { - Some(ServiceTier::Fast) - }; - self.set_service_tier_selection(next_tier); - } - SlashCommand::Realtime => { - if !self.realtime_conversation_enabled() { - return; - } - if self.realtime_conversation.is_live() { - self.stop_realtime_conversation_from_ui(); - } else { - self.start_realtime_conversation(); - } - } - SlashCommand::Settings => { - self.open_settings_popup(); - } - SlashCommand::Clawbot => { - self.app_event_tx.send(AppEvent::OpenClawbotManagement); - } - SlashCommand::Personality => { - self.open_personality_popup(); - } - SlashCommand::Plan => { - if !self.collaboration_modes_enabled() { - self.add_info_message( - "Collaboration modes are disabled.".to_string(), - Some("Enable collaboration modes to use /plan.".to_string()), - ); - return; - } - if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { - self.set_collaboration_mask(mask); - } else { - self.add_info_message( - "Plan mode unavailable right now.".to_string(), - /*hint*/ None, - ); - } - } - SlashCommand::Collab => { - if !self.collaboration_modes_enabled() { - self.add_info_message( - "Collaboration modes are disabled.".to_string(), - Some("Enable collaboration modes to use /collab.".to_string()), - ); - return; - } - self.open_collaboration_modes_popup(); - } - SlashCommand::Agent | SlashCommand::MultiAgents => { - self.app_event_tx.send(AppEvent::OpenAgentPicker); - } - SlashCommand::Approvals => { - self.open_permissions_popup(); - } - SlashCommand::Permissions => { - self.open_permissions_popup(); - } - SlashCommand::ElevateSandbox => { - #[cfg(target_os = "windows")] - { - let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); - let windows_degraded_sandbox_enabled = - matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); - if !windows_degraded_sandbox_enabled - || !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - { - // This command should not be visible/recognized outside degraded mode, - // but guard anyway in case something dispatches it directly. - return; - } - - let Some(preset) = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "auto") - else { - // Avoid panicking in interactive UI; treat this as a recoverable - // internal error. - self.add_error_message( - "Internal error: missing the 'auto' approval preset.".to_string(), - ); - return; - }; - - if let Err(err) = self - .config - .permissions - .approval_policy - .can_set(&preset.approval) - { - self.add_error_message(err.to_string()); - return; - } - - self.session_telemetry.counter( - "codex.windows_sandbox.setup_elevated_sandbox_command", - /*inc*/ 1, - &[], - ); - self.app_event_tx - .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); - } - #[cfg(not(target_os = "windows"))] - { - let _ = &self.session_telemetry; - // Not supported; on non-Windows this command should never be reachable. - }; - } - SlashCommand::SandboxReadRoot => { - self.add_error_message( - "Usage: /sandbox-add-read-dir ".to_string(), - ); - } - SlashCommand::Experimental => { - self.open_experimental_popup(); - } - SlashCommand::Quit | SlashCommand::Exit => { - self.request_quit_without_confirmation(); - } - SlashCommand::Logout => { - if let Err(e) = codex_login::logout( - &self.config.codex_home, - self.config.cli_auth_credentials_store_mode, - ) { - tracing::error!("failed to logout: {e}"); - } - self.request_quit_without_confirmation(); - } - // SlashCommand::Undo => { - // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); - // } - SlashCommand::Diff => { - self.add_diff_in_progress(); - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let text = match get_git_diff().await { - Ok((is_git_repo, diff_text)) => { - if is_git_repo { - diff_text - } else { - "`/diff` — _not inside a git repository_".to_string() - } - } - Err(e) => format!("Failed to compute diff: {e}"), - }; - tx.send(AppEvent::DiffResult(text)); - }); - } - SlashCommand::Copy => { - self.copy_latest_output_to_clipboard(); - } - SlashCommand::Mention => { - self.insert_str("@"); - } - SlashCommand::Skills => { - self.open_skills_menu(); - } - SlashCommand::Status => { - if self.should_prefetch_rate_limits() { - let request_id = self.next_status_refresh_request_id; - self.next_status_refresh_request_id = - self.next_status_refresh_request_id.wrapping_add(1); - self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); - self.app_event_tx - .send(AppEvent::RefreshRateLimits { request_id }); - } else { - self.add_status_output( - /*refreshing_rate_limits*/ false, /*request_id*/ None, - ); - } - } - SlashCommand::Insight => { - self.add_info_message(insight::start_message(), /*hint*/ None); - insight::spawn_report_generation(self.config.clone(), self.app_event_tx.clone()); - } - SlashCommand::DebugConfig => { - self.add_debug_config_output(); - } - SlashCommand::Title => { - self.open_terminal_title_setup(); - } - SlashCommand::Statusline => { - self.open_status_line_setup(); - } - SlashCommand::Theme => { - self.open_theme_picker(); - } - SlashCommand::Ps => { - self.add_ps_output(); - } - SlashCommand::Stop => { - self.clean_background_terminals(); - } - SlashCommand::MemoryDrop => { - self.add_app_server_stub_message("Memory maintenance"); - } - SlashCommand::MemoryUpdate => { - self.add_app_server_stub_message("Memory maintenance"); - } - SlashCommand::Mcp => { - self.add_mcp_output(); - } - SlashCommand::Apps => { - self.add_connectors_output(); - } - SlashCommand::Plugins => { - self.add_plugins_output(); - } - 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( - format!("Current rollout path: {}", path.display()), - /*hint*/ None, - ); - } else { - self.add_info_message( - "Rollout path is not available yet.".to_string(), - /*hint*/ None, - ); - } - } - SlashCommand::TestApproval => { - use std::collections::HashMap; - - use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; - use codex_protocol::protocol::FileChange; - - self.on_apply_patch_approval_request( - "1".to_string(), - ApplyPatchApprovalRequestEvent { - call_id: "1".to_string(), - turn_id: "turn-1".to_string(), - changes: HashMap::from([ - ( - PathBuf::from("/tmp/test.txt"), - FileChange::Add { - content: "test".to_string(), - }, - ), - ( - PathBuf::from("/tmp/test2.txt"), - FileChange::Update { - unified_diff: "+test\n-test2".to_string(), - move_path: None, - }, - ), - ]), - reason: None, - grant_root: Some(PathBuf::from("/tmp")), - }, - ); - } - } - } - /// Inner implementation with an injectable clipboard backend for testing. fn copy_last_agent_markdown_with( &mut self, @@ -5820,149 +5439,6 @@ impl ChatWidget { self.request_redraw(); } - fn dispatch_command_with_args( - &mut self, - cmd: SlashCommand, - args: String, - _text_elements: Vec, - ) { - if !cmd.supports_inline_args() { - self.dispatch_command(cmd); - return; - } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - return; - } - - let trimmed = args.trim(); - match cmd { - SlashCommand::Fast => { - if trimmed.is_empty() { - self.dispatch_command(cmd); - return; - } - match trimmed.to_ascii_lowercase().as_str() { - "on" => self.set_service_tier_selection(Some(ServiceTier::Fast)), - "off" => self.set_service_tier_selection(/*service_tier*/ None), - "status" => { - let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) - { - "on" - } else { - "off" - }; - self.add_info_message( - format!("Fast mode is {status}."), - /*hint*/ None, - ); - } - _ => { - self.add_error_message("Usage: /fast [on|off|status]".to_string()); - } - } - } - SlashCommand::Rename if !trimmed.is_empty() => { - self.session_telemetry - .counter("codex.thread.rename", /*inc*/ 1, &[]); - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; - }; - let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else { - self.add_error_message("Thread name cannot be empty.".to_string()); - return; - }; - let cell = Self::rename_confirmation_cell(&name, self.thread_id); - self.add_boxed_history(Box::new(cell)); - self.request_redraw(); - self.app_event_tx.set_thread_name(name); - self.bottom_pane.drain_pending_submission_state(); - } - SlashCommand::Plan if !trimmed.is_empty() => { - self.dispatch_command(cmd); - if self.active_mode_kind() != ModeKind::Plan { - return; - } - let Some((prepared_args, prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ true) - else { - return; - }; - let local_images = self - .bottom_pane - .take_recent_submission_images_with_placeholders(); - let remote_image_urls = self.take_remote_image_urls(); - let user_message = UserMessage { - text: prepared_args, - local_images, - remote_image_urls, - text_elements: prepared_elements, - mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), - }; - 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 { - self.queue_user_message(user_message); - } - } - SlashCommand::Review if !trimmed.is_empty() => { - let Some((prepared_args, _prepared_elements)) = self - .bottom_pane - .prepare_inline_args_submission(/*record_history*/ false) - else { - return; - }; - self.submit_op(AppCommand::review(ReviewRequest { - target: ReviewTarget::Custom { - instructions: prepared_args, - }, - user_facing_hint: None, - })); - self.bottom_pane.drain_pending_submission_state(); - } - SlashCommand::SandboxReadRoot 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::BeginWindowsSandboxGrantReadRoot { - path: prepared_args, - }); - 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), - } - } - #[cfg(test)] pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { self.last_agent_markdown.as_deref() @@ -7583,6 +7059,8 @@ impl ChatWidget { EventMsg::CollabResumeBegin(ev) => self.on_collab_event(multi_agents::resume_begin(ev)), EventMsg::CollabResumeEnd(ev) => self.on_collab_event(multi_agents::resume_end(ev)), EventMsg::ThreadRolledBack(rollback) => { + self.last_agent_markdown = None; + self.saw_copy_source_this_turn = false; if from_replay { self.app_event_tx.send(AppEvent::ApplyThreadRollback { num_turns: rollback.num_turns, diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 2c5c83b38..c97e9ab34 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -91,6 +91,12 @@ impl ChatWidget { SlashCommand::Fork => { self.app_event_tx.send(AppEvent::ForkCurrentSession); } + SlashCommand::Thread => { + self.app_event_tx.send(AppEvent::OpenThreadPanel); + } + SlashCommand::Profile => { + self.app_event_tx.send(AppEvent::OpenProfileManagementPanel); + } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME); if init_target.exists() { @@ -143,7 +149,10 @@ impl ChatWidget { if !self.realtime_audio_device_selection_enabled() { return; } - self.open_realtime_audio_popup(); + self.open_settings_popup(); + } + SlashCommand::Clawbot => { + self.app_event_tx.send(AppEvent::OpenClawbotManagement); } SlashCommand::Personality => { self.open_personality_popup(); @@ -290,6 +299,13 @@ impl ChatWidget { SlashCommand::DebugConfig => { self.add_debug_config_output(); } + SlashCommand::Insight => { + self.add_info_message(crate::insight::start_message(), /*hint*/ None); + crate::insight::spawn_report_generation( + self.config.clone(), + self.app_event_tx.clone(), + ); + } SlashCommand::Title => { self.open_terminal_title_setup(); } @@ -320,6 +336,12 @@ impl ChatWidget { SlashCommand::Plugins => { self.add_plugins_output(); } + 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( @@ -503,6 +525,18 @@ impl ChatWidget { .send(AppEvent::ResumeSessionByIdOrName(prepared_args)); 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(); + } SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { let Some((prepared_args, _prepared_elements)) = self .bottom_pane diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index df64b5221..50cf720a3 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -835,7 +835,7 @@ async fn capture_and_restore_thread_input_state_preserves_profile_fallback_retry approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/home/user/project"), + cwd: PathBuf::from("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index f30e5be1a..39480a261 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -475,17 +475,17 @@ async fn slash_copy_state_clears_on_thread_rollback() { chat.handle_codex_event(Event { id: "turn-1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("Reply that will be rolled back".to_string()), - }), + msg: EventMsg::TurnComplete(turn_complete_event( + "turn-1", + Some("Reply that will be rolled back"), + )), }); chat.handle_codex_event(Event { id: "rollback-1".into(), msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), }); - assert_eq!(chat.last_copyable_output, None); + assert_eq!(chat.last_agent_markdown_text(), None); } #[tokio::test] @@ -495,7 +495,7 @@ async fn slash_insight_generates_report_and_announces_path() { let codex_home = temp_home.path().to_path_buf(); let sessions_dir = codex_home.join("sessions"); std::fs::create_dir_all(&sessions_dir).unwrap(); - chat.config.codex_home = codex_home.clone(); + chat.config.codex_home = codex_home.clone().abs(); chat.config.sqlite_home = codex_home.clone(); let thread_id = ThreadId::new(); @@ -567,7 +567,7 @@ async fn slash_insight_generates_report_and_announces_path() { process_id: None, turn_id: "turn-1".to_string(), command: vec!["rg".to_string(), "insight".to_string()], - cwd: PathBuf::from("/repo"), + cwd: PathBuf::from("/repo").abs(), parsed_cmd: Vec::new(), source: ExecCommandSource::Agent, interaction_input: None, 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 05d80b2e1..be732f909 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1361,6 +1361,8 @@ async fn completed_turn_history_renders_timestamp_separator_snapshot() { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("Timestamp rendered.".into()), + completed_at: Some(2), + duration_ms: Some(2_000), }), }); @@ -2093,7 +2095,7 @@ async fn hook_notifications_are_hidden_when_hook_output_preference_is_disabled() handler_type: AppServerHookHandlerType::Command, execution_mode: AppServerHookExecutionMode::Sync, scope: AppServerHookScope::Turn, - source_path: PathBuf::from("/tmp/hooks.json"), + source_path: PathBuf::from("/tmp/hooks.json").abs(), display_order: 0, status: AppServerHookRunStatus::Running, status_message: Some("warming the shell".to_string()), @@ -2115,7 +2117,7 @@ async fn hook_notifications_are_hidden_when_hook_output_preference_is_disabled() handler_type: AppServerHookHandlerType::Command, execution_mode: AppServerHookExecutionMode::Sync, scope: AppServerHookScope::Turn, - source_path: PathBuf::from("/tmp/hooks.json"), + source_path: PathBuf::from("/tmp/hooks.json").abs(), display_order: 0, status: AppServerHookRunStatus::Completed, status_message: Some("warming the shell".to_string()), diff --git a/codex-rs/tui/src/display_preferences.rs b/codex-rs/tui/src/display_preferences.rs index da5a9fff4..3694e768a 100644 --- a/codex-rs/tui/src/display_preferences.rs +++ b/codex-rs/tui/src/display_preferences.rs @@ -2,8 +2,8 @@ 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; +use crate::legacy_core::config::Config; +use crate::legacy_core::config::edit::ConfigEdit; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DisplayPreferenceKey { @@ -175,8 +175,8 @@ mod tests { 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; + use crate::legacy_core::config::ConfigBuilder; + use crate::legacy_core::config::edit::ConfigEdit; #[tokio::test] async fn startup_tooltips_follow_config_and_setters() { diff --git a/codex-rs/tui/src/insight/collector.rs b/codex-rs/tui/src/insight/collector.rs index b454fb92a..b45d1fc60 100644 --- a/codex-rs/tui/src/insight/collector.rs +++ b/codex-rs/tui/src/insight/collector.rs @@ -3,10 +3,10 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; +use crate::legacy_core::config::Config; use anyhow::Context; use chrono::DateTime; use chrono::Utc; -use codex_core::config::Config; use codex_protocol::ThreadId; use codex_protocol::protocol::DynamicToolCallResponseEvent; use codex_protocol::protocol::EventMsg; @@ -28,13 +28,16 @@ use super::types::CollectionResult; pub(crate) async fn collect_sessions(config: &Config) -> anyhow::Result { let mut rollout_paths = Vec::new(); collect_rollout_paths( - config.codex_home.join(SESSIONS_SUBDIR), + config.codex_home.join(SESSIONS_SUBDIR).to_path_buf(), /*archived*/ false, &mut rollout_paths, ) .await?; collect_rollout_paths( - config.codex_home.join(ARCHIVED_SESSIONS_SUBDIR), + config + .codex_home + .join(ARCHIVED_SESSIONS_SUBDIR) + .to_path_buf(), /*archived*/ true, &mut rollout_paths, ) diff --git a/codex-rs/tui/src/insight/mod.rs b/codex-rs/tui/src/insight/mod.rs index 4899e72ec..aae469c75 100644 --- a/codex-rs/tui/src/insight/mod.rs +++ b/codex-rs/tui/src/insight/mod.rs @@ -5,8 +5,8 @@ mod types; use std::path::Path; +use crate::legacy_core::config::Config; use chrono::Utc; -use codex_core::config::Config; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -52,7 +52,7 @@ async fn generate_report(config: Config) -> anyhow::Result { let report_path = report_path(config.codex_home.as_path(), generated_at); let data = InsightReportData { generated_at, - codex_home: config.codex_home.clone(), + codex_home: config.codex_home.to_path_buf(), report_path, overview: aggregated.overview, roots: aggregated.roots, @@ -77,8 +77,8 @@ mod tests { use std::path::PathBuf; use std::time::Duration; + use crate::legacy_core::config::ConfigBuilder; use chrono::Utc; - use codex_core::config::ConfigBuilder; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandEndEvent; @@ -94,6 +94,7 @@ mod tests { use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::UserMessageEvent; + use codex_utils_absolute_path::test_support::PathBufExt; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -160,7 +161,7 @@ mod tests { process_id: None, turn_id: "turn-1".to_string(), command: vec!["rg".to_string(), "todo".to_string()], - cwd: PathBuf::from("/repo"), + cwd: PathBuf::from("/repo").abs(), parsed_cmd: Vec::new(), source: ExecCommandSource::Agent, interaction_input: None, @@ -178,6 +179,8 @@ mod tests { item: RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: Some(2), + duration_ms: Some(2_000), })), }; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2f3586483..b191252a1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -144,7 +144,6 @@ mod notifications; pub(crate) mod onboarding; mod oss_selection; mod pager_overlay; -pub(crate) mod public_widgets; mod profile_router; pub(crate) mod public_widgets; mod render; @@ -657,7 +656,7 @@ fn app_server_thread_matches_cwd(thread: &AppServerThread, cwd_filter: &Path) -> ) { return thread_cwd == filter_cwd; } - thread.cwd == cwd_filter + thread.cwd.as_path() == cwd_filter } fn config_cwd_for_app_server_target( diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 48ef3e4cd..fedd91166 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -64,7 +64,7 @@ fn main() -> anyhow::Result<()> { eprintln!("ERROR: {message}"); std::process::exit(1); } - ExitReason::UserRequested => {} + ExitReason::UserRequested | ExitReason::RespawnRequested => {} } let color_enabled = supports_color::on(Stream::Stdout).is_some(); From fe65971e4f3ee9c26843845bf56fe34003136e4d Mon Sep 17 00:00:00 2001 From: piping Date: Fri, 17 Apr 2026 11:17:31 +0800 Subject: [PATCH 82/83] fmt code and bring ctrlx ctrl r back --- codex-rs/Cargo.lock | 132 ++++++++++++++++-- codex-rs/clawbot/src/config.rs | 5 +- .../src/provider/feishu/coordination.rs | 78 +++++++---- .../src/provider/feishu/runtime_loop.rs | 11 +- codex-rs/core/src/codex.rs | 10 ++ codex-rs/core/src/codex/turn.rs | 3 + codex-rs/core/src/config/mod.rs | 3 +- codex-rs/tui/src/app.rs | 62 ++++---- codex-rs/tui/src/app/agent_navigation.rs | 2 + codex-rs/tui/src/app/clawbot_controller.rs | 9 -- codex-rs/tui/src/app/clawbot_controls.rs | 4 +- codex-rs/tui/src/app/feature_dispatch.rs | 1 - codex-rs/tui/src/app/key_chord.rs | 19 +++ codex-rs/tui/src/app/thread_controller.rs | 1 + codex-rs/tui/src/app_event.rs | 4 +- codex-rs/tui/src/bottom_pane/mod.rs | 1 - ...chat_composer__tests__slash_popup_res.snap | 5 +- codex-rs/tui/src/chatwidget.rs | 6 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 3 + .../src/chatwidget/tests/slash_commands.rs | 32 +++++ .../src/chatwidget/tests/status_and_layout.rs | 2 + codex-rs/tui/src/multi_agents.rs | 18 ++- codex-rs/tui/src/resume_picker.rs | 6 +- codex-rs/tui/src/slash_command.rs | 5 +- 24 files changed, 317 insertions(+), 105 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4bb80940d..167cfcfc3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -940,6 +940,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1531,7 +1540,6 @@ dependencies = [ "anyhow", "clap", "codex-experimental-api-macros", - "codex-git-utils", "codex-protocol", "codex-shell-command", "codex-utils-absolute-path", @@ -1649,6 +1657,7 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", + "codex-app-server-protocol", "codex-config", "codex-connectors", "codex-core", @@ -1870,12 +1879,11 @@ dependencies = [ "codex-app-server-protocol", "codex-execpolicy", "codex-features", - "codex-git-utils", "codex-model-provider-info", "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", - "dunce", + "codex-utils-path", "futures", "multimap", "pretty_assertions", @@ -1973,6 +1981,7 @@ dependencies = [ "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", + "crypto_box", "csv", "dirs", "dunce", @@ -2002,6 +2011,7 @@ dependencies = [ "serde_json", "serial_test", "sha1", + "sha2", "shlex", "similar", "tempfile", @@ -2032,6 +2042,7 @@ dependencies = [ name = "codex-core-plugins" version = "0.0.0" dependencies = [ + "chrono", "codex-app-server-protocol", "codex-config", "codex-core-skills", @@ -2264,6 +2275,8 @@ name = "codex-git-utils" version = "0.0.0" dependencies = [ "assert_matches", + "codex-exec-server", + "codex-protocol", "codex-utils-absolute-path", "futures", "once_cell", @@ -2334,6 +2347,7 @@ dependencies = [ "codex-protocol", "codex-sandboxing", "codex-utils-absolute-path", + "globset", "landlock", "libc", "pkg-config", @@ -2620,13 +2634,13 @@ dependencies = [ "chrono", "codex-async-utils", "codex-execpolicy", - "codex-git-utils", "codex-network-proxy", "codex-utils-absolute-path", "codex-utils-image", "codex-utils-string", "codex-utils-template", "encoding_rs", + "globset", "http 1.4.0", "icu_decimal", "icu_locale_core", @@ -2759,6 +2773,7 @@ dependencies = [ "dunce", "libc", "pretty_assertions", + "regex-lite", "serde_json", "tempfile", "tokio", @@ -2899,11 +2914,16 @@ dependencies = [ "codex-rollout", "codex-state", "pretty_assertions", + "prost 0.14.3", "serde", "serde_json", "tempfile", "thiserror 2.0.18", "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", "uuid", ] @@ -2943,6 +2963,7 @@ dependencies = [ "codex-cli", "codex-cloud-requirements", "codex-config", + "codex-connectors", "codex-exec-server", "codex-features", "codex-feedback", @@ -3665,9 +3686,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto_box" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009" +dependencies = [ + "aead", + "blake2", + "crypto_secretbox", + "curve25519-dalek", + "salsa20", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto_secretbox" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" +dependencies = [ + "aead", + "cipher", + "generic-array", + "poly1305", + "salsa20", + "subtle", + "zeroize", +] + [[package]] name = "csv" version = "1.4.0" @@ -4957,6 +5009,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -6225,7 +6278,7 @@ checksum = "79d4b1f8c37d37efd353c1b116df0f98ff21c8bcb216f67ab026dd2fd2b9ab81" dependencies = [ "bytes", "prost 0.13.5", - "prost-build", + "prost-build 0.12.6", ] [[package]] @@ -7733,7 +7786,7 @@ dependencies = [ "heck 0.4.1", "itertools 0.11.0", "prost 0.12.6", - "prost-types", + "prost-types 0.12.6", ] [[package]] @@ -7747,7 +7800,7 @@ dependencies = [ "pbjson", "pbjson-build", "prost 0.12.6", - "prost-build", + "prost-build 0.12.6", "serde", ] @@ -8174,14 +8227,33 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", "heck 0.5.0", - "itertools 0.10.5", + "itertools 0.11.0", "log", "multimap", "once_cell", "petgraph 0.6.5", "prettyplease", "prost 0.12.6", - "prost-types", + "prost-types 0.12.6", + "regex", + "syn 2.0.114", + "tempfile", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap", + "petgraph 0.8.3", + "prettyplease", + "prost 0.14.3", + "prost-types 0.14.3", "regex", "syn 2.0.114", "tempfile", @@ -8194,7 +8266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.11.0", "proc-macro2", "quote", "syn 2.0.114", @@ -8235,6 +8307,15 @@ dependencies = [ "prost 0.12.6", ] +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost 0.14.3", +] + [[package]] name = "psl" version = "2.1.184" @@ -11194,8 +11275,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" dependencies = [ "async-trait", + "axum", "base64 0.22.1", "bytes", + "h2", "http 1.4.0", "http-body", "http-body-util", @@ -11205,6 +11288,7 @@ dependencies = [ "percent-encoding", "pin-project", "rustls-native-certs 0.8.3", + "socket2 0.6.2", "sync_wrapper", "tokio", "tokio-rustls", @@ -11215,6 +11299,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "tonic-build" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1882ac3bf5ef12877d7ed57aad87e75154c11931c2ba7e6cde5e22d63522c734" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "tonic-prost" version = "0.14.3" @@ -11226,6 +11322,22 @@ dependencies = [ "tonic", ] +[[package]] +name = "tonic-prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4556786613791cfef4ed134aa670b61a85cfcacf71543ef33e8d801abae988f" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build 0.14.3", + "prost-types 0.14.3", + "quote", + "syn 2.0.114", + "tempfile", + "tonic-build", +] + [[package]] name = "tower" version = "0.5.3" diff --git a/codex-rs/clawbot/src/config.rs b/codex-rs/clawbot/src/config.rs index d2acb9120..a59465411 100644 --- a/codex-rs/clawbot/src/config.rs +++ b/codex-rs/clawbot/src/config.rs @@ -87,7 +87,10 @@ impl FeishuCoordinationConfig { } pub fn heartbeat_ttl(&self) -> Duration { - Duration::from_secs(self.heartbeat_ttl_secs.max(self.heartbeat_interval_secs.max(1) * 2)) + Duration::from_secs( + self.heartbeat_ttl_secs + .max(self.heartbeat_interval_secs.max(1) * 2), + ) } } diff --git a/codex-rs/clawbot/src/provider/feishu/coordination.rs b/codex-rs/clawbot/src/provider/feishu/coordination.rs index 766e25ca9..09e332d4b 100644 --- a/codex-rs/clawbot/src/provider/feishu/coordination.rs +++ b/codex-rs/clawbot/src/provider/feishu/coordination.rs @@ -277,18 +277,23 @@ impl FeishuBaseClient { let existing = self .find_force_record_by_key(&tables.force_table_id, &key) .await?; - let force_until_ms = if force_connect { now_ms + ttl_ms } else { now_ms }; + let force_until_ms = if force_connect { + now_ms + ttl_ms + } else { + now_ms + }; let should_write = force_connect || existing.as_ref().is_some_and(|record| { - record.target_instance_id == target_instance_id - && record.force_until_ms > now_ms + record.target_instance_id == target_instance_id && record.force_until_ms > now_ms }); if !should_write { return Ok(()); } let fields = ForceIntentRecord { - record_id: existing.as_ref().and_then(|record| record.record_id.clone()), + record_id: existing + .as_ref() + .and_then(|record| record.record_id.clone()), key, app_id: app_id.to_string(), target_instance_id: target_instance_id.to_string(), @@ -470,7 +475,9 @@ impl FeishuBaseClient { if let Some(token) = page_token.clone() { request = request.query("page_token", token); } - let response = self.request_json("list Feishu Base tables", request).await?; + let response = self + .request_json("list Feishu Base tables", request) + .await?; let payload = paged_payload( response .data @@ -507,8 +514,8 @@ impl FeishuBaseClient { } async fn create_table(&self, table_name: &str) -> Result<()> { - let request: ApiRequest = ApiRequest::post(tables_url(&self.base_token)) - .json_body(&serde_json::json!({ + let request: ApiRequest = + ApiRequest::post(tables_url(&self.base_token)).json_body(&serde_json::json!({ "table": { "name": table_name, "fields": [{ @@ -526,13 +533,16 @@ impl FeishuBaseClient { let mut page_token = None; let mut fields = Vec::new(); loop { - let mut request: ApiRequest = ApiRequest::get(fields_url(&self.base_token, table_id)) - .query("page_size", "100"); + let mut request: ApiRequest = + ApiRequest::get(fields_url(&self.base_token, table_id)).query("page_size", "100"); if let Some(token) = page_token.clone() { request = request.query("page_token", token); } let response = self - .request_json(&format!("list Feishu Base fields for table {table_id}"), request) + .request_json( + &format!("list Feishu Base fields for table {table_id}"), + request, + ) .await?; let payload = paged_payload( response @@ -596,7 +606,10 @@ impl FeishuBaseClient { request = request.query("page_token", token); } let response = self - .request_json(&format!("list Feishu Base records for table {table_id}"), request) + .request_json( + &format!("list Feishu Base records for table {table_id}"), + request, + ) .await?; let payload = paged_payload( response @@ -667,7 +680,11 @@ impl FeishuBaseClient { if response.is_success() { Ok(response) } else { - Err(classify_feishu_api_error(operation, &self.base_token, &response)) + Err(classify_feishu_api_error( + operation, + &self.base_token, + &response, + )) } } } @@ -863,24 +880,24 @@ fn select_leader( .then(left.instance_id.cmp(&right.instance_id)) .then(left.session_id.cmp(&right.session_id)) }); - let leader = if let Some(force_intent) = force_intent.filter(|intent| intent.is_active(now_ms)) { - if let Some(forced_lease) = active - .iter() - .find(|lease| lease.instance_id == force_intent.target_instance_id) - { - forced_lease.clone() + let leader = + if let Some(force_intent) = force_intent.filter(|intent| intent.is_active(now_ms)) { + if let Some(forced_lease) = active + .iter() + .find(|lease| lease.instance_id == force_intent.target_instance_id) + { + forced_lease.clone() + } else { + active.first().cloned().ok_or_else(|| { + anyhow!("no active Feishu Base coordination heartbeat rows found") + })? + } } else { active .first() .cloned() .ok_or_else(|| anyhow!("no active Feishu Base coordination heartbeat rows found"))? - } - } else { - active - .first() - .cloned() - .ok_or_else(|| anyhow!("no active Feishu Base coordination heartbeat rows found"))? - }; + }; Ok(LeadershipSnapshot { is_leader: leader.instance_id == current_instance_id, leader_instance_id: leader.instance_id, @@ -1017,12 +1034,12 @@ fn unix_timestamp_ms_now() -> Result { mod tests { use pretty_assertions::assert_eq; + use super::BaseTable; use super::FORCE_FIELDS; use super::ForceIntentRecord; use super::HEARTBEAT_FIELDS; use super::HeartbeatLease; use super::RequiredField; - use super::BaseTable; use super::choose_named_table; use super::classify_feishu_api_error; use super::select_leader; @@ -1123,14 +1140,17 @@ mod tests { }, ]; - let chosen = choose_named_table(&tables, "clawbot_coordination_heartbeat") - .expect("named table"); + let chosen = + choose_named_table(&tables, "clawbot_coordination_heartbeat").expect("named table"); assert_eq!(chosen.table_id, "tbl_a"); } #[test] fn required_field_sets_match_expected_shape() { - assert_eq!(HEARTBEAT_FIELDS.first(), Some(&RequiredField::new("key", 1))); + assert_eq!( + HEARTBEAT_FIELDS.first(), + Some(&RequiredField::new("key", 1)) + ); assert_eq!(FORCE_FIELDS.first(), Some(&RequiredField::new("key", 1))); } diff --git a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs index d3aa9c1df..4ae597c9b 100644 --- a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs +++ b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs @@ -38,7 +38,8 @@ pub(super) async fn run_with_reconnect( } if let Some(coordination) = FeishuBaseCoordinator::new(workspace_root.as_path(), &config)? { - return run_with_coordination(workspace_root, config, provider_event_tx, coordination).await; + return run_with_coordination(workspace_root, config, provider_event_tx, coordination) + .await; } let mut reconnect_delay = INITIAL_RECONNECT_DELAY; @@ -79,7 +80,9 @@ async fn run_with_coordination( let mut last_standby_message = None::; loop { - if let Some(handle) = websocket_task.as_ref() && handle.is_finished() { + if let Some(handle) = websocket_task.as_ref() + && handle.is_finished() + { let result = websocket_task .take() .expect("finished websocket task should still be present") @@ -248,7 +251,9 @@ fn spawn_websocket_task( config: FeishuConfig, provider_event_tx: mpsc::UnboundedSender, ) -> JoinHandle> { - tokio::spawn(async move { run_once(workspace_root.as_path(), &config, &provider_event_tx).await }) + tokio::spawn( + async move { run_once(workspace_root.as_path(), &config, &provider_event_tx).await }, + ) } fn emit_reconnect_state( diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1b81af705..d1f6e0805 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -193,6 +193,16 @@ pub(crate) use self::turn_context::TurnSkillsContext; #[cfg(test)] mod rollout_reconstruction_tests; +fn should_rotate_prompt_cache_key_for_stream_retry(err: &CodexErr) -> bool { + matches!( + err, + CodexErr::Stream(message, _) + if message.contains("stream disconnected before completion") + || message.contains("stream closed before response.completed") + || message.contains("websocket closed by server before response.completed") + ) +} + #[derive(Debug, PartialEq)] pub enum SteerInputError { NoActiveTurn(Vec), diff --git a/codex-rs/core/src/codex/turn.rs b/codex-rs/core/src/codex/turn.rs index 84610804a..760eced6a 100644 --- a/codex-rs/core/src/codex/turn.rs +++ b/codex-rs/core/src/codex/turn.rs @@ -1093,6 +1093,9 @@ async fn run_sampling_request( } if retries < max_retries { retries += 1; + if super::should_rotate_prompt_cache_key_for_stream_retry(&err) { + client_session.rotate_prompt_cache_key_for_retry(); + } let delay = match &err { CodexErr::Stream(_, requested_delay) => { requested_delay.unwrap_or_else(|| backoff(retries)) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 61eb57ee5..96e41c97c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -44,9 +44,8 @@ use codex_config::types::OtelExporterKind; use codex_config::types::ShellEnvironmentPolicy; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDiscoverable; -use codex_config::types::TuiNotificationSettings; -use codex_config::types::Tui; use codex_config::types::TuiDisplayPreferences; +use codex_config::types::TuiNotificationSettings; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_exec_server::ExecutorFileSystem; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a014203bf..7c7d77930 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -202,7 +202,6 @@ pub(crate) mod workflow_runtime; mod workflow_scheduler; mod workflow_yaml; -use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; use self::btw::BtwSessionState; @@ -4049,39 +4048,6 @@ impl App { !had_read_error } - /// Returns the adjacent thread id for keyboard navigation, backfilling from the server if the - /// local cache has no neighbor. - /// - /// Tries the fast path first: ask `AgentNavigationState` directly. If it returns `None` (no - /// adjacent entry exists, typically because the cache was never populated with remote - /// subagents), performs a full `backfill_loaded_subagent_threads` and retries. This ensures the - /// first next/previous keypress in a resumed remote session discovers subagents on demand - /// without requiring the user to wait for a proactive fetch. - async fn adjacent_thread_id_with_backfill( - &mut self, - app_server: &mut AppServerSession, - direction: AgentNavigationDirection, - ) -> Option { - let current_thread = self.current_displayed_thread_id(); - if let Some(thread_id) = self - .agent_navigation - .adjacent_thread_id(current_thread, direction) - { - return Some(thread_id); - } - - let primary_thread_id = self.primary_thread_id?; - if self.last_subagent_backfill_attempt == Some(primary_thread_id) { - return None; - } - - if self.backfill_loaded_subagent_threads(app_server).await { - self.last_subagent_backfill_attempt = Some(primary_thread_id); - } - self.agent_navigation - .adjacent_thread_id(self.current_displayed_thread_id(), direction) - } - fn fresh_session_config(&self) -> Config { let mut config = self.config.clone(); config.service_tier = self.chat_widget.current_service_tier(); @@ -6677,6 +6643,10 @@ impl App { .send(AppEvent::SelectAgentThread(thread_id)); } } + KeyChordAction::RespawnCodex => { + self.app_event_tx + .send(AppEvent::Exit(ExitMode::RespawnImmediate)); + } } None } @@ -11958,6 +11928,30 @@ model = "gpt-5.2" assert_eq!(app.chat_widget.composer_text_with_pending(), ""); } + #[tokio::test] + async fn ctrl_x_ctrl_r_requests_respawn_exit() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().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('r'), + KeyModifiers::CONTROL, + )), + None + ); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::Exit(ExitMode::RespawnImmediate)) + ); + } + #[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/agent_navigation.rs b/codex-rs/tui/src/app/agent_navigation.rs index 6e16b3e18..b6b52ae6b 100644 --- a/codex-rs/tui/src/app/agent_navigation.rs +++ b/codex-rs/tui/src/app/agent_navigation.rs @@ -41,6 +41,7 @@ pub(crate) struct AgentNavigationState { } /// Direction of keyboard traversal through the stable picker order. +#[cfg(test)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum AgentNavigationDirection { /// Move toward the entry that was seen earlier in spawn order, wrapping at the front. @@ -166,6 +167,7 @@ impl AgentNavigationState { /// just whichever thread bookkeeping most recently marked active. If the wrong current thread /// is supplied, next/previous navigation will jump in a way that feels nondeterministic even /// though the cache itself is correct. + #[cfg(test)] pub(crate) fn adjacent_thread_id( &self, current_displayed_thread_id: Option, diff --git a/codex-rs/tui/src/app/clawbot_controller.rs b/codex-rs/tui/src/app/clawbot_controller.rs index 4b068d30b..09ff0b9db 100644 --- a/codex-rs/tui/src/app/clawbot_controller.rs +++ b/codex-rs/tui/src/app/clawbot_controller.rs @@ -64,15 +64,6 @@ impl ClawbotController { )); } } - AppEvent::SaveClawbotManualBindSessionId { session_id } => { - if let Err(err) = app - .bind_clawbot_session_to_current_thread(app_server, session_id) - .await - { - app.chat_widget - .add_error_message(format!("Failed to bind Clawbot session: {err}")); - } - } AppEvent::ClawbotSetTurnMode { mode } => { if let Err(err) = app.save_clawbot_turn_mode(mode) { app.chat_widget diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs index 900a7d88e..60083d280 100644 --- a/codex-rs/tui/src/app/clawbot_controls.rs +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -219,6 +219,7 @@ impl App { let view = CustomPromptView::new( field.title().to_string(), field.prompt_placeholder().to_string(), + field.current_value(config.as_ref()).unwrap_or_default(), Some(field.prompt_context_label(config.as_ref())), Box::new(move |value| { tx.send(AppEvent::SaveClawbotFeishuConfigValue { field, value }); @@ -311,6 +312,7 @@ impl App { Ok(()) } + #[cfg(test)] pub(crate) async fn bind_clawbot_session_to_current_thread( &mut self, app_server: &mut AppServerSession, @@ -1509,7 +1511,7 @@ fn clawbot_session_item( })] } else { vec![Box::new(move |tx: &AppEventSender| { - tx.send(AppEvent::SaveClawbotManualBindSessionId { + tx.send(AppEvent::BindClawbotDiscoveredSession { session_id: session_id.clone(), }); })] diff --git a/codex-rs/tui/src/app/feature_dispatch.rs b/codex-rs/tui/src/app/feature_dispatch.rs index 8502a222b..6a2c9000c 100644 --- a/codex-rs/tui/src/app/feature_dispatch.rs +++ b/codex-rs/tui/src/app/feature_dispatch.rs @@ -59,7 +59,6 @@ impl FeatureRoute { | AppEvent::SaveClawbotFeishuConfigValue { .. } | AppEvent::BindClawbotDiscoveredSession { .. } | AppEvent::BindClawbotSessionAndPreempt { .. } - | AppEvent::SaveClawbotManualBindSessionId { .. } | AppEvent::ClawbotSetTurnMode { .. } | AppEvent::ClawbotSetThreadForwarding { .. } | AppEvent::ScanClawbotFeishuSessions diff --git a/codex-rs/tui/src/app/key_chord.rs b/codex-rs/tui/src/app/key_chord.rs index 9291ffbac..d072efc0f 100644 --- a/codex-rs/tui/src/app/key_chord.rs +++ b/codex-rs/tui/src/app/key_chord.rs @@ -13,6 +13,7 @@ pub(crate) enum KeyChordState { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum KeyChordAction { SelectAgentSlot(u8), + RespawnCodex, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -60,6 +61,9 @@ fn handle_ctrl_x_second_key(state: &mut KeyChordState, key_event: KeyEvent) -> K let slot = (slot as u8) - b'0'; KeyChordResolution::Matched(KeyChordAction::SelectAgentSlot(slot)) } + (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + KeyChordResolution::Matched(KeyChordAction::RespawnCodex) + } (KeyCode::Char('x'), KeyModifiers::CONTROL) => KeyChordResolution::AwaitingSecondKey, (KeyCode::Esc, _) => KeyChordResolution::Cancelled, _ => KeyChordResolution::Forward(key_event), @@ -92,6 +96,21 @@ mod tests { assert_eq!(state, KeyChordState::Idle); } + #[test] + fn ctrl_x_ctrl_r_matches_respawn() { + 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::RespawnCodex) + ); + assert_eq!(state, KeyChordState::Idle); + } + #[test] fn ctrl_x_unknown_second_key_is_forwarded_and_clears_state() { let mut state = KeyChordState::default(); diff --git a/codex-rs/tui/src/app/thread_controller.rs b/codex-rs/tui/src/app/thread_controller.rs index 205f4e991..36ff20a58 100644 --- a/codex-rs/tui/src/app/thread_controller.rs +++ b/codex-rs/tui/src/app/thread_controller.rs @@ -46,6 +46,7 @@ impl ThreadController { app.chat_widget.token_usage(), app.chat_widget.thread_id(), app.chat_widget.thread_name(), + app.chat_widget.rollout_path().as_deref(), ); app.chat_widget .add_plain_history_lines(vec!["/fork".magenta().into()]); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 19b7dd6e5..a20937eb1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -102,6 +102,7 @@ pub(crate) enum ClawbotForwardingChannel { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ClawbotSessionBindSource { DiscoveredSession, + #[cfg(test)] ManualSessionId, } @@ -569,9 +570,6 @@ pub(crate) enum AppEvent { BindClawbotSessionAndPreempt { session_id: String, }, - SaveClawbotManualBindSessionId { - session_id: String, - }, ClawbotSetTurnMode { mode: ClawbotTurnMode, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 56b796740..667034628 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -29,7 +29,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 bottom_pane_view::ViewCompletion; use codex_features::Features; use codex_file_search::FileMatch; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap index ff5628a2f..cfcf8f02c 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -1,10 +1,11 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 6545 expression: terminal.backend() --- " " "› /res " " " " " -" " -" /resume resume a saved chat " +" /resume resume a saved chat " +" /respawn restart Codex and resume this session " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dab4ca281..5e5187cef 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -7256,6 +7256,12 @@ impl ChatWidget { .send(AppEvent::Exit(ExitMode::ShutdownFirst)); } + /// Request an immediate exit so the outer CLI respawns this session. + fn request_respawn(&self) { + self.app_event_tx + .send(AppEvent::Exit(ExitMode::RespawnImmediate)); + } + fn request_redraw(&mut self) { self.frame_requester.schedule_frame(); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index c97e9ab34..09f303fcb 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -243,6 +243,9 @@ impl ChatWidget { SlashCommand::Quit | SlashCommand::Exit => { self.request_quit_without_confirmation(); } + SlashCommand::Respawn => { + self.request_respawn(); + } SlashCommand::Logout => { if let Err(e) = codex_login::logout( &self.config.codex_home, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 39480a261..4eb98674f 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -291,6 +291,26 @@ async fn slash_btw_dispatches_start_event() { } } +#[tokio::test] +async fn slash_btw_dispatches_start_event_while_task_running() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); + + chat.bottom_pane.set_composer_text( + "/btw compare against prior context".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 against prior context"); + } + 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; @@ -300,6 +320,18 @@ async fn slash_quit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +#[tokio::test] +async fn slash_respawn_requests_respawn_exit() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Respawn); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::Exit(ExitMode::RespawnImmediate)) + ); +} + #[tokio::test] async fn slash_copy_state_tracks_turn_complete_final_reply() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; 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 be732f909..d1a1fa31b 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2095,6 +2095,7 @@ async fn hook_notifications_are_hidden_when_hook_output_preference_is_disabled() handler_type: AppServerHookHandlerType::Command, execution_mode: AppServerHookExecutionMode::Sync, scope: AppServerHookScope::Turn, + source: codex_app_server_protocol::HookSource::Unknown, source_path: PathBuf::from("/tmp/hooks.json").abs(), display_order: 0, status: AppServerHookRunStatus::Running, @@ -2117,6 +2118,7 @@ async fn hook_notifications_are_hidden_when_hook_output_preference_is_disabled() handler_type: AppServerHookHandlerType::Command, execution_mode: AppServerHookExecutionMode::Sync, scope: AppServerHookScope::Turn, + source: codex_app_server_protocol::HookSource::Unknown, source_path: PathBuf::from("/tmp/hooks.json").abs(), display_order: 0, status: AppServerHookRunStatus::Completed, diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 4c3ab659f..becd5912d 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -19,11 +19,13 @@ use codex_protocol::protocol::CollabResumeBeginEvent; use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; +#[cfg(test)] use crossterm::event::KeyCode; +#[cfg(test)] use crossterm::event::KeyEvent; -#[cfg(target_os = "macos")] +#[cfg(all(test, target_os = "macos"))] use crossterm::event::KeyEventKind; -#[cfg(target_os = "macos")] +#[cfg(all(test, target_os = "macos"))] use crossterm::event::KeyModifiers; use ratatui::style::Stylize; use ratatui::text::Line; @@ -88,16 +90,19 @@ pub(crate) fn format_agent_picker_item_name( } } +#[cfg(test)] pub(crate) fn previous_agent_shortcut() -> crate::key_hint::KeyBinding { crate::key_hint::alt(KeyCode::Left) } +#[cfg(test)] pub(crate) fn next_agent_shortcut() -> crate::key_hint::KeyBinding { crate::key_hint::alt(KeyCode::Right) } /// Matches the canonical "previous agent" binding plus platform-specific fallbacks that keep agent /// navigation working when enhanced key reporting is unavailable. +#[cfg(test)] pub(crate) fn previous_agent_shortcut_matches( key_event: KeyEvent, allow_word_motion_fallback: bool, @@ -108,6 +113,7 @@ pub(crate) fn previous_agent_shortcut_matches( /// Matches the canonical "next agent" binding plus platform-specific fallbacks that keep agent /// navigation working when enhanced key reporting is unavailable. +#[cfg(test)] pub(crate) fn next_agent_shortcut_matches( key_event: KeyEvent, allow_word_motion_fallback: bool, @@ -116,7 +122,7 @@ pub(crate) fn next_agent_shortcut_matches( || next_agent_word_motion_fallback(key_event, allow_word_motion_fallback) } -#[cfg(target_os = "macos")] +#[cfg(all(test, target_os = "macos"))] fn previous_agent_word_motion_fallback( key_event: KeyEvent, allow_word_motion_fallback: bool, @@ -137,7 +143,7 @@ fn previous_agent_word_motion_fallback( ) } -#[cfg(not(target_os = "macos"))] +#[cfg(all(test, not(target_os = "macos")))] fn previous_agent_word_motion_fallback( _key_event: KeyEvent, _allow_word_motion_fallback: bool, @@ -145,7 +151,7 @@ fn previous_agent_word_motion_fallback( false } -#[cfg(target_os = "macos")] +#[cfg(all(test, target_os = "macos"))] fn next_agent_word_motion_fallback(key_event: KeyEvent, allow_word_motion_fallback: bool) -> bool { // Some terminals, especially on macOS, send Option+b/f as word-motion keys instead of // Option+arrow events unless enhanced keyboard reporting is enabled. Callers should only @@ -163,7 +169,7 @@ fn next_agent_word_motion_fallback(key_event: KeyEvent, allow_word_motion_fallba ) } -#[cfg(not(target_os = "macos"))] +#[cfg(all(test, not(target_os = "macos")))] fn next_agent_word_motion_fallback( _key_event: KeyEvent, _allow_word_motion_fallback: bool, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index f23d456f7..27dcdd02c 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -2171,7 +2171,8 @@ mod tests { state.update_view_rows(/*rows*/ 3); state.relative_time_reference = Some(now); - let metrics = calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true, now); + let metrics = + calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true, now); let width: u16 = 80; let height: u16 = 6; @@ -2478,7 +2479,8 @@ mod tests { state.update_thread_names().await; state.relative_time_reference = Some(now); - let metrics = calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true, now); + let metrics = + calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true, now); let width: u16 = 80; let height: u16 = 5; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 406f125e9..40caf8b8b 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -49,6 +49,7 @@ pub enum SlashCommand { Apps, Plugins, Workflow, + Respawn, Btw, Logout, Quit, @@ -125,6 +126,7 @@ impl SlashCommand { SlashCommand::Apps => "manage apps", SlashCommand::Plugins => "browse plugins", SlashCommand::Workflow => "inspect and trigger workspace workflows", + SlashCommand::Respawn => "restart Codex and resume this session", SlashCommand::Btw => "run a hidden read-only temporary discussion", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", @@ -172,7 +174,6 @@ impl SlashCommand { | SlashCommand::Memories | SlashCommand::Review | SlashCommand::Plan - | SlashCommand::Btw | SlashCommand::Clear | SlashCommand::Logout | SlashCommand::MemoryDrop @@ -191,8 +192,10 @@ impl SlashCommand { | SlashCommand::Apps | SlashCommand::Plugins | SlashCommand::Workflow + | SlashCommand::Respawn | SlashCommand::Thread | SlashCommand::Profile + | SlashCommand::Btw | SlashCommand::Feedback | SlashCommand::Quit | SlashCommand::Exit => true, From a20a8de6ef333a2b854e1eb406fc6c6a43ae08a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 06:46:14 +0000 Subject: [PATCH 83/83] chore(deps): bump codespell-project/codespell-problem-matcher Bumps [codespell-project/codespell-problem-matcher](https://github.com/codespell-project/codespell-problem-matcher) from 1.1.0 to 1.2.0. - [Release notes](https://github.com/codespell-project/codespell-problem-matcher/releases) - [Commits](https://github.com/codespell-project/codespell-problem-matcher/compare/b80729f885d32f78a716c2f107b4db1025001c42...9ba2c57125d4908eade4308f32c4ff814c184633) --- updated-dependencies: - dependency-name: codespell-project/codespell-problem-matcher dependency-version: 1.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codespell.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 11815a01e..7f6e88108 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -19,7 +19,7 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Annotate locations with typos - uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1 + uses: codespell-project/codespell-problem-matcher@9ba2c57125d4908eade4308f32c4ff814c184633 # v1.2.0 - name: Codespell uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2 with: