Skip to content
Merged
1,144 changes: 1,144 additions & 0 deletions .omo/plans/flag-experiment-master-plan.md

Large diffs are not rendered by default.

41 changes: 38 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ autobins = false
members = [
".",
"crates/jcode-agent-runtime",
"crates/jcode-experiment-flags",
"crates/jcode-app-core",
"crates/jcode-base",
"crates/jcode-tui",
Expand Down Expand Up @@ -77,6 +78,7 @@ members = [
[workspace.dependencies]
ffs-search = { git = "https://github.com/quangdang46/fast_file_search", rev = "4cd7f27" }
ffs-engine = { git = "https://github.com/quangdang46/fast_file_search", rev = "4cd7f27" }
strum = { version = "0.26", features = ["derive"] }

[lib]
name = "jcode"
Expand Down Expand Up @@ -253,6 +255,7 @@ jcode-task-types = { path = "crates/jcode-task-types" }
jcode-tool-core = { path = "crates/jcode-tool-core" }
jcode-tool-types = { path = "crates/jcode-tool-types" }
jcode-side-panel-types = { path = "crates/jcode-side-panel-types" }
jcode-experiment-flags = { path = "crates/jcode-experiment-flags" }

# Archive extraction (for auto-update)
flate2 = "1"
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-app-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ jcode-build-meta = { path = "../jcode-build-meta" }
# Re-exported via `pub use jcode_base::*` in lib.rs. default-features=false so
# this crate controls jcode-base's optional features (see [features] below).
jcode-base = { path = "../jcode-base", default-features = false }
jcode-experiment-flags = { path = "../jcode-experiment-flags" }
jcode-compaction-core = { path = "../jcode-compaction-core" }
jcode-config-types = { path = "../jcode-config-types" }
jcode-core = { path = "../jcode-core" }
Expand Down
7 changes: 6 additions & 1 deletion crates/jcode-app-core/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,12 @@ impl Agent {
memory: &crate::memory::PendingMemory,
) -> (Message, bool) {
let message = Self::memory_injection_message(memory);
let persist = crate::config::config().features.persist_memory_injections;
let persist = {
let config = crate::config::config();
let experiments =
jcode_experiment_flags::Experiments::from_config(&config.experiments.entries);
experiments.check(jcode_experiment_flags::ExperimentFlag::PersistMemoryInjection)
};
if persist {
self.add_message_with_display_role(
Role::User,
Expand Down
41 changes: 40 additions & 1 deletion crates/jcode-app-core/src/server/client_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,12 @@ pub(super) async fn handle_client(
let registry = Registry::new(provider.clone(), crate::tool::shared_agent_registry()).await;
let registry_ms = t0.elapsed().as_millis();

let mut swarm_enabled = crate::config::config().features.swarm;
// Gate swarm coordination on the SwarmCoordination experiment flag.
// Falls back to the legacy features.swarm value if the experiment is not set.
let mut swarm_enabled = jcode_experiment_flags::Experiments::from_config(
&crate::config::config().experiments.entries,
)
.check(jcode_experiment_flags::ExperimentFlag::SwarmCoordination);
let mut last_available_models_snapshot: Option<String> = None;
const MAX_LIVE_AVAILABLE_MODELS_UPDATE_BYTES: usize = 64 * 1024;

Expand Down Expand Up @@ -2409,6 +2414,40 @@ pub(super) async fn handle_client(
.await;
}

Request::ExperimentList { id: _ } => {
let config = crate::config::config();
let experiments =
jcode_experiment_flags::Experiments::from_config(&config.experiments.entries);
let states = experiments.all_flag_states();
let flags: Vec<jcode_protocol::ExperimentFlagWire> = states
.iter()
.map(|s| jcode_protocol::ExperimentFlagWire {
flag: format!("{:?}", s.flag),
key: s.key.to_string(),
stage: format!("{:?}", s.stage),
enabled: s.enabled,
default_enabled: s.default_enabled,
})
.collect();
let _ = client_event_tx.send(ServerEvent::ExperimentFlags { flags });
}

Request::ExperimentSet { id, key, enabled } => {
let mut config = crate::config::Config::load();
config.experiments.entries.insert(key, enabled);
if let Err(e) = config.save() {
crate::logging::error(&format!("Failed to save experiment config: {e}"));
let _ = client_event_tx.send(ServerEvent::Error {
id,
message: format!("Failed to save experiment config: {e}"),
retry_after_secs: None,
});
} else {
crate::config::invalidate_config_cache();
let _ = client_event_tx.send(ServerEvent::Done { id });
}
}

// These are handled via channels, not direct requests from TUI
Request::ClientDebugCommand { id, .. } => {
handle_client_debug_command(id, &client_event_tx).await;
Expand Down
5 changes: 4 additions & 1 deletion crates/jcode-app-core/src/server/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ pub(super) async fn create_headless_session(
report_back_to_session_id: Option<String>,
) -> Result<String> {
let memory_enabled = crate::config::config().features.memory;
let swarm_enabled = crate::config::config().features.swarm;
let swarm_enabled = jcode_experiment_flags::Experiments::from_config(
&crate::config::config().experiments.entries,
)
.check(jcode_experiment_flags::ExperimentFlag::SwarmCoordination);

let working_dir = if let Some(path_str) = command.strip_prefix("create_session:") {
let path_str = path_str.trim();
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-base/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ jcode-build-support = { path = "../jcode-build-support" }
jcode-build-meta = { path = "../jcode-build-meta" }
jcode-compaction-core = { path = "../jcode-compaction-core" }
jcode-config-types = { path = "../jcode-config-types" }
jcode-experiment-flags = { path = "../jcode-experiment-flags" }
jcode-core = { path = "../jcode-core" }
jcode-memory-types = { path = "../jcode-memory-types" }
jcode-message-types = { path = "../jcode-message-types" }
Expand Down
14 changes: 9 additions & 5 deletions crates/jcode-base/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
pub use jcode_config_types::{
AgentsConfig, AmbientConfig, AuthConfig, AutoJudgeConfig, AutoReviewConfig, CompactionConfig,
CompactionMode, CrossProviderFailoverMode, DiagramDisplayMode, DiagramPanePosition,
DiffDisplayMode, DisplayConfig, FeatureConfig, GatewayConfig, KeybindingsConfig,
MarkdownSpacingMode, NamedProviderAuth, NamedProviderConfig, NamedProviderModelConfig,
NamedProviderType, NativeScrollbarConfig, ProviderConfig, ReasoningDisplayMode, SafetyConfig,
SessionPickerResumeAction, SwarmSpawnMode, TerminalConfig, UpdateChannel, WebSearchConfig,
WebSearchEngine,
DiffDisplayMode, DisplayConfig, ExperimentConfig, FeatureConfig, GatewayConfig,
KeybindingsConfig, MarkdownSpacingMode, NamedProviderAuth, NamedProviderConfig,
NamedProviderModelConfig, NamedProviderType, NativeScrollbarConfig, ProviderConfig,
ReasoningDisplayMode, SafetyConfig, SessionPickerResumeAction, SwarmSpawnMode, TerminalConfig,
UpdateChannel, WebSearchConfig, WebSearchEngine,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashSet};
Expand Down Expand Up @@ -393,6 +393,10 @@ pub struct Config {
/// Feature toggles
pub features: FeatureConfig,

/// Experiment flags section
#[serde(default)]
pub experiments: ExperimentConfig,

/// Web search tool configuration
pub websearch: WebSearchConfig,

Expand Down
10 changes: 10 additions & 0 deletions crates/jcode-base/src/config/config_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ impl Config {
anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e)
})?;
config.display.apply_legacy_compat();
// Migrate legacy [features] toggles into [experiments] for users who
// haven't yet updated their config. Non-default values are propagated
// so behavior is preserved across the FeatureConfig -> ExperimentConfig
// transition.
jcode_experiment_flags::migrate_feature_legacy_into(
&mut config.experiments.entries,
Some(config.features.dcp_enabled),
Some(config.features.swarm),
Some(config.features.persist_memory_injections),
);
Ok(Some(config))
}

Expand Down
31 changes: 31 additions & 0 deletions crates/jcode-base/src/config/env_overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,9 @@ impl Config {
if let Ok(v) = std::env::var("JCODE_SWARM_ENABLED") {
if let Some(parsed) = parse_env_bool(&v) {
self.features.swarm = parsed;
self.experiments
.entries
.insert("swarm".to_string(), parsed);
}
}
if let Ok(v) = std::env::var("JCODE_MESSAGE_TIMESTAMPS") {
Expand All @@ -321,6 +324,9 @@ impl Config {
if let Ok(v) = std::env::var("JCODE_PERSIST_MEMORY_INJECTIONS") {
if let Some(parsed) = parse_env_bool(&v) {
self.features.persist_memory_injections = parsed;
self.experiments
.entries
.insert("persist_memory_injections".to_string(), parsed);
}
}
if let Ok(v) = std::env::var("JCODE_UPDATE_CHANNEL") {
Expand Down Expand Up @@ -665,6 +671,31 @@ impl Config {
}
}

// Experiment flags: JCODE_EXPERIMENTS overrides config
// Format: comma-separated key=value pairs
if let Ok(raw) = std::env::var("JCODE_EXPERIMENTS") {
for pair in raw.split(',') {
let pair = pair.trim();
if let Some((key, value)) = pair.split_once('=') {
let key = key.trim().to_string();
if let Some(val) = parse_env_bool(value.trim()) {
self.experiments.entries.insert(key, val);
}
}
}
}

// Individual experiment flag env vars (higher priority than JCODE_EXPERIMENTS)
// JCODE_DCP_ENABLED, JCODE_SWARM, JCODE_HOOKS_V2, etc.
for spec in jcode_experiment_flags::EXPERIMENT_FLAGS {
let env_key = format!("JCODE_{}", spec.key.to_uppercase());
if let Ok(raw) = std::env::var(&env_key) {
if let Some(val) = parse_env_bool(&raw) {
self.experiments.entries.insert(spec.key.to_string(), val);
}
}
}

// Copilot premium mode: env var overrides config
// If set in config but not in env, propagate config -> env
if let Ok(v) = std::env::var("JCODE_COPILOT_PREMIUM") {
Expand Down
12 changes: 12 additions & 0 deletions crates/jcode-config-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,18 @@ impl Default for FeatureConfig {
}
}

/// Experiment flags section in config.toml — dynamically keyed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ExperimentConfig {
/// Dynamic experiment flag entries, e.g.:
/// [experiments]
/// hooks_v2 = true
/// js_plugins = false
#[serde(flatten)]
pub entries: BTreeMap<String, bool>,
}

/// Search engine used by the websearch tool.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "lowercase")]
Expand Down
11 changes: 11 additions & 0 deletions crates/jcode-experiment-flags/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[package]
name = "jcode-experiment-flags"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
publish = false

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1" }
strum = { workspace = true, features = ["derive"] }
Loading
Loading