mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
Merge remote-tracking branch 'origin/main' into etraut/extract-goal-extension
# Conflicts: # codex-rs/ext/goal/tests/goal_extension_backend.rs
This commit is contained in:
@@ -192,7 +192,7 @@ Example with notification opt-out:
|
||||
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
|
||||
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
|
||||
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
|
||||
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
|
||||
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
|
||||
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment.
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
|
||||
@@ -54,6 +54,7 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
|
||||
"mentions_v2",
|
||||
"plugins",
|
||||
"remote_control",
|
||||
"remote_plugin",
|
||||
"tool_suggest",
|
||||
"tool_call_mcp_elicitation",
|
||||
];
|
||||
|
||||
@@ -340,6 +340,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
BTreeMap::from([
|
||||
("memories".to_string(), true),
|
||||
("plugins".to_string(), true),
|
||||
("remote_plugin".to_string(), true),
|
||||
("tool_suggest".to_string(), true),
|
||||
("tool_call_mcp_elicitation".to_string(), false),
|
||||
]),
|
||||
@@ -352,6 +353,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
enablement: BTreeMap::from([
|
||||
("memories".to_string(), true),
|
||||
("plugins".to_string(), true),
|
||||
("remote_plugin".to_string(), true),
|
||||
("tool_suggest".to_string(), true),
|
||||
("tool_call_mcp_elicitation".to_string(), false),
|
||||
]),
|
||||
@@ -381,6 +383,13 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
.and_then(|features| features.get("plugins")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("remote_plugin")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
@@ -474,7 +483,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -
|
||||
);
|
||||
assert!(
|
||||
error.message.contains(
|
||||
"apps, memories, mentions_v2, plugins, remote_control, tool_suggest, tool_call_mcp_elicitation"
|
||||
"apps, memories, mentions_v2, plugins, remote_control, remote_plugin, tool_suggest, tool_call_mcp_elicitation"
|
||||
),
|
||||
"{}",
|
||||
error.message
|
||||
|
||||
@@ -223,6 +223,8 @@ pub struct TuiVimNormalKeymap {
|
||||
pub delete_char: Option<KeybindingsSpec>,
|
||||
/// Delete from cursor to end of line (`D`).
|
||||
pub delete_to_line_end: Option<KeybindingsSpec>,
|
||||
/// Change from cursor to end of line and enter insert mode (`C`).
|
||||
pub change_to_line_end: Option<KeybindingsSpec>,
|
||||
/// Yank the entire line (`Y`).
|
||||
pub yank_line: Option<KeybindingsSpec>,
|
||||
/// Paste after cursor (`p`).
|
||||
|
||||
@@ -2834,6 +2834,7 @@
|
||||
"append_after_cursor": null,
|
||||
"append_line_end": null,
|
||||
"cancel_operator": null,
|
||||
"change_to_line_end": null,
|
||||
"delete_char": null,
|
||||
"delete_to_line_end": null,
|
||||
"enter_insert": null,
|
||||
@@ -3500,6 +3501,7 @@
|
||||
"append_after_cursor": null,
|
||||
"append_line_end": null,
|
||||
"cancel_operator": null,
|
||||
"change_to_line_end": null,
|
||||
"delete_char": null,
|
||||
"delete_to_line_end": null,
|
||||
"enter_insert": null,
|
||||
@@ -3765,6 +3767,14 @@
|
||||
],
|
||||
"description": "Cancel a pending operator and return to normal mode."
|
||||
},
|
||||
"change_to_line_end": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Change from cursor to end of line and enter insert mode (`C`)."
|
||||
},
|
||||
"delete_char": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -1113,6 +1113,108 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_limit() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (parent_thread_id, parent_thread) = harness.start_thread().await;
|
||||
let startup_turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(
|
||||
startup_turn_context.as_ref(),
|
||||
&[ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "parent startup developer context".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
}],
|
||||
)
|
||||
.await;
|
||||
parent_thread
|
||||
.inject_user_message_without_turn("current parent task".to_string())
|
||||
.await;
|
||||
let spawn_turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-last-n-under-limit".to_string();
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(
|
||||
spawn_turn_context.as_ref(),
|
||||
&[spawn_agent_call(&parent_spawn_call_id)],
|
||||
)
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.ensure_rollout_materialized()
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.flush_rollout()
|
||||
.await
|
||||
.expect("parent rollout should flush");
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_metadata(
|
||||
harness.config.clone(),
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
|
||||
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("bounded forked spawn should drop startup prefix")
|
||||
.thread_id;
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should be registered");
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
assert!(
|
||||
history_contains_text(history.raw_items(), "current parent task"),
|
||||
"bounded fork should retain the requested recent parent turn"
|
||||
);
|
||||
assert!(
|
||||
!history_contains_text(history.raw_items(), "parent startup developer context"),
|
||||
"bounded fork should drop parent startup context even when fewer turns exist than requested"
|
||||
);
|
||||
assert!(
|
||||
child_thread
|
||||
.codex
|
||||
.session
|
||||
.reference_context_item()
|
||||
.await
|
||||
.is_none(),
|
||||
"bounded forked child should still rebuild context after truncating the cached prefix"
|
||||
);
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_live_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should submit");
|
||||
let _ = parent_thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
|
||||
@@ -905,6 +905,7 @@ pub(crate) fn build_guardian_review_session_config(
|
||||
.map(guardian_policy_prompt_with_config)
|
||||
.unwrap_or_else(guardian_policy_prompt),
|
||||
);
|
||||
guardian_config.notify = None;
|
||||
guardian_config.developer_instructions = None;
|
||||
guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
|
||||
guardian_config
|
||||
|
||||
@@ -2215,6 +2215,25 @@ async fn guardian_review_session_config_clears_parent_developer_instructions() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_clears_legacy_notify() {
|
||||
let mut parent_config = test_config().await;
|
||||
parent_config.notify = Some(vec![
|
||||
"/path/to/notify".to_string(),
|
||||
"turn-ended".to_string(),
|
||||
]);
|
||||
|
||||
let guardian_config = build_guardian_review_session_config_for_test(
|
||||
&parent_config,
|
||||
/*live_network_config*/ None,
|
||||
"active-model",
|
||||
/*reasoning_effort*/ None,
|
||||
)
|
||||
.expect("guardian config");
|
||||
|
||||
assert_eq!(guardian_config.notify, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_uses_live_network_proxy_state() {
|
||||
let mut parent_config = test_config().await;
|
||||
|
||||
@@ -12,7 +12,7 @@ pub(super) fn schedule_turn(session: &Arc<Session>) {
|
||||
if session
|
||||
.services
|
||||
.extensions
|
||||
.idle_turn_contributors()
|
||||
.thread_lifecycle_contributors()
|
||||
.is_empty()
|
||||
{
|
||||
return;
|
||||
@@ -77,14 +77,15 @@ async fn has_active_or_pending_work(session: &Session) -> bool {
|
||||
|
||||
async fn next_idle_turn_input(session: &Session) -> Option<Vec<TurnInput>> {
|
||||
let collaboration_mode = session.collaboration_mode().await;
|
||||
for contributor in session.services.extensions.idle_turn_contributors() {
|
||||
for contributor in session.services.extensions.thread_lifecycle_contributors() {
|
||||
let Some(items) = contributor
|
||||
.next_idle_turn(codex_extension_api::IdleTurnInput {
|
||||
.on_thread_idle(codex_extension_api::ThreadIdleInput {
|
||||
collaboration_mode: &collaboration_mode,
|
||||
session_store: &session.services.session_extension_data,
|
||||
thread_store: &session.services.thread_extension_data,
|
||||
})
|
||||
.await
|
||||
.map(|request| request.items)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -130,7 +130,8 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
|
||||
|
||||
/// Return a suffix of `items` that keeps the last `n_from_end` fork turns.
|
||||
///
|
||||
/// If fewer than or equal to `n_from_end` fork turns exist, this returns the full rollout.
|
||||
/// If fewer than or equal to `n_from_end` fork turns exist, this keeps from the first fork-turn
|
||||
/// boundary and still drops pre-turn startup context.
|
||||
pub(crate) fn truncate_rollout_to_last_n_fork_turns(
|
||||
items: &[RolloutItem],
|
||||
n_from_end: usize,
|
||||
@@ -140,11 +141,14 @@ pub(crate) fn truncate_rollout_to_last_n_fork_turns(
|
||||
}
|
||||
|
||||
let fork_turn_positions = fork_turn_positions_in_rollout(items);
|
||||
if fork_turn_positions.len() <= n_from_end {
|
||||
return items.to_vec();
|
||||
}
|
||||
|
||||
let keep_idx = fork_turn_positions[fork_turn_positions.len() - n_from_end];
|
||||
let Some(keep_idx) = fork_turn_positions
|
||||
.len()
|
||||
.checked_sub(n_from_end)
|
||||
.map(|position| fork_turn_positions[position])
|
||||
.or_else(|| fork_turn_positions.first().copied())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
items[keep_idx..].to_vec()
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,17 @@ fn assistant_msg(text: &str) -> ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn developer_msg(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn inter_agent_msg(text: &str, trigger_turn: bool) -> ResponseItem {
|
||||
let communication = InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
@@ -197,6 +208,23 @@ fn truncates_rollout_to_last_n_fork_turns_counts_trigger_turn_messages() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_rollout_to_last_n_fork_turns_drops_startup_prefix_even_when_under_limit() {
|
||||
let rollout = vec![
|
||||
RolloutItem::ResponseItem(developer_msg("startup developer context")),
|
||||
RolloutItem::ResponseItem(user_msg("current task")),
|
||||
RolloutItem::ResponseItem(assistant_msg("answer")),
|
||||
];
|
||||
|
||||
let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 2);
|
||||
let expected = rollout[1..].to_vec();
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&truncated).unwrap(),
|
||||
serde_json::to_value(&expected).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_rollout_to_last_n_fork_turns_applies_thread_rollback_markers() {
|
||||
let rollout = vec![
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_core::sandboxing::SandboxPermissions;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::fs_wait;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_sandbox;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
let notify_dir = TempDir::new()?;
|
||||
let notify_script = notify_dir.path().join("notify.sh");
|
||||
fs::write(
|
||||
¬ify_script,
|
||||
r#"#!/bin/bash
|
||||
set -e
|
||||
payload_path="$(dirname "${0}")/notify.jsonl"
|
||||
printf '%s\n' "${@: -1}" >> "${payload_path}""#,
|
||||
)?;
|
||||
fs::set_permissions(¬ify_script, fs::Permissions::from_mode(0o755))?;
|
||||
let notify_file = notify_dir.path().join("notify.jsonl");
|
||||
let notify_script_str = notify_script.to_str().unwrap().to_string();
|
||||
let sandbox_policy_for_config = sandbox_policy.clone();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.notify = Some(vec![notify_script_str]);
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config
|
||||
.set_legacy_sandbox_policy(sandbox_policy_for_config)
|
||||
.expect("set sandbox policy");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let output_file = test.cwd.path().join("guardian-review-notify.txt");
|
||||
let command = format!("printf guardian-approved > {}", output_file.display());
|
||||
let tool_args = json!({
|
||||
"cmd": command,
|
||||
"yield_time_ms": 1_000_u64,
|
||||
"sandbox_permissions": SandboxPermissions::RequireEscalated,
|
||||
"justification": "Exercise Guardian approval routing.",
|
||||
});
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-parent-tool"),
|
||||
ev_function_call(
|
||||
"exec-call",
|
||||
"exec_command",
|
||||
&serde_json::to_string(&tool_args)?,
|
||||
),
|
||||
ev_completed("resp-parent-tool"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-guardian-review"),
|
||||
ev_assistant_message(
|
||||
"msg-guardian-review",
|
||||
&json!({
|
||||
"risk_level": "low",
|
||||
"user_authorization": "high",
|
||||
"outcome": "allow",
|
||||
"rationale": "The command writes a marker file in the workspace.",
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
ev_completed("resp-guardian-review"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-parent-done"),
|
||||
ev_assistant_message("msg-parent-done", "done"),
|
||||
ev_completed("resp-parent-done"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
environments: None,
|
||||
items: vec![UserInput::Text {
|
||||
text: "run a command that requires Guardian review".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
cwd: Some(test.cwd.path().to_path_buf()),
|
||||
approval_policy: Some(approval_policy),
|
||||
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
let guardian_request = responses
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|request| request.body_contains_text("Exercise Guardian approval routing."))
|
||||
.expect("expected Guardian review request");
|
||||
assert!(guardian_request.body_contains_text(&command));
|
||||
|
||||
fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let notify_payload_raw = tokio::fs::read_to_string(¬ify_file).await?;
|
||||
let payloads: Vec<Value> = notify_payload_raw
|
||||
.lines()
|
||||
.map(serde_json::from_str::<Value>)
|
||||
.collect::<std::result::Result<_, _>>()?;
|
||||
|
||||
assert_eq!(
|
||||
payloads.len(),
|
||||
1,
|
||||
"unexpected notify payloads: {payloads:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
payloads[0]["input-messages"],
|
||||
json!(["run a command that requires Guardian review"])
|
||||
);
|
||||
assert_eq!(payloads[0]["last-assistant-message"], json!("done"));
|
||||
assert!(
|
||||
!notify_payload_raw.contains(
|
||||
"The following is the Codex agent history whose request action you are assessing."
|
||||
),
|
||||
"Guardian review transcript leaked into legacy notify payload: {notify_payload_raw}"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&output_file)?, "guardian-approved");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -50,6 +50,8 @@ mod deprecation_notice;
|
||||
mod exec;
|
||||
mod exec_policy;
|
||||
mod fork_thread;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod guardian_review;
|
||||
mod hierarchical_agents;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod hooks;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
//! Contributor traits and inputs that let extensions hook into host-owned workflows.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
@@ -22,6 +19,8 @@ mod turn_lifecycle;
|
||||
|
||||
pub use prompt::PromptFragment;
|
||||
pub use prompt::PromptSlot;
|
||||
pub use thread_lifecycle::ThreadIdleInput;
|
||||
pub use thread_lifecycle::ThreadIdleRequest;
|
||||
pub use thread_lifecycle::ThreadResumeInput;
|
||||
pub use thread_lifecycle::ThreadStartInput;
|
||||
pub use thread_lifecycle::ThreadStopInput;
|
||||
@@ -58,6 +57,15 @@ pub trait ThreadLifecycleContributor<C: Sync>: Send + Sync {
|
||||
/// Called after the host constructs a runtime from persisted history.
|
||||
async fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {}
|
||||
|
||||
/// Called after the host has drained immediately pending thread work.
|
||||
///
|
||||
/// Implementations may use host capabilities captured by the extension to
|
||||
/// submit follow-up input. The host remains responsible for deciding
|
||||
/// whether that input starts a turn, is queued, or is ignored.
|
||||
async fn on_thread_idle(&self, _input: ThreadIdleInput<'_>) -> Option<ThreadIdleRequest> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Called before the host drops the thread runtime and thread-scoped store.
|
||||
async fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {}
|
||||
}
|
||||
@@ -149,26 +157,6 @@ pub struct ToolContributionInput<'a> {
|
||||
pub persistent_thread: bool,
|
||||
}
|
||||
|
||||
/// Context supplied when the host is idle and extensions may request work.
|
||||
pub struct IdleTurnInput<'a> {
|
||||
/// Effective collaboration mode for the next default turn.
|
||||
pub collaboration_mode: &'a CollaborationMode,
|
||||
/// Store scoped to the host session runtime.
|
||||
pub session_store: &'a ExtensionData,
|
||||
/// Store scoped to this thread runtime.
|
||||
pub thread_store: &'a ExtensionData,
|
||||
}
|
||||
|
||||
pub type IdleTurnFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Option<Vec<ResponseInputItem>>> + Send + 'a>>;
|
||||
|
||||
/// Extension contribution that can request host-owned work while a thread is idle.
|
||||
pub trait IdleTurnContributor: Send + Sync {
|
||||
fn next_idle_turn<'a>(&'a self, _input: IdleTurnInput<'a>) -> IdleTurnFuture<'a> {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
}
|
||||
|
||||
/// Contributor for host-owned tool lifecycle gates.
|
||||
///
|
||||
/// Implementations should use these callbacks to observe tool execution without
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
|
||||
use crate::ExtensionData;
|
||||
|
||||
/// Input supplied when the host starts a runtime for a thread.
|
||||
@@ -18,6 +21,21 @@ pub struct ThreadResumeInput<'a> {
|
||||
pub thread_store: &'a ExtensionData,
|
||||
}
|
||||
|
||||
/// Input supplied when the host has no immediately pending thread work.
|
||||
pub struct ThreadIdleInput<'a> {
|
||||
/// Effective collaboration mode for the next default turn.
|
||||
pub collaboration_mode: &'a CollaborationMode,
|
||||
/// Store scoped to the host session runtime.
|
||||
pub session_store: &'a ExtensionData,
|
||||
/// Store scoped to this thread runtime.
|
||||
pub thread_store: &'a ExtensionData,
|
||||
}
|
||||
|
||||
/// Extension request to start a new idle turn.
|
||||
pub struct ThreadIdleRequest {
|
||||
pub items: Vec<ResponseInputItem>,
|
||||
}
|
||||
|
||||
/// Input supplied when the host stops a thread runtime.
|
||||
pub struct ThreadStopInput<'a> {
|
||||
/// Store scoped to the host session runtime.
|
||||
|
||||
@@ -25,11 +25,10 @@ pub use codex_tools::parse_tool_input_schema_without_compaction;
|
||||
pub use contributors::ApprovalReviewContributor;
|
||||
pub use contributors::ConfigContributor;
|
||||
pub use contributors::ContextContributor;
|
||||
pub use contributors::IdleTurnContributor;
|
||||
pub use contributors::IdleTurnFuture;
|
||||
pub use contributors::IdleTurnInput;
|
||||
pub use contributors::PromptFragment;
|
||||
pub use contributors::PromptSlot;
|
||||
pub use contributors::ThreadIdleInput;
|
||||
pub use contributors::ThreadIdleRequest;
|
||||
pub use contributors::ThreadLifecycleContributor;
|
||||
pub use contributors::ThreadResumeInput;
|
||||
pub use contributors::ThreadStartInput;
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::ConfigContributor;
|
||||
use crate::ContextContributor;
|
||||
use crate::ExtensionData;
|
||||
use crate::ExtensionEventSink;
|
||||
use crate::IdleTurnContributor;
|
||||
use crate::NoopExtensionEventSink;
|
||||
use crate::ThreadLifecycleContributor;
|
||||
use crate::TokenUsageContributor;
|
||||
@@ -27,7 +26,6 @@ pub struct ExtensionRegistryBuilder<C: Sync> {
|
||||
tool_contributors: Vec<Arc<dyn ToolContributor>>,
|
||||
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
|
||||
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
|
||||
idle_turn_contributors: Vec<Arc<dyn IdleTurnContributor>>,
|
||||
approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>,
|
||||
}
|
||||
|
||||
@@ -44,7 +42,6 @@ impl<C: Sync> Default for ExtensionRegistryBuilder<C> {
|
||||
tool_contributors: Vec::new(),
|
||||
tool_lifecycle_contributors: Vec::new(),
|
||||
turn_item_contributors: Vec::new(),
|
||||
idle_turn_contributors: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,11 +113,6 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
|
||||
self.turn_item_contributors.push(contributor);
|
||||
}
|
||||
|
||||
/// Registers one idle-turn contributor.
|
||||
pub fn idle_turn_contributor(&mut self, contributor: Arc<dyn IdleTurnContributor>) {
|
||||
self.idle_turn_contributors.push(contributor);
|
||||
}
|
||||
|
||||
/// Finishes construction and returns the immutable registry.
|
||||
pub fn build(self) -> ExtensionRegistry<C> {
|
||||
ExtensionRegistry {
|
||||
@@ -134,7 +126,6 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
|
||||
tool_contributors: self.tool_contributors,
|
||||
tool_lifecycle_contributors: self.tool_lifecycle_contributors,
|
||||
turn_item_contributors: self.turn_item_contributors,
|
||||
idle_turn_contributors: self.idle_turn_contributors,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +141,6 @@ pub struct ExtensionRegistry<C: Sync> {
|
||||
tool_contributors: Vec<Arc<dyn ToolContributor>>,
|
||||
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
|
||||
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
|
||||
idle_turn_contributors: Vec<Arc<dyn IdleTurnContributor>>,
|
||||
approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>,
|
||||
}
|
||||
|
||||
@@ -219,11 +209,6 @@ impl<C: Sync> ExtensionRegistry<C> {
|
||||
pub fn turn_item_contributors(&self) -> &[Arc<dyn TurnItemContributor>] {
|
||||
&self.turn_item_contributors
|
||||
}
|
||||
|
||||
/// Returns the registered idle-turn contributors.
|
||||
pub fn idle_turn_contributors(&self) -> &[Arc<dyn IdleTurnContributor>] {
|
||||
&self.idle_turn_contributors
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an empty shared registry for hosts that do not register contributions.
|
||||
|
||||
@@ -83,6 +83,17 @@ impl GoalAccountingState {
|
||||
self.inner().current_turn_id.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn turn_is_current_active_goal(&self, turn_id: &str) -> bool {
|
||||
let inner = self.inner();
|
||||
if inner.current_turn_id.as_deref() != Some(turn_id) {
|
||||
return false;
|
||||
}
|
||||
let Some(turn) = inner.turns.get(turn_id) else {
|
||||
return false;
|
||||
};
|
||||
turn.account_tokens && turn.active_goal_id.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn record_token_usage(
|
||||
&self,
|
||||
turn_id: impl Into<String>,
|
||||
|
||||
@@ -7,9 +7,8 @@ use codex_extension_api::ConfigContributor;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionEventSink;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::IdleTurnContributor;
|
||||
use codex_extension_api::IdleTurnFuture;
|
||||
use codex_extension_api::IdleTurnInput;
|
||||
use codex_extension_api::ThreadIdleInput;
|
||||
use codex_extension_api::ThreadIdleRequest;
|
||||
use codex_extension_api::ThreadLifecycleContributor;
|
||||
use codex_extension_api::ThreadResumeInput;
|
||||
use codex_extension_api::ThreadStartInput;
|
||||
@@ -128,6 +127,21 @@ where
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_thread_idle(&self, input: ThreadIdleInput<'_>) -> Option<ThreadIdleRequest> {
|
||||
let runtime = goal_runtime_handle(input.thread_store)?;
|
||||
match runtime
|
||||
.idle_continuation_items(input.collaboration_mode.mode)
|
||||
.await
|
||||
{
|
||||
Ok(Some(items)) => Some(ThreadIdleRequest { items }),
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to request idle goal continuation: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> ConfigContributor<C> for GoalExtension<C>
|
||||
@@ -417,28 +431,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> IdleTurnContributor for GoalExtension<C>
|
||||
where
|
||||
C: Send + Sync + 'static,
|
||||
{
|
||||
fn next_idle_turn<'a>(&'a self, input: IdleTurnInput<'a>) -> IdleTurnFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let runtime = goal_runtime_handle(input.thread_store)?;
|
||||
match runtime
|
||||
.idle_continuation_items(input.collaboration_mode.mode)
|
||||
.await
|
||||
{
|
||||
Ok(Some(items)) => Some(items),
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to request idle goal continuation: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_with_backend<C>(
|
||||
registry: &mut ExtensionRegistryBuilder<C>,
|
||||
state_dbs: Arc<codex_state::StateRuntime>,
|
||||
@@ -460,8 +452,7 @@ pub fn install_with_backend<C>(
|
||||
registry.turn_lifecycle_contributor(extension.clone());
|
||||
registry.token_usage_contributor(extension.clone());
|
||||
registry.tool_lifecycle_contributor(extension.clone());
|
||||
registry.tool_contributor(extension.clone());
|
||||
registry.idle_turn_contributor(extension);
|
||||
registry.tool_contributor(extension);
|
||||
}
|
||||
|
||||
fn goal_runtime_handle(thread_store: &ExtensionData) -> Option<Arc<GoalRuntimeHandle>> {
|
||||
|
||||
@@ -177,6 +177,54 @@ impl GoalRuntimeHandle {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> {
|
||||
if !self.is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self
|
||||
.inner
|
||||
.accounting_state
|
||||
.turn_is_current_active_goal(turn_id)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let progress_event_id = format!("{turn_id}:usage-limit-progress");
|
||||
self.account_active_goal_progress(
|
||||
turn_id,
|
||||
progress_event_id.as_str(),
|
||||
codex_state::GoalAccountingMode::ActiveOnly,
|
||||
BudgetLimitedGoalDisposition::ClearActive,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let previous_status = self
|
||||
.current_goal_status_for_metrics(/*expected_goal_id*/ None)
|
||||
.await?;
|
||||
let Some(goal) = self
|
||||
.inner
|
||||
.state_dbs
|
||||
.thread_goals()
|
||||
.usage_limit_active_thread_goal(self.thread_id())
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
self.inner
|
||||
.metrics
|
||||
.record_terminal_if_status_changed(previous_status, &goal);
|
||||
self.inner.accounting_state.clear_active_goal();
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
self.inner.event_emitter.thread_goal_updated(
|
||||
format!("{turn_id}:usage-limit"),
|
||||
Some(turn_id.to_string()),
|
||||
goal,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn restore_after_resume(&self) -> Result<(), String> {
|
||||
if !self.is_enabled() {
|
||||
return Ok(());
|
||||
@@ -201,45 +249,6 @@ impl GoalRuntimeHandle {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> {
|
||||
if !self.is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.account_active_goal_progress(
|
||||
turn_id,
|
||||
&format!("{turn_id}:usage-limit"),
|
||||
codex_state::GoalAccountingMode::ActiveOnly,
|
||||
BudgetLimitedGoalDisposition::ClearActive,
|
||||
)
|
||||
.await?;
|
||||
let previous_status = self
|
||||
.current_goal_status_for_metrics(/*expected_goal_id*/ None)
|
||||
.await?;
|
||||
let Some(goal) = self
|
||||
.inner
|
||||
.state_dbs
|
||||
.thread_goals()
|
||||
.usage_limit_active_thread_goal(self.thread_id())
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
else {
|
||||
self.inner.accounting_state.finish_turn(turn_id);
|
||||
return Ok(());
|
||||
};
|
||||
self.inner
|
||||
.metrics
|
||||
.record_terminal_if_status_changed(previous_status, &goal);
|
||||
self.inner.accounting_state.finish_turn(turn_id);
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
self.inner.event_emitter.thread_goal_updated(
|
||||
format!("{turn_id}:usage-limit"),
|
||||
Some(turn_id.to_string()),
|
||||
goal,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn idle_continuation_items(
|
||||
&self,
|
||||
collaboration_mode: ModeKind,
|
||||
|
||||
@@ -8,7 +8,7 @@ use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionEventSink;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::FunctionCallError;
|
||||
use codex_extension_api::IdleTurnInput;
|
||||
use codex_extension_api::ThreadIdleInput;
|
||||
use codex_extension_api::ThreadResumeInput;
|
||||
use codex_extension_api::ThreadStartInput;
|
||||
use codex_extension_api::ToolCall;
|
||||
@@ -409,7 +409,7 @@ async fn usage_limit_turn_error_accounts_and_marks_goal_terminal() -> anyhow::Re
|
||||
assert_eq!(
|
||||
vec![
|
||||
CapturedGoalEvent {
|
||||
event_id: "turn-1:usage-limit".to_string(),
|
||||
event_id: "turn-1:usage-limit-progress".to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
status: ThreadGoalStatus::Active,
|
||||
tokens_used: 23,
|
||||
@@ -423,11 +423,34 @@ async fn usage_limit_turn_error_accounts_and_marks_goal_terminal() -> anyhow::Re
|
||||
],
|
||||
harness.sink.goal_events()
|
||||
);
|
||||
|
||||
harness
|
||||
.record_token_usage(
|
||||
"turn-1",
|
||||
&token_usage(
|
||||
/*input_tokens*/ 50, /*cached_input_tokens*/ 5,
|
||||
/*output_tokens*/ 20, /*reasoning_output_tokens*/ 0,
|
||||
/*total_tokens*/ 70,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
harness
|
||||
.notify_tool_finish("turn-1", "call-shell-after-usage-limit", "shell")
|
||||
.await;
|
||||
harness.stop_turn("turn-1").await;
|
||||
|
||||
let goal = runtime
|
||||
.thread_goals()
|
||||
.get_thread_goal(thread_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("goal should exist"))?;
|
||||
assert_eq!(23, goal.tokens_used);
|
||||
assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idle_turn_contributor_requests_hidden_goal_continuation() -> anyhow::Result<()> {
|
||||
async fn thread_idle_hook_requests_hidden_goal_continuation() -> anyhow::Result<()> {
|
||||
let runtime = test_runtime().await?;
|
||||
let thread_id = test_thread_id()?;
|
||||
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
|
||||
@@ -462,6 +485,159 @@ async fn idle_turn_contributor_requests_hidden_goal_continuation() -> anyhow::Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_limit_budget_limited_goal_accounts_remaining_progress() -> anyhow::Result<()> {
|
||||
let runtime = test_runtime().await?;
|
||||
let thread_id = test_thread_id()?;
|
||||
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
|
||||
let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?;
|
||||
harness.start_turn("turn-1", &TokenUsage::default()).await;
|
||||
|
||||
let tools = harness.tools();
|
||||
let create_tool = tool_by_name(&tools, "create_goal");
|
||||
create_tool
|
||||
.handle(tool_call(
|
||||
"create_goal",
|
||||
"call-create-goal",
|
||||
json!({
|
||||
"objective": "ship goal extension backend",
|
||||
"token_budget": 25,
|
||||
}),
|
||||
))
|
||||
.await?;
|
||||
|
||||
harness
|
||||
.record_token_usage(
|
||||
"turn-1",
|
||||
&token_usage(
|
||||
/*input_tokens*/ 20, /*cached_input_tokens*/ 5,
|
||||
/*output_tokens*/ 10, /*reasoning_output_tokens*/ 0,
|
||||
/*total_tokens*/ 30,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
harness
|
||||
.notify_tool_finish("turn-1", "call-shell", "shell")
|
||||
.await;
|
||||
harness.sink.clear();
|
||||
|
||||
harness
|
||||
.record_token_usage(
|
||||
"turn-1",
|
||||
&token_usage(
|
||||
/*input_tokens*/ 24, /*cached_input_tokens*/ 5,
|
||||
/*output_tokens*/ 16, /*reasoning_output_tokens*/ 0,
|
||||
/*total_tokens*/ 40,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
harness
|
||||
.runtime_handle()
|
||||
.usage_limit_active_goal_for_turn("turn-1")
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
let goal = runtime
|
||||
.thread_goals()
|
||||
.get_thread_goal(thread_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("goal should exist"))?;
|
||||
assert_eq!(35, goal.tokens_used);
|
||||
assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status);
|
||||
assert_eq!(
|
||||
vec![
|
||||
CapturedGoalEvent {
|
||||
event_id: "turn-1:usage-limit-progress".to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
status: ThreadGoalStatus::BudgetLimited,
|
||||
tokens_used: 35,
|
||||
},
|
||||
CapturedGoalEvent {
|
||||
event_id: "turn-1:usage-limit".to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
status: ThreadGoalStatus::UsageLimited,
|
||||
tokens_used: 35,
|
||||
},
|
||||
],
|
||||
harness.sink.goal_events()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_limit_plan_turn_does_not_stop_goal() -> anyhow::Result<()> {
|
||||
let runtime = test_runtime().await?;
|
||||
let thread_id = test_thread_id()?;
|
||||
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
|
||||
let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?;
|
||||
|
||||
let tools = harness.tools();
|
||||
let create_tool = tool_by_name(&tools, "create_goal");
|
||||
create_tool
|
||||
.handle(tool_call(
|
||||
"create_goal",
|
||||
"call-create-goal",
|
||||
json!({ "objective": "ship goal extension backend" }),
|
||||
))
|
||||
.await?;
|
||||
|
||||
harness
|
||||
.start_turn_with_mode("turn-plan", ModeKind::Plan, &TokenUsage::default())
|
||||
.await;
|
||||
harness.sink.clear();
|
||||
harness
|
||||
.runtime_handle()
|
||||
.usage_limit_active_goal_for_turn("turn-plan")
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
let goal = runtime
|
||||
.thread_goals()
|
||||
.get_thread_goal(thread_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("goal should exist"))?;
|
||||
assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status);
|
||||
assert_eq!(Vec::<CapturedGoalEvent>::new(), harness.sink.goal_events());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_limit_stale_turn_does_not_stop_current_goal() -> anyhow::Result<()> {
|
||||
let runtime = test_runtime().await?;
|
||||
let thread_id = test_thread_id()?;
|
||||
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
|
||||
let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?;
|
||||
harness.start_turn("turn-1", &TokenUsage::default()).await;
|
||||
|
||||
let tools = harness.tools();
|
||||
let create_tool = tool_by_name(&tools, "create_goal");
|
||||
create_tool
|
||||
.handle(tool_call(
|
||||
"create_goal",
|
||||
"call-create-goal",
|
||||
json!({ "objective": "ship goal extension backend" }),
|
||||
))
|
||||
.await?;
|
||||
harness.stop_turn("turn-1").await;
|
||||
harness.start_turn("turn-2", &TokenUsage::default()).await;
|
||||
harness.sink.clear();
|
||||
|
||||
harness
|
||||
.runtime_handle()
|
||||
.usage_limit_active_goal_for_turn("turn-1")
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
let goal = runtime
|
||||
.thread_goals()
|
||||
.get_thread_goal(thread_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("goal should exist"))?;
|
||||
assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status);
|
||||
assert_eq!(Vec::<CapturedGoalEvent>::new(), harness.sink.goal_events());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_goal_can_block_and_accounts_final_progress() -> anyhow::Result<()> {
|
||||
let runtime = test_runtime().await?;
|
||||
@@ -815,8 +991,14 @@ impl GoalExtensionHarness {
|
||||
}
|
||||
|
||||
async fn start_turn(&self, turn_id: &str, usage: &TokenUsage) {
|
||||
self.start_turn_with_mode(turn_id, ModeKind::Default, usage)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn start_turn_with_mode(&self, turn_id: &str, mode: ModeKind, usage: &TokenUsage) {
|
||||
let turn_store = ExtensionData::new(turn_id);
|
||||
let collaboration_mode = default_collaboration_mode();
|
||||
let mut collaboration_mode = default_collaboration_mode();
|
||||
collaboration_mode.mode = mode;
|
||||
for contributor in self.registry.turn_lifecycle_contributors() {
|
||||
contributor
|
||||
.on_turn_start(TurnStartInput {
|
||||
@@ -911,16 +1093,16 @@ impl GoalExtensionHarness {
|
||||
async fn next_idle_turn(&self, mode: ModeKind) -> Option<Vec<ResponseInputItem>> {
|
||||
let mut collaboration_mode = default_collaboration_mode();
|
||||
collaboration_mode.mode = mode;
|
||||
for contributor in self.registry.idle_turn_contributors() {
|
||||
for contributor in self.registry.thread_lifecycle_contributors() {
|
||||
if let Some(request) = contributor
|
||||
.next_idle_turn(IdleTurnInput {
|
||||
.on_thread_idle(ThreadIdleInput {
|
||||
collaboration_mode: &collaboration_mode,
|
||||
session_store: &self.session_store,
|
||||
thread_store: &self.thread_store,
|
||||
})
|
||||
.await
|
||||
{
|
||||
return Some(request);
|
||||
return Some(request.items);
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
@@ -7,11 +7,10 @@ use super::auth::BedrockAuthMethod;
|
||||
use super::auth::resolve_auth_method;
|
||||
|
||||
const BEDROCK_MANTLE_SERVICE_NAME: &str = "bedrock-mantle";
|
||||
const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 13] = [
|
||||
const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 12] = [
|
||||
"us-east-2",
|
||||
"us-east-1",
|
||||
"us-west-2",
|
||||
"us-gov-west-1",
|
||||
"ap-southeast-3",
|
||||
"ap-south-1",
|
||||
"ap-northeast-1",
|
||||
@@ -73,10 +72,6 @@ mod tests {
|
||||
base_url("ap-northeast-1").expect("supported region"),
|
||||
"https://bedrock-mantle.ap-northeast-1.api.aws/openai/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
base_url("us-gov-west-1").expect("supported region"),
|
||||
"https://bedrock-mantle.us-gov-west-1.api.aws/openai/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -400,16 +400,16 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn refresh_plugin_mentions(&mut self, app_server: &AppServerSession) {
|
||||
let config = self.config.clone();
|
||||
let cwd = self.config.cwd.to_path_buf();
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
if !self.config.features.enabled(Feature::Plugins) {
|
||||
app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None });
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
match fetch_plugin_mentions(request_handle, config.cwd.to_path_buf()).await {
|
||||
match fetch_plugin_mentions(request_handle, cwd).await {
|
||||
Ok(plugins) => {
|
||||
app_event_tx.send(AppEvent::PluginMentionsLoaded {
|
||||
plugins: Some(plugins),
|
||||
|
||||
@@ -506,9 +506,6 @@ impl App {
|
||||
self.chat_widget
|
||||
.on_marketplace_add_loaded(cwd.clone(), source, result);
|
||||
if add_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(error = %err, "failed to refresh config after marketplace add");
|
||||
}
|
||||
self.fetch_plugins_list(app_server, cwd);
|
||||
}
|
||||
}
|
||||
@@ -516,14 +513,7 @@ impl App {
|
||||
let marketplace_contents_changed =
|
||||
matches!(&result, Ok(response) if !response.upgraded_roots.is_empty());
|
||||
if marketplace_contents_changed {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"failed to refresh config after marketplace upgrade"
|
||||
);
|
||||
}
|
||||
self.chat_widget.refresh_plugin_mentions();
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
self.refresh_plugin_mentions_after_config_write();
|
||||
}
|
||||
self.chat_widget
|
||||
.on_marketplace_upgrade_loaded(cwd.clone(), result);
|
||||
@@ -558,11 +548,7 @@ impl App {
|
||||
);
|
||||
if remove_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path()
|
||||
{
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(error = %err, "failed to refresh config after marketplace remove");
|
||||
}
|
||||
self.chat_widget.refresh_plugin_mentions();
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
self.refresh_plugin_mentions_after_config_write();
|
||||
self.fetch_plugins_list(app_server, cwd);
|
||||
}
|
||||
}
|
||||
@@ -609,11 +595,7 @@ impl App {
|
||||
} => {
|
||||
let install_succeeded = result.is_ok();
|
||||
if install_succeeded {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(error = %err, "failed to refresh config after plugin install");
|
||||
}
|
||||
self.chat_widget.refresh_plugin_mentions();
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
self.refresh_plugin_mentions_after_config_write();
|
||||
}
|
||||
let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded(
|
||||
cwd.clone(),
|
||||
@@ -665,14 +647,7 @@ impl App {
|
||||
self.pending_plugin_enabled_writes.remove(&plugin_id);
|
||||
let update_succeeded = result.is_ok();
|
||||
if update_succeeded {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"failed to refresh config after plugin toggle"
|
||||
);
|
||||
}
|
||||
self.chat_widget.refresh_plugin_mentions();
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
self.refresh_plugin_mentions_after_config_write();
|
||||
}
|
||||
self.chat_widget
|
||||
.on_plugin_enabled_set(cwd, plugin_id, enabled, result);
|
||||
@@ -1303,14 +1278,7 @@ impl App {
|
||||
} => {
|
||||
let uninstall_succeeded = result.is_ok();
|
||||
if uninstall_succeeded {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"failed to refresh config after plugin uninstall"
|
||||
);
|
||||
}
|
||||
self.chat_widget.refresh_plugin_mentions();
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
self.refresh_plugin_mentions_after_config_write();
|
||||
}
|
||||
self.chat_widget.on_plugin_uninstall_loaded(
|
||||
cwd.clone(),
|
||||
@@ -1705,14 +1673,6 @@ impl App {
|
||||
{
|
||||
Ok(()) => {
|
||||
self.chat_widget.update_skill_enabled(path, enabled);
|
||||
if !app_server.uses_remote_workspace()
|
||||
&& let Err(err) = self.refresh_in_memory_config_from_disk().await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"failed to refresh config after skill toggle"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let path_display = path.display();
|
||||
@@ -1749,11 +1709,6 @@ impl App {
|
||||
{
|
||||
Ok(_) => {
|
||||
self.chat_widget.update_connector_enabled(&id, enabled);
|
||||
if !app_server.uses_remote_workspace()
|
||||
&& let Err(err) = self.refresh_in_memory_config_from_disk().await
|
||||
{
|
||||
tracing::warn!(error = %err, "failed to refresh config after app toggle");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
@@ -2098,6 +2053,11 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_plugin_mentions_after_config_write(&mut self) {
|
||||
self.chat_widget.refresh_plugin_mentions();
|
||||
self.chat_widget.submit_op(AppCommand::reload_user_config());
|
||||
}
|
||||
|
||||
async fn apply_keymap_clear(&mut self, context: String, action: String) {
|
||||
let keymap_config = match crate::keymap_setup::keymap_without_custom_binding(
|
||||
&self.config.tui_keymap,
|
||||
|
||||
@@ -684,7 +684,6 @@ impl App {
|
||||
}
|
||||
AppCommand::ReloadUserConfig => {
|
||||
app_server.reload_user_config().await?;
|
||||
self.refresh_in_memory_config_from_disk().await?;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommand::OverrideTurnContext { .. } => {
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea.rs
|
||||
expression: "states.join(\"\\n\\n\")"
|
||||
---
|
||||
alpha beta gamma
|
||||
^
|
||||
|
||||
alpha beta gamma
|
||||
^
|
||||
|
||||
alpha beta gamma
|
||||
^
|
||||
@@ -755,7 +755,12 @@ impl TextArea {
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) {
|
||||
self.kill_to_end_of_line();
|
||||
self.vim_kill_to_end_of_line();
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.change_to_line_end.is_pressed(event) {
|
||||
self.vim_kill_to_end_of_line();
|
||||
self.vim_mode = VimMode::Insert;
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.yank_line.is_pressed(event) {
|
||||
@@ -914,7 +919,7 @@ impl TextArea {
|
||||
VimMotion::Down => self.move_cursor_down(),
|
||||
VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()),
|
||||
VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()),
|
||||
VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()),
|
||||
VimMotion::WordEnd => self.set_cursor(self.vim_word_end_exclusive()),
|
||||
VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()),
|
||||
VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()),
|
||||
}
|
||||
@@ -1007,6 +1012,13 @@ impl TextArea {
|
||||
}
|
||||
}
|
||||
|
||||
fn vim_kill_to_end_of_line(&mut self) {
|
||||
let eol = self.end_of_current_line();
|
||||
if self.cursor_pos < eol {
|
||||
self.kill_range(self.cursor_pos..eol);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kill_to_beginning_of_line(&mut self) {
|
||||
let bol = self.beginning_of_current_line();
|
||||
let range = if self.cursor_pos == bol {
|
||||
@@ -1734,7 +1746,11 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub(crate) fn end_of_next_word(&self) -> usize {
|
||||
let suffix = &self.text[self.cursor_pos..];
|
||||
self.end_of_next_word_from(self.cursor_pos)
|
||||
}
|
||||
|
||||
fn end_of_next_word_from(&self, cursor_pos: usize) -> usize {
|
||||
let suffix = &self.text[cursor_pos..];
|
||||
let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else {
|
||||
return self.text.len();
|
||||
};
|
||||
@@ -1742,16 +1758,16 @@ impl TextArea {
|
||||
let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())];
|
||||
let mut pieces = split_word_pieces(run).into_iter().peekable();
|
||||
let Some((start, piece)) = pieces.next() else {
|
||||
return self.cursor_pos + first_non_ws;
|
||||
return cursor_pos + first_non_ws;
|
||||
};
|
||||
let word_start = self.cursor_pos + first_non_ws + start;
|
||||
let word_start = cursor_pos + first_non_ws + start;
|
||||
let mut end = word_start + piece.len();
|
||||
if piece.chars().all(is_word_separator) {
|
||||
while let Some((idx, piece)) = pieces.peek() {
|
||||
if !piece.chars().all(is_word_separator) {
|
||||
break;
|
||||
}
|
||||
end = self.cursor_pos + first_non_ws + *idx + piece.len();
|
||||
end = cursor_pos + first_non_ws + *idx + piece.len();
|
||||
pieces.next();
|
||||
}
|
||||
}
|
||||
@@ -1759,8 +1775,22 @@ impl TextArea {
|
||||
self.adjust_pos_out_of_elements(end, /*prefer_start*/ false)
|
||||
}
|
||||
|
||||
fn vim_word_end_cursor(&self) -> usize {
|
||||
fn vim_word_end_exclusive(&self) -> usize {
|
||||
let end = self.end_of_next_word();
|
||||
let target = if end > self.cursor_pos {
|
||||
self.prev_atomic_boundary(end)
|
||||
} else {
|
||||
end
|
||||
};
|
||||
if target == self.cursor_pos && end < self.text.len() {
|
||||
self.end_of_next_word_from(end)
|
||||
} else {
|
||||
end
|
||||
}
|
||||
}
|
||||
|
||||
fn vim_word_end_cursor(&self) -> usize {
|
||||
let end = self.vim_word_end_exclusive();
|
||||
if end > self.cursor_pos {
|
||||
self.prev_atomic_boundary(end)
|
||||
} else {
|
||||
@@ -2275,6 +2305,60 @@ mod tests {
|
||||
assert_eq!(t.cursor(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_shift_c_changes_to_line_end_and_enters_insert_mode() {
|
||||
let mut t = ta_with("hello world\nnext line");
|
||||
t.set_cursor(/*pos*/ 6);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SHIFT));
|
||||
|
||||
assert_eq!(t.text(), "hello \nnext line");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
assert_eq!(t.cursor(), 6);
|
||||
assert_eq!(t.kill_buffer, "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_uppercase_c_changes_to_line_end() {
|
||||
let mut t = ta_with("hello world\nnext line");
|
||||
t.set_cursor(/*pos*/ 6);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello \nnext line");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
assert_eq!(t.cursor(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_d_at_line_end_does_not_remove_newline() {
|
||||
let mut t = ta_with("hello\nworld");
|
||||
t.set_cursor(/*pos*/ "hello".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello\nworld");
|
||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
||||
assert_eq!(t.kill_buffer, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_c_at_line_end_enters_insert_without_removing_newline() {
|
||||
let mut t = ta_with("hello\nworld");
|
||||
t.set_cursor(/*pos*/ "hello".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello\nworld");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
assert_eq!(t.cursor(), "hello".len());
|
||||
assert_eq!(t.kill_buffer, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_shift_o_opens_line_above_with_shift_only_binding() {
|
||||
let mut t = ta_with("hello\nworld");
|
||||
@@ -2348,6 +2432,62 @@ mod tests {
|
||||
assert_eq!(t.kill_buffer, "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_e_advances_from_each_word_end() {
|
||||
let mut t = ta_with("alpha beta gamma");
|
||||
t.set_cursor("alph".len()); // codespell:ignore alph
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
let mut states = Vec::new();
|
||||
|
||||
for _ in 0..3 {
|
||||
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
states.push(format!("{}\n{}^", t.text(), " ".repeat(t.cursor())));
|
||||
}
|
||||
|
||||
insta::assert_snapshot!("vim_e_advances_from_each_word_end", states.join("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_delete_to_word_end_advances_from_existing_word_end() {
|
||||
let mut t = ta_with("alpha beta gamma");
|
||||
t.set_cursor("alph".len()); // codespell:ignore alph
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "alph gamma"); // codespell:ignore alph
|
||||
assert_eq!(t.kill_buffer, "a beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_e_from_word_end_can_land_on_trailing_space() {
|
||||
let mut t = ta_with("alpha ");
|
||||
t.set_cursor("alph".len()); // codespell:ignore alph
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.cursor(), "alpha ".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_e_advances_across_atomic_element_word_ends() {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str("alpha ");
|
||||
t.insert_element("<element>");
|
||||
t.insert_str(" gamma");
|
||||
let element_start = t.elements[0].range.start;
|
||||
t.set_cursor("alph".len()); // codespell:ignore alph
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
assert_eq!(t.cursor(), element_start);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
assert_eq!(t.cursor(), "alpha <element> gamm".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_dollar_lands_on_line_end_character() {
|
||||
let mut t = ta_with("abc\n123");
|
||||
|
||||
@@ -156,6 +156,7 @@ pub(crate) struct VimNormalKeymap {
|
||||
pub(crate) move_line_end: Vec<KeyBinding>,
|
||||
pub(crate) delete_char: Vec<KeyBinding>,
|
||||
pub(crate) delete_to_line_end: Vec<KeyBinding>,
|
||||
pub(crate) change_to_line_end: Vec<KeyBinding>,
|
||||
pub(crate) yank_line: Vec<KeyBinding>,
|
||||
pub(crate) paste_after: Vec<KeyBinding>,
|
||||
pub(crate) start_delete_operator: Vec<KeyBinding>,
|
||||
@@ -469,6 +470,7 @@ impl RuntimeKeymap {
|
||||
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
|
||||
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
|
||||
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
|
||||
change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end),
|
||||
yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line),
|
||||
paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after),
|
||||
start_delete_operator: resolve_local!(
|
||||
@@ -786,6 +788,10 @@ impl RuntimeKeymap {
|
||||
shift(KeyCode::Char('d')),
|
||||
plain(KeyCode::Char('D'))
|
||||
],
|
||||
change_to_line_end: default_bindings![
|
||||
shift(KeyCode::Char('c')),
|
||||
plain(KeyCode::Char('C'))
|
||||
],
|
||||
yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))],
|
||||
paste_after: default_bindings![plain(KeyCode::Char('p'))],
|
||||
start_delete_operator: default_bindings![plain(KeyCode::Char('d'))],
|
||||
@@ -1176,6 +1182,10 @@ impl RuntimeKeymap {
|
||||
"delete_to_line_end",
|
||||
self.vim_normal.delete_to_line_end.as_slice(),
|
||||
),
|
||||
(
|
||||
"change_to_line_end",
|
||||
self.vim_normal.change_to_line_end.as_slice(),
|
||||
),
|
||||
("yank_line", self.vim_normal.yank_line.as_slice()),
|
||||
("paste_after", self.vim_normal.paste_after.as_slice()),
|
||||
(
|
||||
|
||||
@@ -135,6 +135,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."),
|
||||
action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."),
|
||||
action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."),
|
||||
action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."),
|
||||
action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."),
|
||||
action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."),
|
||||
action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."),
|
||||
@@ -263,6 +264,7 @@ pub(super) fn binding_slot<'a>(
|
||||
("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end),
|
||||
("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char),
|
||||
("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end),
|
||||
("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end),
|
||||
("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line),
|
||||
("vim_normal", "paste_after") => Some(&mut keymap.vim_normal.paste_after),
|
||||
("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator),
|
||||
@@ -373,6 +375,7 @@ pub(super) fn bindings_for_action<'a>(
|
||||
("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()),
|
||||
("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()),
|
||||
("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()),
|
||||
("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()),
|
||||
("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()),
|
||||
("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()),
|
||||
("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()),
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
93 actions, 1 customized, 2 unbound.
|
||||
94 actions, 1 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
94 actions, 0 customized, 3 unbound.
|
||||
95 actions, 0 customized, 3 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
|
||||
+2
-2
@@ -2,14 +2,14 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (93 selectable)
|
||||
tab: All (94 selectable)
|
||||
tab: Common (19 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (2) (2 selectable)
|
||||
tab: App (9 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (34 selectable)
|
||||
tab: Vim (35 selectable)
|
||||
tab: Navigation (20 selectable)
|
||||
tab: Approval (8 selectable)
|
||||
tab: Debug (1 selectable)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
93 actions, 0 customized, 2 unbound.
|
||||
94 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
|
||||
Navigation Approval Debug
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
93 actions, 0 customized, 2 unbound.
|
||||
94 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Release = "latest"
|
||||
[string]$Release = $env:CODEX_RELEASE
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Release)) {
|
||||
$Release = "latest"
|
||||
}
|
||||
|
||||
$NonInteractive = $env:CODEX_NON_INTERACTIVE -match "^(?i:1|true|yes)$"
|
||||
|
||||
function Write-Step {
|
||||
param(
|
||||
[string]$Message
|
||||
@@ -27,6 +34,10 @@ function Prompt-YesNo {
|
||||
[string]$Prompt
|
||||
)
|
||||
|
||||
if ($NonInteractive) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) {
|
||||
return $false
|
||||
}
|
||||
@@ -55,6 +66,16 @@ function Normalize-Version {
|
||||
return $RawVersion
|
||||
}
|
||||
|
||||
function Assert-ValidReleaseVersion {
|
||||
param(
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
if ($Version -cne "latest" -and $Version -cnotmatch "^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)(?:\.[0-9]+)?)?$") {
|
||||
throw "Invalid Codex release version: $Version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]."
|
||||
}
|
||||
}
|
||||
|
||||
function Find-ReleaseAssetMetadata {
|
||||
param(
|
||||
[string]$AssetName,
|
||||
@@ -141,6 +162,22 @@ function Path-Contains {
|
||||
return $false
|
||||
}
|
||||
|
||||
function Prepend-PathEntry {
|
||||
param(
|
||||
[string]$PathValue,
|
||||
[string]$Entry
|
||||
)
|
||||
|
||||
$needle = $Entry.TrimEnd("\")
|
||||
$segments = @($Entry)
|
||||
if (-not [string]::IsNullOrWhiteSpace($PathValue)) {
|
||||
$segments += $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries) |
|
||||
Where-Object { $_.TrimEnd("\") -ine $needle }
|
||||
}
|
||||
|
||||
return ($segments -join ";")
|
||||
}
|
||||
|
||||
function Invoke-WithInstallLock {
|
||||
param(
|
||||
[string]$LockPath,
|
||||
@@ -181,6 +218,7 @@ function Remove-StaleInstallArtifacts {
|
||||
|
||||
function Resolve-Version {
|
||||
$normalizedVersion = Normalize-Version -RawVersion $Release
|
||||
Assert-ValidReleaseVersion -Version $normalizedVersion
|
||||
if ($normalizedVersion -ne "latest") {
|
||||
return $normalizedVersion
|
||||
}
|
||||
@@ -191,7 +229,9 @@ function Resolve-Version {
|
||||
exit 1
|
||||
}
|
||||
|
||||
return (Normalize-Version -RawVersion $release.tag_name)
|
||||
$resolvedVersion = Normalize-Version -RawVersion $release.tag_name
|
||||
Assert-ValidReleaseVersion -Version $resolvedVersion
|
||||
return $resolvedVersion
|
||||
}
|
||||
|
||||
function Get-VersionFromBinary {
|
||||
@@ -839,7 +879,16 @@ try {
|
||||
Maybe-HandleConflictingInstall -Conflict $conflictingInstall
|
||||
|
||||
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) {
|
||||
$prioritizeVisibleBin = $null -ne $conflictingInstall
|
||||
if ($prioritizeVisibleBin) {
|
||||
$newUserPath = Prepend-PathEntry -PathValue $userPath -Entry $visibleBinDir
|
||||
if ($newUserPath -cne $userPath) {
|
||||
[Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
|
||||
Write-Step "PATH updated for future PowerShell sessions."
|
||||
} else {
|
||||
Write-Step "$visibleBinDir is already first on PATH."
|
||||
}
|
||||
} elseif (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) {
|
||||
if ([string]::IsNullOrWhiteSpace($userPath)) {
|
||||
$newUserPath = $visibleBinDir
|
||||
} else {
|
||||
@@ -854,7 +903,9 @@ if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) {
|
||||
Write-Step "PATH is already configured for future PowerShell sessions."
|
||||
}
|
||||
|
||||
if (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) {
|
||||
if ($prioritizeVisibleBin) {
|
||||
$env:Path = Prepend-PathEntry -PathValue $env:Path -Entry $visibleBinDir
|
||||
} elseif (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) {
|
||||
if ([string]::IsNullOrWhiteSpace($env:Path)) {
|
||||
$env:Path = $visibleBinDir
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
set -eu
|
||||
|
||||
RELEASE="latest"
|
||||
RELEASE="${CODEX_RELEASE:-latest}"
|
||||
NON_INTERACTIVE="${CODEX_NON_INTERACTIVE:-false}"
|
||||
|
||||
BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
BIN_PATH="$BIN_DIR/codex"
|
||||
@@ -46,6 +47,19 @@ normalize_version() {
|
||||
esac
|
||||
}
|
||||
|
||||
validate_version() {
|
||||
version="$1"
|
||||
|
||||
if [ "$version" = "latest" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$'; then
|
||||
echo "Invalid Codex release version: $version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -60,6 +74,10 @@ parse_args() {
|
||||
--help | -h)
|
||||
cat <<EOF
|
||||
Usage: install.sh [--release VERSION]
|
||||
|
||||
Environment:
|
||||
CODEX_RELEASE Version to install; overridden by --release.
|
||||
CODEX_NON_INTERACTIVE Set to 1, true, or yes to skip prompts.
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
@@ -259,6 +277,7 @@ require_command() {
|
||||
|
||||
resolve_version() {
|
||||
normalized_version="$(normalize_version "$RELEASE")"
|
||||
validate_version "$normalized_version"
|
||||
|
||||
if [ "$normalized_version" != "latest" ]; then
|
||||
printf '%s\n' "$normalized_version"
|
||||
@@ -273,6 +292,7 @@ resolve_version() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_version "$resolved"
|
||||
printf '%s\n' "$resolved"
|
||||
}
|
||||
|
||||
@@ -304,7 +324,9 @@ add_to_path() {
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$BIN_DIR:"*)
|
||||
return
|
||||
if [ -z "$conflict_manager" ]; then
|
||||
return
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -544,6 +566,12 @@ classify_existing_codex() {
|
||||
prompt_yes_no() {
|
||||
prompt="$1"
|
||||
|
||||
case "$NON_INTERACTIVE" in
|
||||
1 | [Tt][Rr][Uu][Ee] | [Yy][Ee][Ss])
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if ( : </dev/tty ) 2>/dev/null; then
|
||||
printf '%s [y/N] ' "$prompt" >/dev/tty
|
||||
if ! IFS= read -r answer </dev/tty; then
|
||||
|
||||
Reference in New Issue
Block a user