mirror of
https://github.com/openai/codex.git
synced 2026-04-26 15:45:02 +00:00
## What changed
- In `codex-rs/core/src/skills/injection.rs`, we now honor explicit
`UserInput::Skill { name, path }` first, then fall back to text mentions
only when safe.
- In `codex-rs/tui/src/bottom_pane/chat_composer.rs`, mention selection
is now token-bound (selected mention is tied to the specific inserted
`$token`), and we snapshot bindings at submit time so selection is not
lost.
- In `codex-rs/tui/src/chatwidget.rs` and
`codex-rs/tui/src/bottom_pane/mod.rs`, submit/queue paths now consume
the submit-time mention snapshot (instead of rereading cleared composer
state).
- In `codex-rs/tui/src/mention_codec.rs` and
`codex-rs/tui/src/bottom_pane/chat_composer_history.rs`, history now
round-trips mention targets so resume restores the same selected
duplicate.
- In `codex-rs/tui/src/bottom_pane/skill_popup.rs` and
`codex-rs/tui/src/bottom_pane/chat_composer.rs`, duplicate labels are
normalized to `[Repo]` / `[App]`, app rows no longer show `Connected -`,
and description space is a bit wider.
<img width="550" height="163" alt="Screenshot 2026-02-05 at 9 56 56 PM"
src="https://github.com/user-attachments/assets/346a7eb2-a342-4a49-aec8-68dfec0c7d89"
/>
<img width="550" height="163" alt="Screenshot 2026-02-05 at 9 57 09 PM"
src="https://github.com/user-attachments/assets/5e04d9af-cccf-4932-98b3-c37183e445ed"
/>
## Before vs now
- Before: selecting a duplicate could still submit the default/repo
match, and resume could lose which duplicate was originally selected.
- Now: the exact selected target (skill path or app id) is preserved
through submit, queue/restore, and resume.
## Manual test
1. Build and run this branch locally:
- `cd /Users/daniels/code/codex/codex-rs`
- `cargo build -p codex-cli --bin codex`
- `./target/debug/codex`
2. Open mention picker with `$` and pick a duplicate entry (not the
first one).
3. Confirm duplicate UI:
- repo duplicate rows show `[Repo]`
- app duplicate rows show `[App]`
- app description does **not** start with `Connected -`
4. Submit the prompt, then press Up to restore draft and submit again.
Expected: it keeps the same selected duplicate target.
5. Use `/resume` to reopen the session and send again.
Expected: restored mention still resolves to the same duplicate target.
152 lines
5.0 KiB
Rust
152 lines
5.0 KiB
Rust
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use codex_core::config::ProjectConfig;
|
|
use codex_core::protocol::AskForApproval;
|
|
use codex_core::protocol::EventMsg;
|
|
use codex_core::protocol::Op;
|
|
use codex_core::protocol::SandboxPolicy;
|
|
use codex_protocol::config_types::ReasoningSummary;
|
|
use codex_protocol::config_types::TrustLevel;
|
|
use codex_protocol::user_input::UserInput;
|
|
use core_test_support::responses;
|
|
use core_test_support::responses::ResponsesRequest;
|
|
use core_test_support::responses::mount_sse_sequence;
|
|
use core_test_support::responses::start_mock_server;
|
|
use core_test_support::test_codex::TestCodex;
|
|
use core_test_support::test_codex::test_codex;
|
|
use core_test_support::wait_for_event;
|
|
use tokio::time::timeout;
|
|
|
|
fn enable_trusted_project(config: &mut codex_core::config::Config) {
|
|
config.active_project = ProjectConfig {
|
|
trust_level: Some(TrustLevel::Trusted),
|
|
};
|
|
}
|
|
|
|
fn write_skill(home: &Path, name: &str, description: &str, body: &str) -> PathBuf {
|
|
let skill_dir = home.join("skills").join(name);
|
|
fs::create_dir_all(&skill_dir).expect("create skill dir");
|
|
let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n");
|
|
let path = skill_dir.join("SKILL.md");
|
|
fs::write(&path, contents).expect("write skill");
|
|
path
|
|
}
|
|
|
|
fn contains_skill_body(request: &ResponsesRequest, skill_body: &str) -> bool {
|
|
request
|
|
.message_input_texts("user")
|
|
.iter()
|
|
.any(|text| text.contains(skill_body) && text.contains("<skill>"))
|
|
}
|
|
|
|
async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) -> Result<()> {
|
|
let session_model = test.session_configured.model.clone();
|
|
test.codex
|
|
.submit(Op::UserTurn {
|
|
items: vec![
|
|
UserInput::Text {
|
|
text: prompt.to_string(),
|
|
text_elements: Vec::new(),
|
|
},
|
|
UserInput::Skill {
|
|
name: "demo".to_string(),
|
|
path: skill_path,
|
|
},
|
|
],
|
|
final_output_json_schema: None,
|
|
cwd: test.cwd_path().to_path_buf(),
|
|
approval_policy: AskForApproval::Never,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
model: session_model,
|
|
effort: None,
|
|
summary: ReasoningSummary::Auto,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
})
|
|
.await?;
|
|
|
|
wait_for_event(test.codex.as_ref(), |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn live_skills_reload_refreshes_skill_cache_after_skill_change() -> Result<()> {
|
|
let server = start_mock_server().await;
|
|
let responses = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
responses::sse(vec![responses::ev_completed("resp-1")]),
|
|
responses::sse(vec![responses::ev_completed("resp-2")]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let skill_v1 = "skill body v1";
|
|
let skill_v2 = "skill body v2";
|
|
let mut builder = test_codex()
|
|
.with_pre_build_hook(move |home| {
|
|
write_skill(home, "demo", "demo skill", skill_v1);
|
|
})
|
|
.with_config(|config| {
|
|
enable_trusted_project(config);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
let skill_path = dunce::canonicalize(test.codex_home_path().join("skills/demo/SKILL.md"))?;
|
|
|
|
submit_skill_turn(&test, skill_path.clone(), "please use $demo").await?;
|
|
let first_request = responses
|
|
.requests()
|
|
.first()
|
|
.cloned()
|
|
.expect("first request captured");
|
|
assert!(
|
|
contains_skill_body(&first_request, skill_v1),
|
|
"expected initial skill body in request"
|
|
);
|
|
|
|
write_skill(test.codex_home_path(), "demo", "demo skill", skill_v2);
|
|
|
|
let saw_skills_update = timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
match test.codex.next_event().await {
|
|
Ok(event) => {
|
|
if matches!(event.msg, EventMsg::SkillsUpdateAvailable) {
|
|
break;
|
|
}
|
|
}
|
|
Err(err) => panic!("event stream ended unexpectedly: {err}"),
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
if saw_skills_update.is_err() {
|
|
// Some environments do not reliably surface file watcher events for
|
|
// skill changes. Clear the cache explicitly so we can still validate
|
|
// that the updated skill body is injected on the next turn.
|
|
test.thread_manager.skills_manager().clear_cache();
|
|
}
|
|
|
|
submit_skill_turn(&test, skill_path.clone(), "please use $demo again").await?;
|
|
let last_request = responses
|
|
.last_request()
|
|
.expect("request captured after skill update");
|
|
|
|
assert!(
|
|
contains_skill_body(&last_request, skill_v2),
|
|
"expected updated skill body after reload"
|
|
);
|
|
|
|
Ok(())
|
|
}
|