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
274 changes: 219 additions & 55 deletions rust/lithe-agent-host/src/claude_desktop.rs

Large diffs are not rendered by default.

25 changes: 20 additions & 5 deletions rust/lithe-agent-host/src/claude_earlier_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,9 +334,13 @@ fn title(name: &str, input: &Value) -> String {
fn bounded(value: &Value, limit: usize) -> Value {
match value {
Value::String(text) => Value::String(truncate_to(text, limit)),
Value::Array(items) => Value::Array(items.iter().map(|item| bounded(item, limit)).collect()),
Value::Array(items) => {
Value::Array(items.iter().map(|item| bounded(item, limit)).collect())
}
Value::Object(map) => Value::Object(
map.iter().map(|(k, v)| (k.clone(), bounded(v, limit))).collect(),
map.iter()
.map(|(k, v)| (k.clone(), bounded(v, limit)))
.collect(),
),
other => other.clone(),
}
Expand Down Expand Up @@ -477,9 +481,16 @@ mod tests {
let updates = earlier_updates(dir.path(), &cwd, "abc-1");
let mut presenter = claude_desktop::Presenter::default();
let shown = presenter.present("s", updates[0].clone());
assert_eq!(shown[0]["_meta"]["lithe"]["claudeDesktop"]["code"], widget.as_str());
assert_eq!(
shown[0]["_meta"]["lithe"]["claudeDesktop"]["code"],
widget.as_str()
);
let write = updates[1]["rawInput"]["content"].as_str().unwrap();
assert_eq!(write.chars().count(), TEXT_LIMIT + 1, "Ordinary inputs stay previews");
assert_eq!(
write.chars().count(),
TEXT_LIMIT + 1,
"Ordinary inputs stay previews"
);
}

#[test]
Expand All @@ -495,7 +506,11 @@ mod tests {
boundary(),
]);
let updates = earlier_updates(dir.path(), &cwd, "abc-1");
assert_eq!(updates.len(), 2, "only the inline base64 image within the limit is replayed");
assert_eq!(
updates.len(),
2,
"only the inline base64 image within the limit is replayed"
);
assert_eq!(updates[1]["sessionUpdate"], "user_message_chunk");
assert_eq!(updates[1]["content"]["type"], "image");
assert_eq!(updates[1]["content"]["data"], "/9j/4A==");
Expand Down
8 changes: 6 additions & 2 deletions rust/lithe-agent-host/src/edit_review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ fn claude_write(update: &Value, reported: Option<&[DiffEvidence]>) -> Option<Dif
return None;
}
let response = &claude["toolResponse"];
let path = response["filePath"].as_str().filter(|path| !path.is_empty())?;
let path = response["filePath"]
.as_str()
.filter(|path| !path.is_empty())?;
let new = response["content"].as_str()?;
let old = response["originalFile"].as_str();
let operation = match response["type"].as_str()? {
Expand Down Expand Up @@ -286,7 +288,9 @@ pub fn reverse_plan(request: &ReverseRequest) -> ReversePlan {
(true, Some(old), None) => Some(old.to_owned()),
(true, _, _) => return conflict(ConflictReason::Changed),
// Creation: remove only the exact created content.
(false, None, Some(current)) if canonical(&current).0 == canonical(&diff.new_text).0 => {
(false, None, Some(current))
if canonical(&current).0 == canonical(&diff.new_text).0 =>
{
None
}
(false, None, _) => return conflict(ConflictReason::Changed),
Expand Down
23 changes: 16 additions & 7 deletions rust/lithe-agent-host/src/edit_review/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,27 @@ fn shared_evidence_fixture_matches_host_annotation() {
#[test]
fn shared_reverse_plan_fixture_matches_planner() {
let fixture = fixture();
for case in fixture["reversePlan"].as_array().expect("reverse plan cases") {
for case in fixture["reversePlan"]
.as_array()
.expect("reverse plan cases")
{
let name = case["name"].as_str().unwrap_or_default();
let request: ReverseRequest =
serde_json::from_value(expand(&case["request"])).expect(name);
let request: ReverseRequest = serde_json::from_value(expand(&case["request"])).expect(name);
let plan = serde_json::to_value(reverse_plan(&request)).expect(name);
assert_eq!(plan, expand(&case["expected"]), "{name}");
}
}

#[test]
fn content_beyond_the_entry_limit_marks_every_diff_incomplete() {
let mut content = vec![json!({"type": "diff", "path": "a.txt", "oldText": "a", "newText": "b"})];
content.extend((0..CONTENT_LIMIT).map(|_| json!({"type": "content", "content": {"type": "text", "text": "x"}})));
let diffs = evidence(&json!({"sessionUpdate": "tool_call", "content": content})).expect("diffs");
let mut content =
vec![json!({"type": "diff", "path": "a.txt", "oldText": "a", "newText": "b"})];
content.extend(
(0..CONTENT_LIMIT)
.map(|_| json!({"type": "content", "content": {"type": "text", "text": "x"}})),
);
let diffs =
evidence(&json!({"sessionUpdate": "tool_call", "content": content})).expect("diffs");
assert_eq!(diffs.len(), 1);
assert!(diffs[0].truncated && !diffs[0].revertible);
}
Expand Down Expand Up @@ -99,7 +106,9 @@ fn canonical_match_maps_multibyte_offsets_back_to_the_original() {
};
assert_eq!(
reverse_plan(&request),
ReversePlan::Write { text: "😀\r前\r\n旧\r\n后".into() }
ReversePlan::Write {
text: "😀\r前\r\n旧\r\n后".into()
}
);
}

Expand Down
4 changes: 2 additions & 2 deletions rust/lithe-agent-host/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,19 @@ mod claude_desktop;
mod claude_earlier_history;
mod claude_subscription;
pub mod cli_update;
pub mod edit_review;
mod codex_context;
mod codex_history;
mod codex_retry;
pub mod edit_review;
pub mod environment;
pub mod hooks;
pub mod install;
pub mod instructions;
pub mod mcp_probe;
mod model_call_statistics;
mod model_calls;
pub mod next_turn_config;
mod model_proxy;
pub mod next_turn_config;
mod prompt;
mod prompt_retry;
mod session_defaults;
Expand Down
22 changes: 16 additions & 6 deletions rust/lithe-agent-host/src/model_call_statistics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ pub(crate) struct StatisticsTracker {
impl StatisticsTracker {
/// Contribution of a call attributed to `session_id`. Unattributed calls
/// are dropped by both products, so they keep no per-session memory.
pub(crate) fn contribution(&self, session_id: Option<&str>, call: &ModelCall) -> ModelCallStatistics {
pub(crate) fn contribution(
&self,
session_id: Option<&str>,
call: &ModelCall,
) -> ModelCallStatistics {
let Some(session_id) = session_id else {
return ModelCallStatistics::of_call(call, &mut false);
};
Expand Down Expand Up @@ -180,7 +184,9 @@ mod tests {
scaled_to_usage: context["scaledToUsage"].as_bool().unwrap(),
});
ModelCall {
api: serde_json::from_value::<ModelApiName>(value["api"].clone()).unwrap().0,
api: serde_json::from_value::<ModelApiName>(value["api"].clone())
.unwrap()
.0,
model: None,
status: value["status"].as_u64().unwrap() as u16,
streaming: value["streaming"].as_bool().unwrap_or(false),
Expand Down Expand Up @@ -241,10 +247,14 @@ mod tests {
#[test]
fn tool_memory_is_per_session() {
let tracker = StatisticsTracker::default();
let tools = call(&serde_json::json!({"api":"responses","status":200,"durationMs":1,
"context":{"systemPromptTokens":1,"toolTokens":1,"mcpToolTokens":0,"messageTokens":1,"toolCount":1,"mcpToolCount":0,"scaledToUsage":false}}));
let toolless = call(&serde_json::json!({"api":"responses","status":200,"durationMs":1,
"context":{"systemPromptTokens":1,"toolTokens":0,"mcpToolTokens":0,"messageTokens":9,"toolCount":0,"mcpToolCount":0,"scaledToUsage":false}}));
let tools = call(
&serde_json::json!({"api":"responses","status":200,"durationMs":1,
"context":{"systemPromptTokens":1,"toolTokens":1,"mcpToolTokens":0,"messageTokens":1,"toolCount":1,"mcpToolCount":0,"scaledToUsage":false}}),
);
let toolless = call(
&serde_json::json!({"api":"responses","status":200,"durationMs":1,
"context":{"systemPromptTokens":1,"toolTokens":0,"mcpToolTokens":0,"messageTokens":9,"toolCount":0,"mcpToolCount":0,"scaledToUsage":false}}),
);
assert!(tracker.contribution(Some("a"), &tools).context.is_some());
assert!(tracker.contribution(Some("a"), &toolless).context.is_none());
assert!(tracker.contribution(Some("b"), &toolless).context.is_some());
Expand Down
13 changes: 9 additions & 4 deletions rust/lithe-agent-host/src/next_turn_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,10 @@ pub fn catalog(options: &[SessionConfigOption]) -> Vec<CatalogOption> {
return None;
};
let choices = match &select.options {
SessionConfigSelectOptions::Ungrouped(values) => {
values.iter().map(|value| value.value.0.to_string()).collect()
}
SessionConfigSelectOptions::Ungrouped(values) => values
.iter()
.map(|value| value.value.0.to_string())
.collect(),
SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.flat_map(|group| group.options.iter())
Expand Down Expand Up @@ -215,7 +216,11 @@ mod tests {
}
}
};
assert_eq!(Value::Array(sent), case["expected"]["requests"], "{name} requests");
assert_eq!(
Value::Array(sent),
case["expected"]["requests"],
"{name} requests"
);
assert_eq!(
failure.map(|failure| serde_json::to_value(failure).unwrap()),
case["expected"]["failure"].as_str().map(Value::from),
Expand Down
12 changes: 10 additions & 2 deletions rust/lithe-agent-host/src/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ fn execute_at(
.as_ref()
.is_some_and(|p| !p.is_absolute() || !p.is_dir())
|| !claude.is_absolute()
|| request.codex_home.as_ref().is_some_and(|p| !p.is_absolute())
|| request
.codex_home
.as_ref()
.is_some_and(|p| !p.is_absolute())
{
return Err(Error::Invalid(
"The skill workspace and configuration directory must be absolute directories.".into(),
Expand Down Expand Up @@ -153,7 +156,12 @@ fn execute_at(
.enable_all()
.build()
.map_err(|_| Error::Failed("Skill discovery could not start.".into()))?;
let result = runtime.block_on(CodexHistory::skills(cli.path, cwd.to_path_buf(), request.codex_home.clone(), cancelled));
let result = runtime.block_on(CodexHistory::skills(
cli.path,
cwd.to_path_buf(),
request.codex_home.clone(),
cancelled,
));
check(cancelled)?;
let value = result.map_err(|_| {
Error::Failed("Codex skill discovery failed. Check the CLI and retry.".into())
Expand Down
12 changes: 10 additions & 2 deletions rust/lithe-agent-host/src/skills/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,12 +487,20 @@ async fn native_discovery_uses_the_selected_account_directory() {
fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
tokio::time::timeout(
Duration::from_secs(5),
CodexHistory::skills(executable, fixture.0.join("project"), Some(home.clone()), &|| false),
CodexHistory::skills(
executable,
fixture.0.join("project"),
Some(home.clone()),
&|| false,
),
)
.await
.unwrap()
.unwrap();
assert_eq!(fs::read_to_string(fixture.0.join("home.log")).unwrap(), home.to_str().unwrap());
assert_eq!(
fs::read_to_string(fixture.0.join("home.log")).unwrap(),
home.to_str().unwrap()
);
}

#[cfg(unix)]
Expand Down
15 changes: 12 additions & 3 deletions rust/lithe-agent-host/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2466,9 +2466,15 @@ async fn next_turn_configuration_applies_choices_and_reports_one_result() {
assert_eq!(request["params"]["value"], "model-b");
harness
.agent
.reply(&request, json!({ "configOptions": configured["configOptions"] }))
.reply(
&request,
json!({ "configOptions": configured["configOptions"] }),
)
.await;
assert_eq!(serde_json::to_value(harness.event().await).unwrap(), configured);
assert_eq!(
serde_json::to_value(harness.event().await).unwrap(),
configured
);

harness.send(next["command"].clone());
let request = harness.agent.expect("session/set_config_option").await;
Expand All @@ -2479,7 +2485,10 @@ async fn next_turn_configuration_applies_choices_and_reports_one_result() {
let failed = serde_json::to_value(harness.event().await).unwrap();
assert_eq!(failed["kind"], "nextTurnConfigured");
assert_eq!(failed["failure"], "requestFailed");
assert!(failed.get("configOptions").is_none(), "no request succeeded");
assert!(
failed.get("configOptions").is_none(),
"no request succeeded"
);
assert!(failed["message"].as_str().unwrap().contains("Rejected"));
assert_eq!(harness.stop().await, Ok(()));
}
Expand Down
Loading