Files
dev/code-rs/core/src/environment_context.rs
T
James Peter 5764da6c85 Merge branch 'issue-376-upgrade-repo' into main
# Conflicts:
#	code-rs/core/src/environment_context.rs
2025-11-07 16:32:19 +10:00

1251 lines
45 KiB
Rust

use chrono::Local;
use os_info::Type as OsType;
use os_info::Version;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use sha1::{Digest, Sha1};
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use strum_macros::Display as DeriveDisplay;
use which::which;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use crate::shell::Shell;
use code_protocol::config_types::SandboxMode;
use code_protocol::models::ContentItem;
use code_protocol::models::ResponseItem;
use code_protocol::protocol::BROWSER_SNAPSHOT_CLOSE_TAG;
use code_protocol::protocol::BROWSER_SNAPSHOT_OPEN_TAG;
use code_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
use code_protocol::protocol::ENVIRONMENT_CONTEXT_DELTA_CLOSE_TAG;
use code_protocol::protocol::ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG;
use code_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, DeriveDisplay)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum NetworkAccess {
Restricted,
Enabled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename = "environment_context", rename_all = "snake_case")]
pub(crate) struct EnvironmentContext {
pub cwd: Option<PathBuf>,
pub approval_policy: Option<AskForApproval>,
pub sandbox_mode: Option<SandboxMode>,
pub network_access: Option<NetworkAccess>,
pub writable_roots: Option<Vec<PathBuf>>,
pub operating_system: Option<OperatingSystemInfo>,
pub common_tools: Option<Vec<String>>,
pub shell: Option<Shell>,
pub current_date: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct OperatingSystemInfo {
pub family: Option<String>,
pub version: Option<String>,
pub architecture: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct ToolCandidate {
pub label: &'static str,
pub detection_names: &'static [&'static str],
}
pub const TOOL_CANDIDATES: &[ToolCandidate] = &[
ToolCandidate { label: "git", detection_names: &["git"] },
ToolCandidate { label: "gh", detection_names: &["gh"] },
ToolCandidate { label: "rg", detection_names: &["rg"] },
ToolCandidate { label: "fd", detection_names: &["fd", "fdfind"] },
ToolCandidate { label: "fzf", detection_names: &["fzf"] },
ToolCandidate { label: "jq", detection_names: &["jq"] },
ToolCandidate { label: "yq", detection_names: &["yq"] },
ToolCandidate { label: "sed", detection_names: &["sed"] },
ToolCandidate { label: "awk", detection_names: &["awk"] },
ToolCandidate { label: "xargs", detection_names: &["xargs"] },
ToolCandidate { label: "parallel", detection_names: &["parallel"] },
ToolCandidate { label: "curl", detection_names: &["curl"] },
ToolCandidate { label: "wget", detection_names: &["wget"] },
ToolCandidate { label: "tar", detection_names: &["tar"] },
ToolCandidate { label: "unzip", detection_names: &["unzip"] },
ToolCandidate { label: "gzip", detection_names: &["gzip"] },
ToolCandidate { label: "zstd", detection_names: &["zstd"] },
ToolCandidate { label: "make", detection_names: &["make"] },
ToolCandidate { label: "just", detection_names: &["just"] },
ToolCandidate { label: "node", detection_names: &["node"] },
ToolCandidate { label: "npm", detection_names: &["npm"] },
ToolCandidate { label: "pnpm", detection_names: &["pnpm"] },
ToolCandidate { label: "python3", detection_names: &["python3"] },
ToolCandidate { label: "pipx", detection_names: &["pipx"] },
ToolCandidate { label: "go", detection_names: &["go"] },
ToolCandidate { label: "rustup", detection_names: &["rustup"] },
ToolCandidate { label: "cargo", detection_names: &["cargo"] },
ToolCandidate { label: "rustc", detection_names: &["rustc"] },
ToolCandidate { label: "shellcheck", detection_names: &["shellcheck"] },
ToolCandidate { label: "shfmt", detection_names: &["shfmt"] },
ToolCandidate { label: "docker", detection_names: &["docker"] },
ToolCandidate { label: "docker compose", detection_names: &["docker", "docker-compose"] },
ToolCandidate { label: "sqlite3", detection_names: &["sqlite3"] },
ToolCandidate { label: "duckdb", detection_names: &["duckdb"] },
ToolCandidate { label: "rsync", detection_names: &["rsync"] },
ToolCandidate { label: "openssl", detection_names: &["openssl"] },
ToolCandidate { label: "ssh", detection_names: &["ssh"] },
ToolCandidate { label: "dig", detection_names: &["dig"] },
ToolCandidate { label: "nc", detection_names: &["nc", "netcat"] },
ToolCandidate { label: "lsof", detection_names: &["lsof"] },
ToolCandidate { label: "ripgrep-all", detection_names: &["ripgrep-all", "rga"] },
ToolCandidate { label: "entr", detection_names: &["entr"] },
ToolCandidate { label: "watchexec", detection_names: &["watchexec"] },
ToolCandidate { label: "hyperfine", detection_names: &["hyperfine"] },
ToolCandidate { label: "pv", detection_names: &["pv"] },
ToolCandidate { label: "bat", detection_names: &["bat"] },
ToolCandidate { label: "delta", detection_names: &["delta"] },
ToolCandidate { label: "tree", detection_names: &["tree"] },
ToolCandidate { label: "python", detection_names: &["python"] },
ToolCandidate { label: "deno", detection_names: &["deno"] },
ToolCandidate { label: "bun", detection_names: &["bun"] },
ToolCandidate { label: "js", detection_names: &["js"] },
];
impl EnvironmentContext {
pub fn new(
cwd: Option<PathBuf>,
approval_policy: Option<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
shell: Option<Shell>,
) -> Self {
Self {
cwd,
approval_policy,
sandbox_mode: match sandbox_policy {
Some(SandboxPolicy::DangerFullAccess) => Some(SandboxMode::DangerFullAccess),
Some(SandboxPolicy::ReadOnly) => Some(SandboxMode::ReadOnly),
Some(SandboxPolicy::WorkspaceWrite { .. }) => Some(SandboxMode::WorkspaceWrite),
None => None,
},
network_access: match sandbox_policy {
Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled),
Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted),
Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => {
if network_access {
Some(NetworkAccess::Enabled)
} else {
Some(NetworkAccess::Restricted)
}
}
None => None,
},
writable_roots: match sandbox_policy {
Some(SandboxPolicy::WorkspaceWrite { writable_roots, .. }) => {
if writable_roots.is_empty() {
None
} else {
Some(writable_roots.clone())
}
}
_ => None,
},
operating_system: detect_operating_system_info(),
common_tools: detect_common_tools(),
shell,
current_date: Some(Local::now().format("%Y-%m-%d").to_string()),
}
}
/// Compares two environment contexts, ignoring the shell. Useful when
/// comparing turn to turn, since the initial environment_context will
/// include the shell, and then it is not configurable from turn to turn.
#[cfg(test)]
pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool {
let EnvironmentContext {
cwd,
approval_policy,
sandbox_mode,
network_access,
writable_roots,
operating_system,
common_tools,
current_date,
// should compare all fields except shell
shell: _,
} = other;
self.cwd == *cwd
&& self.approval_policy == *approval_policy
&& self.sandbox_mode == *sandbox_mode
&& self.network_access == *network_access
&& self.writable_roots == *writable_roots
&& self.operating_system == *operating_system
&& self.common_tools == *common_tools
&& self.current_date == *current_date
}
}
// Note: The core no longer exposes `TurnContext` here; callers construct
// `EnvironmentContext` directly via `EnvironmentContext::new(...)`.
impl EnvironmentContext {
/// Serializes the environment context to XML. Libraries like `quick-xml`
/// require custom macros to handle Enums with newtypes, so we just do it
/// manually, to keep things simple. Output looks like:
///
/// ```xml
/// <environment_context>
/// <cwd>...</cwd>
/// <approval_policy>...</approval_policy>
/// <sandbox_mode>...</sandbox_mode>
/// <writable_roots>...</writable_roots>
/// <network_access>...</network_access>
/// <operating_system>
/// <family>...</family>
/// <version>...</version>
/// <architecture>...</architecture>
/// </operating_system>
/// <common_tools>...</common_tools>
/// <shell>...</shell>
/// </environment_context>
/// ```
pub fn serialize_to_xml(self) -> String {
let mut lines = vec![ENVIRONMENT_CONTEXT_OPEN_TAG.to_string()];
if let Some(cwd) = self.cwd {
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
}
if let Some(approval_policy) = self.approval_policy {
lines.push(format!(
" <approval_policy>{approval_policy}</approval_policy>"
));
}
if let Some(sandbox_mode) = self.sandbox_mode {
lines.push(format!(" <sandbox_mode>{sandbox_mode}</sandbox_mode>"));
}
if let Some(network_access) = self.network_access {
lines.push(format!(
" <network_access>{network_access}</network_access>"
));
}
if let Some(writable_roots) = self.writable_roots {
lines.push(" <writable_roots>".to_string());
for writable_root in writable_roots {
lines.push(format!(
" <root>{}</root>",
writable_root.to_string_lossy()
));
}
lines.push(" </writable_roots>".to_string());
}
if let Some(operating_system) = self.operating_system {
lines.push(" <operating_system>".to_string());
if let Some(family) = operating_system.family {
lines.push(format!(" <family>{family}</family>"));
}
if let Some(version) = operating_system.version {
lines.push(format!(" <version>{version}</version>"));
}
if let Some(architecture) = operating_system.architecture {
lines.push(format!(" <architecture>{architecture}</architecture>"));
}
lines.push(" </operating_system>".to_string());
}
if let Some(common_tools) = self.common_tools {
if !common_tools.is_empty() {
lines.push(" <common_tools>".to_string());
for tool in common_tools {
lines.push(format!(" <tool>{tool}</tool>"));
}
lines.push(" </common_tools>".to_string());
}
}
if let Some(current_date) = self.current_date {
lines.push(format!(" <current_date>{current_date}</current_date>"));
}
if let Some(shell) = self.shell
&& let Some(shell_name) = shell.name()
{
lines.push(format!(" <shell>{shell_name}</shell>"));
}
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
lines.join("\n")
}
}
impl From<EnvironmentContext> for ResponseItem {
fn from(ec: EnvironmentContext) -> Self {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: ec.serialize_to_xml(),
}],
}
}
}
/// Canonical snapshot of the environment context that is safe to serialize as JSON.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EnvironmentContextSnapshot {
pub version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<AskForApproval>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_mode: Option<SandboxMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_access: Option<NetworkAccess>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub writable_roots: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub operating_system: Option<OperatingSystemInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub common_tools: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shell: Option<Shell>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
}
impl EnvironmentContextSnapshot {
pub const VERSION: u32 = 1;
pub(crate) fn from_context(ctx: &EnvironmentContext) -> Self {
Self {
version: Self::VERSION,
cwd: ctx.cwd.as_ref().map(|p| p.display().to_string()),
approval_policy: ctx.approval_policy.clone(),
sandbox_mode: ctx.sandbox_mode.clone(),
network_access: ctx.network_access.clone(),
writable_roots: ctx
.writable_roots
.as_ref()
.map(|roots| roots.iter().map(|p| p.display().to_string()).collect())
.unwrap_or_default(),
operating_system: ctx.operating_system.clone(),
common_tools: ctx.common_tools.clone().unwrap_or_default(),
shell: ctx.shell.clone(),
git_branch: None,
reasoning_effort: None,
}
}
/// Compute a stable fingerprint that ignores volatile fields such as OS info and shell.
pub fn fingerprint(&self) -> String {
let mut map = BTreeMap::new();
if let Some(cwd) = &self.cwd {
map.insert("cwd", cwd.clone());
}
if let Some(policy) = &self.approval_policy {
map.insert("approval_policy", format!("{policy}"));
}
if let Some(mode) = &self.sandbox_mode {
map.insert("sandbox_mode", format!("{mode}"));
}
if let Some(access) = &self.network_access {
map.insert("network_access", format!("{access}"));
}
if !self.writable_roots.is_empty() {
map.insert("writable_roots", self.writable_roots.join("|"));
}
if let Some(branch) = &self.git_branch {
map.insert("git_branch", branch.clone());
}
if let Some(reasoning) = &self.reasoning_effort {
map.insert("reasoning_effort", reasoning.clone());
}
let encoded = serde_json::to_vec(&map).expect("serializing fingerprint fields");
let mut sha = Sha1::new();
sha.update(encoded);
format!("{:x}", sha.finalize())
}
pub fn diff_from(&self, previous: &EnvironmentContextSnapshot) -> EnvironmentContextDelta {
let mut changes = BTreeMap::new();
if self.cwd != previous.cwd {
changes.insert("cwd".to_string(), option_string_to_json(&self.cwd));
}
if self.approval_policy != previous.approval_policy {
changes.insert(
"approval_policy".to_string(),
serde_json::to_value(&self.approval_policy).unwrap_or(JsonValue::Null),
);
}
if self.sandbox_mode != previous.sandbox_mode {
changes.insert(
"sandbox_mode".to_string(),
serde_json::to_value(&self.sandbox_mode).unwrap_or(JsonValue::Null),
);
}
if self.network_access != previous.network_access {
changes.insert(
"network_access".to_string(),
serde_json::to_value(&self.network_access).unwrap_or(JsonValue::Null),
);
}
if self.writable_roots != previous.writable_roots {
changes.insert(
"writable_roots".to_string(),
JsonValue::Array(self.writable_roots.iter().map(|s| JsonValue::String(s.clone())).collect()),
);
}
if self.operating_system != previous.operating_system {
changes.insert(
"operating_system".to_string(),
serde_json::to_value(&self.operating_system).unwrap_or(JsonValue::Null),
);
}
if self.common_tools != previous.common_tools {
changes.insert(
"common_tools".to_string(),
JsonValue::Array(self.common_tools.iter().map(|s| JsonValue::String(s.clone())).collect()),
);
}
if self.shell != previous.shell {
changes.insert(
"shell".to_string(),
serde_json::to_value(&self.shell).unwrap_or(JsonValue::Null),
);
}
if self.git_branch != previous.git_branch {
changes.insert(
"git_branch".to_string(),
option_string_to_json(&self.git_branch),
);
}
if self.reasoning_effort != previous.reasoning_effort {
changes.insert(
"reasoning_effort".to_string(),
option_string_to_json(&self.reasoning_effort),
);
}
EnvironmentContextDelta {
version: Self::VERSION,
base_fingerprint: previous.fingerprint(),
changes,
}
}
pub fn to_response_item(&self) -> serde_json::Result<ResponseItem> {
self.to_response_item_with_id(None)
}
pub fn to_response_item_with_id(
&self,
stream_id: Option<&str>,
) -> serde_json::Result<ResponseItem> {
snapshot_to_response_item(self, stream_id)
}
pub fn with_metadata(mut self, git_branch: Option<String>, reasoning_effort: Option<String>) -> Self {
self.git_branch = git_branch;
self.reasoning_effort = reasoning_effort;
self
}
/// Applies a delta to this snapshot, producing an updated snapshot.
pub fn apply_delta(&self, delta: &EnvironmentContextDelta) -> EnvironmentContextSnapshot {
let mut updated = self.clone();
for (key, value) in &delta.changes {
match key.as_str() {
"cwd" => {
updated.cwd = value.as_str().map(|s| s.to_string());
}
"approval_policy" => {
if let Ok(policy) = serde_json::from_value::<AskForApproval>(value.clone()) {
updated.approval_policy = Some(policy);
}
}
"sandbox_mode" => {
if let Ok(mode) = serde_json::from_value::<SandboxMode>(value.clone()) {
updated.sandbox_mode = Some(mode);
}
}
"network_access" => {
if let Ok(access) = serde_json::from_value::<NetworkAccess>(value.clone()) {
updated.network_access = Some(access);
}
}
"writable_roots" => {
if let Ok(roots) = serde_json::from_value::<Vec<String>>(value.clone()) {
updated.writable_roots = roots;
}
}
"operating_system" => {
if let Ok(os) = serde_json::from_value::<OperatingSystemInfo>(value.clone()) {
updated.operating_system = Some(os);
}
}
"common_tools" => {
if let Ok(tools) = serde_json::from_value::<Vec<String>>(value.clone()) {
updated.common_tools = tools;
}
}
"shell" => {
if let Ok(shell) = serde_json::from_value::<Shell>(value.clone()) {
updated.shell = Some(shell);
}
}
"git_branch" => {
updated.git_branch = match value {
JsonValue::Null => None,
JsonValue::String(s) => Some(s.clone()),
_ => updated.git_branch.clone(),
};
}
"reasoning_effort" => {
updated.reasoning_effort = match value {
JsonValue::Null => None,
JsonValue::String(s) => Some(s.clone()),
_ => updated.reasoning_effort.clone(),
};
}
_ => {}
}
}
updated
}
}
impl From<&EnvironmentContext> for EnvironmentContextSnapshot {
fn from(ctx: &EnvironmentContext) -> Self {
EnvironmentContextSnapshot::from_context(ctx)
}
}
impl From<EnvironmentContext> for EnvironmentContextSnapshot {
fn from(ctx: EnvironmentContext) -> Self {
EnvironmentContextSnapshot::from_context(&ctx)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EnvironmentContextDelta {
pub version: u32,
pub base_fingerprint: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub changes: BTreeMap<String, JsonValue>,
}
impl EnvironmentContextDelta {
pub fn to_response_item(&self) -> serde_json::Result<ResponseItem> {
self.to_response_item_with_id(None)
}
pub fn to_response_item_with_id(
&self,
stream_id: Option<&str>,
) -> serde_json::Result<ResponseItem> {
delta_to_response_item(self, stream_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BrowserSnapshot {
pub version: u32,
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub captured_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport: Option<ViewportDimensions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
}
impl BrowserSnapshot {
pub const VERSION: u32 = 1;
pub fn new(url: String, captured_at: String) -> Self {
Self {
version: Self::VERSION,
url,
title: None,
captured_at,
viewport: None,
metadata: None,
}
}
pub fn to_response_item(&self) -> serde_json::Result<ResponseItem> {
self.to_response_item_with_id(None)
}
pub fn to_response_item_with_id(
&self,
stream_id: Option<&str>,
) -> serde_json::Result<ResponseItem> {
browser_snapshot_to_response_item(self, stream_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ViewportDimensions {
pub width: u32,
pub height: u32,
}
#[derive(Debug, Default, Clone)]
pub struct EnvironmentContextTracker {
next_sequence: u64,
last_snapshot: Option<EnvironmentContextSnapshot>,
}
impl EnvironmentContextTracker {
pub fn new() -> Self {
Self::default()
}
pub fn observe(
&mut self,
snapshot: EnvironmentContextSnapshot,
) -> Option<EnvironmentContextEmission> {
match &self.last_snapshot {
None => {
self.next_sequence += 1;
self.last_snapshot = Some(snapshot.clone());
Some(EnvironmentContextEmission::Full {
sequence: self.next_sequence,
snapshot,
})
}
Some(previous) => {
if snapshot.fingerprint() == previous.fingerprint() {
None
} else {
self.next_sequence += 1;
let delta = snapshot.diff_from(previous);
self.last_snapshot = Some(snapshot.clone());
Some(EnvironmentContextEmission::Delta {
sequence: self.next_sequence,
snapshot,
delta,
})
}
}
}
}
pub fn last_snapshot(&self) -> Option<&EnvironmentContextSnapshot> {
self.last_snapshot.as_ref()
}
pub(crate) fn emit_response_items(
&mut self,
env_context: &EnvironmentContext,
git_branch: Option<String>,
reasoning_effort: Option<String>,
stream_id: Option<&str>,
) -> serde_json::Result<Option<(EnvironmentContextEmission, Vec<ResponseItem>)>> {
let emission = match self.observe(
EnvironmentContextSnapshot::from_context(env_context)
.with_metadata(git_branch, reasoning_effort),
) {
Some(emission) => emission,
None => return Ok(None),
};
let items = emission
.clone()
.into_response_items_with_id(stream_id)?;
Ok(Some((emission, items)))
}
/// Restores tracker state so the next emission continues at the provided sequence.
pub fn restore(&mut self, last_snapshot: EnvironmentContextSnapshot, next_sequence: u64) {
self.last_snapshot = Some(last_snapshot);
self.next_sequence = next_sequence.max(1);
}
}
#[derive(Debug, Clone)]
pub enum EnvironmentContextEmission {
Full {
sequence: u64,
snapshot: EnvironmentContextSnapshot,
},
Delta {
sequence: u64,
snapshot: EnvironmentContextSnapshot,
delta: EnvironmentContextDelta,
},
}
impl EnvironmentContextEmission {
pub fn sequence(&self) -> u64 {
match self {
EnvironmentContextEmission::Full { sequence, .. }
| EnvironmentContextEmission::Delta { sequence, .. } => *sequence,
}
}
pub fn into_response_items(self) -> serde_json::Result<Vec<ResponseItem>> {
self.into_response_items_with_id(None)
}
pub fn into_response_items_with_id(
self,
stream_id: Option<&str>,
) -> serde_json::Result<Vec<ResponseItem>> {
match self {
EnvironmentContextEmission::Full { snapshot, .. } => Ok(vec![
snapshot.to_response_item_with_id(stream_id)?,
]),
EnvironmentContextEmission::Delta { delta, .. } => Ok(vec![
delta.to_response_item_with_id(stream_id)?,
]),
}
}
pub fn snapshot(&self) -> &EnvironmentContextSnapshot {
match self {
EnvironmentContextEmission::Full { snapshot, .. }
| EnvironmentContextEmission::Delta { snapshot, .. } => snapshot,
}
}
}
fn option_string_to_json(value: &Option<String>) -> JsonValue {
match value {
Some(v) => JsonValue::String(v.clone()),
None => JsonValue::Null,
}
}
fn snapshot_to_response_item(
snapshot: &EnvironmentContextSnapshot,
stream_id: Option<&str>,
) -> serde_json::Result<ResponseItem> {
let json = serde_json::to_string_pretty(snapshot)?;
Ok(ResponseItem::Message {
id: stream_id.map(|id| id.to_string()),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"{}\n{}\n{}",
ENVIRONMENT_CONTEXT_OPEN_TAG, json, ENVIRONMENT_CONTEXT_CLOSE_TAG
),
}],
})
}
fn delta_to_response_item(
delta: &EnvironmentContextDelta,
stream_id: Option<&str>,
) -> serde_json::Result<ResponseItem> {
let json = serde_json::to_string_pretty(delta)?;
Ok(ResponseItem::Message {
id: stream_id.map(|id| id.to_string()),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"{}\n{}\n{}",
ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG, json, ENVIRONMENT_CONTEXT_DELTA_CLOSE_TAG
),
}],
})
}
fn browser_snapshot_to_response_item(
snapshot: &BrowserSnapshot,
stream_id: Option<&str>,
) -> serde_json::Result<ResponseItem> {
let json = serde_json::to_string_pretty(snapshot)?;
Ok(ResponseItem::Message {
id: stream_id.map(|id| id.to_string()),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"{}\n{}\n{}",
BROWSER_SNAPSHOT_OPEN_TAG, json, BROWSER_SNAPSHOT_CLOSE_TAG
),
}],
})
}
fn detect_operating_system_info() -> Option<OperatingSystemInfo> {
let info = os_info::get();
let family = match info.os_type() {
OsType::Unknown => None,
other => Some(other.to_string()),
};
let version = match info.version() {
Version::Unknown => None,
other => {
let text = other.to_string();
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
};
let architecture = {
let arch = std::env::consts::ARCH;
if arch.is_empty() {
None
} else {
Some(arch.to_string())
}
};
if family.is_none() && version.is_none() && architecture.is_none() {
return None;
}
Some(OperatingSystemInfo {
family,
version,
architecture,
})
}
fn detect_common_tools() -> Option<Vec<String>> {
let mut available = Vec::new();
for candidate in TOOL_CANDIDATES {
let detection_names = if candidate.detection_names.is_empty() {
&[candidate.label][..]
} else {
candidate.detection_names
};
if detection_names
.iter()
.any(|name| which(name).is_ok())
{
available.push(candidate.label.to_string());
}
}
if available.is_empty() {
None
} else {
Some(available)
}
}
#[cfg(test)]
mod tests {
use crate::shell::BashShell;
use crate::shell::ZshShell;
use super::*;
use pretty_assertions::assert_eq;
fn workspace_write_policy(writable_roots: Vec<&str>, network_access: bool) -> SandboxPolicy {
SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.into_iter().map(PathBuf::from).collect(),
network_access,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
allow_git_writes: true,
}
}
#[test]
fn serialize_workspace_write_environment_context() {
let mut context = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp"], false)),
None,
);
context.operating_system = None;
context.common_tools = None;
context.current_date = Some("2025-01-02".to_string());
let expected = r#"<environment_context>
<cwd>/repo</cwd>
<approval_policy>on-request</approval_policy>
<sandbox_mode>workspace-write</sandbox_mode>
<network_access>restricted</network_access>
<writable_roots>
<root>/repo</root>
<root>/tmp</root>
</writable_roots>
<current_date>2025-01-02</current_date>
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
}
#[test]
fn serialize_read_only_environment_context() {
let mut context = EnvironmentContext::new(
None,
Some(AskForApproval::Never),
Some(SandboxPolicy::ReadOnly),
None,
);
context.operating_system = None;
context.common_tools = None;
context.current_date = Some("2025-01-02".to_string());
let expected = r#"<environment_context>
<approval_policy>never</approval_policy>
<sandbox_mode>read-only</sandbox_mode>
<network_access>restricted</network_access>
<current_date>2025-01-02</current_date>
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
}
#[test]
fn serialize_full_access_environment_context() {
let mut context = EnvironmentContext::new(
None,
Some(AskForApproval::OnFailure),
Some(SandboxPolicy::DangerFullAccess),
None,
);
context.operating_system = None;
context.common_tools = None;
context.current_date = Some("2025-01-02".to_string());
let expected = r#"<environment_context>
<approval_policy>on-failure</approval_policy>
<sandbox_mode>danger-full-access</sandbox_mode>
<network_access>enabled</network_access>
<current_date>2025-01-02</current_date>
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
}
#[test]
fn serialize_environment_context_includes_os_and_tools() {
let mut context = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], true)),
None,
);
context.operating_system = Some(OperatingSystemInfo {
family: Some("macos".to_string()),
version: Some("14.0".to_string()),
architecture: Some("aarch64".to_string()),
});
context.common_tools = Some(vec!["rg".to_string(), "git".to_string()]);
context.current_date = Some("2025-01-02".to_string());
let xml = context.serialize_to_xml();
assert!(xml.contains("<operating_system>"));
assert!(xml.contains("<family>macos</family>"));
assert!(xml.contains("<version>14.0</version>"));
assert!(xml.contains("<architecture>aarch64</architecture>"));
assert!(xml.contains("<common_tools>"));
assert!(xml.contains("<tool>rg</tool>"));
assert!(xml.contains("<tool>git</tool>"));
assert!(xml.contains("<current_date>2025-01-02</current_date>"));
}
fn message_text(item: &ResponseItem) -> &str {
match item {
ResponseItem::Message { content, .. } => match &content[0] {
ContentItem::InputText { text } => text,
other => panic!("unexpected content item: {other:?}"),
},
other => panic!("unexpected response item: {other:?}"),
}
}
fn parse_tagged_json(text: &str, open: &str, close: &str) -> serde_json::Value {
let trimmed = text.trim();
assert!(trimmed.starts_with(open), "expected prefix {open}");
assert!(trimmed.ends_with(close), "expected suffix {close}");
let inner = &trimmed[open.len()..trimmed.len() - close.len()];
serde_json::from_str(inner.trim()).expect("valid tagged json")
}
#[test]
fn tracker_emits_full_and_delta_messages() {
let mut tracker = EnvironmentContextTracker::new();
let mut ctx = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::DangerFullAccess),
None,
);
let first = tracker
.emit_response_items(&ctx, Some("main".into()), Some("Medium".into()), Some("env-stream"))
.expect("serialize full")
.expect("full emission");
assert!(matches!(first.0, EnvironmentContextEmission::Full { .. }));
assert_eq!(first.0.sequence(), 1);
let full_text = message_text(&first.1[0]);
assert_eq!(message_id(&first.1[0]), Some("env-stream"));
assert!(full_text.contains(ENVIRONMENT_CONTEXT_OPEN_TAG));
assert!(!full_text.contains("== System Status =="));
let full_json = parse_tagged_json(
full_text,
ENVIRONMENT_CONTEXT_OPEN_TAG,
ENVIRONMENT_CONTEXT_CLOSE_TAG,
);
assert_eq!(full_json["git_branch"], "main");
// Unchanged context should not emit again
let none = tracker
.emit_response_items(&ctx, Some("main".into()), Some("Medium".into()), Some("env-stream"))
.expect("unchanged serialize");
assert!(none.is_none());
// Changing stable fields triggers a delta emission
ctx.cwd = Some(PathBuf::from("/repo-two"));
let delta = tracker
.emit_response_items(&ctx, Some("feature".into()), Some("High".into()), Some("env-stream"))
.expect("serialize delta")
.expect("delta emission");
assert!(matches!(delta.0, EnvironmentContextEmission::Delta { .. }));
assert_eq!(delta.0.sequence(), 2);
let delta_text = message_text(&delta.1[0]);
assert_eq!(message_id(&delta.1[0]), Some("env-stream"));
assert!(delta_text.contains(ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG));
let delta_json = parse_tagged_json(
delta_text,
ENVIRONMENT_CONTEXT_DELTA_OPEN_TAG,
ENVIRONMENT_CONTEXT_DELTA_CLOSE_TAG,
);
assert_eq!(delta_json["changes"]["cwd"], "/repo-two");
assert_eq!(delta_json["changes"]["git_branch"], "feature");
assert_eq!(delta_json["changes"]["reasoning_effort"], "High");
}
fn message_id(item: &ResponseItem) -> Option<&str> {
match item {
ResponseItem::Message { id, .. } => id.as_deref(),
_ => None,
}
}
#[test]
fn equals_except_shell_compares_approval_policy() {
// Approval policy
let context1 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], false)),
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::Never),
Some(workspace_write_policy(vec!["/repo"], true)),
None,
);
// ensure current_date doesn't influence this comparison
let fixed_date = Some("2025-01-02".to_string());
let mut context1 = context1;
context1.current_date = fixed_date.clone();
let mut context2 = context2;
context2.current_date = fixed_date;
assert!(!context1.equals_except_shell(&context2));
}
#[test]
fn equals_except_shell_compares_sandbox_policy() {
let context1 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::new_read_only_policy()),
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::new_workspace_write_policy()),
None,
);
let mut context1 = context1;
context1.current_date = Some("2025-01-02".to_string());
let mut context2 = context2;
context2.current_date = Some("2025-01-02".to_string());
assert!(!context1.equals_except_shell(&context2));
}
#[test]
fn equals_except_shell_compares_workspace_write_policy() {
let context1 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)),
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp"], true)),
None,
);
let mut context1 = context1;
context1.current_date = Some("2025-01-02".to_string());
let mut context2 = context2;
context2.current_date = Some("2025-01-02".to_string());
assert!(!context1.equals_except_shell(&context2));
}
#[test]
fn browser_snapshot_respects_stream_id() {
let mut snapshot = BrowserSnapshot::new(
"https://example.test".to_string(),
"2025-11-04T00:00:00Z".to_string(),
);
snapshot.title = Some("Example".to_string());
let message = snapshot
.to_response_item_with_id(Some("browser-stream"))
.expect("serialize snapshot");
assert_eq!(message_id(&message), Some("browser-stream"));
if let ResponseItem::Message { content, .. } = message {
if let ContentItem::InputText { text } = &content[0] {
assert!(text.contains(BROWSER_SNAPSHOT_OPEN_TAG));
assert!(text.contains("Example"));
} else {
panic!("expected text content");
}
} else {
panic!("expected message response item");
}
}
#[test]
fn equals_except_shell_ignores_shell() {
let context1 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], false)),
Some(Shell::Bash(BashShell {
shell_path: "/bin/bash".into(),
bashrc_path: "/home/user/.bashrc".into(),
})),
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], false)),
Some(Shell::Zsh(ZshShell {
shell_path: "/bin/zsh".into(),
zshrc_path: "/home/user/.zshrc".into(),
})),
);
let mut context1 = context1;
context1.current_date = Some("2025-01-02".to_string());
let mut context2 = context2;
context2.current_date = Some("2025-01-02".to_string());
assert!(context1.equals_except_shell(&context2));
}
#[test]
fn snapshot_fingerprint_ignores_volatile_fields() {
let mut ctx_a = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], true)),
None,
);
ctx_a.operating_system = Some(OperatingSystemInfo {
family: Some("macos".into()),
version: Some("14.0".into()),
architecture: Some("arm64".into()),
});
ctx_a.common_tools = Some(vec!["git".into(), "rg".into()]);
let mut ctx_b = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], true)),
None,
);
ctx_b.operating_system = Some(OperatingSystemInfo {
family: Some("ubuntu".into()),
version: Some("22.04".into()),
architecture: Some("x86_64".into()),
});
ctx_b.common_tools = Some(vec!["npm".into(), "node".into()]);
let snap_a = EnvironmentContextSnapshot::from(&ctx_a);
let snap_b = EnvironmentContextSnapshot::from(&ctx_b);
assert_eq!(snap_a.fingerprint(), snap_b.fingerprint());
}
#[test]
fn snapshot_diff_detects_changes() {
let ctx_a = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], false)),
None,
);
let ctx_b = EnvironmentContext::new(
Some(PathBuf::from("/other")),
Some(AskForApproval::Never),
Some(workspace_write_policy(vec!["/repo"], true)),
None,
);
let snap_a = EnvironmentContextSnapshot::from(&ctx_a);
let snap_b = EnvironmentContextSnapshot::from(&ctx_b);
let delta = snap_b.diff_from(&snap_a);
assert!(delta.changes.contains_key("cwd"));
assert!(delta.changes.contains_key("approval_policy"));
assert!(delta.changes.contains_key("network_access"));
assert_eq!(delta.base_fingerprint, snap_a.fingerprint());
}
#[test]
fn tracker_emits_full_then_none_when_unchanged() {
let ctx = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], false)),
None,
);
let snapshot = EnvironmentContextSnapshot::from(&ctx);
let mut tracker = EnvironmentContextTracker::new();
let first = tracker
.observe(snapshot.clone())
.expect("first snapshot should emit");
assert!(matches!(first, EnvironmentContextEmission::Full { .. }));
let second = tracker.observe(snapshot);
assert!(second.is_none(), "unchanged snapshot should not emit");
}
#[test]
fn serialize_environment_context_includes_current_date() {
let mut context = EnvironmentContext::new(None, None, None, None);
context.current_date = Some("2025-01-02".to_string());
let xml = context.serialize_to_xml();
assert!(xml.contains("<current_date>2025-01-02</current_date>"));
}
#[test]
fn current_date_format_is_iso8601() {
let context = EnvironmentContext::new(None, None, None, None);
let date = context
.current_date
.expect("current_date should be populated");
assert_eq!(date.len(), 10);
assert_eq!(date.chars().nth(4), Some('-'));
assert_eq!(date.chars().nth(7), Some('-'));
}
}