Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions crates/agent/src/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1018,11 +1018,16 @@ async fn resolve_response<T>(
}
}

/// Add the `ultrathink` keyword, which Claude Code honours anywhere in the
/// prompt. A message that starts with `/` is a slash command, and the CLI
/// expands one only at byte 0, so there the keyword trails the text instead.
fn turn_text(text: String, ultrathink: bool) -> String {
if ultrathink {
format!("Ultrathink:\n{text}")
} else {
if !ultrathink {
text
} else if text.starts_with('/') {
format!("{text}\n\nultrathink")
} else {
format!("Ultrathink:\n{text}")
}
}

Expand Down Expand Up @@ -3278,7 +3283,9 @@ fn map_usage(usage: &Value, model_usage: Option<&Value>) -> TokenUsage {

/// Parse Claude system-init `slash_commands` (→ [`ProviderCommandKind::Command`])
/// and `skills` (→ [`ProviderCommandKind::Skill`]) into [`ProviderCommand`]s.
/// Both are arrays of bare name strings; the CLI supplies no descriptions.
/// Both are arrays of bare name strings; the CLI supplies no descriptions. A
/// skill is listed under both, which is how the runtime knows a `$name`
/// mention of it is sent as the `/name` command the CLI expands.
fn parse_provider_commands(init: &Value) -> Vec<ProviderCommand> {
let mut out = Vec::new();
let mut push = |field: &str, kind: ProviderCommandKind| {
Expand Down
115 changes: 5 additions & 110 deletions crates/agent/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -924,19 +924,19 @@ async fn initialize_and_open_thread(
.or_else(|| opts.model.clone());

// Discover the session's skills for the composer's `$` menu. Supported since
// codex 0.144.1 (verified live). That protocol version has no request for
// custom prompts/commands, so those come from CODEX_HOME/prompts/*.md below.
// Either source failing is non-fatal so older builds still start.
// codex 0.144.1 (verified live); a failure is non-fatal so older builds
// still start. The app-server expands a `$name` mention in plain turn text
// itself; it has no request to list or run `~/.codex/prompts` files, so
// those are not offered.
let mut next_id = 3;
let mut provider_commands = match request_codex_skills(&opts.cwd, stdin, lines, next_id).await {
let provider_commands = match request_codex_skills(&opts.cwd, stdin, lines, next_id).await {
Ok(commands) => commands,
Err(err) => {
log::debug!("codex skills/list unavailable: {err}");
Vec::new()
}
};
next_id += 1;
provider_commands.extend(load_codex_prompts(&opts.launch_env));
Ok((thread_id, model, next_id, provider_commands))
}

Expand Down Expand Up @@ -1018,68 +1018,6 @@ fn parse_codex_skills(result: &Value) -> Vec<ProviderCommand> {
out
}

/// Resolve the Codex data home exactly as the spawned provider sees it: the
/// dedicated home override wins, then the inherited process variables, finally
/// `$HOME/.codex`.
fn codex_home(launch_env: &LaunchEnv) -> Option<PathBuf> {
launch_env
.home
.clone()
.or_else(|| std::env::var_os("CODEX_HOME").map(PathBuf::from))
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex")))
}

/// Map custom prompt files into slash commands. The app-server schema in Codex
/// 0.144.1 exposes no custom-prompt listing request, so the CLI's documented
/// on-disk prompt directory is the compatibility source.
fn load_codex_prompts(launch_env: &LaunchEnv) -> Vec<ProviderCommand> {
let Some(home) = codex_home(launch_env) else {
return Vec::new();
};
let prompts_dir = home.join("prompts");
let Ok(entries) = std::fs::read_dir(&prompts_dir) else {
return Vec::new();
};
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.is_file()
&& path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
})
.collect();
paths.sort();

paths
.into_iter()
.filter_map(|path| {
let name = path.file_stem()?.to_str()?.trim();
if name.is_empty() {
return None;
}
let description = match std::fs::read_to_string(&path) {
Ok(contents) => contents
.lines()
.next()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned),
Err(err) => {
log::debug!("could not read Codex prompt {}: {err}", path.display());
None
}
};
Some(ProviderCommand {
name: name.to_owned(),
description,
kind: ProviderCommandKind::Command,
})
})
.collect()
}

/// Render a JSON-RPC error object with everything the server sent — message,
/// code, and the `data` payload — falling back to the raw JSON when even the
/// message is missing. Losing any of it makes provider failures undiagnosable.
Expand Down Expand Up @@ -3571,49 +3509,6 @@ mod tests {
assert_eq!(commands[1].description.as_deref(), Some("charts"));
}

#[test]
fn codex_prompt_files_become_slash_commands_from_home_override() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let home = std::env::temp_dir().join(format!("agent-codex-prompts-{nonce}"));
let prompts = home.join("prompts");
std::fs::create_dir_all(&prompts).unwrap();
std::fs::write(
prompts.join("review.md"),
"Review the current diff\n\nDo a careful review.",
)
.unwrap();
std::fs::write(prompts.join("ship.MD"), "Ship it safely\n").unwrap();
std::fs::write(prompts.join("empty.md"), "").unwrap();
std::fs::write(prompts.join("ignored.txt"), "not a prompt").unwrap();

let commands = load_codex_prompts(&LaunchEnv {
env: vec![("CODEX_HOME".into(), "/wrong/home".into())],
home: Some(home.clone()),
});
assert_eq!(
commands
.iter()
.map(|command| command.name.as_str())
.collect::<Vec<_>>(),
["empty", "review", "ship"]
);
assert!(
commands
.iter()
.all(|command| command.kind == ProviderCommandKind::Command)
);
assert_eq!(commands[0].description, None);
assert_eq!(
commands[1].description.as_deref(),
Some("Review the current diff")
);
assert_eq!(commands[2].description.as_deref(), Some("Ship it safely"));
let _ = std::fs::remove_dir_all(home);
}

/// A codex binary that dies at startup (the npm-packaging failure mode:
/// a JS loader error on stderr, then exit 1) must surface its exit status
/// and stderr in the startup error, not just "exited during startup".
Expand Down
7 changes: 5 additions & 2 deletions crates/agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,9 +749,12 @@ pub struct ProviderCommand {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderCommandKind {
/// A `/`-command (Claude slash command).
/// A `/`-command, sent as `/name …` at the start of the turn text (Claude
/// `slash_commands`, which include its skills).
Command,
/// A `$`-skill (Claude skill / Codex skill).
/// A `$`-skill, sent as a `$name` mention in the turn text (Codex skills).
/// A skill the provider also lists as a command is sent as that command,
/// the only form Claude Code expands.
Skill,
}

Expand Down
55 changes: 48 additions & 7 deletions crates/runtime/src/app/active_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,24 +50,65 @@ pub(super) enum QueuedMessageKind {
}

impl QueuedMessage {
/// The text actually sent to the provider (image-only placeholder and
/// Ultrathink prefix applied). The recorded user message keeps `text`
/// verbatim, so an image-only bubble renders as just its thumbnails.
pub(super) fn wire_text(&self) -> String {
/// The text actually sent to the provider (image-only placeholder, native
/// skill invocation and Ultrathink keyword applied). The recorded user
/// message keeps `text` verbatim, so an image-only bubble renders as just
/// its thumbnails.
pub(super) fn wire_text(&self, provider_commands: &[ProviderCommand]) -> String {
let text = if let Some(transcript) = &self.relay_transcript {
assemble_relay_prompt(transcript, &self.text)
} else {
self.text.clone()
};
let text = wire_text_with_placeholder(text, &self.attachments);
let text = native_skill_invocation(text, provider_commands);
if self.ultrathink {
format!("Ultrathink:\n{text}")
ultrathink_text(text)
} else {
text
}
}
}

/// Send a leading `$name` skill mention as `/name` when the provider also lists
/// `name` as a slash command. Claude Code reports every user-invocable skill
/// under both `skills` and `slash_commands`, and expands only the `/name` form
/// (at byte 0 of the message); Codex lists skills alone and expands `$name`
/// itself, so its mentions are left as typed.
pub(super) fn native_skill_invocation(
text: String,
provider_commands: &[ProviderCommand],
) -> String {
let Some(rest) = text.strip_prefix('$') else {
return text;
};
let name_len = rest.find(char::is_whitespace).unwrap_or(rest.len());
let name = &rest[..name_len];
let listed = |kind: ProviderCommandKind| {
provider_commands
.iter()
.any(|command| command.kind == kind && command.name == name)
};
if name.is_empty()
|| !listed(ProviderCommandKind::Skill)
|| !listed(ProviderCommandKind::Command)
{
return text;
}
format!("/{rest}")
}

/// Add the `ultrathink` keyword, which Claude Code honours anywhere in the
/// prompt. A message that starts with `/` is a slash command, and the CLI
/// expands one only at byte 0, so there the keyword trails the text instead.
pub(super) fn ultrathink_text(text: String) -> String {
if text.starts_with('/') {
format!("{text}\n\nultrathink")
} else {
format!("Ultrathink:\n{text}")
}
}

/// Providers require non-empty turn text: an image-only message uses a
/// synthetic placeholder on the wire while the transcript records the user's
/// empty text plus the attachments.
Expand Down Expand Up @@ -177,7 +218,7 @@ pub struct ActiveSession {
/// retention and LRU eviction. Active or working sessions keep this clear.
pub(super) idle_since: Option<Instant>,
/// Provider-native commands / skills discovered at session start (Claude
/// `slash_commands` + `skills`; Codex `skills/list` + custom prompts).
/// `slash_commands` + `skills`; Codex `skills/list`).
/// Seeded from the per-provider cache, then replaced by live updates.
pub(super) provider_commands: Vec<ProviderCommand>,
/// The agent's self-described options (ACP `modes` / `models` /
Expand Down Expand Up @@ -505,7 +546,7 @@ impl ActiveSession {
commands
.try_send(SessionCommand::SendTurn {
delivery_id: send.id,
text: send.wire_text(),
text: send.wire_text(&self.provider_commands),
options: Some(send.options),
attachments: send.attachments,
})
Expand Down
6 changes: 3 additions & 3 deletions crates/runtime/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use agent::{
AgentError, AgentEvent, ApprovalDecision, ApprovalMode, Attachment, CatalogRefresh,
InteractionMode, ItemContent, ItemStatus, LaunchEnv, ModelSpec, OptionDescriptor,
OptionDescriptors, OptionSelection, PlanResolution, ProviderCommand, ProviderKind, RewindMode,
SessionCommand, SessionHandle, SessionOptions, ThreadItem, TurnOptions, TurnStatus,
list_models,
OptionDescriptors, OptionSelection, PlanResolution, ProviderCommand, ProviderCommandKind,
ProviderKind, RewindMode, SessionCommand, SessionHandle, SessionOptions, ThreadItem,
TurnOptions, TurnStatus, list_models,
};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
Expand Down
2 changes: 1 addition & 1 deletion crates/runtime/src/app/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ impl AppState {
self.preview_draft_or_persist_active(target_id, cx);
}

/// Arm an Ultrathink turn: the next send is prefixed with `Ultrathink:\n`.
/// Arm an Ultrathink turn: the next send carries the `ultrathink` keyword.
/// This is a transient per-send flag, not a persisted option.
pub fn select_ultrathink(&mut self, target_id: &str, _cx: &mut HostCx) {
if let Some(active) = self.resident_mut(target_id) {
Expand Down
4 changes: 3 additions & 1 deletion crates/runtime/src/app/send.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use super::active_session::{native_skill_invocation, ultrathink_text};
use super::*;

impl AppState {
Expand Down Expand Up @@ -601,8 +602,9 @@ impl AppState {
SendRouting::Steer => {
let session_id = active.meta.id.clone();
let wire_text = wire_text_with_placeholder(text.clone(), &attachments);
let wire_text = native_skill_invocation(wire_text, &active.provider_commands);
let wire_text = if active.pending_ultrathink {
format!("Ultrathink:\n{wire_text}")
ultrathink_text(wire_text)
} else {
wire_text
};
Expand Down
43 changes: 43 additions & 0 deletions crates/runtime/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5009,6 +5009,49 @@ fn send_routing_matrix() {
assert_eq!(dead.route(true), SendRouting::QueueUnsupported);
}

/// Claude Code expands a slash command only at byte 0 of the message, so the
/// Ultrathink keyword trails a command instead of displacing it.
#[test]
fn ultrathink_keyword_trails_a_slash_command() {
assert_eq!(ultrathink_text("deep".into()), "Ultrathink:\ndeep");
assert_eq!(
ultrathink_text("/review the diff".into()),
"/review the diff\n\nultrathink"
);
}

/// A `$skill` the provider also exposes as a slash command (Claude) goes out
/// as that command; a skill-only mention (Codex) and an unknown `$word` are
/// sent as typed.
#[test]
fn skill_mentions_use_the_providers_native_invocation() {
let command = |name: &str, kind| ProviderCommand {
name: name.into(),
description: None,
kind,
};
let claude = [
command("review", ProviderCommandKind::Command),
command("review", ProviderCommandKind::Skill),
];
let codex = [command("review", ProviderCommandKind::Skill)];
assert_eq!(
native_skill_invocation("$review the diff".into(), &claude),
"/review the diff"
);
assert_eq!(
native_skill_invocation("$review".into(), &claude),
"/review"
);
assert_eq!(
native_skill_invocation("$review the diff".into(), &codex),
"$review the diff"
);
for text in ["$HOME is set", "please $review", "$"] {
assert_eq!(native_skill_invocation(text.into(), &claude), text);
}
}

/// Ultrathink is per-send: it rides with the message it was armed for, not
/// with whatever happens to be dispatched later.
#[test]
Expand Down
Loading
Loading