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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,37 @@ appear in a patch rather than inflating the version toward 1.0 on a crate still
shape. **Where that happens the entry says so at the top**, because a version number that
under-signals is only acceptable if the changelog over-signals to compensate.

## 0.5.1

Re-verified against the installed CLIs: claude 2.1.267, codex-cli 0.154.0, GitHub Copilot
CLI 1.0.88 and grok 1.0.40. Every flag was checked against each CLI's `--help`, and the live
suite passed against all four.

### Fixed

- **Codex: resuming with extra directories no longer fails.** `codex exec resume` refuses
`--add-dir`; a resumed run now carries its roots as
`-c sandbox_workspace_write.writable_roots=[...]`. A fresh `exec` still takes the flag.
- **Codex: app-server errors carry their message.** The notification is
`{ error: { message }, willRetry }`; the crate read a top-level `message` and always got
nothing. A notification Codex will retry no longer ends the turn as an error.
- **Claude: a `[1m]` run reports its window even when the Haiku helper ran.**
`--output-format json` has no `init` record, so with the helper listed beside the run's
model there was no name to choose by and the window came back unknown. The run's own
`modelUsage` entry is now found by its counts, which equal the top-level `usage`; anything
ambiguous is still unknown rather than guessed.
- **Copilot: effort is passed as `--reasoning-effort`**, the documented spelling in 1.0.88.
`--effort` still parses but is no longer in `--help`.
- **Claude's login hint is `claude auth login`**, the subcommand 2.1.267 documents.

### Changed

- **Codex catalogue** follows what 0.154.0 reports: `gpt-6-astra` (now the default),
`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-daybreak-blue-latest` (account
dependent) and `gpt-5.5` (retiring 2026-10-14). `gpt-5.4` and `gpt-5.4-mini` are gone.
- **Grok catalogue** follows `grok models` on 1.0.40: `grok-4.7` (now the default),
`grok-4.7-build-fast`, `grok-4.6`, `grok-4.5`.

## 0.5.0

The crate now runs on nagoya instead of tokio, and every entry point that spawns a CLI
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "agent-abstraction"
version = "0.5.0"
version = "0.5.1"
edition = "2024"
# The floor edition 2024 requires, and where the strictest dependencies (uuid,
# getrandom) sit. Derived from the dependency graph rather than compile-tested.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ Every entry point that spawns a CLI (`run`, `stream`, `interrupt`, `Probe::run`,

## What each agent can actually do

Verified live, against `claude 2.1.205`, `codex-cli 0.146.0` and `GitHub Copilot CLI 1.0.78`
and not inferred from documentation.
Verified live, against `claude 2.1.267`, `codex-cli 0.154.0`, `GitHub Copilot CLI 1.0.88` and
`grok 1.0.40`, and not inferred from documentation.

| | session id | fork | events | system prompt | resume flag |
|---|---|---|---|---|---|
Expand Down
105 changes: 81 additions & 24 deletions src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,13 +373,13 @@ impl Agent {
/// The command that resolves a missing login for this agent.
///
/// Verified against each CLI's own help: Codex and Copilot expose a `login`
/// subcommand, while Claude authenticates interactively or through a
/// long-lived token.
/// subcommand, and so does Claude (`claude auth login`, verified against
/// 2.1.267), which also offers `setup-token` for a long-lived token.
#[must_use]
pub fn login_hint(self) -> &'static str {
match self {
Agent::Claude => {
"run `claude` and use /login, or `claude setup-token` for a \
"run `claude auth login`, or `claude setup-token` for a \
long-lived token"
}
Agent::Codex => "run `codex login`",
Expand All @@ -397,14 +397,14 @@ impl Agent {
#[must_use]
pub fn verified_version(self) -> crate::Version {
let (major, minor, patch) = match self {
// `claude --version` -> "2.1.220 (Claude Code)"
Agent::Claude => (2, 1, 220),
// `codex --version` -> "codex-cli 0.147.0"
Agent::Codex => (0, 147, 0),
// `copilot --version` -> "GitHub Copilot CLI 1.0.78."
Agent::Copilot => (1, 0, 78),
// `grok --version` -> "grok 1.0.30 (04b7ffed98c6) [stable]"
Agent::Grok => (1, 0, 30),
// `claude --version` -> "2.1.267 (Claude Code)"
Agent::Claude => (2, 1, 267),
// `codex --version` -> "codex-cli 0.154.0"
Agent::Codex => (0, 154, 0),
// `copilot --version` -> "GitHub Copilot CLI 1.0.88."
Agent::Copilot => (1, 0, 88),
// `grok --version` -> "grok 1.0.40 (eb1a2256660d)"
Agent::Grok => (1, 0, 40),
};
crate::Version {
major,
Expand Down Expand Up @@ -540,7 +540,11 @@ impl Agent {
},
// `codex exec --json` emits `thread_id`; interactive turns use the
// app-server protocol, which exposes steering and approvals.
// Continuation remains linear (`codex fork` is TUI-only).
// Continuation remains linear here. codex-cli 0.154.0 does have a
// headless `codex exec fork <SESSION_ID>` (and app-server
// `thread/fork`), but this crate does not drive it yet, so `fork`
// stays false and a fork request is `Error::Unsupported` rather
// than a silent linear resume.
Agent::Codex => Caps {
session: SessionSupport::Printed,
fork: false,
Expand Down Expand Up @@ -939,7 +943,8 @@ fn argv_claude(plan: &Plan) -> Vec<Arg> {
}
if plan.format == Format::Stream {
// Claude refuses `-p --output-format stream-json` without it:
// "--print with --output-format=stream-json requires --verbose".
// "Error: When using --print, --output-format=stream-json requires
// --verbose" (claude 2.1.267).
a.bare("--verbose");
// Without this Claude emits only *completed* messages, so text arrives
// a paragraph at a time. With it, `stream_event` records carry the
Expand Down Expand Up @@ -1017,11 +1022,32 @@ fn argv_codex(plan: &Plan) -> Vec<Arg> {
if let Some(effort) = plan.effort.as_ref() {
a.pair("-c", format!("model_reasoning_effort={effort}"));
}
// Verified against codex-cli 0.146.0. Options remain options after the
// positional prompt, but keeping roots before it makes the command's
// security posture readable and matches the CLI's help shape.
for dir in &plan.extra_dirs {
a.pair("--add-dir", dir);
// Verified against codex-cli 0.154.0. `codex exec` takes `--add-dir`, but
// `codex exec resume` refuses it ("unexpected argument '--add-dir'"), the
// same way it refuses `--sandbox`. A resumed run carries the roots through
// the config key `--add-dir` sets, so resuming with extra directories no
// longer fails before the agent starts. JSON string encoding is a valid
// TOML basic string, so each path is quoted and escaped the same way.
// Keeping roots before the prompt makes the security posture readable.
if resuming {
if !plan.extra_dirs.is_empty() {
let roots: Vec<String> = plan
.extra_dirs
.iter()
.map(|dir| serde_json::Value::from(dir.as_str()).to_string())
.collect();
a.pair(
"-c",
format!(
"sandbox_workspace_write.writable_roots=[{}]",
roots.join(",")
),
);
}
} else {
for dir in &plan.extra_dirs {
a.pair("--add-dir", dir);
}
}
// Codex reads the schema from a file, which the runner writes before the
// spawn. `Request::argv` has no file to name, so it shows a placeholder:
Expand Down Expand Up @@ -1082,11 +1108,13 @@ fn argv_copilot(plan: &Plan) -> Vec<Arg> {
};

a.opt("--model", plan.model.as_ref());
// Verified against Copilot CLI 1.0.78: `--effort` is the documented spelling
// and `--reasoning-effort` its alias (none, minimal, low, medium, high,
// xhigh, max). A wider set than Claude's, which is why the level is passed
// through rather than mapped to a shared enum.
a.opt("--effort", plan.effort.as_ref());
// Verified against Copilot CLI 1.0.88: `--reasoning-effort` is now the
// documented spelling (none, minimal, low, medium, high, xhigh, max).
// `--effort`, documented in 1.0.78, still parses but is gone from `--help`,
// so the documented flag is the one passed. A wider set than Claude's,
// which is why the level is passed through rather than mapped to a shared
// enum.
a.opt("--reasoning-effort", plan.effort.as_ref());
// One flag serves both directions: it sets the UUID for a new session and
// resumes an existing one by id.
match &plan.cont {
Expand Down Expand Up @@ -1362,7 +1390,10 @@ mod tests {
assert_eq!(claude[pos(&claude, "--effort").unwrap() + 1], "xhigh");

let copilot = argv(Agent::Copilot, &p);
assert_eq!(copilot[pos(&copilot, "--effort").unwrap() + 1], "xhigh");
assert_eq!(
copilot[pos(&copilot, "--reasoning-effort").unwrap() + 1],
"xhigh"
);

let codex = argv(Agent::Codex, &p);
assert!(
Expand All @@ -1384,6 +1415,7 @@ mod tests {
for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
let a = argv(agent, &p);
assert!(pos(&a, "--effort").is_none(), "{agent}: {a:?}");
assert!(pos(&a, "--reasoning-effort").is_none(), "{agent}: {a:?}");
assert!(
!a.iter()
.any(|arg| arg.starts_with("model_reasoning_effort")),
Expand Down Expand Up @@ -1495,6 +1527,31 @@ mod tests {
assert_eq!(a.last().unwrap(), "hi");
}

/// codex-cli 0.154.0 refuses `--add-dir` on `exec resume`, so a resumed
/// run carries its roots in the config key instead, TOML-quoted.
#[test]
fn codex_resume_carries_extra_roots_as_config_not_add_dir() {
let mut p = plan("codex");
p.cont = Continue::Resume("thread-9".into());
p.extra_dirs = vec!["/repo".into(), "/with \"quote\"".into()];
let a = argv(Agent::Codex, &p);
assert!(!a.contains(&"--add-dir".to_string()), "{a:?}");
let at = a
.iter()
.position(|arg| arg.starts_with("sandbox_workspace_write.writable_roots="))
.expect("the roots ride a config override");
assert_eq!(a[at - 1], "-c");
assert_eq!(
a[at],
r#"sandbox_workspace_write.writable_roots=["/repo","/with \"quote\""]"#
);

// A fresh exec still takes the flag.
p.cont = Continue::New;
let fresh = argv(Agent::Codex, &p);
assert!(fresh.contains(&"--add-dir".to_string()), "{fresh:?}");
}

/// `Minimal` exists to withhold secrets, so nothing it passes through may
/// be a credential carrier. Proxy URLs in particular routinely embed
/// `user:pass`, which is why they are offered separately instead.
Expand Down
6 changes: 5 additions & 1 deletion src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,11 @@ mod tests {
let status = AuthStatus::read(Agent::Claude, r#"{"loggedIn": false}"#, true);
assert_eq!(status.state, AuthState::LoggedOut);
assert!(status.needs_login());
assert!(status.summary().contains("/login"), "{}", status.summary());
assert!(
status.summary().contains("claude auth login"),
"{}",
status.summary()
);
}

/// Verbatim from `codex login status`.
Expand Down
49 changes: 48 additions & 1 deletion src/codex_app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,10 +356,20 @@ impl Protocol {
.map(str::to_string);
self.finished = true;
}
// Verified against the codex-cli 0.154.0 app-server schema:
// `ErrorNotification { error: TurnError { message, .. }, threadId,
// turnId, willRetry }`. The message is under `error`, so reading
// `params.message` always came back empty. A notification codex
// says it will retry is not the end of the turn: marking it an
// error failed runs that went on to succeed.
"error" => {
if params.get("willRetry").and_then(Value::as_bool) == Some(true) {
return step;
}
self.terminal.stop = Stop::Error;
self.terminal.error_message = params
.get("message")
.pointer("/error/message")
.or_else(|| params.get("message"))
.and_then(Value::as_str)
.map(str::to_string);
}
Expand Down Expand Up @@ -811,6 +821,43 @@ mod tests {
protocol
}

/// codex-cli 0.154.0 sends `{ error: { message }, willRetry }`. A retried
/// error is not the end of the turn; a final one carries its message.
#[test]
fn an_error_notification_reads_its_nested_message_and_skips_retries() {
let mut protocol = running_protocol();
protocol.push(&json!({
"method": "error",
"params": {
"error": {"message": "overloaded"},
"threadId": "thread-7",
"turnId": "turn-9",
"willRetry": true
}
}));
assert_ne!(
protocol.terminal.stop,
Stop::Error,
"a retry is not a failure"
);
assert_eq!(protocol.terminal.error_message, None);

protocol.push(&json!({
"method": "error",
"params": {
"error": {"message": "quota exhausted"},
"threadId": "thread-7",
"turnId": "turn-9",
"willRetry": false
}
}));
assert_eq!(protocol.terminal.stop, Stop::Error);
assert_eq!(
protocol.terminal.error_message.as_deref(),
Some("quota exhausted")
);
}

#[test]
fn interruption_targets_the_active_codex_turn() {
let mut protocol = running_protocol();
Expand Down
53 changes: 50 additions & 3 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1292,6 +1292,15 @@ fn claude_usage(v: &Value, model: Option<&str>) -> Usage {
// The key is the resolved model name the `init` record announced, verified
// against claude 2.1.212: asking for `sonnet[1m]`, init says
// `claude-sonnet-5[1m]` and that exact string keys `modelUsage`.
//
// `--output-format json`, which a plain run uses, has no `init` record, so
// there is often no name at all. When the helper also ran, that left two
// entries and nothing to choose by, and a `[1m]` run reported no window
// (claude 2.1.267, intermittently, depending on whether the helper ran).
// The top-level `usage` is the run's own model: its four counts equal that
// model's `modelUsage` entry and not the helper's. So an entry is chosen
// by those counts only when exactly one matches; anything else is still
// reported as unknown rather than guessed.
let per_model = v
.get("modelUsage")
.and_then(Value::as_object)
Expand All @@ -1300,9 +1309,32 @@ fn claude_usage(v: &Value, model: Option<&str>) -> Usage {
(Some(entry), _) => Some(entry),
// One entry and no name to match: it can only be the run's model.
(None, 1) => models.values().next(),
// Several entries and no match. Guessing here is how the bug
// happened, so the window is reported as unknown instead.
(None, _) => None,
// Several entries and no name: the one whose counts are the
// run's own, if exactly one is.
(None, _) => {
let top = v.get("usage");
let count = |value: Option<&Value>, key: &str| {
value.and_then(|u| u.get(key)).and_then(Value::as_u64)
};
let same = |entry: &Value| {
[
("input_tokens", "inputTokens"),
("output_tokens", "outputTokens"),
("cache_read_input_tokens", "cacheReadInputTokens"),
("cache_creation_input_tokens", "cacheCreationInputTokens"),
]
.iter()
.all(|(ours, theirs)| {
count(top, ours).is_some()
&& count(top, ours) == count(Some(entry), theirs)
})
};
let mut matching = models.values().filter(|entry| same(entry));
match (matching.next(), matching.next()) {
(Some(entry), None) => Some(entry),
_ => None,
}
}
},
);
let of_model = |key: &str| per_model.and_then(|m| m.get(key)).and_then(Value::as_u64);
Expand Down Expand Up @@ -1678,6 +1710,21 @@ mod tests {
assert_eq!(single.usage.context_window, Some(200_000));
}

/// `--output-format json` has no init record, and claude 2.1.267 lists the
/// Haiku helper beside the run's model when the helper ran. The run's own
/// entry is the one whose counts equal the top-level `usage`.
#[test]
fn a_json_result_finds_its_model_by_its_own_counts() {
let (_, term) = run(
Agent::Claude,
&[
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s","usage":{"input_tokens":2,"output_tokens":4,"cache_read_input_tokens":27128,"cache_creation_input_tokens":9825},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":521,"outputTokens":12,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"contextWindow":200000},"claude-sonnet-5[1m]":{"inputTokens":2,"outputTokens":4,"cacheReadInputTokens":27128,"cacheCreationInputTokens":9825,"contextWindow":1000000,"maxOutputTokens":64000}}}"#,
],
);
assert_eq!(term.usage.context_window, Some(1_000_000));
assert_eq!(term.usage.max_output_tokens, Some(64_000));
}

#[test]
fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
let (events, _) = run(
Expand Down
Loading
Loading