mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 04:29:24 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac501c9562 | ||
|
|
d7cadafbab | ||
|
|
f03f69e086 | ||
|
|
84a359272f |
No files matched your search
@@ -180,7 +180,6 @@ async fn persist_exec_checkpoint(
|
||||
Some(write_request.as_ref()),
|
||||
&captured_source,
|
||||
&captured_owner,
|
||||
snapshot.agent_preset.as_deref(),
|
||||
)?;
|
||||
Ok(publication)
|
||||
},
|
||||
@@ -240,7 +239,6 @@ pub async fn run_exec_agent(
|
||||
// allowlist; this also skips workspace snapshots, LSP and the automation
|
||||
// store, and writes the one-shot `--json` receipt shape.
|
||||
one_shot: bool,
|
||||
conversation_mode: Option<String>,
|
||||
) -> Result<()> {
|
||||
use codewhale_config::AppMode;
|
||||
use codewhale_engine::compaction::CompactionConfig;
|
||||
@@ -310,10 +308,6 @@ pub async fn run_exec_agent(
|
||||
let mut exec_write_receipt = resume_session
|
||||
.as_ref()
|
||||
.and_then(|saved| saved.write_receipt.clone());
|
||||
let exec_agent_preset = resume_session.as_ref().map_or_else(
|
||||
|| conversation_mode.clone(),
|
||||
|saved| saved.agent_preset.clone(),
|
||||
);
|
||||
let exec_is_fresh = resume_session.is_none();
|
||||
|
||||
let exec_session_id = fleet_capture
|
||||
@@ -790,7 +784,6 @@ pub async fn run_exec_agent(
|
||||
model: saved.metadata.model,
|
||||
workspace: saved.metadata.workspace,
|
||||
mode,
|
||||
agent_preset: saved.agent_preset,
|
||||
})
|
||||
.await?;
|
||||
loaded_session_id = Some(saved_id.clone());
|
||||
@@ -799,24 +792,6 @@ pub async fn run_exec_agent(
|
||||
}
|
||||
}
|
||||
|
||||
if loaded_session_id.is_none() && exec_agent_preset.is_some() {
|
||||
let snapshot = engine_handle.get_session_snapshot().await?;
|
||||
engine_handle
|
||||
.send(Op::SyncSession {
|
||||
expected: None,
|
||||
control: codewhale_engine::core::ops::SessionControlId::new(),
|
||||
session_id: Some(snapshot.session_id),
|
||||
messages: Vec::new(),
|
||||
system_prompt: None,
|
||||
system_prompt_override: false,
|
||||
model: snapshot.model,
|
||||
workspace: snapshot.workspace,
|
||||
mode,
|
||||
agent_preset: exec_agent_preset.clone(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Lifecycle outbox (`[lifecycle_outbox]`): headless `codewhale exec`
|
||||
// gets the same turn boundaries as the interactive TUI. Disabled
|
||||
// (all emits no-op) when the config has no path.
|
||||
@@ -1465,7 +1440,6 @@ pub async fn run_exec_agent(
|
||||
None,
|
||||
source,
|
||||
owner,
|
||||
exec_agent_preset.as_deref(),
|
||||
)
|
||||
});
|
||||
match saved {
|
||||
@@ -2153,7 +2127,6 @@ mod tests {
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "exec".into(),
|
||||
agent_preset: None,
|
||||
});
|
||||
let caller = SessionCheckpointCaller {
|
||||
session_id: snapshot.session_id.clone(),
|
||||
@@ -2222,7 +2195,6 @@ mod tests {
|
||||
None,
|
||||
source.as_ref().unwrap(),
|
||||
snapshot.source.as_ref().unwrap(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
source = Some(publication.write_receipt());
|
||||
@@ -2346,58 +2318,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_session_file_keeps_the_conversation_mode_it_was_started_in() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let manager =
|
||||
codewhale_engine::session_manager::SessionManager::new(tmp.path().join("exec-mode"))
|
||||
.unwrap();
|
||||
let workspace = tmp.path().join("workspace");
|
||||
let messages: Vec<codewhale_models::Message> = serde_json::from_value(serde_json::json!([
|
||||
{"role":"user","content":[{"type":"text","text":"hello"}]}
|
||||
]))
|
||||
.unwrap();
|
||||
let owner =
|
||||
codewhale_engine::core::ops::SessionSnapshotSource::turn("exec-turn".into(), None);
|
||||
let save = |source: &codewhale_engine::session_manager::SessionWriteReceipt,
|
||||
preset: Option<&str>| {
|
||||
crate::exec_agent::persist_exec_session(
|
||||
&messages,
|
||||
"model",
|
||||
crate::exec_agent::PersistedProviderRoute {
|
||||
kind: "custom",
|
||||
id: None,
|
||||
},
|
||||
&workspace,
|
||||
&None,
|
||||
Some("exec-mode-session"),
|
||||
1,
|
||||
None,
|
||||
&manager,
|
||||
&[],
|
||||
None,
|
||||
source,
|
||||
&owner,
|
||||
preset,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let first = save(
|
||||
&manager.expect_absent_session("exec-mode-session").unwrap(),
|
||||
Some("creator"),
|
||||
);
|
||||
let saved = manager.load_session_snapshot("exec-mode-session").unwrap();
|
||||
assert_eq!(saved.agent_preset.as_deref(), Some("creator"));
|
||||
|
||||
save(&first.write_receipt(), None);
|
||||
let saved = manager.load_session_snapshot("exec-mode-session").unwrap();
|
||||
assert_eq!(
|
||||
saved.agent_preset.as_deref(),
|
||||
Some("creator"),
|
||||
"a later save without the mode does not turn the conversation into Standard"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_child_settlement_deadline_bounds_a_stalled_engine_probe() {
|
||||
let mut engine = mock_engine_handle();
|
||||
@@ -3457,7 +3377,6 @@ fn persist_exec_session(
|
||||
checkpoint: Option<&codewhale_engine::core::events::SessionCheckpointRequest>,
|
||||
source: &session_manager::SessionWriteReceipt,
|
||||
owner: &codewhale_engine::core::ops::SessionSnapshotSource,
|
||||
agent_preset: Option<&str>,
|
||||
) -> Result<session_manager::SessionPublication> {
|
||||
let captured = source.capture(session_manager::SessionWriteOwner::Engine(owner.clone()))?;
|
||||
if checkpoint.is_some() {
|
||||
@@ -3503,9 +3422,6 @@ fn persist_exec_session(
|
||||
Some("exec"),
|
||||
)
|
||||
};
|
||||
if saved.agent_preset.is_none() {
|
||||
saved.agent_preset = agent_preset.map(str::to_string);
|
||||
}
|
||||
saved.write_receipt = Some(captured);
|
||||
stamp_exec_session_metadata(
|
||||
&mut saved,
|
||||
|
||||
@@ -7649,7 +7649,6 @@ async fn session_save_without_an_id_keeps_the_conversation_that_owns_the_snapsho
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".to_string(),
|
||||
agent_preset: None,
|
||||
};
|
||||
if let Some(tx) = tx.lock().ok().and_then(|mut guard| guard.take()) {
|
||||
let _ = tx.send(snapshot);
|
||||
|
||||
+1
-26
@@ -156,10 +156,6 @@ struct Cli {
|
||||
session_id: Option<String>,
|
||||
#[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
|
||||
prompt_flag: Option<String>,
|
||||
/// Conversation mode for a new interactive session (for example
|
||||
/// `creator`). With `--resume` or `--continue` it must match the saved mode.
|
||||
#[arg(long = "conversation-mode", value_name = "MODE")]
|
||||
conversation_mode: Option<String>,
|
||||
/// Per-run config override (`KEY=VALUE`), repeatable, never saved.
|
||||
/// Runtime keys: provider, model/default_text_model, verbosity,
|
||||
/// approval_policy, sandbox_mode, telemetry. Dedicated flags win;
|
||||
@@ -2968,10 +2964,6 @@ fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
|
||||
forwarded.push("--resume".to_string());
|
||||
forwarded.push(session_id.to_string());
|
||||
}
|
||||
if let Some(mode) = cli.conversation_mode.as_deref() {
|
||||
forwarded.push("--conversation-mode".to_string());
|
||||
forwarded.push(mode.to_string());
|
||||
}
|
||||
|
||||
let prompt =
|
||||
cli.prompt_flag
|
||||
@@ -3057,6 +3049,7 @@ fn start_cli_telemetry(
|
||||
telemetry::init(consent);
|
||||
telemetry::record(Event::SessionStart {
|
||||
source: SessionSource::Unknown,
|
||||
install_age: telemetry::install_age(),
|
||||
});
|
||||
Some(CliTelemetrySession {
|
||||
started: std::time::Instant::now(),
|
||||
@@ -13340,24 +13333,6 @@ verbosity = "concise"
|
||||
assert!(root_tui_passthrough(&cli).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_conversation_mode_is_forwarded_and_is_not_prompt_text() {
|
||||
let cli = parse_ok(&["codewhale", "--conversation-mode", "creator"]);
|
||||
|
||||
assert_eq!(cli.conversation_mode.as_deref(), Some("creator"));
|
||||
assert!(cli.prompt.is_empty());
|
||||
assert_eq!(
|
||||
root_tui_passthrough(&cli).unwrap(),
|
||||
vec!["--conversation-mode", "creator"]
|
||||
);
|
||||
|
||||
let cli = parse_ok(&["codewhale", "--conversation-mode", "creator", "hello"]);
|
||||
assert_eq!(
|
||||
root_tui_passthrough(&cli).unwrap(),
|
||||
vec!["--conversation-mode", "creator", "--prompt", "hello"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_top_level_continue_for_interactive_resume() {
|
||||
let cli = parse_ok(&["codewhale", "--continue"]);
|
||||
|
||||
@@ -1361,7 +1361,6 @@ pub struct SessionSyncPayload {
|
||||
pub model: String,
|
||||
pub workspace: PathBuf,
|
||||
pub mode: CommandMode,
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
|
||||
/// `/branch` success projection (`session/branch.rs`). The handler composes
|
||||
@@ -1487,15 +1486,10 @@ pub trait CommandSessionLifecycleContext {
|
||||
/// `/fork <session_id|prefix>`: explicit-source fork.
|
||||
fn fork_from(&mut self, session_id_or_prefix: &str) -> Result<SessionForkFromReceipt, String>;
|
||||
|
||||
/// `/new [<mode>] [--force]`: fresh-session transition. The caller has
|
||||
/// already parsed the arguments and applied the transition-blocked gate;
|
||||
/// `conversation_mode` is the id the person named, absent for Standard.
|
||||
/// The host validates it against the Engine's mode table.
|
||||
fn fresh_session(
|
||||
&mut self,
|
||||
force: bool,
|
||||
conversation_mode: Option<&str>,
|
||||
) -> Result<SessionNewReceipt, String>;
|
||||
/// `/new [--force]`: fresh-session transition. The caller has already
|
||||
/// parsed the argument and applied the transition-blocked gate; blocker,
|
||||
/// busy-work-state, and success handling match the baseline.
|
||||
fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String>;
|
||||
|
||||
/// `/load <path>`: resolve the path (separator-bearing direct vs
|
||||
/// workspace-relative) and validate the saved-session shape without
|
||||
|
||||
@@ -141,9 +141,6 @@ pub enum ConfigAction {
|
||||
// Views and pickers.
|
||||
OpenConfigView,
|
||||
OpenModePicker,
|
||||
SetConversationMode {
|
||||
id: String,
|
||||
},
|
||||
OpenThemePicker,
|
||||
OpenStatusPicker,
|
||||
OpenProviderPicker,
|
||||
|
||||
@@ -2333,14 +2333,10 @@ impl CommandSessionLifecycleContext for FakeLifecycle {
|
||||
.clone()
|
||||
.ok_or_else(|| format!("unexpected fork_from({id}) on empty fake"))?
|
||||
}
|
||||
fn fresh_session(
|
||||
&mut self,
|
||||
force: bool,
|
||||
conversation_mode: Option<&str>,
|
||||
) -> Result<SessionNewReceipt, String> {
|
||||
self.fresh.clone().ok_or_else(|| {
|
||||
format!("unexpected fresh_session({force}, {conversation_mode:?}) on empty fake")
|
||||
})?
|
||||
fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String> {
|
||||
self.fresh
|
||||
.clone()
|
||||
.ok_or_else(|| format!("unexpected fresh_session({force}) on empty fake"))?
|
||||
}
|
||||
fn load_session(&mut self, path: &str) -> Result<PathBuf, String> {
|
||||
self.load
|
||||
@@ -2380,7 +2376,6 @@ fn lifecycle_sync_payload(session_id: Option<&str>) -> SessionSyncPayload {
|
||||
model: "lifecycle-model".to_string(),
|
||||
workspace: PathBuf::from("/workspace/lifecycle"),
|
||||
mode: CommandMode::Plan,
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2460,7 +2455,7 @@ fn lifecycle_facet_is_object_safe_and_transports_every_outcome() {
|
||||
explicit.sync.workspace,
|
||||
PathBuf::from("/workspace/lifecycle")
|
||||
);
|
||||
let fresh = fake.fresh_session(true, None).expect("fresh ok");
|
||||
let fresh = fake.fresh_session(true).expect("fresh ok");
|
||||
assert_eq!(fresh.truncated_id, "new-id");
|
||||
assert_eq!(fresh.sync.messages.len(), 1);
|
||||
let loaded = fake.load_session("loaded.json").expect("load ok");
|
||||
|
||||
@@ -417,7 +417,6 @@ fn synced_conversation() -> SessionSyncPayload {
|
||||
model: "model".to_string(),
|
||||
workspace: std::path::PathBuf::from("/tmp/undo-workspace"),
|
||||
mode: codewhale_command_contract::CommandMode::Agent,
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
struct UndoFacet {
|
||||
|
||||
@@ -484,6 +484,22 @@ pub fn public_model_identifier(model: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Case-insensitive form of [`public_model_identifier`] for ids that were
|
||||
/// lowercased before the lookup.
|
||||
#[must_use]
|
||||
pub fn public_model_identifier_ignore_case(model: &str) -> bool {
|
||||
let catalog = super::bundled_models_dev_catalog();
|
||||
let matches = |id: &str| id.eq_ignore_ascii_case(model);
|
||||
catalog.reviewed.intrinsic.keys().any(|id| matches(id))
|
||||
|| catalog.models.keys().any(|id| matches(id))
|
||||
|| catalog.providers.values().any(|provider| {
|
||||
provider
|
||||
.models
|
||||
.iter()
|
||||
.any(|(id, row)| matches(id) || matches(&row.id))
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn official_deepseek_model_id(model: &str) -> Option<&'static str> {
|
||||
bundled_reviewed()
|
||||
|
||||
@@ -45,7 +45,7 @@ pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
|
||||
/// when the collection policy, schema, or disclosure materially changes. Prior
|
||||
/// declines remain off. Keying it to the app version would re-prompt every
|
||||
/// release, which is nagging with extra steps.
|
||||
pub const TELEMETRY_NOTICE_VERSION: &str = "6";
|
||||
pub const TELEMETRY_NOTICE_VERSION: &str = "8";
|
||||
|
||||
/// Canonical setup step ids. The ordering matches the first-run spine so a
|
||||
/// `BTreeMap<SetupStep, _>` renders in wizard order.
|
||||
|
||||
@@ -3504,7 +3504,6 @@ impl Engine {
|
||||
.as_deref()
|
||||
.map(crate::project_context::InstructionAssembly::receipt_json),
|
||||
mode: self.current_mode.as_setting().to_string(),
|
||||
agent_preset: self.session.agent_preset.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4504,7 +4503,6 @@ impl Engine {
|
||||
model,
|
||||
workspace,
|
||||
mode,
|
||||
agent_preset,
|
||||
} => {
|
||||
if expected
|
||||
.as_deref()
|
||||
@@ -4525,9 +4523,6 @@ impl Engine {
|
||||
} else {
|
||||
previous_session_id.clone()
|
||||
};
|
||||
let keep_conversation_mode = next_session_id == previous_session_id
|
||||
&& !self.session.messages.is_empty()
|
||||
&& self.session.agent_preset != agent_preset;
|
||||
let closed_session_id = (next_session_id != previous_session_id)
|
||||
.then_some(previous_session_id);
|
||||
// SyncSession installs a conversation's identity; an id
|
||||
@@ -4594,14 +4589,6 @@ impl Engine {
|
||||
self.session.model = model;
|
||||
self.session.workspace = workspace.clone();
|
||||
self.current_mode = mode;
|
||||
if keep_conversation_mode {
|
||||
tracing::warn!(
|
||||
target: "conversation_mode",
|
||||
"ignored a conversation mode change on a conversation that already has messages"
|
||||
);
|
||||
} else {
|
||||
self.session.agent_preset = agent_preset;
|
||||
}
|
||||
self.config.model.clone_from(&self.session.model);
|
||||
self.config.workspace = workspace.clone();
|
||||
if plugin_workspace_changed {
|
||||
@@ -11806,6 +11793,7 @@ fn record_model_turn_telemetry(
|
||||
{
|
||||
return;
|
||||
}
|
||||
let model = turn.last_parent_model.as_deref().unwrap_or(model);
|
||||
let outcome = match status {
|
||||
TurnOutcomeStatus::Completed => codewhale_telemetry::TurnOutcome::Ok,
|
||||
TurnOutcomeStatus::Failed => codewhale_telemetry::TurnOutcome::Failed,
|
||||
|
||||
@@ -73,6 +73,7 @@ include!("tests/test_cases_18.rs");
|
||||
include!("tests/test_cases_19.rs");
|
||||
|
||||
mod admission_gates;
|
||||
mod model_turn_telemetry;
|
||||
mod redacted_thinking;
|
||||
mod runtime_state;
|
||||
mod sse_turn_recovery;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn model_turn_telemetry_names_the_model_the_last_request_used() {
|
||||
let mut turn = crate::core::turn::TurnContext::new(4);
|
||||
turn.add_parent_usage(&Usage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
..Usage::default()
|
||||
});
|
||||
turn.note_parent_model("deepseek-v4pro");
|
||||
|
||||
record_model_turn_telemetry(
|
||||
codewhale_config::ProviderKind::Deepseek,
|
||||
"auto",
|
||||
&turn,
|
||||
TurnOutcomeStatus::Completed,
|
||||
);
|
||||
|
||||
let reported = codewhale_telemetry::session_counters()
|
||||
.usage_events()
|
||||
.into_iter()
|
||||
.any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
codewhale_telemetry::Event::ModelUsage { provider, model, .. }
|
||||
if provider == "deepseek" && model == "deepseek-v4-pro"
|
||||
)
|
||||
});
|
||||
assert!(reported, "the resolved wire model, not `auto`, is reported");
|
||||
}
|
||||
@@ -643,7 +643,6 @@ async fn edit_last_turn_restores_the_exchange_when_the_replacement_never_starts(
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -665,111 +664,6 @@ async fn edit_last_turn_restores_the_exchange_when_the_replacement_never_starts(
|
||||
run.await.unwrap();
|
||||
}
|
||||
|
||||
async fn sync_with_mode(
|
||||
handle: &EngineHandle,
|
||||
workspace: &std::path::Path,
|
||||
session_id: &str,
|
||||
messages: Vec<Message>,
|
||||
agent_preset: Option<&str>,
|
||||
) {
|
||||
handle
|
||||
.send(Op::SyncSession {
|
||||
expected: None,
|
||||
control: crate::core::ops::SessionControlId::new(),
|
||||
session_id: Some(session_id.to_string()),
|
||||
messages,
|
||||
system_prompt: None,
|
||||
system_prompt_override: false,
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: workspace.to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: agent_preset.map(str::to_string),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn user_text(text: &str) -> Message {
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_session_installs_the_conversation_mode_on_the_engine_session() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let (engine, handle) = quiet_engine(deterministic_engine_config(tmp.path()));
|
||||
let run = tokio::spawn(engine.run());
|
||||
assert_eq!(session_snapshot(&handle).await.agent_preset, None);
|
||||
sync_with_mode(
|
||||
&handle,
|
||||
tmp.path(),
|
||||
"blank-one",
|
||||
Vec::new(),
|
||||
Some("creator"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
session_snapshot(&handle).await.agent_preset.as_deref(),
|
||||
Some("creator")
|
||||
);
|
||||
sync_with_mode(&handle, tmp.path(), "blank-one", Vec::new(), None).await;
|
||||
assert_eq!(
|
||||
session_snapshot(&handle).await.agent_preset,
|
||||
None,
|
||||
"a still-blank conversation may change its mode"
|
||||
);
|
||||
sync_with_mode(
|
||||
&handle,
|
||||
tmp.path(),
|
||||
"other",
|
||||
vec![user_text("hi")],
|
||||
Some("creator"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
session_snapshot(&handle).await.agent_preset.as_deref(),
|
||||
Some("creator"),
|
||||
"another conversation installs its own mode"
|
||||
);
|
||||
sync_with_mode(&handle, tmp.path(), "fresh", Vec::new(), None).await;
|
||||
assert_eq!(session_snapshot(&handle).await.agent_preset, None);
|
||||
handle.send(Op::Shutdown).await.unwrap();
|
||||
run.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_sync_of_the_same_conversation_with_messages_cannot_change_its_conversation_mode() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let (engine, handle) = quiet_engine(deterministic_engine_config(tmp.path()));
|
||||
let run = tokio::spawn(engine.run());
|
||||
let history = vec![user_text("first message")];
|
||||
sync_with_mode(
|
||||
&handle,
|
||||
tmp.path(),
|
||||
"locked",
|
||||
history.clone(),
|
||||
Some("creator"),
|
||||
)
|
||||
.await;
|
||||
sync_with_mode(&handle, tmp.path(), "locked", history.clone(), None).await;
|
||||
assert_eq!(
|
||||
session_snapshot(&handle).await.agent_preset.as_deref(),
|
||||
Some("creator")
|
||||
);
|
||||
sync_with_mode(&handle, tmp.path(), "locked", history, Some("minimal")).await;
|
||||
assert_eq!(
|
||||
session_snapshot(&handle).await.agent_preset.as_deref(),
|
||||
Some("creator")
|
||||
);
|
||||
handle.send(Op::Shutdown).await.unwrap();
|
||||
run.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_mcp_config_is_reported_instead_of_silently_empty() {
|
||||
// C02-18: an unreadable config still yields an empty, reloadable pool,
|
||||
|
||||
@@ -1044,7 +1044,6 @@ async fn unchanged_compaction_config_is_acknowledged_silently() {
|
||||
model: current.model.clone(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync restored session");
|
||||
@@ -1240,7 +1239,6 @@ async fn sync_session_restores_current_mode() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Plan,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
@@ -1323,7 +1321,6 @@ async fn sync_session_without_prompt_repins_full_system_prompt_on_next_turn() {
|
||||
model: codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL.to_string(),
|
||||
workspace: workspace.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync fresh session without a persisted prompt");
|
||||
@@ -1425,7 +1422,6 @@ async fn sync_session_same_id_does_not_finalize_live_worker() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: workspace.clone(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("install session");
|
||||
@@ -1448,7 +1444,6 @@ async fn sync_session_same_id_does_not_finalize_live_worker() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: workspace.clone(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("re-sync same session");
|
||||
@@ -1492,7 +1487,6 @@ async fn sync_session_different_id_finalizes_live_worker() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: workspace.clone(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("install session-a");
|
||||
@@ -1518,7 +1512,6 @@ async fn sync_session_different_id_finalizes_live_worker() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: workspace.clone(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("switch to session-b");
|
||||
@@ -1577,7 +1570,6 @@ async fn sync_session_migrates_one_checkpoint_and_strips_its_system_carrier() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync compacted session");
|
||||
|
||||
@@ -36,7 +36,6 @@ async fn sync_session_projects_persisted_subagent_handoff_for_headless_restore()
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
@@ -177,7 +176,6 @@ async fn edit_last_turn_preserves_current_mode() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
@@ -336,7 +334,6 @@ async fn edit_last_turn_cuts_at_user_prompt_before_tool_results() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
@@ -454,7 +451,6 @@ async fn edit_last_turn_without_user_prompt_errors_and_sends_nothing() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
@@ -573,7 +569,6 @@ async fn edit_last_turn_without_user_prompt_errors_and_sends_nothing() {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync unsupported user session");
|
||||
|
||||
@@ -641,6 +641,7 @@ impl Engine {
|
||||
job.response_settled(!response_stream_failed);
|
||||
}
|
||||
turn.add_parent_usage(&usage);
|
||||
turn.note_parent_model(&stream_request.model);
|
||||
turn.note_parent_prompt_len(
|
||||
self.session.messages.len(),
|
||||
crate::compaction::estimate_request_input_tokens(
|
||||
|
||||
@@ -117,8 +117,6 @@ pub struct SessionSnapshot {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instruction_assembly: Option<serde_json::Value>,
|
||||
pub mode: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
|
||||
/// Live context-window posture for one thread, computed where the session
|
||||
@@ -470,7 +468,6 @@ pub enum Op {
|
||||
model: String,
|
||||
workspace: PathBuf,
|
||||
mode: AppMode,
|
||||
agent_preset: Option<String>,
|
||||
},
|
||||
|
||||
/// Rewind only the exact conversation observed by the caller. The Engine
|
||||
|
||||
@@ -1261,7 +1261,6 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op {
|
||||
model,
|
||||
workspace,
|
||||
mode,
|
||||
agent_preset,
|
||||
..
|
||||
} => wire_op::Op::SyncSession {
|
||||
expected: expected.as_ref().map(to_value),
|
||||
@@ -1272,7 +1271,6 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op {
|
||||
model: model.clone(),
|
||||
workspace: workspace.clone(),
|
||||
mode: app_mode_str(*mode).to_string(),
|
||||
agent_preset: agent_preset.clone(),
|
||||
},
|
||||
Op::RewindConversation {
|
||||
expected, messages, ..
|
||||
@@ -1741,7 +1739,6 @@ mod tests {
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".into(),
|
||||
agent_preset: None,
|
||||
};
|
||||
let (tx, mut receive) = tokio::sync::oneshot::channel();
|
||||
let op = Op::RewindConversation {
|
||||
|
||||
@@ -408,8 +408,6 @@ pub struct Session {
|
||||
/// boundaries so the next send can compact on last-turn pressure
|
||||
/// (#5577). Cleared when history is rewritten (compaction, restore).
|
||||
pub latest_parent_input_tokens: Option<u32>,
|
||||
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
|
||||
/// Cumulative usage statistics for a session.
|
||||
@@ -483,7 +481,6 @@ impl Session {
|
||||
tool_activation_cache: ToolActivationCache::default(),
|
||||
messages_revision: 0,
|
||||
latest_parent_input_tokens: None,
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,9 @@ pub struct TurnContext {
|
||||
/// their own routed receipts.
|
||||
pub parent_route_usage: Usage,
|
||||
|
||||
/// Model the most recent parent-route request was dispatched with.
|
||||
pub last_parent_model: Option<String>,
|
||||
|
||||
/// Provider calls whose usage became ambiguous after dispatch (for
|
||||
/// example an RLM timeout). A non-zero value makes cost coverage
|
||||
/// explicitly incomplete instead of inventing a zero-usage receipt.
|
||||
@@ -199,6 +202,7 @@ impl TurnContext {
|
||||
..Usage::default()
|
||||
},
|
||||
parent_route_usage: Usage::default(),
|
||||
last_parent_model: None,
|
||||
routed_usage_dropped_records: 0,
|
||||
latest_parent_input_tokens: None,
|
||||
messages_len_at_last_parent_prompt: None,
|
||||
@@ -275,6 +279,10 @@ impl TurnContext {
|
||||
add_usage_to(&mut self.parent_route_usage, usage);
|
||||
}
|
||||
|
||||
pub fn note_parent_model(&mut self, model: &str) {
|
||||
self.last_parent_model = Some(model.to_string());
|
||||
}
|
||||
|
||||
pub fn add_routed_usage_dropped_records(&mut self, dropped_records: u64) {
|
||||
self.routed_usage_dropped_records = self
|
||||
.routed_usage_dropped_records
|
||||
|
||||
@@ -1295,7 +1295,6 @@ fn the_wire_result_is_the_tools_text_and_json_and_says_when_it_was_cut() {
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".into(),
|
||||
agent_preset: None,
|
||||
});
|
||||
let hash = SessionCheckpointRequest::prefix_hash(&snapshot.messages).unwrap();
|
||||
let make = |projection| {
|
||||
|
||||
@@ -9161,7 +9161,6 @@ impl RuntimeThreadManager {
|
||||
thread.auto_approve,
|
||||
)
|
||||
.mode,
|
||||
agent_preset: thread.agent_preset.clone(),
|
||||
})
|
||||
.await?;
|
||||
let observed = engine.get_session_snapshot().await?;
|
||||
@@ -14917,7 +14916,6 @@ source_usage_records: Default::default(),
|
||||
thread.auto_approve,
|
||||
)
|
||||
.mode,
|
||||
agent_preset: thread.agent_preset.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to sync thread session: {e}"))?;
|
||||
|
||||
@@ -19625,7 +19625,6 @@ fn answer_session_snapshot(
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: String::new(),
|
||||
agent_preset: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -19705,7 +19704,6 @@ async fn compaction_lifecycle_emits_item_events_with_compaction_counts() -> Resu
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".into(),
|
||||
agent_preset: None,
|
||||
});
|
||||
let (reply, received) = oneshot::channel();
|
||||
let withdraw = CancellationToken::new();
|
||||
@@ -22039,7 +22037,6 @@ mod runtime_image_inputs {
|
||||
model: stored_thread.model.clone(),
|
||||
workspace: stored_thread.workspace.clone(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await?;
|
||||
let followup = reopened
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use super::*;
|
||||
use crate::conversation_modes::{CREATOR, CreatorAvailabilityGuard, ModeRefusal, STANDARD};
|
||||
use crate::core::engine::Engine;
|
||||
use crate::llm_client::mock::MockLlmClient;
|
||||
|
||||
fn refusal_code(error: &anyhow::Error) -> &'static str {
|
||||
error
|
||||
@@ -134,58 +132,3 @@ async fn creator_is_listed_unavailable_until_it_loads_its_tools() -> Result<()>
|
||||
assert_eq!(refusal_code(&refused), ModeRefusal::MODE_UNAVAILABLE);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_runtime_thread_sync_keeps_its_conversation_mode() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let manager = test_manager(tmp.path().to_path_buf())?;
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let thread = manager.create_thread(create_request(Some(CREATOR))).await?;
|
||||
let mock = Arc::new(MockLlmClient::new(Vec::new()));
|
||||
let (engine, handle) = Engine::new_with_model_client(
|
||||
EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
model: thread.model.clone(),
|
||||
subagents_enabled: false,
|
||||
snapshots_enabled: false,
|
||||
memory_enabled: false,
|
||||
terminal_chrome_enabled: false,
|
||||
..Default::default()
|
||||
},
|
||||
&Config::default(),
|
||||
mock,
|
||||
);
|
||||
manager
|
||||
.install_test_engine(&thread.id, handle.clone())
|
||||
.await?;
|
||||
let run = tokio::spawn(engine.run());
|
||||
let messages = vec![Message {
|
||||
role: Role::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "first".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}];
|
||||
let session = crate::session_manager::create_saved_session(
|
||||
&messages,
|
||||
&thread.model,
|
||||
&thread.workspace,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
manager
|
||||
.synchronize_history_operation(&thread.id, &session)
|
||||
.await?;
|
||||
let observed = handle.get_session_snapshot().await?;
|
||||
assert_eq!(observed.agent_preset.as_deref(), Some(CREATOR));
|
||||
manager
|
||||
.synchronize_history_operation(&thread.id, &session)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
handle.get_session_snapshot().await?.agent_preset.as_deref(),
|
||||
Some(CREATOR)
|
||||
);
|
||||
handle.send(Op::Shutdown).await?;
|
||||
tokio::time::timeout(Duration::from_secs(10), run).await??;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2090,27 +2090,9 @@ pub struct SavedSession {
|
||||
/// record resume, export, and the Runtime API read back.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub turn_outcomes: Vec<SavedTurnOutcome>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "read_saved_agent_preset"
|
||||
)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_preset: Option<String>,
|
||||
}
|
||||
|
||||
fn read_saved_agent_preset<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Ok(
|
||||
match <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)? {
|
||||
None | Some(serde_json::Value::Null) => None,
|
||||
Some(serde_json::Value::String(id)) => Some(id),
|
||||
Some(other) => Some(other.to_string()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
impl SavedSession {
|
||||
pub fn capture_publication(
|
||||
&mut self,
|
||||
|
||||
@@ -362,25 +362,3 @@
|
||||
let loaded = manager.load_session(&saved.metadata.id).expect("load");
|
||||
assert_eq!(loaded.agent_preset.as_deref(), Some("creator"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_edited_agent_preset_still_loads_and_never_becomes_standard() {
|
||||
let session = create_saved_session(&[], "test-model", std::path::Path::new("."), 0, None);
|
||||
let base = serde_json::to_value(&session).expect("serialize");
|
||||
let big = "x".repeat(1_000_000);
|
||||
for (edited, expected) in [
|
||||
(serde_json::Value::Null, None),
|
||||
(serde_json::json!("creator"), Some("creator".to_string())),
|
||||
(serde_json::json!(""), Some(String::new())),
|
||||
(serde_json::json!("../x"), Some("../x".to_string())),
|
||||
(serde_json::json!(5), Some("5".to_string())),
|
||||
(serde_json::json!(true), Some("true".to_string())),
|
||||
(serde_json::json!({"a": 1}), Some("{\"a\":1}".to_string())),
|
||||
(serde_json::json!(big.clone()), Some(big)),
|
||||
] {
|
||||
let mut raw = base.clone();
|
||||
raw["agent_preset"] = edited;
|
||||
let loaded: SavedSession = serde_json::from_value(raw).expect("loads");
|
||||
assert_eq!(loaded.agent_preset, expected);
|
||||
}
|
||||
}
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "endormiscat",
|
||||
"PetWatchUnavailable": "La telemetria de la mascota està en pausa. /pet on ho torna a intentar.",
|
||||
"ConversationChangedBeforeUndo": "La conversa ha canviat abans de completar l’acció de desfer. No s’ha desfet res ni s’ha enviat cap nou intent. Revisa la conversa actual abans de tornar-ho a provar.",
|
||||
"ConversationModePickerTitle": "Mode",
|
||||
"ConversationModeGroupConversation": "Mode de conversa",
|
||||
"ConversationModeGroupPermissions": "Permisos",
|
||||
"ConversationModeStandardName": "Mode estàndard",
|
||||
"ConversationModeStandardDescription": "L'agent de cada dia amb les seves eines i habilitats habituals.",
|
||||
"ConversationModeCreatorName": "Mode creador",
|
||||
"ConversationModeCreatorDescription": "Crea i prova connectors en aquesta conversa.",
|
||||
"ConversationModeNotReady": "Encara no està a punt en aquesta versió.",
|
||||
"ConversationModeLockedNote": "Fix després del primer missatge. Comença'n una altra amb {command}.",
|
||||
"ConversationModeLockedRefusal": "El mode de conversa queda fix quan s'envia el primer missatge. Comença una conversa nova amb {command}.",
|
||||
"ConversationModeUnknown": "Mode de conversa desconegut «{id}». Disponibles: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} no està disponible: {reason}",
|
||||
"ConversationModeSet": "Mode de conversa: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (no disponible)",
|
||||
"ConversationModeSavedUnavailableNotice": "Aquesta conversa es va desar en {name}, que no està disponible aquí ({reason}). Se'n mostra l'historial.",
|
||||
"ConversationModeResumeUnreadable": "No s'ha pogut llegir la conversa desada per comprovar-ne el mode; no s'ha iniciat ni canviat res.",
|
||||
"SessionArchiveExported": "Sessió exportada",
|
||||
"SessionArchiveSizes": "elements / mida sense comprimir / arxiu",
|
||||
"SessionArchiveNoArtifacts": "No s’han trobat fitxers d’artefactes regulars en aquesta sessió.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "dösend",
|
||||
"PetWatchUnavailable": "Die Haustier-Telemetrie pausiert. /pet on versucht es erneut.",
|
||||
"ConversationChangedBeforeUndo": "Die Unterhaltung wurde geändert, bevor das Rückgängigmachen abgeschlossen war. Es wurde nichts rückgängig gemacht und kein erneuter Versuch gesendet. Prüfe die aktuelle Unterhaltung, bevor du es erneut versuchst.",
|
||||
"ConversationModePickerTitle": "Modus",
|
||||
"ConversationModeGroupConversation": "Gesprächsmodus",
|
||||
"ConversationModeGroupPermissions": "Berechtigungen",
|
||||
"ConversationModeStandardName": "Standardmodus",
|
||||
"ConversationModeStandardDescription": "Der Alltagsagent mit seinen üblichen Werkzeugen und Skills.",
|
||||
"ConversationModeCreatorName": "Creator-Modus",
|
||||
"ConversationModeCreatorDescription": "Plugins in diesem Gespräch bauen und testen.",
|
||||
"ConversationModeNotReady": "In dieser Version noch nicht bereit.",
|
||||
"ConversationModeLockedNote": "Nach der ersten Nachricht festgelegt. Ein anderes Gespräch starten mit {command}.",
|
||||
"ConversationModeLockedRefusal": "Der Gesprächsmodus ist festgelegt, sobald die erste Nachricht gesendet wurde. Starte ein neues Gespräch mit {command}.",
|
||||
"ConversationModeUnknown": "Unbekannter Gesprächsmodus „{id}“. Verfügbar: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} ist nicht verfügbar: {reason}",
|
||||
"ConversationModeSet": "Gesprächsmodus: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (nicht verfügbar)",
|
||||
"ConversationModeSavedUnavailableNotice": "Dieses Gespräch wurde im {name} gespeichert, der hier nicht verfügbar ist ({reason}). Sein Verlauf wird angezeigt.",
|
||||
"ConversationModeResumeUnreadable": "Das gespeicherte Gespräch konnte nicht gelesen werden, um seinen Modus zu prüfen. Es wurde nichts gestartet oder geändert.",
|
||||
"SessionArchiveExported": "Sitzung exportiert",
|
||||
"SessionArchiveSizes": "Einträge / unkomprimierte Größe / Archivgröße",
|
||||
"SessionArchiveNoArtifacts": "Keine regulären Artefaktdateien für diese Sitzung gefunden.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "dozing",
|
||||
"PetWatchUnavailable": "Pet telemetry paused. /pet on retries.",
|
||||
"ConversationChangedBeforeUndo": "The conversation changed before undo could finish. Nothing was undone and no retry was sent. Review the current conversation before trying again.",
|
||||
"ConversationModePickerTitle": "Mode",
|
||||
"ConversationModeGroupConversation": "Conversation mode",
|
||||
"ConversationModeGroupPermissions": "Permissions",
|
||||
"ConversationModeStandardName": "Standard mode",
|
||||
"ConversationModeStandardDescription": "The everyday agent with its usual tools and skills.",
|
||||
"ConversationModeCreatorName": "Creator mode",
|
||||
"ConversationModeCreatorDescription": "Build and test plugins in this conversation.",
|
||||
"ConversationModeNotReady": "Not ready yet in this version.",
|
||||
"ConversationModeLockedNote": "Fixed after the first message. Start another with {command}.",
|
||||
"ConversationModeLockedRefusal": "The conversation mode is fixed once the first message is sent. Start a new conversation with {command}.",
|
||||
"ConversationModeUnknown": "Unknown conversation mode \"{id}\". Available: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} is not available: {reason}",
|
||||
"ConversationModeSet": "Conversation mode: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (unavailable)",
|
||||
"ConversationModeSavedUnavailableNotice": "This conversation was saved in {name}, which is not available here ({reason}). Its history is shown.",
|
||||
"ConversationModeResumeUnreadable": "Could not read the saved conversation to check its mode, so nothing was started or changed.",
|
||||
"SessionArchiveExported": "Exported session",
|
||||
"SessionArchiveSizes": "members / uncompressed / archive",
|
||||
"SessionArchiveNoArtifacts": "No regular artifacts found for this session.",
|
||||
@@ -242,10 +226,10 @@
|
||||
"TranscriptReasoningExpand": "expand",
|
||||
"TranscriptThought": "Thought",
|
||||
"TranscriptThoughtFor": "Thought for {duration}",
|
||||
"TelemetryNoticeDefaultOn": "Usage reporting is on by default. Share aggregate version/platform, session, feature and error counts with Codewhale and PostHog (processor). Also counts providers, public catalog models, token totals and built-in tool use. No content or IP. docs/TELEMETRY.md Turn off: codewhale config set telemetry false",
|
||||
"TelemetryNoticeDefaultOn": "Usage reporting is on by default. Share aggregate version/platform, session, feature and error counts with Codewhale and PostHog (processor). Also counts providers, public catalog models, token totals, request counts, model wait time, prompt-size ranges, built-in tool use, how an exec session started and a coarse install-age range. No content or IP. docs/TELEMETRY.md Turn off: codewhale config set telemetry false",
|
||||
"TelemetryNoticeHeadline": "Codewhale usage reporting",
|
||||
"TelemetryNoticeBody": "Codewhale counts: which version you run, OS and CPU family, session\nduration and outcome, and aggregate feature and error counters.\n\nIt also counts the providers and public catalog models you use, their\ntoken totals, and how often each built-in tool runs.\n\nIt never collects your conversations, code, prompts, files, repo or\nbranch names, model content, or credentials — and it never sends a\nper-turn or per-tool timeline of agent activity.\n\nYou are identified only by a random ID stored on this machine, replaced\nevery 90 days. Change your mind any time:\n codewhale config set telemetry false\n\nFull schema, field by field: docs/TELEMETRY.md\n\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeCompactBody": "Counts: version, OS/CPU, session\ntime/result, feature/error totals.\nAlso: provider/model, tokens, tools.\nRandom local ID rotates every 90d.\nNever: chat/code/prompts/files/names;\nmodel content/credentials; turn/tools.\nSchema: docs/TELEMETRY.md\nOff: codewhale config set telemetry\nfalse\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeBody": "Codewhale counts: which version you run, OS and CPU family, session\nduration and outcome, and aggregate feature and error counters.\n\nIt also counts the providers and public catalog models you use, their\ntoken totals, request counts, time spent waiting on the model and\nprompt-size ranges, how often each built-in tool runs, how an exec\nsession was started, and a coarse install-age range.\n\nIt never collects your conversations, code, prompts, files, repo or\nbranch names, model content, or credentials — and it never sends a\nper-turn or per-tool timeline of agent activity.\n\nYou are identified only by a random ID stored on this machine, replaced\nevery 90 days. Change your mind any time:\n codewhale config set telemetry false\n\nFull schema, field by field: docs/TELEMETRY.md\n\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeCompactBody": "Counts: version, OS/CPU, session\ntime/result, feature/error totals.\nAlso: provider/model, tokens, tools,\nrequests, wait time, prompt-size and\ninstall-age ranges, exec start.\nRandom local ID rotates every 90d.\nNever: chat/code/prompts/files/names;\nmodel content/credentials; turn/tools.\nSchema: docs/TELEMETRY.md\nOff: codewhale config set telemetry\nfalse\nUsage reporting is on by default. Codewhale and PostHog process these counts when delivery is configured. No IP is collected.",
|
||||
"TelemetryNoticeChoiceKeep": "Yes, keep anonymous counts",
|
||||
"TelemetryNoticeChoiceDisable": "No, turn off tracking",
|
||||
"TelemetryNoticeActionChoose": "choose",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "dormitando",
|
||||
"PetWatchUnavailable": "La telemetría de la mascota está en pausa. /pet on reintenta.",
|
||||
"ConversationChangedBeforeUndo": "La conversación cambió antes de completar la acción de deshacer. No se deshizo nada ni se envió un nuevo intento. Revisa la conversación actual antes de volver a intentarlo.",
|
||||
"ConversationModePickerTitle": "Modo",
|
||||
"ConversationModeGroupConversation": "Modo de conversación",
|
||||
"ConversationModeGroupPermissions": "Permisos",
|
||||
"ConversationModeStandardName": "Modo estándar",
|
||||
"ConversationModeStandardDescription": "El agente de todos los días con sus herramientas y habilidades habituales.",
|
||||
"ConversationModeCreatorName": "Modo creador",
|
||||
"ConversationModeCreatorDescription": "Crea y prueba plugins en esta conversación.",
|
||||
"ConversationModeNotReady": "Aún no está listo en esta versión.",
|
||||
"ConversationModeLockedNote": "Fijo tras el primer mensaje. Inicia otra con {command}.",
|
||||
"ConversationModeLockedRefusal": "El modo de conversación queda fijo al enviar el primer mensaje. Inicia una conversación nueva con {command}.",
|
||||
"ConversationModeUnknown": "Modo de conversación desconocido «{id}». Disponibles: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} no está disponible: {reason}",
|
||||
"ConversationModeSet": "Modo de conversación: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (no disponible)",
|
||||
"ConversationModeSavedUnavailableNotice": "Esta conversación se guardó en {name}, que no está disponible aquí ({reason}). Se muestra su historial.",
|
||||
"ConversationModeResumeUnreadable": "No se pudo leer la conversación guardada para comprobar su modo, así que no se inició ni se cambió nada.",
|
||||
"SessionArchiveExported": "Sesión exportada",
|
||||
"SessionArchiveSizes": "elementos / tamaño sin comprimir / archivo",
|
||||
"SessionArchiveNoArtifacts": "No se encontraron archivos de artefactos regulares en esta sesión.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "assoupi",
|
||||
"PetWatchUnavailable": "La télémétrie de la mascotte est en pause. /pet on réessaie.",
|
||||
"ConversationChangedBeforeUndo": "La conversation a changé avant la fin de l’annulation. Rien n’a été annulé et aucune nouvelle tentative n’a été envoyée. Vérifiez la conversation actuelle avant de réessayer.",
|
||||
"ConversationModePickerTitle": "Mode",
|
||||
"ConversationModeGroupConversation": "Mode de conversation",
|
||||
"ConversationModeGroupPermissions": "Autorisations",
|
||||
"ConversationModeStandardName": "Mode standard",
|
||||
"ConversationModeStandardDescription": "L'agent du quotidien avec ses outils et compétences habituels.",
|
||||
"ConversationModeCreatorName": "Mode créateur",
|
||||
"ConversationModeCreatorDescription": "Créer et tester des plugins dans cette conversation.",
|
||||
"ConversationModeNotReady": "Pas encore prêt dans cette version.",
|
||||
"ConversationModeLockedNote": "Fixé après le premier message. Pour en démarrer une autre : {command}.",
|
||||
"ConversationModeLockedRefusal": "Le mode de conversation est fixé dès l'envoi du premier message. Démarrez une nouvelle conversation avec {command}.",
|
||||
"ConversationModeUnknown": "Mode de conversation inconnu « {id} ». Disponibles : {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} n'est pas disponible : {reason}",
|
||||
"ConversationModeSet": "Mode de conversation : {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (indisponible)",
|
||||
"ConversationModeSavedUnavailableNotice": "Cette conversation a été enregistrée en {name}, indisponible ici ({reason}). Son historique est affiché.",
|
||||
"ConversationModeResumeUnreadable": "Impossible de lire la conversation enregistrée pour vérifier son mode ; rien n'a été démarré ni modifié.",
|
||||
"SessionArchiveExported": "Session exportée",
|
||||
"SessionArchiveSizes": "éléments / taille non compressée / archive",
|
||||
"SessionArchiveNoArtifacts": "Aucun fichier d’artefact ordinaire trouvé pour cette session.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "ऊँघ रहा है",
|
||||
"PetWatchUnavailable": "पालतू की टेलीमेट्री रुकी हुई है। /pet on से फिर कोशिश करें।",
|
||||
"ConversationChangedBeforeUndo": "पूर्ववत करने की प्रक्रिया पूरी होने से पहले बातचीत बदल गई। कुछ भी पूर्ववत नहीं किया गया और दोबारा अनुरोध नहीं भेजा गया। फिर से कोशिश करने से पहले मौजूदा बातचीत की समीक्षा करें।",
|
||||
"ConversationModePickerTitle": "मोड",
|
||||
"ConversationModeGroupConversation": "बातचीत मोड",
|
||||
"ConversationModeGroupPermissions": "अनुमतियाँ",
|
||||
"ConversationModeStandardName": "मानक मोड",
|
||||
"ConversationModeStandardDescription": "रोज़ के उपयोग वाला एजेंट, अपने सामान्य टूल और स्किल के साथ।",
|
||||
"ConversationModeCreatorName": "क्रिएटर मोड",
|
||||
"ConversationModeCreatorDescription": "इस बातचीत में प्लगइन बनाएँ और परखें।",
|
||||
"ConversationModeNotReady": "इस संस्करण में अभी तैयार नहीं है।",
|
||||
"ConversationModeLockedNote": "पहले संदेश के बाद तय हो जाता है। दूसरी शुरू करने के लिए {command} चलाएँ।",
|
||||
"ConversationModeLockedRefusal": "पहला संदेश भेजते ही बातचीत का मोड तय हो जाता है। {command} से नई बातचीत शुरू करें।",
|
||||
"ConversationModeUnknown": "अज्ञात बातचीत मोड \"{id}\"। उपलब्ध: {modes}।",
|
||||
"ConversationModeUnavailableRefusal": "{name} उपलब्ध नहीं है: {reason}",
|
||||
"ConversationModeSet": "बातचीत मोड: {name}।",
|
||||
"ConversationModeLabelUnavailable": "{name} (उपलब्ध नहीं)",
|
||||
"ConversationModeSavedUnavailableNotice": "यह बातचीत {name} में सहेजी गई थी, जो यहाँ उपलब्ध नहीं है ({reason})। इसका इतिहास दिखाया गया है।",
|
||||
"ConversationModeResumeUnreadable": "इसका मोड जाँचने के लिए सहेजी गई बातचीत नहीं पढ़ी जा सकी, इसलिए कुछ शुरू या बदला नहीं गया।",
|
||||
"SessionArchiveExported": "सत्र निर्यात किया गया",
|
||||
"SessionArchiveSizes": "प्रविष्टियाँ / असंपीड़ित आकार / संग्रह का आकार",
|
||||
"SessionArchiveNoArtifacts": "इस सत्र में कोई सामान्य आर्टिफ़ैक्ट फ़ाइल नहीं मिली।",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "terlelap",
|
||||
"PetWatchUnavailable": "Telemetri hewan peliharaan dijeda. /pet on mencoba lagi.",
|
||||
"ConversationChangedBeforeUndo": "Percakapan berubah sebelum pembatalan selesai. Tidak ada yang dibatalkan dan tidak ada permintaan coba lagi yang dikirim. Periksa percakapan saat ini sebelum mencoba lagi.",
|
||||
"ConversationModePickerTitle": "Mode",
|
||||
"ConversationModeGroupConversation": "Mode percakapan",
|
||||
"ConversationModeGroupPermissions": "Izin",
|
||||
"ConversationModeStandardName": "Mode standar",
|
||||
"ConversationModeStandardDescription": "Agen sehari-hari dengan alat dan skill yang biasa.",
|
||||
"ConversationModeCreatorName": "Mode kreator",
|
||||
"ConversationModeCreatorDescription": "Bangun dan uji plugin dalam percakapan ini.",
|
||||
"ConversationModeNotReady": "Belum siap di versi ini.",
|
||||
"ConversationModeLockedNote": "Tetap setelah pesan pertama. Mulai yang lain dengan {command}.",
|
||||
"ConversationModeLockedRefusal": "Mode percakapan tetap setelah pesan pertama dikirim. Mulai percakapan baru dengan {command}.",
|
||||
"ConversationModeUnknown": "Mode percakapan \"{id}\" tidak dikenal. Tersedia: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} tidak tersedia: {reason}",
|
||||
"ConversationModeSet": "Mode percakapan: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (tidak tersedia)",
|
||||
"ConversationModeSavedUnavailableNotice": "Percakapan ini disimpan dalam {name}, yang tidak tersedia di sini ({reason}). Riwayatnya ditampilkan.",
|
||||
"ConversationModeResumeUnreadable": "Percakapan tersimpan tidak dapat dibaca untuk memeriksa modenya, jadi tidak ada yang dimulai atau diubah.",
|
||||
"SessionArchiveExported": "Sesi diekspor",
|
||||
"SessionArchiveSizes": "jumlah entri / ukuran tanpa kompresi / arsip",
|
||||
"SessionArchiveNoArtifacts": "Tidak ditemukan berkas artefak biasa untuk sesi ini.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "うたた寝",
|
||||
"PetWatchUnavailable": "ペットの計測が一時停止しました。/pet on で再試行します。",
|
||||
"ConversationChangedBeforeUndo": "取り消しが完了する前に会話が変更されました。取り消しも再試行の送信も行っていません。現在の会話を確認してから、もう一度お試しください。",
|
||||
"ConversationModePickerTitle": "モード",
|
||||
"ConversationModeGroupConversation": "会話モード",
|
||||
"ConversationModeGroupPermissions": "権限",
|
||||
"ConversationModeStandardName": "標準モード",
|
||||
"ConversationModeStandardDescription": "いつもの道具とスキルを備えた日常用のエージェントです。",
|
||||
"ConversationModeCreatorName": "クリエイターモード",
|
||||
"ConversationModeCreatorDescription": "この会話でプラグインを作成・テストします。",
|
||||
"ConversationModeNotReady": "このバージョンではまだ使えません。",
|
||||
"ConversationModeLockedNote": "最初のメッセージ後は固定されます。別の会話は {command} で始めます。",
|
||||
"ConversationModeLockedRefusal": "会話モードは最初のメッセージを送ると固定されます。{command} で新しい会話を始めてください。",
|
||||
"ConversationModeUnknown": "会話モード「{id}」は不明です。使用可能: {modes}。",
|
||||
"ConversationModeUnavailableRefusal": "{name} は使えません: {reason}",
|
||||
"ConversationModeSet": "会話モード: {name}。",
|
||||
"ConversationModeLabelUnavailable": "{name}(利用不可)",
|
||||
"ConversationModeSavedUnavailableNotice": "この会話は {name} で保存されましたが、ここでは使えません({reason})。履歴は表示されています。",
|
||||
"ConversationModeResumeUnreadable": "保存された会話を読み取れず、モードを確認できませんでした。何も開始・変更していません。",
|
||||
"SessionArchiveExported": "セッションをエクスポートしました",
|
||||
"SessionArchiveSizes": "項目数 / 非圧縮サイズ / アーカイブサイズ",
|
||||
"SessionArchiveNoArtifacts": "このセッションに通常のアーティファクトファイルはありません。",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "졸고 있음",
|
||||
"PetWatchUnavailable": "펫 원격 측정이 일시 중지되었습니다. /pet on 으로 재시도합니다.",
|
||||
"ConversationChangedBeforeUndo": "실행 취소가 완료되기 전에 대화가 변경되었습니다. 취소된 내용이 없으며 재시도 요청도 보내지 않았습니다. 현재 대화를 확인한 후 다시 시도하세요.",
|
||||
"ConversationModePickerTitle": "모드",
|
||||
"ConversationModeGroupConversation": "대화 모드",
|
||||
"ConversationModeGroupPermissions": "권한",
|
||||
"ConversationModeStandardName": "표준 모드",
|
||||
"ConversationModeStandardDescription": "평소 도구와 스킬을 갖춘 일상용 에이전트입니다.",
|
||||
"ConversationModeCreatorName": "크리에이터 모드",
|
||||
"ConversationModeCreatorDescription": "이 대화에서 플러그인을 만들고 테스트합니다.",
|
||||
"ConversationModeNotReady": "이 버전에서는 아직 준비되지 않았습니다.",
|
||||
"ConversationModeLockedNote": "첫 메시지 이후에는 고정됩니다. 다른 대화는 {command} 로 시작하세요.",
|
||||
"ConversationModeLockedRefusal": "대화 모드는 첫 메시지를 보내면 고정됩니다. {command} 로 새 대화를 시작하세요.",
|
||||
"ConversationModeUnknown": "알 수 없는 대화 모드 \"{id}\". 사용 가능: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} 사용 불가: {reason}",
|
||||
"ConversationModeSet": "대화 모드: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (사용 불가)",
|
||||
"ConversationModeSavedUnavailableNotice": "이 대화는 {name}에서 저장되었지만 여기서는 사용할 수 없습니다({reason}). 기록은 표시됩니다.",
|
||||
"ConversationModeResumeUnreadable": "저장된 대화를 읽어 모드를 확인할 수 없어 아무것도 시작하거나 변경하지 않았습니다.",
|
||||
"SessionArchiveExported": "세션을 내보냈습니다",
|
||||
"SessionArchiveSizes": "항목 수 / 압축 전 크기 / 아카이브 크기",
|
||||
"SessionArchiveNoArtifacts": "이 세션에 일반 산출물 파일이 없습니다.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "cochilando",
|
||||
"PetWatchUnavailable": "A telemetria do pet foi pausada. /pet on tenta novamente.",
|
||||
"ConversationChangedBeforeUndo": "A conversa mudou antes de concluir a ação de desfazer. Nada foi desfeito e nenhuma nova tentativa foi enviada. Revise a conversa atual antes de tentar novamente.",
|
||||
"ConversationModePickerTitle": "Modo",
|
||||
"ConversationModeGroupConversation": "Modo de conversa",
|
||||
"ConversationModeGroupPermissions": "Permissões",
|
||||
"ConversationModeStandardName": "Modo padrão",
|
||||
"ConversationModeStandardDescription": "O agente do dia a dia, com suas ferramentas e habilidades habituais.",
|
||||
"ConversationModeCreatorName": "Modo criador",
|
||||
"ConversationModeCreatorDescription": "Crie e teste plugins nesta conversa.",
|
||||
"ConversationModeNotReady": "Ainda não está pronto nesta versão.",
|
||||
"ConversationModeLockedNote": "Fixo após a primeira mensagem. Inicie outra com {command}.",
|
||||
"ConversationModeLockedRefusal": "O modo de conversa fica fixo depois que a primeira mensagem é enviada. Inicie uma nova conversa com {command}.",
|
||||
"ConversationModeUnknown": "Modo de conversa desconhecido \"{id}\". Disponíveis: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} não está disponível: {reason}",
|
||||
"ConversationModeSet": "Modo de conversa: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (indisponível)",
|
||||
"ConversationModeSavedUnavailableNotice": "Esta conversa foi salva no {name}, que não está disponível aqui ({reason}). O histórico dela é exibido.",
|
||||
"ConversationModeResumeUnreadable": "Não foi possível ler a conversa salva para verificar o modo, então nada foi iniciado ou alterado.",
|
||||
"SessionArchiveExported": "Sessão exportada",
|
||||
"SessionArchiveSizes": "itens / tamanho sem compressão / arquivo",
|
||||
"SessionArchiveNoArtifacts": "Nenhum artefato regular encontrado nesta sessão.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "дремлет",
|
||||
"PetWatchUnavailable": "Телеметрия питомца приостановлена. /pet on повторит попытку.",
|
||||
"ConversationChangedBeforeUndo": "Разговор изменился до завершения отмены. Ничего не отменено, повторный запрос не отправлен. Проверьте текущий разговор, прежде чем повторять попытку.",
|
||||
"ConversationModePickerTitle": "Режим",
|
||||
"ConversationModeGroupConversation": "Режим беседы",
|
||||
"ConversationModeGroupPermissions": "Разрешения",
|
||||
"ConversationModeStandardName": "Стандартный режим",
|
||||
"ConversationModeStandardDescription": "Повседневный агент с обычными инструментами и навыками.",
|
||||
"ConversationModeCreatorName": "Режим создателя",
|
||||
"ConversationModeCreatorDescription": "Создавайте и проверяйте плагины в этой беседе.",
|
||||
"ConversationModeNotReady": "В этой версии пока не готово.",
|
||||
"ConversationModeLockedNote": "Фиксируется после первого сообщения. Начните другую командой {command}.",
|
||||
"ConversationModeLockedRefusal": "Режим беседы фиксируется после отправки первого сообщения. Начните новую беседу командой {command}.",
|
||||
"ConversationModeUnknown": "Неизвестный режим беседы «{id}». Доступны: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} недоступен: {reason}",
|
||||
"ConversationModeSet": "Режим беседы: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (недоступен)",
|
||||
"ConversationModeSavedUnavailableNotice": "Эта беседа сохранена в режиме «{name}», недоступном здесь ({reason}). Её история показана.",
|
||||
"ConversationModeResumeUnreadable": "Не удалось прочитать сохранённый разговор, чтобы проверить его режим. Ничего не запущено и не изменено.",
|
||||
"SessionArchiveExported": "Сеанс экспортирован",
|
||||
"SessionArchiveSizes": "записи / размер без сжатия / размер архива",
|
||||
"SessionArchiveNoArtifacts": "Обычные файлы артефактов для этого сеанса не найдены.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "дрімає",
|
||||
"PetWatchUnavailable": "Телеметрію улюбленця призупинено. /pet on повторить спробу.",
|
||||
"ConversationChangedBeforeUndo": "Розмова змінилася до завершення скасування. Нічого не скасовано, повторний запит не надіслано. Перевірте поточну розмову, перш ніж спробувати знову.",
|
||||
"ConversationModePickerTitle": "Режим",
|
||||
"ConversationModeGroupConversation": "Режим розмови",
|
||||
"ConversationModeGroupPermissions": "Дозволи",
|
||||
"ConversationModeStandardName": "Стандартний режим",
|
||||
"ConversationModeStandardDescription": "Щоденний агент зі звичними інструментами та навичками.",
|
||||
"ConversationModeCreatorName": "Режим творця",
|
||||
"ConversationModeCreatorDescription": "Створюйте й перевіряйте плагіни в цій розмові.",
|
||||
"ConversationModeNotReady": "У цій версії ще не готово.",
|
||||
"ConversationModeLockedNote": "Фіксується після першого повідомлення. Почніть іншу командою {command}.",
|
||||
"ConversationModeLockedRefusal": "Режим розмови фіксується після надсилання першого повідомлення. Почніть нову розмову командою {command}.",
|
||||
"ConversationModeUnknown": "Невідомий режим розмови «{id}». Доступні: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} недоступний: {reason}",
|
||||
"ConversationModeSet": "Режим розмови: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (недоступний)",
|
||||
"ConversationModeSavedUnavailableNotice": "Цю розмову збережено в режимі «{name}», недоступному тут ({reason}). Її історію показано.",
|
||||
"ConversationModeResumeUnreadable": "Не вдалося прочитати збережену розмову, щоб перевірити її режим. Нічого не запущено й не змінено.",
|
||||
"SessionArchiveExported": "Сеанс експортовано",
|
||||
"SessionArchiveSizes": "записи / розмір без стиснення / розмір архіву",
|
||||
"SessionArchiveNoArtifacts": "Звичайних файлів артефактів для цього сеансу не знайдено.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "ngủ gật",
|
||||
"PetWatchUnavailable": "Đã tạm dừng dữ liệu thú cưng. /pet on sẽ thử lại.",
|
||||
"ConversationChangedBeforeUndo": "Cuộc trò chuyện đã thay đổi trước khi hoàn tất hoàn tác. Không có nội dung nào bị hoàn tác và không gửi yêu cầu thử lại. Hãy kiểm tra cuộc trò chuyện hiện tại trước khi thử lại.",
|
||||
"ConversationModePickerTitle": "Chế độ",
|
||||
"ConversationModeGroupConversation": "Chế độ cuộc trò chuyện",
|
||||
"ConversationModeGroupPermissions": "Quyền",
|
||||
"ConversationModeStandardName": "Chế độ tiêu chuẩn",
|
||||
"ConversationModeStandardDescription": "Tác nhân hằng ngày với các công cụ và kỹ năng quen thuộc.",
|
||||
"ConversationModeCreatorName": "Chế độ sáng tạo",
|
||||
"ConversationModeCreatorDescription": "Xây dựng và thử plugin trong cuộc trò chuyện này.",
|
||||
"ConversationModeNotReady": "Phiên bản này chưa sẵn sàng.",
|
||||
"ConversationModeLockedNote": "Cố định sau tin nhắn đầu tiên. Bắt đầu cuộc khác bằng {command}.",
|
||||
"ConversationModeLockedRefusal": "Chế độ cuộc trò chuyện được cố định sau khi gửi tin nhắn đầu tiên. Hãy bắt đầu cuộc trò chuyện mới bằng {command}.",
|
||||
"ConversationModeUnknown": "Không rõ chế độ cuộc trò chuyện \"{id}\". Hiện có: {modes}.",
|
||||
"ConversationModeUnavailableRefusal": "{name} không khả dụng: {reason}",
|
||||
"ConversationModeSet": "Chế độ cuộc trò chuyện: {name}.",
|
||||
"ConversationModeLabelUnavailable": "{name} (không khả dụng)",
|
||||
"ConversationModeSavedUnavailableNotice": "Cuộc trò chuyện này được lưu ở {name}, hiện không khả dụng ở đây ({reason}). Lịch sử vẫn được hiển thị.",
|
||||
"ConversationModeResumeUnreadable": "Không đọc được cuộc trò chuyện đã lưu để kiểm tra chế độ, nên không có gì được bắt đầu hoặc thay đổi.",
|
||||
"SessionArchiveExported": "Đã xuất phiên",
|
||||
"SessionArchiveSizes": "số mục / dung lượng chưa nén / tệp lưu trữ",
|
||||
"SessionArchiveNoArtifacts": "Không tìm thấy tệp sản phẩm thông thường trong phiên này.",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "打盹",
|
||||
"PetWatchUnavailable": "宠物遥测已暂停。/pet on 重试。",
|
||||
"ConversationChangedBeforeUndo": "撤销完成前,会话已发生变化。未撤销任何内容,也未发送重试请求。请先检查当前会话,再重试。",
|
||||
"ConversationModePickerTitle": "模式",
|
||||
"ConversationModeGroupConversation": "对话模式",
|
||||
"ConversationModeGroupPermissions": "权限",
|
||||
"ConversationModeStandardName": "标准模式",
|
||||
"ConversationModeStandardDescription": "日常使用的智能体,带有常用工具和技能。",
|
||||
"ConversationModeCreatorName": "创作者模式",
|
||||
"ConversationModeCreatorDescription": "在此对话中构建并测试插件。",
|
||||
"ConversationModeNotReady": "此版本尚未就绪。",
|
||||
"ConversationModeLockedNote": "发送第一条消息后即固定。用 {command} 开始另一个对话。",
|
||||
"ConversationModeLockedRefusal": "发送第一条消息后,对话模式即固定。请用 {command} 开始新对话。",
|
||||
"ConversationModeUnknown": "未知的对话模式“{id}”。可用:{modes}。",
|
||||
"ConversationModeUnavailableRefusal": "{name}不可用:{reason}",
|
||||
"ConversationModeSet": "对话模式:{name}。",
|
||||
"ConversationModeLabelUnavailable": "{name}(不可用)",
|
||||
"ConversationModeSavedUnavailableNotice": "此对话保存时使用{name},但这里无法使用({reason})。已显示其历史记录。",
|
||||
"ConversationModeResumeUnreadable": "无法读取已保存的对话以检查其模式,因此没有开始或更改任何内容。",
|
||||
"SessionArchiveExported": "会话已导出",
|
||||
"SessionArchiveSizes": "条目数 / 未压缩大小 / 归档大小",
|
||||
"SessionArchiveNoArtifacts": "此会话没有常规产物文件。",
|
||||
|
||||
@@ -91,22 +91,6 @@
|
||||
"PetDozing": "打盹",
|
||||
"PetWatchUnavailable": "寵物遙測已暫停。/pet on 重試。",
|
||||
"ConversationChangedBeforeUndo": "復原完成前,對話已發生變更。未復原任何內容,也未送出重試請求。請先檢查目前的對話,再重試。",
|
||||
"ConversationModePickerTitle": "模式",
|
||||
"ConversationModeGroupConversation": "對話模式",
|
||||
"ConversationModeGroupPermissions": "權限",
|
||||
"ConversationModeStandardName": "標準模式",
|
||||
"ConversationModeStandardDescription": "日常使用的代理,具備常用工具與技能。",
|
||||
"ConversationModeCreatorName": "創作者模式",
|
||||
"ConversationModeCreatorDescription": "在此對話中建置並測試外掛。",
|
||||
"ConversationModeNotReady": "此版本尚未就緒。",
|
||||
"ConversationModeLockedNote": "傳送第一則訊息後即固定。用 {command} 開始另一個對話。",
|
||||
"ConversationModeLockedRefusal": "傳送第一則訊息後,對話模式即固定。請用 {command} 開始新對話。",
|
||||
"ConversationModeUnknown": "未知的對話模式「{id}」。可用:{modes}。",
|
||||
"ConversationModeUnavailableRefusal": "{name}無法使用:{reason}",
|
||||
"ConversationModeSet": "對話模式:{name}。",
|
||||
"ConversationModeLabelUnavailable": "{name}(無法使用)",
|
||||
"ConversationModeSavedUnavailableNotice": "此對話儲存時使用{name},但這裡無法使用({reason})。已顯示其歷史記錄。",
|
||||
"ConversationModeResumeUnreadable": "無法讀取已儲存的對話以檢查其模式,因此沒有開始或變更任何內容。",
|
||||
"SessionArchiveExported": "工作階段已匯出",
|
||||
"SessionArchiveSizes": "項目數 / 未壓縮大小 / 封存大小",
|
||||
"SessionArchiveNoArtifacts": "此工作階段沒有一般產物檔案。",
|
||||
|
||||
@@ -171,22 +171,6 @@ pub enum MessageId {
|
||||
MobileStreamReconnecting,
|
||||
MobileStreamConnected,
|
||||
ConversationChangedBeforeUndo,
|
||||
ConversationModePickerTitle,
|
||||
ConversationModeGroupConversation,
|
||||
ConversationModeGroupPermissions,
|
||||
ConversationModeStandardName,
|
||||
ConversationModeStandardDescription,
|
||||
ConversationModeCreatorName,
|
||||
ConversationModeCreatorDescription,
|
||||
ConversationModeNotReady,
|
||||
ConversationModeLockedNote,
|
||||
ConversationModeLockedRefusal,
|
||||
ConversationModeUnknown,
|
||||
ConversationModeUnavailableRefusal,
|
||||
ConversationModeSet,
|
||||
ConversationModeLabelUnavailable,
|
||||
ConversationModeSavedUnavailableNotice,
|
||||
ConversationModeResumeUnreadable,
|
||||
SessionArchiveExported,
|
||||
SessionArchiveSizes,
|
||||
SessionArchiveNoArtifacts,
|
||||
@@ -3130,22 +3114,6 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::MobileStreamReconnecting,
|
||||
MessageId::MobileStreamConnected,
|
||||
MessageId::ConversationChangedBeforeUndo,
|
||||
MessageId::ConversationModePickerTitle,
|
||||
MessageId::ConversationModeGroupConversation,
|
||||
MessageId::ConversationModeGroupPermissions,
|
||||
MessageId::ConversationModeStandardName,
|
||||
MessageId::ConversationModeStandardDescription,
|
||||
MessageId::ConversationModeCreatorName,
|
||||
MessageId::ConversationModeCreatorDescription,
|
||||
MessageId::ConversationModeNotReady,
|
||||
MessageId::ConversationModeLockedNote,
|
||||
MessageId::ConversationModeLockedRefusal,
|
||||
MessageId::ConversationModeUnknown,
|
||||
MessageId::ConversationModeUnavailableRefusal,
|
||||
MessageId::ConversationModeSet,
|
||||
MessageId::ConversationModeLabelUnavailable,
|
||||
MessageId::ConversationModeSavedUnavailableNotice,
|
||||
MessageId::ConversationModeResumeUnreadable,
|
||||
MessageId::SessionArchiveExported,
|
||||
MessageId::SessionArchiveSizes,
|
||||
MessageId::SessionArchiveNoArtifacts,
|
||||
|
||||
@@ -444,8 +444,6 @@ pub enum Op {
|
||||
workspace: PathBuf,
|
||||
#[serde(default = "default_mode")]
|
||||
mode: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
agent_preset: Option<String>,
|
||||
},
|
||||
|
||||
/// Rewind an unchanged conversation. `expected` is the complete observed
|
||||
@@ -789,7 +787,6 @@ mod tests {
|
||||
model: "m".into(),
|
||||
workspace: PathBuf::from("/ws"),
|
||||
mode: "agent".into(),
|
||||
agent_preset: Some("creator".into()),
|
||||
},
|
||||
Op::RewindConversation {
|
||||
expected: json!({"session_id": "s", "messages": []}),
|
||||
|
||||
@@ -344,6 +344,7 @@ mod tests {
|
||||
};
|
||||
handle.record(Event::SessionStart {
|
||||
source: crate::SessionSource::Unknown,
|
||||
install_age: None,
|
||||
});
|
||||
let waiter = std::thread::spawn(move || handle.persist_local());
|
||||
assert!(matches!(rx.try_recv(), Ok(Message::Event(_))));
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use crate::event::{Counters, Errors, Event, ModelCounters, TurnWall};
|
||||
use crate::event::{ContextSize, Counters, Errors, Event, ModelCounters, TurnWall};
|
||||
|
||||
/// One session's accumulated counts.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -48,6 +48,7 @@ struct ModelSlot {
|
||||
model: String,
|
||||
counters: ModelCounters,
|
||||
turn_wall: TurnWall,
|
||||
context: ContextSize,
|
||||
}
|
||||
|
||||
/// At most this many distinct provider and model pairs per session; later
|
||||
@@ -186,6 +187,7 @@ impl SessionCounters {
|
||||
model,
|
||||
counters: ModelCounters::default(),
|
||||
turn_wall: TurnWall::default(),
|
||||
context: ContextSize::default(),
|
||||
});
|
||||
models.len() - 1
|
||||
}
|
||||
@@ -238,6 +240,7 @@ impl SessionCounters {
|
||||
model: slot.model.clone(),
|
||||
counters: slot.counters,
|
||||
turn_wall: slot.turn_wall,
|
||||
context: slot.context,
|
||||
}));
|
||||
}
|
||||
if let Ok(tools) = self.tools.lock() {
|
||||
|
||||
@@ -6,7 +6,7 @@ use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::buffer;
|
||||
use crate::event::{Arch, Libc, Os};
|
||||
use crate::event::{Arch, InstallAge, Libc, Os};
|
||||
|
||||
/// How long an install id may live before it is replaced.
|
||||
///
|
||||
@@ -47,6 +47,77 @@ pub struct TelemetryState {
|
||||
/// a permanently offline machine tries at most once per interval.
|
||||
#[serde(default)]
|
||||
pub last_flush: Option<String>,
|
||||
/// The UTC day (`YYYY-MM-DD`) this state was first created. Local only: it
|
||||
/// is read to pick an [`InstallAge`] bucket and is never sent.
|
||||
#[serde(default)]
|
||||
pub first_seen_day: Option<String>,
|
||||
/// Set when this file predates `first_seen_day`, so its age is unknown and
|
||||
/// is reported as the oldest bucket rather than as new.
|
||||
#[serde(default)]
|
||||
pub first_seen_unknown: bool,
|
||||
}
|
||||
|
||||
enum StateRead {
|
||||
Missing,
|
||||
Unreadable,
|
||||
Present(TelemetryState),
|
||||
}
|
||||
|
||||
fn read_state_checked(root: &Path) -> StateRead {
|
||||
match std::fs::read_to_string(buffer::state_path(root)) {
|
||||
Ok(body) => serde_json::from_str::<TelemetryState>(&body)
|
||||
.map_or(StateRead::Unreadable, StateRead::Present),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => StateRead::Missing,
|
||||
Err(_) => StateRead::Unreadable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's date in UTC.
|
||||
#[must_use]
|
||||
pub fn utc_today() -> chrono::NaiveDate {
|
||||
chrono::Utc::now().date_naive()
|
||||
}
|
||||
|
||||
/// Record the first-seen day if this state has none. A missing state file is a
|
||||
/// new install; a file that has a recorded version but no day predates this
|
||||
/// field and is marked unknown. An unreadable file is left untouched.
|
||||
pub(crate) fn stamp_first_seen(root: &Path, today: chrono::NaiveDate) {
|
||||
let mut state = match read_state_checked(root) {
|
||||
StateRead::Unreadable => return,
|
||||
StateRead::Missing => TelemetryState::default(),
|
||||
StateRead::Present(state) => state,
|
||||
};
|
||||
if state.first_seen_day.is_some() || state.first_seen_unknown {
|
||||
return;
|
||||
}
|
||||
if state.last_version.is_some() {
|
||||
state.first_seen_unknown = true;
|
||||
} else {
|
||||
state.first_seen_day = Some(today.format("%Y-%m-%d").to_string());
|
||||
}
|
||||
let _ = write_state(root, &state);
|
||||
}
|
||||
|
||||
/// The coarse age bucket for this installation, or `None` when the local state
|
||||
/// cannot be read. A missing state is new.
|
||||
pub(crate) fn install_age_at(root: &Path, today: chrono::NaiveDate) -> Option<InstallAge> {
|
||||
let state = match read_state_checked(root) {
|
||||
StateRead::Unreadable => return None,
|
||||
StateRead::Missing => return Some(InstallAge::NewToday),
|
||||
StateRead::Present(state) => state,
|
||||
};
|
||||
if state.first_seen_unknown {
|
||||
return Some(InstallAge::Older);
|
||||
}
|
||||
let Some(day) = state.first_seen_day else {
|
||||
return Some(InstallAge::NewToday);
|
||||
};
|
||||
let first = chrono::NaiveDate::parse_from_str(&day, "%Y-%m-%d").ok()?;
|
||||
Some(match today.signed_duration_since(first).num_days() {
|
||||
..=0 => InstallAge::NewToday,
|
||||
1..=6 => InstallAge::UnderWeek,
|
||||
_ => InstallAge::Older,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the install id, minting a fresh one if it is missing, unreadable,
|
||||
|
||||
+122
-26
@@ -33,10 +33,10 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Wire schema version. Bumped on any field add, remove, or retype; never
|
||||
/// reused. `crates/telemetry/tests/golden/v1.json` pins what v1 was.
|
||||
pub const SCHEMA_VERSION: u32 = 4;
|
||||
pub const SCHEMA_VERSION: u32 = 6;
|
||||
|
||||
/// Default-on policy disclosure version. This is not human consent.
|
||||
pub const NOTICE_VERSION: u32 = 6;
|
||||
pub const NOTICE_VERSION: u32 = 8;
|
||||
|
||||
/// Which product surface produced a batch.
|
||||
///
|
||||
@@ -277,6 +277,12 @@ pub enum SessionSource {
|
||||
/// Started under CI (`CI` or `GITHUB_ACTIONS` set), including the GitHub
|
||||
/// Action, so automation is never counted as a person.
|
||||
Ci,
|
||||
/// `exec` started by Codewhale's own fleet dispatcher.
|
||||
FleetWorker,
|
||||
/// `exec` started from inside an agent's shell command.
|
||||
Agent,
|
||||
/// `exec` whose stdin or stdout is not a terminal.
|
||||
Scripted,
|
||||
}
|
||||
|
||||
impl SessionSource {
|
||||
@@ -288,6 +294,9 @@ impl SessionSource {
|
||||
Self::Api,
|
||||
Self::Unknown,
|
||||
Self::Ci,
|
||||
Self::FleetWorker,
|
||||
Self::Agent,
|
||||
Self::Scripted,
|
||||
];
|
||||
|
||||
/// The wire spelling.
|
||||
@@ -300,6 +309,37 @@ impl SessionSource {
|
||||
Self::Api => "api",
|
||||
Self::Unknown => "unknown",
|
||||
Self::Ci => "ci",
|
||||
Self::FleetWorker => "fleet_worker",
|
||||
Self::Agent => "agent",
|
||||
Self::Scripted => "scripted",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long this install has existed, as a coarse bucket. The first-seen date
|
||||
/// itself never leaves the device.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstallAge {
|
||||
/// First seen on the current UTC day.
|
||||
NewToday,
|
||||
/// First seen 1 to 6 UTC days ago.
|
||||
UnderWeek,
|
||||
/// First seen 7 or more UTC days ago, or before the date was recorded.
|
||||
Older,
|
||||
}
|
||||
|
||||
impl InstallAge {
|
||||
/// Every value, for exhaustive iteration.
|
||||
pub const ALL: &'static [Self] = &[Self::NewToday, Self::UnderWeek, Self::Older];
|
||||
|
||||
/// The wire spelling.
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NewToday => "new_today",
|
||||
Self::UnderWeek => "under_week",
|
||||
Self::Older => "older",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,11 +406,20 @@ pub enum ExitClass {
|
||||
Panic,
|
||||
/// Exited non-successfully without a signal or a panic.
|
||||
Error,
|
||||
/// The process ended without its shutdown report; its aggregates were
|
||||
/// recovered from a local checkpoint at a later start.
|
||||
Unclean,
|
||||
}
|
||||
|
||||
impl ExitClass {
|
||||
/// Every value, for exhaustive iteration.
|
||||
pub const ALL: &'static [Self] = &[Self::Clean, Self::Signal, Self::Panic, Self::Error];
|
||||
pub const ALL: &'static [Self] = &[
|
||||
Self::Clean,
|
||||
Self::Signal,
|
||||
Self::Panic,
|
||||
Self::Error,
|
||||
Self::Unclean,
|
||||
];
|
||||
|
||||
/// The wire spelling.
|
||||
#[must_use]
|
||||
@@ -380,6 +429,7 @@ impl ExitClass {
|
||||
Self::Signal => "signal",
|
||||
Self::Panic => "panic",
|
||||
Self::Error => "error",
|
||||
Self::Unclean => "unclean",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +441,7 @@ impl ExitClass {
|
||||
Self::Signal => 1,
|
||||
Self::Panic => 2,
|
||||
Self::Error => 3,
|
||||
Self::Unclean => 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +452,7 @@ impl ExitClass {
|
||||
1 => Self::Signal,
|
||||
2 => Self::Panic,
|
||||
3 => Self::Error,
|
||||
4 => Self::Unclean,
|
||||
_ => Self::Clean,
|
||||
}
|
||||
}
|
||||
@@ -639,6 +691,9 @@ pub enum Event {
|
||||
SessionStart {
|
||||
/// How it was started.
|
||||
source: SessionSource,
|
||||
/// Coarse install age. `null` when the local state cannot be read.
|
||||
#[serde(default)]
|
||||
install_age: Option<InstallAge>,
|
||||
},
|
||||
/// A session ended. Everything the session accumulated ships here, once.
|
||||
SessionEnd {
|
||||
@@ -696,6 +751,9 @@ pub enum Event {
|
||||
counters: ModelCounters,
|
||||
/// Turn wall-clock buckets for these turns.
|
||||
turn_wall: TurnWall,
|
||||
/// Requests by input size.
|
||||
#[serde(default)]
|
||||
context: ContextSize,
|
||||
},
|
||||
/// One session's calls of one tool.
|
||||
ToolUsage {
|
||||
@@ -725,6 +783,16 @@ pub struct ModelCounters {
|
||||
pub cache_read_tokens: u64,
|
||||
/// Input tokens written to the provider's prompt cache.
|
||||
pub cache_write_tokens: u64,
|
||||
/// Provider requests on the session's own route for this model.
|
||||
#[serde(default)]
|
||||
pub requests: u32,
|
||||
/// Reasoning tokens as the provider reports them; a subset of
|
||||
/// `output_tokens`, which is not reduced.
|
||||
#[serde(default)]
|
||||
pub reasoning_tokens: u64,
|
||||
/// Whole seconds spent waiting on the model, summed over the session.
|
||||
#[serde(default)]
|
||||
pub generation_secs: u32,
|
||||
}
|
||||
|
||||
impl ModelCounters {
|
||||
@@ -737,9 +805,44 @@ impl ModelCounters {
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"requests",
|
||||
"reasoning_tokens",
|
||||
"generation_secs",
|
||||
];
|
||||
}
|
||||
|
||||
/// Per-session count of model requests by input size, in tokens including
|
||||
/// cache reads. Half-open intervals; K is 1024. A histogram, never a
|
||||
/// per-request series.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContextSize {
|
||||
/// Requests with `[0, 32768)` input tokens.
|
||||
pub lt_32k: u32,
|
||||
/// Requests with `[32768, 131072)` input tokens.
|
||||
#[serde(rename = "32k_128k")]
|
||||
pub k32_to_k128: u32,
|
||||
/// Requests with `[131072, 262144)` input tokens.
|
||||
#[serde(rename = "128k_256k")]
|
||||
pub k128_to_k256: u32,
|
||||
/// Requests with 262144 or more input tokens.
|
||||
pub gte_256k: u32,
|
||||
}
|
||||
|
||||
impl ContextSize {
|
||||
/// Field names in wire spelling, in declaration order.
|
||||
pub const FIELDS: &'static [&'static str] = &["lt_32k", "32k_128k", "128k_256k", "gte_256k"];
|
||||
|
||||
/// Record one request with `tokens` input tokens.
|
||||
pub fn observe(&mut self, tokens: u64) {
|
||||
match tokens {
|
||||
0..32_768 => self.lt_32k = self.lt_32k.saturating_add(1),
|
||||
32_768..131_072 => self.k32_to_k128 = self.k32_to_k128.saturating_add(1),
|
||||
131_072..262_144 => self.k128_to_k256 = self.k128_to_k256.saturating_add(1),
|
||||
_ => self.gte_256k = self.gte_256k.saturating_add(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// First-party tool names reported by `tool_usage`. Any other name is
|
||||
/// reported as `mcp` or `other`, so a plugin's or server's tool name never
|
||||
/// leaves the machine.
|
||||
@@ -830,17 +933,22 @@ pub fn is_reported_tool_name(value: &str) -> bool {
|
||||
value == "mcp" || value == "other" || TOOL_NAMES.contains(&value)
|
||||
}
|
||||
|
||||
/// The `model` value for a model id: the lowercased id when the bundled model
|
||||
/// catalog lists it, otherwise `other`. Custom, local and fine-tuned model
|
||||
/// names therefore never leave the machine.
|
||||
/// The `model` value for a model id: the lowercased canonical id when the
|
||||
/// bundled model catalog lists it, otherwise `other`. Official aliases and
|
||||
/// compatibility spellings are resolved first. Custom, local and fine-tuned
|
||||
/// model names therefore never leave the machine.
|
||||
#[must_use]
|
||||
pub fn reported_model_id(model: &str) -> String {
|
||||
use codewhale_config::catalog::reviewed::{compatibility_alias, official_deepseek_model_id};
|
||||
let id = model.trim().to_ascii_lowercase();
|
||||
if is_catalogued_model(&id) {
|
||||
id
|
||||
} else {
|
||||
"other".to_string()
|
||||
}
|
||||
let canonical = official_deepseek_model_id(&id)
|
||||
.or_else(|| compatibility_alias("canonical_model_name", &id))
|
||||
.map(str::to_ascii_lowercase);
|
||||
canonical
|
||||
.into_iter()
|
||||
.chain(std::iter::once(id))
|
||||
.find(|candidate| is_catalogued_model(candidate))
|
||||
.unwrap_or_else(|| "other".to_string())
|
||||
}
|
||||
|
||||
/// Whether `value` may appear as `model_usage.model`.
|
||||
@@ -850,21 +958,9 @@ pub fn is_reported_model_id(value: &str) -> bool {
|
||||
}
|
||||
|
||||
fn is_catalogued_model(id: &str) -> bool {
|
||||
if id.is_empty() || id.len() > 128 {
|
||||
return false;
|
||||
}
|
||||
let catalog = codewhale_config::catalog::bundled_models_dev_catalog();
|
||||
catalog.reviewed.intrinsic.contains_key(id)
|
||||
|| catalog
|
||||
.models
|
||||
.keys()
|
||||
.any(|key| key.eq_ignore_ascii_case(id))
|
||||
|| catalog.providers.values().any(|provider| {
|
||||
provider
|
||||
.models
|
||||
.keys()
|
||||
.any(|key| key.eq_ignore_ascii_case(id))
|
||||
})
|
||||
!id.is_empty()
|
||||
&& id.len() <= 128
|
||||
&& codewhale_config::catalog::reviewed::public_model_identifier_ignore_case(id)
|
||||
}
|
||||
|
||||
impl Event {
|
||||
|
||||
@@ -57,9 +57,9 @@ pub use decision::{
|
||||
};
|
||||
pub use envelope::reduce_panic_site;
|
||||
pub use event::{
|
||||
Arch, Batch, ColdStartBucket, Counters, DurationBucket, Errors, Event, ExitClass, InstallKind,
|
||||
Libc, ModelCounters, NOTICE_VERSION, Os, ProductCounters, SCHEMA_VERSION, SessionSource,
|
||||
Surface, TurnWall,
|
||||
Arch, Batch, ColdStartBucket, ContextSize, Counters, DurationBucket, Errors, Event, ExitClass,
|
||||
InstallAge, InstallKind, Libc, ModelCounters, NOTICE_VERSION, Os, ProductCounters,
|
||||
SCHEMA_VERSION, SessionSource, Surface, TurnWall,
|
||||
};
|
||||
|
||||
/// How long the shutdown flush may hold the process.
|
||||
@@ -123,9 +123,18 @@ pub fn init(consent: TelemetryConsent) {
|
||||
exit_class: AtomicU8::new(ExitClass::Clean.as_u8()),
|
||||
});
|
||||
|
||||
envelope::stamp_first_seen(&root, envelope::utc_today());
|
||||
record_install_or_upgrade(&root);
|
||||
}
|
||||
|
||||
/// The coarse install-age bucket for this process's `session_start`, or `None`
|
||||
/// when unarmed or when the local state cannot be read.
|
||||
#[must_use]
|
||||
pub fn install_age() -> Option<InstallAge> {
|
||||
let armed = ARMED.get()?;
|
||||
envelope::install_age_at(&armed.root, envelope::utc_today())
|
||||
}
|
||||
|
||||
/// Note that this binary's version differs from the one last seen on this
|
||||
/// machine, at most once per version.
|
||||
///
|
||||
|
||||
@@ -25,7 +25,9 @@ Codewhale counts: which version you run, OS and CPU family, session
|
||||
duration and outcome, and aggregate feature and error counters.
|
||||
|
||||
It also counts the providers and public catalog models you use, their
|
||||
token totals, and how often each built-in tool runs.
|
||||
token totals, request counts, time spent waiting on the model and
|
||||
prompt-size ranges, how often each built-in tool runs, how an exec
|
||||
session was started, and a coarse install-age range.
|
||||
|
||||
It never collects your conversations, code, prompts, files, repo or
|
||||
branch names, model content, or credentials — and it never sends a
|
||||
@@ -41,7 +43,7 @@ Usage reporting is on by default. Codewhale and PostHog process these
|
||||
counts when delivery is configured. No IP is collected.";
|
||||
|
||||
/// Concise disclosure for every armed runtime surface, including headless CLI.
|
||||
pub const STARTUP_DISCLOSURE: &str = "Usage reporting is on by default: Codewhale and PostHog process aggregate version/platform, session, feature, error, model/token and built-in tool counts when delivery is configured. No content or IP. Turn off: codewhale config set telemetry false. Details: codewhale config telemetry";
|
||||
pub const STARTUP_DISCLOSURE: &str = "Usage reporting is on by default: Codewhale and PostHog process aggregate version/platform, session, feature, error, model/token/request/wait-time/prompt-size, exec-start, install-age range and built-in tool counts when delivery is configured. No content or IP. Turn off: codewhale config set telemetry false. Details: codewhale config telemetry";
|
||||
|
||||
/// Present the policy once per revision without recording human acceptance.
|
||||
/// Failure to save the display marker only causes a later repeat disclosure.
|
||||
|
||||
+322
-11
@@ -121,11 +121,12 @@ fn every_event() -> Vec<Event> {
|
||||
previous_version: Some("0.9.3-rc.1".to_string()),
|
||||
},
|
||||
Event::SessionStart {
|
||||
source: SessionSource::Resume,
|
||||
source: SessionSource::FleetWorker,
|
||||
install_age: Some(InstallAge::UnderWeek),
|
||||
},
|
||||
Event::SessionEnd {
|
||||
duration_bucket: DurationBucket::OneToTen,
|
||||
exit_class: ExitClass::Panic,
|
||||
exit_class: ExitClass::Unclean,
|
||||
cold_start_bucket: Some(ColdStartBucket::Mid),
|
||||
providers: vec!["custom".to_string(), "deepseek".to_string()],
|
||||
counters: Counters {
|
||||
@@ -183,6 +184,9 @@ fn every_event() -> Vec<Event> {
|
||||
output_tokens: 9_120,
|
||||
cache_read_tokens: 380_001,
|
||||
cache_write_tokens: 0,
|
||||
requests: 27,
|
||||
reasoning_tokens: 2_310,
|
||||
generation_secs: 412,
|
||||
},
|
||||
turn_wall: TurnWall {
|
||||
lt_5s: 3,
|
||||
@@ -190,6 +194,12 @@ fn every_event() -> Vec<Event> {
|
||||
thirty_to_onetwenty: 2,
|
||||
gte_120s: 0,
|
||||
},
|
||||
context: ContextSize {
|
||||
lt_32k: 9,
|
||||
k32_to_k128: 11,
|
||||
k128_to_k256: 5,
|
||||
gte_256k: 2,
|
||||
},
|
||||
},
|
||||
Event::ToolUsage {
|
||||
tool: "read_file".to_string(),
|
||||
@@ -269,6 +279,7 @@ fn closed_enum_values() -> Vec<String> {
|
||||
values.extend(Libc::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(InstallKind::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(SessionSource::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(InstallAge::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(DurationBucket::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(ExitClass::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
values.extend(ColdStartBucket::ALL.iter().map(|v| v.as_str().to_string()));
|
||||
@@ -1226,6 +1237,7 @@ fn probe_threshold_cannot_hide_an_over_cap_buffer() {
|
||||
// only safe if `MAX_EVENTS` lines cannot fit under it.
|
||||
let shortest = serde_json::to_string(&Event::SessionStart {
|
||||
source: SessionSource::Api,
|
||||
install_age: None,
|
||||
})
|
||||
.expect("serialize");
|
||||
let floor = (shortest.len() as u64 + 1) * buffer::MAX_EVENTS as u64;
|
||||
@@ -1446,6 +1458,7 @@ fn record_blocking_is_a_noop_when_unarmed() {
|
||||
});
|
||||
crate::record(Event::SessionStart {
|
||||
source: SessionSource::Interactive,
|
||||
install_age: None,
|
||||
});
|
||||
crate::set_exit_class(ExitClass::Panic);
|
||||
assert_eq!(crate::exit_class(), ExitClass::Clean);
|
||||
@@ -1659,6 +1672,7 @@ fn no_public_api_accepts_a_bare_bool() {
|
||||
|
||||
const TELEMETRY_DOC: &str = include_str!("../../../docs/TELEMETRY.md");
|
||||
const GOLDEN_V4: &str = include_str!("../tests/golden/v4.json");
|
||||
const GOLDEN_V6: &str = include_str!("../tests/golden/v6.json");
|
||||
|
||||
/// Extract the fenced ```jsonc blocks from the schema doc, in order.
|
||||
fn jsonc_blocks(doc: &str) -> Vec<String> {
|
||||
@@ -1864,13 +1878,12 @@ fn event_field_names_match_documented_schema() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_payload_v4() {
|
||||
fn golden_payload_v6() {
|
||||
assert_eq!(NOTICE_VERSION.to_string(), TELEMETRY_NOTICE_VERSION);
|
||||
// `crates/telemetry/tests/golden/v4.json` is one fully-populated instance of
|
||||
// the envelope and every event. Any field add, remove, or retype fails here
|
||||
// until the developer re-blesses it under a bumped `SCHEMA_VERSION` — and it
|
||||
// is also the artifact a future receiver author reads to know exactly what
|
||||
// v1 was.
|
||||
// `crates/telemetry/tests/golden/v6.json` is one fully-populated instance of
|
||||
// the envelope and every event, and the batch the collector's own tests
|
||||
// post. Any field add, remove, or retype fails here until the developer
|
||||
// re-blesses it under a bumped `SCHEMA_VERSION`.
|
||||
//
|
||||
// Re-bless with: `CODEWHALE_BLESS_TELEMETRY_GOLDEN=1 cargo test -p codewhale-telemetry`
|
||||
let batch = every_field_batch();
|
||||
@@ -1878,22 +1891,81 @@ fn golden_payload_v4() {
|
||||
batch.schema_version, SCHEMA_VERSION,
|
||||
"the fixture must be built at the current schema version"
|
||||
);
|
||||
assert_eq!(SCHEMA_VERSION, 6);
|
||||
let mut rendered = serde_json::to_string_pretty(&batch).expect("serialize");
|
||||
rendered.push('\n');
|
||||
|
||||
if std::env::var("CODEWHALE_BLESS_TELEMETRY_GOLDEN").is_ok() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v4.json");
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/v6.json");
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create golden dir");
|
||||
std::fs::write(&path, &rendered).expect("write golden");
|
||||
return;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
rendered, GOLDEN_V4,
|
||||
"the v4 payload changed; bump SCHEMA_VERSION and re-bless the golden file"
|
||||
rendered, GOLDEN_V6,
|
||||
"the v6 payload changed; bump SCHEMA_VERSION and re-bless the golden file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_schema_4_golden_still_reads_back_with_the_new_fields_defaulted() {
|
||||
let legacy: Batch = serde_json::from_str(GOLDEN_V4).expect("v4 golden parses");
|
||||
assert_eq!(legacy.schema_version, 4);
|
||||
for event in &legacy.events {
|
||||
match event {
|
||||
Event::SessionStart { install_age, .. } => assert_eq!(*install_age, None),
|
||||
Event::ModelUsage {
|
||||
counters, context, ..
|
||||
} => {
|
||||
assert_eq!(counters.requests, 0);
|
||||
assert_eq!(counters.reasoning_tokens, 0);
|
||||
assert_eq!(counters.generation_secs, 0);
|
||||
assert_eq!(*context, ContextSize::default());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_buckets_are_half_open_at_32k_128k_and_256k() {
|
||||
let mut size = ContextSize::default();
|
||||
for tokens in [
|
||||
0,
|
||||
32_767,
|
||||
32_768,
|
||||
131_071,
|
||||
131_072,
|
||||
262_143,
|
||||
262_144,
|
||||
u64::MAX,
|
||||
] {
|
||||
size.observe(tokens);
|
||||
}
|
||||
assert_eq!(
|
||||
size,
|
||||
ContextSize {
|
||||
lt_32k: 2,
|
||||
k32_to_k128: 2,
|
||||
k128_to_k256: 2,
|
||||
gte_256k: 2,
|
||||
}
|
||||
);
|
||||
let json = serde_json::to_value(size).expect("serialize");
|
||||
let keys: Vec<&str> = json
|
||||
.as_object()
|
||||
.expect("object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
let mut expected: Vec<&str> = ContextSize::FIELDS.to_vec();
|
||||
expected.sort_unstable();
|
||||
let mut got = keys;
|
||||
got.sort_unstable();
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_comparison_names_install_upgrade_and_downgrade() {
|
||||
assert!(crate::version_is_older("0.9.3", "0.9.4"));
|
||||
@@ -1994,3 +2066,242 @@ fn the_notice_summarizes_what_the_schema_collects_and_states_every_red_line() {
|
||||
assert!(!body.contains("CODEWHALE_TELEMETRY=0"));
|
||||
assert!(body.contains("docs/TELEMETRY.md"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- model naming --
|
||||
|
||||
fn model_id_is_collector_shaped(id: &str) -> bool {
|
||||
let bytes = id.as_bytes();
|
||||
(1..=128).contains(&bytes.len())
|
||||
&& bytes[0].is_ascii_lowercase() | bytes[0].is_ascii_digit()
|
||||
&& bytes[1..].iter().all(|b| {
|
||||
b.is_ascii_lowercase()
|
||||
|| b.is_ascii_digit()
|
||||
|| matches!(b, b'.' | b'_' | b':' | b'/' | b'@' | b'+' | b'-')
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_model_id_resolves_aliases_and_spellings() {
|
||||
assert_eq!(reported_model_id("deepseek-v4-pro"), "deepseek-v4-pro");
|
||||
assert_eq!(reported_model_id(" DeepSeek-V4-Pro "), "deepseek-v4-pro");
|
||||
assert_eq!(reported_model_id("deepseek-v4pro"), "deepseek-v4-pro");
|
||||
assert_eq!(
|
||||
reported_model_id("deepseek-ai/deepseek-v4-pro"),
|
||||
"deepseek-v4-pro"
|
||||
);
|
||||
assert_eq!(
|
||||
reported_model_id("deepseek/deepseek-v4pro"),
|
||||
"deepseek-v4-pro"
|
||||
);
|
||||
assert_eq!(reported_model_id("pro"), "deepseek-v4-pro");
|
||||
assert_eq!(reported_model_id("flash"), "deepseek-flash");
|
||||
assert_eq!(reported_model_id("auto"), "other");
|
||||
assert_eq!(reported_model_id("my-private-finetune"), "other");
|
||||
assert_eq!(reported_model_id(""), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_model_id_never_leaves_the_catalog() {
|
||||
use codewhale_config::catalog::reviewed::{
|
||||
bundled_reviewed, public_model_identifier_ignore_case,
|
||||
};
|
||||
let reviewed = bundled_reviewed();
|
||||
let mut spellings: Vec<&str> = reviewed
|
||||
.official_deepseek_aliases
|
||||
.keys()
|
||||
.chain(reviewed.go_aliases.keys())
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
if let Some(group) = reviewed.compatibility_aliases.get("canonical_model_name") {
|
||||
spellings.extend(group.keys().map(String::as_str));
|
||||
}
|
||||
assert!(spellings.len() > 20, "alias tables look empty");
|
||||
for spelling in spellings {
|
||||
let reported = reported_model_id(spelling);
|
||||
assert!(is_reported_model_id(&reported), "{spelling} -> {reported}");
|
||||
assert!(
|
||||
model_id_is_collector_shaped(&reported),
|
||||
"{spelling} -> {reported}"
|
||||
);
|
||||
}
|
||||
for hostile in ["", "x/../y", "deepseek-v4-pro\n", "Ünï", &"a".repeat(300)] {
|
||||
let reported = reported_model_id(hostile);
|
||||
assert!(
|
||||
reported == "other" || public_model_identifier_ignore_case(&reported),
|
||||
"{hostile:?} -> {reported}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_listed_model_and_provider_default_maps_to_a_catalogued_id() {
|
||||
let catalog = codewhale_config::catalog::bundled_models_dev_catalog();
|
||||
let mut missing = Vec::new();
|
||||
let mut check = |origin: &str, id: &str| {
|
||||
let reported = reported_model_id(id);
|
||||
if reported == "other" || !model_id_is_collector_shaped(&reported) {
|
||||
missing.push(format!("{origin}: {id}"));
|
||||
}
|
||||
};
|
||||
for provider in catalog.providers.values() {
|
||||
for (id, row) in &provider.models {
|
||||
check("catalog", id);
|
||||
check("catalog row", &row.id);
|
||||
}
|
||||
}
|
||||
for id in catalog.models.keys() {
|
||||
check("canonical", id);
|
||||
}
|
||||
|
||||
let descriptors: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../config/assets/provider_descriptors.json"),
|
||||
)
|
||||
.expect("provider descriptors"),
|
||||
)
|
||||
.expect("descriptors json");
|
||||
let exceptions: &[&str] = &["antigravity", "custom", "openai-compatible", "ollama"];
|
||||
for provider in descriptors["providers"].as_array().expect("providers") {
|
||||
let id = provider["id"].as_str().expect("provider id");
|
||||
let selectable = provider["selectable"].as_bool().unwrap_or(false);
|
||||
let default = provider["default_model"].as_str().unwrap_or_default();
|
||||
if selectable && !default.is_empty() && !exceptions.contains(&id) {
|
||||
check(&format!("default for {id}"), default);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"ids the catalog does not name:\n{}",
|
||||
missing.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- install age --
|
||||
|
||||
fn day(text: &str) -> chrono::NaiveDate {
|
||||
chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d").expect("date")
|
||||
}
|
||||
|
||||
fn root_with_state(state: &str) -> (tempfile::TempDir, PathBuf) {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create telemetry dir");
|
||||
std::fs::write(buffer::state_path(&root), state).expect("write state");
|
||||
(home, root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_home_is_new_today_and_ages_by_utc_day() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create telemetry dir");
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
Some(InstallAge::NewToday)
|
||||
);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
for (today, expected) in [
|
||||
("2026-11-13", InstallAge::NewToday),
|
||||
("2026-11-14", InstallAge::UnderWeek),
|
||||
("2026-11-16", InstallAge::UnderWeek),
|
||||
("2026-11-19", InstallAge::UnderWeek),
|
||||
("2026-11-20", InstallAge::Older),
|
||||
("2027-06-01", InstallAge::Older),
|
||||
("2026-11-12", InstallAge::NewToday),
|
||||
] {
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day(today)),
|
||||
Some(expected),
|
||||
"{today}"
|
||||
);
|
||||
}
|
||||
envelope::stamp_first_seen(&root, day("2026-12-25"));
|
||||
assert_eq!(
|
||||
envelope::read_state(&root).first_seen_day.as_deref(),
|
||||
Some("2026-11-13"),
|
||||
"a second stamp never moves the first-seen day"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_written_before_the_day_existed_reads_older_never_new() {
|
||||
let (_home, root) = root_with_state(r#"{"schema_version":4,"last_version":"0.10.2"}"#);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
let state = envelope::read_state(&root);
|
||||
assert_eq!(state.first_seen_day, None);
|
||||
assert!(state.first_seen_unknown);
|
||||
assert_eq!(state.last_version.as_deref(), Some("0.10.2"));
|
||||
for today in ["2026-11-13", "2026-11-14", "2030-01-01"] {
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day(today)),
|
||||
Some(InstallAge::Older),
|
||||
"{today}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_or_hostile_state_gives_null_and_is_not_overwritten() {
|
||||
for body in [
|
||||
"not json",
|
||||
r#"{"first_seen_day":"9999-99-99"}"#,
|
||||
r#"{"first_seen_day":"yesterday"}"#,
|
||||
] {
|
||||
let (_home, root) = root_with_state(body);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
None,
|
||||
"{body}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(buffer::state_path(&root)).expect("state"),
|
||||
body,
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opting_out_erases_the_age_and_rotating_the_install_id_keeps_it() {
|
||||
let home = temp_home();
|
||||
let root = root_of(&home);
|
||||
buffer::ensure_dir(&root).expect("create telemetry dir");
|
||||
envelope::stamp_first_seen(&root, day("2026-01-01"));
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
Some(InstallAge::Older)
|
||||
);
|
||||
|
||||
std::fs::write(
|
||||
buffer::install_id_path(&root),
|
||||
r#"{"schema_version":1,"install_id":"3f2a9c1e-0000-4000-8000-000000000001","rotated_at":"2020-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.expect("write expired id");
|
||||
let rotated = envelope::read_or_create_install_id(&root).expect("rotate");
|
||||
assert_ne!(rotated.install_id, "3f2a9c1e-0000-4000-8000-000000000001");
|
||||
assert_eq!(
|
||||
envelope::read_state(&root).first_seen_day.as_deref(),
|
||||
Some("2026-01-01"),
|
||||
"rotation does not touch the age"
|
||||
);
|
||||
|
||||
buffer::wipe(&root).expect("wipe");
|
||||
std::fs::remove_file(buffer::tombstone_path(&root)).expect("re-enable");
|
||||
assert_eq!(
|
||||
envelope::install_age_at(&root, day("2026-11-13")),
|
||||
Some(InstallAge::NewToday)
|
||||
);
|
||||
envelope::stamp_first_seen(&root, day("2026-11-13"));
|
||||
assert_eq!(
|
||||
envelope::read_state(&root).first_seen_day.as_deref(),
|
||||
Some("2026-11-13")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_seen_day_never_reaches_the_wire() {
|
||||
let wire = serde_json::to_string(&every_field_batch()).expect("serialize");
|
||||
assert!(!wire.contains("first_seen"));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"schema_version": 6,
|
||||
"notice_version": 8,
|
||||
"sent_at": "2026-08-03T18:04:11Z",
|
||||
"install_id": "3f2a9c1e-0000-4000-8000-000000000001",
|
||||
"app_version": "0.9.4",
|
||||
"git_sha": "abcdef012345",
|
||||
"surface": "control-plane",
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"libc": "none",
|
||||
"tty": true,
|
||||
"events": [
|
||||
{
|
||||
"event": "install_or_upgrade",
|
||||
"kind": "upgrade",
|
||||
"previous_version": "0.9.3-rc.1"
|
||||
},
|
||||
{
|
||||
"event": "session_start",
|
||||
"source": "fleet_worker",
|
||||
"install_age": "under_week"
|
||||
},
|
||||
{
|
||||
"event": "session_end",
|
||||
"duration_bucket": "1m_10m",
|
||||
"exit_class": "unclean",
|
||||
"cold_start_bucket": "250_1000",
|
||||
"providers": [
|
||||
"custom",
|
||||
"deepseek"
|
||||
],
|
||||
"counters": {
|
||||
"turns": 14,
|
||||
"tool_calls": 61,
|
||||
"fleet_dispatch": 0,
|
||||
"workflow_run": 0,
|
||||
"subagent_spawn": 2,
|
||||
"mcp_server_connected": 0,
|
||||
"memory_search": 0,
|
||||
"approval_modal_shown": 0,
|
||||
"approval_auto_allowed": 0,
|
||||
"command_palette_open": 3
|
||||
},
|
||||
"errors": {
|
||||
"auth_preflight_failed": 0,
|
||||
"provider_http_4xx": 0,
|
||||
"provider_http_5xx": 1,
|
||||
"tool_denied_by_policy": 0,
|
||||
"tool_timeout": 0,
|
||||
"network_error": 0
|
||||
},
|
||||
"turn_wall": {
|
||||
"lt_5s": 9,
|
||||
"5_30s": 4,
|
||||
"30_120s": 1,
|
||||
"gte_120s": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "panic",
|
||||
"site": "crates/tui/src/tui/ui.rs:8801:17"
|
||||
},
|
||||
{
|
||||
"event": "product_usage",
|
||||
"counters": {
|
||||
"page_view": 1,
|
||||
"docs_view": 0,
|
||||
"install_copy": 0,
|
||||
"download": 0,
|
||||
"signup": 0,
|
||||
"login": 0,
|
||||
"session_create": 0,
|
||||
"session_resume": 0,
|
||||
"turn_submit": 0,
|
||||
"turn_complete": 0,
|
||||
"settings_open": 0,
|
||||
"integration_connect": 0,
|
||||
"error_shown": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "operations_summary",
|
||||
"requests": 10,
|
||||
"errors": 1,
|
||||
"duration_ms_total": 1500,
|
||||
"duration_ms_max": 300,
|
||||
"probes": 2,
|
||||
"probes_failed": 0
|
||||
},
|
||||
{
|
||||
"event": "model_usage",
|
||||
"provider": "deepseek",
|
||||
"model": "other",
|
||||
"counters": {
|
||||
"turns_ok": 12,
|
||||
"turns_failed": 1,
|
||||
"turns_interrupted": 0,
|
||||
"input_tokens": 418230,
|
||||
"output_tokens": 9120,
|
||||
"cache_read_tokens": 380001,
|
||||
"cache_write_tokens": 0,
|
||||
"requests": 27,
|
||||
"reasoning_tokens": 2310,
|
||||
"generation_secs": 412
|
||||
},
|
||||
"turn_wall": {
|
||||
"lt_5s": 3,
|
||||
"5_30s": 8,
|
||||
"30_120s": 2,
|
||||
"gte_120s": 0
|
||||
},
|
||||
"context": {
|
||||
"lt_32k": 9,
|
||||
"32k_128k": 11,
|
||||
"128k_256k": 5,
|
||||
"gte_256k": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "tool_usage",
|
||||
"tool": "read_file",
|
||||
"calls": 41,
|
||||
"errors": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -41,7 +41,6 @@ pub(crate) fn config_action_to_app_action(action: ConfigAction) -> AppAction {
|
||||
ConfigAction::StartOrcarouterRevoke => AppAction::StartOrcarouterRevoke,
|
||||
ConfigAction::OpenConfigView => AppAction::OpenConfigView,
|
||||
ConfigAction::OpenModePicker => AppAction::OpenModePicker,
|
||||
ConfigAction::SetConversationMode { id } => AppAction::SetConversationMode { id },
|
||||
ConfigAction::OpenThemePicker => AppAction::OpenThemePicker,
|
||||
ConfigAction::OpenStatusPicker => AppAction::OpenStatusPicker,
|
||||
ConfigAction::OpenProviderPicker => AppAction::OpenProviderPicker,
|
||||
@@ -101,7 +100,6 @@ pub(crate) fn app_action_to_config_action(action: AppAction) -> Option<ConfigAct
|
||||
AppAction::StartOrcarouterRevoke => ConfigAction::StartOrcarouterRevoke,
|
||||
AppAction::OpenConfigView => ConfigAction::OpenConfigView,
|
||||
AppAction::OpenModePicker => ConfigAction::OpenModePicker,
|
||||
AppAction::SetConversationMode { id } => ConfigAction::SetConversationMode { id },
|
||||
AppAction::OpenThemePicker => ConfigAction::OpenThemePicker,
|
||||
AppAction::OpenStatusPicker => ConfigAction::OpenStatusPicker,
|
||||
AppAction::OpenProviderPicker => ConfigAction::OpenProviderPicker,
|
||||
|
||||
@@ -438,7 +438,6 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: to_command_mode(app.mode),
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -735,7 +734,6 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -798,7 +796,6 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
forked.journal = Some(forked_journal);
|
||||
forked.leaf_id = forked.journal.as_ref().and_then(|j| j.leaf_id.clone());
|
||||
forked.messages = messages;
|
||||
forked.agent_preset = source_session.agent_preset.clone();
|
||||
forked.metadata.spawn_depth = forked.journal.as_ref().map(|j| j.spawn_depth).unwrap_or(0);
|
||||
forked.metadata.parent_session_id = Some(source_session.metadata.id.clone());
|
||||
forked.metadata.forked_from_message_count = Some(source_session.metadata.message_count);
|
||||
@@ -837,36 +834,12 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn fresh_session(
|
||||
&mut self,
|
||||
force: bool,
|
||||
conversation_mode: Option<&str>,
|
||||
) -> Result<SessionNewReceipt, String> {
|
||||
fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String> {
|
||||
let app = self.host.app.borrow();
|
||||
let agent_preset = match conversation_mode {
|
||||
None => None,
|
||||
Some(id) => {
|
||||
let table = crate::tui::conversation_mode::current_table_blocking(self.host.config);
|
||||
match crate::tui::conversation_mode::resolve(app.ui_locale, &table, id) {
|
||||
crate::tui::conversation_mode::Resolution::Unknown(message) => {
|
||||
return Err(message);
|
||||
}
|
||||
crate::tui::conversation_mode::Resolution::Unavailable { name, reason } => {
|
||||
return Err(crate::tui::conversation_mode::unavailable_message(
|
||||
app.ui_locale,
|
||||
&name,
|
||||
&reason,
|
||||
));
|
||||
}
|
||||
crate::tui::conversation_mode::Resolution::Ready { stored, .. } => stored,
|
||||
}
|
||||
}
|
||||
};
|
||||
if !force {
|
||||
let mut blockers: Vec<&'static str> = Vec::new();
|
||||
if !app.input.trim().is_empty() {
|
||||
@@ -898,7 +871,6 @@ impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode,
|
||||
agent_preset,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1751,7 +1723,6 @@ fn import_session_container(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: to_command_mode(app.mode),
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -7462,9 +7433,7 @@ mod tests {
|
||||
let mut bundle = app.command_contexts();
|
||||
let mut parts = bundle.parts();
|
||||
let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
|
||||
let err = facet
|
||||
.fresh_session(true, None)
|
||||
.expect_err("busy work state");
|
||||
let err = facet.fresh_session(true).expect_err("busy work state");
|
||||
assert!(err.contains("Work state is busy"), "{err}");
|
||||
}
|
||||
assert_eq!(app.api_messages.len(), 1);
|
||||
@@ -7483,7 +7452,7 @@ mod tests {
|
||||
let mut bundle = app.command_contexts();
|
||||
let mut parts = bundle.parts();
|
||||
let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
|
||||
let err = facet.fresh_session(false, None).expect_err("blocker text");
|
||||
let err = facet.fresh_session(false).expect_err("blocker text");
|
||||
assert!(err.contains("/new --force"), "{err}");
|
||||
}
|
||||
assert_eq!(app.input, "draft text");
|
||||
@@ -7494,7 +7463,7 @@ mod tests {
|
||||
let mut parts = bundle.parts();
|
||||
let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
|
||||
let ok = facet
|
||||
.fresh_session(true, None)
|
||||
.fresh_session(true)
|
||||
.expect("force prepares a new session");
|
||||
assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
|
||||
assert_eq!(app.input, "draft text");
|
||||
|
||||
@@ -215,7 +215,6 @@ fn session_sync_payload(app: &App) -> SessionSyncPayload {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: super::to_command_mode(app.mode),
|
||||
agent_preset: app.agent_preset(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ fn sync_session(sync: codewhale_command_contract::facets::SessionSyncPayload) ->
|
||||
model: sync.model,
|
||||
workspace: sync.workspace,
|
||||
mode: super::contract::from_command_mode(sync.mode),
|
||||
agent_preset: sync.agent_preset,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -608,26 +608,12 @@ pub fn mode(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandRe
|
||||
}
|
||||
}
|
||||
}
|
||||
None if is_conversation_mode_word(arg) => {
|
||||
ConfigCommandResult::action(ConfigAction::SetConversationMode {
|
||||
id: arg.trim().to_ascii_lowercase(),
|
||||
})
|
||||
}
|
||||
None => ConfigCommandResult::error(format!("Usage: {MODE_USAGE}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_conversation_mode_word(arg: &str) -> bool {
|
||||
let arg = arg.trim();
|
||||
!arg.is_empty()
|
||||
&& arg.len() <= 64
|
||||
&& arg
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
/// `/mode` usage, shared with the registration metadata.
|
||||
pub(super) const MODE_USAGE: &str = "/mode [work|plan|1|2|<conversation mode>]";
|
||||
pub(super) const MODE_USAGE: &str = "/mode [work|plan|1|2]";
|
||||
|
||||
/// `/pet` usage, shared with the registration metadata.
|
||||
pub const PET_USAGE: &str = "/pet [on|off|inspect|status|appearance|style [smooth|pixel]|language [auto|en|zh-Hans|zh-Hant|ja]|window|source|export|sound [on|off]|avatar [whale|cove|braille|pixels|particles|whale-girl|key]|action [name|live]|view [name|live]]";
|
||||
|
||||
@@ -58,7 +58,6 @@ pub fn clear(app: &mut App) -> CommandResult {
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -260,30 +260,16 @@ fn load_composes_exact_baseline_outcomes() {
|
||||
fn new_composes_exact_baseline_outcomes() {
|
||||
// Unknown argument usage.
|
||||
let mut canned = CannedLifecycle::default();
|
||||
let result = super::new::new_portable(&mut canned, Some("--bogus"));
|
||||
let result = super::new::new_portable(&mut canned, Some("bogus"));
|
||||
assert!(result.is_error);
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Unknown argument: --bogus"),
|
||||
.contains("Unknown argument: bogus"),
|
||||
"{result:?}"
|
||||
);
|
||||
let result = super::new::new_portable(&mut canned, Some("creator standard"));
|
||||
assert!(result.is_error);
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Unknown argument: standard"),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(
|
||||
canned.fresh_modes.is_empty(),
|
||||
"a bad argument asks the host nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
canned.transition_checks.get(),
|
||||
0,
|
||||
@@ -327,21 +313,8 @@ fn new_composes_exact_baseline_outcomes() {
|
||||
Some(SessionAction::NewSession { sync: codewhale_command_contract::facets::SessionSyncPayload { session_id: Some(ref id), .. }, force: true }) if id == "new-123"
|
||||
));
|
||||
assert_eq!(canned.fresh_forces, [true]);
|
||||
assert_eq!(canned.fresh_modes, [None]);
|
||||
assert_eq!(canned.transition_checks.get(), 1);
|
||||
|
||||
let mut canned = CannedLifecycle {
|
||||
fresh: Ok(SessionNewReceipt {
|
||||
truncated_id: "new-456".to_string(),
|
||||
sync: sync_payload("new-456"),
|
||||
}),
|
||||
..CannedLifecycle::default()
|
||||
};
|
||||
let result = super::new::new_portable(&mut canned, Some("--force creator"));
|
||||
assert!(!result.is_error, "{result:?}");
|
||||
assert_eq!(canned.fresh_forces, [true]);
|
||||
assert_eq!(canned.fresh_modes, [Some("creator".to_string())]);
|
||||
|
||||
// Host blocker error passes through.
|
||||
let mut canned = CannedLifecycle {
|
||||
fresh: Err("Cannot start a new session while the composer has unsent text. Run `/new --force` to discard pending work and start a fresh session.".to_string()),
|
||||
|
||||
@@ -32,7 +32,6 @@ pub(crate) struct CannedLifecycle {
|
||||
pub save_paths: Vec<Option<String>>,
|
||||
pub fork_sources: Vec<String>,
|
||||
pub fresh_forces: Vec<bool>,
|
||||
pub fresh_modes: Vec<Option<String>>,
|
||||
pub load_paths: Vec<String>,
|
||||
pub picker_calls: Vec<Option<String>>,
|
||||
pub archive_calls: Vec<(String, bool)>,
|
||||
@@ -59,7 +58,6 @@ impl Default for CannedLifecycle {
|
||||
save_paths: Vec::new(),
|
||||
fork_sources: Vec::new(),
|
||||
fresh_forces: Vec::new(),
|
||||
fresh_modes: Vec::new(),
|
||||
load_paths: Vec::new(),
|
||||
picker_calls: Vec::new(),
|
||||
archive_calls: Vec::new(),
|
||||
@@ -76,7 +74,6 @@ pub(crate) fn sync_payload(session_id: &str) -> SessionSyncPayload {
|
||||
model: "test-model".to_string(),
|
||||
workspace: PathBuf::from("/workspace"),
|
||||
mode: codewhale_command_contract::types::CommandMode::Agent,
|
||||
agent_preset: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,13 +107,8 @@ impl CommandSessionLifecycleContext for CannedLifecycle {
|
||||
self.fork_sources.push(id.to_string());
|
||||
self.fork_from.clone()
|
||||
}
|
||||
fn fresh_session(
|
||||
&mut self,
|
||||
force: bool,
|
||||
conversation_mode: Option<&str>,
|
||||
) -> Result<SessionNewReceipt, String> {
|
||||
fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String> {
|
||||
self.fresh_forces.push(force);
|
||||
self.fresh_modes.push(conversation_mode.map(str::to_string));
|
||||
self.fresh.clone()
|
||||
}
|
||||
fn load_session(&mut self, path: &str) -> Result<PathBuf, String> {
|
||||
|
||||
@@ -17,7 +17,7 @@ pub(in crate::commands) struct NewCmd;
|
||||
pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo {
|
||||
name: "new",
|
||||
aliases: &[],
|
||||
usage: "/new [<mode>] [--force]",
|
||||
usage: "/new [--force]",
|
||||
description_key: "cmd_new_description",
|
||||
};
|
||||
|
||||
@@ -51,21 +51,15 @@ pub(in crate::commands) fn new_portable(
|
||||
lifecycle: &mut dyn CommandSessionLifecycleContext,
|
||||
arg: Option<&str>,
|
||||
) -> CommandResult {
|
||||
let mut force = false;
|
||||
let mut conversation_mode: Option<&str> = None;
|
||||
for token in arg.unwrap_or_default().split_whitespace() {
|
||||
match token {
|
||||
"--force" | "force" => force = true,
|
||||
other if !other.starts_with('-') && conversation_mode.is_none() => {
|
||||
conversation_mode = Some(other);
|
||||
}
|
||||
other => {
|
||||
return CommandResult::error(format!(
|
||||
"Usage: /new [<mode>] [--force]\n\nUnknown argument: {other}"
|
||||
));
|
||||
}
|
||||
let force = match arg.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => false,
|
||||
Some("--force" | "force") => true,
|
||||
Some(other) => {
|
||||
return CommandResult::error(format!(
|
||||
"Usage: /new [--force]\n\nUnknown argument: {other}"
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
if lifecycle.transition_blocked() {
|
||||
// The shared tail names the exits that clear a blocker; `--force` is
|
||||
// not one of them, and a user who has just read it will try exactly
|
||||
@@ -75,7 +69,7 @@ pub(in crate::commands) fn new_portable(
|
||||
message.push_str("\n\n`/new --force` only discards draft or queued input.");
|
||||
return CommandResult::error(message);
|
||||
}
|
||||
match lifecycle.fresh_session(force, conversation_mode) {
|
||||
match lifecycle.fresh_session(force) {
|
||||
Ok(receipt) => CommandResult::action(
|
||||
codewhale_command_contract::outcome::SessionAction::NewSession {
|
||||
sync: receipt.sync,
|
||||
|
||||
@@ -94,7 +94,6 @@ pub(in crate::commands) fn host_result(result: SessionCommandResult) -> CommandR
|
||||
model: sync.model,
|
||||
workspace: sync.workspace,
|
||||
mode: super::contract::from_command_mode(sync.mode),
|
||||
agent_preset: sync.agent_preset,
|
||||
},
|
||||
});
|
||||
CommandResult {
|
||||
|
||||
@@ -77,7 +77,6 @@ pub(crate) async fn acknowledge_new_session(app: &mut App, result: &CommandResul
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await
|
||||
.expect("install current fixture session");
|
||||
@@ -1142,131 +1141,3 @@ fn branch_snapshot_roundtrip_preserves_siblings_ids_stamps_and_engine_projection
|
||||
let snapshot = crate::tui::ui::build_session_snapshot(&mut app, &manager).unwrap();
|
||||
assert_eq!(snapshot.journal.unwrap().entries, journal.entries);
|
||||
}
|
||||
|
||||
fn mode_test_message(text: &str) -> codewhale_models::Message {
|
||||
codewhale_models::Message {
|
||||
role: Role::User,
|
||||
content: vec![codewhale_models::ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn saved_file_value(path: &std::path::Path) -> serde_json::Value {
|
||||
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn new_creator_starts_a_creator_session_and_saves_agent_preset() {
|
||||
let _available = codewhale_engine::conversation_modes::CreatorAvailabilityGuard::set(None);
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let _home = SealedHome::new();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.current_session_id = Some("old-session".to_string());
|
||||
app.capture_session_fixture_source();
|
||||
|
||||
let result = new_session(&mut app, Some("creator"));
|
||||
assert!(!result.is_error, "{:?}", result.message);
|
||||
match result.action.as_ref() {
|
||||
Some(AppAction::NewSession { sync, .. }) => {
|
||||
assert_eq!(sync.agent_preset.as_deref(), Some("creator"));
|
||||
}
|
||||
other => panic!("expected NewSession, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
app.conversation_mode, None,
|
||||
"nothing changes before the Engine acknowledges"
|
||||
);
|
||||
acknowledge_new_session(&mut app, &result).await;
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
assert_eq!(app.agent_preset().as_deref(), Some("creator"));
|
||||
|
||||
app.push_api_message(mode_test_message("hello"));
|
||||
let creator_path = tmpdir.path().join("creator.json");
|
||||
assert!(!save(&mut app, Some(creator_path.to_str().unwrap())).is_error);
|
||||
assert_eq!(saved_file_value(&creator_path)["agent_preset"], "creator");
|
||||
|
||||
let mut standard = create_test_app_with_tmpdir(&tmpdir);
|
||||
standard.push_api_message(mode_test_message("hello"));
|
||||
let standard_path = tmpdir.path().join("standard.json");
|
||||
assert!(!save(&mut standard, Some(standard_path.to_str().unwrap())).is_error);
|
||||
assert!(
|
||||
saved_file_value(&standard_path)
|
||||
.get("agent_preset")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn new_without_argument_starts_standard_from_a_creator_session() {
|
||||
let _available = codewhale_engine::conversation_modes::CreatorAvailabilityGuard::set(None);
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let _home = SealedHome::new();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.current_session_id = Some("old-session".to_string());
|
||||
app.capture_session_fixture_source();
|
||||
app.install_conversation_mode(Some("creator".to_string()), None);
|
||||
app.push_api_message(mode_test_message("a creator conversation"));
|
||||
|
||||
let result = new_session(&mut app, None);
|
||||
assert!(!result.is_error, "{:?}", result.message);
|
||||
match result.action.as_ref() {
|
||||
Some(AppAction::NewSession { sync, .. }) => assert_eq!(sync.agent_preset, None),
|
||||
other => panic!("expected NewSession, got {other:?}"),
|
||||
}
|
||||
acknowledge_new_session(&mut app, &result).await;
|
||||
assert_eq!(
|
||||
app.conversation_mode, None,
|
||||
"never silently from the last one"
|
||||
);
|
||||
|
||||
let explicit = new_session(&mut app, Some("standard --force"));
|
||||
match explicit.action.as_ref() {
|
||||
Some(AppAction::NewSession { sync, force: true }) => assert_eq!(sync.agent_preset, None),
|
||||
other => panic!("expected a forced NewSession, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
fn new_with_an_unknown_mode_is_refused_with_the_mode_list_and_changes_nothing() {
|
||||
let _available = codewhale_engine::conversation_modes::CreatorAvailabilityGuard::set(None);
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let _home = SealedHome::new();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.current_session_id = Some("old-session".to_string());
|
||||
app.capture_session_fixture_source();
|
||||
app.install_conversation_mode(Some("creator".to_string()), None);
|
||||
|
||||
let result = new_session(&mut app, Some("minimal"));
|
||||
assert!(result.is_error);
|
||||
let text = result.message.as_deref().unwrap_or_default();
|
||||
assert!(
|
||||
text.contains("minimal") && text.contains("standard, creator"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(result.action.is_none());
|
||||
assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
assert!(app.pending_fresh_session.is_none());
|
||||
|
||||
drop(_available);
|
||||
let _unavailable = codewhale_engine::conversation_modes::CreatorAvailabilityGuard::set(Some(
|
||||
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON,
|
||||
));
|
||||
let result = new_session(&mut app, Some("creator"));
|
||||
assert!(result.is_error);
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Not ready yet in this version."),
|
||||
"{:?}",
|
||||
result.message
|
||||
);
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
+4
-74
@@ -129,10 +129,6 @@ struct Cli {
|
||||
/// Recover the exact durable Resume/Fork intent without repeating its mutation.
|
||||
#[arg(long, global = true, value_name = "KEY")]
|
||||
operation_key: Option<String>,
|
||||
/// Conversation mode for a new interactive session (for example `creator`).
|
||||
/// With `--resume` or `--continue` it must match the session's saved mode.
|
||||
#[arg(long = "conversation-mode", value_name = "MODE")]
|
||||
conversation_mode: Option<String>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Cli {
|
||||
@@ -477,10 +473,6 @@ struct ExecArgs {
|
||||
/// prompts past the OS per-argument limit (~128 KiB on Linux).
|
||||
#[arg(long, value_name = "PATH", conflicts_with = "prompt")]
|
||||
prompt_file: Option<PathBuf>,
|
||||
/// Conversation mode for a new session (for example `creator`). With a
|
||||
/// resumed session it must match the session's saved mode.
|
||||
#[arg(long = "conversation-mode", value_name = "MODE")]
|
||||
conversation_mode: Option<String>,
|
||||
#[arg(
|
||||
value_name = "PROMPT",
|
||||
trailing_var_arg = true,
|
||||
@@ -2523,7 +2515,10 @@ fn arm_telemetry_with_setup(
|
||||
};
|
||||
codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
|
||||
let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
|
||||
codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
|
||||
codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart {
|
||||
source,
|
||||
install_age: codewhale_telemetry::install_age(),
|
||||
});
|
||||
}
|
||||
|
||||
fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
|
||||
@@ -3018,19 +3013,6 @@ async fn run_async_main_dispatch(
|
||||
.as_deref()
|
||||
.map(load_exec_resume_session)
|
||||
.transpose()?;
|
||||
let exec_conversation_mode = match tui::conversation_mode::launch_conversation_mode(
|
||||
&config,
|
||||
codewhale_localization::resolve_locale("auto"),
|
||||
args.conversation_mode.as_deref(),
|
||||
resume_session.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mode) => mode,
|
||||
Err(message) => {
|
||||
tui::conversation_mode::exit_refused("codewhale exec", &message)
|
||||
}
|
||||
};
|
||||
let explicit_model = args
|
||||
.model
|
||||
.as_deref()
|
||||
@@ -3162,7 +3144,6 @@ async fn run_async_main_dispatch(
|
||||
args.hooks,
|
||||
std::sync::Arc::clone(&plugin_registry),
|
||||
!tool_surface_requested,
|
||||
exec_conversation_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -3326,7 +3307,6 @@ async fn run_async_main_dispatch(
|
||||
let plugin_registry = policy_current_registry(plugin_registry);
|
||||
let prepared = prepare_interactive_config(&cli, &config, true)?;
|
||||
let source_id = resolve_session_id(session_id, last, &prepared.workspace)?;
|
||||
refuse_resume_mode_mismatch(&cli, &prepared.config, &source_id).await;
|
||||
let (prepared, new_session_id) = prepare_mounted_session(
|
||||
&cli,
|
||||
prepared,
|
||||
@@ -14526,7 +14506,6 @@ async fn run_interactive_with_notice(
|
||||
let plugin_registry = policy_current_registry(plugin_registry);
|
||||
let prepared = prepare_interactive_config(cli, config, resume_session_id.is_some())?;
|
||||
let (prepared, resume_session_id) = if let Some(selector) = resume_session_id {
|
||||
refuse_resume_mode_mismatch(cli, &prepared.config, &selector).await;
|
||||
let (prepared, id) = prepare_mounted_session(
|
||||
cli,
|
||||
prepared,
|
||||
@@ -14551,28 +14530,6 @@ async fn run_interactive_with_notice(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn refuse_resume_mode_mismatch(cli: &Cli, config: &Config, selector: &str) {
|
||||
if cli.conversation_mode.is_none() {
|
||||
return;
|
||||
}
|
||||
let locale = codewhale_localization::resolve_locale("auto");
|
||||
let outcome = match tui::conversation_mode::load_saved_for_resume(locale, selector) {
|
||||
Ok(saved) => {
|
||||
tui::conversation_mode::launch_conversation_mode(
|
||||
config,
|
||||
locale,
|
||||
cli.conversation_mode.as_deref(),
|
||||
Some(&saved),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(message) => Err(message),
|
||||
};
|
||||
if let Err(message) = outcome {
|
||||
tui::conversation_mode::exit_refused("codewhale", &message);
|
||||
}
|
||||
}
|
||||
|
||||
struct PreparedInteractiveConfig {
|
||||
config: Config,
|
||||
workspace: PathBuf,
|
||||
@@ -14631,32 +14588,6 @@ async fn run_interactive_prepared(
|
||||
) -> Result<()> {
|
||||
let PreparedInteractiveConfig { config, workspace } = prepared;
|
||||
let config = &config;
|
||||
let launch_conversation_mode = {
|
||||
let locale = codewhale_localization::resolve_locale("auto");
|
||||
let resumed = match (
|
||||
cli.conversation_mode.as_deref(),
|
||||
resume_session_id.as_deref(),
|
||||
) {
|
||||
(Some(_), Some(id)) => {
|
||||
match tui::conversation_mode::load_saved_for_resume(locale, id) {
|
||||
Ok(saved) => Some(saved),
|
||||
Err(message) => tui::conversation_mode::exit_refused("codewhale", &message),
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match tui::conversation_mode::launch_conversation_mode(
|
||||
config,
|
||||
locale,
|
||||
cli.conversation_mode.as_deref(),
|
||||
resumed.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mode) => mode,
|
||||
Err(message) => tui::conversation_mode::exit_refused("codewhale", &message),
|
||||
}
|
||||
};
|
||||
let initial_input = if cli.remote_control {
|
||||
Some(tui::InitialInput::RemoteControl)
|
||||
} else {
|
||||
@@ -14804,7 +14735,6 @@ async fn run_interactive_prepared(
|
||||
},
|
||||
plugin_registry,
|
||||
pending_telemetry_notice,
|
||||
launch_conversation_mode,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -304,106 +304,6 @@ fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSess
|
||||
saved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_conversation_mode_flag_sets_a_new_session_mode() {
|
||||
use codewhale_engine::conversation_modes::CreatorAvailabilityGuard;
|
||||
let cli = parse_cli(&["codewhale", "exec", "--conversation-mode", "creator", "hi"]);
|
||||
let Some(Commands::Exec(args)) = cli.command else {
|
||||
panic!("exec command");
|
||||
};
|
||||
assert_eq!(args.conversation_mode.as_deref(), Some("creator"));
|
||||
let none = parse_cli(&["codewhale", "exec", "hi"]);
|
||||
let Some(Commands::Exec(args_none)) = none.command else {
|
||||
panic!("exec command");
|
||||
};
|
||||
assert_eq!(args_none.conversation_mode, None);
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap();
|
||||
let config = codewhale_runtime::config::Config::default();
|
||||
let locale = codewhale_localization::Locale::En;
|
||||
let resolve = |flag: Option<&str>| {
|
||||
runtime.block_on(tui::conversation_mode::launch_conversation_mode(
|
||||
&config, locale, flag, None,
|
||||
))
|
||||
};
|
||||
{
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
assert_eq!(resolve(Some("creator")), Ok(Some("creator".to_string())));
|
||||
assert_eq!(resolve(Some("standard")), Ok(None));
|
||||
assert_eq!(resolve(None), Ok(None));
|
||||
let unknown = resolve(Some("minimal")).expect_err("unknown id");
|
||||
assert!(unknown.contains("standard, creator"), "{unknown}");
|
||||
}
|
||||
{
|
||||
let _unavailable = CreatorAvailabilityGuard::set(Some(
|
||||
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON,
|
||||
));
|
||||
let refused = resolve(Some("creator")).expect_err("unavailable");
|
||||
assert!(refused.contains("Not ready yet in this version."), "{refused}");
|
||||
}
|
||||
|
||||
let mut saved = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
saved.agent_preset = resolve_stored_for_test(&runtime, &config, "creator");
|
||||
let raw = serde_json::to_value(&saved).unwrap();
|
||||
assert_eq!(raw["agent_preset"], "creator");
|
||||
let standard = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
assert!(serde_json::to_value(&standard).unwrap().get("agent_preset").is_none());
|
||||
}
|
||||
|
||||
fn resolve_stored_for_test(
|
||||
runtime: &tokio::runtime::Runtime,
|
||||
config: &codewhale_runtime::config::Config,
|
||||
flag: &str,
|
||||
) -> Option<String> {
|
||||
let _available = codewhale_engine::conversation_modes::CreatorAvailabilityGuard::set(None);
|
||||
runtime
|
||||
.block_on(tui::conversation_mode::launch_conversation_mode(
|
||||
config,
|
||||
codewhale_localization::Locale::En,
|
||||
Some(flag),
|
||||
None,
|
||||
))
|
||||
.expect("resolves")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_conversation_mode_flag_that_differs_from_a_resumed_session_is_refused_before_any_model_call() {
|
||||
use codewhale_engine::conversation_modes::CreatorAvailabilityGuard;
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap();
|
||||
let config = codewhale_runtime::config::Config::default();
|
||||
let locale = codewhale_localization::Locale::En;
|
||||
let resolve = |flag: &str, saved: &session_manager::SavedSession| {
|
||||
runtime.block_on(tui::conversation_mode::launch_conversation_mode(
|
||||
&config,
|
||||
locale,
|
||||
Some(flag),
|
||||
Some(saved),
|
||||
))
|
||||
};
|
||||
let _unavailable = CreatorAvailabilityGuard::set(Some(
|
||||
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON,
|
||||
));
|
||||
let standard = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
let mut creator = saved_exec_session("deepseek", "deepseek-v4-pro");
|
||||
creator.agent_preset = Some("creator".to_string());
|
||||
|
||||
let refused = resolve("creator", &standard).expect_err("Standard session, creator flag");
|
||||
assert!(refused.contains("fixed") && refused.contains("/new creator"), "{refused}");
|
||||
let refused = resolve("standard", &creator).expect_err("creator session, standard flag");
|
||||
assert!(refused.contains("fixed") && refused.contains("/new"), "{refused}");
|
||||
assert!(resolve("minimal", &creator).is_err());
|
||||
assert_eq!(resolve("standard", &standard), Ok(None));
|
||||
assert_eq!(
|
||||
resolve("creator", &creator),
|
||||
Ok(Some("creator".to_string())),
|
||||
"a matching flag resumes even while the mode is unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
|
||||
let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
|
||||
|
||||
@@ -2277,8 +2277,6 @@ pub struct App {
|
||||
pub undo_selection: bool,
|
||||
/// Current session ID for auto-save updates
|
||||
pub current_session_id: Option<String>,
|
||||
pub(crate) conversation_mode: Option<String>,
|
||||
pub(crate) conversation_mode_unavailable: Option<String>,
|
||||
pub(crate) session_write_receipt:
|
||||
Option<codewhale_engine::session_manager::SessionWriteReceipt>,
|
||||
pub(crate) session_write_owner: Option<codewhale_engine::session_manager::SessionWriteOwner>,
|
||||
@@ -5592,37 +5590,6 @@ impl App {
|
||||
id
|
||||
}
|
||||
|
||||
pub(crate) fn agent_preset(&self) -> Option<String> {
|
||||
self.conversation_mode.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn conversation_mode_locked(&self) -> bool {
|
||||
!self.api_messages.is_empty()
|
||||
|| self.is_loading
|
||||
|| !self.queued_messages.is_empty()
|
||||
|| self.queued_draft.is_some()
|
||||
|| self.pending_fresh_session.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn install_conversation_mode(
|
||||
&mut self,
|
||||
stored: Option<String>,
|
||||
unavailable: Option<String>,
|
||||
) {
|
||||
self.conversation_mode = stored;
|
||||
self.conversation_mode_unavailable = unavailable;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub(crate) fn conversation_mode_label(&self) -> Option<String> {
|
||||
crate::tui::conversation_mode::label(
|
||||
self.ui_locale,
|
||||
self.conversation_mode.as_deref(),
|
||||
self.conversation_mode_unavailable.as_deref(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn set_current_session_id(&mut self, session_id: Option<String>) {
|
||||
if self.current_session_id != session_id {
|
||||
self.view_stack.remove_owned_evidence_pagers();
|
||||
|
||||
@@ -1062,8 +1062,6 @@ impl App {
|
||||
backtrack: crate::tui::backtrack::BacktrackState::new(),
|
||||
undo_selection: false,
|
||||
current_session_id: None,
|
||||
conversation_mode: None,
|
||||
conversation_mode_unavailable: None,
|
||||
session_write_receipt: None,
|
||||
session_write_owner: None,
|
||||
offline_queue_lease: None,
|
||||
|
||||
@@ -559,10 +559,6 @@ pub enum AppAction {
|
||||
model: String,
|
||||
workspace: PathBuf,
|
||||
mode: AppMode,
|
||||
agent_preset: Option<String>,
|
||||
},
|
||||
SetConversationMode {
|
||||
id: String,
|
||||
},
|
||||
OpenConfigView,
|
||||
/// Open this workspace's `.codewhale/hooks.toml` in `$EDITOR`, creating
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
use codewhale_engine::conversation_modes::{
|
||||
self, CREATOR, CREATOR_NOT_READY_REASON, ConversationMode, ConversationModeTable, STANDARD,
|
||||
};
|
||||
use codewhale_engine::extension_host::ExtensionHostOptions;
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
use codewhale_runtime::config::Config;
|
||||
|
||||
const LABEL_CHARS: usize = 40;
|
||||
|
||||
pub(crate) fn options(config: &Config) -> ExtensionHostOptions {
|
||||
ExtensionHostOptions::from_config(config.extension_host.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) async fn current_table(config: &Config) -> ConversationModeTable {
|
||||
conversation_modes::current_table(options(config)).await
|
||||
}
|
||||
|
||||
pub(crate) fn current_table_blocking(config: Option<&Config>) -> ConversationModeTable {
|
||||
let options = config.map_or_else(ExtensionHostOptions::default, options);
|
||||
let future = conversation_modes::current_table(options);
|
||||
match tokio::runtime::Handle::try_current() {
|
||||
Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
|
||||
tokio::task::block_in_place(|| handle.block_on(future))
|
||||
}
|
||||
_ => std::thread::scope(|scope| {
|
||||
scope
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("conversation mode runtime")
|
||||
.block_on(future)
|
||||
})
|
||||
.join()
|
||||
.expect("conversation mode probe")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn display_name(locale: Locale, mode: &ConversationMode) -> String {
|
||||
match mode.id.as_str() {
|
||||
STANDARD => tr(locale, MessageId::ConversationModeStandardName).into_owned(),
|
||||
CREATOR => tr(locale, MessageId::ConversationModeCreatorName).into_owned(),
|
||||
_ => mode.name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn display_description(locale: Locale, mode: &ConversationMode) -> String {
|
||||
match mode.id.as_str() {
|
||||
STANDARD => tr(locale, MessageId::ConversationModeStandardDescription).into_owned(),
|
||||
CREATOR => tr(locale, MessageId::ConversationModeCreatorDescription).into_owned(),
|
||||
_ => mode.description.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn display_reason(locale: Locale, reason: &str) -> String {
|
||||
if reason == CREATOR_NOT_READY_REASON {
|
||||
tr(locale, MessageId::ConversationModeNotReady).into_owned()
|
||||
} else {
|
||||
reason.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn clean(text: &str) -> String {
|
||||
let mut out: String = text
|
||||
.chars()
|
||||
.filter(|c| {
|
||||
!c.is_control()
|
||||
&& !matches!(*c, '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}')
|
||||
})
|
||||
.take(LABEL_CHARS + 1)
|
||||
.collect();
|
||||
if out.chars().count() > LABEL_CHARS {
|
||||
out = out.chars().take(LABEL_CHARS).collect::<String>() + "…";
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn name_for_stored(
|
||||
locale: Locale,
|
||||
stored: Option<&str>,
|
||||
table: Option<&ConversationModeTable>,
|
||||
) -> String {
|
||||
let id = conversation_modes::effective_id(stored);
|
||||
match table.and_then(|table| table.modes.iter().find(|mode| mode.id == id)) {
|
||||
Some(mode) => display_name(locale, mode),
|
||||
None if id == STANDARD => tr(locale, MessageId::ConversationModeStandardName).into_owned(),
|
||||
None if id == CREATOR => tr(locale, MessageId::ConversationModeCreatorName).into_owned(),
|
||||
None => clean(id),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unavailable_reason(
|
||||
stored: Option<&str>,
|
||||
table: &ConversationModeTable,
|
||||
locale: Locale,
|
||||
) -> Option<String> {
|
||||
let id = conversation_modes::effective_id(stored);
|
||||
match table.modes.iter().find(|mode| mode.id == id) {
|
||||
Some(mode) => mode
|
||||
.reason
|
||||
.as_deref()
|
||||
.filter(|_| !mode.available)
|
||||
.map(|reason| display_reason(locale, reason))
|
||||
.or_else(|| (!mode.available).then(|| "unavailable".to_string())),
|
||||
None => Some(
|
||||
tr(locale, MessageId::ConversationModeUnknown)
|
||||
.replace("{id}", &clean(id))
|
||||
.replace(
|
||||
"{modes}",
|
||||
&table
|
||||
.modes
|
||||
.iter()
|
||||
.map(|mode| mode.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn label(
|
||||
locale: Locale,
|
||||
stored: Option<&str>,
|
||||
unavailable: Option<&str>,
|
||||
table: Option<&ConversationModeTable>,
|
||||
) -> Option<String> {
|
||||
stored?;
|
||||
let name = name_for_stored(locale, stored, table);
|
||||
Some(if unavailable.is_some() {
|
||||
tr(locale, MessageId::ConversationModeLabelUnavailable).replace("{name}", &name)
|
||||
} else {
|
||||
name
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn new_command(id: &str) -> String {
|
||||
if id == STANDARD {
|
||||
"/new".to_string()
|
||||
} else {
|
||||
format!("/new {id}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn locked_refusal(locale: Locale, requested_id: &str) -> String {
|
||||
tr(locale, MessageId::ConversationModeLockedRefusal)
|
||||
.replace("{command}", &format!("`{}`", new_command(requested_id)))
|
||||
}
|
||||
|
||||
pub(crate) enum Resolution {
|
||||
Unknown(String),
|
||||
Unavailable {
|
||||
name: String,
|
||||
reason: String,
|
||||
},
|
||||
Ready {
|
||||
stored: Option<String>,
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(locale: Locale, table: &ConversationModeTable, id: &str) -> Resolution {
|
||||
let id = id.trim().to_ascii_lowercase();
|
||||
let id = id.as_str();
|
||||
match table.modes.iter().find(|mode| mode.id == id) {
|
||||
None => Resolution::Unknown(
|
||||
tr(locale, MessageId::ConversationModeUnknown)
|
||||
.replace("{id}", &clean(id))
|
||||
.replace(
|
||||
"{modes}",
|
||||
&table
|
||||
.modes
|
||||
.iter()
|
||||
.map(|mode| mode.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
),
|
||||
),
|
||||
Some(mode) if !mode.available => Resolution::Unavailable {
|
||||
name: display_name(locale, mode),
|
||||
reason: display_reason(
|
||||
locale,
|
||||
mode.reason
|
||||
.as_deref()
|
||||
.unwrap_or("This mode is unavailable."),
|
||||
),
|
||||
},
|
||||
Some(mode) => Resolution::Ready {
|
||||
stored: conversation_modes::stored_form(&mode.id),
|
||||
name: display_name(locale, mode),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unavailable_message(locale: Locale, name: &str, reason: &str) -> String {
|
||||
tr(locale, MessageId::ConversationModeUnavailableRefusal)
|
||||
.replace("{name}", name)
|
||||
.replace("{reason}", reason)
|
||||
}
|
||||
|
||||
pub(crate) fn saved_unavailable_notice(locale: Locale, name: &str, reason: &str) -> String {
|
||||
tr(locale, MessageId::ConversationModeSavedUnavailableNotice)
|
||||
.replace("{name}", name)
|
||||
.replace("{reason}", reason)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_in_place(
|
||||
app: &mut crate::tui::app::App,
|
||||
config: &Config,
|
||||
engine: &codewhale_engine::core::engine::EngineHandle,
|
||||
id: &str,
|
||||
) -> Result<String, String> {
|
||||
let locale = app.ui_locale;
|
||||
let table = current_table(config).await;
|
||||
match resolve(locale, &table, id) {
|
||||
Resolution::Unknown(message) => Err(message),
|
||||
Resolution::Unavailable { name, reason } => {
|
||||
Err(unavailable_message(locale, &name, &reason))
|
||||
}
|
||||
Resolution::Ready { stored, name } => {
|
||||
let done = tr(locale, MessageId::ConversationModeSet).replace("{name}", &name);
|
||||
if stored == app.conversation_mode {
|
||||
return Ok(done);
|
||||
}
|
||||
if app.conversation_mode_locked() {
|
||||
return Err(locked_refusal(locale, id.trim()));
|
||||
}
|
||||
app.install_conversation_mode(stored, None);
|
||||
let _ = engine
|
||||
.send(codewhale_engine::core::ops::Op::SyncSession {
|
||||
expected: None,
|
||||
control: app.session_sync_control(),
|
||||
session_id: app.current_session_id.clone(),
|
||||
messages: app.api_messages.as_ref().clone(),
|
||||
system_prompt: app.system_prompt.clone(),
|
||||
system_prompt_override: false,
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
Ok(done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn launch_conversation_mode(
|
||||
config: &Config,
|
||||
locale: Locale,
|
||||
flag: Option<&str>,
|
||||
resumed: Option<&codewhale_engine::session_manager::SavedSession>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(id) = flag.map(str::trim) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(saved) = resumed {
|
||||
let requested = conversation_modes::stored_form(&id.to_ascii_lowercase());
|
||||
return if requested == saved.agent_preset {
|
||||
Ok(requested)
|
||||
} else {
|
||||
Err(locked_refusal(locale, id))
|
||||
};
|
||||
}
|
||||
let table = current_table(config).await;
|
||||
match resolve(locale, &table, id) {
|
||||
Resolution::Unknown(message) => Err(message),
|
||||
Resolution::Unavailable { name, reason } => {
|
||||
Err(unavailable_message(locale, &name, &reason))
|
||||
}
|
||||
Resolution::Ready { stored, .. } => Ok(stored),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_saved_for_resume(
|
||||
locale: Locale,
|
||||
selector: &str,
|
||||
) -> Result<codewhale_engine::session_manager::SavedSession, String> {
|
||||
codewhale_engine::session_manager::SessionManager::default_location()
|
||||
.ok()
|
||||
.and_then(|manager| manager.load_session_by_prefix(selector).ok())
|
||||
.ok_or_else(|| tr(locale, MessageId::ConversationModeResumeUnreadable).into_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn exit_refused(program: &str, message: &str) -> ! {
|
||||
eprintln!("{program}: {message}");
|
||||
std::process::exit(2)
|
||||
}
|
||||
@@ -34,7 +34,6 @@ pub mod composer_ui;
|
||||
pub mod context_inspector;
|
||||
pub mod context_menu;
|
||||
pub(crate) mod control_socket;
|
||||
pub(crate) mod conversation_mode;
|
||||
pub(crate) mod coordination_detail;
|
||||
pub mod display_refresh;
|
||||
pub mod external_editor;
|
||||
|
||||
@@ -582,7 +582,6 @@ async fn spawn_tui_engine_with_session(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -1190,7 +1190,6 @@ pub(crate) async fn apply_provider_fallback_switch(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -1726,7 +1725,6 @@ async fn apply_command_result_inner(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
let _ = engine_handle
|
||||
@@ -1766,7 +1764,6 @@ async fn apply_command_result_inner(
|
||||
model,
|
||||
workspace,
|
||||
mode,
|
||||
agent_preset,
|
||||
} => {
|
||||
let mut session_id = session_id;
|
||||
let is_full_reset = messages.is_empty() && system_prompt.is_none();
|
||||
@@ -1847,7 +1844,6 @@ async fn apply_command_result_inner(
|
||||
model,
|
||||
workspace,
|
||||
mode,
|
||||
agent_preset,
|
||||
})
|
||||
.await;
|
||||
let _ = engine_handle
|
||||
@@ -1967,7 +1963,6 @@ async fn apply_command_result_inner(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -2815,24 +2810,13 @@ async fn apply_command_result_inner(
|
||||
}
|
||||
AppAction::OpenModePicker => {
|
||||
if app.view_stack.top_kind() != Some(ModalKind::ModePicker) {
|
||||
let view =
|
||||
crate::tui::views::mode_picker::ModePickerView::for_app(app, config).await;
|
||||
app.view_stack.push(view);
|
||||
app.view_stack
|
||||
.push(crate::tui::views::mode_picker::ModePickerView::new(
|
||||
app.mode,
|
||||
app.ui_locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
AppAction::SetConversationMode { id } => {
|
||||
let content = match crate::tui::conversation_mode::set_in_place(
|
||||
app,
|
||||
config,
|
||||
engine_handle,
|
||||
&id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(message) | Err(message) => message,
|
||||
};
|
||||
app.add_message(HistoryCell::System { content });
|
||||
}
|
||||
AppAction::OpenStatusPicker => {
|
||||
if app.view_stack.top_kind() != Some(ModalKind::StatusPicker) {
|
||||
app.view_stack
|
||||
@@ -3205,7 +3189,6 @@ async fn apply_command_result_inner(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -4820,22 +4803,6 @@ pub(crate) fn apply_loaded_session_with_goal(
|
||||
if let Some(mode) = session.metadata.mode.as_deref().and_then(AppMode::parse) {
|
||||
app.set_mode(mode);
|
||||
}
|
||||
let saved_mode_unavailable = match session.agent_preset.clone() {
|
||||
Some(stored) => {
|
||||
let table = crate::tui::conversation_mode::current_table_blocking(Some(config));
|
||||
let reason = crate::tui::conversation_mode::unavailable_reason(
|
||||
Some(&stored),
|
||||
&table,
|
||||
app.ui_locale,
|
||||
);
|
||||
app.install_conversation_mode(Some(stored), reason.clone());
|
||||
reason
|
||||
}
|
||||
None => {
|
||||
app.install_conversation_mode(None, None);
|
||||
None
|
||||
}
|
||||
};
|
||||
app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
|
||||
app.session.total_conversation_tokens = app.session.total_tokens;
|
||||
app.session.displayed_cost_high_water = 0.0;
|
||||
@@ -4898,20 +4865,6 @@ pub(crate) fn apply_loaded_session_with_goal(
|
||||
app.workspace_context_refreshed_at = None;
|
||||
app.system_prompt = session.system_prompt.map(SystemPrompt::Text);
|
||||
app.retire_event_notices(super::event_loop::STARTUP_NOTICE_TOAST);
|
||||
if let Some(reason) = saved_mode_unavailable {
|
||||
let name = crate::tui::conversation_mode::name_for_stored(
|
||||
app.ui_locale,
|
||||
app.conversation_mode.as_deref(),
|
||||
None,
|
||||
);
|
||||
app.add_message(HistoryCell::System {
|
||||
content: crate::tui::conversation_mode::saved_unavailable_notice(
|
||||
app.ui_locale,
|
||||
&name,
|
||||
&reason,
|
||||
),
|
||||
});
|
||||
}
|
||||
app.scroll_to_bottom();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -930,7 +930,6 @@ pub async fn run_tui(
|
||||
options: TuiOptions,
|
||||
plugin_registry: std::sync::Arc<codewhale_plugins::PluginRegistry>,
|
||||
pending_telemetry_notice: Option<codewhale_engine::telemetry_notice::PendingTelemetryNotice>,
|
||||
launch_conversation_mode: Option<String>,
|
||||
) -> Result<()> {
|
||||
// Install notification, sound, category, and attention policy before any
|
||||
// producer (including the model-facing notify tool) can emit an event.
|
||||
@@ -1106,7 +1105,6 @@ pub async fn run_tui(
|
||||
let mut config = config.clone();
|
||||
let config = &mut config;
|
||||
let mut app = App::new_with_plugin_registry(options.clone(), config, plugin_registry);
|
||||
app.install_conversation_mode(launch_conversation_mode, None);
|
||||
let _cursor_accent_guard = codewhale_tui_kit::cursor_accent::CursorAccentGuard::install(
|
||||
app.low_motion || !app.fancy_animations,
|
||||
app.ui_theme.accent_primary,
|
||||
@@ -5146,7 +5144,6 @@ pub(crate) async fn run_event_loop(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -8904,7 +8901,6 @@ mod launch_session_sync_tests {
|
||||
model,
|
||||
workspace: workspace.path().to_path_buf(),
|
||||
mode: AppMode::Agent,
|
||||
agent_preset: None,
|
||||
})
|
||||
.await
|
||||
.expect("sync session");
|
||||
|
||||
@@ -1361,7 +1361,6 @@ pub(crate) fn build_session_snapshot(
|
||||
app.system_prompt.as_ref(),
|
||||
Some(app.mode.as_setting()),
|
||||
);
|
||||
session.agent_preset = app.agent_preset();
|
||||
session.write_receipt = if app.current_session_id.is_none() {
|
||||
Some(
|
||||
manager
|
||||
|
||||
@@ -875,49 +875,3 @@ fn statusline_full_frame_context_reading_updates_below_and_at_warning() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_mode_label_shows_creator_and_nothing_for_standard() {
|
||||
for (width, height) in [(80u16, 24u16), (120, 32)] {
|
||||
let mut standard = frame_app();
|
||||
let standard_rows = draw(&mut standard, width, height);
|
||||
assert!(
|
||||
standard_rows
|
||||
.iter()
|
||||
.all(|row| !row.contains("Creator mode") && !row.contains("Standard mode")),
|
||||
"{width}x{height}: Standard shows nothing new:\n{}",
|
||||
standard_rows.join("\n")
|
||||
);
|
||||
|
||||
let mut creator = frame_app();
|
||||
creator.install_conversation_mode(Some("creator".to_string()), None);
|
||||
let rows = draw(&mut creator, width, height);
|
||||
println!(
|
||||
"--- footer, Creator mode, {width}x{height}\n{}",
|
||||
rows.iter()
|
||||
.map(|row| row.trim_end())
|
||||
.filter(|row| !row.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
assert_eq!(
|
||||
count_rows_containing(&rows, "Creator mode"),
|
||||
1,
|
||||
"{width}x{height}: the mode name paints once, as text:\n{}",
|
||||
rows.join("\n")
|
||||
);
|
||||
|
||||
let mut unavailable = frame_app();
|
||||
unavailable.install_conversation_mode(
|
||||
Some("creator".to_string()),
|
||||
Some("Not ready yet in this version.".to_string()),
|
||||
);
|
||||
let rows = draw(&mut unavailable, width, height);
|
||||
assert_eq!(
|
||||
count_rows_containing(&rows, "Creator mode (unavailable)"),
|
||||
1,
|
||||
"{width}x{height}:\n{}",
|
||||
rows.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2287,7 +2287,6 @@ pub(crate) async fn handle_view_events(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
let _ = engine_handle
|
||||
@@ -3022,9 +3021,11 @@ pub(crate) async fn handle_view_events(
|
||||
}
|
||||
ViewEvent::SetupOpenModeRequested => {
|
||||
if app.view_stack.top_kind() != Some(ModalKind::ModePicker) {
|
||||
let view =
|
||||
crate::tui::views::mode_picker::ModePickerView::for_app(app, config).await;
|
||||
app.view_stack.push(view);
|
||||
app.view_stack
|
||||
.push(crate::tui::views::mode_picker::ModePickerView::new(
|
||||
app.mode,
|
||||
app.ui_locale,
|
||||
));
|
||||
app.status_message =
|
||||
Some("Work mode picker opened from /setup runtime posture.".to_string());
|
||||
}
|
||||
@@ -3458,19 +3459,6 @@ pub(crate) async fn handle_view_events(
|
||||
}
|
||||
app.add_message(HistoryCell::System { content: msg });
|
||||
}
|
||||
ViewEvent::ConversationModeSelected { id } => {
|
||||
let content = match crate::tui::conversation_mode::set_in_place(
|
||||
app,
|
||||
config,
|
||||
engine_handle,
|
||||
&id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(message) | Err(message) => message,
|
||||
};
|
||||
app.add_message(HistoryCell::System { content });
|
||||
}
|
||||
ViewEvent::BacktrackStep { direction } => {
|
||||
app.backtrack.step(direction);
|
||||
if let Some(idx) = app.backtrack.selected_idx() {
|
||||
|
||||
@@ -936,7 +936,6 @@ async fn switch_provider_inner(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -1116,7 +1116,6 @@ pub(crate) fn begin_launch_session(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1166,7 +1165,6 @@ pub(crate) async fn switch_workspace(
|
||||
model: app.model.clone(),
|
||||
workspace: workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -1875,7 +1873,6 @@ pub(crate) struct PendingFreshSession {
|
||||
model: String,
|
||||
workspace: PathBuf,
|
||||
mode: AppMode,
|
||||
agent_preset: Option<String>,
|
||||
queue_transition: Option<OfflineQueueTransition>,
|
||||
write_receipt: codewhale_engine::session_manager::SessionWriteReceipt,
|
||||
input: String,
|
||||
@@ -1910,7 +1907,6 @@ pub(crate) fn begin_fresh_session(
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let agent_preset = sync.agent_preset.clone();
|
||||
let id = sync
|
||||
.session_id
|
||||
.filter(|id| !id.is_empty())
|
||||
@@ -1944,7 +1940,6 @@ pub(crate) fn begin_fresh_session(
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset,
|
||||
queue_transition,
|
||||
write_receipt,
|
||||
input: app.input.clone(),
|
||||
@@ -2030,7 +2025,6 @@ pub(crate) fn poll_fresh_session(app: &mut App, engine: &EngineHandle) -> bool {
|
||||
model: pending.model.clone(),
|
||||
workspace: pending.workspace.clone(),
|
||||
mode: pending.mode,
|
||||
agent_preset: pending.agent_preset.clone(),
|
||||
},
|
||||
);
|
||||
engine.send_reserved_op(
|
||||
@@ -2194,7 +2188,6 @@ fn commit_fresh_session(app: &mut App, mut pending: PendingFreshSession) -> bool
|
||||
app.session_context_references.clear();
|
||||
app.tool_evidence.clear();
|
||||
app.set_current_session_id(Some(pending.id.clone()));
|
||||
app.install_conversation_mode(pending.agent_preset.clone(), None);
|
||||
app.session_write_receipt = Some(pending.write_receipt);
|
||||
app.session_write_owner =
|
||||
Some(codewhale_engine::session_manager::SessionWriteOwner::Control(pending.control));
|
||||
|
||||
@@ -58,7 +58,6 @@ use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};
|
||||
use codewhale_models::Role;
|
||||
use tempfile::TempDir;
|
||||
|
||||
mod conversation_mode;
|
||||
mod conversation_undo;
|
||||
mod model_picker_actions;
|
||||
mod plan_handoff;
|
||||
@@ -34185,7 +34184,6 @@ fn stale_session_projection_cannot_rewind_a_host_launch() {
|
||||
system_prompt: None,
|
||||
instruction_assembly: None,
|
||||
mode: "agent".into(),
|
||||
agent_preset: None,
|
||||
});
|
||||
let (reply, mut answer) = tokio::sync::oneshot::channel();
|
||||
let request = codewhale_engine::core::events::SessionCheckpointRequest::new(
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
use super::*;
|
||||
use codewhale_engine::conversation_modes::{CREATOR_NOT_READY_REASON, CreatorAvailabilityGuard};
|
||||
use codewhale_engine::core::engine::Engine;
|
||||
use codewhale_engine::core::ops::Op;
|
||||
|
||||
fn creator_app() -> App {
|
||||
let mut app = create_test_app();
|
||||
app.install_conversation_mode(Some("creator".to_string()), None);
|
||||
app
|
||||
}
|
||||
|
||||
fn first_message(app: &mut App) {
|
||||
app.push_api_message(text_message("user", "first message"));
|
||||
}
|
||||
|
||||
fn sent_presets(rx: &mut tokio::sync::mpsc::Receiver<Op>) -> Vec<Option<String>> {
|
||||
let mut presets = Vec::new();
|
||||
while let Ok(op) = rx.try_recv() {
|
||||
if let Op::SyncSession { agent_preset, .. } = op {
|
||||
presets.push(agent_preset);
|
||||
}
|
||||
}
|
||||
presets
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn mode_change_after_the_first_message_is_refused_as_locked() {
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let mut app = create_test_app();
|
||||
let mut engine = mock_engine_handle();
|
||||
let config = Config::default();
|
||||
|
||||
let done =
|
||||
crate::tui::conversation_mode::set_in_place(&mut app, &config, &engine.handle, "creator")
|
||||
.await
|
||||
.expect("a blank conversation changes in place");
|
||||
assert!(done.contains("Creator mode"), "{done}");
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
assert_eq!(
|
||||
sent_presets(&mut engine.rx_op),
|
||||
vec![Some("creator".to_string())]
|
||||
);
|
||||
|
||||
first_message(&mut app);
|
||||
for requested in ["standard", "creator"] {
|
||||
let result = crate::tui::conversation_mode::set_in_place(
|
||||
&mut app,
|
||||
&config,
|
||||
&engine.handle,
|
||||
requested,
|
||||
)
|
||||
.await;
|
||||
if requested == "creator" {
|
||||
assert!(result.is_ok(), "naming the current mode is not a change");
|
||||
} else {
|
||||
let refusal = result.expect_err("locked after the first message");
|
||||
assert!(refusal.contains("fixed"), "{refusal}");
|
||||
assert!(refusal.contains("/new"), "{refusal}");
|
||||
}
|
||||
}
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
assert!(
|
||||
sent_presets(&mut engine.rx_op).is_empty(),
|
||||
"a refusal sends nothing"
|
||||
);
|
||||
|
||||
let result = commands::execute("/mode standard", &mut app);
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::SetConversationMode { ref id }) if id == "standard"
|
||||
));
|
||||
let mut blank = create_test_app();
|
||||
blank
|
||||
.queued_messages
|
||||
.push_back(crate::tui::app::QueuedMessage::new(
|
||||
"queued".to_string(),
|
||||
None,
|
||||
));
|
||||
assert!(
|
||||
blank.conversation_mode_locked(),
|
||||
"a queued message locks the mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn unknown_and_unavailable_modes_are_refused_with_their_words() {
|
||||
let mut app = create_test_app();
|
||||
let engine = mock_engine_handle();
|
||||
let config = Config::default();
|
||||
{
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let refusal = crate::tui::conversation_mode::set_in_place(
|
||||
&mut app,
|
||||
&config,
|
||||
&engine.handle,
|
||||
"minimal",
|
||||
)
|
||||
.await
|
||||
.expect_err("unknown id");
|
||||
assert!(
|
||||
refusal.contains("minimal") && refusal.contains("standard, creator"),
|
||||
"{refusal}"
|
||||
);
|
||||
}
|
||||
let _missing = CreatorAvailabilityGuard::set(Some(CREATOR_NOT_READY_REASON));
|
||||
let refusal =
|
||||
crate::tui::conversation_mode::set_in_place(&mut app, &config, &engine.handle, "creator")
|
||||
.await
|
||||
.expect_err("unavailable");
|
||||
assert!(
|
||||
refusal.contains("Not ready yet in this version."),
|
||||
"{refusal}"
|
||||
);
|
||||
assert_eq!(app.conversation_mode, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn a_saved_unavailable_mode_loads_its_history_and_says_why() {
|
||||
let _guard = CreatorAvailabilityGuard::set(Some(CREATOR_NOT_READY_REASON));
|
||||
let hostile = [
|
||||
"creator".to_string(),
|
||||
"nonsense".to_string(),
|
||||
"../x".to_string(),
|
||||
String::new(),
|
||||
"x".repeat(1_000_000),
|
||||
"line\nbreak\u{1b}[31m".to_string(),
|
||||
];
|
||||
for stored in hostile {
|
||||
let mut app = create_test_app();
|
||||
let mut session = saved_session_with_messages(vec![text_message("user", "history line")]);
|
||||
session.agent_preset = Some(stored.clone());
|
||||
apply_loaded_session(&mut app, &mut Config::default(), &session)
|
||||
.expect("the history loads");
|
||||
assert_eq!(app.api_messages.len(), 1, "history is shown");
|
||||
assert_eq!(
|
||||
app.conversation_mode.as_deref(),
|
||||
Some(stored.as_str()),
|
||||
"never silently Standard"
|
||||
);
|
||||
assert!(app.conversation_mode_unavailable.is_some());
|
||||
let label = app.conversation_mode_label().expect("a label");
|
||||
assert!(label.contains("unavailable"), "{label}");
|
||||
assert!(label.chars().count() <= 80 && !label.contains('\n') && !label.contains('\u{1b}'));
|
||||
assert!(app.history.iter().any(|cell| matches!(
|
||||
cell,
|
||||
HistoryCell::System { content } if content.contains("which is not available here")
|
||||
)));
|
||||
}
|
||||
let mut app = create_test_app();
|
||||
let mut session = saved_session_with_messages(vec![text_message("user", "history line")]);
|
||||
session.agent_preset = Some("creator".to_string());
|
||||
apply_loaded_session(&mut app, &mut Config::default(), &session).expect("loads");
|
||||
assert!(app.history.iter().any(|cell| matches!(
|
||||
cell,
|
||||
HistoryCell::System { content } if content.contains("Not ready yet in this version.")
|
||||
)));
|
||||
|
||||
let mut standard = create_test_app();
|
||||
let session = saved_session_with_messages(vec![text_message("user", "history line")]);
|
||||
assert_eq!(session.agent_preset, None);
|
||||
apply_loaded_session(&mut standard, &mut Config::default(), &session).expect("loads");
|
||||
assert_eq!(standard.conversation_mode, None);
|
||||
assert_eq!(standard.conversation_mode_label(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn clear_fork_and_resume_keep_the_conversation_mode() {
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let _home = crate::test_support::SealedHome::new();
|
||||
|
||||
let mut app = creator_app();
|
||||
first_message(&mut app);
|
||||
let cleared = commands::execute("/clear", &mut app);
|
||||
assert!(matches!(
|
||||
cleared.action,
|
||||
Some(AppAction::SyncSession { agent_preset: Some(ref preset), .. }) if preset == "creator"
|
||||
));
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
|
||||
let mut resumed = create_test_app();
|
||||
let mut session = saved_session_with_messages(vec![text_message("user", "older")]);
|
||||
session.agent_preset = Some("creator".to_string());
|
||||
apply_loaded_session(&mut resumed, &mut Config::default(), &session).expect("resume");
|
||||
assert_eq!(resumed.conversation_mode.as_deref(), Some("creator"));
|
||||
assert_eq!(resumed.conversation_mode_unavailable, None);
|
||||
assert_eq!(resumed.agent_preset().as_deref(), Some("creator"));
|
||||
|
||||
let manager = SessionManager::default_location().unwrap();
|
||||
let mut snapshot = build_session_snapshot(&mut resumed, &manager).expect("snapshot");
|
||||
assert_eq!(snapshot.agent_preset.as_deref(), Some("creator"));
|
||||
snapshot.make_storage_compatible();
|
||||
let raw = serde_json::to_value(&snapshot).unwrap();
|
||||
assert_eq!(raw["agent_preset"], "creator");
|
||||
|
||||
let mut plain = create_test_app();
|
||||
let snapshot = build_session_snapshot(&mut plain, &manager).expect("snapshot");
|
||||
let raw = serde_json::to_value(&snapshot).unwrap();
|
||||
assert!(raw.get("agent_preset").is_none(), "Standard stores no key");
|
||||
|
||||
let mut parent = creator_app();
|
||||
first_message(&mut parent);
|
||||
parent.current_session_id = Some(uuid::Uuid::new_v4().to_string());
|
||||
parent.capture_session_fixture_source();
|
||||
let forked = commands::execute("/fork", &mut parent);
|
||||
assert!(!forked.is_error, "{:?}", forked.message);
|
||||
assert!(matches!(
|
||||
forked.action,
|
||||
Some(AppAction::SyncSession { agent_preset: Some(ref preset), .. }) if preset == "creator"
|
||||
));
|
||||
let fork_id = parent.current_session_id.clone().unwrap();
|
||||
let on_disk = manager.load_session(&fork_id).expect("fork record");
|
||||
assert_eq!(on_disk.agent_preset.as_deref(), Some("creator"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn a_resync_of_the_open_conversation_keeps_its_mode() {
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let _home = crate::test_support::SealedHome::new();
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let other = TempDir::new().unwrap();
|
||||
let mut config = Config::default().with_legacy_root(
|
||||
Some("mock-credential".into()),
|
||||
Some("http://127.0.0.1:1/v1".into()),
|
||||
);
|
||||
config.set_feature("mcp", false).unwrap();
|
||||
config.set_feature("subagents", false).unwrap();
|
||||
let mut app = App::new(
|
||||
crate::tui::test_support::test_tui_options(workspace.path()),
|
||||
&config,
|
||||
);
|
||||
app.onboarding_needs_api_key = false;
|
||||
app.offline_mode = false;
|
||||
app.install_conversation_mode(Some("creator".to_string()), None);
|
||||
first_message(&mut app);
|
||||
app.set_current_session_id(Some(uuid::Uuid::new_v4().to_string()));
|
||||
|
||||
let (engine, mut handle) = Engine::new_with_model_client(
|
||||
codewhale_engine::core::engine::EngineConfig {
|
||||
workspace: workspace.path().to_path_buf(),
|
||||
model: app.model.clone(),
|
||||
snapshots_enabled: false,
|
||||
subagents_enabled: false,
|
||||
terminal_chrome_enabled: false,
|
||||
..Default::default()
|
||||
},
|
||||
&config,
|
||||
Arc::new(codewhale_engine::llm_client::mock::MockLlmClient::new(
|
||||
Vec::new(),
|
||||
)),
|
||||
);
|
||||
let run = tokio::spawn(engine.run());
|
||||
let tasks = {
|
||||
use codewhale_engine::task_manager::{
|
||||
TaskExecutionResult, TaskManagerConfig, TaskStatus, TaskTerminalReason,
|
||||
};
|
||||
struct Idle;
|
||||
#[async_trait::async_trait]
|
||||
impl codewhale_engine::task_manager::TaskExecutor for Idle {
|
||||
async fn execute(
|
||||
&self,
|
||||
_: codewhale_engine::task_manager::ExecutionTask,
|
||||
_: tokio::sync::mpsc::Sender<codewhale_engine::task_manager::TaskExecutionEvent>,
|
||||
_: tokio_util::sync::CancellationToken,
|
||||
) -> TaskExecutionResult {
|
||||
TaskExecutionResult {
|
||||
status: TaskStatus::Completed,
|
||||
result_text: None,
|
||||
error: None,
|
||||
terminal_reason: TaskTerminalReason::Completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
TaskManager::start_with_executor(
|
||||
TaskManagerConfig {
|
||||
data_dir: workspace.path().into(),
|
||||
worker_count: 1,
|
||||
default_workspace: workspace.path().into(),
|
||||
default_model: "fixture".into(),
|
||||
default_mode: "plan".into(),
|
||||
allow_shell: false,
|
||||
trust_mode: false,
|
||||
execution_limits: codewhale_engine::task_manager::TaskExecutionLimits::default(),
|
||||
human_waits_answerable: false,
|
||||
},
|
||||
Arc::new(Idle),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
let mut backend = ColorCompatBackend::new(
|
||||
std::io::stdout(),
|
||||
codewhale_palette::ColorDepth::Monochrome,
|
||||
codewhale_palette::PaletteMode::Dark,
|
||||
);
|
||||
backend.set_terminal_size(ratatui::layout::Size::new(80, 24));
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
for action in [
|
||||
AppAction::SwitchWorkspace {
|
||||
workspace: other.path().to_path_buf(),
|
||||
},
|
||||
AppAction::PluginRegistryChanged,
|
||||
] {
|
||||
let result = commands::CommandResult {
|
||||
message: None,
|
||||
action: Some(action),
|
||||
is_error: false,
|
||||
};
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
apply_command_result(
|
||||
&mut terminal,
|
||||
&mut app,
|
||||
&mut handle,
|
||||
&tasks,
|
||||
&mut config,
|
||||
result,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("the re-sync finished")
|
||||
.unwrap();
|
||||
let snapshot = tokio::time::timeout(Duration::from_secs(20), handle.get_session_snapshot())
|
||||
.await
|
||||
.expect("the Engine answered")
|
||||
.expect("snapshot");
|
||||
assert_eq!(snapshot.agent_preset.as_deref(), Some("creator"));
|
||||
assert_eq!(snapshot.messages.len(), 1);
|
||||
assert_eq!(app.conversation_mode.as_deref(), Some("creator"));
|
||||
}
|
||||
let _ = handle.send(Op::Shutdown).await;
|
||||
run.abort();
|
||||
}
|
||||
@@ -70,7 +70,6 @@ fn sync_snapshot(
|
||||
model: snapshot.model.clone(),
|
||||
workspace: snapshot.workspace.clone(),
|
||||
mode: AppMode::parse(&snapshot.mode).unwrap(),
|
||||
agent_preset: snapshot.agent_preset.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,7 +477,6 @@ fn undo_retry_apply_path_keeps_engine_request_and_reopened_session_consistent()
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
mode: app.mode,
|
||||
agent_preset: app.agent_preset(),
|
||||
};
|
||||
for action in [restore, AppAction::SendMessage("after reopen".into())] {
|
||||
apply_command_result(
|
||||
|
||||
@@ -722,9 +722,6 @@ pub(crate) fn dial_chip(app: &App) -> PostureChip {
|
||||
if let Some(scope) = filesystem_scope_notice(app) {
|
||||
chip.0 = format!("{} · {scope}", chip.0).into();
|
||||
}
|
||||
if let Some(label) = app.conversation_mode_label() {
|
||||
chip.0 = format!("{label} · {}", chip.0).into();
|
||||
}
|
||||
chip
|
||||
}
|
||||
|
||||
|
||||
@@ -997,9 +997,6 @@ pub enum ViewEvent {
|
||||
ModeSelected {
|
||||
mode: codewhale_config::AppMode,
|
||||
},
|
||||
ConversationModeSelected {
|
||||
id: String,
|
||||
},
|
||||
/// Emitted by the `/statusline` picker every time the user toggles an
|
||||
/// item (live preview) and once more on Enter (final). The handler
|
||||
/// updates `app.status_items` immediately and persists on `final_save`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `/mode` picker: the conversation's mode first, then permissions.
|
||||
//! `/mode` picker for the currently supported interactive modes.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
@@ -12,140 +12,56 @@ use ratatui::{
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::tui::app::{App, AppModeUi};
|
||||
use crate::tui::conversation_mode as modes;
|
||||
use crate::tui::app::AppModeUi;
|
||||
use crate::tui::views::{
|
||||
ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area,
|
||||
render_modal_footer, render_modal_surface,
|
||||
};
|
||||
use codewhale_config::AppMode;
|
||||
use codewhale_engine::conversation_modes::ConversationModeTable;
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
use codewhale_localization::Locale;
|
||||
use codewhale_palette as palette;
|
||||
use codewhale_runtime::config::Config;
|
||||
use codewhale_tui_kit::menu_style;
|
||||
|
||||
const VISIBLE_MODES: [AppMode; 2] = [AppMode::Agent, AppMode::Plan];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ConversationRow {
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
unavailable: Option<String>,
|
||||
current: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Entry {
|
||||
Conversation(usize),
|
||||
Permission(AppMode),
|
||||
}
|
||||
|
||||
pub struct ModePickerView {
|
||||
cursor: usize,
|
||||
locale: Locale,
|
||||
rows: Vec<ConversationRow>,
|
||||
locked: bool,
|
||||
other_command: String,
|
||||
row_hitboxes: RefCell<Vec<Rect>>,
|
||||
}
|
||||
|
||||
impl ModePickerView {
|
||||
pub async fn for_app(app: &App, config: &Config) -> Self {
|
||||
let table = modes::current_table(config).await;
|
||||
Self::from_table(
|
||||
app.mode,
|
||||
app.ui_locale,
|
||||
&table,
|
||||
app.conversation_mode.as_deref(),
|
||||
app.conversation_mode_locked(),
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_table(
|
||||
current: AppMode,
|
||||
locale: Locale,
|
||||
table: &ConversationModeTable,
|
||||
conversation: Option<&str>,
|
||||
locked: bool,
|
||||
) -> Self {
|
||||
let current_id = codewhale_engine::conversation_modes::effective_id(conversation);
|
||||
let rows = table
|
||||
.modes
|
||||
pub fn new(current: AppMode, locale: Locale) -> Self {
|
||||
let cursor = VISIBLE_MODES
|
||||
.iter()
|
||||
.map(|mode| ConversationRow {
|
||||
id: mode.id.clone(),
|
||||
name: modes::display_name(locale, mode),
|
||||
description: modes::display_description(locale, mode),
|
||||
unavailable: (!mode.available).then(|| {
|
||||
modes::display_reason(
|
||||
locale,
|
||||
mode.reason
|
||||
.as_deref()
|
||||
.unwrap_or("This mode is unavailable."),
|
||||
)
|
||||
}),
|
||||
current: mode.id == current_id,
|
||||
})
|
||||
.collect();
|
||||
let other_command = modes::new_command(
|
||||
table
|
||||
.modes
|
||||
.iter()
|
||||
.find(|mode| mode.id != current_id)
|
||||
.map_or(codewhale_engine::conversation_modes::STANDARD, |mode| {
|
||||
mode.id.as_str()
|
||||
}),
|
||||
);
|
||||
let mut view = Self {
|
||||
cursor: 0,
|
||||
locale,
|
||||
rows,
|
||||
locked,
|
||||
other_command,
|
||||
row_hitboxes: RefCell::new(Vec::new()),
|
||||
};
|
||||
view.cursor = view
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|entry| *entry == Entry::Permission(current))
|
||||
.position(|mode| *mode == current)
|
||||
.unwrap_or(0);
|
||||
view
|
||||
}
|
||||
|
||||
fn entries(&self) -> Vec<Entry> {
|
||||
let mut entries = Vec::new();
|
||||
if !self.locked {
|
||||
entries.extend(
|
||||
self.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| row.unavailable.is_none())
|
||||
.map(|(index, _)| Entry::Conversation(index)),
|
||||
);
|
||||
}
|
||||
entries.extend(VISIBLE_MODES.iter().copied().map(Entry::Permission));
|
||||
entries
|
||||
}
|
||||
|
||||
fn selected(&self) -> Option<Entry> {
|
||||
self.entries().get(self.cursor).copied()
|
||||
}
|
||||
|
||||
fn event_for(&self, entry: Entry) -> ViewEvent {
|
||||
match entry {
|
||||
Entry::Conversation(index) => ViewEvent::ConversationModeSelected {
|
||||
id: self.rows[index].id.clone(),
|
||||
},
|
||||
Entry::Permission(mode) => ViewEvent::ModeSelected { mode },
|
||||
Self {
|
||||
cursor,
|
||||
locale,
|
||||
row_hitboxes: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_mode(&self) -> AppMode {
|
||||
VISIBLE_MODES
|
||||
.get(self.cursor)
|
||||
.copied()
|
||||
.unwrap_or(AppMode::Agent)
|
||||
}
|
||||
|
||||
/// Apply one [`list_nav`](codewhale_tui_kit::list_nav) motion (#6290), returning
|
||||
/// whether it was consumed. Vertical motions cover the whole list —
|
||||
/// Home/End and the page keys included. The horizontal axis has nowhere
|
||||
/// to go on a single-column surface, so those motions are not consumed.
|
||||
fn apply_motion(&mut self, motion: codewhale_tui_kit::list_nav::Motion) -> bool {
|
||||
let len = self.entries().len();
|
||||
let Some(next) = codewhale_tui_kit::list_nav::apply(self.cursor, len, len, motion) else {
|
||||
let Some(next) = codewhale_tui_kit::list_nav::apply(
|
||||
self.cursor,
|
||||
VISIBLE_MODES.len(),
|
||||
VISIBLE_MODES.len(),
|
||||
motion,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
self.cursor = next;
|
||||
@@ -153,22 +69,13 @@ impl ModePickerView {
|
||||
}
|
||||
|
||||
fn select_by_number(&mut self, number: char) -> Option<ViewAction> {
|
||||
let entries = self.entries();
|
||||
let idx = entries.iter().position(
|
||||
|entry| matches!(entry, Entry::Permission(mode) if mode.number() == number),
|
||||
)?;
|
||||
let idx = VISIBLE_MODES
|
||||
.iter()
|
||||
.position(|mode| mode.number() == number)?;
|
||||
self.cursor = idx;
|
||||
Some(ViewAction::EmitAndClose(self.event_for(entries[idx])))
|
||||
}
|
||||
|
||||
fn line_count(&self) -> u16 {
|
||||
let rows = u16::try_from(self.rows.len()).unwrap_or(u16::MAX);
|
||||
let conversation = if self.rows.is_empty() {
|
||||
0
|
||||
} else {
|
||||
2 + rows + u16::from(self.locked)
|
||||
};
|
||||
conversation + 1 + u16::try_from(VISIBLE_MODES.len()).unwrap_or(2)
|
||||
Some(ViewAction::EmitAndClose(ViewEvent::ModeSelected {
|
||||
mode: self.selected_mode(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +89,10 @@ impl ModalView for ModePickerView {
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
// Movement keys come from the shared vocabulary (#6290), so
|
||||
// `j`/`k`, Home/End and the page keys mean here exactly what they
|
||||
// mean on every other list. This match owns only the keys the
|
||||
// vocabulary does not claim.
|
||||
if let Some(motion) = codewhale_tui_kit::list_nav::motion(&key)
|
||||
&& self.apply_motion(motion)
|
||||
{
|
||||
@@ -189,10 +100,9 @@ impl ModalView for ModePickerView {
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => ViewAction::Close,
|
||||
KeyCode::Enter => match self.selected() {
|
||||
Some(entry) => ViewAction::EmitAndClose(self.event_for(entry)),
|
||||
None => ViewAction::None,
|
||||
},
|
||||
KeyCode::Enter => ViewAction::EmitAndClose(ViewEvent::ModeSelected {
|
||||
mode: self.selected_mode(),
|
||||
}),
|
||||
KeyCode::Char(number) => self.select_by_number(number).unwrap_or(ViewAction::None),
|
||||
_ => ViewAction::None,
|
||||
}
|
||||
@@ -223,18 +133,14 @@ impl ModalView for ModePickerView {
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let popup_height = self.line_count() + 7;
|
||||
let popup_height = u16::try_from(VISIBLE_MODES.len()).unwrap_or(2) + 7;
|
||||
let popup_area = centered_modal_area(area, 68, popup_height, 44, 8);
|
||||
|
||||
render_modal_surface(area, popup_area, buf);
|
||||
|
||||
let title = format!(
|
||||
" {} ",
|
||||
tr(self.locale, MessageId::ConversationModePickerTitle)
|
||||
);
|
||||
let block = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
title,
|
||||
" Mode ",
|
||||
Style::default()
|
||||
.fg(palette::WHALE_ACTION)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -259,105 +165,47 @@ impl ModalView for ModePickerView {
|
||||
|
||||
self.row_hitboxes.borrow_mut().clear();
|
||||
|
||||
let entries = self.entries();
|
||||
let heading_style = Style::default()
|
||||
.fg(palette::TEXT_MUTED)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let muted = Style::default().fg(palette::TEXT_MUTED);
|
||||
let width = usize::from(content.width);
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut lines = Vec::with_capacity(VISIBLE_MODES.len());
|
||||
|
||||
let push_row = |lines: &mut Vec<Line<'static>>,
|
||||
entry: Option<Entry>,
|
||||
prefix: String,
|
||||
text: String,
|
||||
disabled: bool| {
|
||||
let cursor_here = entry.is_some_and(|entry| entries.get(self.cursor) == Some(&entry));
|
||||
let row_style = if cursor_here {
|
||||
for (idx, mode) in VISIBLE_MODES.iter().copied().enumerate() {
|
||||
let is_cursor = idx == self.cursor;
|
||||
let row_style = if is_cursor {
|
||||
menu_style::selected_row_style()
|
||||
} else if disabled {
|
||||
muted
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
let hint_style = if cursor_here {
|
||||
let hint_style = if is_cursor {
|
||||
menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
|
||||
} else {
|
||||
muted
|
||||
Style::default().fg(palette::TEXT_MUTED)
|
||||
};
|
||||
let room = width.saturating_sub(UnicodeWidthStr::width(prefix.as_str()));
|
||||
let text = codewhale_tui_kit::ui_text::semantic_truncate(&text, room);
|
||||
if entry.is_some() {
|
||||
let y = content
|
||||
.y
|
||||
.saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX));
|
||||
self.row_hitboxes
|
||||
.borrow_mut()
|
||||
.push(Rect::new(content.x, y, content.width, 1));
|
||||
}
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, row_style),
|
||||
Span::styled(text, hint_style),
|
||||
]));
|
||||
};
|
||||
|
||||
if !self.rows.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
tr(self.locale, MessageId::ConversationModeGroupConversation).into_owned(),
|
||||
heading_style,
|
||||
)));
|
||||
if self.locked {
|
||||
lines.push(Line::from(Span::styled(
|
||||
codewhale_tui_kit::ui_text::semantic_truncate(
|
||||
tr(self.locale, MessageId::ConversationModeLockedNote)
|
||||
.replace("{command}", &format!("`{}`", self.other_command))
|
||||
.as_str(),
|
||||
width,
|
||||
),
|
||||
muted,
|
||||
)));
|
||||
}
|
||||
for (index, row) in self.rows.iter().enumerate() {
|
||||
let selectable = !self.locked && row.unavailable.is_none();
|
||||
let entry = selectable.then_some(Entry::Conversation(index));
|
||||
let cursor_here =
|
||||
entry.is_some_and(|entry| entries.get(self.cursor) == Some(&entry));
|
||||
let pointer = if cursor_here {
|
||||
codewhale_tui_kit::glyphs::selection_marker(true).to_string()
|
||||
} else {
|
||||
" ".to_string()
|
||||
};
|
||||
let check = if row.current { "✓" } else { " " };
|
||||
let pad =
|
||||
" ".repeat(16usize.saturating_sub(UnicodeWidthStr::width(row.name.as_str())));
|
||||
let prefix = format!("{pointer} {check} {}{pad}", row.name);
|
||||
let text = row
|
||||
.unavailable
|
||||
.clone()
|
||||
.unwrap_or_else(|| row.description.clone());
|
||||
push_row(
|
||||
&mut lines,
|
||||
entry,
|
||||
prefix,
|
||||
text,
|
||||
row.unavailable.is_some() || self.locked,
|
||||
);
|
||||
}
|
||||
lines.push(Line::default());
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
tr(self.locale, MessageId::ConversationModeGroupPermissions).into_owned(),
|
||||
heading_style,
|
||||
)));
|
||||
for mode in VISIBLE_MODES {
|
||||
let entry = Entry::Permission(mode);
|
||||
let cursor_here = entries.get(self.cursor) == Some(&entry);
|
||||
let pointer = codewhale_tui_kit::glyphs::selection_marker(cursor_here);
|
||||
let pointer = codewhale_tui_kit::glyphs::selection_marker(is_cursor);
|
||||
let name = mode.display_name_localized(self.locale);
|
||||
let hint = mode.picker_hint_localized(self.locale);
|
||||
// Pad by terminal columns, not scalar count, so wide (CJK) mode
|
||||
// names keep the hint column aligned.
|
||||
let pad = " ".repeat(8usize.saturating_sub(UnicodeWidthStr::width(&*name)));
|
||||
let prefix = format!("{pointer} {}. {name}{pad}", mode.number());
|
||||
push_row(&mut lines, Some(entry), prefix, hint.into_owned(), false);
|
||||
// A hint is prose: the pane edge used to cut it mid-word
|
||||
// (`ask f`, `before ac`). Truncate at a word joint instead —
|
||||
// a clipped clause reads as a sentence, a clipped word reads
|
||||
// as a bug.
|
||||
let hint_width =
|
||||
usize::from(content.width).saturating_sub(UnicodeWidthStr::width(prefix.as_str()));
|
||||
let hint = codewhale_tui_kit::ui_text::semantic_truncate(hint.as_ref(), hint_width);
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, row_style),
|
||||
Span::styled(hint, hint_style),
|
||||
]));
|
||||
self.row_hitboxes.borrow_mut().push(Rect::new(
|
||||
content.x,
|
||||
content
|
||||
.y
|
||||
.saturating_add(u16::try_from(idx).unwrap_or(u16::MAX)),
|
||||
content.width,
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
Paragraph::new(lines).render(content, buf);
|
||||
@@ -367,31 +215,18 @@ impl ModalView for ModePickerView {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codewhale_engine::conversation_modes::{
|
||||
self, CREATOR_NOT_READY_REASON, CreatorAvailabilityGuard,
|
||||
};
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
fn picker(current: AppMode, conversation: Option<&str>, locked: bool) -> ModePickerView {
|
||||
ModePickerView::from_table(
|
||||
current,
|
||||
Locale::En,
|
||||
&conversation_modes::table(Some(CREATOR_NOT_READY_REASON.to_string())),
|
||||
conversation,
|
||||
locked,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_on_current_mode() {
|
||||
let view = picker(AppMode::Plan, None, false);
|
||||
assert_eq!(view.selected(), Some(Entry::Permission(AppMode::Plan)));
|
||||
let view = ModePickerView::new(AppMode::Plan, Locale::En);
|
||||
assert_eq!(view.selected_mode(), AppMode::Plan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_emits_selected_mode() {
|
||||
let mut view = picker(AppMode::Agent, None, false);
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match action {
|
||||
@@ -402,27 +237,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
|
||||
/// every overlay to remain readable and fully operable at.
|
||||
const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
|
||||
|
||||
fn render_view(view: ModePickerView, width: u16, height: u16) -> (Buffer, Rect) {
|
||||
fn render_at(width: u16, height: u16) -> (Buffer, Rect) {
|
||||
use crate::tui::views::ViewStack;
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
// Pre-fill with a sentinel so any cell the composited modal fails to
|
||||
// paint (bleed-through) is detectable as a surviving 'X'.
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
buf[(x, y)].set_symbol("X");
|
||||
}
|
||||
}
|
||||
// Render through the ViewStack so the shared opaque backdrop is painted
|
||||
// exactly as it is in production.
|
||||
let mut stack = ViewStack::new();
|
||||
stack.push(view);
|
||||
stack.push(ModePickerView::new(AppMode::Agent, Locale::En));
|
||||
stack.render(area, &mut buf);
|
||||
(buf, area)
|
||||
}
|
||||
|
||||
fn render_at(width: u16, height: u16) -> (Buffer, Rect) {
|
||||
render_view(picker(AppMode::Agent, None, false), width, height)
|
||||
}
|
||||
|
||||
fn rows(buf: &Buffer, area: Rect) -> Vec<String> {
|
||||
(0..area.height)
|
||||
.map(|y| {
|
||||
@@ -433,64 +270,42 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn frame_text(buf: &Buffer, area: Rect) -> String {
|
||||
rows(buf, area)
|
||||
.iter()
|
||||
.map(|row| row.trim_end().to_string())
|
||||
.filter(|row| !row.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_picker_is_usable_and_opaque_at_blocker_sizes() {
|
||||
for (w, h) in BLOCKER_SIZES {
|
||||
for view in [
|
||||
picker(AppMode::Agent, None, false),
|
||||
picker(AppMode::Plan, Some("creator"), true),
|
||||
] {
|
||||
let (buf, area) = render_view(view, w, h);
|
||||
let text = rows(&buf, area).join("\n");
|
||||
let (buf, area) = render_at(w, h);
|
||||
let text = rows(&buf, area).join("\n");
|
||||
|
||||
assert!(text.contains("move"), "{w}x{h}: missing 'move' hint");
|
||||
assert!(text.contains("select"), "{w}x{h}: missing 'select' hint");
|
||||
assert!(text.contains("cancel"), "{w}x{h}: missing 'cancel' hint");
|
||||
// Action labels are present (footer never drops an action).
|
||||
assert!(text.contains("move"), "{w}x{h}: missing 'move' hint");
|
||||
assert!(text.contains("select"), "{w}x{h}: missing 'select' hint");
|
||||
assert!(text.contains("cancel"), "{w}x{h}: missing 'cancel' hint");
|
||||
|
||||
// The cursor row carries the charter selection pointer.
|
||||
assert!(
|
||||
text.contains(codewhale_tui_kit::glyphs::SELECTION),
|
||||
"{w}x{h}: missing charter selection pointer"
|
||||
);
|
||||
|
||||
// Composited frame is fully opaque: no sentinel survives and every
|
||||
// cell carries the modal/backdrop ink background.
|
||||
assert!(
|
||||
!text.contains('X'),
|
||||
"{w}x{h}: background bleed-through into modal surface"
|
||||
);
|
||||
let center = &buf[(w / 2, h / 2)];
|
||||
assert_eq!(
|
||||
center.bg,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
|
||||
// No row exceeds the frame width (no horizontal overflow).
|
||||
for (y, row) in rows(&buf, area).iter().enumerate() {
|
||||
assert!(
|
||||
text.contains("Conversation mode") && text.contains("Permissions"),
|
||||
"{w}x{h}: both groups are labelled"
|
||||
UnicodeWidthStr::width(row.trim_end()) <= w as usize,
|
||||
"{w}x{h}: row {y} overflows width: {row:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(codewhale_tui_kit::glyphs::SELECTION),
|
||||
"{w}x{h}: missing charter selection pointer"
|
||||
);
|
||||
assert!(
|
||||
!text.contains('X'),
|
||||
"{w}x{h}: background bleed-through into modal surface"
|
||||
);
|
||||
let lines = rows(&buf, area);
|
||||
let heading_row = lines
|
||||
.iter()
|
||||
.position(|row| row.contains("Conversation mode"))
|
||||
.expect("heading row");
|
||||
let heading_byte = lines[heading_row]
|
||||
.find("Conversation mode")
|
||||
.expect("heading column");
|
||||
let heading_x = lines[heading_row][..heading_byte].chars().count();
|
||||
let padding = &buf[(
|
||||
u16::try_from(heading_x + "Conversation mode".len() + 3).unwrap(),
|
||||
u16::try_from(heading_row).unwrap(),
|
||||
)];
|
||||
assert_eq!(
|
||||
padding.bg,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
for (y, row) in rows(&buf, area).iter().enumerate() {
|
||||
assert!(
|
||||
UnicodeWidthStr::width(row.trim_end()) <= w as usize,
|
||||
"{w}x{h}: row {y} overflows width: {row:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -504,7 +319,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn number_keys_select_modes() {
|
||||
let mut view = picker(AppMode::Agent, None, false);
|
||||
// Visible roster: 1 Act, 2 Plan.
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||||
assert!(matches!(
|
||||
action,
|
||||
@@ -513,21 +329,30 @@ mod tests {
|
||||
})
|
||||
));
|
||||
|
||||
for key in ['3', '4', '5'] {
|
||||
let mut view = picker(AppMode::Agent, None, false);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
|
||||
assert!(matches!(action, ViewAction::None), "{key}");
|
||||
}
|
||||
// Retired Operate number (3) selects nothing.
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
|
||||
assert!(matches!(action, ViewAction::None));
|
||||
|
||||
// Legacy YOLO shorthand (4) is not offered by the picker.
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE));
|
||||
assert!(matches!(action, ViewAction::None));
|
||||
|
||||
// Old Operate number (5) is gone — no numeric gaps.
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE));
|
||||
assert!(matches!(action, ViewAction::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_click_renders_and_selects_mode_row() {
|
||||
let mut view = picker(AppMode::Agent, None, false);
|
||||
let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
|
||||
let mut terminal = Terminal::new(TestBackend::new(100, 30)).expect("test terminal");
|
||||
terminal
|
||||
.draw(|frame| view.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("render mode picker");
|
||||
let rect = view.row_hitboxes.borrow()[2];
|
||||
let rect = view.row_hitboxes.borrow()[1];
|
||||
let action = view.handle_mouse(MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: rect.x,
|
||||
@@ -541,126 +366,4 @@ mod tests {
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn mode_picker_lists_conversation_modes_first_and_locks_after_first_message() {
|
||||
let _available = CreatorAvailabilityGuard::set(None);
|
||||
let table = conversation_modes::current_table(Default::default()).await;
|
||||
for (w, h) in [(80, 24), (120, 32)] {
|
||||
let open = ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
let (buf, area) = render_view(open, w, h);
|
||||
let text = frame_text(&buf, area);
|
||||
println!("--- /mode picker, blank conversation, creator available, {w}x{h}\n{text}");
|
||||
let order = |needle: &str| {
|
||||
text.find(needle)
|
||||
.unwrap_or_else(|| panic!("{needle}: {text}"))
|
||||
};
|
||||
assert!(order("Conversation mode") < order("Standard mode"));
|
||||
assert!(order("Standard mode") < order("Creator mode"));
|
||||
assert!(order("Creator mode") < order("Permissions"));
|
||||
assert!(order("Permissions") < order("1. Work"));
|
||||
assert!(!text.contains("Fixed after the first message"));
|
||||
|
||||
let mut open =
|
||||
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
open.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
open.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert!(matches!(
|
||||
open.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
ViewAction::EmitAndClose(ViewEvent::ConversationModeSelected { ref id }) if id == "standard"
|
||||
));
|
||||
|
||||
let locked = ModePickerView::from_table(
|
||||
AppMode::Agent,
|
||||
Locale::En,
|
||||
&table,
|
||||
Some("creator"),
|
||||
true,
|
||||
);
|
||||
let (buf, area) = render_view(locked, w, h);
|
||||
let text = frame_text(&buf, area);
|
||||
println!(
|
||||
"--- /mode picker, after the first message, creator conversation, {w}x{h}\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Fixed after the first message. Start another with /new creator.")
|
||||
);
|
||||
assert!(text.contains("Creator mode"), "{text}");
|
||||
}
|
||||
let mut locked =
|
||||
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, Some("creator"), true);
|
||||
assert_eq!(
|
||||
locked.entries().len(),
|
||||
2,
|
||||
"only the permission rows stay selectable"
|
||||
);
|
||||
for key in [KeyCode::Up, KeyCode::Up, KeyCode::Home] {
|
||||
locked.handle_key(KeyEvent::new(key, KeyModifiers::NONE));
|
||||
assert!(matches!(locked.selected(), Some(Entry::Permission(_))));
|
||||
}
|
||||
for number in ['1', '2'] {
|
||||
let mut locked =
|
||||
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, true);
|
||||
assert!(matches!(
|
||||
locked.handle_key(KeyEvent::new(KeyCode::Char(number), KeyModifiers::NONE)),
|
||||
ViewAction::EmitAndClose(ViewEvent::ModeSelected { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn creator_row_is_listed_disabled_with_its_reason_while_unavailable() {
|
||||
let runtime_missing =
|
||||
"Creator mode needs a plugin runtime (Node.js or Bun), and none was found.";
|
||||
for reason in [CREATOR_NOT_READY_REASON, runtime_missing] {
|
||||
let _guard = CreatorAvailabilityGuard::set(Some(reason));
|
||||
let table = conversation_modes::current_table(Default::default()).await;
|
||||
let view = ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
let (buf, area) = render_view(view, 100, 30);
|
||||
let text = frame_text(&buf, area);
|
||||
println!("--- /mode picker, creator unavailable ({reason})\n{text}");
|
||||
assert!(
|
||||
text.contains("Creator mode"),
|
||||
"listed, never hidden: {text}"
|
||||
);
|
||||
let shown = if reason == CREATOR_NOT_READY_REASON {
|
||||
"Not ready yet in this version."
|
||||
} else {
|
||||
"Creator mode needs a plugin runtime"
|
||||
};
|
||||
assert!(text.contains(shown), "{text}");
|
||||
assert!(!text.contains(CREATOR_NOT_READY_REASON));
|
||||
let mut view =
|
||||
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
assert_eq!(view.entries().len(), 3);
|
||||
for key in [
|
||||
KeyCode::Up,
|
||||
KeyCode::Up,
|
||||
KeyCode::Up,
|
||||
KeyCode::Home,
|
||||
KeyCode::End,
|
||||
] {
|
||||
let action = view.handle_key(KeyEvent::new(key, KeyModifiers::NONE));
|
||||
assert!(matches!(action, ViewAction::None));
|
||||
}
|
||||
let mut view =
|
||||
ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(
|
||||
!matches!(action, ViewAction::EmitAndClose(ViewEvent::ConversationModeSelected { ref id }) if id == "creator"),
|
||||
"an unavailable mode is never selectable"
|
||||
);
|
||||
}
|
||||
let _guard = CreatorAvailabilityGuard::set(None);
|
||||
let table = conversation_modes::current_table(Default::default()).await;
|
||||
let mut view = ModePickerView::from_table(AppMode::Agent, Locale::En, &table, None, false);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert!(matches!(
|
||||
view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
ViewAction::EmitAndClose(ViewEvent::ConversationModeSelected { ref id }) if id == "creator"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use super::qa_harness::{
|
||||
harness::{Harness, make_sealed_workspace},
|
||||
keys,
|
||||
};
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(15);
|
||||
|
||||
fn print_frame(label: &str, tui: &mut Harness) {
|
||||
tui.pump();
|
||||
let text = tui.frame().text();
|
||||
println!("--- {label}");
|
||||
for line in text.lines().filter(|line| !line.trim().is_empty()) {
|
||||
println!("{}", line.trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_binary_lists_creator_disabled_and_refuses_new_creator_with_the_reason() {
|
||||
let workspace = make_sealed_workspace().expect("sealed workspace");
|
||||
std::fs::write(workspace.home().join(".codewhale/.onboarded"), "").unwrap();
|
||||
let trust = workspace.workspace().join(".deepseek");
|
||||
std::fs::create_dir_all(&trust).unwrap();
|
||||
std::fs::write(trust.join("trusted"), "").unwrap();
|
||||
let mut tui = Harness::builder(Harness::codewhale_binary())
|
||||
.cwd(workspace.workspace())
|
||||
.clear_env()
|
||||
.seal_home(workspace.home())
|
||||
.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
.env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
|
||||
.env("CODEWHALE_NO_UPDATE_CHECK", "1")
|
||||
.env("CODEWHALE_DISABLE_LOCAL_OLLAMA_PROBE", "1")
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("RUST_LOG", "warn")
|
||||
.args([
|
||||
"--workspace",
|
||||
workspace.workspace().to_str().unwrap(),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
"--fresh",
|
||||
])
|
||||
.size(32, 100)
|
||||
.spawn()
|
||||
.expect("start TUI");
|
||||
tui.wait_for_text("New session", WAIT).unwrap();
|
||||
tui.wait_for_idle(Duration::from_millis(250), Duration::from_secs(5))
|
||||
.unwrap();
|
||||
|
||||
tui.type_line("/new creator").unwrap();
|
||||
tui.wait_for_text("Not ready yet in this version.", WAIT)
|
||||
.unwrap();
|
||||
print_frame("/new creator in the shipped binary", &mut tui);
|
||||
assert!(
|
||||
!tui.frame().contains("Creator mode"),
|
||||
"the conversation stays Standard: {}",
|
||||
tui.diagnostics()
|
||||
);
|
||||
|
||||
tui.type_line("/mode").unwrap();
|
||||
tui.wait_for_text("Conversation mode", WAIT).unwrap();
|
||||
tui.wait_for_text("Permissions", WAIT).unwrap();
|
||||
print_frame("/mode picker in the shipped binary", &mut tui);
|
||||
let frame = tui.frame().text();
|
||||
let creator_row = frame
|
||||
.lines()
|
||||
.find(|line| line.contains("Creator mode"))
|
||||
.expect("Creator is listed");
|
||||
assert!(
|
||||
creator_row.contains("Not ready yet in this version."),
|
||||
"{creator_row}"
|
||||
);
|
||||
let first_group = frame.find("Conversation mode").unwrap();
|
||||
let second_group = frame.find("Permissions").unwrap();
|
||||
assert!(first_group < frame.find("Standard mode").unwrap());
|
||||
assert!(frame.find("Creator mode").unwrap() < second_group);
|
||||
tui.send(keys::key::esc()).unwrap();
|
||||
tui.shutdown();
|
||||
}
|
||||
@@ -21,8 +21,6 @@ mod automations_editor_pty;
|
||||
mod config_theme_nav_pty;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod contextual_tips_pty;
|
||||
#[cfg(all(unix, feature = "long-running-tests"))]
|
||||
mod conversation_mode_pty;
|
||||
mod core_session_command_extraction;
|
||||
mod directory_listing_acceptance;
|
||||
mod epic_acceptance_harness;
|
||||
|
||||
@@ -40,23 +40,6 @@ Run `/mode` to open the mode picker, or switch directly with `/mode work` or
|
||||
`/mode plan`. `operate` and its spellings still parse, as Work, and say that
|
||||
Operate was retired, so an older setting or script never errors.
|
||||
|
||||
The picker has two groups. **Conversation mode** lists the modes the Engine
|
||||
offers, Standard mode and Creator mode, each with a one-line description; a
|
||||
mode that cannot be used yet is listed, disabled, with its reason. **Permissions**
|
||||
holds Work and Plan, still on `1` and `2`; the conversation modes have no
|
||||
number keys. A conversation's mode is chosen before its first message and is
|
||||
fixed after it: `/mode creator` or the picker's first group changes a blank
|
||||
conversation in place, and after the first message the picker shows the group
|
||||
as locked and `/mode creator` is refused with a note to start a new
|
||||
conversation. `/new` starts a Standard conversation, `/new creator` starts a
|
||||
Creator mode conversation (`--force` still combines). Resume, `/load`, `/fork`
|
||||
and `/clear` keep the conversation's saved mode, and the mode name appears in the
|
||||
footer when it is not Standard. A mode never changes a permission: Creator
|
||||
mode in Plan is still Plan. `codewhale` and `codewhale exec` take
|
||||
`--conversation-mode <MODE>` to start a new session in a mode; with `--resume`,
|
||||
`--session-id` or `--continue` it must match the session's saved mode, or the
|
||||
run exits with code 2 before any model call.
|
||||
|
||||
- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.
|
||||
- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.
|
||||
- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = "operate"` loads as Work and says so once; a Runtime API request with `mode: "operate"` runs as `agent` with the default posture (Accept Edits) and logs one notice per process, never an error. Agents forked from an older Operate conversation do not inherit its contract. Multi-agent work stays on `agent`, `workflow` and `/fleet` in every mode.
|
||||
|
||||
+59
-19
@@ -4,7 +4,7 @@
|
||||
|
||||
**Usage analytics are on by default in the current source.** The first
|
||||
interactive launch gives a localized, nonblocking disclosure naming **Codewhale
|
||||
and PostHog**, with a direct route to turn counting off. Notice version `6`
|
||||
and PostHog**, with a direct route to turn counting off. Notice version `8`
|
||||
describes this policy; displaying it is not recorded as human acceptance.
|
||||
Missing an older acceptance record does not turn a fresh installation off.
|
||||
Existing explicit opt-outs remain off across upgrades, and unreadable privacy
|
||||
@@ -65,7 +65,7 @@ codewhale --telemetry false # the same kill switch, for one command
|
||||
**`telemetry = false` in the config file is the opt-out.** It is a floor:
|
||||
`--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a
|
||||
setting you can undo by accident from a wrapper script is not a setting. It
|
||||
deletes the random install id, truncates every buffered event and every dry-run
|
||||
deletes the random install id and the local first-seen day (so the install-age bucket starts over at `new_today` if you turn telemetry back on), truncates every buffered event and every dry-run
|
||||
record, and writes a tombstone. Appends, identity/state writes, and delivery all
|
||||
share the wipe's ordering lock, so once opt-out returns no pre-opt-out write or
|
||||
POST remains in flight. If any part of that wipe fails, the tombstone is still
|
||||
@@ -104,7 +104,7 @@ Everything is under `$CODEWHALE_HOME/telemetry/` (`0700`), every file `0600`:
|
||||
| `buffer.jsonl` | pending events, one JSON object per line |
|
||||
| `buffer.jsonl.lock` | the sibling ordering lock shared by writes, delivery, arming, and wipe |
|
||||
| `dryrun.jsonl` | where batches go when the endpoint is configured empty |
|
||||
| `state.json` | the last app version seen and the last flush attempt |
|
||||
| `state.json` | the last app version seen, the last flush attempt, and the UTC day this state was first created. The date never leaves the device: only the `install_age` bucket is sent |
|
||||
| `install_id.json` | the random install id and when it was minted |
|
||||
| `disabled` | the tombstone; present means nothing is appended or sent |
|
||||
|
||||
@@ -162,10 +162,14 @@ most once per flush point and never grows a queue.
|
||||
|
||||
## Event schema
|
||||
|
||||
`SCHEMA_VERSION = 4`. `notice_version = 6` identifies the disclosed default-on
|
||||
policy; it does not assert human consent or that a notice was seen. Schema 4
|
||||
adds the `model_usage` and `tool_usage` events and the `ci` session source.
|
||||
Schema-3 batches with `notice_version = 5` are still accepted without them, which
|
||||
`SCHEMA_VERSION = 6`. `notice_version = 8` identifies the disclosed default-on
|
||||
policy; it does not assert human consent or that a notice was seen. Schema 6
|
||||
adds, to `model_usage`, the request count, reasoning tokens, whole seconds spent
|
||||
waiting on the model and a four-bucket input-size histogram; adds a coarse
|
||||
install age and three exec start labels to `session_start`; and adds the
|
||||
`unclean` exit class. Schema 4 added the `model_usage` and `tool_usage` events
|
||||
and the `ci` session source. Schema-4 and schema-3 batches are still accepted
|
||||
without the schema-6 fields. Schema-3 batches with `notice_version = 5` are still accepted without them, which
|
||||
is what the web and desktop control-plane relay sends. The closed
|
||||
v2 contract still accepts only `consent_version = 4`, retaining its original
|
||||
explicit opt-in meaning. Fields cannot be mixed between versions. The unchanged
|
||||
@@ -178,8 +182,8 @@ new fields, surfaces, and `product_usage` events.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema_version": 4,
|
||||
"notice_version": 6,
|
||||
"schema_version": 6,
|
||||
"notice_version": 8,
|
||||
"sent_at": "2026-08-03T18:04:11Z", // RFC3339 UTC, second precision
|
||||
"install_id": "3f2a…", // uuid v4, rotates every 90 days
|
||||
"app_version": "0.9.12",
|
||||
@@ -196,7 +200,7 @@ new fields, surfaces, and `product_usage` events.
|
||||
| Field | Type | Source anchor | Rule |
|
||||
|---|---|---|---|
|
||||
| `schema_version` | `u32` | const in `crates/telemetry/src/event.rs` | Bumped on any field add/remove/retype. Never reused. Pinned by a golden snapshot test. |
|
||||
| `notice_version` | `u32` | policy constant in `crates/telemetry/src/event.rs` | Exactly `6` for schema 4; identifies the disclosed opt-out policy, not a human acceptance record. |
|
||||
| `notice_version` | `u32` | policy constant in `crates/telemetry/src/event.rs` | Exactly `8` for schema 6; identifies the disclosed opt-out policy, not a human acceptance record. |
|
||||
| `sent_at` | RFC3339 | `chrono::Utc::now()` | Second precision. Per-**batch** only — events carry no timestamps at all. |
|
||||
| `install_id` | uuid v4 | `crates/telemetry/src/envelope.rs` | Random, never derived, rotated every 90 days. See "Where it lives" above. |
|
||||
| `app_version` | string | `env!("CARGO_PKG_VERSION")`, as at `crates/telemetry/src/lib.rs:112` | Must match `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`. |
|
||||
@@ -284,14 +288,27 @@ Emitted once when `state.json`'s `last_version` differs from `app_version`.
|
||||
### Event: `session_start`
|
||||
|
||||
```jsonc
|
||||
{ "event": "session_start", "source": "interactive" }
|
||||
{ "event": "session_start", "source": "interactive", "install_age": "older" }
|
||||
```
|
||||
|
||||
`source` is `SessionSource` (`crates/state/src/lib.rs:34-41`) stringified by `session_source_to_str` (`:1909-1917`): `interactive | resume | fork | api | unknown | ci`.
|
||||
`source` is `SessionSource` (`crates/telemetry/src/event.rs`), a closed enum declared in the telemetry crate: `interactive | resume | fork | api | unknown | ci | fleet_worker | agent | scripted`.
|
||||
`ci` replaces the others whenever `CI` or `GITHUB_ACTIONS` is set to a true
|
||||
value, including the Codewhale GitHub Action, so automated runs are never
|
||||
counted as people. Only that boolean environment fact is read.
|
||||
|
||||
`fleet_worker`, `agent` and `scripted` separate automated `exec` sessions from
|
||||
people. `fleet_worker` is an `exec` that Codewhale's own fleet dispatcher
|
||||
started, `agent` is an `exec` started from inside an agent's shell command (only
|
||||
the presence of a Codewhale-owned environment variable is read, never its
|
||||
value), and `scripted` is an `exec` whose standard input or output is not a
|
||||
terminal. When more than one applies the order is `fleet_worker`, `agent`, `ci`,
|
||||
`scripted`. Other surfaces are unchanged.
|
||||
|
||||
`install_age` is `new_today | under_week | older | null`: a coarse bucket of how
|
||||
long this installation has existed. It is computed on your device and only the
|
||||
bucket leaves it; the date it comes from is stored only in the local state file
|
||||
and never leaves the device. The same UTC day is `new_today`, 1 to 6 days is `under_week`, and 7 or more is `older`. An installation whose state predates this field reports `older`, never `new_today`. `null` means the local state could not be read.
|
||||
|
||||
### Event: `session_end`
|
||||
|
||||
The workhorse. Everything a session accumulated ships here, once.
|
||||
@@ -317,11 +334,11 @@ The workhorse. Everything a session accumulated ships here, once.
|
||||
|
||||
**`duration_bucket`** — `chrono` delta from `app.session_started_at` (`crates/tui/src/tui/app.rs:1889`). Half-open, seconds: `lt_1m` (`d < 60`), `1m_10m` (`60 ≤ d < 600`), `10m_60m` (`600 ≤ d < 3600`), `gt_60m` (`d ≥ 3600`).
|
||||
|
||||
**`exit_class`** — `clean | signal | panic | error`. **Derived from an explicit `AtomicU8`, never from an exit code.** `RunTerminationReason::Canceled` maps to exit 130 (`crates/engine/src/core/runtime_contract/termination.rs:53`), the same value the signal task uses (`crates/tui/src/lib.rs:682`, 128+SIGINT), so a code-based derivation would report every Esc-cancelled turn as a signal. The atomic is set by the panic hook (`crates/tui/src/lib.rs:1582`), by the signal task (`:678-696`) before `std::process::exit`, and on the clean path from `RunTerminationReason::is_success()` (`crates/engine/src/core/runtime_contract/termination.rs:44-46`) — `error` otherwise. Exit codes remain insufficient: an approval-required exit (3) and a terminal exec failure (1) need their actual termination classification.
|
||||
**`exit_class`** — `clean | signal | panic | error | unclean`. `unclean` means the process ended without its own shutdown report and its totals were recovered from a local checkpoint at a later start. **Derived from an explicit `AtomicU8`, never from an exit code.** `RunTerminationReason::Canceled` maps to exit 130 (`crates/engine/src/core/runtime_contract/termination.rs:53`), the same value the signal task uses (`crates/tui/src/lib.rs:682`, 128+SIGINT), so a code-based derivation would report every Esc-cancelled turn as a signal. The atomic is set by the panic hook (`crates/tui/src/lib.rs:1582`), by the signal task (`:678-696`) before `std::process::exit`, and on the clean path from `RunTerminationReason::is_success()` (`crates/engine/src/core/runtime_contract/termination.rs:44-46`) — `error` otherwise. Exit codes remain insufficient: an approval-required exit (3) and a terminal exec failure (1) need their actual termination classification.
|
||||
|
||||
**`cold_start_bucket`** — from `startup_trace::elapsed_ms()`, which reads `PROCESS_START` directly and is independent of the startup summary's buffer clear (`crates/runtime/src/startup_trace.rs:39-45`). Boundaries: `lt_250`, `250_1000`, `1000_3000`, `gte_3000`. Absent on non-TUI surfaces.
|
||||
|
||||
**`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:295`, a `&'static str` from a closed enum; `Custom` yields the literal `"custom"`). **The API takes `ProviderKind` by value, never `&str`.** Do not call `ProviderKind::parse` or `parse_config_identity` (`:300`, `:330`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:5049`), `provider_id_for_persistence()` (`:5058`), `ExecStreamMeta.provider_id` (`crates/app-server/src/exec_agent.rs`), or `PlannedTurnRoute.effective_provider_label` (`crates/engine/src/turn_route_plan.rs:189-193`) — all four return the customer's own `[providers.<name>]` table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and `/status` already prints it (`crates/tui/src/commands/groups/config/status.rs:24-28`). **No model id is ever sent, for any provider** — `crates/runtime/src/safe_label.rs:11-15` documents that a model id can be a path, a URL, or a deployment id that is itself a credential.
|
||||
**`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:295`, a `&'static str` from a closed enum; `Custom` yields the literal `"custom"`). **The API takes `ProviderKind` by value, never `&str`.** Do not call `ProviderKind::parse` or `parse_config_identity` (`:300`, `:330`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:5049`), `provider_id_for_persistence()` (`:5058`), `ExecStreamMeta.provider_id` (`crates/app-server/src/exec_agent.rs`), or `PlannedTurnRoute.effective_provider_label` (`crates/engine/src/turn_route_plan.rs:189-193`) — all four return the customer's own `[providers.<name>]` table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and `/status` already prints it (`crates/tui/src/commands/groups/config/status.rs:24-28`). **No model id outside the bundled public catalog is ever sent, for any provider** — `crates/runtime/src/safe_label.rs:11-15` documents that a model id can be a path, a URL, or a deployment id that is itself a credential. Those are sent as `other`; see `model_usage`.
|
||||
|
||||
**`counters`** — closed field set. Every bump happens at the **call site**, never inside a conditionally-entered handler:
|
||||
|
||||
@@ -443,9 +460,13 @@ to make defaults, and whether prompt caching is working.
|
||||
"input_tokens": 418230,
|
||||
"output_tokens": 9120,
|
||||
"cache_read_tokens": 380001,
|
||||
"cache_write_tokens": 0
|
||||
"cache_write_tokens": 0,
|
||||
"requests": 27,
|
||||
"reasoning_tokens": 2310,
|
||||
"generation_secs": 412
|
||||
},
|
||||
"turn_wall": { "lt_5s": 3, "5_30s": 8, "30_120s": 2, "gte_120s": 0 }
|
||||
"turn_wall": { "lt_5s": 3, "5_30s": 8, "30_120s": 2, "gte_120s": 0 },
|
||||
"context": { "lt_32k": 9, "32k_128k": 11, "128k_256k": 5, "gte_256k": 2 }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -458,6 +479,25 @@ cache reads. Turn counts are by outcome, and `turn_wall` buckets each turn's
|
||||
wall time as `session_end` does. A session reports at most 16 provider and model
|
||||
pairs; later pairs are counted under `other`. No per-turn timeline is sent.
|
||||
|
||||
Schema 6 adds four aggregates to each row:
|
||||
|
||||
- `requests` counts the model requests this session made on its own route for
|
||||
that provider and model.
|
||||
- `reasoning_tokens` is the part of `output_tokens` the provider reports as
|
||||
reasoning. `output_tokens` is not reduced, and the value is `0` when the
|
||||
provider does not report it.
|
||||
- `generation_secs` is the total whole seconds the session spent waiting on the
|
||||
model for those requests. It is one sum for the session; no per-request
|
||||
duration is ever sent.
|
||||
- `context` counts requests by input size, in tokens including cache reads:
|
||||
`lt_32k` is under 32768, `32k_128k` is 32768 up to 131072, `128k_256k` is
|
||||
131072 up to 262144, and `gte_256k` is 262144 or more. The four buckets add up
|
||||
to `requests`. Each edge belongs to the higher bucket. No per-request size is
|
||||
sent.
|
||||
|
||||
Until a client fills them these values are `0`. A schema-4 row carries none of
|
||||
these fields.
|
||||
|
||||
### Event: `tool_usage`
|
||||
|
||||
One event per tool a session called, sent with `session_end`. It is how
|
||||
@@ -508,7 +548,7 @@ configured ingest may forward validated schema-v3 / notice-v5 batches and
|
||||
legacy schema-v2 / consent-v4 batches to
|
||||
PostHog's [batch capture API](https://posthog.com/docs/api/capture). The only
|
||||
permitted origins are `https://us.i.posthog.com` and `https://eu.i.posthog.com`.
|
||||
Schema-v1 and schema-v4 batches cannot reach that path in this source. PostHog receives the same bounded
|
||||
Schema-v1, schema-v4 and schema-v6 batches cannot reach that path in this source. PostHog receives the same bounded
|
||||
envelope and event fields, the batch timestamp as its event timestamp, and
|
||||
`codewhale:<install_id>` as its anonymous `distinct_id`. Events are named
|
||||
`codewhale_install_or_upgrade`, `codewhale_session_start`,
|
||||
@@ -558,7 +598,7 @@ It prints the daily series, a 7-day trend over complete UTC days, the freshness
|
||||
|
||||
### What is never collected — the public red-line list
|
||||
|
||||
Prompts; completions; tool arguments; diffs; patches; file contents; filenames; absolute or relative paths; git remotes; repo names; branch names; workspace commit SHAs; memory entries; chat history; API keys, tokens, cookies, or `Authorization` headers (including any boolean asserting a key exists); model ids that are not in Codewhale's bundled public model catalog (custom, local, fine-tuned or private model names are sent only as `other`); custom provider table names; MCP server names, commands, URLs, or tool names; plugin and configured tool names; approval rule text; error message bodies; panic message text; per-event timestamps; keystrokes; clipboard; screenshots; microphone; camera; location; and any third-party ad or analytics SDK — there are none in the runtime binary and none may be added.
|
||||
Prompts; completions; tool arguments; diffs; patches; file contents; filenames; absolute or relative paths; git remotes; repo names; branch names; workspace commit SHAs; memory entries; chat history; API keys, tokens, cookies, or `Authorization` headers (including any boolean asserting a key exists); model ids that are not in Codewhale's bundled public model catalog (custom, local, fine-tuned or private model names are sent only as `other`); custom provider table names; MCP server names, commands, URLs, or tool names; plugin and configured tool names; approval rule text; error message bodies; panic message text; per-event timestamps; per-request durations or sizes; environment variable values; CI vendor or job names; parent process names; hostnames; container ids; the date an installation was first seen (only the install-age bucket leaves the device); keystrokes; clipboard; screenshots; microphone; camera; location; and any third-party ad or analytics SDK — there are none in the runtime binary and none may be added.
|
||||
|
||||
Two named traps for the implementer. `crates/state/src/lib.rs` persists `git_sha`, `git_branch`, `git_origin_url`, `cwd`, and `path` on the threads table (`:93, :399, :653`): a payload builder that accepts a `Thread` or `ThreadMeta` and derives `Serialize` breaches the contract in one line. **Never derive `Serialize` over an existing state type** — build every telemetry struct from scratch with explicit fields. And `crates/core/src/lib.rs:1389-1398` is the one place in the tree where the word `telemetry` sits inside a JSON object next to `prompt`, `base_url`, and `has_api_key`. It is the object someone will copy. Do not.
|
||||
|
||||
@@ -567,7 +607,7 @@ Two named traps for the implementer. `crates/state/src/lib.rs` persists `git_sha
|
||||
## Website funnel aggregates in the source candidate
|
||||
|
||||
The website extension uses `FUNNEL_SCHEMA_VERSION = 5` and notice version `7`.
|
||||
The Rust runtime remains on `SCHEMA_VERSION = 4`, notice `6`; existing browser
|
||||
The Rust runtime is on `SCHEMA_VERSION = 6`, notice `8`; existing browser
|
||||
schema `3`, notice `5`, and legacy schemas `1` and `2` retain their original
|
||||
contracts. Displaying a newer notice never overrides an opt-out or establishes
|
||||
human acceptance. Schema 5 is first-party only and does not activate PostHog.
|
||||
|
||||
+1
-1
@@ -319,7 +319,7 @@ owner = "crates/engine/src/tools/review_host.rs"
|
||||
[[feature]]
|
||||
id = "modes"
|
||||
name = "Modes"
|
||||
summary = "Plan and Work modes on the Shift+Tab dial; a conversation is Standard or Creator mode, fixed after its first message."
|
||||
summary = "Plan and Work modes; Plan is a stop on the Shift+Tab dial, Operate parses as Work; a finished plan offers work."
|
||||
status = "stable"
|
||||
surfaces = { tui = "stable", api = "stable" }
|
||||
docs = "docs/MODES.md"
|
||||
|
||||
@@ -17,7 +17,7 @@ Workflow 通过同一个子智能体运行时执行命名步骤;Fleet 管理
|
||||
|
||||
## TUI 模式
|
||||
|
||||
一个拨盘决定 Codewhale 可以不经询问做多少事。按 `Shift+Tab` 逐档切换:**Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**。Plan 档就是 Plan 模式;其余每一档都是带该权限姿态的 Work。页脚显示当前档位和 `Shift+Tab to change`。未保存权限姿态时,新安装从 Accept Edits 开始。显式保存的选择(包括旧版本中的 Ask)在升级和重启后保持不变。`Tab` 只补全输入框(composer)菜单、命令、文件提及和建议;它不会切换模式,也从不发送或排队输入框中的文本(用 `Enter`)。每次有效权限变化都会在对话记录中写入一行 `Permissions changed to …`。按 `Ctrl+T` 循环切换思考强度。运行 `/mode` 打开模式选择器,或直接用 `/mode work`、`/mode plan` 切换。`operate` 及其写法仍按 Work 解析,并说明 Operate 已退役,所以旧的设置或脚本不会报错。选择器分为两组:**对话模式**列出引擎提供的标准模式与创作者模式(暂不可用的模式会列出并置灰,同时给出原因),**权限**包含 Work 与 Plan(仍为 `1`、`2`)。对话的模式在第一条消息之前选定,之后固定:空白对话可用 `/mode creator` 或选择器就地更改;发送第一条消息后选择器锁定该组,`/mode creator` 会被拒绝并提示开启新对话。`/new` 开始标准对话,`/new creator` 开始创作者模式对话(仍可配合 `--force`)。恢复、`/load`、`/fork` 和 `/clear` 保留已保存的对话模式;非标准模式的名称会显示在页脚。模式不会改变任何权限。`codewhale` 与 `codewhale exec` 接受 `--conversation-mode <MODE>`;与 `--resume`、`--session-id` 或 `--continue` 同用时必须与已保存的模式一致,否则在任何模型调用之前以退出码 2 结束。
|
||||
一个拨盘决定 Codewhale 可以不经询问做多少事。按 `Shift+Tab` 逐档切换:**Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**。Plan 档就是 Plan 模式;其余每一档都是带该权限姿态的 Work。页脚显示当前档位和 `Shift+Tab to change`。未保存权限姿态时,新安装从 Accept Edits 开始。显式保存的选择(包括旧版本中的 Ask)在升级和重启后保持不变。`Tab` 只补全输入框(composer)菜单、命令、文件提及和建议;它不会切换模式,也从不发送或排队输入框中的文本(用 `Enter`)。每次有效权限变化都会在对话记录中写入一行 `Permissions changed to …`。按 `Ctrl+T` 循环切换思考强度。运行 `/mode` 打开模式选择器,或直接用 `/mode work`、`/mode plan` 切换。`operate` 及其写法仍按 Work 解析,并说明 Operate 已退役,所以旧的设置或脚本不会报错。
|
||||
|
||||
- **Plan**:设计优先的提示方式。原语(primitive)名称保持不变,仍是大家熟悉的那些,但运行时会集中拒绝文件修改和 shell 执行。只读检查与策略允许的研究(包括延迟加载的 Web 搜索/抓取)仍然可用。当一个 Plan 回合成功结束并给出非空回复(包括纯文字计划)时,TUI 会询问如何继续:Work(Ask)、Work(Accept Edits)、Work(Auto-Review),或继续规划。选择 Work 会把已批准的原文作为普通后续消息发送,并保留该回合已有的 To-dos,或追加一个待办的计划项而不替换无关的工作。
|
||||
- **Work**(内部为 `agent`):普通的多步执行。第一回合的工具箱包含 `read`、`write`、`edit`、`file_search`、`grep_files`、`bash`、`agent`、`todo_write`、`present` 和 `load_skill`,以及无需先搜索即可使用的目标与后台 shell 控制工具 `create_goal`、`get_goal`、`update_goal` 和 `task_shell_wait`。当请求属于一个持久的目标时,智能体会调用 `create_goal`;宿主从不根据措辞去推断目标,契约也不得告诉模型相反的内容。审批、沙箱、仓库规则(repository law)和托管策略决定什么可以执行。
|
||||
|
||||
@@ -15,9 +15,9 @@ hostname is the only way in.
|
||||
|
||||
The current Engine source defaults usage analytics on under notice version 6, with a
|
||||
clear opt-out. Prior explicit declines remain off; a missing acceptance record
|
||||
is not treated as consent. Schema 4 carries `notice_version: 6`; schema 3
|
||||
is not treated as consent. Schema 6 carries `notice_version: 8` and schema 4 carries `notice_version: 6`; schema 3
|
||||
uses notice 5, and legacy schema 2 retains its original explicit
|
||||
`consent_version: 4` contract. Schema 1 and schema 4 batches are first-party
|
||||
`consent_version: 4` contract. Schema 1, 4 and 6 batches are first-party
|
||||
only in this source. Local validation is not proof this updated Worker has been deployed. A user who wants
|
||||
to contact nobody sets `telemetry_endpoint = ""`, which writes batches to
|
||||
`$CODEWHALE_HOME/telemetry/dryrun.jsonl` instead.
|
||||
@@ -38,7 +38,7 @@ require separate operator approval; none is established by the local tests.
|
||||
|
||||
Validated schema 3 batches carrying `notice_version: 5` and legacy schema 2
|
||||
batches carrying explicit `consent_version: 4` can reach the processor. Schema 1
|
||||
and schema 4 batches remain first-party only. Capture uses
|
||||
and schema 4 and 6 batches remain first-party only. Capture uses
|
||||
`/batch/`, fixed anonymous/no-geo controls, no incoming headers, no cookies,
|
||||
no redirects, no response parsing, no retries, and a 1.5-second timeout.
|
||||
PostHog failure is isolated from the successful Analytics Engine write.
|
||||
@@ -143,20 +143,21 @@ take the next free slot.
|
||||
| `blob8` | `tty` — `'true'` \| `'false'` |
|
||||
| `blob9` | `install_kind` (`install_or_upgrade` only) |
|
||||
| `blob10` | `previous_version` (`install_or_upgrade` only) |
|
||||
| `blob11` | `session_source` (`session_start` only) |
|
||||
| `blob12` | `duration_bucket` (`session_end` only) |
|
||||
| `blob13` | `exit_class` (`session_end` only) |
|
||||
| `blob11` | `session_source` (`session_start` only; schema 6 adds `fleet_worker`, `agent`, `scripted`) |
|
||||
| `blob12` | `duration_bucket` (`session_end`); `install_age` (`session_start`, schema 6 only: `new_today`, `under_week`, `older`, `''` for null or older schemas) |
|
||||
| `blob13` | `exit_class` (`session_end` only; schema 6 adds `unclean`) |
|
||||
| `blob14` | `cold_start_bucket` (`session_end` only; `''` on surfaces that do not measure it) |
|
||||
| `blob15` | `providers`, comma-joined, already sorted and deduplicated; the one `provider` on `model_usage` |
|
||||
| `blob16` | `panic_site` (`panic`) — a `crates/…` path or the literal `<dep>`; `model` (`model_usage`); `tool` (`tool_usage`) |
|
||||
| `blob17` | `sent_at` — the *batch* timestamp. Events carry none. |
|
||||
| `blob18` | `aggregate_counters` — closed JSON counts, for `product_usage` or `operations_summary` |
|
||||
| `blob19` | `schema_version` |
|
||||
| `blob20` | `privacy_version`: notice6 for v4, notice5 for v3, consent4 for v2, empty for v1; `blob19` keeps their meanings distinct |
|
||||
| `blob20` | `privacy_version`: notice8 for v6, notice7 for the v5 website funnel, notice6 for v4, notice5 for v3, consent4 for v2, empty for v1; `blob19` keeps their meanings distinct |
|
||||
| `double1..10` | `counters`: `turns`, `tool_calls`, `fleet_dispatch`, `workflow_run`, `subagent_spawn`, `mcp_server_connected`, `memory_search`, `approval_modal_shown`, `approval_auto_allowed`, `command_palette_open` |
|
||||
| `double11..16` | `errors`: `auth_preflight_failed`, `provider_http_4xx`, `provider_http_5xx`, `tool_denied_by_policy`, `tool_timeout`, `network_error` |
|
||||
| `double17..20` | `turn_wall`: `lt_5s`, `5_30s`, `30_120s`, `gte_120s` |
|
||||
| `double1..11` on `model_usage` | `turns_ok`, `turns_failed`, `turns_interrupted`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, then `turn_wall` `lt_5s`, `5_30s`, `30_120s`, `gte_120s` |
|
||||
| `double12..18` on `model_usage` | schema 6 only, `0` on v4 rows (`blob19` tells them apart): `requests`, `reasoning_tokens`, `generation_secs` (whole seconds, session sum, u32), then input-size request counts `lt_32k`, `32k_128k`, `128k_256k`, `gte_256k` |
|
||||
| `double1..2` on `tool_usage` | `calls`, `errors` |
|
||||
|
||||
Columns not relevant to an event are `''` / `0`. `tty` is a blob because the 20
|
||||
|
||||
@@ -13,7 +13,7 @@ export const CAVEATS = [
|
||||
"Runtime install means first initialized enabled telemetry state, not all installations or new people. IDs rotate every 90 days and are removed on opt-out.",
|
||||
"Native success is observed model turns_ok, not a first-ever primary or useful task. Missing usage, same-process child activity and late cancellation can affect it.",
|
||||
"Dates are received batch dates rather than exact event times. Offline, opted-out and dropped deliveries are absent; ingest has no replay deduplication guarantee.",
|
||||
"Native IDs with an observed CI start are excluded in full because the aggregate schema cannot assign model counts to individual sessions.",
|
||||
"Native IDs with an observed CI, fleet worker, agent or scripted start are excluded in full because the aggregate schema cannot assign model counts to individual sessions.",
|
||||
];
|
||||
|
||||
function instant(value, field) {
|
||||
@@ -72,7 +72,7 @@ export function buildFunnelReport(input, { now = new Date() } = {}) {
|
||||
const installs = new Map(); const successes = new Map(); const started = new Set(); const ci = new Set();
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
for (const row of input.native_rows) {
|
||||
if (!uuid.test(row.install_id) || !NATIVE_SURFACES.includes(row.surface) || ![1, 2, 3, 4].includes(count(row.schema_version, "schema_version"))) throw new Error("invalid native identity, surface or version");
|
||||
if (!uuid.test(row.install_id) || !NATIVE_SURFACES.includes(row.surface) || ![1, 2, 3, 4, 6].includes(count(row.schema_version, "schema_version"))) throw new Error("invalid native identity, surface or version");
|
||||
const at = instant(fresh(row.received_at), "received_at");
|
||||
if (!within(at, from, to)) throw new Error("native row outside coverage");
|
||||
if (newest === null || at > newest) newest = at;
|
||||
@@ -82,11 +82,11 @@ export function buildFunnelReport(input, { now = new Date() } = {}) {
|
||||
if (!["install", "upgrade", "downgrade"].includes(row.install_kind)) throw new Error("invalid install kind");
|
||||
if (row.install_kind === "install" && (!installs.has(row.install_id) || at < installs.get(row.install_id))) installs.set(row.install_id, at);
|
||||
} else if (row.event === "session_start") {
|
||||
if (!["interactive", "resume", "fork", "api", "unknown", "ci"].includes(row.session_source)) throw new Error("invalid session source");
|
||||
if (row.session_source === "ci") ci.add(row.install_id);
|
||||
if (!["interactive", "resume", "fork", "api", "unknown", "ci", "fleet_worker", "agent", "scripted"].includes(row.session_source)) throw new Error("invalid session source");
|
||||
if (["ci", "fleet_worker", "agent", "scripted"].includes(row.session_source)) ci.add(row.install_id);
|
||||
started.add(row.install_id);
|
||||
} else if (row.event === "model_usage") {
|
||||
if (count(row.schema_version, "schema_version") !== 4) throw new Error("model usage requires v4");
|
||||
if (![4, 6].includes(count(row.schema_version, "schema_version"))) throw new Error("model usage requires v4 or v6");
|
||||
if (count(row.turns_ok, "turns_ok") > 0) {
|
||||
const observations = successes.get(row.install_id) ?? []; observations.push(at); successes.set(row.install_id, observations);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,14 @@
|
||||
*/
|
||||
|
||||
import type { Batch, Event } from "./schema";
|
||||
import { COUNTER_FIELDS, ERROR_FIELDS, MODEL_COUNTER_FIELDS, TURN_WALL_FIELDS } from "./schema";
|
||||
import {
|
||||
CONTEXT_FIELDS,
|
||||
COUNTER_FIELDS,
|
||||
ERROR_FIELDS,
|
||||
MODEL_COUNTER_FIELDS,
|
||||
MODEL_COUNTER_FIELDS_V6_EXTRA,
|
||||
TURN_WALL_FIELDS,
|
||||
} from "./schema";
|
||||
|
||||
/** The subset of `AnalyticsEngineDataset` this Worker uses. */
|
||||
export interface DataPointSink {
|
||||
@@ -58,7 +65,7 @@ export const BLOB_COLUMNS = [
|
||||
"install_kind", // blob9
|
||||
"previous_version", // blob10
|
||||
"session_source", // blob11
|
||||
"duration_bucket", // blob12
|
||||
"duration_bucket", // blob12 session_end; install_age on session_start (schema 6)
|
||||
"exit_class", // blob13
|
||||
"cold_start_bucket", // blob14
|
||||
"providers", // blob15 comma-joined, already sorted and deduplicated
|
||||
@@ -66,7 +73,7 @@ export const BLOB_COLUMNS = [
|
||||
"sent_at", // blob17 the batch timestamp; events carry none
|
||||
"aggregate_counters", // blob18 closed product/operations JSON count object
|
||||
"schema_version", // blob19
|
||||
"privacy_version", // blob20: v3/v4 notice, v2 consent, empty on v1; blob19 disambiguates
|
||||
"privacy_version", // blob20: v3 to v6 notice, v2 consent, empty on v1; blob19 disambiguates
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -111,6 +118,7 @@ function toDataPoint(batch: Batch, event: Event): DataPoint {
|
||||
break;
|
||||
case "session_start":
|
||||
blobs[10] = event.source;
|
||||
blobs[11] = event.install_age ?? "";
|
||||
break;
|
||||
case "session_end":
|
||||
blobs[11] = event.duration_bucket;
|
||||
@@ -141,8 +149,10 @@ function toDataPoint(batch: Batch, event: Event): DataPoint {
|
||||
doubles = [
|
||||
...MODEL_COUNTER_FIELDS.map((field) => event.counters[field]),
|
||||
...TURN_WALL_FIELDS.map((field) => event.turn_wall[field]),
|
||||
...EMPTY_DOUBLES.slice(MODEL_COUNTER_FIELDS.length + TURN_WALL_FIELDS.length),
|
||||
...MODEL_COUNTER_FIELDS_V6_EXTRA.map((field) => event.counters[field] ?? 0),
|
||||
...CONTEXT_FIELDS.map((field) => event.context?.[field] ?? 0),
|
||||
];
|
||||
doubles = [...doubles, ...EMPTY_DOUBLES.slice(doubles.length)];
|
||||
break;
|
||||
case "tool_usage":
|
||||
blobs[15] = event.tool;
|
||||
|
||||
@@ -22,6 +22,8 @@ export const SCHEMA_VERSION = 4;
|
||||
/** Notice-v5 batches without `model_usage`, `tool_usage` or the `ci` source. */
|
||||
export const V3_SCHEMA_VERSION = 3;
|
||||
|
||||
export const V6_SCHEMA_VERSION = 6;
|
||||
|
||||
export const FUNNEL_SCHEMA_VERSION = 5;
|
||||
export const FUNNEL_NOTICE_VERSION = 7;
|
||||
export const FUNNEL_COUNTER_FIELDS = [
|
||||
@@ -34,6 +36,8 @@ export const FUNNEL_COUNTER_FIELDS = [
|
||||
/** Disclosed default-on policy, not a record of human acceptance. */
|
||||
export const NOTICE_VERSION = 6;
|
||||
|
||||
export const V6_NOTICE_VERSION = 8;
|
||||
|
||||
/** The notice version schema v3 batches carry. */
|
||||
export const V3_NOTICE_VERSION = 5;
|
||||
|
||||
@@ -146,6 +150,11 @@ export const SESSION_SOURCES = [
|
||||
"ci",
|
||||
] as const;
|
||||
|
||||
export const V6_ONLY_SESSION_SOURCES = ["fleet_worker", "agent", "scripted"] as const;
|
||||
export const V6_SESSION_SOURCES = [...SESSION_SOURCES, ...V6_ONLY_SESSION_SOURCES] as const;
|
||||
|
||||
export const INSTALL_AGES = ["new_today", "under_week", "older"] as const;
|
||||
|
||||
/** `DurationBucket` wire spellings. */
|
||||
export const DURATION_BUCKETS = [
|
||||
"lt_1m",
|
||||
@@ -157,6 +166,8 @@ export const DURATION_BUCKETS = [
|
||||
/** `ExitClass::as_str`. */
|
||||
export const EXIT_CLASSES = ["clean", "signal", "panic", "error"] as const;
|
||||
|
||||
export const V6_EXIT_CLASSES = [...EXIT_CLASSES, "unclean"] as const;
|
||||
|
||||
/** `ColdStartBucket` wire spellings. */
|
||||
export const COLD_START_BUCKETS = [
|
||||
"lt_250",
|
||||
@@ -211,6 +222,19 @@ export const MODEL_COUNTER_FIELDS = [
|
||||
"cache_write_tokens",
|
||||
] as const;
|
||||
|
||||
export const MODEL_COUNTER_FIELDS_V6_EXTRA = [
|
||||
"requests",
|
||||
"reasoning_tokens",
|
||||
"generation_secs",
|
||||
] as const;
|
||||
|
||||
export const V6_MODEL_COUNTER_FIELDS = [
|
||||
...MODEL_COUNTER_FIELDS,
|
||||
...MODEL_COUNTER_FIELDS_V6_EXTRA,
|
||||
] as const;
|
||||
|
||||
export const CONTEXT_FIELDS = ["lt_32k", "32k_128k", "128k_256k", "gte_256k"] as const;
|
||||
|
||||
/** `event::TOOL_NAMES` plus the `mcp` and `other` buckets. */
|
||||
export const TOOL_NAMES = [
|
||||
"agent", "apply_patch", "authenticate", "bash", "code_execution", "create_goal",
|
||||
@@ -286,6 +310,12 @@ export const EVENT_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
funnel_usage: ["event", "counters"],
|
||||
};
|
||||
|
||||
export const V6_EVENT_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
...EVENT_FIELDS,
|
||||
session_start: ["event", "source", "install_age"],
|
||||
model_usage: ["event", "provider", "model", "counters", "turn_wall", "context"],
|
||||
};
|
||||
|
||||
/** Every event discriminant, for the doc-match test. */
|
||||
export const EVENT_NAMES = Object.keys(EVENT_FIELDS);
|
||||
|
||||
@@ -430,7 +460,7 @@ export type FunnelCounters = Record<(typeof FUNNEL_COUNTER_FIELDS)[number], numb
|
||||
|
||||
export type Event =
|
||||
| { event: "install_or_upgrade"; kind: string; previous_version: string | null }
|
||||
| { event: "session_start"; source: string }
|
||||
| { event: "session_start"; source: string; install_age?: string | null }
|
||||
| {
|
||||
event: "session_end";
|
||||
duration_bucket: string;
|
||||
@@ -448,8 +478,10 @@ export type Event =
|
||||
event: "model_usage";
|
||||
provider: string;
|
||||
model: string;
|
||||
counters: Record<(typeof MODEL_COUNTER_FIELDS)[number], number>;
|
||||
counters: Record<(typeof MODEL_COUNTER_FIELDS)[number], number> &
|
||||
Partial<Record<(typeof MODEL_COUNTER_FIELDS_V6_EXTRA)[number], number>>;
|
||||
turn_wall: TurnWall;
|
||||
context?: Record<(typeof CONTEXT_FIELDS)[number], number>;
|
||||
}
|
||||
| { event: "tool_usage"; tool: string; calls: number; errors: number }
|
||||
| ({ event: "operations_summary" } & Record<(typeof OPERATIONS_FIELDS)[number], number>);
|
||||
@@ -546,13 +578,28 @@ function u32Map(
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateEvent(value: unknown, where: string): string | null {
|
||||
function u32Fields(
|
||||
value: Record<string, unknown>,
|
||||
fields: readonly string[],
|
||||
where: string,
|
||||
): string | null {
|
||||
for (const field of fields) {
|
||||
const item = value[field];
|
||||
if (typeof item !== "number" || !Number.isInteger(item) || item < 0 || item > U32_MAX) {
|
||||
return `${where}.${field}: not a u32`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateEvent(value: unknown, where: string, v6 = false): string | null {
|
||||
if (!isPlainObject(value)) return `${where}: not an object`;
|
||||
const name = value.event;
|
||||
if (typeof name !== "string" || !Object.hasOwn(EVENT_FIELDS, name)) {
|
||||
const eventFields = v6 ? V6_EVENT_FIELDS : EVENT_FIELDS;
|
||||
if (typeof name !== "string" || !Object.hasOwn(eventFields, name)) {
|
||||
return `${where}: unknown event discriminant`;
|
||||
}
|
||||
const keyError = keysExactly(value, EVENT_FIELDS[name], `${where}(${name})`);
|
||||
const keyError = keysExactly(value, eventFields[name], `${where}(${name})`);
|
||||
if (keyError) return keyError;
|
||||
|
||||
switch (name) {
|
||||
@@ -575,8 +622,15 @@ function validateEvent(value: unknown, where: string): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "session_start":
|
||||
return enumString(value.source, SESSION_SOURCES, `${where}.source`);
|
||||
case "session_start": {
|
||||
const sourceError = enumString(
|
||||
value.source,
|
||||
v6 ? V6_SESSION_SOURCES : SESSION_SOURCES,
|
||||
`${where}.source`,
|
||||
);
|
||||
if (sourceError || !v6 || value.install_age === null) return sourceError;
|
||||
return enumString(value.install_age, INSTALL_AGES, `${where}.install_age`);
|
||||
}
|
||||
case "session_end": {
|
||||
const bucketError = enumString(
|
||||
value.duration_bucket,
|
||||
@@ -586,7 +640,7 @@ function validateEvent(value: unknown, where: string): string | null {
|
||||
if (bucketError) return bucketError;
|
||||
const exitError = enumString(
|
||||
value.exit_class,
|
||||
EXIT_CLASSES,
|
||||
v6 ? V6_EXIT_CLASSES : EXIT_CLASSES,
|
||||
`${where}.exit_class`,
|
||||
);
|
||||
if (exitError) return exitError;
|
||||
@@ -638,9 +692,14 @@ function validateEvent(value: unknown, where: string): string | null {
|
||||
if (typeof value.model !== "string" || !MODEL_RE.test(value.model)) {
|
||||
return `${where}.model: not a catalogued model id`;
|
||||
}
|
||||
const counterError = v6
|
||||
? countMap(value.counters, V6_MODEL_COUNTER_FIELDS, `${where}.counters`, Number.MAX_SAFE_INTEGER) ??
|
||||
u32Fields(value.counters as Record<string, unknown>, ["requests", "generation_secs"], `${where}.counters`)
|
||||
: countMap(value.counters, MODEL_COUNTER_FIELDS, `${where}.counters`, Number.MAX_SAFE_INTEGER);
|
||||
return (
|
||||
countMap(value.counters, MODEL_COUNTER_FIELDS, `${where}.counters`, Number.MAX_SAFE_INTEGER) ??
|
||||
u32Map(value.turn_wall, TURN_WALL_FIELDS, `${where}.turn_wall`)
|
||||
counterError ??
|
||||
u32Map(value.turn_wall, TURN_WALL_FIELDS, `${where}.turn_wall`) ??
|
||||
(v6 ? u32Map(value.context, CONTEXT_FIELDS, `${where}.context`) : null)
|
||||
);
|
||||
}
|
||||
case "tool_usage":
|
||||
@@ -675,15 +734,12 @@ export function validateBatch(value: unknown): Accepted | Rejection {
|
||||
|
||||
const legacy = value.schema_version === 1;
|
||||
const explicitConsent = value.schema_version === 2;
|
||||
const v3 = value.schema_version === V3_SCHEMA_VERSION;
|
||||
const v4 = value.schema_version === SCHEMA_VERSION;
|
||||
const funnel = value.schema_version === FUNNEL_SCHEMA_VERSION;
|
||||
const preUsage = value.schema_version !== SCHEMA_VERSION && !funnel;
|
||||
if (
|
||||
!legacy &&
|
||||
!explicitConsent &&
|
||||
value.schema_version !== SCHEMA_VERSION &&
|
||||
!funnel &&
|
||||
value.schema_version !== V3_SCHEMA_VERSION
|
||||
) {
|
||||
const v6 = value.schema_version === V6_SCHEMA_VERSION;
|
||||
const preUsage = legacy || explicitConsent || v3;
|
||||
if (!legacy && !explicitConsent && !v3 && !v4 && !funnel && !v6) {
|
||||
return { ok: false, reason: "batch.schema_version: unsupported" };
|
||||
}
|
||||
const fields = legacy ? LEGACY_ENVELOPE_FIELDS : explicitConsent ? V2_ENVELOPE_FIELDS : ENVELOPE_FIELDS;
|
||||
@@ -695,7 +751,7 @@ export function validateBatch(value: unknown): Accepted | Rejection {
|
||||
if (
|
||||
!legacy &&
|
||||
!explicitConsent &&
|
||||
value.notice_version !== (funnel ? FUNNEL_NOTICE_VERSION : preUsage ? V3_NOTICE_VERSION : NOTICE_VERSION)
|
||||
value.notice_version !== (funnel ? FUNNEL_NOTICE_VERSION : v6 ? V6_NOTICE_VERSION : preUsage ? V3_NOTICE_VERSION : NOTICE_VERSION)
|
||||
) {
|
||||
return { ok: false, reason: "batch.notice_version: current policy required" };
|
||||
}
|
||||
@@ -753,7 +809,7 @@ export function validateBatch(value: unknown): Accepted | Rejection {
|
||||
return { ok: false, reason: "events: aggregate product/operations events require schema v2 or v3" };
|
||||
}
|
||||
if (preUsage && (event.event === "model_usage" || event.event === "tool_usage")) {
|
||||
return { ok: false, reason: "events: model_usage and tool_usage require schema v4" };
|
||||
return { ok: false, reason: "events: model_usage and tool_usage require schema v4 or later" };
|
||||
}
|
||||
if (preUsage && event.event === "session_start" && event.source === "ci") {
|
||||
return { ok: false, reason: "events: the ci session source requires schema v4" };
|
||||
@@ -762,7 +818,7 @@ export function validateBatch(value: unknown): Accepted | Rejection {
|
||||
return { ok: false, reason: "events: operations_summary requires control-plane surface" };
|
||||
}
|
||||
}
|
||||
const eventError = validateEvent(event, `events[${index}]`);
|
||||
const eventError = validateEvent(event, `events[${index}]`, v6);
|
||||
if (eventError) return { ok: false, reason: eventError };
|
||||
}
|
||||
|
||||
|
||||
@@ -97,12 +97,12 @@ describe("additive website funnel contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps all four historical goldens accepted and runtime v4 first-party only", async () => {
|
||||
it("keeps the historical goldens and v6 accepted, with runtime v4 and v6 first-party only", async () => {
|
||||
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
|
||||
for (const version of [1, 2, 3, 4]) {
|
||||
for (const version of [1, 2, 3, 4, 6]) {
|
||||
const batch = JSON.parse(readFileSync(new URL(`../../crates/telemetry/tests/golden/v${version}.json`, import.meta.url), "utf8"));
|
||||
expect(validateBatch(batch).ok, `v${version}`).toBe(true);
|
||||
if (version !== 4) continue;
|
||||
if (version !== 4 && version !== 6) continue;
|
||||
const { env, written } = harness();
|
||||
expect((await worker.fetch(postJson(batch), { ...env, POSTHOG_HOST: "https://us.i.posthog.com", POSTHOG_PROJECT_TOKEN: "phc_local_test_fixture", POSTHOG_IP_SAFE_EGRESS_VERIFIED: "true" })).status).toBe(204);
|
||||
expect(written).toHaveLength(batch.events.length);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
import worker from "../src/index";
|
||||
import { INGEST_PATH } from "../src/route";
|
||||
import { BLOB_COLUMNS, DOUBLE_COLUMNS } from "../src/datapoint";
|
||||
import { MAX_BODY_BYTES, SURFACES } from "../src/schema";
|
||||
import { MAX_BODY_BYTES, SURFACES, validateBatch } from "../src/schema";
|
||||
import { goldenBatch, harness, post, postJson } from "./support";
|
||||
|
||||
describe("method and route", () => {
|
||||
@@ -473,3 +473,149 @@ describe("failing closed and quiet", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema 6", () => {
|
||||
const golden = (version: number): Record<string, any> =>
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
new URL(`../../crates/telemetry/tests/golden/v${version}.json`, import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const event = (batch: Record<string, any>, name: string) =>
|
||||
batch.events.find((item: Record<string, any>) => item.event === name);
|
||||
|
||||
it("accepts every historical golden and the v6 golden", () => {
|
||||
for (const version of [1, 2, 3, 4, 6]) {
|
||||
expect(validateBatch(golden(version)).ok, `v${version}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("answers 204 to the v4 and v6 goldens", async () => {
|
||||
for (const version of [4, 6]) {
|
||||
const { env, written } = harness();
|
||||
expect((await worker.fetch(postJson(golden(version)), env)).status).toBe(204);
|
||||
expect(written).toHaveLength(golden(version).events.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires notice 8 on v6 and refuses the v4 notice", () => {
|
||||
const batch = golden(6);
|
||||
batch.notice_version = 6;
|
||||
expect(validateBatch(batch).ok).toBe(false);
|
||||
expect(validateBatch({ ...golden(4), notice_version: 8 }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a v4 batch carrying any v6 key or value", () => {
|
||||
const mutations: Array<(batch: Record<string, any>) => void> = [
|
||||
(batch) => { event(batch, "model_usage").context = event(golden(6), "model_usage").context; },
|
||||
(batch) => { event(batch, "model_usage").counters.requests = 1; },
|
||||
(batch) => { event(batch, "model_usage").counters.reasoning_tokens = 1; },
|
||||
(batch) => { event(batch, "model_usage").counters.generation_secs = 1; },
|
||||
(batch) => { event(batch, "session_start").install_age = "older"; },
|
||||
(batch) => { event(batch, "session_start").source = "fleet_worker"; },
|
||||
(batch) => { event(batch, "session_start").source = "agent"; },
|
||||
(batch) => { event(batch, "session_start").source = "scripted"; },
|
||||
(batch) => { event(batch, "session_end").exit_class = "unclean"; },
|
||||
];
|
||||
for (const [index, mutate] of mutations.entries()) {
|
||||
const batch = golden(4);
|
||||
mutate(batch);
|
||||
expect(validateBatch(batch).ok, `mutation ${index}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a v6 batch missing or adding a key", () => {
|
||||
const mutations: Array<(batch: Record<string, any>) => void> = [
|
||||
(batch) => { delete event(batch, "model_usage").context; },
|
||||
(batch) => { delete event(batch, "model_usage").context["32k_128k"]; },
|
||||
(batch) => { event(batch, "model_usage").context.gte_512k = 0; },
|
||||
(batch) => { delete event(batch, "model_usage").counters.requests; },
|
||||
(batch) => { delete event(batch, "model_usage").counters.reasoning_tokens; },
|
||||
(batch) => { delete event(batch, "model_usage").counters.generation_secs; },
|
||||
(batch) => { event(batch, "model_usage").counters.generation_ms = 1; },
|
||||
(batch) => { event(batch, "model_usage").model_family = "x"; },
|
||||
(batch) => { delete event(batch, "session_start").install_age; },
|
||||
(batch) => { event(batch, "session_start").extra = "x"; },
|
||||
(batch) => { event(batch, "session_end").counters.requests = 1; },
|
||||
];
|
||||
for (const [index, mutate] of mutations.entries()) {
|
||||
const batch = golden(6);
|
||||
mutate(batch);
|
||||
expect(validateBatch(batch).ok, `mutation ${index}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses open values where v6 has a closed set or a count", () => {
|
||||
const mutations: Array<(batch: Record<string, any>) => void> = [
|
||||
(batch) => { event(batch, "session_start").install_age = "2026-11-01"; },
|
||||
(batch) => { event(batch, "session_start").install_age = "OLDER"; },
|
||||
(batch) => { event(batch, "session_start").source = "github-actions"; },
|
||||
(batch) => { event(batch, "session_end").exit_class = "killed"; },
|
||||
(batch) => { event(batch, "model_usage").counters.requests = 4294967296; },
|
||||
(batch) => { event(batch, "model_usage").counters.requests = 1.5; },
|
||||
(batch) => { event(batch, "model_usage").counters.generation_secs = -1; },
|
||||
(batch) => { event(batch, "model_usage").counters.generation_secs = 4294967296; },
|
||||
(batch) => { event(batch, "model_usage").counters.generation_secs = 1794528251123; },
|
||||
(batch) => { event(batch, "model_usage").counters.reasoning_tokens = "7"; },
|
||||
(batch) => { event(batch, "model_usage").context.lt_32k = 4294967296; },
|
||||
(batch) => { event(batch, "model_usage").context.lt_32k = "1"; },
|
||||
(batch) => { event(batch, "model_usage").model = "/Users/someone/model"; },
|
||||
];
|
||||
for (const [index, mutate] of mutations.entries()) {
|
||||
const batch = golden(6);
|
||||
mutate(batch);
|
||||
expect(validateBatch(batch).ok, `mutation ${index}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a null install_age and every v6 source and exit class", () => {
|
||||
for (const source of ["interactive", "resume", "fork", "api", "unknown", "ci", "fleet_worker", "agent", "scripted"]) {
|
||||
for (const age of ["new_today", "under_week", "older", null]) {
|
||||
const batch = golden(6);
|
||||
event(batch, "session_start").source = source;
|
||||
event(batch, "session_start").install_age = age;
|
||||
expect(validateBatch(batch).ok, `${source}/${age}`).toBe(true);
|
||||
}
|
||||
}
|
||||
for (const exit of ["clean", "signal", "panic", "error", "unclean"]) {
|
||||
const batch = golden(6);
|
||||
event(batch, "session_end").exit_class = exit;
|
||||
expect(validateBatch(batch).ok, exit).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes v6 model_usage after the unchanged eleven doubles, and leaves v4 rows zero there", async () => {
|
||||
const v6 = harness();
|
||||
await worker.fetch(postJson(golden(6)), v6.env);
|
||||
const row = v6.written.find((point) => point.blobs[0] === "model_usage")!;
|
||||
expect(row.doubles).toEqual([
|
||||
12, 1, 0, 418230, 9120, 380001, 0,
|
||||
3, 8, 2, 0,
|
||||
27, 2310, 412,
|
||||
9, 11, 5, 2,
|
||||
0, 0,
|
||||
]);
|
||||
expect(row.blobs[BLOB_COLUMNS.indexOf("schema_version")]).toBe("6");
|
||||
|
||||
const v4 = harness();
|
||||
await worker.fetch(postJson(golden(4)), v4.env);
|
||||
const old = v4.written.find((point) => point.blobs[0] === "model_usage")!;
|
||||
expect(old.doubles).toEqual([
|
||||
12, 1, 0, 418230, 9120, 380001, 0,
|
||||
3, 8, 2, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes install_age to blob12 of session_start rows only", async () => {
|
||||
const { env, written } = harness();
|
||||
await worker.fetch(postJson(golden(6)), env);
|
||||
const start = written.find((point) => point.blobs[0] === "session_start")!;
|
||||
expect(start.blobs[BLOB_COLUMNS.indexOf("session_source")]).toBe("fleet_worker");
|
||||
expect(start.blobs[BLOB_COLUMNS.indexOf("duration_bucket")]).toBe("under_week");
|
||||
const end = written.find((point) => point.blobs[0] === "session_end")!;
|
||||
expect(end.blobs[BLOB_COLUMNS.indexOf("exit_class")]).toBe("unclean");
|
||||
expect(end.blobs[BLOB_COLUMNS.indexOf("duration_bucket")]).toBe("1m_10m");
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,19 @@ describe("existing-pipeline owner funnel report", () => {
|
||||
expect(buildFunnelReport(input, { now: NOW }).native.seven_day_initialization_cohort.unavailable_reason).toContain("incomplete");
|
||||
});
|
||||
|
||||
it("excludes fleet worker, agent and scripted starts like CI, and accepts v6 model usage", () => {
|
||||
for (const source of ["fleet_worker", "agent", "scripted"]) {
|
||||
const input = receipt();
|
||||
input.native_rows.push({ ...native("session_start", "2026-10-04T00:00:00Z"), session_source: source });
|
||||
input.native_rows.push({ ...native("model_usage", "2026-10-04T00:00:00Z"), schema_version: 6 });
|
||||
const report = buildFunnelReport(input, { now: NOW });
|
||||
expect(report.native.excluded_ci_ids, source).toBe(1);
|
||||
expect(report.native.observed_completed_ids, source).toBe(0);
|
||||
}
|
||||
const input = receipt(); input.native_rows.push({ ...native("model_usage", "2026-10-04T00:00:00Z"), schema_version: 5 });
|
||||
expect(() => buildFunnelReport(input, { now: NOW })).toThrow(/version/);
|
||||
});
|
||||
|
||||
it("does not compute a cohort fraction from sampled native records", () => {
|
||||
const input = receipt(); input.native_rows[0].weight = 4;
|
||||
expect(buildFunnelReport(input, { now: NOW }).native.seven_day_initialization_cohort).toMatchObject({ fraction: null, sampled: true });
|
||||
|
||||
@@ -20,23 +20,27 @@ import {
|
||||
BATCH_MAX_BYTES,
|
||||
BATCH_MAX_EVENTS,
|
||||
COLD_START_BUCKETS,
|
||||
CONTEXT_FIELDS,
|
||||
COUNTER_FIELDS,
|
||||
DURATION_BUCKETS,
|
||||
ENVELOPE_FIELDS,
|
||||
ERROR_FIELDS,
|
||||
EVENT_FIELDS,
|
||||
EVENT_NAMES,
|
||||
EXIT_CLASSES,
|
||||
INSTALL_AGES,
|
||||
INSTALL_KINDS,
|
||||
LIBCS,
|
||||
MAX_BODY_BYTES,
|
||||
OSES,
|
||||
PRODUCT_COUNTER_FIELDS,
|
||||
OPERATIONS_FIELDS,
|
||||
SCHEMA_VERSION,
|
||||
SESSION_SOURCES,
|
||||
SURFACES,
|
||||
TURN_WALL_FIELDS,
|
||||
V6_EVENT_FIELDS as EVENT_FIELDS,
|
||||
V6_EXIT_CLASSES as EXIT_CLASSES,
|
||||
V6_MODEL_COUNTER_FIELDS,
|
||||
V6_NOTICE_VERSION,
|
||||
V6_SCHEMA_VERSION as SCHEMA_VERSION,
|
||||
V6_SESSION_SOURCES as SESSION_SOURCES,
|
||||
} from "../src/schema";
|
||||
import { DOC, goldenBatch } from "./support";
|
||||
|
||||
@@ -168,6 +172,16 @@ describe("the doc and the validator publish the same fields", () => {
|
||||
expect([...documentedKeys(BLOCKS[3])].sort()).toEqual(expected);
|
||||
});
|
||||
|
||||
it("agrees on the model_usage field set, nested objects included", () => {
|
||||
const expected = [
|
||||
...EVENT_FIELDS.model_usage,
|
||||
...V6_MODEL_COUNTER_FIELDS,
|
||||
...TURN_WALL_FIELDS,
|
||||
...CONTEXT_FIELDS,
|
||||
].sort();
|
||||
expect([...documentedKeys(BLOCKS[7])].sort()).toEqual([...new Set(expected)].sort());
|
||||
});
|
||||
|
||||
it("agrees on the panic field set", () => {
|
||||
expect([...documentedKeys(BLOCKS[4])].sort()).toEqual(
|
||||
[...EVENT_FIELDS.panic].sort(),
|
||||
@@ -216,6 +230,7 @@ describe("the doc and the validator publish the same enums", () => {
|
||||
["session source", SESSION_SOURCES],
|
||||
["duration bucket", DURATION_BUCKETS],
|
||||
["exit class", EXIT_CLASSES],
|
||||
["install age", INSTALL_AGES],
|
||||
["cold start bucket", COLD_START_BUCKETS],
|
||||
] as const)("publishes every %s value it accepts", (_label, values) => {
|
||||
for (const value of values) {
|
||||
@@ -232,6 +247,8 @@ describe("the doc and the validator publish the same limits", () => {
|
||||
expect(match).not.toBeNull();
|
||||
expect(Number((match as RegExpMatchArray)[1])).toBe(SCHEMA_VERSION);
|
||||
expect(goldenBatch().schema_version).toBe(1); // preserved legacy golden
|
||||
const notice = DOC.match(/`notice_version\s*=\s*(\d+)`/);
|
||||
expect(Number((notice as RegExpMatchArray)[1])).toBe(V6_NOTICE_VERSION);
|
||||
});
|
||||
|
||||
it("agrees on the per-batch caps", () => {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/MODES.md",
|
||||
"sha256": "014a36c17573dc58e936300e1eaddcdffc8cb616da1cc1e8b6fe4e962af810f1",
|
||||
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not on the\npermission dial.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nOne dial says how much Codewhale may do without asking. Press `Shift+Tab` to\nstep it: **Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**. Plan\nis the Plan mode; every other stop is Work with that permission posture. The\nfooter shows the current stop and `Shift+Tab to change`. New installs start on\nAccept Edits when no permission posture is saved. An explicit saved choice,\nincluding Ask from an earlier version, stays in control.\n`Tab` only completes composer menus, commands, file mentions and suggestions;\nit never changes the mode, and never sends or queues composer text (use\n`Enter`).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work` or\n`/mode plan`. `operate` and its spellings still parse, as Work, and say that\nOperate was retired, so an older setting or script never errors.\n\nThe picker has two groups. **Conversation mode** lists the modes the Engine\noffers, Standard mode and Creator mode, each with a one-line description; a\nmode that cannot be used yet is listed, disabled, with its reason. **Permissions**\nholds Work and Plan, still on `1` and `2`; the conversation modes have no\nnumber keys. A conversation's mode is chosen before its first message and is\nfixed after it: `/mode creator` or the picker's first group changes a blank\nconversation in place, and after the first message the picker shows the group\nas locked and `/mode creator` is refused with a note to start a new\nconversation. `/new` starts a Standard conversation, `/new creator` starts a\nCreator mode conversation (`--force` still combines). Resume, `/load`, `/fork`\nand `/clear` keep the conversation's saved mode, and the mode name appears in the\nfooter when it is not Standard. A mode never changes a permission: Creator\nmode in Plan is still Plan. `codewhale` and `codewhale exec` take\n`--conversation-mode <MODE>` to start a new session in a mode; with `--resume`,\n`--session-id` or `--continue` it must match the session's saved mode, or the\nrun exits with code 2 before any model call.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = \"operate\"` loads as Work and says so once; a Runtime API request with `mode: \"operate\"` runs as `agent` with tLine truncated
|
||||
"sha256": "bfd3ed8cae274f9b72d1805232f41d4342cc29b45b53617a26f06c3776941202",
|
||||
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not on the\npermission dial.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nOne dial says how much Codewhale may do without asking. Press `Shift+Tab` to\nstep it: **Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**. Plan\nis the Plan mode; every other stop is Work with that permission posture. The\nfooter shows the current stop and `Shift+Tab to change`. New installs start on\nAccept Edits when no permission posture is saved. An explicit saved choice,\nincluding Ask from an earlier version, stays in control.\n`Tab` only completes composer menus, commands, file mentions and suggestions;\nit never changes the mode, and never sends or queues composer text (use\n`Enter`).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work` or\n`/mode plan`. `operate` and its spellings still parse, as Work, and say that\nOperate was retired, so an older setting or script never errors.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = \"operate\"` loads as Work and says so once; a Runtime API request with `mode: \"operate\"` runs as `agent` with the default posture (Accept Edits) and logs one notice per process, never an error. Agents forked from an older Operate conversation do not inherit its contract. Multi-agent work stays on `agent`, `workflow` and `/fleet` in every mode.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work |\n|:---|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated |\n| `bash` | visible name; execution denied | approval- and policy-gated; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same |\n| Paid or external-service tools | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode; fleet profiles never widen it |\n\nMode changes scheduling emphasLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/SANDBOX.md",
|
||||
@@ -137,8 +137,8 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/MODES.md",
|
||||
"sha256": "014a36c17573dc58e936300e1eaddcdffc8cb616da1cc1e8b6fe4e962af810f1",
|
||||
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not on the\npermission dial.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nOne dial says how much Codewhale may do without asking. Press `Shift+Tab` to\nstep it: **Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**. Plan\nis the Plan mode; every other stop is Work with that permission posture. The\nfooter shows the current stop and `Shift+Tab to change`. New installs start on\nAccept Edits when no permission posture is saved. An explicit saved choice,\nincluding Ask from an earlier version, stays in control.\n`Tab` only completes composer menus, commands, file mentions and suggestions;\nit never changes the mode, and never sends or queues composer text (use\n`Enter`).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work` or\n`/mode plan`. `operate` and its spellings still parse, as Work, and say that\nOperate was retired, so an older setting or script never errors.\n\nThe picker has two groups. **Conversation mode** lists the modes the Engine\noffers, Standard mode and Creator mode, each with a one-line description; a\nmode that cannot be used yet is listed, disabled, with its reason. **Permissions**\nholds Work and Plan, still on `1` and `2`; the conversation modes have no\nnumber keys. A conversation's mode is chosen before its first message and is\nfixed after it: `/mode creator` or the picker's first group changes a blank\nconversation in place, and after the first message the picker shows the group\nas locked and `/mode creator` is refused with a note to start a new\nconversation. `/new` starts a Standard conversation, `/new creator` starts a\nCreator mode conversation (`--force` still combines). Resume, `/load`, `/fork`\nand `/clear` keep the conversation's saved mode, and the mode name appears in the\nfooter when it is not Standard. A mode never changes a permission: Creator\nmode in Plan is still Plan. `codewhale` and `codewhale exec` take\n`--conversation-mode <MODE>` to start a new session in a mode; with `--resume`,\n`--session-id` or `--continue` it must match the session's saved mode, or the\nrun exits with code 2 before any model call.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = \"operate\"` loads as Work and says so once; a Runtime API request with `mode: \"operate\"` runs as `agent` with tLine truncated
|
||||
"sha256": "bfd3ed8cae274f9b72d1805232f41d4342cc29b45b53617a26f06c3776941202",
|
||||
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not on the\npermission dial.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nOne dial says how much Codewhale may do without asking. Press `Shift+Tab` to\nstep it: **Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**. Plan\nis the Plan mode; every other stop is Work with that permission posture. The\nfooter shows the current stop and `Shift+Tab to change`. New installs start on\nAccept Edits when no permission posture is saved. An explicit saved choice,\nincluding Ask from an earlier version, stays in control.\n`Tab` only completes composer menus, commands, file mentions and suggestions;\nit never changes the mode, and never sends or queues composer text (use\n`Enter`).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work` or\n`/mode plan`. `operate` and its spellings still parse, as Work, and say that\nOperate was retired, so an older setting or script never errors.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = \"operate\"` loads as Work and says so once; a Runtime API request with `mode: \"operate\"` runs as `agent` with the default posture (Accept Edits) and logs one notice per process, never an error. Agents forked from an older Operate conversation do not inherit its contract. Multi-agent work stays on `agent`, `workflow` and `/fleet` in every mode.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work |\n|:---|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated |\n| `bash` | visible name; execution denied | approval- and policy-gated; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same |\n| Paid or external-service tools | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode; fleet profiles never widen it |\n\nMode changes scheduling emphasLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -201,8 +201,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/MODES.md",
|
||||
"sha256": "014a36c17573dc58e936300e1eaddcdffc8cb616da1cc1e8b6fe4e962af810f1",
|
||||
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not on the\npermission dial.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nOne dial says how much Codewhale may do without asking. Press `Shift+Tab` to\nstep it: **Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**. Plan\nis the Plan mode; every other stop is Work with that permission posture. The\nfooter shows the current stop and `Shift+Tab to change`. New installs start on\nAccept Edits when no permission posture is saved. An explicit saved choice,\nincluding Ask from an earlier version, stays in control.\n`Tab` only completes composer menus, commands, file mentions and suggestions;\nit never changes the mode, and never sends or queues composer text (use\n`Enter`).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work` or\n`/mode plan`. `operate` and its spellings still parse, as Work, and say that\nOperate was retired, so an older setting or script never errors.\n\nThe picker has two groups. **Conversation mode** lists the modes the Engine\noffers, Standard mode and Creator mode, each with a one-line description; a\nmode that cannot be used yet is listed, disabled, with its reason. **Permissions**\nholds Work and Plan, still on `1` and `2`; the conversation modes have no\nnumber keys. A conversation's mode is chosen before its first message and is\nfixed after it: `/mode creator` or the picker's first group changes a blank\nconversation in place, and after the first message the picker shows the group\nas locked and `/mode creator` is refused with a note to start a new\nconversation. `/new` starts a Standard conversation, `/new creator` starts a\nCreator mode conversation (`--force` still combines). Resume, `/load`, `/fork`\nand `/clear` keep the conversation's saved mode, and the mode name appears in the\nfooter when it is not Standard. A mode never changes a permission: Creator\nmode in Plan is still Plan. `codewhale` and `codewhale exec` take\n`--conversation-mode <MODE>` to start a new session in a mode; with `--resume`,\n`--session-id` or `--continue` it must match the session's saved mode, or the\nrun exits with code 2 before any model call.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = \"operate\"` loads as Work and says so once; a Runtime API request with `mode: \"operate\"` runs as `agent` with tLine truncated
|
||||
"sha256": "bfd3ed8cae274f9b72d1805232f41d4342cc29b45b53617a26f06c3776941202",
|
||||
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not on the\npermission dial.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nOne dial says how much Codewhale may do without asking. Press `Shift+Tab` to\nstep it: **Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**. Plan\nis the Plan mode; every other stop is Work with that permission posture. The\nfooter shows the current stop and `Shift+Tab to change`. New installs start on\nAccept Edits when no permission posture is saved. An explicit saved choice,\nincluding Ask from an earlier version, stays in control.\n`Tab` only completes composer menus, commands, file mentions and suggestions;\nit never changes the mode, and never sends or queues composer text (use\n`Enter`).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work` or\n`/mode plan`. `operate` and its spellings still parse, as Work, and say that\nOperate was retired, so an older setting or script never errors.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = \"operate\"` loads as Work and says so once; a Runtime API request with `mode: \"operate\"` runs as `agent` with the default posture (Accept Edits) and logs one notice per process, never an error. Agents forked from an older Operate conversation do not inherit its contract. Multi-agent work stays on `agent`, `workflow` and `/fleet` in every mode.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work |\n|:---|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated |\n| `bash` | visible name; execution denied | approval- and policy-gated; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same |\n| Paid or external-service tools | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode; fleet profiles never widen it |\n\nMode changes scheduling emphasLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -477,8 +477,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/MODES.md",
|
||||
"sha256": "014a36c17573dc58e936300e1eaddcdffc8cb616da1cc1e8b6fe4e962af810f1",
|
||||
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not on the\npermission dial.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nOne dial says how much Codewhale may do without asking. Press `Shift+Tab` to\nstep it: **Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**. Plan\nis the Plan mode; every other stop is Work with that permission posture. The\nfooter shows the current stop and `Shift+Tab to change`. New installs start on\nAccept Edits when no permission posture is saved. An explicit saved choice,\nincluding Ask from an earlier version, stays in control.\n`Tab` only completes composer menus, commands, file mentions and suggestions;\nit never changes the mode, and never sends or queues composer text (use\n`Enter`).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work` or\n`/mode plan`. `operate` and its spellings still parse, as Work, and say that\nOperate was retired, so an older setting or script never errors.\n\nThe picker has two groups. **Conversation mode** lists the modes the Engine\noffers, Standard mode and Creator mode, each with a one-line description; a\nmode that cannot be used yet is listed, disabled, with its reason. **Permissions**\nholds Work and Plan, still on `1` and `2`; the conversation modes have no\nnumber keys. A conversation's mode is chosen before its first message and is\nfixed after it: `/mode creator` or the picker's first group changes a blank\nconversation in place, and after the first message the picker shows the group\nas locked and `/mode creator` is refused with a note to start a new\nconversation. `/new` starts a Standard conversation, `/new creator` starts a\nCreator mode conversation (`--force` still combines). Resume, `/load`, `/fork`\nand `/clear` keep the conversation's saved mode, and the mode name appears in the\nfooter when it is not Standard. A mode never changes a permission: Creator\nmode in Plan is still Plan. `codewhale` and `codewhale exec` take\n`--conversation-mode <MODE>` to start a new session in a mode; with `--resume`,\n`--session-id` or `--continue` it must match the session's saved mode, or the\nrun exits with code 2 before any model call.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = \"operate\"` loads as Work and says so once; a Runtime API request with `mode: \"operate\"` runs as `agent` with tLine truncated
|
||||
"sha256": "bfd3ed8cae274f9b72d1805232f41d4342cc29b45b53617a26f06c3776941202",
|
||||
"content": "# Modes and Permission Postures\n\n> 阅读简体中文版:[zh_hans/MODES.md](zh_hans/MODES.md)\n\nCodewhale has three related concepts:\n\n- **TUI mode**: what kind of visible interaction you're in (Plan/Work).\n- **Permission posture**: how aggressively the UI asks before executing tools.\n- **Workflow**: named steps that coordinate sub-agents, with dependencies and\n results, available in any TUI mode.\n\nModel selection is separate. `--model auto` and `/model auto` route each turn to\na concrete model and thinking level; they are not TUI modes and are not on the\npermission dial.\n\nWorkflow runs named steps through the same sub-agent runtime; Fleet manages\ntheir saved roles and model assignments. A workflow adds ordering, result\nhandoffs, gates and a progress view. The active mode and permission posture\nstill control what each step may execute.\n\nBefore assigning steps, query `agent(action=\"roster\")` for the saved Fleet\nmodels and roles. A plan child can select a listed model with `model`, or use\nthe saved `role`/`profile` assignment. An Exact Fleet fixes each member's route.\n\n## TUI Modes\n\nOne dial says how much Codewhale may do without asking. Press `Shift+Tab` to\nstep it: **Ask → Accept Edits → Plan → Auto-Review → Full Access → Ask**. Plan\nis the Plan mode; every other stop is Work with that permission posture. The\nfooter shows the current stop and `Shift+Tab to change`. New installs start on\nAccept Edits when no permission posture is saved. An explicit saved choice,\nincluding Ask from an earlier version, stays in control.\n`Tab` only completes composer menus, commands, file mentions and suggestions;\nit never changes the mode, and never sends or queues composer text (use\n`Enter`).\nEvery change of effective permission writes a `Permissions changed to …` line\nin the transcript.\nPress `Ctrl+T` to cycle reasoning effort.\nRun `/mode` to open the mode picker, or switch directly with `/mode work` or\n`/mode plan`. `operate` and its spellings still parse, as Work, and say that\nOperate was retired, so an older setting or script never errors.\n\n- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Accept Edits), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.\n- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `grep_files`, `bash`, `agent`, `todo_write`, `present`, and `load_skill`, plus `create_goal`, `get_goal`, `update_goal`, and `task_shell_wait` so goal and background-shell controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.\n- **Operate is retired.** It was Work with an orchestration contract, and that contract now lives in every session's constitution: make a goal of a durable request, keep the plan visible, fan independent work out to parallel sub-agents, and have an independent reviewer try to refute a non-trivial change. A saved `default_mode = \"operate\"` loads as Work and says so once; a Runtime API request with `mode: \"operate\"` runs as `agent` with the default posture (Accept Edits) and logs one notice per process, never an error. Agents forked from an older Operate conversation do not inherit its contract. Multi-agent work stays on `agent`, `workflow` and `/fleet` in every mode.\n\n`Act` and `/mode act` remain compatibility aliases for Work. Saved settings\nstill normalize to the internal value `agent`.\n\n### Tool availability by mode\n\n| Tool family | Plan | Work |\n|:---|:---:|:---:|\n| `read` and policy-allowed deferred research tools | yes | yes |\n| `write` and `edit` | visible names; execution denied | approval- and policy-gated |\n| `bash` | visible name; execution denied | approval- and policy-gated; delegation is preferred when parallelism or isolation helps |\n| `agent` | yes, subject to child-depth authority | yes, subject to child-depth authority |\n| Deferred native, MCP, and plugin tools | discoverable through `tool_search` when policy permits | same |\n| Paid or external-service tools | follows permission posture | follows permission posture |\n| Access outside the workspace root | explicit trusted paths only | only through trusted paths or trust mode; fleet profiles never widen it |\n\nMode changes scheduling emphasLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/public-surface-facts.json",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"format": "codewhale.features.v1",
|
||||
"source": {
|
||||
"path": "docs/features.toml",
|
||||
"sha256": "3b1dd6728ef545ca5928777d43f17bf47a5d993b279bc0b0b558440266375cf4"
|
||||
"sha256": "4c4dfe17cea716328253c687d9d0665e1b24fc3b59d00f2a81cf1157dfea7072"
|
||||
},
|
||||
"features": [
|
||||
{
|
||||
@@ -360,7 +360,7 @@
|
||||
{
|
||||
"id": "modes",
|
||||
"name": "Modes",
|
||||
"summary": "Plan and Work modes on the Shift+Tab dial; a conversation is Standard or Creator mode, fixed after its first message.",
|
||||
"summary": "Plan and Work modes; Plan is a stop on the Shift+Tab dial, Operate parses as Work; a finished plan offers work.",
|
||||
"status": "stable",
|
||||
"surfaces": {
|
||||
"tui": "stable",
|
||||
|
||||
Reference in new issue
Block a user