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
18 changes: 9 additions & 9 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,15 @@ ignore = "0.4"
# The official ACP runtime (the `sacp` crate was upstreamed into it and renamed).
# Stable protocol v1 only: `unstable_protocol_v2` is deliberately left off, so
# `initialize` goes out as codeg writes it, at protocol version 1, and the
# runtime refuses an answer that names any other version. The one unstable
# feature is the surface codeg consumes beyond the stable schema:
# `session/fork` (the Session Notices and Session Compaction RFDs are stable
# since schema 1.11). (`unstable_end_turn_token_usage` stays off: codeg's token
# accounting reads the agents' own transcripts, not the prompt response's
# `usage`.) No default features: codeg runs an agent through its own transport
# (`acp/agent_process.rs`), not the crate's `process`/`stdio` ones.
agent-client-protocol = { version = "3.4.0", features = ["unstable_session_fork"] }
# runtime refuses an answer that names any other version. The two unstable
# features are the surface codeg consumes beyond the stable schema:
# `session/fork`, and the prompt response's `usage`, which is read only for
# adapters whose counter contract is known (`acp/reported_usage.rs`); agents
# with native usage stores keep their own parsers. (The Session Notices and
# Session Compaction RFDs are stable since schema 1.11.) No default features:
# codeg runs an agent through its own transport (`acp/agent_process.rs`), not
# the crate's `process`/`stdio` ones.
agent-client-protocol = { version = "3.4.0", features = ["unstable_session_fork", "unstable_end_turn_token_usage"] }
tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt", "net", "rt-multi-thread"] }
uuid = { version = "1", features = ["v4"] }
# Version-5 UUIDs (browser profile data-store identifiers) by hand: uuid's
Expand Down Expand Up @@ -357,4 +358,3 @@ temp-env = { version = "0.3", features = ["async_closure"] }
tokio = { version = "1", features = ["test-util", "macros", "rt", "sync", "time"] }
insta = { version = "1", features = ["json", "redactions"] }
axum-test = { version = "17", features = ["ws"] }

211 changes: 203 additions & 8 deletions src-tauri/src/acp/connection.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src-tauri/src/acp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub mod provider_models;
pub mod question;
pub mod registry;
pub mod remote_registry;
pub(crate) mod reported_usage;
pub mod result_tool;
pub mod scratch_dir;
pub mod service_error;
Expand Down
277 changes: 277 additions & 0 deletions src-tauri/src/acp/reported_usage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
//! Prompt-response counters are adapter contracts, not a universal ACP ledger.
//!
//! The official Claude adapter reports cache-exclusive, per-prompt main-loop
//! counters. Command Code reports cumulative, cache-inclusive counters, while
//! TRAE has its own native usage enrichment. Unknown adapters stay unsupported
//! until their counter contract is established. ZCode's existing metrics path
//! remains separate. This policy is captured from the launch definition so a
//! registration edit cannot change the interpretation of a running process.

use crate::acp::registry::AgentDistribution;
use crate::models::agent::AgentType;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum PromptUsageMode {
#[default]
Unsupported,
PerPromptCacheExclusive,
}

impl PromptUsageMode {
pub(crate) fn for_distribution(agent: AgentType, distribution: &AgentDistribution) -> Self {
if !matches!(agent, AgentType::Custom(_)) {
return Self::Unsupported;
}
let AgentDistribution::Npx {
package,
cmd,
version,
..
} = distribution
else {
return Self::Unsupported;
};
const PACKAGE: &str = "@agentclientprotocol/claude-agent-acp";
// Accept a version/tag of this package, never an npm alias, URL, fork,
// or a different executable installed alongside it.
let version_or_tag = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"._-+^~*".contains(&c))
};
let official = *package == PACKAGE
|| package
.strip_prefix(PACKAGE)
.and_then(|suffix| suffix.strip_prefix('@'))
.is_some_and(version_or_tag);
if official && *cmd == "claude-agent-acp" && version_or_tag(version) {
Self::PerPromptCacheExclusive
} else {
Self::Unsupported
}
}

pub(crate) fn select(
self,
usage: Option<&agent_client_protocol::schema::v1::Usage>,
) -> Option<&agent_client_protocol::schema::v1::Usage> {
match self {
Self::PerPromptCacheExclusive => usage,
Self::Unsupported => None,
}
}

/// The one model a response's counters were spent on, when the adapter
/// names it. Without it a turn records the model selector's value, which
/// for this adapter is an alias (`opus`, `default`) that neither a price
/// list nor the by-model breakdown can resolve.
///
/// Since 0.71.0 the response's `_meta.quota.model_usage` splits the
/// prompt's own tally by model id: the prompt's sub-agents are included,
/// work the agent started on its own is not, and a model with nothing to
/// report is left out. A single model there is the one every recorded
/// counter was spent on. Several cannot say which one the main loop used.
pub(crate) fn reported_model(
self,
meta: Option<&agent_client_protocol::schema::v1::Meta>,
) -> Option<String> {
if self != Self::PerPromptCacheExclusive {
return None;
}
let mut spent = meta?
.get("quota")?
.get("model_usage")?
.as_array()?
.iter()
.filter(|row| {
row.pointer("/token_count/totalTokens")
.and_then(serde_json::Value::as_u64)
.is_some_and(|total| total > 0)
})
.map(|row| row.get("model").and_then(serde_json::Value::as_str));
let model = spent.next()??.trim();
if model.is_empty() || !spent.all(|other| other.map(str::trim) == Some(model)) {
return None;
}
Some(model.to_string())
}
}

#[cfg(test)]
mod tests {
use super::*;

fn npx(package: &'static str, cmd: &'static str, version: &'static str) -> AgentDistribution {
AgentDistribution::Npx {
package,
cmd,
version,
args: &[],
env: &[],
node_required: None,
}
}

#[test]
fn recognizes_the_producer_not_the_custom_id_or_display_name() {
let source = npx(
"@agentclientprotocol/claude-agent-acp",
"claude-agent-acp",
"0.97.0",
);
assert_eq!(
PromptUsageMode::for_distribution(AgentType::Custom("review-seat"), &source),
PromptUsageMode::PerPromptCacheExclusive
);
for source in [
npx("command-code", "command-code", "1.86.3"),
npx("trae-cli", "trae", "0.209.1"),
npx("@example/claude-agent-acp", "claude-agent-acp", "1.0.0"),
npx(
"@agentclientprotocol/claude-agent-acp-extra",
"claude-agent-acp",
"1.0.0",
),
npx(
"@agentclientprotocol/claude-agent-acp@npm:other",
"claude-agent-acp",
"latest",
),
npx(
"@agentclientprotocol/claude-agent-acp",
"other-command",
"0.97.0",
),
npx(
"@agentclientprotocol/claude-agent-acp",
"claude-agent-acp",
"npm:other",
),
] {
assert_eq!(
PromptUsageMode::for_distribution(AgentType::Custom("claude-code-2"), &source),
PromptUsageMode::Unsupported
);
}
}

#[test]
fn official_versions_keep_native_store_agents_on_their_existing_paths() {
let source = npx(
"@agentclientprotocol/claude-agent-acp@0.97.0",
"claude-agent-acp",
"latest",
);
assert_eq!(
PromptUsageMode::for_distribution(AgentType::Custom("work"), &source),
PromptUsageMode::PerPromptCacheExclusive
);
for agent in crate::models::agent::BUILTIN_AGENT_TYPES {
assert_eq!(
PromptUsageMode::for_distribution(*agent, &source),
PromptUsageMode::Unsupported
);
}
}

/// What the manual add form saves: the package spec alone, so the launch
/// command is derived from it and the version label falls back to
/// `custom`. That definition must still select the adapter's contract.
#[test]
fn a_manually_added_official_adapter_is_recognized_from_its_saved_definition() {
use crate::acp::custom_registry::{
build_meta, CustomAgentDef, CustomAgentSpec, CustomDistributionKind, NpxSpec,
};
let mode = |package: &str| {
let meta = build_meta(&CustomAgentDef {
registry_id: "work-claude".into(),
name: "Work Claude".into(),
description: String::new(),
version: String::new(),
distribution_kind: CustomDistributionKind::Npx,
spec: CustomAgentSpec {
npx: Some(NpxSpec {
package: package.into(),
..Default::default()
}),
..Default::default()
},
icon_url: None,
skills_shared_store: false,
skills_dir: None,
source: Default::default(),
version_probe: None,
supports_mcp: true,
})
.unwrap();
PromptUsageMode::for_distribution(meta.agent_type, &meta.distribution)
};
for package in [
"@agentclientprotocol/claude-agent-acp",
"@agentclientprotocol/claude-agent-acp@0.97.0",
"@agentclientprotocol/claude-agent-acp@latest",
] {
assert_eq!(
mode(package),
PromptUsageMode::PerPromptCacheExclusive,
"{package}"
);
}
assert_eq!(
mode("@zed-industries/claude-code-acp@0.16.0"),
PromptUsageMode::Unsupported
);
}

#[test]
fn a_response_names_its_model_only_when_one_model_did_the_work() {
let meta = |rows: serde_json::Value| -> agent_client_protocol::schema::v1::Meta {
serde_json::from_value(serde_json::json!({"quota": {"model_usage": rows}})).unwrap()
};
let row = |model: &str, total: u64| serde_json::json!({"model": model, "token_count": {"totalTokens": total}});
let mode = PromptUsageMode::PerPromptCacheExclusive;
let one = meta(serde_json::json!([row("claude-opus-5-5", 4010)]));
assert_eq!(
mode.reported_model(Some(&one)).as_deref(),
Some("claude-opus-5-5")
);
// A model with nothing to report does not make the answer ambiguous.
let idle = meta(serde_json::json!([
row("claude-opus-5-5", 4010),
row("claude-haiku-5-5", 0)
]));
assert_eq!(
mode.reported_model(Some(&idle)).as_deref(),
Some("claude-opus-5-5")
);
// A sub-agent on another model: which one the main loop used is unknown.
let two = meta(serde_json::json!([
row("claude-opus-5-5", 4010),
row("claude-haiku-5-5", 2005)
]));
assert_eq!(mode.reported_model(Some(&two)), None);
assert_eq!(
mode.reported_model(Some(&meta(serde_json::json!([])))),
None
);
assert_eq!(mode.reported_model(None), None);
assert_eq!(
PromptUsageMode::Unsupported.reported_model(Some(&one)),
None
);
}

#[test]
fn counters_are_not_guessed_for_unknown_or_cumulative_adapters() {
let usage: agent_client_protocol::schema::v1::Usage = serde_json::from_value(serde_json::json!({
"inputTokens": 234, "outputTokens": 56, "cachedReadTokens": 1000, "totalTokens": 1290,
})).unwrap();
assert!(PromptUsageMode::Unsupported.select(Some(&usage)).is_none());
assert!(PromptUsageMode::PerPromptCacheExclusive
.select(Some(&usage))
.is_some());
assert!(PromptUsageMode::PerPromptCacheExclusive
.select(None)
.is_none());
}
}
3 changes: 3 additions & 0 deletions src-tauri/src/acp/session_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,8 @@ pub struct SessionState {
/// them as copied history and drop them.
pub external_id_changed_at: Option<std::time::SystemTime>,
pub agent_type: AgentType,
/// Counter semantics of the adapter definition used for this launch.
pub(crate) prompt_usage_mode: crate::acp::reported_usage::PromptUsageMode,
pub working_dir: Option<PathBuf>,
pub owner_window_label: String,
pub folder_id: Option<i32>,
Expand Down Expand Up @@ -859,6 +861,7 @@ impl SessionState {
external_id: None,
external_id_changed_at: None,
agent_type,
prompt_usage_mode: crate::acp::reported_usage::PromptUsageMode::default(),
working_dir,
owner_window_label,
folder_id,
Expand Down
34 changes: 33 additions & 1 deletion src-tauri/src/acp_transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,9 @@ fn compact_batch(pending: &[Queued], compactable: bool) -> (String, bool) {
// absent on both) is the only case where merging loses nothing.
Some(kind)
if open.as_ref().is_some_and(|(open_kind, open_entry)| {
*open_kind == kind && open_entry.p.get("_meta") == entry.p.get("_meta")
*open_kind == kind
&& open_entry.p.get("_meta") == entry.p.get("_meta")
&& open_entry.p.get("messageId") == entry.p.get("messageId")
}) =>
{
let (_, open_entry) = open.as_mut().expect("guard matched Some");
Expand Down Expand Up @@ -2092,6 +2094,36 @@ mod tests {
assert_eq!(entries[2].p["_meta"], serde_json::json!({"a": 2}));
}

#[test]
fn typed_message_ids_survive_recording_and_separate_compacted_messages() {
use agent_client_protocol::schema::v1::SessionUpdate;
let message = |t, id| {
// Exercise the same typed decode/encode boundary as the live
// notification handler, not only hand-built transcript JSON.
let update: SessionUpdate = serde_json::from_value(serde_json::json!({
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "x"},
"messageId": id,
})).unwrap();
PendingRecord::Entry(TranscriptEntry {
t, k: EntryKind::Update, steer: false,
p: serde_json::to_value(update).unwrap(),
})
};
let pending: Vec<_> = vec![
prompt_record(1, "hi"),
message(2, "msg-first"), message(3, "msg-first"),
message(4, "msg-second"), message(5, "msg-second"),
].into_iter().map(queued).collect();
let (lines, _) = compact_batch(&pending, false);
let entries = parse_transcript(&lines).entries;
assert_eq!(entries.len(), 3);
assert_eq!(entries[1].p["messageId"], "msg-first");
assert_eq!(entries[2].p["messageId"], "msg-second");
assert_eq!(entries[1].p["content"]["text"], "xx");
assert_eq!(entries[2].p["content"]["text"], "xx");
}

/// The frontend refetches conversation detail the moment a turn completes,
/// so everything buffered during the turn has to be on disk by the time the
/// turn end's ack resolves — that ack is exactly what `record_turn_end`
Expand Down
Loading
Loading