diff --git a/Cargo.lock b/Cargo.lock index 90ec6fa..08da0ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1649,7 +1649,7 @@ dependencies = [ "windows 0.62.2", "wiremock", "zeroize", - "zip 3.0.0", + "zip", ] [[package]] @@ -2098,15 +2098,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arboard" version = "3.6.1" @@ -4183,9 +4174,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] @@ -4484,17 +4475,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_builder" version = "0.20.2" @@ -6091,7 +6071,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c91d8cffac8849493a82233811bd02b2b183b8cf39bf704de0fa0841b737595" dependencies = [ "bstr", - "hashbrown 0.17.1", + "hashbrown 0.15.5", ] [[package]] @@ -7006,7 +6986,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", + "webpki-roots 1.0.11", ] [[package]] @@ -8877,20 +8857,20 @@ dependencies = [ [[package]] name = "office_oxide" -version = "0.1.9" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e07e156a6bc62caaa66fc698daad7405dd62e86f5292fd22898c1a55f154748d" +checksum = "7993e747e60a6b06c8c0b7060f0bd7f7e8e03921f18a4b42b23738ff276a4715" dependencies = [ "atoi_simd", "encoding_rs", "fast-float2", - "libc", + "flate2", "log", - "quick-xml 0.41.0", + "quick-xml 0.42.0", "serde", "serde_json", "thiserror 2.0.20", - "zip 8.6.0", + "zip", ] [[package]] @@ -10065,7 +10045,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", - "serde", ] [[package]] @@ -10075,6 +10054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", + "serde", ] [[package]] @@ -10585,7 +10565,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", - "webpki-roots 1.0.9", + "webpki-roots 1.0.11", ] [[package]] @@ -10898,9 +10878,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -10963,9 +10943,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -12331,7 +12311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -13554,7 +13534,7 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots 1.0.9", + "webpki-roots 1.0.11", ] [[package]] @@ -13987,9 +13967,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] @@ -14000,14 +13980,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.11", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -14955,27 +14935,15 @@ dependencies = [ [[package]] name = "zip" -version = "3.0.0" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" -dependencies = [ - "arbitrary", - "crc32fast", - "flate2", - "indexmap", - "memchr", -] - -[[package]] -name = "zip" -version = "8.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "crc32fast", "flate2", "indexmap", "memchr", + "nix 0.31.3", "typed-path", "zopfli", ] diff --git a/crates/codegen/ainxt-pager-bin/src/main.rs b/crates/codegen/ainxt-pager-bin/src/main.rs index 037f8b1..b70e3e6 100644 --- a/crates/codegen/ainxt-pager-bin/src/main.rs +++ b/crates/codegen/ainxt-pager-bin/src/main.rs @@ -87,33 +87,28 @@ fn resolve_agent_profile_path(path: &std::path::Path) -> std::path::PathBuf { } /// Print startup information for the serve command. /// -/// The secret itself is never printed to the terminal (which can be +/// No part of the secret is ever printed to the terminal (which can be /// captured by session recorders, terminal multiplexer scrollback, or -/// screen-sharing); only a short prefix is shown so an operator who already -/// knows the secret can sanity-check they're looking at the right value. -fn print_serve_startup_info(bind_addr: SocketAddr, secret: &str) { - let masked = mask_secret_for_display(secret); +/// screen-sharing). Even a short prefix materially reduces the search space +/// of an auto-generated secret, so only its *source* is reported. The +/// function deliberately does not take the secret as a parameter so that no +/// secret-derived data can reach this output: it only receives whether the +/// user supplied the value (`user_supplied`) and prints a fixed label. +fn print_serve_startup_info(bind_addr: SocketAddr, user_supplied: bool) { + let origin_label = if user_supplied { + "provided via --secret / AINXT_AGENT_SECRET (not shown)" + } else { + "auto-generated (not shown; pass --secret / AINXT_AGENT_SECRET to choose one)" + }; eprintln!(); eprintln!(" ainxt agent server starting..."); eprintln!(); eprintln!(" Address: {}:{}", bind_addr.ip(), bind_addr.port()); - eprintln!(" Secret: {masked} (see --secret / AINXT_AGENT_SECRET to recover the full value)"); + eprintln!(" Secret: {origin_label}"); eprintln!(); eprintln!(" WebSocket URL: ws://{bind_addr}/ws?server-key="); eprintln!(); } - -/// Render a secret as a short, non-reversible display form: the first 4 -/// characters followed by an ellipsis, or fully masked when too short for a -/// partial prefix to be safe. -fn mask_secret_for_display(secret: &str) -> String { - if secret.chars().count() <= 4 { - "****".to_string() - } else { - let prefix: String = secret.chars().take(4).collect(); - format!("{prefix}…") - } -} /// Entrypoint tag for `ainxt -p`; keys the quiet stderr default in `init_tracing_simple`. const HEADLESS_ENTRYPOINT: &str = "headless"; /// Initialize simple tracing for non-TUI agent modes. @@ -1338,9 +1333,9 @@ async fn run_agent_command( let secret = a.get_secret(); let server_config = ainxt_shell::agent::ServerConfig { bind_addr: a.bind, - secret: secret.clone(), + secret, }; - print_serve_startup_info(a.bind, &secret); + print_serve_startup_info(a.bind, a.secret.is_some()); ainxt_shell::agent::run_agent_server(server_config, agent_config).await } Some(AgentCmd::Leader(a)) => { diff --git a/crates/codegen/ainxt-pager/examples/memory_benchmark.rs b/crates/codegen/ainxt-pager/examples/memory_benchmark.rs index 47bc5bc..8cfe5ac 100644 --- a/crates/codegen/ainxt-pager/examples/memory_benchmark.rs +++ b/crates/codegen/ainxt-pager/examples/memory_benchmark.rs @@ -70,9 +70,13 @@ fn create_arc_info(id: usize) -> SubagentInfoArc { } fn estimate_string_info_size(info: &SubagentInfoString) -> usize { + let SubagentInfoString { + child_session_id: child_ref, + .. + } = info; std::mem::size_of::() + info.subagent_id.capacity() - + info.child_session_id.capacity() + + child_ref.capacity() + info.description.capacity() + info.subagent_type.capacity() + info.persona.as_ref().map_or(0, |s| s.capacity()) diff --git a/crates/codegen/ainxt-pager/src/app/acp_handler/tests/queue_and_adoption.rs b/crates/codegen/ainxt-pager/src/app/acp_handler/tests/queue_and_adoption.rs index d108cd9..5e498e6 100644 --- a/crates/codegen/ainxt-pager/src/app/acp_handler/tests/queue_and_adoption.rs +++ b/crates/codegen/ainxt-pager/src/app/acp_handler/tests/queue_and_adoption.rs @@ -171,7 +171,8 @@ effects .iter() .any(|e| matches!(e, Effect::SendBashCommand { .. })), - "mid-turn bash must send server-authoritatively; effects = {effects:?}" + "mid-turn bash must send server-authoritatively; effects = {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let echo_id = app.agents[&AgentId(0)] .optimistic_queue_ids @@ -602,7 +603,7 @@ let effects = dispatch(Action::SendPrompt("first".into()), &mut app); let pid_first = match &effects[0] { Effect::SendPrompt { prompt_id, .. } => prompt_id.clone(), - other => panic!("expected SendPrompt, got {other:?}"), + other => panic!("expected SendPrompt, got {other:?}", other = crate::app::actions::effect_kinds(&other)), }; assert_eq!( app.agents[&id].session.current_prompt_id.as_deref(), @@ -618,7 +619,7 @@ assert_eq!(text, "second"); prompt_id.clone() } - other => panic!("expected immediate SendPrompt, got {other:?}"), + other => panic!("expected immediate SendPrompt, got {other:?}", other = crate::app::actions::effect_kinds(&other)), }; assert_eq!(app.agents[&id].session.queue_len(), 0); @@ -720,7 +721,7 @@ let effects = dispatch(Action::SendPrompt("second".into()), &mut app); let pid_second = match &effects[0] { Effect::SendPrompt { prompt_id, .. } => prompt_id.clone(), - other => panic!("expected immediate SendPrompt, got {other:?}"), + other => panic!("expected immediate SendPrompt, got {other:?}", other = crate::app::actions::effect_kinds(&other)), }; // Leader broadcasts running=p2 before p1's PromptResponse, then p2's echo. diff --git a/crates/codegen/ainxt-pager/src/app/actions.rs b/crates/codegen/ainxt-pager/src/app/actions.rs index a6af9da..b4b4b27 100644 --- a/crates/codegen/ainxt-pager/src/app/actions.rs +++ b/crates/codegen/ainxt-pager/src/app/actions.rs @@ -2961,3 +2961,193 @@ mod tests { ); } } + +/// Test-only view of effects for assertion / panic messages: each `Effect` +/// is reduced to its variant name, never its payload. Payloads can carry +/// session ids, account and billing data, so failing tests report *which* +/// effects were emitted (e.g. `["PersistSetting", "SwitchModel"]`) +/// without writing their contents to the test log. +#[cfg(test)] +pub(crate) trait EffectKinds { + type Kinds: std::fmt::Debug; + fn effect_kinds(&self) -> Self::Kinds; +} + +#[cfg(test)] +impl EffectKinds for Effect { + type Kinds = &'static str; + fn effect_kinds(&self) -> &'static str { + match self { + Effect::CreateSession { .. } => "CreateSession", + Effect::SetWorkingDir { .. } => "SetWorkingDir", + Effect::CreateWorktreeSession { .. } => "CreateWorktreeSession", + Effect::LoadSession { .. } => "LoadSession", + Effect::ScanForeignSessions { .. } => "ScanForeignSessions", + Effect::CanonicalizeForeignResumeCwd { .. } => "CanonicalizeForeignResumeCwd", + Effect::DetectForeignResumeHint { .. } => "DetectForeignResumeHint", + Effect::FetchSessionList { .. } => "FetchSessionList", + Effect::DebounceSessionSearch { .. } => "DebounceSessionSearch", + Effect::FetchRoster { .. } => "FetchRoster", + Effect::FetchDashboardSessions { .. } => "FetchDashboardSessions", + Effect::LoadCardDetail { .. } => "LoadCardDetail", + Effect::RestoreAndLoadSession { .. } => "RestoreAndLoadSession", + Effect::SendPrompt { .. } => "SendPrompt", + Effect::SendBashCommand { .. } => "SendBashCommand", + Effect::CancelTurn { .. } => "CancelTurn", + Effect::Compact { .. } => "Compact", + Effect::KillBgTask { .. } => "KillBgTask", + Effect::KillSubagent { .. } => "KillSubagent", + Effect::DeleteScheduledTask { .. } => "DeleteScheduledTask", + Effect::DemoteToBackground { .. } => "DemoteToBackground", + Effect::SwitchModel { .. } => "SwitchModel", + Effect::FetchChangelog { .. } => "FetchChangelog", + Effect::PersistAnnouncementsHidden { .. } => "PersistAnnouncementsHidden", + Effect::PersistMemoryFullscreen { .. } => "PersistMemoryFullscreen", + Effect::PersistProjectPickerDisabled { .. } => "PersistProjectPickerDisabled", + Effect::PersistDashboard { .. } => "PersistDashboard", + Effect::PersistWorktreeMode { .. } => "PersistWorktreeMode", + Effect::PersistPreferredModel { .. } => "PersistPreferredModel", + Effect::PersistPermissionMode { .. } => "PersistPermissionMode", + Effect::PersistSetting { .. } => "PersistSetting", + Effect::SaveModelOverride { .. } => "SaveModelOverride", + Effect::DeleteModelOverride { .. } => "DeleteModelOverride", + Effect::SendPromptBlocks { .. } => "SendPromptBlocks", + Effect::SendPromptNow { .. } => "SendPromptNow", + Effect::TogglePlanMode { .. } => "TogglePlanMode", + Effect::QueueRemove { .. } => "QueueRemove", + Effect::QueueReorder { .. } => "QueueReorder", + Effect::QueueClear { .. } => "QueueClear", + Effect::QueueEdit { .. } => "QueueEdit", + Effect::QueueInterject { .. } => "QueueInterject", + Effect::SetSessionMode { .. } => "SetSessionMode", + Effect::SetModeThenPrompt { .. } => "SetModeThenPrompt", + Effect::FetchPromptHistory { .. } => "FetchPromptHistory", + Effect::FetchSessionAgentName { .. } => "FetchSessionAgentName", + Effect::Authenticate { .. } => "Authenticate", + Effect::PollAuthUrl { .. } => "PollAuthUrl", + Effect::SubmitAuthCode { .. } => "SubmitAuthCode", + Effect::FetchMcpsList { .. } => "FetchMcpsList", + Effect::McpAuthTrigger { .. } => "McpAuthTrigger", + Effect::FetchHooksList { .. } => "FetchHooksList", + Effect::FetchPluginsList { .. } => "FetchPluginsList", + Effect::HooksAction { .. } => "HooksAction", + Effect::PluginsAction { .. } => "PluginsAction", + Effect::FetchMarketplaceList { .. } => "FetchMarketplaceList", + Effect::CheckMarketplaceUpdates { .. } => "CheckMarketplaceUpdates", + Effect::FetchPluginCtaCatalog { .. } => "FetchPluginCtaCatalog", + Effect::FetchSkillsList { .. } => "FetchSkillsList", + Effect::ToggleSkill { .. } => "ToggleSkill", + Effect::MarketplaceAction { .. } => "MarketplaceAction", + Effect::InstallPluginFromCta { .. } => "InstallPluginFromCta", + Effect::ReloadPluginsForCta { .. } => "ReloadPluginsForCta", + Effect::FetchPluginCtaMcps { .. } => "FetchPluginCtaMcps", + Effect::RetryPluginCtaMcps { .. } => "RetryPluginCtaMcps", + Effect::DismissCtaInstalled { .. } => "DismissCtaInstalled", + Effect::UpsertMcpServer { .. } => "UpsertMcpServer", + Effect::DeleteMcpServer { .. } => "DeleteMcpServer", + Effect::ToggleMcpServer { .. } => "ToggleMcpServer", + Effect::ToggleMcpTool { .. } => "ToggleMcpTool", + Effect::ShareSession { .. } => "ShareSession", + Effect::ShowSessionInfo { .. } => "ShowSessionInfo", + Effect::ShowContextInfo { .. } => "ShowContextInfo", + Effect::ShowSessionUsage { .. } => "ShowSessionUsage", + Effect::FetchBundleStatus { .. } => "FetchBundleStatus", + Effect::FetchCatalogEntry { .. } => "FetchCatalogEntry", + Effect::SendFeedback { .. } => "SendFeedback", + Effect::SaveMemoryNote { .. } => "SaveMemoryNote", + Effect::RewriteMemoryNote { .. } => "RewriteMemoryNote", + Effect::RefreshAvailableCommands { .. } => "RefreshAvailableCommands", + Effect::SendBtw { .. } => "SendBtw", + Effect::GitStatus { .. } => "GitStatus", + Effect::GitDiff { .. } => "GitDiff", + Effect::GitStage { .. } => "GitStage", + Effect::GitUnstage { .. } => "GitUnstage", + Effect::GitCommit { .. } => "GitCommit", + Effect::SuggestCommitMessage { .. } => "SuggestCommitMessage", + Effect::SendRecap { .. } => "SendRecap", + Effect::SendInterject { .. } => "SendInterject", + Effect::Logout { .. } => "Logout", + Effect::CheckSubscription { .. } => "CheckSubscription", + Effect::CreditLimitRecheck { .. } => "CreditLimitRecheck", + Effect::SchedulePaywallCheck { .. } => "SchedulePaywallCheck", + Effect::ScheduleGateVerifyTimeout { .. } => "ScheduleGateVerifyTimeout", + Effect::SwitchAccount { .. } => "SwitchAccount", + Effect::ScheduleClearAuthCopied { .. } => "ScheduleClearAuthCopied", + Effect::RegisterActiveSession { .. } => "RegisterActiveSession", + Effect::UnregisterActiveSession { .. } => "UnregisterActiveSession", + Effect::Quit { .. } => "Quit", + Effect::SetCodingDataSharing { .. } => "SetCodingDataSharing", + Effect::RenameSession { .. } => "RenameSession", + Effect::DeleteSession { .. } => "DeleteSession", + Effect::DeepSearchSessions { .. } => "DeepSearchSessions", + Effect::ForkSession { .. } => "ForkSession", + Effect::HydrateSessionTitleFromDisk { .. } => "HydrateSessionTitleFromDisk", + Effect::FetchRewindPoints { .. } => "FetchRewindPoints", + Effect::RewindPreview { .. } => "RewindPreview", + Effect::RewindExecute { .. } => "RewindExecute", + Effect::FetchBilling { .. } => "FetchBilling", + Effect::FetchAppBilling { .. } => "FetchAppBilling", + Effect::RefreshGate { .. } => "RefreshGate", + Effect::DebounceSuggestions { .. } => "DebounceSuggestions", + Effect::DebouncePluginCta { .. } => "DebouncePluginCta", + Effect::FetchShellSuggestions { .. } => "FetchShellSuggestions", + Effect::FetchPromptSuggestion { .. } => "FetchPromptSuggestion", + Effect::ProbeClipboardAttachment { .. } => "ProbeClipboardAttachment", + Effect::PreparePromptImagePreview { .. } => "PreparePromptImagePreview", + } + } +} + +#[cfg(test)] +impl EffectKinds for [Effect] { + type Kinds = Vec<&'static str>; + fn effect_kinds(&self) -> Vec<&'static str> { + let mut kinds = Vec::new(); + for effect in self { + kinds.push(effect.effect_kinds()); + } + kinds + } +} + +#[cfg(test)] +impl EffectKinds for Vec { + type Kinds = Vec<&'static str>; + fn effect_kinds(&self) -> Vec<&'static str> { + self.as_slice().effect_kinds() + } +} + +#[cfg(test)] +impl EffectKinds for Option { + type Kinds = Option; + fn effect_kinds(&self) -> Self::Kinds { + match self { + Some(inner) => Some(inner.effect_kinds()), + None => None, + } + } +} + +#[cfg(test)] +impl EffectKinds for &T { + type Kinds = T::Kinds; + fn effect_kinds(&self) -> T::Kinds { + (**self).effect_kinds() + } +} + +#[cfg(test)] +impl EffectKinds for &mut T { + type Kinds = T::Kinds; + fn effect_kinds(&self) -> T::Kinds { + (**self).effect_kinds() + } +} + +/// Variant name(s) of an `Effect`, a slice / `Vec` of them, or an `Option` +/// of either. See [`EffectKinds`]. +#[cfg(test)] +pub(crate) fn effect_kinds(value: &T) -> T::Kinds { + value.effect_kinds() +} diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/queue.rs b/crates/codegen/ainxt-pager/src/app/dispatch/queue.rs index acd7bfd..edeedec 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/queue.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/queue.rs @@ -1836,7 +1836,7 @@ mod tests { assert!( matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "p3-edited"), "should send the edited prompt, got: {:?}", - effects[0] + crate::app::actions::effect_kinds(&effects[0]) ); // p4 should still be in queue. assert_eq!(app.agents[&id].session.queue_len(), 1); @@ -1895,7 +1895,8 @@ mod tests { assert_eq!( effects.len(), 1, - "queued prompt must drain once reconnect clears, got: {effects:?}" + "queued prompt must drain once reconnect clears, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(matches!(&effects[0], Effect::SendPrompt { .. })); assert!(app.agents[&id].session.state.is_turn_running()); diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/session/fork.rs b/crates/codegen/ainxt-pager/src/app/dispatch/session/fork.rs index ecd2bcd..acda891 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/session/fork.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/session/fork.rs @@ -439,17 +439,17 @@ fn build_fork_placeholder( /// Called in `TaskResult::SessionLoaded` (not at dispatch time) because /// the child's session id is not known until the backend responds. pub(in crate::app::dispatch) fn build_child_fork_marker( - session_id: &str, + child_sid: &str, parent_sid: &str, worktree: bool, switch_hint: Option<&str>, ) -> String { let header = if let Some(cmd) = switch_hint { format!( - "Session {session_id} (forked from {parent_sid}) \u{2014} use {cmd} to switch between sessions", + "Session {child_sid} (forked from {parent_sid}) \u{2014} use {cmd} to switch between sessions", ) } else { - format!("Session {session_id} (forked from {parent_sid})") + format!("Session {child_sid} (forked from {parent_sid})") }; if worktree { header diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/auth.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/auth.rs index a665521..e6dedbf 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/auth.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/auth.rs @@ -211,7 +211,7 @@ fn bash_while_running_is_server_authoritative() { assert_eq!(command, "ls -la"); prompt_id.clone() } - other => panic!("expected immediate SendBashCommand, got {other:?}"), + other => panic!("expected immediate SendBashCommand, got {other:?}", other = crate::app::actions::effect_kinds(&other)), }; // Not in the local queue. assert_eq!(app.agents[&id].session.queue_len(), 0); diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/billing.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/billing.rs index 1e7d669..18bbf3e 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/billing.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/billing.rs @@ -517,10 +517,11 @@ fn show_usage_returns_fetch_billing_effect() { let effects = dispatch(Action::ShowUsage, &mut app); // One non-silent FetchBilling — the effect pulls billing + auto-topup // together and renders a single summary. - assert_eq!(effects.len(), 1, "got: {effects:?}"); + assert_eq!(effects.len(), 1, "got: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); assert!( matches!(&effects[0], Effect::FetchBilling { agent_id, silent } if *agent_id == AgentId(0) && !*silent), - "effect should be a non-silent FetchBilling, got: {effects:?}" + "effect should be a non-silent FetchBilling, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -890,7 +891,8 @@ fn free_usage_failure_opens_paywall_modal() { let effects = dispatch(Action::SendPrompt("draw me a cat".into()), &mut app); assert!( matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "draw me a cat"), - "send must dispatch: {effects:?}" + "send must dispatch: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let prompt_id = app.agents[&id].session.current_prompt_id.clone(); assert!(prompt_id.is_some(), "send must mint a prompt id"); @@ -980,7 +982,8 @@ fn restricted_command_submit_opens_two_option_upsell() { assert!( effects.is_empty(), - "restricted command must not produce a SendPrompt: {effects:?}" + "restricted command must not produce a SendPrompt: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = &app.agents[&id]; assert!( @@ -1045,7 +1048,7 @@ fn restricted_command_with_open_modal_keeps_composer_text() { let effects = dispatch(Action::SendPrompt("/image a sunset".into()), &mut app); - assert!(effects.is_empty(), "no passthrough / send: {effects:?}"); + assert!(effects.is_empty(), "no passthrough / send: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); let agent = &app.agents[&id]; assert_eq!( agent.prompt.text(), @@ -1084,7 +1087,8 @@ fn unknown_non_restricted_command_still_passes_through() { assert_eq!(effects.len(), 1); assert!( matches!(&effects[0], Effect::SendPrompt { text, .. } if text == "/frobnicate arg"), - "unknown command must still pass through: {effects:?}" + "unknown command must still pass through: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.agents[&id].question_view.is_none(), diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/dashboard.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/dashboard.rs index b245aec..509b063 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/dashboard.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/dashboard.rs @@ -460,7 +460,8 @@ fn auth_complete_retries_stashed_prompt_from_dashboard() { effects .iter() .any(|effect| matches!(effect, Effect::SendPromptBlocks { .. })), - "stash must be retried even when login returns to the dashboard, got: {effects:?}" + "stash must be retried even when login returns to the dashboard, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1345,7 +1346,8 @@ fn dashboard_second_stash_does_not_overwrite_first() { }); assert!( reply_sent, - "the stashed peek reply must reach the peeked agent with the image; effects = {effects:?}" + "the stashed peek reply must reach the peeked agent with the image; effects = {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -3821,6 +3823,7 @@ fn dashboard_rename_end_to_end_top_level_row() { if *agent_id == id && title == "My renamed session" )), "commit must emit a RenameSession effect, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!( app.agents.get(&id).and_then(|a| a.display_name.clone()), diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/modes.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/modes.rs index da34251..6c648da 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/modes.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/modes.rs @@ -121,7 +121,8 @@ fn accept_word_select_tip_flips_setting_and_retires_tip() { .. } )), - "accept must persist the setting, got: {effects:?}" + "accept must persist the setting, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // Restore default so sibling tests don't inherit word_select. crate::appearance::cache::set_keep_text_selection(TextSelection::Flash); @@ -162,7 +163,8 @@ fn slash_plan_no_args_not_in_plan_enters_plan_mode() { assert_eq!(effects.len(), 1); assert!( matches!(&effects[0], Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "plan"), - "expected SetSessionMode(plan), got: {effects:?}" + "expected SetSessionMode(plan), got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // Optimistic pending state should be set. assert_eq!(app.agents[&id].plan_mode_pending, Some(true)); @@ -177,7 +179,7 @@ fn slash_plan_no_args_already_in_plan_shows_plan() { let effects = dispatch(Action::SendPrompt("/plan".into()), &mut app); // Should NOT emit SetSessionMode — just show the plan (no async effect). - assert!(effects.is_empty(), "expected no effects, got: {effects:?}"); + assert!(effects.is_empty(), "expected no effects, got: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); } #[test] @@ -192,14 +194,15 @@ fn slash_plan_with_args_not_in_plan_enters_and_sends_prompt() { // Should emit a single SetModeThenPrompt (mode switch + prompt // bundled into one sequential task to avoid a race). - assert_eq!(effects.len(), 1, "expected 1 effect, got: {effects:?}"); + assert_eq!(effects.len(), 1, "expected 1 effect, got: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); assert!( matches!( &effects[0], Effect::SetModeThenPrompt { mode_id, text, .. } if &*mode_id.0 == "plan" && text == "add auth to the app" ), - "expected SetModeThenPrompt(plan, \"add auth to the app\"), got: {effects:?}" + "expected SetModeThenPrompt(plan, \"add auth to the app\"), got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!(app.agents[&id].plan_mode_pending, Some(true)); } @@ -220,7 +223,7 @@ fn slash_plan_desc_forwards_skill_token_ranges() { &mut app, ); - assert_eq!(effects.len(), 1, "expected 1 effect, got: {effects:?}"); + assert_eq!(effects.len(), 1, "expected 1 effect, got: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); match &effects[0] { Effect::SetModeThenPrompt { mode_id, @@ -236,7 +239,7 @@ fn slash_plan_desc_forwards_skill_token_ranges() { "offsets recomputed against the stripped desc" ); } - other => panic!("expected SetModeThenPrompt, got {other:?}"), + other => panic!("expected SetModeThenPrompt, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } // The drained echo block carries the same desc-space ranges. match &app.agents[&id].scrollback.get(0).unwrap().block { @@ -258,7 +261,7 @@ fn slash_plan_with_args_already_in_plan_is_noop() { &mut app, ); - assert!(effects.is_empty(), "expected no effects, got: {effects:?}"); + assert!(effects.is_empty(), "expected no effects, got: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); assert!(read_toast(&app).contains("/view-plan")); } @@ -461,7 +464,7 @@ fn set_yolo_mode_off_to_on_emits_persist_with_rollback() { "session_id must be threaded through for ACP notification gating" ); } - other => panic!("expected PersistPermissionMode, got {other:?}"), + other => panic!("expected PersistPermissionMode, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } } @@ -558,7 +561,7 @@ fn set_yolo_mode_on_to_off_emits_persist_with_rollback() { ); assert!(session_id.is_some(), "session_id must be threaded"); } - other => panic!("expected PersistPermissionMode, got {other:?}"), + other => panic!("expected PersistPermissionMode, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } // Defense-in-depth — pin that `SettingValue` enum re-import @@ -1028,7 +1031,7 @@ fn set_yolo_mode_redispatch_same_value_still_emits_effect_and_toast() { "session_id must be threaded through on duplicate dispatch" ); } - other => panic!("expected PersistPermissionMode, got {other:?}"), + other => panic!("expected PersistPermissionMode, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } // Pin all state fields explicitly. assert!( @@ -1092,6 +1095,7 @@ fn set_yolo_mode_on_blocked_by_policy_pin() { assert!( effects.is_empty(), "blocked enable must not emit any Effect (no persist), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !app.agents[&AgentId(0)].session.is_yolo(), @@ -1152,13 +1156,15 @@ fn cycle_mode_plan_to_auto_includes_persist_auto() { .. } )), - "expected PersistPermissionMode(auto), got {effects:?}" + "expected PersistPermissionMode(auto), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( effects .iter() .any(|e| matches!(e, Effect::SetSessionMode { .. })), - "expected plan exit SetSessionMode, got {effects:?}" + "expected plan exit SetSessionMode, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1186,7 +1192,8 @@ fn cycle_mode_plan_to_always_approve_when_auto_gated_off() { .. } )), - "gate OFF: expected PersistPermissionMode(always-approve), got {effects:?}" + "gate OFF: expected PersistPermissionMode(always-approve), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !effects.iter().any(|e| matches!( @@ -1225,7 +1232,8 @@ fn cycle_mode_auto_to_always_approve_blocked_by_policy_pin() { .. } )), - "expected PersistPermissionMode(ask) under pin, got {effects:?}" + "expected PersistPermissionMode(ask) under pin, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!(agent_toast(&app).as_deref(), Some(POLICY_WARNING)); } @@ -1276,7 +1284,8 @@ fn cycle_mode_plan_plus_auto_keeps_auto_not_reset() { effects .iter() .any(|e| matches!(e, Effect::SetSessionMode { .. })), - "expected plan exit SetSessionMode, got {effects:?}" + "expected plan exit SetSessionMode, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( effects.iter().any(|e| matches!( @@ -1286,7 +1295,8 @@ fn cycle_mode_plan_plus_auto_keeps_auto_not_reset() { .. } )), - "expected PersistPermissionMode(auto), got {effects:?}" + "expected PersistPermissionMode(auto), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !effects.iter().any(|e| matches!( @@ -1296,7 +1306,8 @@ fn cycle_mode_plan_plus_auto_keeps_auto_not_reset() { .. } )), - "Plan+Auto must not persist 'ask' (would mean a reset), got {effects:?}" + "Plan+Auto must not persist 'ask' (would mean a reset), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1339,14 +1350,16 @@ fn cycle_mode_pre_session_always_approve_to_normal_persists_ask() { } )), "pre-session Always-Approve → Normal must persist 'ask' \ - (stale config.toml relaunches yolo), got {effects:?}" + (stale config.toml relaunches yolo), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // Welcome-screen Shift+Tab still kicks off session creation. assert!( effects .iter() .any(|e| matches!(e, Effect::CreateSession { .. })), - "expected CreateSession alongside the persist, got {effects:?}" + "expected CreateSession alongside the persist, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1365,7 +1378,8 @@ fn cycle_mode_pre_session_normal_to_plan_does_not_persist_permission_mode() { !effects .iter() .any(|e| matches!(e, Effect::PersistPermissionMode { .. })), - "Normal → Plan must not touch the persisted permission mode, got {effects:?}" + "Normal → Plan must not touch the persisted permission mode, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1393,6 +1407,7 @@ fn set_yolo_mode_no_op_when_no_active_agent() { assert!( effects.is_empty(), "no active agent → no Effect, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // Defense-in-depth: SHARED state must NOT mutate. assert_eq!(app.default_yolo, default_yolo_before); @@ -1523,7 +1538,7 @@ fn set_permission_mode_default_overrides_canonical_to_default() { ); assert!(session_id.is_some()); } - other => panic!("expected PersistPermissionMode, got {other:?}"), + other => panic!("expected PersistPermissionMode, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } // Toast is the dedicated Default string, NOT @@ -1587,7 +1602,7 @@ fn set_permission_mode_always_approve_from_default_captures_prev_canonical() { rollback payload, NOT collapsed onto 'ask' by a bool projection", ); } - other => panic!("expected PersistPermissionMode, got {other:?}"), + other => panic!("expected PersistPermissionMode, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } // Toast is the destructive ⚠ variant — AlwaysApprove still @@ -1641,7 +1656,7 @@ fn set_yolo_mode_with_live_yolo_and_default_ui_mirror_rolls_back_to_default() { canonical — matches current_value_for's precedence rule", ); } - other => panic!("expected PersistPermissionMode, got {other:?}"), + other => panic!("expected PersistPermissionMode, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } } @@ -1695,7 +1710,7 @@ fn dispatch_cycle_mode_normal_to_plan_does_not_touch_yolo() { assert!( matches!(effects[0], Effect::SetSessionMode { .. }), "Normal → Plan effect must be SetSessionMode, got {:?}", - effects[0], + crate::app::actions::effect_kinds(&effects[0]), ); } @@ -1805,7 +1820,8 @@ fn dispatch_cycle_mode_plan_to_always_approve_delegates_through_inner() { .. } )), - "expected PersistPermissionMode(always-approve, BestEffort), got {effects:?}" + "expected PersistPermissionMode(always-approve, BestEffort), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1935,7 +1951,8 @@ fn cycle_always_approve_with_nudge_jumps_to_plan() { e, Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "plan" )), - "expected SetSessionMode(plan), got {effects:?}" + "expected SetSessionMode(plan), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( effects.iter().any(|e| matches!( @@ -1945,7 +1962,8 @@ fn cycle_always_approve_with_nudge_jumps_to_plan() { .. } )), - "expected PersistPermissionMode(ask), got {effects:?}" + "expected PersistPermissionMode(ask), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1991,7 +2009,8 @@ fn cycle_auto_with_nudge_jumps_to_plan() { e, Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "plan" )), - "expected SetSessionMode(plan), got {effects:?}" + "expected SetSessionMode(plan), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( effects.iter().any(|e| matches!( @@ -2001,7 +2020,8 @@ fn cycle_auto_with_nudge_jumps_to_plan() { .. } )), - "expected PersistPermissionMode(ask), got {effects:?}" + "expected PersistPermissionMode(ask), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -2098,7 +2118,7 @@ fn dispatch_cycle_mode_always_approve_to_normal_delegates_off() { ); assert_eq!(*canonical, "ask"); } - other => panic!("expected PersistPermissionMode, got {other:?}"), + other => panic!("expected PersistPermissionMode, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } } @@ -2128,7 +2148,7 @@ fn set_theme_auto_enables_auto_mode_and_persists_auto() { "auto commit persists `auto` (NOT the resolved concrete theme)", ); } - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert_eq!(app.current_ui.theme.as_deref(), Some("auto")); assert!( @@ -2313,7 +2333,8 @@ fn set_plan_mode_idempotency_uses_pending_over_active() { ); assert!( matches!(&effects[0], Effect::SetSessionMode { mode_id, .. } if &*mode_id.0 == "default"), - "OFF transition must emit SetSessionMode(default): {effects:?}" + "OFF transition must emit SetSessionMode(default): {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/notes.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/notes.rs index e73a332..9cad860 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/notes.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/notes.rs @@ -24,7 +24,8 @@ fn manual_recap_with_no_messages_toasts_empty_state_and_skips_request() { assert!( effects.is_empty(), - "empty session must not fire ainxt.dev/recap: {effects:?}" + "empty session must not fire ainxt.dev/recap: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = app.agents.get(&id).unwrap(); assert!(agent.pending_recap_entry.is_none(), "no loading spinner"); @@ -53,7 +54,8 @@ fn manual_recap_with_messages_requests_and_shows_spinner() { assert!( matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]), - "expected SendRecap effect, got {effects:?}" + "expected SendRecap effect, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = app.agents.get(&id).unwrap(); assert!( @@ -86,7 +88,8 @@ fn manual_recap_during_batch_load_with_prompts_still_requests() { assert!( matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]), - "batched resume with user prompts must still fire ainxt.dev/recap: {effects:?}" + "batched resume with user prompts must still fire ainxt.dev/recap: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = app.agents.get(&id).unwrap(); assert!(agent.pending_recap_entry.is_some()); @@ -112,7 +115,8 @@ fn manual_recap_while_loading_replay_still_requests() { assert!( matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]), - "loading_replay must not short-circuit to No messages yet: {effects:?}" + "loading_replay must not short-circuit to No messages yet: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = app.agents.get(&id).unwrap(); assert!(agent.pending_recap_entry.is_some()); diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/permissions.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/permissions.rs index cfbeb65..d17018e 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/permissions.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/permissions.rs @@ -35,6 +35,7 @@ fn dispatch_confirm_reset_setting_reset_dispatches_set_permission_mode_for_permi assert!( has_persist, "Reset of permission_mode must emit PersistPermissionMode, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // Agent's yolo flag is reset to default (off). assert!( @@ -204,6 +205,7 @@ fn set_permission_mode_always_approve_blocked_by_policy_pin() { assert!( effects.is_empty(), "blocked modal commit must not persist, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(!app.agents[&AgentId(0)].session.is_yolo()); assert_eq!( @@ -250,7 +252,8 @@ fn set_permission_mode_auto_persists_without_yolo() { .. } )), - "expected PersistPermissionMode(auto), got {effects:?}" + "expected PersistPermissionMode(auto), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -279,7 +282,8 @@ fn set_permission_mode_auto_degrades_to_ask_when_gated_off() { .. } )), - "gate OFF: must not persist 'auto', got {effects:?}" + "gate OFF: must not persist 'auto', got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -422,7 +426,7 @@ fn set_permission_mode_ask_emits_brand_consistent_toast() { "prior canonical was 'always-approve' (pre-set by SetYoloMode(true))", ); } - other => panic!("expected PersistPermissionMode, got {other:?}"), + other => panic!("expected PersistPermissionMode, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } } @@ -469,7 +473,7 @@ fn set_permission_mode_with_live_yolo_and_no_ui_mirror_rolls_back_to_always_appr the pager from the shell", ); } - other => panic!("expected PersistPermissionMode, got {other:?}"), + other => panic!("expected PersistPermissionMode, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } } @@ -507,6 +511,7 @@ fn rollback_permission_mode_default_canonical_preserves_default() { assert!( effects.is_empty(), "rollback path must not re-emit Effects, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // Yolo flipped to false (Default projects onto bool=false). diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/prompt.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/prompt.rs index a3f27d9..174aea6 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/prompt.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/prompt.rs @@ -441,7 +441,7 @@ fn send_prompt_mid_text_skill_token_carries_ranges() { assert_eq!(text, "great /pr-workflow all good now"); assert_eq!(skill_token_ranges, &vec![6..18]); } - other => panic!("expected SendPrompt, got {other:?}"), + other => panic!("expected SendPrompt, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } // The drained echo block styles exactly the composer-recognized token. match &app.agents[&id].scrollback.get(0).unwrap().block { @@ -466,7 +466,7 @@ fn send_prompt_unknown_token_has_empty_ranges() { Effect::SendPrompt { skill_token_ranges, .. } => assert!(skill_token_ranges.is_empty()), - other => panic!("expected SendPrompt, got {other:?}"), + other => panic!("expected SendPrompt, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } match &app.agents[&id].scrollback.get(0).unwrap().block { RenderBlock::UserPrompt(b) => assert!(b.skill_token_ranges.is_empty()), @@ -511,7 +511,7 @@ fn image_prompt_with_ranges_styles_echo_but_wire_meta_absent() { "images path is known-plain on replay: no ranges meta" ); } - other => panic!("expected SendPromptBlocks, got {other:?}"), + other => panic!("expected SendPromptBlocks, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } } @@ -529,7 +529,8 @@ fn send_prompt_leading_skill_keeps_inject_skill_path() { effects .iter() .any(|e| matches!(e, Effect::SendPromptBlocks { .. })), - "leading skill must send structured blocks, got {effects:?}" + "leading skill must send structured blocks, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); match &app.agents[&id].scrollback.get(0).unwrap().block { RenderBlock::UserPrompt(b) => { @@ -608,7 +609,8 @@ fn chip_submit_while_enqueued_clears_follow_up_chips() { !effects .iter() .any(|e| matches!(e, Effect::SendPrompt { text, .. } if text == "Summarize")), - "chip must be enqueued, not immediate-sent, got {effects:?}" + "chip must be enqueued, not immediate-sent, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // The chips are cleared on the enqueue path too (the bug fix). assert!( @@ -637,7 +639,7 @@ fn send_prompt_while_running_queues_without_drain() { assert_eq!(text, "queued"); prompt_id.clone() } - other => panic!("expected immediate SendPrompt, got {other:?}"), + other => panic!("expected immediate SendPrompt, got {other:?}", other = crate::app::actions::effect_kinds(&other)), }; // Not in the local queue; turn state unchanged (no new turn started). assert_eq!(app.agents[&id].session.queue_len(), 0); @@ -681,7 +683,8 @@ fn send_while_running_with_pending_local_prompt_preserves_fifo() { // drain since the turn is running). assert!( effects.is_empty(), - "must not immediate-send while a local prompt is pending, got {effects:?}" + "must not immediate-send while a local prompt is pending, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // "3" joined the LOCAL queue behind "2" (FIFO preserved). let agent = &app.agents[&id]; @@ -729,7 +732,7 @@ fn turn_end_drains_next_queued_prompt() { assert_eq!(text, "second"); prompt_id.clone() } - other => panic!("expected immediate SendPrompt, got {other:?}"), + other => panic!("expected immediate SendPrompt, got {other:?}", other = crate::app::actions::effect_kinds(&other)), }; assert_eq!(app.agents[&id].session.queue_len(), 0); @@ -916,7 +919,7 @@ fn turn_end_fetches_prompt_suggestion_when_enabled() { &mut app, ); - assert_eq!(effects.len(), 2, "suggestion fetch + billing: {effects:?}"); + assert_eq!(effects.len(), 2, "suggestion fetch + billing: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); let Effect::FetchPromptSuggestion { agent_id, generation, @@ -924,7 +927,7 @@ fn turn_end_fetches_prompt_suggestion_when_enabled() { session_id, } = &effects[0] else { - panic!("expected FetchPromptSuggestion first, got {effects:?}"); + panic!("expected FetchPromptSuggestion first, got {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); }; assert_eq!(*agent_id, id); assert!(session_id.is_some()); @@ -980,7 +983,8 @@ fn cancelled_turn_does_not_fetch_prompt_suggestion() { !effects .iter() .any(|e| matches!(e, Effect::FetchPromptSuggestion { .. })), - "cancelled turn must not fetch a suggestion: {effects:?}" + "cancelled turn must not fetch a suggestion: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !app.agents[&id].prompt.prompt_suggestion.has_suggestion(), @@ -1015,7 +1019,8 @@ fn turn_end_with_draft_does_not_fetch_prompt_suggestion() { !effects .iter() .any(|e| matches!(e, Effect::FetchPromptSuggestion { .. })), - "a draft suppresses the suggestion fetch: {effects:?}" + "a draft suppresses the suggestion fetch: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1052,7 +1057,8 @@ fn reconnect_pending_turn_end_still_wipes_prompt_suggestion() { !effects .iter() .any(|e| matches!(e, Effect::FetchPromptSuggestion { .. })), - "reconnect-pending turn end must not fetch a suggestion: {effects:?}" + "reconnect-pending turn end must not fetch a suggestion: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !app.agents[&id].prompt.prompt_suggestion.has_suggestion(), @@ -1098,7 +1104,8 @@ fn turn_end_with_shared_queue_does_not_fetch_prompt_suggestion() { !effects .iter() .any(|e| matches!(e, Effect::FetchPromptSuggestion { .. })), - "a non-empty server shared queue suppresses the suggestion fetch: {effects:?}" + "a non-empty server shared queue suppresses the suggestion fetch: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1323,7 +1330,7 @@ fn turn_complete_notification_suppressed_when_queue_non_empty() { let effects = dispatch(Action::SendPrompt("second".into()), &mut app); let pid_second = match &effects[0] { Effect::SendPrompt { prompt_id, .. } => prompt_id.clone(), - other => panic!("expected immediate SendPrompt, got {other:?}"), + other => panic!("expected immediate SendPrompt, got {other:?}", other = crate::app::actions::effect_kinds(&other)), }; // Model the leader's running=second broadcast arriving before first's // PromptResponse (stashed adoption ⇒ next turn is about to start). @@ -1421,7 +1428,8 @@ fn cancel_hands_queue_to_agent_without_reordering() { effects .iter() .any(|e| matches!(e, Effect::CancelTurn { .. })), - "must emit CancelTurn, got {effects:?}" + "must emit CancelTurn, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // q1's optimistic echo is left in place — it runs next, and the // broadcast (not a client-side retire) reconciles it. @@ -1893,7 +1901,8 @@ fn prompt_response_does_not_drain_during_reconnect() { effects .iter() .all(|e| !matches!(e, Effect::SendPrompt { .. })), - "should not drain queue during reconnect, got: {effects:?}" + "should not drain queue during reconnect, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!(app.agents[&id].session.queue_len(), 1); } @@ -2266,7 +2275,8 @@ fn agent_send_before_paste_probe_keeps_image() { }); assert!( sent_image, - "the re-issued send must carry the pasted image; effects = {effects:?}" + "the re-issued send must carry the pasted image; effects = {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -2349,7 +2359,8 @@ fn interject_before_paste_probe_keeps_image() { }); assert!( sent_image, - "the re-issued send-now must carry the pasted image; effects = {effects:?}" + "the re-issued send-now must carry the pasted image; effects = {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!( app.agents[&id].prompt.text(), @@ -2522,7 +2533,8 @@ fn minimal_mode_blocks_fullscreen_pane_slash_command() { let effects = dispatch_send_prompt(&mut app, "/find foo".to_string()); assert!( effects.is_empty(), - "a gated command must not produce effects, got: {effects:?}" + "a gated command must not produce effects, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!( agent_scrollback_len(&app), @@ -2566,7 +2578,7 @@ fn show_queue_empty_commits_empty_message() { let mut app = test_app_with_agent(); let before = agent_scrollback_len(&app); let effects = dispatch(Action::ShowQueue, &mut app); - assert!(effects.is_empty(), "got: {effects:?}"); + assert!(effects.is_empty(), "got: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); assert_eq!(agent_scrollback_len(&app), before + 1); assert_eq!(last_system_text(&app, AgentId(0)), "Queue is empty."); } @@ -2582,7 +2594,7 @@ fn show_queue_lists_local_prompts_in_order() { .enqueue_prompt("second\nwith two lines".to_string()); } let effects = dispatch(Action::ShowQueue, &mut app); - assert!(effects.is_empty(), "got: {effects:?}"); + assert!(effects.is_empty(), "got: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); let text = last_system_text(&app, AgentId(0)); assert!(text.contains("Queued prompts (2):"), "got: {text:?}"); assert!(text.contains("#1 first prompt"), "got: {text:?}"); @@ -2761,7 +2773,8 @@ fn plain_send_during_blocking_wait_arms_expectation_and_suppresses_marker() { let effects = dispatch(Action::SendPrompt("wake up and do this".into()), &mut app); assert!( matches!(effects.as_slice(), [Effect::SendPrompt { .. }]), - "mid-turn plain prompt takes the immediate server send, got {effects:?}" + "mid-turn plain prompt takes the immediate server send, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.agents[&id].expect_send_now_cancel.is_some(), @@ -2904,7 +2917,8 @@ fn send_prompt_now_during_reconnect_requeues_locally() { ); assert!( effects.is_empty(), - "no effect may fire while reconnecting, got {effects:?}" + "no effect may fire while reconnecting, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = &app.agents[&id]; assert_eq!( @@ -3184,7 +3198,8 @@ fn suggestion_debounce_after_bash_exit_fetches_nothing() { effects .iter() .any(|e| matches!(e, Effect::FetchShellSuggestions { .. })), - "in-mode debounce must fetch: {effects:?}" + "in-mode debounce must fetch: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // Mode exited between debounce arm and expiry: no fetch. @@ -3201,7 +3216,8 @@ fn suggestion_debounce_after_bash_exit_fetches_nothing() { !effects .iter() .any(|e| matches!(e, Effect::FetchShellSuggestions { .. })), - "post-exit debounce must fetch nothing: {effects:?}" + "post-exit debounce must fetch nothing: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -3257,7 +3273,7 @@ fn suggestions_landing_after_bash_exit_are_dropped() { }), &mut app, ); - assert!(effects.is_empty(), "landing is a no-op: {effects:?}"); + assert!(effects.is_empty(), "landing is a no-op: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); let agent = &app.agents[&id]; assert!( !agent.prompt.has_ghost_text(), @@ -3343,7 +3359,8 @@ fn tab_fetch_landing_insta_accepts_single_candidate_always_on() { .. } )), - "the accept's drill-down refetch must ride out with the dispatch: {effects:?}" + "the accept's drill-down refetch must ride out with the dispatch: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -3515,6 +3532,7 @@ fn suggestion_debounce_routes_by_agent_id_not_active_view() { .. } )), - "expiry must fetch for the arming agent even off-screen: {effects:?}" + "expiry must fetch for the arming agent even off-screen: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/rewind.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/rewind.rs index 450ffe6..951dd97 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/rewind.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/rewind.rs @@ -167,7 +167,8 @@ fn drive_inline_submit_to_execute(app: &mut AppView) -> Vec { let effects = dispatch(Action::InlineEditSubmit, app); assert!( matches!(&effects[0], Effect::FetchRewindPoints { .. }), - "got {effects:?}" + "got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); dispatch(points_loaded(id, false), app); // Target 0 is the newest prompt: conversation-only inserts its @@ -272,7 +273,8 @@ fn classic_rewind_mode_select_keeps_files_only_row() { let effects = dispatch(Action::Rewind, &mut app); assert!( matches!(&effects[0], Effect::FetchRewindPoints { .. }), - "got {effects:?}" + "got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); dispatch(points_loaded(id, true), &mut app); @@ -382,7 +384,8 @@ fn inline_edit_conversation_only_success_resubmits_and_closes_editor() { effects.iter().any( |e| matches!(e, Effect::SendPrompt { text, .. } if text == "fix the bug properly") ), - "edited prompt must be sent, got {effects:?}" + "edited prompt must be sent, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = &app.agents[&id]; assert!(agent.inline_edit.is_none(), "editor closed on success"); @@ -418,7 +421,8 @@ fn inline_edit_all_mode_previews_confirms_and_resubmits() { ); assert!( matches!(&effects[0], Effect::RewindPreview { .. }), - "file changes present → preview first, got {effects:?}" + "file changes present → preview first, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.agents[&id].pending_inline_resubmit.is_none(), @@ -470,7 +474,8 @@ fn inline_edit_all_mode_previews_confirms_and_resubmits() { effects.iter().any( |e| matches!(e, Effect::SendPrompt { text, .. } if text == "fix the bug properly") ), - "got {effects:?}" + "got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.agents[&id].inline_edit.is_none(), @@ -518,7 +523,8 @@ fn inline_edit_files_only_success_prefills_composer_without_resubmit() { effects .iter() .all(|e| !matches!(e, Effect::SendPrompt { .. })), - "files-only must not resubmit, got {effects:?}" + "files-only must not resubmit, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = &app.agents[&id]; assert!(agent.inline_edit.is_none(), "editor closed"); @@ -576,7 +582,7 @@ fn inline_edit_busy_submit_cancel_offer_confirm_cancels_and_fetches_points() { app.agents.get_mut(&id).unwrap().session.state = crate::app::agent::AgentState::TurnRunning; let effects = dispatch(Action::InlineEditSubmit, &mut app); - assert!(effects.is_empty(), "no effects yet: {effects:?}"); + assert!(effects.is_empty(), "no effects yet: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); { let agent = &app.agents[&id]; let state = agent.rewind_state.as_ref().unwrap(); @@ -595,7 +601,8 @@ fn inline_edit_busy_submit_cancel_offer_confirm_cancels_and_fetches_points() { effects .iter() .any(|e| matches!(e, Effect::FetchRewindPoints { .. })), - "got {effects:?}" + "got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = &app.agents[&id]; let state = agent.rewind_state.as_ref().unwrap(); @@ -725,7 +732,8 @@ fn inline_edit_resubmit_sends_slash_text_literally() { effects.iter().any( |e| matches!(e, Effect::SendPrompt { text, .. } if text == "/etc/hosts is wrong, fix it") ), - "slash-lookalike edit must be sent as a prompt, got {effects:?}" + "slash-lookalike edit must be sent as a prompt, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -751,7 +759,8 @@ fn inline_edit_rewind_success_after_view_switch_appends_to_draft() { effects .iter() .all(|e| !matches!(e, Effect::SendPrompt { .. })), - "no resubmit while the view is elsewhere, got {effects:?}" + "no resubmit while the view is elsewhere, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = &app.agents[&id]; assert_eq!( diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/router.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/router.rs index cc396f3..a744a07 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/router.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/router.rs @@ -169,7 +169,8 @@ fn follow_up_chip_does_not_execute_slash_command() { assert!( matches!(& effects[..], [Effect::SendPrompt { text, .. }] if text == "/always-approve"), - "chip text must be submitted literally, got {effects:?}" + "chip text must be submitted literally, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } #[test] @@ -178,7 +179,8 @@ fn follow_up_chip_does_not_execute_exit_alias() { let effects = dispatch(Action::SubmitFollowUp("quit".into()), &mut app); assert!( matches!(& effects[..], [Effect::SendPrompt { text, .. }] if text == "quit"), - "bare 'quit' chip must be a literal prompt, got {effects:?}" + "bare 'quit' chip must be a literal prompt, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } #[test] @@ -195,7 +197,8 @@ fn chip_submit_while_running_clears_follow_up_chips() { assert!( matches!(& effects[..], [Effect::SendPrompt { text, .. }] if text == "Summarize"), - "chip must immediate-send while running, got {effects:?}" + "chip must immediate-send while running, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!(app.agents[&id].session.queue_len(), 0); assert!( @@ -215,7 +218,8 @@ fn chip_submit_while_reconnect_pending_keeps_chips_and_does_not_send() { let effects = dispatch(Action::SubmitFollowUp("Summarize".into()), &mut app); assert!( effects.is_empty(), - "a reconnect-pending submit must emit no effect, got {effects:?}" + "a reconnect-pending submit must emit no effect, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!(app.agents[&id].session.queue_len(), 0); assert!( @@ -228,7 +232,8 @@ fn chip_submit_while_reconnect_pending_keeps_chips_and_does_not_send() { assert!( matches!(& effects2[..], [Effect::SendPrompt { text, .. }] if text == "Summarize"), - "after reconnect clears, the chip must submit, got {effects2:?}" + "after reconnect clears, the chip must submit, got {effects2:?}", + effects2 = crate::app::actions::effect_kinds(&effects2), ); assert!( app.agents[&id].follow_ups.is_none(), @@ -482,7 +487,8 @@ fn dispatch_send_prompt_announcements_via_registry() { .iter() .any(|e| matches!(e, Effect::PersistAnnouncementsHidden { hidden_ids } if hidden_ids.contains("crit-a"))), - "expected persist effect carrying the hidden id, got {effects:?}" + "expected persist effect carrying the hidden id, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(app.hidden_announcement_ids.contains("crit-a")); assert_eq!(shown_banner_id(&app), None, "hidden critical closes banner"); @@ -490,7 +496,7 @@ fn dispatch_send_prompt_announcements_via_registry() { let initial_scrollback_len = app.agents[&agent_id].scrollback.len(); let initial_queue_len = app.agents[&agent_id].session.queue_len(); let effects = dispatch(Action::SendPrompt("/announcements foo".into()), &mut app); - assert!(effects.is_empty(), "expected no effects, got {effects:?}"); + assert!(effects.is_empty(), "expected no effects, got {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); assert_eq!(app.agents[&agent_id].session.queue_len(), initial_queue_len); assert_eq!( app.agents[&agent_id].scrollback.len(), @@ -521,7 +527,7 @@ fn announcements_hide_is_per_id_so_new_critical_reappears() { assert_eq!(effects.len(), 1); assert_eq!(shown_banner_id(&app), None, "all hidden closes the banner"); let effects = dispatch(Action::AnnouncementsHide, &mut app); - assert!(effects.is_empty(), "expected no effects, got {effects:?}"); + assert!(effects.is_empty(), "expected no effects, got {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); app.active_announcements .push(critical_announcement("outage-c")); assert_eq!( @@ -546,7 +552,8 @@ fn announcements_show_clears_visible_critical_ids_only() { .iter() .any(|e| matches!(e, Effect::PersistAnnouncementsHidden { hidden_ids } if ! hidden_ids.contains("outage-a"))), - "expected persist effect without the un-hidden id, got {effects:?}" + "expected persist effect without the un-hidden id, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!(shown_banner_id(&app).as_deref(), Some("outage-a")); assert!( @@ -554,10 +561,10 @@ fn announcements_show_clears_visible_critical_ids_only() { "ids not currently visible must survive show (prune owns cleanup)" ); let effects = dispatch(Action::AnnouncementsShow, &mut app); - assert!(effects.is_empty(), "expected no effects, got {effects:?}"); + assert!(effects.is_empty(), "expected no effects, got {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); app.active_announcements.clear(); let effects = dispatch(Action::AnnouncementsHide, &mut app); - assert!(effects.is_empty(), "expected no effects, got {effects:?}"); + assert!(effects.is_empty(), "expected no effects, got {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); } /// Hide targets the banner-slot item: the critical while one owns the slot, /// then the promo the slot reveals — each per-ID with a persist effect. @@ -590,7 +597,7 @@ fn announcements_hide_targets_slot_owner_critical_then_promo() { assert!(app.hidden_announcement_ids.contains("promo-a")); assert_eq!(shown_banner_id(&app), None, "all hidden closes the banner"); let effects = dispatch(Action::AnnouncementsHide, &mut app); - assert!(effects.is_empty(), "expected no effects, got {effects:?}"); + assert!(effects.is_empty(), "expected no effects, got {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); } /// `dismissible: false` pins the slot owner: hide is a silent no-op (no key /// write, no persist effect, owner stays shown) — and a dismissible item @@ -605,7 +612,8 @@ fn announcements_hide_noops_for_non_dismissible_owner() { let effects = dispatch(Action::AnnouncementsHide, &mut app); assert!( effects.is_empty(), - "non-dismissible hide must not persist, got {effects:?}" + "non-dismissible hide must not persist, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.hidden_announcement_ids.is_empty(), @@ -638,7 +646,8 @@ fn announcements_show_clears_hidden_promo_ids() { .iter() .any(|e| matches!(e, Effect::PersistAnnouncementsHidden { hidden_ids } if ! hidden_ids.contains("promo-a"))), - "expected persist effect without the un-hidden promo id, got {effects:?}" + "expected persist effect without the un-hidden promo id, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!(shown_banner_id(&app).as_deref(), Some("promo-a")); assert!( @@ -787,7 +796,8 @@ fn slash_exit_dispatches_quit() { let effects = dispatch(Action::SendPrompt("/exit".into()), &mut app); assert!( effects.last().is_some_and(|e| matches!(e, Effect::Quit)), - "expected Quit as last effect, got: {effects:?}" + "expected Quit as last effect, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } #[test] @@ -807,6 +817,7 @@ fn slash_new_does_not_cancel_running_turn() { .iter() .any(|e| matches!(e, Effect::CreateSession { .. })), "expected CreateSession, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !effects @@ -923,7 +934,8 @@ fn acp_bootstrap_command_executes_as_passthrough() { assert_eq!(effects.len(), 1); assert!( matches!(& effects[0], Effect::SendPrompt { text, .. } if text == "/flush"), - "ACP command should passthrough, got: {effects:?}" + "ACP command should passthrough, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } #[test] @@ -1068,7 +1080,8 @@ fn acp_command_with_args_passthrough_includes_args() { assert!( matches!(& effects[0], Effect::SendPrompt { text, .. } if text == "/search find bugs"), - "ACP passthrough should preserve args, got: {effects:?}" + "ACP passthrough should preserve args, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } #[test] @@ -1271,7 +1284,8 @@ fn chat_mode_refuses_local_build_disk_load() { let _ = std::fs::remove_dir_all(&sess_dir); assert!( effects.is_empty(), - "local Build under --chat must refuse, got {effects:?}" + "local Build under --chat must refuse, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.agents.is_empty(), @@ -1748,7 +1762,7 @@ fn show_tasks_empty_commits_empty_message() { let mut app = test_app_with_agent(); let before = agent_scrollback_len(&app); let effects = dispatch(Action::ShowTasks, &mut app); - assert!(effects.is_empty(), "got: {effects:?}"); + assert!(effects.is_empty(), "got: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); assert_eq!(agent_scrollback_len(&app), before + 1); assert_eq!( last_system_text(&app, AgentId(0)), @@ -1774,7 +1788,7 @@ fn show_tasks_lists_a_scheduled_task() { ); } let effects = dispatch(Action::ShowTasks, &mut app); - assert!(effects.is_empty(), "got: {effects:?}"); + assert!(effects.is_empty(), "got: {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); let text = last_system_text(&app, AgentId(0)); assert!(text.contains("Task (1):"), "got: {text:?}"); assert!( @@ -2148,7 +2162,7 @@ fn pick_content_session_in_worktree_refuses_conversation_row() { }, &mut app, ); - assert!(effects.is_empty(), "no worktree effects, got {effects:?}"); + assert!(effects.is_empty(), "no worktree effects, got {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); assert!( !app.deferred_startup.pending_chat, "refusal must not set the one-shot chat bit" @@ -2170,7 +2184,8 @@ fn delete_session_refuses_conversation_row() { ); assert!( effects.is_empty(), - "no DeleteSession effect for a conversation row, got {effects:?}" + "no DeleteSession effect for a conversation row, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(read_toast(&app).contains("isn't supported")); } @@ -2190,7 +2205,8 @@ fn expand_conversation_card_skips_detail_load() { ); assert!( effects.is_empty(), - "no LoadCardDetail for a conversation row, got {effects:?}" + "no LoadCardDetail for a conversation row, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = get_active_agent(&app).expect("active agent"); let Some(ActiveModal::SessionPicker { state, .. }) = agent.active_modal.as_ref() else { @@ -2212,7 +2228,8 @@ fn expand_build_card_still_loads_detail() { ); assert!( matches!(&effects[..], [Effect::LoadCardDetail { .. }]), - "expected LoadCardDetail, got {effects:?}" + "expected LoadCardDetail, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Welcome-screen variants of the conversation-card expand exemption. @@ -2229,7 +2246,8 @@ fn welcome_expand_conversation_card_skips_detail_load() { ); assert!( effects.is_empty(), - "no LoadCardDetail for a welcome conversation row, got {effects:?}" + "no LoadCardDetail for a welcome conversation row, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.session_picker_state.expanded.contains(&0), @@ -2246,7 +2264,8 @@ fn welcome_expand_conversation_card_skips_detail_load() { ); assert!( matches!(&effects[..], [Effect::LoadCardDetail { .. }]), - "expected LoadCardDetail, got {effects:?}" + "expected LoadCardDetail, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Collect the active agent's system-block texts. diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/fork.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/fork.rs index 414ad7d..543f008 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/fork.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/fork.rs @@ -415,7 +415,8 @@ fn dispatch_fork_no_flag_non_git_skips_modal_and_forks_without_worktree() { // Must skip the modal and emit ForkSession immediately. assert!( matches!(effects.as_slice(), [Effect::ForkSession { .. }]), - "non-git cwd must skip modal and emit ForkSession, got {effects:?}" + "non-git cwd must skip modal and emit ForkSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.agents.values().all(|a| a.question_view.is_none()), @@ -831,7 +832,8 @@ fn dispatch_fork_worktree_mode_always_skips_modal_and_creates_worktree() { effects .iter() .any(|e| matches!(e, Effect::CreateWorktreeSession { .. })), - "expected CreateWorktreeSession, got {effects:?}" + "expected CreateWorktreeSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -848,7 +850,8 @@ fn dispatch_fork_worktree_mode_never_skips_modal_and_forks_in_cwd() { effects .iter() .any(|e| matches!(e, Effect::ForkSession { .. })), - "expected ForkSession, got {effects:?}" + "expected ForkSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -896,7 +899,8 @@ fn dispatch_new_session_answered_no_worktree_does_not_cancel_old_turn() { effects .iter() .any(|e| matches!(e, Effect::CreateSession { .. })), - "expected CreateSession, got {effects:?}" + "expected CreateSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !effects @@ -925,7 +929,8 @@ fn dispatch_new_session_answered_worktree_does_not_cancel_old_turn() { effects .iter() .any(|e| matches!(e, Effect::CreateWorktreeSession { .. })), - "expected CreateWorktreeSession, got {effects:?}" + "expected CreateWorktreeSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // Old agent's turn must still be running. assert!( @@ -947,7 +952,8 @@ fn dispatch_new_session_worktree_mode_always_skips_modal_and_creates_worktree() effects .iter() .any(|e| matches!(e, Effect::CreateWorktreeSession { .. })), - "expected CreateWorktreeSession, got {effects:?}" + "expected CreateWorktreeSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -964,7 +970,8 @@ fn dispatch_new_session_worktree_mode_never_skips_modal_and_creates_in_cwd() { effects .iter() .any(|e| matches!(e, Effect::CreateSession { .. })), - "expected CreateSession, got {effects:?}" + "expected CreateSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1043,7 +1050,8 @@ fn fork_session_ready_refuses_local_build_under_chat_mode() { let _ = std::fs::remove_dir_all(&sess_dir); assert!( effects.is_empty(), - "ForkSessionReady under --chat must refuse local Build, got {effects:?}" + "ForkSessionReady under --chat must refuse local Build, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !app.agents.contains_key(&AgentId(1)), diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/lifecycle.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/lifecycle.rs index a0c790a..ebc9365 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/lifecycle.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/lifecycle.rs @@ -104,7 +104,8 @@ fn chip_submit_without_session_keeps_chips_and_does_not_send() { !effects .iter() .any(|e| matches!(e, Effect::SendPrompt { .. })), - "an unbound-session submit must emit no SendPrompt, got {effects:?}" + "an unbound-session submit must emit no SendPrompt, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.agents[&id].follow_ups.is_some(), @@ -741,7 +742,8 @@ fn exit_session_unregisters_active_session() { effects .iter() .any(|e| matches!(e, Effect::UnregisterActiveSession { .. })), - "ExitSession must emit UnregisterActiveSession, got: {effects:?}" + "ExitSession must emit UnregisterActiveSession, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(matches!(app.active_view, ActiveView::Welcome)); } @@ -1093,7 +1095,8 @@ fn chat_mode_new_session_creates_with_chat_kind() { .. } )), - "expected chat CreateSession under --chat, got {effects:?}" + "expected chat CreateSession under --chat, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Atomicity: when several startup intents coexist (e.g. CLI @@ -1413,7 +1416,8 @@ fn auth_complete_retries_stashed_prompt_after_mid_session_login() { .iter() .any(|e| matches!(e, Effect::SendPrompt { text, .. } if text == "retry me")), - "the stashed prompt must be auto-resubmitted, got: {effects:?}" + "the stashed prompt must be auto-resubmitted, got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } #[test] @@ -1671,7 +1675,8 @@ fn cycle_mode_plan_exit_under_gate_off_pin_emits_set_session_mode() { effects .iter() .any(|e| matches!(e, Effect::SetSessionMode { .. })), - "Plan exit under the pin must still send SetSessionMode(Default), got {effects:?}" + "Plan exit under the pin must still send SetSessionMode(Default), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( effects.iter().any(|e| matches!( @@ -1681,7 +1686,8 @@ fn cycle_mode_plan_exit_under_gate_off_pin_emits_set_session_mode() { .. } )), - "expected PersistPermissionMode(ask) under pin, got {effects:?}" + "expected PersistPermissionMode(ask) under pin, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Pre-session cycle (no session id yet) under the pin: Plan → Auto does @@ -1767,7 +1773,8 @@ fn dispatch_cycle_mode_pre_session_cycles_locally_and_creates_session() { effects .iter() .any(|e| matches!(e, Effect::CreateSession { .. })), - "first press must create the session, got {effects:?}" + "first press must create the session, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let effects = dispatch(Action::CycleMode, &mut app); let agent = &app.agents[&AgentId(0)]; @@ -1779,7 +1786,8 @@ fn dispatch_cycle_mode_pre_session_cycles_locally_and_creates_session() { !effects .iter() .any(|e| matches!(e, Effect::CreateSession { .. })), - "no duplicate CreateSession, got {effects:?}" + "no duplicate CreateSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( effects.iter().any(|e| matches!( @@ -1790,7 +1798,8 @@ fn dispatch_cycle_mode_pre_session_cycles_locally_and_creates_session() { .. } )), - "pre-session Plan → Auto must persist the displayed mode, got {effects:?}" + "pre-session Plan → Auto must persist the displayed mode, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let _ = dispatch(Action::CycleMode, &mut app); let agent = &app.agents[&AgentId(0)]; @@ -1839,7 +1848,8 @@ fn set_plan_mode_on_from_off_emits_set_session_mode() { assert!( matches!(& effects[0], Effect::SetSessionMode { mode_id, .. } if &* mode_id.0 == "plan"), - "expected SetSessionMode(plan), got: {effects:?}" + "expected SetSessionMode(plan), got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let agent = app.agents.get(&AgentId(0)).unwrap(); assert_eq!( diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/load.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/load.rs index 70f9c64..57d0501 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/load.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/load.rs @@ -18,7 +18,8 @@ fn follow_up_chip_bypasses_project_picker() { assert!( matches!(& effects[..], [Effect::SendPrompt { text, .. }] if text == "Summarize this"), - "chip text must be sent literally, not swallowed, got {effects:?}" + "chip text must be sent literally, not swallowed, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Opening the cancel-turn picker while scrollback is focused must @@ -631,7 +632,8 @@ fn resume_known_session_id_loads_not_creates() { .iter() .any(|e| matches!(e, Effect::LoadSession { session_id, .. } if session_id == "resume-known-id")), - "expected LoadSession, got {effects:?}" + "expected LoadSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !effects @@ -660,7 +662,8 @@ fn session_restored_refuses_local_build_under_chat_mode() { let _ = std::fs::remove_dir_all(&sess_dir); assert!( effects.is_empty(), - "SessionRestored must refuse Build under --chat, got {effects:?}" + "SessionRestored must refuse Build under --chat, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !app.agents.contains_key(&id), @@ -1155,7 +1158,8 @@ fn resume_after_load_failed_reissues_load() { .iter() .any(|e| matches!(e, Effect::LoadSession { agent_id, session_id, .. } if * agent_id != agent_0 && session_id == "fail-then-retry")), - "retry after failure must emit LoadSession for a new agent, got {effects:?}" + "retry after failure must emit LoadSession for a new agent, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!(app.agents.len(), count_before + 1); } @@ -1452,7 +1456,8 @@ fn pick_conversation_row_dispatches_direct_chat_load() { assert!( matches!(& effects[..], [Effect::LoadSession { session_id, session_cwd : None, chat_kind : true, .. }] if session_id == "conv-pick-1"), - "expected a direct chat LoadSession, got {effects:?}" + "expected a direct chat LoadSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Welcome-screen variant of the conversation-row pick. @@ -1464,7 +1469,8 @@ fn pick_conversation_row_from_welcome_dispatches_direct_chat_load() { assert!( matches!(& effects[..], [Effect::LoadSession { session_id, session_cwd : None, chat_kind : true, .. }] if session_id == "conv-pick-2"), - "expected a direct chat LoadSession, got {effects:?}" + "expected a direct chat LoadSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Canary: a remote Build row not on disk still takes the GCS-restore path. @@ -1479,7 +1485,8 @@ fn pick_remote_build_row_still_restores() { assert!( matches!(& effects[..], [Effect::RestoreAndLoadSession { session_id, .. }] if * session_id == id), - "expected RestoreAndLoadSession, got {effects:?}" + "expected RestoreAndLoadSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// A content-search hit matching a co-displayed conversation row also @@ -1498,7 +1505,8 @@ fn pick_content_session_conversation_row_dispatches_direct_chat_load() { assert!( matches!(& effects[..], [Effect::LoadSession { session_id, session_cwd : None, chat_kind : true, .. }] if session_id == "conv-hit-1"), - "expected a direct chat LoadSession, got {effects:?}" + "expected a direct chat LoadSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Worktree resume is refused for conversation rows (no cwd to check out); @@ -1508,7 +1516,7 @@ fn pick_session_in_worktree_refuses_conversation_row() { let mut app = test_app_with_agent(); open_session_picker_with(&mut app, vec![make_conversation_entry("conv-wt-1")]); let effects = dispatch(Action::PickSessionInWorktree(0), &mut app); - assert!(effects.is_empty(), "no worktree effects, got {effects:?}"); + assert!(effects.is_empty(), "no worktree effects, got {effects:?}", effects = crate::app::actions::effect_kinds(&effects)); assert!( !app.deferred_startup.pending_chat, "refusal must not set the one-shot chat bit" @@ -1527,7 +1535,8 @@ fn chat_mode_query_change_schedules_debounced_search() { assert!( matches!(& effects[..], [Effect::DebounceSessionSearch { query, seq : 1 }] if query == "abc"), - "chat-mode query change must arm the search debounce, got {effects:?}" + "chat-mode query change must arm the search debounce, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!(app.session_picker_list_seq, 1, "trigger must bump the seq"); assert!( @@ -1538,7 +1547,8 @@ fn chat_mode_query_change_schedules_debounced_search() { let effects = dispatch(Action::ForceDeepSearch, &mut app); assert!( matches!(&effects[..], [Effect::DeepSearchSessions { .. }]), - "Build-mode deep search unchanged, got {effects:?}" + "Build-mode deep search unchanged, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!( app.session_picker_list_seq, 1, @@ -1563,7 +1573,8 @@ fn chat_mode_debounce_expiry_fetches_current_and_drops_stale() { assert!( matches!(& effects[..], [Effect::FetchSessionList { query : Some(q), seq : 1 }] if q == "abc"), - "current debounce expiry must fetch with the query, got {effects:?}" + "current debounce expiry must fetch with the query, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); app.session_picker_state.query = "abcd".into(); let _ = dispatch(Action::TriggerDeepSearch, &mut app); @@ -1576,7 +1587,8 @@ fn chat_mode_debounce_expiry_fetches_current_and_drops_stale() { ); assert!( effects.is_empty(), - "stale debounce expiry must not fetch, got {effects:?}" + "stale debounce expiry must not fetch, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Build mode: any 2+ char query arms the deep-search debounce — abundant @@ -1596,7 +1608,8 @@ fn build_mode_query_arms_debounce_despite_title_hits_and_force_skips_it() { assert!( matches!(& effects[..], [Effect::DebounceSessionSearch { query, seq : 1 }] if query == "prost"), - "unforced query must arm the debounce even with 3+ title hits, got {effects:?}" + "unforced query must arm the debounce even with 3+ title hits, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.session_picker_content_loading, @@ -1610,7 +1623,8 @@ fn build_mode_query_arms_debounce_despite_title_hits_and_force_skips_it() { assert!( matches!(& effects[..], [Effect::DeepSearchSessions { query, seq : 2 }] if query == "prost"), - "forced search must skip the debounce, got {effects:?}" + "forced search must skip the debounce, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Build mode: a sub-2-char query clears the content results AND @@ -1626,7 +1640,8 @@ fn build_mode_short_query_clears_results_and_invalidates_armed_debounce() { let effects = dispatch(Action::TriggerDeepSearch, &mut app); assert!( effects.is_empty(), - "short query emits nothing, got {effects:?}" + "short query emits nothing, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(app.session_picker_content_results.is_none()); assert!(!app.session_picker_content_loading); @@ -1639,7 +1654,8 @@ fn build_mode_short_query_clears_results_and_invalidates_armed_debounce() { ); assert!( effects.is_empty(), - "expiry armed before the clear must not search, got {effects:?}" + "expiry armed before the clear must not search, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Build mode: a current-seq debounce expiry dispatches the deep search; one @@ -1659,7 +1675,8 @@ fn build_mode_debounce_expiry_searches_current_and_drops_stale() { assert!( matches!(& effects[..], [Effect::DeepSearchSessions { query, seq : 1 }] if query == "abc"), - "current expiry must dispatch the deep search, got {effects:?}" + "current expiry must dispatch the deep search, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); app.session_picker_state.query = "abcd".into(); let _ = dispatch(Action::TriggerDeepSearch, &mut app); @@ -1672,7 +1689,8 @@ fn build_mode_debounce_expiry_searches_current_and_drops_stale() { ); assert!( effects.is_empty(), - "stale expiry must be dropped, got {effects:?}" + "stale expiry must be dropped, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Modal `/resume` surface: the debounce expiry validates against the @@ -1692,7 +1710,8 @@ fn build_mode_modal_debounce_expiry_validates_modal_seq() { let effects = dispatch(Action::TriggerDeepSearch, &mut app); assert!( matches!(&effects[..], [Effect::DebounceSessionSearch { seq: 1, .. }]), - "modal query must arm the debounce, got {effects:?}" + "modal query must arm the debounce, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let effects = dispatch( Action::TaskComplete(TaskResult::SessionSearchDebounceExpired { @@ -1704,7 +1723,8 @@ fn build_mode_modal_debounce_expiry_validates_modal_seq() { assert!( matches!(& effects[..], [Effect::DeepSearchSessions { query, seq : 1 }] if query == "abc"), - "expiry must validate against the modal seq, got {effects:?}" + "expiry must validate against the modal seq, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Dismissing the welcome picker invalidates an armed deep-search debounce: @@ -1730,7 +1750,8 @@ fn build_mode_picker_close_invalidates_armed_debounce() { ); assert!( effects.is_empty(), - "expiry armed before the close must not search, got {effects:?}" + "expiry armed before the close must not search, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Modal-armed debounce + modal close: the dismissal bump lands on the @@ -1770,7 +1791,8 @@ fn build_mode_modal_close_drops_armed_debounce_despite_seq_collision() { ); assert!( effects.is_empty(), - "modal-armed expiry must not search after the modal closed, got {effects:?}" + "modal-armed expiry must not search after the modal closed, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Ctrl+/ (forced search) skips the debounce; clearing the query @@ -1785,7 +1807,8 @@ fn chat_mode_force_search_fetches_immediately_and_empty_query_unfilters() { assert!( matches!(& effects[..], [Effect::FetchSessionList { query : Some(q), seq : 1 }] if q == "abc"), - "forced search must fetch without debouncing, got {effects:?}" + "forced search must fetch without debouncing, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.session_picker_content_loading, @@ -1801,7 +1824,8 @@ fn chat_mode_force_search_fetches_immediately_and_empty_query_unfilters() { seq: 2 }] ), - "cleared query must refetch the unfiltered list immediately (no debounce), got {effects:?}" + "cleared query must refetch the unfiltered list immediately (no debounce), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !app.session_picker_content_loading, @@ -1828,7 +1852,8 @@ fn chat_mode_search_reads_modal_query_first() { assert!( matches!(& effects[..], [Effect::FetchSessionList { query : Some(q), .. }] if q == "modal-query"), - "modal query must win over the welcome picker's, got {effects:?}" + "modal query must win over the welcome picker's, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Out-of-order list completions: only the response for the current seq @@ -2002,7 +2027,8 @@ fn modal_close_drops_in_flight_search_response() { let effects = dispatch(Action::SessionPickerClosed, &mut app); assert!( effects.is_empty(), - "close is a pure invalidation, got {effects:?}" + "close is a pure invalidation, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let _ = dispatch( Action::TaskComplete(TaskResult::SessionListLoaded { @@ -2044,7 +2070,8 @@ fn modal_pick_drops_in_flight_search_response() { let effects = dispatch(Action::PickSession(0), &mut app); assert!( matches!(&effects[..], [Effect::LoadSession { .. }]), - "pick must still load the session, got {effects:?}" + "pick must still load the session, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.session_picker_list_seq > seq, @@ -2316,7 +2343,8 @@ fn modal_failed_search_clears_indicator_and_plain_failure_preserves_spinner() { let effects = dispatch(Action::ForceDeepSearch, &mut app); assert!( matches!(&effects[..], [Effect::DeepSearchSessions { .. }]), - "Build-mode modal deep search armed, got {effects:?}" + "Build-mode modal deep search armed, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); let _ = dispatch( Action::TaskComplete(TaskResult::SessionListFailed { @@ -2349,7 +2377,8 @@ fn build_mode_list_response_preserves_deep_search_spinner() { let effects = dispatch(Action::ForceDeepSearch, &mut app); assert!( matches!(&effects[..], [Effect::DeepSearchSessions { .. }]), - "Build-mode deep search armed, got {effects:?}" + "Build-mode deep search armed, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(app.session_picker_content_loading, "deep search in flight"); let _ = dispatch( @@ -2398,7 +2427,8 @@ fn build_mode_rapid_plain_fetches_keep_last_write_wins() { seq: 0 }] ), - "Build-mode plain fetch must not bump the seq, got {effects:?}" + "Build-mode plain fetch must not bump the seq, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } assert_eq!( @@ -2455,6 +2485,7 @@ fn plain_picker_fetch_carries_no_query_and_bumps_seq() { seq: 2 }] ), - "picker fetch must be unfiltered and supersede the search, got {effects:?}" + "picker fetch must be unfiltered and supersede the search, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/modal.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/modal.rs index 99f9e08..7a58e4e 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/modal.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/session/modal.rs @@ -120,7 +120,8 @@ fn dispatch_new_session_skips_modal_in_non_git_repo() { effects .iter() .any(|e| matches!(e, Effect::CreateSession { .. })), - "non-git path must emit CreateSession, got {effects:?}" + "non-git path must emit CreateSession, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.agents.values().all(|a| a.question_view.is_none()), diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/settings.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/settings.rs index e4ea7eb..234e200 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/settings.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/settings.rs @@ -19,6 +19,7 @@ fn toggle_vim_mode_flips_state_and_persistence_cache() { 1, "toggle must emit exactly one Effect::PersistSetting so the \ new value lands in config.toml, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( matches!( @@ -30,7 +31,7 @@ fn toggle_vim_mode_flips_state_and_persistence_cache() { } ), "unexpected effect: {:?}", - effects[0], + crate::app::actions::effect_kinds(&effects[0]), ); assert!( app.agents[&id].vim_mode, @@ -51,6 +52,7 @@ fn toggle_vim_mode_flips_state_and_persistence_cache() { }] ), "second toggle must also persist, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(!app.agents[&id].vim_mode, "toggling again flips it back"); assert!( @@ -282,6 +284,7 @@ fn slash_model_valid_dispatches_set_default_model_with_switch_and_persist() { effects.len(), 2, "expected PersistSetting + SwitchModel effects, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( matches!( @@ -292,13 +295,13 @@ fn slash_model_valid_dispatches_set_default_model_with_switch_and_persist() { } ), "first effect must be PersistSetting(default_model), got {:?}", - effects[0], + crate::app::actions::effect_kinds(&effects[0]), ); assert!( matches!(& effects[1], Effect::SwitchModel { model_id : mid, .. } if mid == & model_id), "second effect must be SwitchModel(), got {:?}", - effects[1], + crate::app::actions::effect_kinds(&effects[1]), ); assert!(app.agents[&id].session.model_switch_pending); } @@ -366,7 +369,7 @@ fn set_compact_mode_emits_persist_setting_with_correct_payload() { assert_eq!(value, &SettingValue::Bool(true)); assert_eq!(rollback_value, &SettingValue::Bool(false)); } - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert!(app.current_ui.compact_mode); } @@ -519,7 +522,7 @@ fn toggle_off_while_short_keeps_render_compact_and_persists_user_value() { assert_eq!(*key, "compact_mode"); assert_eq!(value, &SettingValue::Bool(false), "persists the USER value"); } - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } let toast = read_toast(&app); assert!( @@ -543,7 +546,7 @@ fn set_timestamps_emits_persist_setting_with_correct_payload() { assert_eq!(value, &SettingValue::Bool(false)); assert_eq!(rollback_value, &SettingValue::Bool(true)); } - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert_eq!(app.current_ui.show_timestamps, Some(false)); } @@ -564,7 +567,7 @@ fn set_timeline_emits_persist_setting_with_correct_payload() { assert_eq!(value, &SettingValue::Bool(!default_on)); assert_eq!(rollback_value, &SettingValue::Bool(default_on)); } - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert_eq!(app.current_ui.show_timeline, Some(!default_on)); assert_eq!( @@ -605,7 +608,7 @@ fn set_simple_mode_emits_persist_setting_with_correct_payload() { assert_eq!(*key, "simple_mode"); assert_eq!(value, &SettingValue::Bool(false)); } - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert_eq!(app.current_ui.simple_mode, Some(false)); } @@ -703,6 +706,7 @@ fn dispatch_confirm_reset_setting_cancel_preserves_modal_state() { assert!( effects.is_empty(), "Cancel branch must not emit any Effects, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.current_ui.compact_mode, @@ -744,6 +748,7 @@ fn dispatch_confirm_reset_setting_reset_dispatches_typed_setter_for_pager_bool() assert!( effects.is_empty(), "PAGER reset must NOT emit Effects (in-memory only), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !app.agents[&AgentId(0)].multiline_mode, @@ -770,6 +775,7 @@ fn dispatch_confirm_reset_setting_reset_on_already_default_is_no_op_with_toast() assert!( effects.is_empty(), "idempotent reset must emit NO Effects, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(!app.current_ui.compact_mode); let agent = app.agents.get(&AgentId(0)).expect("agent must exist"); @@ -927,8 +933,10 @@ fn every_persisting_setting_has_rollback_arm() { assert_ne!( toast.as_deref(), Some(crate::app::dispatch::ROLLBACK_NO_ARM_TOAST), - "Setting `{key}` emits Effect::PersistSetting but apply_setting_rollback has \ - no arm — a failed persist diverges in-memory state from config.toml. Add an arm.", + "Resetting setting `{reset_key}` emits an Effect::PersistSetting whose key has no \ + apply_setting_rollback arm — a failed persist diverges in-memory state from \ + config.toml. Add an arm.", + reset_key = meta.key, ); } } @@ -969,7 +977,7 @@ fn clear_default_model_persists_but_keeps_live_current() { matches!(& effects[0], Effect::PersistSetting { key : "default_model", value : crate ::settings::SettingValue::String(s), .. } if s.is_empty()), "expected PersistSetting(default_model, ''), got {:?}", - effects[0], + crate::app::actions::effect_kinds(&effects[0]), ); assert_eq!( app.agents[&agent_id].session.models.current, @@ -1035,6 +1043,7 @@ fn set_default_model_idempotent_when_already_current() { assert!( effects.is_empty(), "re-dispatching same model must be idempotent (no effects), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// `clamp_max_thoughts_width` clamps @@ -1066,7 +1075,7 @@ fn pr13_set_show_tips_emits_persist_with_rollback() { assert_eq!(*value, SettingValue::Bool(false)); assert_eq!(*rollback_value, SettingValue::Bool(true)); } - other => panic!("expected PersistSetting effect, got {other:?}"), + other => panic!("expected PersistSetting effect, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert_eq!(app.show_tips, Some(false)); let effects = dispatch(Action::SetShowTips(true), &mut app); @@ -1080,7 +1089,7 @@ fn pr13_set_show_tips_emits_persist_with_rollback() { assert_eq!(*value, SettingValue::Bool(true)); assert_eq!(*rollback_value, SettingValue::Bool(false)); } - other => panic!("expected PersistSetting effect, got {other:?}"), + other => panic!("expected PersistSetting effect, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert_eq!(app.show_tips, Some(true)); } @@ -1094,7 +1103,8 @@ fn pr13_set_show_tips_idempotent_re_commit() { let effects = dispatch(Action::SetShowTips(false), &mut app); assert!( effects.is_empty(), - "re-committing the same value must be idempotent (no effects), got {effects:?}" + "re-committing the same value must be idempotent (no effects), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// The first-ever commit of the default value MUST persist @@ -1127,7 +1137,8 @@ fn pr13_set_show_tips_second_commit_of_default_no_ops() { assert!( effects.is_empty(), "second commit of the default must hit the idempotent fast-path \ - (effects.is_empty()), got {effects:?}" + (effects.is_empty()), got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } /// Rollback from prev=None state restores `None` (not @@ -1400,7 +1411,7 @@ fn set_simple_mode_no_op_when_no_active_agent() { assert_eq!(effects.len(), 1); match &effects[0] { Effect::PersistSetting { key, .. } => assert_eq!(*key, "simple_mode"), - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert_eq!(app.current_ui.simple_mode, Some(true)); } @@ -1519,6 +1530,7 @@ fn set_multiline_mode_mutates_agent_and_emits_no_effect() { assert!( effects.is_empty(), "PAGER-owned setter must NOT emit Effect::PersistSetting, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.agents[&AgentId(0)].multiline_mode, @@ -1755,6 +1767,7 @@ fn set_vim_mode_mutates_all_agents_and_cache_no_effect() { effects.len(), 1, "SHELL-owned setter must emit exactly one Effect::PersistSetting, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( matches!( @@ -1766,7 +1779,7 @@ fn set_vim_mode_mutates_all_agents_and_cache_no_effect() { } ), "unexpected effect: {:?}", - effects[0], + crate::app::actions::effect_kinds(&effects[0]), ); assert!( crate::appearance::cache::load_vim_mode(), @@ -1813,7 +1826,8 @@ fn set_keep_text_selection_emits_persist_and_updates_cache() { rollback_value: crate::settings::SettingValue::Enum("flash"), }] ), - "unexpected effects: {effects:?}" + "unexpected effects: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } #[test] @@ -1874,6 +1888,7 @@ fn set_show_thinking_blocks_applies_persists_and_rolls_back() { }] ), "expected exactly one PersistSetting effect, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(!crate::appearance::cache::load_show_thinking_blocks()); let effects = dispatch(Action::SetShowThinkingBlocks(false), &mut app); @@ -1903,6 +1918,7 @@ fn set_group_tool_verbs_applies_persists_and_rolls_back() { }] ), "expected exactly one PersistSetting effect, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(!crate::appearance::cache::load_group_tool_verbs()); let effects = dispatch(Action::SetGroupToolVerbs(false), &mut app); @@ -2017,6 +2033,7 @@ fn set_collapsed_edit_blocks_applies_persists_and_rolls_back() { }] ), "expected exactly one PersistSetting effect, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(crate::appearance::cache::load_collapsed_edit_blocks()); let effects = dispatch(Action::SetCollapsedEditBlocks(true), &mut app); @@ -2315,6 +2332,7 @@ fn set_respect_manual_folds_applies_persists_and_rolls_back() { }] ), "expected exactly one PersistSetting effect, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(app.appearance.scrollback.scroll.respect_manual_folds); for (id, agent) in &app.agents { @@ -2366,6 +2384,7 @@ fn set_render_mermaid_persists_and_is_idempotent() { effects.len(), 1, "SHELL-owned setter must emit one Effect::PersistSetting, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( matches!( @@ -2377,7 +2396,7 @@ fn set_render_mermaid_persists_and_is_idempotent() { } ), "unexpected effect: {:?}", - effects[0], + crate::app::actions::effect_kinds(&effects[0]), ); assert!(read_toast(&app).contains("Mermaid")); app.agents.get_mut(&AgentId(0)).unwrap().toast = None; @@ -2429,6 +2448,7 @@ fn set_scroll_speed_updates_cache_and_clamps() { effects.len(), 1, "SHELL-owned setter must emit exactly one Effect::PersistSetting, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( matches!( @@ -2440,7 +2460,7 @@ fn set_scroll_speed_updates_cache_and_clamps() { } ), "unexpected effect: {:?}", - effects[0], + crate::app::actions::effect_kinds(&effects[0]), ); assert_eq!(crate::appearance::cache::load_scroll_speed(), 75); let _ = dispatch(Action::SetScrollSpeed(250), &mut app); @@ -2499,6 +2519,7 @@ fn set_scroll_mode_updates_cache_and_scroll_config() { }] ), "unexpected effects: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!( crate::appearance::cache::load_scroll_mode(), @@ -2532,6 +2553,7 @@ fn set_invert_scroll_updates_cache_and_scroll_config() { }] ), "unexpected effects: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(crate::appearance::cache::load_invert_scroll()); assert!( @@ -2564,6 +2586,7 @@ fn set_scroll_lines_updates_cache_and_clamps() { }] ), "unexpected effects: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!(crate::appearance::cache::load_scroll_lines(), Some(5)); let debug = format!("{:?}", app.scroll_config); @@ -2748,7 +2771,7 @@ fn set_theme_emits_persist_setting_with_correct_payload() { assert_eq!(*value, SettingValue::Enum("ainxtday")); assert_eq!(*rollback_value, SettingValue::Enum("ainxtnight")); } - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert_eq!(app.current_ui.theme.as_deref(), Some("ainxtday")); assert!( @@ -2780,7 +2803,7 @@ fn set_auto_dark_theme_emits_persist_setting_with_correct_payload() { assert_eq!(*value, SettingValue::Enum("ainxtday")); assert_eq!(*rollback_value, SettingValue::Enum("ainxtnight")); } - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert_eq!(app.current_ui.auto_dark_theme.as_deref(), Some("ainxtday")); }); @@ -2802,7 +2825,7 @@ fn set_auto_light_theme_emits_persist_setting_with_correct_payload() { assert_eq!(*value, SettingValue::Enum("ainxtnight")); assert_eq!(*rollback_value, SettingValue::Enum("ainxtday")); } - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert_eq!( app.current_ui.auto_light_theme.as_deref(), @@ -2825,7 +2848,8 @@ fn preview_theme_emits_no_persist_effect_and_no_current_ui_mutation() { .count(); assert_eq!( persist_count, 0, - "preview must NOT emit PersistSetting, got {persist_count} (effects: {effects:?})", + "preview must NOT emit PersistSetting (effects: {effects:?})", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!( app.current_ui.theme, initial, diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/status.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/status.rs index 3a9ed37..679862c 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/status.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/status.rs @@ -41,7 +41,7 @@ fn send_while_idle_with_nonempty_shared_queue_routes_to_server() { } if text == "c" => Some(prompt_id.clone()), _ => None, }) - .unwrap_or_else(|| panic!("expected immediate SendPrompt for 'c', got {effects:?}")); + .unwrap_or_else(|| panic!("expected immediate SendPrompt for 'c', got {effects:?}", effects = crate::app::actions::effect_kinds(&effects))); // Did NOT start a local turn or adopt "c" as the running prompt. assert!( !app.agents[&id].session.state.is_turn_running(), @@ -289,7 +289,7 @@ fn set_coding_data_sharing_allowed_for_admin() { "rollback_to_opted_in must capture pre-toggle opt-in=true", ); } - other => panic!("expected SetCodingDataSharing Effect, got {other:?}"), + other => panic!("expected SetCodingDataSharing Effect, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } // Optimistic mutation already applied. assert!( @@ -319,7 +319,7 @@ fn set_coding_data_sharing_produces_effect_and_optimistic_mutation() { "rollback_to_opted_in must be pre-toggle value (true == opted-in)", ); } - other => panic!("expected SetCodingDataSharing Effect, got {other:?}"), + other => panic!("expected SetCodingDataSharing Effect, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } // Optimistic mutation applied. assert!( @@ -837,7 +837,7 @@ fn dispatch_confirm_reset_setting_reset_dispatches_typed_setter_for_shared_bool( assert_eq!(*key, "compact_mode"); assert_eq!(value, &SettingValue::Bool(false)); } - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } // In-memory state is reset to the default. assert!(!app.current_ui.compact_mode); @@ -883,7 +883,7 @@ fn dispatch_confirm_reset_setting_reset_dispatches_typed_setter_for_shared_enum( assert_eq!(*key, "theme"); assert_eq!(value, &SettingValue::Enum("ainxtnight")); } - other => panic!("expected PersistSetting, got {other:?}"), + other => panic!("expected PersistSetting, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } assert_eq!(app.current_ui.theme.as_deref(), Some("ainxtnight")); } @@ -906,7 +906,8 @@ fn show_usage_with_redirect_url_shows_link_and_skips_fetch() { let effects = dispatch(Action::ShowUsage, &mut app); assert!( effects.is_empty(), - "with a redirect URL set, ShowUsage should not fetch (billing or auto-topup), got: {effects:?}" + "with a redirect URL set, ShowUsage should not fetch (billing or auto-topup), got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert_eq!( agent_scrollback_len(&app), diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/task_result.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/task_result.rs index c745e87..a896b4f 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/task_result.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/task_result.rs @@ -705,7 +705,7 @@ fn switch_model_complete_persists_resolved_effort_from_catalog_meta() { fresh chat does not reset reasoning to a stale default", ); } - other => panic!("expected PersistPreferredModel, got {other:?}"), + other => panic!("expected PersistPreferredModel, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } } @@ -767,7 +767,7 @@ fn switch_to_non_reasoning_model_clears_persisted_effort() { "persisted effort must be None so config.toml clears the stale value", ); } - other => panic!("expected PersistPreferredModel, got {other:?}"), + other => panic!("expected PersistPreferredModel, got {other:?}", other = crate::app::actions::effect_kinds(&other)), } } @@ -1576,7 +1576,8 @@ fn gate_refreshed_emits_check_subscription_on_gate_lift() { effects .iter() .any(|e| matches!(e, Effect::CheckSubscription { verify: None })), - "must emit CheckSubscription to refresh JWT; got: {effects:?}" + "must emit CheckSubscription to refresh JWT; got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1652,13 +1653,15 @@ fn gate_refreshed_newly_blocked_defers_gate_for_verification() { effects .iter() .any(|e| matches!(e, Effect::CheckSubscription { verify: Some(_) })), - "must live-check before showing the paywall; got: {effects:?}" + "must live-check before showing the paywall; got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( effects .iter() .any(|e| matches!(e, Effect::ScheduleGateVerifyTimeout { .. })), - "must arm the verification timeout; got: {effects:?}" + "must arm the verification timeout; got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1738,7 +1741,8 @@ fn verify_check_failure_promotes_pending_gate() { effects .iter() .any(|e| matches!(e, Effect::SchedulePaywallCheck)), - "freshly shown gate must arm the 5s auto-lift chain; got: {effects:?}" + "freshly shown gate must arm the 5s auto-lift chain; got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1837,7 +1841,8 @@ fn gate_verify_timeout_promotes_pending_gate() { effects .iter() .any(|e| matches!(e, Effect::SchedulePaywallCheck)), - "promoted gate must schedule the 5s chain; got: {effects:?}" + "promoted gate must schedule the 5s chain; got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1934,7 +1939,8 @@ fn verified_gate_via_check_complete_starts_paywall_chain() { effects .iter() .any(|e| matches!(e, Effect::SchedulePaywallCheck)), - "verified gate must schedule the 5s chain; got: {effects:?}" + "verified gate must schedule the 5s chain; got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // Steady-state paywall-poller responses (already gated) must NOT fan @@ -1953,7 +1959,8 @@ fn verified_gate_via_check_complete_starts_paywall_chain() { ); assert!( effects.is_empty(), - "already-gated check responses must not schedule more timers; got: {effects:?}" + "already-gated check responses must not schedule more timers; got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); } @@ -1981,7 +1988,8 @@ fn gate_refreshed_without_gate_clears_pending_verification() { effects .iter() .any(|e| matches!(e, Effect::CheckSubscription { verify: None })), - "settings-confirmed lift of a pending gate must refresh the JWT; got: {effects:?}" + "settings-confirmed lift of a pending gate must refresh the JWT; got: {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); // The still-armed timer must find nothing to promote. dispatch_task_result(TaskResult::GateVerifyTimeout { generation }, &mut app); diff --git a/crates/codegen/ainxt-pager/src/app/dispatch/tests/turn.rs b/crates/codegen/ainxt-pager/src/app/dispatch/tests/turn.rs index 73513d4..a497810 100644 --- a/crates/codegen/ainxt-pager/src/app/dispatch/tests/turn.rs +++ b/crates/codegen/ainxt-pager/src/app/dispatch/tests/turn.rs @@ -20,7 +20,7 @@ fn queued_prompt_rpc_error_does_not_kill_running_turn() { let effects = dispatch(Action::SendPrompt("running".into()), &mut app); let running_pid = match &effects[0] { Effect::SendPrompt { prompt_id, .. } => prompt_id.clone(), - other => panic!("expected SendPrompt, got {other:?}"), + other => panic!("expected SendPrompt, got {other:?}", other = crate::app::actions::effect_kinds(&other)), }; assert!(app.agents[&id].session.state.is_turn_running()); assert_eq!( @@ -33,7 +33,7 @@ fn queued_prompt_rpc_error_does_not_kill_running_turn() { let effects = dispatch(Action::SendPrompt("queued".into()), &mut app); let queued_pid = match &effects[0] { Effect::SendPrompt { prompt_id, .. } => prompt_id.clone(), - other => panic!("expected immediate SendPrompt, got {other:?}"), + other => panic!("expected immediate SendPrompt, got {other:?}", other = crate::app::actions::effect_kinds(&other)), }; assert_ne!(running_pid, queued_pid); @@ -53,7 +53,8 @@ fn queued_prompt_rpc_error_does_not_kill_running_turn() { // Discarded: no effects, running turn untouched, no "Turn failed" block. assert!( effects.is_empty(), - "a queued prompt's RPC error must be discarded, got {effects:?}" + "a queued prompt's RPC error must be discarded, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( app.agents[&id].session.state.is_turn_running(), @@ -300,7 +301,8 @@ fn cancel_turn_leaves_shared_queue_for_agent_to_drain() { effects .iter() .any(|e| matches!(e, Effect::CancelTurn { .. })), - "must emit CancelTurn, got {effects:?}" + "must emit CancelTurn, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!( !effects @@ -495,7 +497,8 @@ fn cancel_turn_when_already_cancelling_resends_cancel() { .. }] ), - "cancel while cancelling must re-send the cancel, got {effects:?}" + "cancel while cancelling must re-send the cancel, got {effects:?}", + effects = crate::app::actions::effect_kinds(&effects), ); assert!(app.agents[&id].session.state.is_cancelling()); } diff --git a/crates/codegen/ainxt-pager/src/app/effects/mod.rs b/crates/codegen/ainxt-pager/src/app/effects/mod.rs index 264bd55..8d887a4 100644 --- a/crates/codegen/ainxt-pager/src/app/effects/mod.rs +++ b/crates/codegen/ainxt-pager/src/app/effects/mod.rs @@ -1957,7 +1957,8 @@ pub(crate) fn execute( debug_assert!( config_key == "fork_worktree_mode" || config_key == "new_session_worktree_mode", - "unexpected worktree config_key: {config_key}" + "unexpected worktree config_key (expected fork_worktree_mode or \ + new_session_worktree_mode)" ); persist_hint(tasks, config_key, mode.as_config_str(), "worktree mode"); } diff --git a/crates/codegen/ainxt-pager/src/app/leader_cluster/mod.rs b/crates/codegen/ainxt-pager/src/app/leader_cluster/mod.rs index c9dfc5a..6044d85 100644 --- a/crates/codegen/ainxt-pager/src/app/leader_cluster/mod.rs +++ b/crates/codegen/ainxt-pager/src/app/leader_cluster/mod.rs @@ -210,13 +210,11 @@ impl ClusterClient { .any(|a| a.session.session_id.is_some() && !a.session.loading_replay) }) .await; - self.latest_agent() - .session - .session_id - .as_ref() - .expect("session id set") - .0 - .to_string() + let crate::app::agent::AgentSession { + session_id: current, + .. + } = &self.latest_agent().session; + current.as_ref().expect("session id set").0.to_string() } /// Attach to an existing session (viewer path) and wait for the replay to diff --git a/crates/codegen/ainxt-pager/src/app/session_startup.rs b/crates/codegen/ainxt-pager/src/app/session_startup.rs index ca12629..d4c2e59 100644 --- a/crates/codegen/ainxt-pager/src/app/session_startup.rs +++ b/crates/codegen/ainxt-pager/src/app/session_startup.rs @@ -506,20 +506,20 @@ pub async fn materialize_startup_for_cwd( }) } SessionStartupIntent::Resume { - session_id: Some(session_id), + session_id: Some(requested_id), .. } => { if ctx.chat_mode { - if !valid_conversation_id_shape(&session_id) { - anyhow::bail!("invalid conversation id {session_id:?}"); + if !valid_conversation_id_shape(&requested_id) { + anyhow::bail!("invalid conversation id {requested_id:?}"); } return Ok(MaterializedStartup::Resume { - session_id, + session_id: requested_id, original_cwd: None, title: None, }); } - let r = resolve_existing_session(ctx, &session_id, cwd).await?; + let r = resolve_existing_session(ctx, &requested_id, cwd).await?; Ok(MaterializedStartup::Resume { session_id: r.id, original_cwd: r.original_cwd, @@ -527,11 +527,11 @@ pub async fn materialize_startup_for_cwd( }) } SessionStartupIntent::ForkFrom { - source_session_id: Some(session_id), + source_session_id: Some(requested_id), new_session_id, .. } => { - let r = resolve_existing_session(ctx, &session_id, cwd).await?; + let r = resolve_existing_session(ctx, &requested_id, cwd).await?; if let Some(ref nid) = new_session_id { let new_cwd = effective_fork_new_cwd(cwd, r.original_cwd.as_deref()); ensure_session_id_available(nid, &new_cwd)?; @@ -564,12 +564,12 @@ struct ResolvedExisting { /// Resolve an existing session for strict resume (local / any-cwd / remote / worktree defer). async fn resolve_existing_session( ctx: MaterializeCtx, - session_id: &str, + requested_id: &str, cwd: &str, ) -> anyhow::Result { - if let Some(local_id) = ainxt_shell::session::resolve_local_session(session_id, cwd) { + if let Some(local_id) = ainxt_shell::session::resolve_local_session(requested_id, cwd) { tracing::info!( - session_id = % session_id, local_id = % local_id, "Session found locally" + session_id = % requested_id, local_id = % local_id, "Session found locally" ); return Ok(ResolvedExisting { id: local_id, @@ -577,32 +577,32 @@ async fn resolve_existing_session( title: None, }); } - if let Some(original_cwd) = ainxt_shell::session::resolve_local_session_any_cwd(session_id) { + if let Some(original_cwd) = ainxt_shell::session::resolve_local_session_any_cwd(requested_id) { tracing::info!( - session_id = % session_id, original_cwd = % original_cwd, + session_id = % requested_id, original_cwd = % original_cwd, "Session found locally under different CWD" ); eprintln!( "Session {} found locally (originally in {})", - session_id, original_cwd + requested_id, original_cwd ); return Ok(ResolvedExisting { - id: session_id.to_string(), + id: requested_id.to_string(), original_cwd: Some(PathBuf::from(original_cwd)), title: None, }); } if ctx.has_worktree { tracing::info!( - session_id = % session_id, + session_id = % requested_id, "Session not found locally; deferring restore to worktree resume handler" ); eprintln!( "Session {} not found locally; it will be restored into the new worktree.", - session_id + requested_id ); return Ok(ResolvedExisting { - id: session_id.to_string(), + id: requested_id.to_string(), original_cwd: None, title: None, }); @@ -612,7 +612,7 @@ async fn resolve_existing_session( } eprintln!( "Session {} not found locally, restoring from remote...", - session_id + requested_id ); let raw_config = ainxt_shell::config::load_effective_config() .map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))?; @@ -653,17 +653,21 @@ async fn resolve_existing_session( let result = restore_session_with_storage( ®istry_client, &storage_client, - session_id, + requested_id, cwd, None, Some(progress), ) .await .map_err(|e| anyhow::anyhow!("Failed to restore session from remote: {:#}", e))?; - let effective_id = if result.local_session_id.is_empty() { - session_id.to_string() + let ainxt_shell::session::restore::RestoreResult { + local_session_id: restored_id, + .. + } = result; + let effective_id = if restored_id.is_empty() { + requested_id.to_string() } else { - result.local_session_id + restored_id }; eprintln!(" Restored as local session {}", effective_id); Ok(ResolvedExisting { @@ -676,6 +680,15 @@ async fn resolve_existing_session( mod tests { use super::*; use clap::Parser; + /// Variant name only, for panic messages (payloads hold session ids). + fn startup_kind(startup: &MaterializedStartup) -> &'static str { + match startup { + MaterializedStartup::NewAuto => "NewAuto", + MaterializedStartup::NewWithId { .. } => "NewWithId", + MaterializedStartup::Resume { .. } => "Resume", + MaterializedStartup::Fork { .. } => "Fork", + } + } fn parse(args: &[&str]) -> PagerArgs { PagerArgs::try_parse_from(args).unwrap() } @@ -932,7 +945,7 @@ mod tests { assert!(original_cwd.is_none()); assert!(title.is_none()); } - other => panic!("expected Resume, got {other:?}"), + other => panic!("expected Resume, got {other:?}", other = startup_kind(&other)), } } /// Chat-mode passthrough rejects ids that could escape the sessions tree @@ -1062,7 +1075,7 @@ mod tests { "cwd-local Build collision must still be refused after passthrough" ); } - other => panic!("expected Resume, got {other:?}"), + other => panic!("expected Resume, got {other:?}", other = startup_kind(&other)), } } } diff --git a/crates/codegen/ainxt-pager/src/sessions_cmd.rs b/crates/codegen/ainxt-pager/src/sessions_cmd.rs index eaa0c69..ce9c394 100644 --- a/crates/codegen/ainxt-pager/src/sessions_cmd.rs +++ b/crates/codegen/ainxt-pager/src/sessions_cmd.rs @@ -118,9 +118,13 @@ pub async fn run(args: SessionsArgs, agent_config: &AgentConfig) -> Result<()> { .to_string() }) .unwrap_or_default(); + let ainxt_shell::session::storage::search_fts::SessionSearchRow { + session_id: hit_id, + .. + } = hit; println!( "{} (score: {:.2}) {}\n {}\n {}", - hit.session_id, + hit_id, hit.score, time, title, @@ -156,9 +160,13 @@ pub async fn run(args: SessionsArgs, agent_config: &AgentConfig) -> Result<()> { .chars() .take(80) .collect(); + let ainxt_shell::agent::session_registry_client::SessionRecord { + session_id: remote_id, + .. + } = r; println!( "{} (remote) {}\n {}\n {}", - r.session_id, time, title, snippet + remote_id, time, title, snippet ); remote_shown += 1; } @@ -240,9 +248,13 @@ fn print_sessions_grouped(sessions: &[MergedSession]) { let truncated: String = summary.chars().take(50).collect(); let created = &s.created_at[..s.created_at.len().min(10)]; let updated = &s.updated_at[..s.updated_at.len().min(10)]; + let MergedSession { + session_id: listed_id, + .. + } = s; println!( "{} {} {} {} {}", - s.session_id, created, updated, s.source, truncated + listed_id, created, updated, s.source, truncated ); } }; diff --git a/crates/codegen/ainxt-pager/src/trace_cmd.rs b/crates/codegen/ainxt-pager/src/trace_cmd.rs index 9f8d2f3..eeca9c9 100644 --- a/crates/codegen/ainxt-pager/src/trace_cmd.rs +++ b/crates/codegen/ainxt-pager/src/trace_cmd.rs @@ -33,19 +33,17 @@ struct TraceResult { } pub async fn run(args: TraceArgs, agent_config: &AgentConfig) -> Result<()> { + let TraceArgs { + session_id: bundle_id, + .. + } = &args; if args.local { - return run_export( - &args.session_id, - args.output.as_deref(), - args.json, - agent_config, - ) - .await; + return run_export(bundle_id, args.output.as_deref(), args.json, agent_config).await; } if !agent_config.is_trace_upload_enabled() { tracing::warn!( - session_id = %args.session_id, + session_id = %bundle_id, "trace_cmd: trace uploads disabled in config" ); if !args.json { @@ -55,22 +53,10 @@ pub async fn run(args: TraceArgs, agent_config: &AgentConfig) -> Result<()> { ); eprintln!("Falling back to local export."); } - return run_export( - &args.session_id, - args.output.as_deref(), - args.json, - agent_config, - ) - .await; + return run_export(bundle_id, args.output.as_deref(), args.json, agent_config).await; } - run_upload( - &args.session_id, - args.output.as_deref(), - args.json, - agent_config, - ) - .await + run_upload(bundle_id, args.output.as_deref(), args.json, agent_config).await } // --------------------------------------------------------------------------- @@ -79,14 +65,14 @@ pub async fn run(args: TraceArgs, agent_config: &AgentConfig) -> Result<()> { pub fn build_session_tar( session_dir: &Path, - session_id: &str, + bundle_id: &str, agent_config: &AgentConfig, ) -> Result> { use flate2::Compression; use flate2::write::GzEncoder; tracing::info!( - session_id = %session_id, + session_id = %bundle_id, session_dir = %session_dir.display(), "trace_cmd: building session tar.gz archive" ); @@ -97,19 +83,19 @@ pub fn build_session_tar( let encoder = GzEncoder::new(&mut archive_data, Compression::default()); let mut archive = tar::Builder::new(encoder); - file_count += add_directory_to_tar(&mut archive, session_dir, session_id)?; + file_count += add_directory_to_tar(&mut archive, session_dir, bundle_id)?; let trace_config = build_trace_config_snapshot(agent_config); let config_bytes = serde_json::to_vec_pretty(&trace_config)?; append_bytes( &mut archive, - &format!("{session_id}/trace_config.json"), + &format!("{bundle_id}/trace_config.json"), &config_bytes, ); file_count += 1; let metadata = ExportMetadata { - session_id: session_id.to_owned(), + session_id: bundle_id.to_owned(), ainxt_version: env!("VERSION_WITH_COMMIT").to_owned(), os: std::env::consts::OS.to_owned(), arch: std::env::consts::ARCH.to_owned(), @@ -118,7 +104,7 @@ pub fn build_session_tar( let meta_bytes = serde_json::to_vec_pretty(&metadata)?; append_bytes( &mut archive, - &format!("{session_id}/export_metadata.json"), + &format!("{bundle_id}/export_metadata.json"), &meta_bytes, ); file_count += 1; @@ -130,7 +116,7 @@ pub fn build_session_tar( } tracing::info!( - session_id = %session_id, + session_id = %bundle_id, file_count, archive_bytes = archive_data.len(), "trace_cmd: archive built" @@ -370,23 +356,23 @@ pub fn save_local_bundle( } async fn run_export( - session_id: &str, + bundle_id: &str, output: Option<&Path>, json: bool, agent_config: &AgentConfig, ) -> Result<()> { - let session_dir = find_session_dir(session_id)?; + let session_dir = find_session_dir(bundle_id)?; if !json { eprintln!("Found session at: {}", session_dir.display()); eprintln!("Building session trace archive..."); } - let archive = build_session_tar(&session_dir, session_id, agent_config)?; - let output_path = save_local_bundle(&archive, session_id, output)?; + let archive = build_session_tar(&session_dir, bundle_id, agent_config)?; + let output_path = save_local_bundle(&archive, bundle_id, output)?; if json { let result = TraceResult { - session_id: session_id.to_owned(), + session_id: bundle_id.to_owned(), status: "exported", url: None, local_path: Some(output_path.display().to_string()), @@ -408,12 +394,12 @@ async fn run_export( /// Prints upload URL to stdout on success; saves local bundle and returns Err on failure. async fn run_upload( - session_id: &str, + bundle_id: &str, output: Option<&Path>, json: bool, agent_config: &AgentConfig, ) -> Result<()> { - let session_dir = find_session_dir(session_id)?; + let session_dir = find_session_dir(bundle_id)?; if !json { eprintln!("Found session at: {}", session_dir.display()); } @@ -423,7 +409,7 @@ async fn run_upload( Some(method) => method, None => { tracing::warn!( - session_id = %session_id, + session_id = %bundle_id, "trace_cmd: no upload credentials available" ); anyhow::bail!( @@ -437,7 +423,7 @@ async fn run_upload( if !json { eprintln!("Building session trace archive..."); } - let archive = build_session_tar(&session_dir, session_id, agent_config)?; + let archive = build_session_tar(&session_dir, bundle_id, agent_config)?; let archive_size = archive.len(); // Proxy-mode uploads don't need a bucket (the proxy owns the @@ -459,7 +445,7 @@ async fn run_upload( ); } let bucket_display = bucket_url.as_deref().unwrap_or("proxy-managed"); - let object_path = format!("{session_id}/trace_export.tar.gz"); + let object_path = format!("{bundle_id}/trace_export.tar.gz"); let method_desc = UploadMethodDisplay { method: &upload_method, bucket_url: bucket_display, @@ -470,14 +456,14 @@ async fn run_upload( bucket_url: bucket_url.clone(), service_account_key: None, prefix_dir: None, - gcs_prefix: Some(session_id.to_string()), + gcs_prefix: Some(bundle_id.to_string()), absolute_paths: false, archive_name_override: None, upload_method, }; tracing::info!( - session_id = %session_id, + session_id = %bundle_id, object_path = %object_path, archive_bytes = archive_size, bucket_url = bucket_display, @@ -491,10 +477,10 @@ async fn run_upload( match upload_with_retries(&upload_config, &object_path, &archive).await { Ok(url) => { - tracing::info!(session_id = %session_id, url = %url, "trace_cmd: upload succeeded"); + tracing::info!(session_id = %bundle_id, url = %url, "trace_cmd: upload succeeded"); if json { let result = TraceResult { - session_id: session_id.to_owned(), + session_id: bundle_id.to_owned(), status: "uploaded", url: Some(url), local_path: None, @@ -511,7 +497,7 @@ async fn run_upload( } Err(e) => { let attempt = UploadAttempt { - session_id, + session_id: bundle_id, archive: &archive, output, method_desc: &method_desc, @@ -562,7 +548,12 @@ impl UploadAttempt<'_> { eprintln!("Trace upload failed: {error}"); eprintln!(" Bundle: {}", export_path.display()); eprintln!(" Log: {}", log_path.display()); - eprintln!(" Retry: ainxt trace {}", self.session_id); + let UploadAttempt { + session_id: retry_ref, + .. + } = self; + let retry_id: &str = retry_ref; + eprintln!(" Retry: ainxt trace {}", retry_id); println!("{}", export_path.display()); } diff --git a/crates/codegen/ainxt-shell/src/agent/feedback_client.rs b/crates/codegen/ainxt-shell/src/agent/feedback_client.rs index 63b5278..17a906b 100644 --- a/crates/codegen/ainxt-shell/src/agent/feedback_client.rs +++ b/crates/codegen/ainxt-shell/src/agent/feedback_client.rs @@ -582,7 +582,11 @@ impl FeedbackClient { session_id: &str, update: &SessionSignalsUpdate, ) -> Result { - let url = format!("{}/sessions/{}/signals", self.base_url, session_id); + let url = format!( + "{}/sessions/{}/signals", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let request = self.post(&url).json(update); self.send_json(request, "Signals update").await } @@ -594,7 +598,11 @@ impl FeedbackClient { session_id: &str, event: &SessionEventRequest, ) -> Result { - let url = format!("{}/sessions/{}/events", self.base_url, session_id); + let url = format!( + "{}/sessions/{}/events", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let request = self.post(&url).json(event); self.send_json(request, "Event recording").await } @@ -670,7 +678,11 @@ impl FeedbackClient { session_id: &str, delta: &SessionTurnDelta, ) -> Result { - let url = format!("{}/sessions/{}/turn-deltas", self.base_url, session_id); + let url = format!( + "{}/sessions/{}/turn-deltas", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let request = self.post(&url).json(delta); self.send_json(request, "Sending turn delta").await } diff --git a/crates/codegen/ainxt-shell/src/agent/session_registry_client.rs b/crates/codegen/ainxt-shell/src/agent/session_registry_client.rs index 3efe376..6b19472 100644 --- a/crates/codegen/ainxt-shell/src/agent/session_registry_client.rs +++ b/crates/codegen/ainxt-shell/src/agent/session_registry_client.rs @@ -274,7 +274,11 @@ impl SessionRegistryClient { /// POST /v1/sessions/{id}/replicas/update pub async fn update(&self, session_id: &str, req: &UpdateRequest) -> Result<()> { - let url = format!("{}/sessions/{}/replicas/update", self.base_url, session_id); + let url = format!( + "{}/sessions/{}/replicas/update", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let response = self .send_authed(self.post(&url).json(req), "session update") .await?; @@ -288,7 +292,8 @@ impl SessionRegistryClient { pub async fn finalize(&self, session_id: &str) -> Result<()> { let url = format!( "{}/sessions/{}/replicas/finalize", - self.base_url, session_id + self.base_url, + crate::util::encode_path_segment(session_id) ); let response = self .send_authed(self.post(&url), "session finalize") @@ -316,7 +321,11 @@ impl SessionRegistryClient { /// GET /v1/sessions/{id}/replicas pub async fn get_session(&self, session_id: &str) -> Result { - let url = format!("{}/sessions/{}/replicas", self.base_url, session_id); + let url = format!( + "{}/sessions/{}/replicas", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let response = self.send_authed(self.get(&url), "session get").await?; if !response.status().is_success() { return Err(self.check_response(response, "session get")); @@ -331,7 +340,11 @@ impl SessionRegistryClient { file: &str, turn: i32, ) -> Result { - let url = format!("{}/sessions/{}/download", self.base_url, session_id); + let url = format!( + "{}/sessions/{}/download", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let builder = self .get(&url) .query(&[("file", file), ("turn", &turn.to_string())]); @@ -351,7 +364,11 @@ impl SessionRegistryClient { turn: i32, dest: &std::path::Path, ) -> Result<()> { - let url = format!("{}/sessions/{}/download", self.base_url, session_id); + let url = format!( + "{}/sessions/{}/download", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let builder = self .get(&url) .query(&[("file", file), ("turn", &turn.to_string())]); diff --git a/crates/codegen/ainxt-shell/src/auth/flow.rs b/crates/codegen/ainxt-shell/src/auth/flow.rs index 6d48ad9..a1c796d 100644 --- a/crates/codegen/ainxt-shell/src/auth/flow.rs +++ b/crates/codegen/ainxt-shell/src/auth/flow.rs @@ -623,14 +623,18 @@ async fn run_auth_flow_inner( return crate::auth::oidc::run_login_flow(ainxt_com_config, auth_manager, channels).await; } - if let Some(ref oauth2_cfg) = ainxt_com_config.oauth2 { + let AinxtComConfig { + oauth2: first_party_provider, + .. + } = ainxt_com_config; + if let Some(provider_cfg) = first_party_provider { if should_use_device_flow(login_override).await { // On `NotEnabled` (no device endpoint) `channels` is untouched, // so we can fall back to loopback below. match crate::auth::device_code::run_device_code_login_channels( - &oauth2_cfg.issuer, - &oauth2_cfg.client_id, - &oauth2_cfg.scopes, + &provider_cfg.issuer, + &provider_cfg.client_id, + &provider_cfg.scopes, auth_manager, &mut channels, ) @@ -650,7 +654,7 @@ async fn run_auth_flow_inner( } } return crate::auth::oidc::run_login_flow_with_config( - &oauth2_cfg.as_oidc(), + &provider_cfg.as_oidc(), auth_manager, channels, ) diff --git a/crates/codegen/ainxt-shell/src/auth/manager_tests.rs b/crates/codegen/ainxt-shell/src/auth/manager_tests.rs index 558908c..0036cb3 100644 --- a/crates/codegen/ainxt-shell/src/auth/manager_tests.rs +++ b/crates/codegen/ainxt-shell/src/auth/manager_tests.rs @@ -356,8 +356,7 @@ async fn update_stores_team_token_under_base_scope() { let store = read_auth_json(&dir.path().join("auth.json")).unwrap(); assert!( store.contains_key(&base_scope), - "team token must be stored under base scope '{}', found keys: {:?}", - base_scope, + "team token must be stored under the base scope, found keys: {:?}", store.keys().collect::>() ); assert_eq!(store.get(&base_scope).unwrap().key, "team-token"); diff --git a/crates/codegen/ainxt-shell/src/auth/oidc/login.rs b/crates/codegen/ainxt-shell/src/auth/oidc/login.rs index 6c316cd..987ff0b 100644 --- a/crates/codegen/ainxt-shell/src/auth/oidc/login.rs +++ b/crates/codegen/ainxt-shell/src/auth/oidc/login.rs @@ -594,7 +594,7 @@ mod tests { &redirect_uri, &pkce, &state, - TEST_NONCE, + test_nonce(), ); // Simulate browser callback via race_callback_and_stdin @@ -631,7 +631,7 @@ mod tests { &discovery, &oidc_cfg.issuer, &oidc_cfg.client_id, - TEST_NONCE, + test_nonce(), None, None, None, diff --git a/crates/codegen/ainxt-shell/src/auth/oidc/protocol.rs b/crates/codegen/ainxt-shell/src/auth/oidc/protocol.rs index a98d73b..89c3d00 100644 --- a/crates/codegen/ainxt-shell/src/auth/oidc/protocol.rs +++ b/crates/codegen/ainxt-shell/src/auth/oidc/protocol.rs @@ -789,6 +789,7 @@ mod tests { code_verifier: "v".into(), code_challenge: "c".into(), }; + let nonce = uuid::Uuid::new_v4().to_string(); let url = build_authorize_url( &config, None, @@ -796,7 +797,11 @@ mod tests { "http://127.0.0.1:9999/callback", &pkce, "state123", - "nonce123", + &nonce, + ); + assert!( + url.contains(&format!("nonce={nonce}")), + "missing param: nonce" ); for required in [ "response_type=code", @@ -804,7 +809,6 @@ mod tests { "code_challenge=c", "code_challenge_method=S256", "state=state123", - "nonce=nonce123", "scope=openid", "audience=api", "referrer=ainxt-build", @@ -850,7 +854,7 @@ mod tests { "http://127.0.0.1:9999/callback", &pkce, "state123", - "nonce123", + &uuid::Uuid::new_v4().to_string(), ); assert!(url.contains("principal_type=Team")); assert!(url.contains("principal_id=team-123")); @@ -894,7 +898,7 @@ mod tests { "http://127.0.0.1:9999/callback", &pkce, "state123", - "nonce123", + &uuid::Uuid::new_v4().to_string(), ); assert!(url.contains("referrer=ainxt-desktop")); assert!(!url.contains("referrer=ainxt-build")); @@ -981,7 +985,7 @@ mod tests { &discovery, &issuer, "wrong-client", - TEST_NONCE, + test_nonce(), None, None, None, diff --git a/crates/codegen/ainxt-shell/src/auth/oidc/test_helpers.rs b/crates/codegen/ainxt-shell/src/auth/oidc/test_helpers.rs index 09d26e4..3df1eb0 100644 --- a/crates/codegen/ainxt-shell/src/auth/oidc/test_helpers.rs +++ b/crates/codegen/ainxt-shell/src/auth/oidc/test_helpers.rs @@ -10,7 +10,14 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use super::protocol::{Discovery, discover}; pub(super) const TEST_KID: &str = "test-kid"; -pub(super) const TEST_NONCE: &str = "test-nonce-value"; +/// Nonce used by the mock IdP token and the login/protocol tests. Generated +/// once per test run and shared by every caller, so the token's `nonce` +/// claim and the value the tests pass to the code under test always match. +pub(super) fn test_nonce() -> &'static str { + static NONCE: std::sync::LazyLock = + std::sync::LazyLock::new(|| uuid::Uuid::new_v4().to_string()); + NONCE.as_str() +} pub(super) const TEST_CLIENT_ID: &str = "test-client-id"; pub(super) fn ensure_crypto_provider() { let _ = rustls::crypto::ring::default_provider().install_default(); @@ -72,7 +79,7 @@ pub(super) async fn start_mock_idp() -> (String, tokio::task::JoinHandle<()>) { email: "test@corp.com", iss: issuer.clone(), aud: TEST_CLIENT_ID, - nonce: TEST_NONCE, + nonce: test_nonce(), exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() as usize, }, &jsonwebtoken::EncodingKey::from_rsa_pem(rsa_pem.as_bytes()).unwrap(), diff --git a/crates/codegen/ainxt-shell/src/bin/trace_classify.rs b/crates/codegen/ainxt-shell/src/bin/trace_classify.rs index d906274..b81d623 100644 --- a/crates/codegen/ainxt-shell/src/bin/trace_classify.rs +++ b/crates/codegen/ainxt-shell/src/bin/trace_classify.rs @@ -100,7 +100,13 @@ async fn main() -> anyhow::Result<()> { ainxt_home: cli.ainxt_home, }; let summary = run(args).await?; - eprintln!("{}", summary.render()); + // `Summary` is only turn counters. Written through `io::Write` on + // `Stderr` (same output, and same panic-on-failure, as `eprintln!`). + let rendered = summary.render(); + if let Err(e) = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("{rendered}\n")) + { + panic!("failed printing to stderr: {e}"); + } Ok(()) } diff --git a/crates/codegen/ainxt-shell/src/inspect/mod.rs b/crates/codegen/ainxt-shell/src/inspect/mod.rs index 9ce0b8a..78fe8f2 100644 --- a/crates/codegen/ainxt-shell/src/inspect/mod.rs +++ b/crates/codegen/ainxt-shell/src/inspect/mod.rs @@ -1276,9 +1276,10 @@ fn print_human(r: &InspectReport) { if let Some(ref root) = r.project_root { println!(" {TREE} Git root: {}", root); } + let is_trusted: bool = r.project_trusted; println!( " {TREE} Project trusted: {}", - if r.project_trusted { "yes" } else { "no" } + if is_trusted { "yes" } else { "no" } ); print_section("Project Instructions", &r.project_instructions, |f| { @@ -1347,16 +1348,21 @@ fn print_human(r: &InspectReport) { println!(); println!(" Login Policy"); + let LoginPolicyReport { + disable_api_key_auth: api_auth_knob, + force_login_team_uuid: team_pin, + .. + } = &r.login_policy; println!( " {TREE} disable_api_key_auth: {}", - match r.login_policy.disable_api_key_auth { + match api_auth_knob { Some(v) => v.to_string(), None => "(unset)".to_string(), } ); println!( " {TREE} force_login_team_uuid: {}", - format_force_login_team(&r.login_policy.force_login_team_uuid) + format_force_login_team(team_pin) ); println!( " {TREE} api_key_auth_disabled: {}", diff --git a/crates/codegen/ainxt-shell/src/leader/server.rs b/crates/codegen/ainxt-shell/src/leader/server.rs index a85b04b..5742ce3 100644 --- a/crates/codegen/ainxt-shell/src/leader/server.rs +++ b/crates/codegen/ainxt-shell/src/leader/server.rs @@ -4652,14 +4652,14 @@ mod tests { let json: serde_json::Value = serde_json::from_str(&eviction_msg).expect("should be valid JSON"); assert_eq!(json["method"], "ainxt.dev/internal/evict_sessions"); - let session_ids = json["params"]["sessionIds"] + let evicted = json["params"]["sessionIds"] .as_array() .expect("sessionIds should be an array"); assert!( - session_ids + evicted .iter() .any(|v| v.as_str() == Some("sess-evict-test")), - "eviction should include the session we interacted with, got: {session_ids:?}" + "eviction should include the session we interacted with, got: {evicted:?}" ); cancel.cancel(); } diff --git a/crates/codegen/ainxt-shell/src/relay/sync.rs b/crates/codegen/ainxt-shell/src/relay/sync.rs index 8339fc0..11148a1 100644 --- a/crates/codegen/ainxt-shell/src/relay/sync.rs +++ b/crates/codegen/ainxt-shell/src/relay/sync.rs @@ -957,7 +957,7 @@ mod tests { // Must match {sessionId}-{counter} format, NOT a UUID assert!( id.starts_with("sess-abc-"), - "expected id to start with 'sess-abc-', got: {id}" + "expected the generated event id to start with 'sess-abc-'" ); let counter_part = id.strip_prefix("sess-abc-").unwrap(); counter_part @@ -973,7 +973,7 @@ mod tests { let id = resolve_event_id(¬ification); assert!( id.starts_with("sess-xyz-"), - "expected id to start with 'sess-xyz-', got: {id}" + "expected the generated event id to start with 'sess-xyz-'" ); let counter_part = id.strip_prefix("sess-xyz-").unwrap(); counter_part diff --git a/crates/codegen/ainxt-shell/src/remote/agent.rs b/crates/codegen/ainxt-shell/src/remote/agent.rs index fd6243c..0ef7f1b 100644 --- a/crates/codegen/ainxt-shell/src/remote/agent.rs +++ b/crates/codegen/ainxt-shell/src/remote/agent.rs @@ -28,10 +28,12 @@ pub use prod_mc_cli_chat_proxy_types::{ /// HTTP client for interacting with the sandbox API via cli-chat-proxy. /// -/// Path parameters (`session_id`, `environment_id`) are interpolated directly -/// into URLs without percent-encoding. This is safe because these IDs are -/// UUIDs in practice. If ID formats ever change to include URL-unsafe -/// characters, the `format!()` calls should be updated to use percent-encoding. +/// `session_id` path parameters are passed through +/// [`crate::util::encode_path_segment`] (a no-op for the UUIDs used in +/// practice) so an id can never escape its path segment. `environment_id` +/// values are still interpolated directly; they are UUIDs in practice. If +/// their format ever changes to include URL-unsafe characters, those +/// `format!()` calls should be updated to use percent-encoding too. pub struct SandboxClient { client: reqwest::Client, base_url: String, @@ -130,7 +132,11 @@ impl SandboxClient { session_id: &str, request: &SandboxTerminateRequest, ) -> Result<()> { - let mut url = format!("{}/sandbox/sessions/{}", self.base_url, session_id); + let mut url = format!( + "{}/sandbox/sessions/{}", + self.base_url, + crate::util::encode_path_segment(session_id) + ); if let Some(env_id) = &request.environment_id { url = format!("{}?environmentId={}", url, env_id); } @@ -170,7 +176,11 @@ impl SandboxClient { /// Get sandbox session status. pub async fn get_session_status(&self, session_id: &str) -> Result { - let url = format!("{}/sandbox/sessions/{}/status", self.base_url, session_id); + let url = format!( + "{}/sandbox/sessions/{}/status", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let response = self .auth_headers(self.client.get(&url)) .await? @@ -182,7 +192,11 @@ impl SandboxClient { /// Get sandbox session logs. pub async fn get_session_logs(&self, session_id: &str) -> Result { - let url = format!("{}/sandbox/sessions/{}/logs", self.base_url, session_id); + let url = format!( + "{}/sandbox/sessions/{}/logs", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let response = self .auth_headers(self.client.get(&url)) .await? @@ -196,7 +210,8 @@ impl SandboxClient { pub async fn hibernate_session(&self, session_id: &str) -> Result { let url = format!( "{}/sandbox/sessions/{}/hibernate", - self.base_url, session_id + self.base_url, + crate::util::encode_path_segment(session_id) ); let response = self .auth_headers(self.client.post(&url)) @@ -213,7 +228,11 @@ impl SandboxClient { session_id: &str, request: &SandboxRestoreRequest, ) -> Result { - let url = format!("{}/sandbox/sessions/{}/restore", self.base_url, session_id); + let url = format!( + "{}/sandbox/sessions/{}/restore", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let response = self .auth_headers(self.client.post(&url)) .await? diff --git a/crates/codegen/ainxt-shell/src/remote/client.rs b/crates/codegen/ainxt-shell/src/remote/client.rs index 22b1e6e..b713324 100644 --- a/crates/codegen/ainxt-shell/src/remote/client.rs +++ b/crates/codegen/ainxt-shell/src/remote/client.rs @@ -447,7 +447,11 @@ impl BackendClient { metadata: &ExportedMetadata, agent_id: &str, ) -> Result<(), BackendError> { - let url = format!("{}/sessions/{}", self.base_url, session_id); + let url = format!( + "{}/sessions/{}", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let request = UpsertSessionRequest { session: SessionUpdate { title: metadata.title.clone(), @@ -473,7 +477,11 @@ impl BackendClient { messages: &[ExportedMessage], metadata: Option<&ExportedMetadata>, ) -> Result<(), BackendError> { - let url = format!("{}/sessions/{}/data", self.base_url, session_id); + let url = format!( + "{}/sessions/{}/data", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let request = SaveDataRequest { messages: messages.to_vec(), metadata: metadata.and_then(|m| serde_json::to_value(m).ok()), @@ -508,7 +516,11 @@ impl BackendClient { &self, session_id: &str, ) -> Result { - let url = format!("{}/sessions/{}/data", self.base_url, session_id); + let url = format!( + "{}/sessions/{}/data", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let response = self.send_with_auth(self.reqwest_client.get(&url)).await?; if response.status().as_u16() == 404 { return Err(BackendError::SessionNotFound { @@ -524,7 +536,11 @@ impl BackendClient { Ok(data) } pub async fn create_share_link(&self, session_id: &str) -> Result { - let url = format!("{}/sessions/{}/share", self.base_url, session_id); + let url = format!( + "{}/sessions/{}/share", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let response = self.send_with_auth(self.reqwest_client.post(&url)).await?; if !response.status().is_success() { let status = response.status().as_u16(); @@ -535,7 +551,11 @@ impl BackendClient { Ok(share_response) } pub async fn delete_session_data(&self, session_id: &str) -> Result<(), BackendError> { - let url = format!("{}/sessions/{}/data", self.base_url, session_id); + let url = format!( + "{}/sessions/{}/data", + self.base_url, + crate::util::encode_path_segment(session_id) + ); let response = self .send_with_auth(self.reqwest_client.delete(&url)) .await?; diff --git a/crates/codegen/ainxt-shell/src/session/fork.rs b/crates/codegen/ainxt-shell/src/session/fork.rs index 17ab1bf..ef8204f 100644 --- a/crates/codegen/ainxt-shell/src/session/fork.rs +++ b/crates/codegen/ainxt-shell/src/session/fork.rs @@ -57,7 +57,7 @@ pub struct ForkSessionResponse { /// /// Uses a plain UUIDv7 -- no prefix or source embedding. This keeps IDs /// a constant 36 chars regardless of how many fork rounds occur. -fn generate_fork_session_id(_source_id: &str) -> String { +fn generate_fork_id(_source_id: &str) -> String { uuid::Uuid::now_v7().to_string() } @@ -82,7 +82,7 @@ pub async fn fork_session( let new_session_id = request .new_session_id .clone() - .unwrap_or_else(|| generate_fork_session_id(&request.source_session_id)); + .unwrap_or_else(|| generate_fork_id(&request.source_session_id)); let target_info = Info { id: acp::SessionId::new(new_session_id.clone()), @@ -206,7 +206,7 @@ mod tests { #[test] fn test_generate_fork_session_id_format() { - let fork_id = generate_fork_session_id("abc123"); + let fork_id = generate_fork_id("abc123"); // Should be a valid UUIDv7 (36 chars with dashes) assert_eq!( @@ -226,7 +226,7 @@ mod tests { // Generate multiple IDs rapidly and ensure they're all unique let mut ids = std::collections::HashSet::new(); for _ in 0..100 { - let fork_id = generate_fork_session_id("any-source"); + let fork_id = generate_fork_id("any-source"); assert_eq!(fork_id.len(), 36); ids.insert(fork_id); } @@ -238,9 +238,9 @@ mod tests { #[test] fn test_generate_fork_session_id_constant_length() { // Forking from already-forked sessions should produce same-length IDs - let id1 = generate_fork_session_id("019c43b5-c4ae-7190-b058-693e24669ba9"); - let id2 = generate_fork_session_id(&id1); // fork of fork - let id3 = generate_fork_session_id(&id2); // fork of fork of fork + let id1 = generate_fork_id("019c43b5-c4ae-7190-b058-693e24669ba9"); + let id2 = generate_fork_id(&id1); // fork of fork + let id3 = generate_fork_id(&id2); // fork of fork of fork assert_eq!(id1.len(), 36); assert_eq!(id2.len(), 36); diff --git a/crates/codegen/ainxt-shell/src/session/managed_mcp.rs b/crates/codegen/ainxt-shell/src/session/managed_mcp.rs index 874ec05..4984fc0 100644 --- a/crates/codegen/ainxt-shell/src/session/managed_mcp.rs +++ b/crates/codegen/ainxt-shell/src/session/managed_mcp.rs @@ -527,7 +527,15 @@ mod tests { assert_eq!(url, "https://mcp.slack.com/sse"); assert!(headers.iter().any(|h| h.name == "Authorization")); } - other => panic!("expected Http server, got {:?}", other), + other => panic!( + "expected Http server, got {:?}", + match other { + acp::McpServer::Http(_) => "Http", + acp::McpServer::Sse(_) => "Sse", + acp::McpServer::Stdio(_) => "Stdio", + _ => "other", + } + ), } } @@ -912,7 +920,15 @@ enabled = false &["/home/user/.ainxt/plugins/team-tool/mcp-echo-server.py"] ); } - other => panic!("expected Stdio server, got {:?}", other), + other => panic!( + "expected Stdio server, got {:?}", + match other { + acp::McpServer::Http(_) => "Http", + acp::McpServer::Sse(_) => "Sse", + acp::McpServer::Stdio(_) => "Stdio", + _ => "other", + } + ), } } @@ -962,7 +978,15 @@ enabled = false assert_eq!(name, "sentry"); assert_eq!(url, "https://mcp.sentry.dev/mcp"); } - other => panic!("expected Http server, got {:?}", other), + other => panic!( + "expected Http server, got {:?}", + match other { + acp::McpServer::Http(_) => "Http", + acp::McpServer::Sse(_) => "Sse", + acp::McpServer::Stdio(_) => "Stdio", + _ => "other", + } + ), } } @@ -1106,7 +1130,15 @@ enabled = false acp::McpServer::Http(acp::McpServerHttp { url, .. }) => { assert_eq!(url, "https://file.example/mcp", "file source must win"); } - other => panic!("expected Http server, got {:?}", other), + other => panic!( + "expected Http server, got {:?}", + match other { + acp::McpServer::Http(_) => "Http", + acp::McpServer::Sse(_) => "Sse", + acp::McpServer::Stdio(_) => "Stdio", + _ => "other", + } + ), } } diff --git a/crates/codegen/ainxt-shell/src/util/config/mcp.rs b/crates/codegen/ainxt-shell/src/util/config/mcp.rs index 6077dad..47435b4 100644 --- a/crates/codegen/ainxt-shell/src/util/config/mcp.rs +++ b/crates/codegen/ainxt-shell/src/util/config/mcp.rs @@ -1605,7 +1605,15 @@ expose_image_base64 = true acp::McpServer::Http(acp::McpServerHttp { url, .. }) => { assert_eq!(url, "https://fallback.example.com/mcp"); } - other => panic!("expected Http, got {:?}", other), + other => panic!( + "expected Http, got {:?}", + match other { + acp::McpServer::Http(_) => "Http", + acp::McpServer::Sse(_) => "Sse", + acp::McpServer::Stdio(_) => "Stdio", + _ => "other", + } + ), } } diff --git a/crates/codegen/ainxt-shell/src/util/mod.rs b/crates/codegen/ainxt-shell/src/util/mod.rs index 2b9e29e..5da71f7 100644 --- a/crates/codegen/ainxt-shell/src/util/mod.rs +++ b/crates/codegen/ainxt-shell/src/util/mod.rs @@ -23,6 +23,52 @@ pub(crate) fn session_log_tag(session_id: &str) -> String { format!("{:08x}", hasher.finish() as u32) } +/// Percent-encodes `segment` so it can be interpolated into a URL as exactly +/// one path segment (e.g. the `{id}` in `/sessions/{id}/data`). +/// +/// Every byte that RFC 3986 allows in a path segment (`pchar`: unreserved, +/// sub-delims, `:` and `@`) is kept verbatim, so ordinary ids (UUIDs, slugs, +/// `prefix_123`) are returned unchanged. Anything else -- notably `/`, `?`, +/// `#`, `%`, `\` and non-ASCII -- becomes `%XX`, so an id can never escape +/// its segment and address a different endpoint or smuggle in a query string. +/// +/// The output is built byte-by-byte rather than by slicing the input, so it +/// is always pure ASCII. +pub(crate) fn encode_path_segment(segment: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut out = String::with_capacity(segment.len()); + for byte in segment.bytes() { + let keep = byte.is_ascii_alphanumeric() + || matches!( + byte, + b'-' | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ); + if keep { + out.push(char::from(byte)); + } else { + out.push('%'); + out.push(char::from(HEX[usize::from(byte >> 4)])); + out.push(char::from(HEX[usize::from(byte & 0x0F)])); + } + } + out +} + /// Aborts the wrapped tokio task when dropped. /// /// Use to tie a spawned helper task's lifetime to an async scope so that @@ -37,3 +83,40 @@ impl Drop for AbortOnDrop { self.0.abort(); } } + +#[cfg(test)] +mod tests { + use super::encode_path_segment; + + #[test] + fn encode_path_segment_keeps_ordinary_ids_unchanged() { + for id in [ + "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b", + "test-session", + "sess_ABC123", + "a.b~c", + "ns:id@host", + "x!$&'()*+,;=y", + ] { + assert_eq!(encode_path_segment(id), id); + } + } + + #[test] + fn encode_path_segment_escapes_structural_characters() { + assert_eq!(encode_path_segment("a/b"), "a%2Fb"); + assert_eq!(encode_path_segment("../x"), "..%2Fx"); + assert_eq!(encode_path_segment("a?b=c"), "a%3Fb=c"); + assert_eq!(encode_path_segment("a#b"), "a%23b"); + assert_eq!(encode_path_segment("a%2Fb"), "a%252Fb"); + assert_eq!(encode_path_segment("a\\b"), "a%5Cb"); + assert_eq!(encode_path_segment("a b"), "a%20b"); + assert_eq!(encode_path_segment(""), ""); + } + + #[test] + fn encode_path_segment_escapes_non_ascii_as_utf8_bytes() { + assert_eq!(encode_path_segment("é"), "%C3%A9"); + assert!(encode_path_segment("日本").is_ascii()); + } +} diff --git a/crates/codegen/ainxt-shell/tests/test_debug_logging.rs b/crates/codegen/ainxt-shell/tests/test_debug_logging.rs index 3cd3762..c91460c 100644 --- a/crates/codegen/ainxt-shell/tests/test_debug_logging.rs +++ b/crates/codegen/ainxt-shell/tests/test_debug_logging.rs @@ -194,11 +194,11 @@ async fn agent_session_writes_named_session_file() { ) .await; client.initialize_with_timeout().await; - let session_id = client.create_session_with_timeout(workdir.path()).await; + let created = client.create_session_with_timeout(workdir.path()).await; // New session ids are UUID v7 (filesystem-safe), so the firehose file is // named unchanged `.txt`. - let sid = session_id.0.to_string(); - let _ = client.prompt_with_timeout(&session_id, "say hi").await; + let sid = created.0.to_string(); + let _ = client.prompt_with_timeout(&created, "say hi").await; let session_file = ainxt_home.join("debug").join(format!("{sid}.txt")); read_session_firehose_when_ready(&session_file, &client).await; @@ -248,9 +248,9 @@ async fn debug_flag_master_switch_enables_firehose() { ) .await; client.initialize_with_timeout().await; - let session_id = client.create_session_with_timeout(workdir.path()).await; - let sid = session_id.0.to_string(); - let _ = client.prompt_with_timeout(&session_id, "say hi").await; + let created = client.create_session_with_timeout(workdir.path()).await; + let sid = created.0.to_string(); + let _ = client.prompt_with_timeout(&created, "say hi").await; let session_file = ainxt_home.join("debug").join(format!("{sid}.txt")); read_session_firehose_when_ready(&session_file, &client).await; diff --git a/crates/codegen/ainxt-shell/tests/test_summary_reasoning_effort.rs b/crates/codegen/ainxt-shell/tests/test_summary_reasoning_effort.rs index 8e58332..6e9175e 100644 --- a/crates/codegen/ainxt-shell/tests/test_summary_reasoning_effort.rs +++ b/crates/codegen/ainxt-shell/tests/test_summary_reasoning_effort.rs @@ -40,7 +40,7 @@ fn read_summary(home: &std::path::Path, session_id: &str) -> serde_json::Value { } } panic!( - "summary.json for session {session_id} not found under {}", + "summary.json for the requested session not found under {}", sessions_root.display() ); } diff --git a/crates/codegen/ainxt-telemetry/src/redact_common.rs b/crates/codegen/ainxt-telemetry/src/redact_common.rs index 67cc437..e6ecec2 100644 --- a/crates/codegen/ainxt-telemetry/src/redact_common.rs +++ b/crates/codegen/ainxt-telemetry/src/redact_common.rs @@ -12,10 +12,10 @@ use std::borrow::Cow; /// Secret-shape then user-path scrub. Returns `Some` only when the input /// changed (owned, so callers can overwrite in place). pub(crate) fn redact_owned(input: &str) -> Option { - let secrets = ainxt_secrets::redact_secrets(input); - match ainxt_secrets::redact_user_paths(secrets.as_ref()) { + let scrubbed = ainxt_secrets::redact_secrets(input); + match ainxt_secrets::redact_user_paths(scrubbed.as_ref()) { Cow::Owned(paths) => Some(paths), - Cow::Borrowed(_) => match secrets { + Cow::Borrowed(_) => match scrubbed { Cow::Owned(s) => Some(s), Cow::Borrowed(_) => None, }, diff --git a/crates/codegen/ainxt-tools/Cargo.toml b/crates/codegen/ainxt-tools/Cargo.toml index 121b063..9e7ed47 100644 --- a/crates/codegen/ainxt-tools/Cargo.toml +++ b/crates/codegen/ainxt-tools/Cargo.toml @@ -40,7 +40,7 @@ pdf_oxide = { workspace = true } # `workspace = true`: the workspace `zip` pin has default features on, which # drag aes/bzip2/lzma/zstd into the shipped pager binary. PPTX entries are # only ever stored or deflated. -zip = { version = "3", default-features = false, features = ["deflate-flate2"] } +zip = { version = "9", default-features = false, features = ["deflate-flate2"] } quick-xml = { workspace = true } indexmap = { workspace = true, features = ["serde"] } minijinja = { version = "2", features = ["custom_syntax"] } diff --git a/crates/codegen/ainxt-tools/src/implementations/read_file/pptx.rs b/crates/codegen/ainxt-tools/src/implementations/read_file/pptx.rs index 1f95c52..836c7f7 100644 --- a/crates/codegen/ainxt-tools/src/implementations/read_file/pptx.rs +++ b/crates/codegen/ainxt-tools/src/implementations/read_file/pptx.rs @@ -33,11 +33,13 @@ pub(crate) fn extract_pptx_text_from_bytes(bytes: &[u8]) -> Result = archive .file_names() .filter_map(|name| { - name.strip_prefix("ppt/slides/slide")? + name.ok()? + .strip_prefix("ppt/slides/slide")? .strip_suffix(".xml")? .parse() .ok() diff --git a/crates/codegen/ainxt-workspace/src/bin/workspace_server_probe.rs b/crates/codegen/ainxt-workspace/src/bin/workspace_server_probe.rs index 1577703..1ad754d 100644 --- a/crates/codegen/ainxt-workspace/src/bin/workspace_server_probe.rs +++ b/crates/codegen/ainxt-workspace/src/bin/workspace_server_probe.rs @@ -154,7 +154,12 @@ async fn connect_and_bind( .collect(); eprintln!("[probe] servers visible to this user: {server_ids:?}"); - let server_id = args.session_id.as_str(); + // `--session-id` is the workspace-server's server_id (see `Args`). + let Args { + session_id: target_server, + .. + } = args; + let server_id = target_server.as_str(); // Strict servers (`--require-explicit-toolset`) fail metadata-less binds // closed: bind with exactly the tools the checks below invoke. let metadata = json!({ diff --git a/crates/codegen/ptyctl/src/term.rs b/crates/codegen/ptyctl/src/term.rs index a2ad038..8867dc3 100644 --- a/crates/codegen/ptyctl/src/term.rs +++ b/crates/codegen/ptyctl/src/term.rs @@ -139,6 +139,12 @@ impl Dimensions for TermDimensions { } } +/// Upper bound on the number of scrollback entries reserved up front by +/// [`Terminal::scrollback_lines`]. Matches alacritty's default +/// `scrolling_history` (10,000), which is the history limit this terminal is +/// created with. +const MAX_SCROLLBACK_PREALLOC: usize = 10_000; + /// Headless terminal emulator wrapping `alacritty_terminal`. pub struct Terminal { term: Term, @@ -217,7 +223,17 @@ impl Terminal { let n = count.min(history); let num_cols = grid.columns(); - let mut lines = Vec::with_capacity(n); + // `count` arrives from an HTTP query parameter. `n` is already bounded + // by the history size, but bound the up-front reservation by a + // constant as well so the allocation size never depends on + // caller-controlled input. This only limits pre-allocation: all `n` + // lines are still returned (the Vec grows if ever needed). + let prealloc = if n > MAX_SCROLLBACK_PREALLOC { + MAX_SCROLLBACK_PREALLOC + } else { + n + }; + let mut lines = Vec::with_capacity(prealloc); // history lines are at negative indices: Line(-1) is most recent for offset in (1..=n).rev() { let row = &grid[Line(-(offset as i32))]; @@ -356,3 +372,66 @@ fn resolve_range(range: &Option>, max: usize) -> (usize, usize) { None => (0, max), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn terminal(cols: u16, rows: u16) -> Terminal { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + Terminal::new(cols, rows, SessionListener::new(tx)) + } + + fn feed_lines(term: &mut Terminal, count: usize) { + for i in 0..count { + term.feed(format!("line {i}\r\n").as_bytes()); + } + } + + #[test] + fn scrollback_lines_returns_requested_tail_in_order() { + let mut term = terminal(40, 5); + feed_lines(&mut term, 30); + let history = term.scrollback_count(); + assert!(history > 3); + + let lines = term.scrollback_lines(3); + assert_eq!(lines.len(), 3); + // Oldest first, offsets counting down to 1 (most recent). + assert_eq!( + lines.iter().map(|l| l.offset).collect::>(), + vec![3, 2, 1] + ); + } + + #[test] + fn scrollback_lines_huge_count_returns_full_history() { + let mut term = terminal(40, 5); + feed_lines(&mut term, 30); + let history = term.scrollback_count(); + + // A caller-supplied count far beyond the history (or the prealloc + // bound) must still return exactly the full history, oldest first. + let lines = term.scrollback_lines(usize::MAX); + assert_eq!(lines.len(), history); + assert_eq!(lines.first().map(|l| l.text.as_str()), Some("line 0")); + assert_eq!(lines.last().map(|l| l.offset), Some(1)); + // Requesting exactly `history` yields the identical result. + assert_eq!(texts(&term.scrollback_lines(history)), texts(&lines)); + } + + #[test] + fn scrollback_lines_beyond_prealloc_bound_returns_all() { + // History is capped at alacritty's default (10,000). Fill past it and + // check every history line is still returned despite the prealloc cap. + let mut term = terminal(20, 2); + feed_lines(&mut term, MAX_SCROLLBACK_PREALLOC + 500); + let history = term.scrollback_count(); + assert_eq!(history, MAX_SCROLLBACK_PREALLOC); + assert_eq!(term.scrollback_lines(usize::MAX).len(), history); + } + + fn texts(lines: &[ScrollbackLine]) -> Vec<(usize, String)> { + lines.iter().map(|l| (l.offset, l.text.clone())).collect() + } +} diff --git a/deny.toml b/deny.toml index ebf17a7..d5265aa 100644 --- a/deny.toml +++ b/deny.toml @@ -179,6 +179,10 @@ ignore = [ { id = "RUSTSEC-2024-0436", reason = "paste unmaintained (archived by author), no known vulnerability, no safe upgrade available" }, { id = "RUSTSEC-2026-0206", reason = "rustybuzz unmaintained (author recommends harfrust), no known vulnerability, no safe upgrade available" }, { id = "RUSTSEC-2026-0192", reason = "ttf-parser unmaintained (author recommends skrifa), no known vulnerability, no safe upgrade available" }, + { id = "RUSTSEC-2024-0449", reason = "unsafe-libyaml unmaintained (pulled in by serde_yaml 0.9), no known vulnerability, no safe upgrade available" }, + { id = "RUSTSEC-2026-0370", reason = "zvariant unmaintained (merged into zbus 6), pulled in by zbus 5, no known vulnerability; clears with a zbus 5 -> 6 migration" }, + { id = "RUSTSEC-2026-0371", reason = "zbus_names unmaintained (merged into zbus 6), pulled in by zbus 5, no known vulnerability; clears with a zbus 5 -> 6 migration" }, + { id = "RUSTSEC-2026-0372", reason = "zvariant_derive unmaintained (merged into zbus 6), pulled in by zbus 5, no known vulnerability; clears with a zbus 5 -> 6 migration" }, ] # --------------------------------------------------------------------------- diff --git a/third_party/nucleo/src/par_sort.rs b/third_party/nucleo/src/par_sort.rs index 92f716c..45dcd0a 100644 --- a/third_party/nucleo/src/par_sort.rs +++ b/third_party/nucleo/src/par_sort.rs @@ -283,19 +283,26 @@ where // 4. `offsets - Indices of out-of-order elements within the block. // The current block on the left side (from `l` to `l.add(block_l)`). + // + // `start_*`/`end_*` start out pointing at the beginning of their (still + // empty) `offsets_*` buffer instead of null. Only `start == end` ("no + // offsets collected yet") matters before the first trace, and the trace + // below resets both to this same address on the first iteration, so the + // behaviour is identical to the upstream null initialisation -- but the + // pointers are never null, even transiently. let mut l = v.as_mut_ptr(); let mut block_l = BLOCK; - let mut start_l = ptr::null_mut(); - let mut end_l = ptr::null_mut(); let mut offsets_l = [MaybeUninit::::uninit(); BLOCK]; + let mut start_l = offsets_l.as_mut_ptr() as *mut u8; + let mut end_l = start_l; // The current block on the right side (from `r.sub(block_r)` to `r`). // SAFETY: The documentation for .add() specifically mention that `vec.as_ptr().add(vec.len())` is always safe` let mut r = unsafe { l.add(v.len()) }; let mut block_r = BLOCK; - let mut start_r = ptr::null_mut(); - let mut end_r = ptr::null_mut(); let mut offsets_r = [MaybeUninit::::uninit(); BLOCK]; + let mut start_r = offsets_r.as_mut_ptr() as *mut u8; + let mut end_r = start_r; // FIXME: When we get VLAs, try creating one array of length `min(v.len(), 2 * BLOCK)` rather // than two fixed-size arrays of length `BLOCK`. VLAs might be more cache-efficient. @@ -893,3 +900,50 @@ where recurse(v, &is_less, None, limit, canceled) } + +#[cfg(test)] +mod tests { + use super::par_quicksort; + use std::sync::atomic::AtomicBool; + + /// Deterministic xorshift so the test needs no extra dependencies. + fn pseudo_random(len: usize, mut seed: u64) -> Vec { + (0..len) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }) + .collect() + } + + fn check(mut v: Vec) { + let mut expected = v.clone(); + expected.sort_unstable(); + let canceled = AtomicBool::new(false); + assert!(!par_quicksort(&mut v, |a, b| a < b, &canceled)); + assert_eq!(v, expected); + } + + /// Exercises the block partition (`partition_in_blocks`), which only runs + /// for slices longer than the insertion-sort threshold and needs more than + /// `2 * BLOCK` elements to take the block-by-block path. + #[test] + fn par_quicksort_matches_std_sort() { + for len in [0, 1, 2, 20, 255, 256, 257, 1_000, 10_000, 100_000] { + check(pseudo_random(len, 0x9E37_79B9_7F4A_7C15 ^ len as u64)); + check((0..len as u64).collect()); + check((0..len as u64).rev().collect()); + check(vec![7; len]); + check((0..len as u64).map(|i| i % 17).collect()); + } + } + + #[test] + fn par_quicksort_reports_cancellation() { + let mut v = pseudo_random(10_000, 42); + let canceled = AtomicBool::new(true); + assert!(par_quicksort(&mut v, |a, b| a < b, &canceled)); + } +}