diff --git a/.prettierignore b/.prettierignore index 5a6863b9..a60d3a81 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,9 +17,16 @@ **/.pytest_cache/** **/.venv/** **/.DS_Store +**/AGENTS.md +**/CLAUDE.md +**/*codex*.md **/claude_memory_*.md **/Codescribe_memory_*.md +**/logs/** **/node_modules/** +**/build/** +**/.build/** +**/DerivedData/** **/target/** **/target-noembed/** **/dist/** @@ -38,3 +45,4 @@ **/*.mp3 **/*.m4a **/*.zip +**/*.xcassets/** diff --git a/Makefile b/Makefile index 38b5112d..1fcf198f 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ release-codescribe-embedded: release-codescribe # Full verified pipeline: cargo (ffi dylib) → uniffi-bindgen → xcodegen → xcodebuild. # `app-bindings` stops after xcodegen (no Xcode needed) for fast Rust-side iteration. app: - @echo "Building CodeScribe.app (SwiftUI, PROFILE=$(PROFILE))..." + @echo "Building Codescribe.app (SwiftUI, PROFILE=$(PROFILE))..." @./scripts/build-app.sh $(PROFILE) app-bindings: diff --git a/app/agent/anthropic_provider.rs b/app/agent/anthropic_provider.rs index 7654cb1b..86475cd1 100644 --- a/app/agent/anthropic_provider.rs +++ b/app/agent/anthropic_provider.rs @@ -42,14 +42,10 @@ use codescribe_core::agent::{ }; use codescribe_core::llm::provider::{ProviderKind, capability_policy}; -const DEFAULT_ANTHROPIC_ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; const ANTHROPIC_VERSION: &str = "2023-06-01"; /// Anthropic requires `max_tokens` on every request; used only when the caller /// (assistive lane) leaves `options.max_tokens` unset. const DEFAULT_MAX_TOKENS: u32 = 8192; -/// Safe Claude default so the agent path never falls into an empty model when -/// the assistive-lane model env is unset (Settings normally supplies one). -const DEFAULT_ANTHROPIC_MODEL: &str = "claude-opus-4-8"; const DEFAULT_INITIAL_RESPONSE_TIMEOUT_MS: u64 = 90_000; const DEFAULT_INTER_CHUNK_TIMEOUT_MS: u64 = 90_000; @@ -69,20 +65,20 @@ pub struct AnthropicProvider { } impl AnthropicProvider { - pub fn from_env() -> Result { - let api_key = get_env_non_empty("LLM_ANTHROPIC_API_KEY", "Anthropic API key (assistive)")?; - let endpoint = std::env::var("LLM_ANTHROPIC_ENDPOINT") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| DEFAULT_ANTHROPIC_ENDPOINT.to_string()); + /// Build from the resolved assistive lane (fresh settings → env → + /// Keychain). Anthropic always authenticates, so a missing key is a + /// readable error naming the exact account — the availability gate + /// reports the same reason before a send is ever attempted. + pub fn from_lane( + lane: codescribe_core::llm::lane_truth::AssistiveLaneSnapshot, + ) -> Result { + let api_key = lane + .api_key + .context("Anthropic API key (assistive) is required. Set LLM_ANTHROPIC_API_KEY.")?; + let endpoint = lane.endpoint; // Model comes from the shared assistive-lane setting; Settings supplies a // Claude model when the assistive provider is Anthropic. - let default_model = std::env::var("LLM_ASSISTIVE_MODEL") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| DEFAULT_ANTHROPIC_MODEL.to_string()); + let default_model = lane.model; let initial_response_timeout = Duration::from_millis(parse_env_u64( "CODESCRIBE_AI_ATTEMPT_TIMEOUT_MS", @@ -805,15 +801,6 @@ fn validate_anthropic_endpoint(endpoint: &str) -> Result { Ok(url) } -fn get_env_non_empty(key: &str, label: &str) -> Result { - let value = std::env::var(key).with_context(|| format!("{label} is required. Set {key}."))?; - let trimmed = value.trim(); - if trimmed.is_empty() { - anyhow::bail!("{label} is required. Set {key}."); - } - Ok(trimmed.to_string()) -} - fn parse_env_u64(key: &str, default: u64) -> u64 { std::env::var(key) .ok() diff --git a/app/agent/mod.rs b/app/agent/mod.rs index 24c637a6..9d299707 100644 --- a/app/agent/mod.rs +++ b/app/agent/mod.rs @@ -1,6 +1,8 @@ use anyhow::Result; use codescribe_core::agent::AgentProvider; -use codescribe_core::llm::provider::{LlmMode, ProviderKind, resolve_provider}; +use codescribe_core::config::Config; +use codescribe_core::llm::lane_truth; +use codescribe_core::llm::provider::ProviderKind; pub mod anthropic_provider; pub mod openai_provider; @@ -10,18 +12,24 @@ pub mod tools; pub use anthropic_provider::AnthropicProvider; pub use openai_provider::OpenAiProvider; -pub fn create_openai_provider_from_env() -> Result { - OpenAiProvider::from_env() -} - -/// Build the agent (assistive-lane) provider from the configured provider -/// identity (`LLM_ASSISTIVE_PROVIDER`, resolved by -/// [`resolve_provider`]). A missing key for the SELECTED provider surfaces its -/// own clear error (`from_env`) rather than silently falling back to the other -/// provider — misconfiguration must never route to an unintended vendor. +/// Build the agent (assistive-lane) provider from the lane-truth snapshot +/// (fresh settings → env → Keychain), so a Settings save is honored on the +/// very next send — no restart, no stale bootstrap env. A lane that cannot be +/// reached fails with the same actionable reason +/// [`assistive_unavailable_reason`] reports, never a generic key demand, and +/// never a silent fallback to an unintended vendor. pub fn create_default_provider() -> Result> { - match resolve_provider(LlmMode::Assistive) { - ProviderKind::OpenAiResponses => Ok(Box::new(OpenAiProvider::from_env()?)), - ProviderKind::AnthropicMessages => Ok(Box::new(AnthropicProvider::from_env()?)), + let config = Config::load(); + let lane = lane_truth::assistive_availability(&config).map_err(anyhow::Error::msg)?; + match lane.provider { + ProviderKind::OpenAiResponses => Ok(Box::new(OpenAiProvider::from_lane(lane)?)), + ProviderKind::AnthropicMessages => Ok(Box::new(AnthropicProvider::from_lane(lane)?)), } } + +/// User-facing reason the assistive lane cannot reach a model right now +/// (`None` when a send can proceed). Kept beside [`create_default_provider`] +/// so the availability gate and provider construction can never drift. +pub fn assistive_unavailable_reason() -> Option { + lane_truth::assistive_availability(&Config::load()).err() +} diff --git a/app/agent/openai_provider.rs b/app/agent/openai_provider.rs index 726ed27e..e1369ec1 100644 --- a/app/agent/openai_provider.rs +++ b/app/agent/openai_provider.rs @@ -15,8 +15,11 @@ use codescribe_core::agent::{ AgentEvent, AgentProvider, ContentBlock, ImageAsset, Message, Role, StreamOptions, ToolDefinition, }; +use codescribe_core::llm::account_auth; +use codescribe_core::llm::lane_truth::AssistiveLaneSnapshot; +use codescribe_core::llm::provider::ProviderKind; use codescribe_core::llm::responses_streaming_manager::{ - ResponsesStreamingManager, StreamCallbacks, + AuthHeaderMode, ResponsesStreamingManager, StreamCallbacks, }; const DEFAULT_INITIAL_RESPONSE_TIMEOUT_MS: u64 = 90_000; @@ -52,13 +55,27 @@ pub struct OpenAiProvider { previous_response_id: Arc>>, initial_response_timeout: Duration, inter_chunk_timeout: Duration, + /// Lane resolved to "Sign in with ChatGPT" account auth (no API key, official + /// endpoint, stored tokens). Each request fetches a FRESH access token via + /// `account_auth` so the auto-refresh path keeps long sessions alive. + use_account_auth: bool, } impl OpenAiProvider { - pub fn from_env() -> Result { - let endpoint = get_env_non_empty("LLM_ASSISTIVE_ENDPOINT", "LLM endpoint (assistive)")?; - let default_model = get_env_non_empty("LLM_ASSISTIVE_MODEL", "LLM model (assistive)")?; - let api_key = get_env_non_empty("LLM_ASSISTIVE_API_KEY", "OpenAI API key (assistive)")?; + /// Build from the resolved assistive lane (fresh settings → env → + /// Keychain) instead of the frozen bootstrap process env. `api_key: None` + /// becomes an empty key, which the streaming manager translates into a + /// clean unauthenticated request — key-optional local endpoints are a + /// first-class configuration, not an error. + pub fn from_lane(lane: AssistiveLaneSnapshot) -> Result { + let AssistiveLaneSnapshot { + endpoint, + model: default_model, + api_key, + account_auth: use_account_auth, + provider: _, + } = lane; + let api_key = api_key.unwrap_or_default(); let use_previous_response_id = parse_env_bool("CODESCRIBE_AGENT_USE_PREVIOUS_RESPONSE_ID", true); @@ -93,6 +110,7 @@ impl OpenAiProvider { previous_response_id: Arc::new(Mutex::new(None)), initial_response_timeout, inter_chunk_timeout, + use_account_auth, }) } } @@ -152,17 +170,39 @@ impl AgentProvider for OpenAiProvider { stream: true, }; + // Account-auth lanes fetch a fresh access token per request (60s-skew + // auto-refresh) — never a token frozen at provider construction. The + // manager formats the `Bearer` header itself, so this is the raw token. + let account_token = if self.use_account_auth { + Some( + account_auth::access_token(ProviderKind::OpenAiResponses) + .await + .map_err(|error| { + anyhow::anyhow!("ChatGPT account authentication failed: {error}") + })?, + ) + } else { + None + }; + let auth_secret = account_token.as_deref().unwrap_or(&self.api_key); + + let auth_header_mode = if self.use_account_auth { + AuthHeaderMode::BearerOnly + } else { + AuthHeaderMode::BearerAndApiKey + }; let manager = ResponsesStreamingManager::new( &self.client, &self.endpoint, - &self.api_key, + auth_secret, StreamCallbacks { assistant: None, reasoning: None, }, self.initial_response_timeout, self.inter_chunk_timeout, - ); + ) + .with_auth_header_mode(auth_header_mode); let provider_rx = manager.stream_agent(&request).await?; @@ -557,15 +597,6 @@ fn to_data_uri(data: &[u8], media_type: &str) -> String { format!("data:{media_type};base64,{}", BASE64.encode(data)) } -fn get_env_non_empty(key: &str, label: &str) -> Result { - let value = env::var(key).with_context(|| format!("{label} is required. Set {key}."))?; - let trimmed = value.trim(); - if trimmed.is_empty() { - anyhow::bail!("{label} is required. Set {key}."); - } - Ok(trimmed.to_string()) -} - fn parse_env_u64(key: &str, default: u64) -> u64 { env::var(key) .ok() @@ -848,6 +879,7 @@ mod tests { previous_response_id: Arc::new(Mutex::new(None)), initial_response_timeout: Duration::from_secs(1), inter_chunk_timeout: Duration::from_secs(1), + use_account_auth: false, }; let messages = vec![Message::new( Role::User, @@ -890,6 +922,7 @@ mod tests { previous_response_id: Arc::clone(&stored_chain), initial_response_timeout: Duration::from_secs(1), inter_chunk_timeout: Duration::from_secs(1), + use_account_auth: false, }; // Pre-condition: stored chain holds prior failed attempt's response id. @@ -923,6 +956,7 @@ mod tests { previous_response_id: Arc::clone(&stored_chain), initial_response_timeout: Duration::from_secs(1), inter_chunk_timeout: Duration::from_secs(1), + use_account_auth: false, }; let options = StreamOptions::default(); @@ -1001,6 +1035,7 @@ mod tests { previous_response_id: Arc::clone(&stored_chain), initial_response_timeout: Duration::from_secs(1), inter_chunk_timeout: Duration::from_secs(1), + use_account_auth: false, }; let reset_options = StreamOptions { reset_chain: true, @@ -1177,6 +1212,7 @@ mod tests { previous_response_id: Arc::clone(&stored_chain), initial_response_timeout: Duration::from_secs(2), inter_chunk_timeout: Duration::from_secs(2), + use_account_auth: false, }; let messages = vec![Message::new( Role::User, diff --git a/app/agent/tools/mcp.rs b/app/agent/tools/mcp.rs index f65bb7de..38b9e32b 100644 --- a/app/agent/tools/mcp.rs +++ b/app/agent/tools/mcp.rs @@ -5,7 +5,8 @@ use std::thread; use anyhow::{Context, Result, bail}; use codescribe_core::agent::{ToolDefinition, ToolRegistry, ToolResultContent}; -use codescribe_core::llm::provider::{LlmMode, resolve_provider}; +use codescribe_core::llm::lane_truth; +use codescribe_core::llm::provider::LlmMode; use codescribe_core::mcp::{McpClient, McpConfigFile, McpServerConfig, McpTool}; use tracing::{info, warn}; @@ -224,23 +225,26 @@ pub struct CoreReadiness { pub provider_label: String, /// Keychain/env account holding that provider's assistive key. pub key_env_key: String, - /// Whether that key is present (non-empty) in the process env. + /// Whether that key is present (non-empty) in env or Keychain. pub key_set: bool, /// Number of native (compiled-in) tools available to the agent. pub native_tool_count: usize, } /// Probe the core capability gate from live process state: the configured -/// assistive provider ([`resolve_provider`]), whether its key is set, and the -/// count of native tools. Cheap — an env read plus building an in-memory registry -/// (no server spawning, no network). Callers that need Keychain-backed keys in -/// env must load `Config` first (the bridge does this before probing). +/// assistive provider ([`lane_truth::provider`]), whether its key is set, and +/// the count of native tools. Cheap — local config/secret reads plus building an +/// in-memory registry (no server spawning, no network). pub fn probe_core_readiness() -> CoreReadiness { - let provider = resolve_provider(LlmMode::Assistive); + probe_core_readiness_with_secret(lane_truth::secret) +} + +fn probe_core_readiness_with_secret( + resolve_secret: impl FnOnce(&str) -> Option, +) -> CoreReadiness { + let provider = lane_truth::provider(LlmMode::Assistive); let key_env_key = provider.api_key_env_key().to_string(); - let key_set = std::env::var(&key_env_key) - .map(|value| !value.trim().is_empty()) - .unwrap_or(false); + let key_set = resolve_secret(&key_env_key).is_some(); let mut registry = ToolRegistry::new(); super::register_native_tools(&mut registry); @@ -714,10 +718,11 @@ mod tests { use codescribe_core::agent::{ToolRegistry, ToolResultContent}; use serde_json::json; + use serial_test::serial; use super::{ - McpRowTone, probe_agentic_readiness_at, probe_mcp_status_at, public_tool_name, - register_mcp_tools_from_config_path, + McpRowTone, probe_agentic_readiness_at, probe_core_readiness_with_secret, + probe_mcp_status_at, public_tool_name, register_mcp_tools_from_config_path, }; #[test] @@ -905,6 +910,23 @@ mod tests { assert!(!core.key_env_key.is_empty()); } + #[test] + #[serial] + fn lane_truth_keychain_only_secret_sets_probe_core_readiness() { + let _provider = EnvGuard::remove("LLM_ASSISTIVE_PROVIDER"); + let _key = EnvGuard::remove("LLM_ASSISTIVE_API_KEY"); + + let core = probe_core_readiness_with_secret(|account| { + (account == "LLM_ASSISTIVE_API_KEY").then(|| "keychain-only".to_string()) + }); + + assert_eq!(core.key_env_key, "LLM_ASSISTIVE_API_KEY"); + assert!( + core.key_set, + "a Keychain-only secret must satisfy readiness" + ); + } + #[test] fn readiness_is_driven_by_core_gate_not_operator_tooling() { // Empty MCP config (no operator tooling at all) but a passing core gate: @@ -1069,4 +1091,33 @@ mod tests { assert_eq!(basic.summary_rows().len(), 1); assert_eq!(basic.summary_rows()[0].tone, McpRowTone::Neutral); } + + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn remove(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: this process-env test is serialized with `serial`. + unsafe { std::env::remove_var(key) }; + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.previous.as_deref() { + Some(value) => { + // SAFETY: this process-env test is serialized with `serial`. + unsafe { std::env::set_var(self.key, value) }; + } + None => { + // SAFETY: this process-env test is serialized with `serial`. + unsafe { std::env::remove_var(self.key) }; + } + } + } + } } diff --git a/app/controller/helpers.rs b/app/controller/helpers.rs index 391b0a36..922e5750 100644 --- a/app/controller/helpers.rs +++ b/app/controller/helpers.rs @@ -11,12 +11,13 @@ use tokio::sync::{Mutex as TokioMutex, mpsc}; use tracing::{debug, info, warn}; use crate::agent_delivery::AgentDeliveryEvent; -use crate::config::default_assistive_model; use anyhow::{Context, Result}; use codescribe_core::agent::{ AgentSession, AgentUiEvent, ContentBlock, ImageAttachment, Message, Role, StreamOptions, Thread, ThreadMessage, ThreadStore, ToolRegistry, }; +use codescribe_core::config::Config; +use codescribe_core::llm::lane_truth; use serde_json::{Value, json}; use crate::os::tray_status; @@ -286,13 +287,7 @@ fn build_agent_stream_options(ai_assistive_max_tokens: i32) -> StreamOptions { .ok() .filter(|tokens| *tokens > 0); - // Model name comes from settings.json -> loader.rs -> env var. Keep a - // release-safe OpenAI default so the agent path never falls into an empty - // or provider-specific placeholder model. - let model = std::env::var("LLM_ASSISTIVE_MODEL") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(default_assistive_model); + let (_, model) = lane_truth::assistive_identity(&Config::load()); StreamOptions { model, @@ -553,7 +548,7 @@ fn legacy_assistive_thread_messages( fn persist_runtime_thread(runtime: &AgentRuntime) -> Result<()> { let store = ThreadStore::new().context("Failed to initialize ThreadStore")?; let now = Utc::now(); - let model = std::env::var("LLM_ASSISTIVE_MODEL").unwrap_or_else(|_| "unknown".to_string()); + let (provider, model) = lane_truth::assistive_identity(&Config::load()); let mut thread = store .load_thread(&runtime.thread_store_id) @@ -569,7 +564,7 @@ fn persist_runtime_thread(runtime: &AgentRuntime) -> Result<()> { messages: Vec::new(), summary: None, total_tokens: None, - provider: "openai-responses".to_string(), + provider: provider.as_str().to_string(), model: model.clone(), }); @@ -584,7 +579,7 @@ fn persist_runtime_thread(runtime: &AgentRuntime) -> Result<()> { .iter() .map(ThreadMessage::from) .collect(); - thread.provider = "openai-responses".to_string(); + thread.provider = provider.as_str().to_string(); thread.model = model; store @@ -603,7 +598,7 @@ fn persist_legacy_assistive_thread(user_text: &str, assistant_text: &str) -> Res let store = ThreadStore::new().context("Failed to initialize ThreadStore")?; let now = Utc::now(); - let model = std::env::var("LLM_ASSISTIVE_MODEL").unwrap_or_else(|_| "unknown".to_string()); + let (_, model) = lane_truth::assistive_identity(&Config::load()); let mut title = user_text.chars().take(72).collect::(); if title.is_empty() { @@ -1732,7 +1727,7 @@ mod tests { let dir = std::env::temp_dir().join(format!("cs_helpers_vision_{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); let img = dir.join("shot.png"); - std::fs::write(&img, b"\x89PNG\r\n\x1a\nfake").unwrap(); + std::fs::write(&img, b"\x89PNG\r\n\x1a\nfake").expect("test: write fake image"); let missing = dir.join("gone.png"); let text = format!( @@ -1764,7 +1759,8 @@ mod tests { let mut lines = String::from("multi\n\nATTACHMENTS (image paths)\n"); for i in 0..(MAX_AGENT_VISION_IMAGES + 2) { let p = dir.join(format!("img{i}.png")); - std::fs::write(&p, b"\x89PNG\r\n\x1a\nfake").unwrap(); + std::fs::write(&p, b"\x89PNG\r\n\x1a\nfake") + .expect("test: write fake image for overflow"); lines.push_str(&format!("- {}\n", p.display())); } let (_cleaned, images, dropped) = build_image_attachments_from_text(&lines); diff --git a/app/controller/mod.rs b/app/controller/mod.rs index aa0b1f4f..36a7f955 100644 --- a/app/controller/mod.rs +++ b/app/controller/mod.rs @@ -1291,29 +1291,39 @@ impl RecordingController { info!("RECOVERY decision: stale active stream cleared, controller remains IDLE"); } - fn configure_hold_event_sink( - recorder: &mut StreamingRecorder, + fn build_recording_event_sink( + transcript_buffer: Arc>, preview_deltas_enabled: bool, event_broadcast: broadcast::Sender, session_telemetry: SharedSessionTelemetry, - ) { - let tb = recorder.transcript_buffer_handle(); + ) -> Arc { let delta_sink = preview_deltas_enabled.then(|| { Arc::new(helpers::RoutingDeltaSink) as Arc }); - let pe: Arc = - Arc::new(PresentationEmitter::new(tb, delta_sink, None)); + let pe: Arc = Arc::new( + PresentationEmitter::new(transcript_buffer, delta_sink, None), + ); let ipc_sink: Arc = Arc::new(helpers::IpcBroadcastSink::new(event_broadcast)); let telemetry_sink: Arc = Arc::new(helpers::SessionTelemetrySink::new(session_telemetry)); - recorder.set_event_sink(Some(Arc::new( - codescribe_core::pipeline::sinks::FanoutEventSink::new(vec![ - pe, - ipc_sink, - telemetry_sink, - ]), + Arc::new(codescribe_core::pipeline::sinks::FanoutEventSink::new( + vec![pe, ipc_sink, telemetry_sink], + )) + } + + fn configure_hold_event_sink( + recorder: &mut StreamingRecorder, + preview_deltas_enabled: bool, + event_broadcast: broadcast::Sender, + session_telemetry: SharedSessionTelemetry, + ) { + recorder.set_event_sink(Some(Self::build_recording_event_sink( + recorder.transcript_buffer_handle(), + preview_deltas_enabled, + event_broadcast, + session_telemetry, ))); } @@ -1331,24 +1341,11 @@ impl RecordingController { // appends into the current chat user bubble, and VAD end commits that bubble to the // agent without stopping the recorder. Do not route assistive live preview deltas // into the same bubble, or previews and finals will duplicate. - let tb = recorder.transcript_buffer_handle(); - let delta_sink = preview_deltas_enabled.then(|| { - Arc::new(helpers::RoutingDeltaSink) - as Arc - }); - let pe = PresentationEmitter::new(tb, delta_sink, None); - - let pe: Arc = Arc::new(pe); - let ipc_sink: Arc = - Arc::new(helpers::IpcBroadcastSink::new(event_broadcast)); - let telemetry_sink: Arc = - Arc::new(helpers::SessionTelemetrySink::new(session_telemetry)); - recorder.set_event_sink(Some(Arc::new( - codescribe_core::pipeline::sinks::FanoutEventSink::new(vec![ - pe, - ipc_sink, - telemetry_sink, - ]), + recorder.set_event_sink(Some(Self::build_recording_event_sink( + recorder.transcript_buffer_handle(), + preview_deltas_enabled, + event_broadcast, + session_telemetry, ))); } @@ -3126,19 +3123,9 @@ impl RecordingController { } = p; let language_opt = language_opt.as_deref(); - // Hands-off (non-assistive) is a single RAW capture; formatting is the explicit - // post-recording [Format] action, never an auto-format on stop. Force the raw - // branch for the adjudicated hands-off stop so the session lands fast + raw in - // the decision overlay — no AI round-trip on the stop path (this branch is the - // toggle-stuck-watchdog hot path; an extra format call here re-introduces stop - // latency). (ADR 2026-05-28 Faza 1: differentiation by action, not by mode.) - let toggle_handsoff = !assistive - && matches!( - transcript_source, - Some(RecordingTranscriptSource::ToggleSessionAdjudicated) - ); - let force_raw = force_raw || toggle_handsoff; - let force_ai = force_ai && !toggle_handsoff; + // Preserve the mode resolved at recording start. Toggle-adjudicated hands-off + // sessions still land in the decision overlay below, but they must not erase + // the Settings formatting default by force-routing the transcript to RAW. // ALWAYS-ON: Final post-processing pass (lexicon + cleanup + semantic gate) // This ensures ALL output paths receive clean text regardless of mode. diff --git a/app/controller/tests.rs b/app/controller/tests.rs index 86fe2304..6be4e339 100644 --- a/app/controller/tests.rs +++ b/app/controller/tests.rs @@ -1,6 +1,8 @@ //! Controller unit tests use super::*; +use crate::controller::types::{TranscriptPipelineParams, read_truth_sidecar}; +use codescribe_core::pipeline::contracts::EngineEvent; use serial_test::serial; use std::time::Duration; @@ -83,7 +85,10 @@ async fn test_hold_down_schedules_delayed_start() { force_ai: false, }; - controller.handle_hotkey_event(event).await.unwrap(); + controller + .handle_hotkey_event(event) + .await + .expect("down hotkey must schedule without error in test"); // Should still be IDLE (delay not elapsed) assert_eq!(controller.current_state().await, State::Idle); @@ -110,7 +115,10 @@ async fn test_assistive_hold_ctrl_uses_safe_delay_floor() { force_ai: false, }; - controller.handle_hotkey_event(event).await.unwrap(); + controller + .handle_hotkey_event(event) + .await + .expect("assistive ctrl hold hotkey must process in test"); tokio::time::sleep(Duration::from_millis(250)).await; assert_eq!(controller.current_state().await, State::Idle); @@ -135,7 +143,10 @@ async fn test_hold_up_before_delay_cancels() { force_raw: true, force_ai: false, }; - controller.handle_hotkey_event(down_event).await.unwrap(); + controller + .handle_hotkey_event(down_event) + .await + .expect("down must process for cancel test"); // Release before delay elapses tokio::time::sleep(Duration::from_millis(50)).await; @@ -147,7 +158,10 @@ async fn test_hold_up_before_delay_cancels() { force_raw: true, force_ai: false, }; - controller.handle_hotkey_event(up_event).await.unwrap(); + controller + .handle_hotkey_event(up_event) + .await + .expect("up must cancel the pending hold"); // Wait past the original delay tokio::time::sleep(Duration::from_millis(200)).await; @@ -170,8 +184,10 @@ async fn test_fast_assistive_ctrl_tap_before_floor_is_noop() { force_raw: false, force_ai: false, }; - controller.handle_hotkey_event(down_event).await.unwrap(); - + controller + .handle_hotkey_event(down_event) + .await + .expect("hotkey down must succeed in test harness (P2-07 over-correct)"); tokio::time::sleep(Duration::from_millis(100)).await; let up_event = HotkeyInput { key_type: HotkeyType::Hold, @@ -181,7 +197,10 @@ async fn test_fast_assistive_ctrl_tap_before_floor_is_noop() { force_raw: false, force_ai: false, }; - controller.handle_hotkey_event(up_event).await.unwrap(); + controller + .handle_hotkey_event(up_event) + .await + .expect("hotkey up must succeed in test harness (P2-07 over-correct)"); tokio::time::sleep(Duration::from_millis(350)).await; assert_eq!(controller.current_state().await, State::Idle); @@ -201,7 +220,10 @@ async fn test_toggle_starts_immediately() { force_ai: false, }; - controller.handle_hotkey_event(event).await.unwrap(); + controller + .handle_hotkey_event(event) + .await + .expect("toggle press must start recording immediately in test"); // Should immediately transition to REC_TOGGLE assert_eq!(controller.current_state().await, State::RecToggle); @@ -224,7 +246,10 @@ async fn test_busy_state_ignores_hotkeys() { force_ai: false, }; - controller.handle_hotkey_event(event).await.unwrap(); + controller + .handle_hotkey_event(event) + .await + .expect("ignored hotkey in busy must not error the handler"); // Should remain BUSY assert_eq!(controller.current_state().await, State::Busy); @@ -411,6 +436,228 @@ fn test_transcript_delivery_wrap_uses_config_when_enabled() { ); } +fn test_transcript_pipeline_params( + raw_text: &str, + config: Config, + force_raw: bool, + force_ai: bool, + transcript_source: Option, +) -> TranscriptPipelineParams { + TranscriptPipelineParams { + raw_text: raw_text.to_string(), + recording_timestamp: chrono::Local::now(), + assistive: false, + hold_mode: HoldMode::Raw, + force_raw, + force_ai, + config, + language_opt: None, + raw_save_enabled: false, + audio_path: None, + cloud_verdict_opt: None, + cloud_handle: None, + transcript_source, + truth_fallback_class: None, + truth_no_speech_reason: None, + truth_speech_pct: None, + truth_avg_logprob: None, + truth_confidence_flags: Vec::new(), + truth_sparkline: None, + truth_final_pass_disposition: None, + truth_commit_trigger: None, + truth_display_status: "Ready".to_string(), + append_mode: false, + live_stream_session: false, + user_needs_separator: false, + assistant_needs_separator: false, + skip_user_bubble: false, + } +} + +async fn final_transcript_text(events: &mut tokio::sync::broadcast::Receiver) -> String { + loop { + match events.recv().await.expect("final transcript event") { + IpcEvent { + payload: IpcEventPayload::FinalTranscript { text }, + .. + } => return text, + _ => continue, + } + } +} + +struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &std::path::Path) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: tests using this guard are serialized with `#[serial]`, so no + // other thread touches the process environment concurrently. + unsafe { + std::env::set_var(key, value); + } + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + // SAFETY: restore runs under the same `#[serial]` serialization as `set`. + unsafe { + if let Some(previous) = &self.previous { + std::env::set_var(self.key, previous); + } else { + std::env::remove_var(self.key); + } + } + } +} + +fn latest_truth_metadata(root: &std::path::Path) -> RecordingTruthMetadata { + let transcriptions = root.join("transcriptions"); + let mut truth_files = Vec::new(); + for day in std::fs::read_dir(&transcriptions).expect("transcriptions dir") { + let day = day.expect("day dir"); + for entry in std::fs::read_dir(day.path()).expect("day entries") { + let path = entry.expect("history entry").path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".txt.truth.json")) + { + truth_files.push(path); + } + } + } + truth_files.sort(); + let truth_path = truth_files.pop().expect("truth sidecar"); + let transcript_path = truth_path.with_file_name( + truth_path + .file_name() + .and_then(|name| name.to_str()) + .expect("truth file name") + .trim_end_matches(".truth.json"), + ); + read_truth_sidecar(&transcript_path).expect("read truth sidecar") +} + +#[tokio::test] +#[serial] +async fn test_raw_pipeline_applies_lexicon_without_ai_formatting() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let _env_guard = EnvVarGuard::set("CODESCRIBE_DATA_DIR", temp_dir.path()); + let controller = RecordingController::new(); + let config = Config { + ai_formatting_enabled: true, + transcript_tagging_enabled: true, + transcript_tag_template: + codescribe_core::transcript_tagging::DEFAULT_TRANSCRIPT_TAG_TEMPLATE.to_string(), + transcription_overlay_enabled: true, + ..Config::default() + }; + + let mut events = controller.subscribe_events(); + controller + .process_transcript_text_pipeline(test_transcript_pipeline_params( + "Uzywam doker do kontenerow.", + config, + true, + false, + Some(RecordingTranscriptSource::ToggleSessionAdjudicated), + )) + .await + .expect("raw pipeline succeeds"); + let final_text = final_transcript_text(&mut events).await; + + assert!( + final_text.contains("Docker"), + "RAW delivery must still apply deterministic lexicon corrections: {final_text}" + ); + let metadata = latest_truth_metadata(temp_dir.path()); + assert_eq!( + metadata.mode.as_deref(), + Some("raw"), + "force_raw must remain RAW and must not run AI formatting" + ); +} + +#[tokio::test] +#[serial] +async fn test_toggle_adjudicated_respects_settings_default_without_hotkey_override() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let _env_guard = EnvVarGuard::set("CODESCRIBE_DATA_DIR", temp_dir.path()); + let controller = RecordingController::new(); + let config = Config { + ai_formatting_enabled: true, + transcript_tagging_enabled: true, + transcript_tag_template: + codescribe_core::transcript_tagging::DEFAULT_TRANSCRIPT_TAG_TEMPLATE.to_string(), + transcription_overlay_enabled: true, + ..Config::default() + }; + + let mut events = controller.subscribe_events(); + controller + .process_transcript_text_pipeline(test_transcript_pipeline_params( + "literal transcript", + config, + false, + false, + Some(RecordingTranscriptSource::ToggleSessionAdjudicated), + )) + .await + .expect("settings-default pipeline succeeds"); + let final_text = final_transcript_text(&mut events).await; + + assert_eq!(final_text, "literal transcript"); + let metadata = latest_truth_metadata(temp_dir.path()); + assert_eq!( + metadata.mode.as_deref(), + Some("toggle"), + "no hotkey override must preserve the Settings-default toggle route, not force RAW" + ); +} + +#[tokio::test] +#[serial] +async fn test_toggle_adjudicated_explicit_raw_override_still_wins() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let _env_guard = EnvVarGuard::set("CODESCRIBE_DATA_DIR", temp_dir.path()); + let controller = RecordingController::new(); + let config = Config { + ai_formatting_enabled: true, + transcript_tagging_enabled: true, + transcript_tag_template: + codescribe_core::transcript_tagging::DEFAULT_TRANSCRIPT_TAG_TEMPLATE.to_string(), + transcription_overlay_enabled: true, + ..Config::default() + }; + + let mut events = controller.subscribe_events(); + controller + .process_transcript_text_pipeline(test_transcript_pipeline_params( + "literal transcript", + config, + true, + false, + Some(RecordingTranscriptSource::ToggleSessionAdjudicated), + )) + .await + .expect("explicit raw override pipeline succeeds"); + let final_text = final_transcript_text(&mut events).await; + + assert_eq!(final_text, "literal transcript"); + let metadata = latest_truth_metadata(temp_dir.path()); + assert_eq!( + metadata.mode.as_deref(), + Some("raw"), + "explicit RAW hotkey override must still win over the Settings default" + ); +} + #[test] fn test_toggle_stop_event_preserves_active_session_identity() { let right_option_stop = HotkeyInput { @@ -1096,7 +1343,10 @@ async fn test_toggle_press_does_not_set_force_raw_mode() { force_raw: false, force_ai: false, }; - controller.handle_hotkey_event(event).await.unwrap(); + controller + .handle_hotkey_event(event) + .await + .expect("toggle press hotkey must succeed in test (P2-07)"); // force_raw should be false assert!( @@ -1122,7 +1372,10 @@ async fn test_toggle_press_sets_force_ai_mode() { force_raw: false, force_ai: true, }; - controller.handle_hotkey_event(event).await.unwrap(); + controller + .handle_hotkey_event(event) + .await + .expect("toggle force_ai hotkey must succeed (P2-07 over-correct)"); assert!( *controller.force_ai_mode.read().await, @@ -1892,6 +2145,112 @@ async fn test_processing_failure_emits_user_visible_warning() { } } +fn test_final_event(utterance_id: u64, text: &str) -> EngineEvent { + EngineEvent::UtteranceFinal { + utterance_id, + text: text.to_string(), + raw_text: text.to_string(), + start_ts: 0.0, + end_ts: 1.0, + segments: Vec::new(), + vad_speech_pct: Some(100.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + } +} + +#[tokio::test] +async fn hold_event_sink_forwards_live_preview_then_final_in_order() { + let controller = RecordingController::new(); + let mut events = controller.subscribe_events(); + let sink = RecordingController::build_recording_event_sink( + Arc::new(tokio::sync::Mutex::new(String::new())), + true, + controller.event_broadcast.clone(), + Arc::clone(&controller.session_telemetry), + ); + + sink.on_event(&EngineEvent::Preview { + rev: 1, + text: "live words".to_string(), + }); + sink.on_event(&test_final_event(41, "live words")); + + let preview = events.try_recv().expect("hold preview must reach IPC"); + let final_event = events.try_recv().expect("hold final must reach IPC"); + assert!(matches!( + preview.payload, + IpcEventPayload::Engine(EngineEventWire::Preview { rev: 1, ref text }) + if text == "live words" + )); + assert!(matches!( + final_event.payload, + IpcEventPayload::Engine(EngineEventWire::UtteranceFinal { + utterance_id: 41, + ref text, + .. + }) if text == "live words" + )); + assert!( + events.try_recv().is_err(), + "one engine event must yield one IPC event" + ); +} + +#[tokio::test] +async fn late_correction_after_final_is_a_single_patch_event_not_a_second_final() { + let controller = RecordingController::new(); + let mut events = controller.subscribe_events(); + let sink = RecordingController::build_recording_event_sink( + Arc::new(tokio::sync::Mutex::new(String::new())), + true, + controller.event_broadcast.clone(), + Arc::clone(&controller.session_telemetry), + ); + + sink.on_event(&EngineEvent::Preview { + rev: 1, + text: "raw text".to_string(), + }); + sink.on_event(&test_final_event(7, "raw text")); + sink.on_event(&EngineEvent::Correction { + rev: 2, + text: "corrected text".to_string(), + previous_text: "raw text".to_string(), + }); + + let payloads = [ + events.try_recv().expect("preview event").payload, + events.try_recv().expect("final event").payload, + events.try_recv().expect("correction event").payload, + ]; + assert_eq!( + payloads + .iter() + .filter(|payload| matches!( + payload, + IpcEventPayload::Engine(EngineEventWire::UtteranceFinal { .. }) + )) + .count(), + 1, + "postprocess correction must not masquerade as a second delivery final" + ); + assert!(matches!( + &payloads[2], + IpcEventPayload::Engine(EngineEventWire::Correction { + text, + previous_text, + .. + }) if text == "corrected text" && previous_text == "raw text" + )); + assert!( + events.try_recv().is_err(), + "late correction must be emitted exactly once" + ); +} + /// The formatted transcript must be persisted BEFORE the paste attempt so a paste /// error (which `?`-early-returns from `process_stopped_recording`) cannot drop the /// AI-formatted layer from history. Paste is skipped under `cfg!(test)`, so the diff --git a/app/os/hotkeys/detector.rs b/app/os/hotkeys/detector.rs index e8268fad..708edce8 100644 --- a/app/os/hotkeys/detector.rs +++ b/app/os/hotkeys/detector.rs @@ -766,6 +766,49 @@ mod tests { ); } + #[test] + fn detector_fn_hold_emits_down_and_up_for_one_physical_hold() { + let mut detector = HotkeyDetector::default(); + let config = test_config( + ShortcutBinding::HoldFn, + ShortcutBinding::DoubleLeftOption, + ShortcutBinding::DoubleRightOption, + ); + let base = Instant::now(); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base, + key: HotkeyPhysicalKey::Fn, + modifiers: mods(false, false, false, false, true), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Down, + mode: HoldMode::Raw, + force_ai: false, + }) + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_secs(1), + key: HotkeyPhysicalKey::Fn, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Up, + mode: HoldMode::Raw, + force_ai: false, + }) + ); + assert!(!detector.is_combo_active()); + } + #[test] fn test_modifier_flags_ctrl_only() { let flags = ModifierFlags::ctrl_only(); diff --git a/app/presentation/emitter.rs b/app/presentation/emitter.rs index 6235528b..0061d9d0 100644 --- a/app/presentation/emitter.rs +++ b/app/presentation/emitter.rs @@ -69,8 +69,25 @@ impl SessionTranscriptState { } } - fn apply_correction(&mut self, text: &str) { - self.apply_preview(text); + fn apply_correction(&mut self, previous_text: &str, text: &str) { + let previous = normalize_transcript_fragment(previous_text); + let corrected = normalize_transcript_fragment(text); + + // Over-correct for P3-03 (late correction to penultimate/older utterance): + // search committed from the tail for a match and patch it. This prevents + // append-dupe when a correction for non-tail arrives after its finalize. + // Only falls back to preview-append if no match found (new content). + if self.active_preview.is_empty() { + // Fast path + P3-03: search from tail (last first). Collapsed if for clippy. + for rec in self.committed.iter_mut().rev() { + if normalize_transcript_fragment(&rec.text) == previous { + rec.text = corrected; + return; + } + } + } + + self.apply_preview(&corrected); } #[cfg(test)] @@ -365,10 +382,14 @@ impl EventSink for PresentationEmitter { }; self.send_cmd(EmitterCmd::SetTargetText(rendered)); } - EngineEvent::Correction { text, .. } => { + EngineEvent::Correction { + text, + previous_text, + .. + } => { let rendered = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); - state.apply_correction(text); + state.apply_correction(previous_text, text); match self.delta_render_mode { DeltaRenderMode::SessionRendered => state.rendered_text(), DeltaRenderMode::ActivePreviewOnly => state.active_preview.clone(), @@ -611,7 +632,7 @@ mod tests { Vec::new(), ); state.apply_preview("drugi parcjal"); - state.apply_correction("drugi partial"); + state.apply_correction("drugi parcjal", "drugi partial"); assert_eq!(state.rendered_text(), "Pierwszy fragment drugi partial"); } @@ -674,6 +695,55 @@ mod tests { assert!(state.rendered_text().is_empty()); } + #[test] + fn correction_after_final_patches_committed_utterance_without_appending() { + let mut state = SessionTranscriptState::default(); + state.apply_preview("raw words"); + assert_eq!( + state.finalize(1, "raw words", "raw words", 0.0, 1.0, Vec::new()), + Some("raw words".to_string()) + ); + + state.apply_correction("raw words", "corrected words"); + + assert_eq!(state.rendered_text(), "corrected words"); + assert_eq!(state.committed().len(), 1); + assert_eq!(state.committed()[0].text, "corrected words"); + assert!(state.active_preview.is_empty()); + } + + #[tokio::test] + async fn delivery_buffer_receives_one_utterance_when_correction_finishes_after_final() { + let transcript = Arc::new(Mutex::new(String::new())); + let emitter = PresentationEmitter::new(transcript.clone(), None, None); + + emitter.on_event(&EngineEvent::Preview { + rev: 1, + text: "raw words".to_string(), + }); + emitter.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 1, + text: "raw words".to_string(), + raw_text: "raw words".to_string(), + start_ts: 0.0, + end_ts: 1.0, + segments: Vec::new(), + vad_speech_pct: Some(100.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + }); + emitter.on_event(&EngineEvent::Correction { + rev: 2, + text: "corrected words".to_string(), + previous_text: "raw words".to_string(), + }); + + tokio::time::sleep(std::time::Duration::from_millis(220)).await; + assert_eq!(transcript.lock().await.as_str(), "corrected words"); + } + #[tokio::test] async fn correction_after_final_still_appends_after_previous_utterance() { let transcript = Arc::new(Mutex::new(String::new())); @@ -902,4 +972,62 @@ mod tests { let snapshot = transcript.lock().await.clone(); assert_eq!(snapshot, "Ala ma kota"); } + + #[tokio::test] + async fn correction_targets_penultimate_utterance_patches_instead_of_appending() { + // P3-03 over-correct + marbles fortify: late correction whose previous_text + // matches a non-tail (penultimate) committed utterance must patch it, not + // append via preview fallback. This closes the "korekta do przedostatniej + // wypowiedzi appenduje" gap. + let transcript = Arc::new(Mutex::new(String::new())); + let emitter = PresentationEmitter::new(transcript.clone(), None, None); + + // Commit two utterances. + emitter.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 1, + text: "Ala ma kota.".to_string(), + raw_text: "Ala ma kota.".to_string(), + start_ts: 0.0, + end_ts: 1.0, + segments: Vec::new(), + vad_speech_pct: Some(100.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + }); + emitter.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 2, + text: "A kot ma Ale.".to_string(), + raw_text: "A kot ma Ale.".to_string(), + start_ts: 0.0, + end_ts: 2.0, + segments: Vec::new(), + vad_speech_pct: Some(100.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + }); + + // Late correction targets the *first* (penultimate at arrival) utterance. + emitter.on_event(&EngineEvent::Correction { + rev: 99, + text: "Ala ma psa.".to_string(), + previous_text: "Ala ma kota.".to_string(), + }); + + tokio::time::sleep(std::time::Duration::from_millis(120)).await; + let snapshot = transcript.lock().await.clone(); + assert!( + snapshot.contains("Ala ma psa."), + "penultimate correction must patch in place, got: {snapshot:?}" + ); + assert!( + snapshot.contains("A kot ma Ale."), + "later utterance must remain untouched, got: {snapshot:?}" + ); + // No duplication of the corrected text. + assert_eq!(snapshot.matches("Ala ma").count(), 1); + } } diff --git a/bridge/src/agent.rs b/bridge/src/agent.rs index 10bf8c83..ea3fb848 100644 --- a/bridge/src/agent.rs +++ b/bridge/src/agent.rs @@ -11,7 +11,8 @@ use codescribe_core::agent::{ Thread, ThreadMessage, ThreadStore, ToolRegistry, }; use codescribe_core::attachment::{MAX_VISION_IMAGE_BYTES, load_image_for_vision}; -use codescribe_core::llm::provider::{LlmMode, provider_supports_vision, resolve_provider}; +use codescribe_core::llm::lane_truth::assistive_identity; +use codescribe_core::llm::provider::provider_supports_vision; use tokio::task::AbortHandle; use crate::CsError; @@ -33,6 +34,15 @@ pub struct CsAttachment { pub path: String, } +/// Assistive-lane availability for the Swift chat surface: `available` gates +/// the send, `detail` is the honest reason shown in the thread when the lane +/// cannot reach a model (empty when ready). +#[derive(uniffi::Record)] +pub struct CsAgentAvailability { + pub available: bool, + pub detail: String, +} + /// Foreign callback trait — agent streaming events forwarded to Swift. /// Mirrors `AgentUiEvent`; the Swift side must hop these onto the main actor. #[uniffi::export(with_foreign)] @@ -63,14 +73,33 @@ impl CodescribeAgent { Self::default() } - /// True when the assistive LLM provider can be built from the environment - /// (LLM_ASSISTIVE_ENDPOINT / _MODEL / _API_KEY present). Same gate the live - /// app uses before agent replies are possible. + /// True when the assistive lane can currently reach a provider. Resolved + /// fresh on every call (settings → env → Keychain via lane_truth), so a + /// Settings save flips this on the very next send — no restart, no stale + /// bootstrap env. A key-optional local endpoint counts as available. pub fn is_available(&self) -> bool { // Warm settings + Keychain only when the agent surface is actually used. // Constructing the Swift app model must not trigger a keychain prompt. let _ = codescribe_core::config::Config::load(); - codescribe::agent::create_default_provider().is_ok() + codescribe::agent::assistive_unavailable_reason().is_none() + } + + /// Availability of the assistive lane as one record: `available` mirrors + /// [`Self::is_available`]; `detail` carries the actionable, user-facing + /// reason when the lane cannot reach a model (which lane, endpoint or key + /// is missing — never a generic "add an API key"). Empty when ready. + pub fn availability(&self) -> CsAgentAvailability { + let _ = codescribe_core::config::Config::load(); + match codescribe::agent::assistive_unavailable_reason() { + None => CsAgentAvailability { + available: true, + detail: String::new(), + }, + Some(detail) => CsAgentAvailability { + available: false, + detail, + }, + } } /// Stream one agent reply for `text` on the conversation identified by @@ -437,9 +466,10 @@ fn validate_composer_attachments( } // Vision gate: refuse (readable error) rather than silently drop the images - // when the configured assistive model cannot read them. - let provider = resolve_provider(LlmMode::Assistive); - let model = std::env::var("LLM_ASSISTIVE_MODEL").unwrap_or_default(); + // when the configured assistive model cannot read them. Lane identity comes + // from lane_truth (fresh settings), not the frozen bootstrap env. + let config = codescribe_core::config::Config::load(); + let (provider, model) = assistive_identity(&config); if !provider_supports_vision(provider, &model) { return Err(CsError::Agent { msg: "The selected model can't read images. Switch to a vision-capable \ @@ -496,10 +526,11 @@ async fn persist_thread(thread_id: String, messages: Vec) { return Ok(()); }; - let model = std::env::var("LLM_ASSISTIVE_MODEL").unwrap_or_default(); - // Reflect the resolved assistive provider so persisted thread metadata is - // accurate when Anthropic is active (was hardcoded "openai-responses"). - let provider = resolve_provider(LlmMode::Assistive).as_str().to_string(); + // Reflect the resolved assistive lane (fresh settings, Keychain-free) + // so persisted thread metadata matches what the send actually used. + let config = codescribe_core::config::Config::load(); + let (provider, model) = assistive_identity(&config); + let provider = provider.as_str().to_string(); let mut thread = store.load_thread(&thread_id).unwrap_or_else(|_| Thread { id: thread_id.clone(), diff --git a/bridge/src/config.rs b/bridge/src/config.rs index 60958e60..b8da6364 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -12,7 +12,7 @@ use std::fs; use std::str::FromStr; use std::sync::{Mutex, OnceLock}; -use codescribe_core::config::keychain::{KEYCHAIN_ACCOUNTS, delete_key, load_key, save_key}; +use codescribe_core::config::keychain::{KEYCHAIN_ACCOUNTS, delete_key, save_key}; use codescribe_core::config::prompts::{get_assistive_prompt_path, get_formatting_prompt_path}; use codescribe_core::config::{ Config, DEFAULT_ASSISTIVE_PROMPT, DEFAULT_FORMATTING_PROMPT, UserSettings, reset_to_defaults, @@ -21,6 +21,7 @@ use codescribe_core::llm::account_auth; use codescribe_core::llm::key_liveness::{ ApiKeyLivenessResult, ApiKeyLivenessStatus, probe_api_key_liveness, }; +use codescribe_core::llm::lane_truth; use codescribe_core::llm::model_discovery::{ ModelDiscoveryStatus, discover_models as discover_provider_models, }; @@ -153,6 +154,7 @@ pub struct CsApiKeyProbeResult { pub account: String, pub status: CsApiKeyProbeStatus, pub message: String, + pub probed_endpoint: Option, } /// One selectable model for a provider (id sent on the wire + display label). @@ -187,13 +189,18 @@ pub struct CsProviderOption { pub api_key_set: bool, /// True when provider-account tokens are stored for this provider. pub account_signed_in: bool, - /// True when the account-login flow can start. For OpenAI this requires - /// `CODESCRIBE_OPENAI_OAUTH_CLIENT_ID`; until then Settings renders the - /// disabled "Sign in with ChatGPT" affordance. + /// True when the account-login flow can start. For OpenAI this requires a + /// configured OAuth client id (settings `LLM_OPENAI_OAUTH_CLIENT_ID`, or + /// dev env `CODESCRIBE_OPENAI_OAUTH_CLIENT_ID`); until then Settings + /// renders the disabled "Sign in with ChatGPT" affordance. pub account_login_enabled: bool, - /// Human-readable account status ("signed in", "not signed in", or - /// "awaiting app registration"). Never contains secrets. + /// Human-readable account status ("signed in as ", "not signed in", + /// or "awaiting app registration"). Never contains secrets. pub account_status_message: String, + /// Operator-configured OAuth client id (settings → env resolution). A + /// non-secret app identity — shown and editable in the Keys panel. `None` + /// means the account login is still gated on app registration. + pub oauth_client_id: Option, /// Always empty for live Settings; retained for bridge compatibility with /// older Swift bindings and preview seed objects. pub models: Vec, @@ -323,7 +330,11 @@ impl CodescribeConfig { settings.llm_assistive_model.clone(), &env_file, ), - llm_assistive_provider: effective_file_env_string("LLM_ASSISTIVE_PROVIDER", &env_file), + llm_assistive_provider: effective_settings_string( + "LLM_ASSISTIVE_PROVIDER", + settings.llm_assistive_provider.clone(), + &env_file, + ), formatting_level: effective_settings_string( "FORMATTING_LEVEL", settings.formatting_level.clone(), @@ -447,6 +458,14 @@ impl CodescribeConfig { Config::config_dir().to_string_lossy().to_string() } + /// Canonical normalization for OpenAI Responses endpoints. + /// Strips known suffixes (/v1/responses, /chat/completions, /completions, /v1) + /// and forces the /v1/responses tail. Single source of truth in lane_truth; + /// Swift SettingsViewModel delegates here to eliminate duplication (P2-05). + pub fn normalize_openai_responses_endpoint(&self, endpoint: String) -> String { + lane_truth::normalize_openai_responses_endpoint(&endpoint) + } + /// Presence booleans for every Keychain-backed API key. pub fn key_status(&self) -> CsKeyStatus { // This endpoint is explicitly about keys, so it may prompt. Construction @@ -489,6 +508,9 @@ impl CodescribeConfig { account_signed_in: account_status.signed_in, account_login_enabled: account_status.client_id_configured, account_status_message: account_status.message, + oauth_client_id: matches!(kind, ProviderKind::OpenAiResponses) + .then(account_auth::configured_client_id) + .flatten(), models: Vec::new(), } }) @@ -496,8 +518,9 @@ impl CodescribeConfig { } /// Start provider-account login for the selected provider. Today this is - /// only supported for OpenAI Responses and is gated by - /// `CODESCRIBE_OPENAI_OAUTH_CLIENT_ID`; absent client id returns a config + /// only supported for OpenAI Responses and is gated by the configured OAuth + /// client id (settings `LLM_OPENAI_OAUTH_CLIENT_ID`, dev-env fallback + /// `CODESCRIBE_OPENAI_OAUTH_CLIENT_ID`); absent client id returns a config /// error whose message contains "awaiting app registration". pub fn start_account_login( &self, @@ -533,6 +556,81 @@ impl CodescribeConfig { }) } + /// Block until the in-flight provider-account login completes, fails, or + /// times out. Swift calls this from a background queue right after + /// `start_account_login` opened the browser. On timeout (user closed the + /// browser, walked away) the local callback server is shut down — honest + /// status, no zombie port. A second `start_account_login` while pending + /// cancels the first, so this returns "failed" for the superseded attempt. + /// + /// P2-09: 300s default (from caller) is intentional; OAuth human steps can + /// exceed short timeouts. No configurability knob added. P2-08: discovery + /// flows share this; cancel is best-effort via supersede + teardown. + pub fn await_account_login( + &self, + provider_id: String, + timeout_seconds: u64, + ) -> Result { + let provider = ProviderKind::from_str(&provider_id).map_err(|error| CsError::Config { + msg: error.to_string(), + })?; + let login = { + let mut guard = active_account_login().lock().map_err(|_| CsError::Config { + msg: "account login state lock poisoned".to_string(), + })?; + guard.take() + }; + let Some(login) = login else { + return Ok(account_login_result( + provider, + "idle", + "no sign-in in progress", + )); + }; + + let cancel = login.cancel_handle(); + let outcome = account_auth_runtime()?.block_on(async move { + tokio::time::timeout( + std::time::Duration::from_secs(timeout_seconds.max(1)), + login.block_until_done(), + ) + .await + }); + + match outcome { + Ok(Ok(())) => { + let message = account_auth::account_status(provider).message; + Ok(account_login_result(provider, "signed_in", &message)) + } + Ok(Err(error)) => Ok(account_login_result(provider, "failed", &error.to_string())), + Err(_elapsed) => { + cancel.shutdown(); + let message = format!( + "sign-in was not completed within {timeout_seconds}s; the local login server was shut down" + ); + Ok(account_login_result(provider, "timeout", &message)) + } + } + } + + /// Cancel any in-flight provider-account login and free the callback port. + pub fn cancel_account_login(&self) { + if let Ok(mut guard) = active_account_login().lock() + && let Some(login) = guard.take() + { + login.cancel(); + } + } + + /// Sign out of the provider account: remove the stored tokens (Keychain + + /// env mirror). The API-key path is untouched. + pub fn sign_out_account(&self, provider_id: String) -> Result<(), CsError> { + let provider = ProviderKind::from_str(&provider_id).map_err(|error| CsError::Config { + msg: error.to_string(), + })?; + account_auth::clear_account_tokens(provider).map_err(account_auth_to_cs) + } + /// Discover model options from the selected provider using the live provider /// `/models` API plus the existing config/Keychain/env key resolution path. /// Missing key is returned as a typed status, not as a thrown bridge error, @@ -827,10 +925,6 @@ fn effective_env_string( .or_else(|| env_string(key)) } -fn effective_file_env_string(key: &str, env_file: &HashMap) -> Option { - file_env_string(key, env_file).or_else(|| env_string(key)) -} - fn effective_settings_parse( key: &str, setting: Option, @@ -898,7 +992,7 @@ fn env_parse(key: &str) -> Option { /// True when the account env var or Keychain account is present and non-empty. fn key_present(account: &str) -> bool { - env_string(account).is_some() || load_key(account).and_then(non_empty).is_some() + lane_truth::secret(account).is_some() } fn account_auth_runtime() -> Result<&'static tokio::runtime::Runtime, CsError> { @@ -926,6 +1020,26 @@ fn account_auth_to_cs(error: account_auth::AccountAuthError) -> CsError { } } +/// Terminal (non-"started") login result from the live account status — +/// `signed_in` / `client_id_configured` always re-read, never assumed. Lives +/// outside the exported impl: it takes a core `ProviderKind`, which must not +/// cross the FFI boundary. +fn account_login_result( + provider: ProviderKind, + status: &str, + message: &str, +) -> CsAccountLoginResult { + let account_status = account_auth::account_status(provider); + CsAccountLoginResult { + provider_id: provider.as_str().to_string(), + status: status.to_string(), + message: message.to_string(), + auth_url: None, + signed_in: account_status.signed_in, + client_id_configured: account_status.client_id_configured, + } +} + impl From for CsApiKeyProbeStatus { fn from(status: ApiKeyLivenessStatus) -> Self { match status { @@ -945,10 +1059,31 @@ impl From for CsApiKeyProbeResult { account: result.account, status: result.status.into(), message: result.message, + probed_endpoint: result.probed_endpoint, } } } +#[cfg(test)] +mod api_key_probe_tests { + use super::{ApiKeyLivenessResult, ApiKeyLivenessStatus, CsApiKeyProbeResult}; + + #[test] + fn bridge_probe_result_preserves_the_endpoint_used_by_core() { + let result = CsApiKeyProbeResult::from(ApiKeyLivenessResult { + account: "LLM_ASSISTIVE_API_KEY".to_string(), + status: ApiKeyLivenessStatus::Invalid, + message: "provider rejected this key".to_string(), + probed_endpoint: Some("https://api.libraxis.cloud/v1/responses".to_string()), + }); + + assert_eq!( + result.probed_endpoint.as_deref(), + Some("https://api.libraxis.cloud/v1/responses") + ); + } +} + /// Reject unknown Keychain accounts before touching the Keychain. fn ensure_known_account(account: &str) -> Result<(), CsError> { if KEYCHAIN_ACCOUNTS.contains(&account) { @@ -1041,8 +1176,9 @@ mod reset_tests { #[cfg(test)] mod settings_snapshot_tests { use super::CodescribeConfig; - use codescribe_core::config::UserSettings; + use codescribe_core::config::{Config, UserSettings}; use serial_test::serial; + use std::ffi::{OsStr, OsString}; use std::time::{SystemTime, UNIX_EPOCH}; fn scratch(tag: &str) -> std::path::PathBuf { @@ -1056,6 +1192,54 @@ mod settings_snapshot_tests { )) } + #[test] + #[serial] + fn assistive_provider_ui_write_is_promoted_to_settings_json() { + let root = scratch("assistive_provider"); + std::fs::create_dir_all(&root).unwrap(); + + let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &root); + let _env_path = EnvGuard::remove("CODESCRIBE_ENV_PATH"); + let _provider = EnvGuard::remove("LLM_ASSISTIVE_PROVIDER"); + + let config = CodescribeConfig::new(); + config + .update_config( + "LLM_ASSISTIVE_PROVIDER".to_string(), + "anthropic-messages".to_string(), + ) + .expect("persist provider through the UI bridge"); + + let persisted: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(UserSettings::settings_path()) + .expect("read promoted settings.json"), + ) + .expect("parse promoted settings.json"); + assert_eq!( + persisted + .get("speech") + .and_then(|value| value.get("assistive")) + .and_then(|value| value.get("provider")) + .and_then(serde_json::Value::as_str), + Some("anthropic-messages") + ); + + let env_path = Config::env_path(); + if env_path.exists() { + let env = Config::parse_env_file(&env_path).expect("parse optional .env"); + assert!( + !env.contains_key("LLM_ASSISTIVE_PROVIDER"), + "promoted provider must not be written to .env" + ); + } + assert_eq!( + config.load_settings().llm_assistive_provider.as_deref(), + Some("anthropic-messages") + ); + + let _ = std::fs::remove_dir_all(root); + } + #[test] #[serial] fn load_settings_prefers_persisted_model_over_stale_process_env() { @@ -1092,4 +1276,37 @@ mod settings_snapshot_tests { } let _ = std::fs::remove_dir_all(root); } + + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + // SAFETY: this module serializes every process-env test. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: this module serializes every process-env test. + unsafe { std::env::remove_var(key) }; + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: this module serializes every process-env test. + unsafe { + match self.previous.as_ref() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } } diff --git a/bridge/src/lib.rs b/bridge/src/lib.rs index 67d6bedf..eddac138 100644 --- a/bridge/src/lib.rs +++ b/bridge/src/lib.rs @@ -25,6 +25,7 @@ mod config; mod hotkeys; mod mcp_admin; mod notes; +mod quality; mod recording; mod threads; mod tray_status; @@ -32,6 +33,7 @@ mod tray_status; pub use agent::{CodescribeAgent, CsAgentListener}; pub use agent_delivery::CsAgentDeliveryListener; pub use hotkeys::CodescribeHotkeys; +pub use quality::commit_overlay_quality_record; pub use tray_status::{ CodescribeTrayStatus, CsTrayStatusKind, CsTrayStatusListener, CsTrayStatusPayload, CsTrayStatusTone, @@ -45,12 +47,16 @@ pub enum CsError { Agent { msg: String }, Config { msg: String }, Recording { msg: String }, + Quality { msg: String }, } impl std::fmt::Display for CsError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - CsError::Agent { msg } | CsError::Config { msg } | CsError::Recording { msg } => { + CsError::Agent { msg } + | CsError::Config { msg } + | CsError::Recording { msg } + | CsError::Quality { msg } => { write!(f, "{msg}") } } diff --git a/bridge/src/quality.rs b/bridge/src/quality.rs new file mode 100644 index 00000000..db0398e2 --- /dev/null +++ b/bridge/src/quality.rs @@ -0,0 +1,42 @@ +//! P0-D: thin UniFFI surface for the quality/correction loop. +//! One module per concern (matches bridge discipline). Does NOT bloat recording.rs. +//! +//! Exposes commit for overlay FINAL edits: +//! - capture (raw, delivered, edited) +//! - write quality record JSONL +//! - feed safe lexicon candidates to the custom lexicon consumed by PostProcessor +//! +//! Privacy: local disk only. + +use crate::CsError; + +#[uniffi::export] +pub fn commit_overlay_quality_record( + raw_text: String, + delivered_text: String, + edited_text: String, + action: String, +) -> Result<(), CsError> { + // Delegate to core. Model/mode are best-effort for MVP (overlay always). + // action carried for meta (over-correct for P2-03: "captureQualityIfEdited gubi action"). + codescribe_core::quality::overlay_quality::commit_overlay_correction( + &raw_text, + &delivered_text, + &edited_text, + "overlay", + None, + Some(&action), + ) + .map(|_path| ()) + .map_err(|e| CsError::Quality { + msg: format!("quality commit failed: {}", e), + }) +} + +// Future: CsQualityRecord record type + richer meta if needed. +// For MVP strings keep surface tiny and wiring trivial from Swift. + +#[cfg(test)] +mod tests { + // Bridge tests are light; real behavior tested via core + integration. +} diff --git a/core/agent/thread_store.rs b/core/agent/thread_store.rs index 7d24e1fe..a4aa890b 100644 --- a/core/agent/thread_store.rs +++ b/core/agent/thread_store.rs @@ -767,7 +767,7 @@ mod tests { fn inline_image_roundtrips_through_disk_backed_asset() -> Result<()> { let tmp = TempDir::new()?; let store = ThreadStore::new_in(tmp.path().join("threads"))?; - let image_bytes = b"w5a-inline-roundtrip-bytes".to_vec(); + let image_bytes = format!("w5a-inline-roundtrip-bytes-{}", std::process::id()).into_bytes(); let message = Message { role: Role::User, diff --git a/core/config/keychain.rs b/core/config/keychain.rs index 225dbeae..5d2b1cc0 100644 --- a/core/config/keychain.rs +++ b/core/config/keychain.rs @@ -41,6 +41,7 @@ impl Default for KeychainBundle { } static BUNDLE_CACHE: OnceLock>> = OnceLock::new(); +static PROCESS_ENV_SEEDS: OnceLock>> = OnceLock::new(); static POPULATE_ONCE: Once = Once::new(); fn bundle_cache() -> &'static RwLock> { @@ -65,6 +66,30 @@ fn write_bundle_cache(bundle: Option) { } } +fn process_env_seeds() -> &'static RwLock> { + PROCESS_ENV_SEEDS.get_or_init(|| RwLock::new(BTreeMap::new())) +} + +fn remember_process_env_seed(account: &str, secret: &str) { + match process_env_seeds().write() { + Ok(mut guard) => { + guard.insert(account.to_string(), secret.to_string()); + } + Err(poisoned) => { + poisoned + .into_inner() + .insert(account.to_string(), secret.to_string()); + } + } +} + +fn process_env_seed(account: &str) -> Option { + match process_env_seeds().read() { + Ok(guard) => guard.get(account).cloned(), + Err(poisoned) => poisoned.into_inner().get(account).cloned(), + } +} + fn encode_bundle(bundle: &KeychainBundle) -> Result> { let json = serde_json::to_string(bundle).context("Failed to serialize bundle")?; let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); @@ -189,6 +214,59 @@ pub fn load_key(account: &str) -> Option { None } +/// Resolve the current runtime secret without touching Keychain. +/// +/// Explicit process environment values retain highest priority. Values copied +/// from Keychain during bootstrap are origin-tracked, so a later Settings save +/// or delete cannot be shadowed by that stale process snapshot. +pub fn cached_runtime_key(account: &str) -> Option { + let env_value = non_empty_env(account); + let seeded_env_value = process_env_seed(account); + if let Some(value) = explicit_env_value(env_value, seeded_env_value.as_deref()) { + return Some(value); + } + non_empty_secret(read_bundle_cache().and_then(|bundle| bundle.keys.get(account).cloned())) +} + +/// Resolve the current runtime secret, reading Keychain when the in-memory +/// cache has not been populated yet. Use this only on explicit secret-use +/// paths, where a macOS Keychain prompt is appropriate. +pub fn runtime_key(account: &str) -> Option { + let env_value = non_empty_env(account); + let seeded_env_value = process_env_seed(account); + if let Some(value) = explicit_env_value(env_value, seeded_env_value.as_deref()) { + return Some(value); + } + non_empty_secret(load_key(account)) +} + +fn non_empty_env(account: &str) -> Option { + std::env::var(account) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +fn resolve_runtime_key( + env_value: Option, + seeded_env_value: Option, + stored_value: Option, +) -> Option { + explicit_env_value(env_value, seeded_env_value.as_deref()) + .or_else(|| non_empty_secret(stored_value)) +} + +fn explicit_env_value(env_value: Option, seeded_env_value: Option<&str>) -> Option { + env_value.filter(|value| seeded_env_value != Some(value)) +} + +fn non_empty_secret(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + /// Deletes a secret from the macOS Keychain. Ignores "not found" errors. pub fn delete_key(account: &str) -> Result<()> { if is_test_env() { @@ -207,6 +285,7 @@ pub fn delete_key(account: &str) -> Result<()> { Err(e) => { let desc = format!("{e}"); if desc.contains("not found") || desc.contains("-25300") { + write_bundle_cache(None); debug!("Keychain bundle not found, nothing to delete"); Ok(()) } else { @@ -248,6 +327,7 @@ pub fn populate_env_from_keychain() { unsafe { std::env::set_var(account, value); } + remember_process_env_seed(account, value); debug!("Set {account} from Keychain bundle"); } } @@ -256,7 +336,7 @@ pub fn populate_env_from_keychain() { #[cfg(test)] mod tests { - use super::keychain_disabled_by_signals; + use super::{keychain_disabled_by_signals, resolve_runtime_key}; #[test] fn disable_keychain_flag_is_honored() { @@ -274,4 +354,38 @@ mod tests { assert!(!keychain_disabled_by_signals(false, true, true)); assert!(!keychain_disabled_by_signals(false, false, false)); } + + #[test] + fn explicit_process_env_keeps_priority_over_keychain() { + assert_eq!( + resolve_runtime_key( + Some("explicit".to_string()), + None, + Some("stored".to_string()) + ) + .as_deref(), + Some("explicit") + ); + } + + #[test] + fn updated_keychain_replaces_bootstrap_seed() { + assert_eq!( + resolve_runtime_key( + Some("old".to_string()), + Some("old".to_string()), + Some("new".to_string()) + ) + .as_deref(), + Some("new") + ); + } + + #[test] + fn deleted_keychain_entry_invalidates_bootstrap_seed() { + assert_eq!( + resolve_runtime_key(Some("old".to_string()), Some("old".to_string()), None), + None + ); + } } diff --git a/core/config/loader.rs b/core/config/loader.rs index c7c3865f..d838c9c1 100644 --- a/core/config/loader.rs +++ b/core/config/loader.rs @@ -146,6 +146,9 @@ impl Config { } fn config_runtime_env_var(key: &str) -> Result { + if super::keychain::KEYCHAIN_ACCOUNTS.contains(&key) { + return super::keychain::cached_runtime_key(key).ok_or(VarError::NotPresent); + } if !Self::can_seed_process_env() && Self::was_seeded_env_key(key) { return Err(VarError::NotPresent); } @@ -546,6 +549,11 @@ impl Config { { Self::safe_set_env("LLM_ASSISTIVE_MODEL", v); } + if Self::config_runtime_env_var("LLM_ASSISTIVE_PROVIDER").is_err() + && let Some(ref v) = settings.llm_assistive_provider + { + Self::safe_set_env("LLM_ASSISTIVE_PROVIDER", v); + } // ── Promoted fields (previously .env only) ── // LLM formatting (not in Config struct, read from env at runtime) @@ -722,6 +730,43 @@ impl Config { if is_regular { let mut settings = super::settings::UserSettings::load(); + if value.trim().is_empty() { + let unset_llm_override = match key { + "LLM_ENDPOINT" => { + settings.llm_endpoint = None; + true + } + "LLM_MODEL" => { + settings.llm_model = None; + true + } + "LLM_ASSISTIVE_ENDPOINT" => { + settings.llm_assistive_endpoint = None; + true + } + "LLM_ASSISTIVE_MODEL" => { + settings.llm_assistive_model = None; + true + } + "LLM_ASSISTIVE_PROVIDER" => { + settings.llm_assistive_provider = None; + true + } + "LLM_FORMATTING_ENDPOINT" => { + settings.llm_formatting_endpoint = None; + true + } + "LLM_FORMATTING_MODEL" => { + settings.llm_formatting_model = None; + true + } + _ => false, + }; + if unset_llm_override { + settings.save()?; + return Ok(()); + } + } // Route to appropriate setter based on value type match key { "HOLD_START_DELAY_MS" @@ -819,6 +864,9 @@ impl Config { "LLM_ASSISTIVE_MODEL" => { settings_ref.llm_assistive_model = Some((*value).to_string()) } + "LLM_ASSISTIVE_PROVIDER" => { + settings_ref.llm_assistive_provider = Some((*value).to_string()) + } "FORMATTING_LEVEL" => { settings_ref.formatting_level = Some((*value).to_string()) } @@ -1268,6 +1316,92 @@ mod tests { ); } + #[test] + #[serial] + fn empty_llm_override_unsets_json_path_and_restores_resolved_fallback() { + let _tmp = setup_isolated_data_dir(); + let _lane_endpoint = TestEnvGuard::unset("LLM_ASSISTIVE_ENDPOINT"); + let _shared_endpoint = TestEnvGuard::unset("LLM_ENDPOINT"); + let config = Config::default(); + + config + .save_to_env( + "LLM_ASSISTIVE_ENDPOINT", + "https://api.libraxis.cloud/v1/responses", + ) + .expect("set assistive endpoint override"); + + let set_json: serde_json::Value = serde_json::from_str( + &fs::read_to_string(UserSettings::settings_path()).expect("read settings after set"), + ) + .expect("parse settings after set"); + assert_eq!( + set_json + .pointer("/speech/assistive/llm_endpoint") + .and_then(serde_json::Value::as_str), + Some("https://api.libraxis.cloud/v1/responses") + ); + + config + .save_to_env("LLM_ASSISTIVE_ENDPOINT", "") + .expect("reset assistive endpoint override"); + + let reset_json: serde_json::Value = serde_json::from_str( + &fs::read_to_string(UserSettings::settings_path()).expect("read settings after reset"), + ) + .expect("parse settings after reset"); + assert!( + reset_json + .pointer("/speech/assistive/llm_endpoint") + .is_none(), + "reset must remove the override path, got {reset_json}" + ); + assert_eq!(UserSettings::load().llm_assistive_endpoint, None); + assert_eq!( + crate::llm::lane_truth::endpoint( + crate::llm::provider::LlmMode::Assistive, + &Config::default(), + ), + crate::config::DEFAULT_OPENAI_RESPONSES_ENDPOINT + ); + } + + #[test] + #[serial] + fn empty_assistive_provider_unsets_json_path_and_restores_default() { + let _tmp = setup_isolated_data_dir(); + let _provider = TestEnvGuard::unset("LLM_ASSISTIVE_PROVIDER"); + let config = Config::default(); + + config + .save_to_env("LLM_ASSISTIVE_PROVIDER", "anthropic-messages") + .expect("set assistive provider override"); + assert_eq!( + UserSettings::load().llm_assistive_provider.as_deref(), + Some("anthropic-messages") + ); + + config + .save_to_env("LLM_ASSISTIVE_PROVIDER", "") + .expect("reset assistive provider override"); + + let reset_json: serde_json::Value = serde_json::from_str( + &fs::read_to_string(UserSettings::settings_path()).expect("read settings after reset"), + ) + .expect("parse settings after reset"); + assert!( + reset_json + .pointer("/speech/assistive/llm_provider") + .is_none(), + "reset must remove the provider override path, got {reset_json}" + ); + assert_eq!(UserSettings::load().llm_assistive_provider, None); + assert_eq!( + crate::llm::lane_truth::provider(crate::llm::provider::LlmMode::Assistive), + crate::llm::provider::ProviderKind::OpenAiResponses + ); + } + #[test] #[serial] fn save_to_env_many_persists_batch_without_process_env_mutation() { diff --git a/core/config/settings.rs b/core/config/settings.rs index 6cfe1c7f..7c085f8f 100644 --- a/core/config/settings.rs +++ b/core/config/settings.rs @@ -47,6 +47,14 @@ pub struct UserSettings { #[serde(skip_serializing_if = "Option::is_none")] pub llm_assistive_model: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub llm_assistive_provider: Option, + /// OAuth client id for "Sign in with ChatGPT" (non-secret app identity, not + /// a credential). `None` means "awaiting app registration" — the account + /// login stays gated. Env `CODESCRIBE_OPENAI_OAUTH_CLIENT_ID` remains the + /// dev-only fallback when this is unset. + #[serde(skip_serializing_if = "Option::is_none")] + pub openai_oauth_client_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub chat_zoom: Option, #[serde(skip_serializing_if = "Option::is_none")] pub show_dock_icon: Option, @@ -251,6 +259,8 @@ struct AssistiveV2 { llm_endpoint: Option, #[serde(skip_serializing_if = "Option::is_none")] llm_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + provider: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -324,6 +334,9 @@ struct SystemV2 { // purpose — mirrors mode_bindings' Vec round-trip through the V2 schema. #[serde(skip_serializing_if = "Option::is_none")] agent_workspace_roots: Option>, + // "Sign in with ChatGPT" OAuth client id (non-secret app identity). + #[serde(skip_serializing_if = "Option::is_none")] + openai_oauth_client_id: Option, } /// Canonical list of env keys that route to `settings.json` (not `.env`). @@ -358,8 +371,12 @@ pub const PROMOTED_SETTINGS_KEYS: &[&str] = &[ "LLM_MODEL", "LLM_ASSISTIVE_ENDPOINT", "LLM_ASSISTIVE_MODEL", + "LLM_ASSISTIVE_PROVIDER", "LLM_FORMATTING_ENDPOINT", "LLM_FORMATTING_MODEL", + // "Sign in with ChatGPT" OAuth client id — non-secret app identity, so it + // lives in settings.json (NOT the Keychain); env stays the dev fallback. + "LLM_OPENAI_OAUTH_CLIENT_ID", // Promoted from .env "USE_LOCAL_STT", "LOCAL_MODEL", @@ -434,6 +451,7 @@ impl UserSettings { assistive: Some(AssistiveV2 { llm_endpoint: self.llm_assistive_endpoint.clone(), llm_model: self.llm_assistive_model.clone(), + provider: self.llm_assistive_provider.clone(), }), emission: Some(EmissionV2 { buffer_delay_ms: self.buffer_delay_ms, @@ -468,6 +486,7 @@ impl UserSettings { qube_daemon_autostart: self.qube_daemon_autostart, onboarding_mode: self.onboarding_mode.clone(), agent_workspace_roots: self.agent_workspace_roots.clone(), + openai_oauth_client_id: self.openai_oauth_client_id.clone(), }), } } @@ -541,6 +560,11 @@ impl UserSettings { .as_ref() .and_then(|s| s.assistive.as_ref()) .and_then(|a| a.llm_model.clone()), + llm_assistive_provider: v2 + .speech + .as_ref() + .and_then(|s| s.assistive.as_ref()) + .and_then(|a| a.provider.clone()), chat_zoom: v2.ui.as_ref().and_then(|ui| ui.chat_zoom), show_dock_icon: v2.ui.as_ref().and_then(|ui| ui.show_dock_icon), transcription_overlay_enabled: v2 @@ -591,6 +615,10 @@ impl UserSettings { .system .as_ref() .and_then(|s| s.agent_workspace_roots.clone()), + openai_oauth_client_id: v2 + .system + .as_ref() + .and_then(|s| s.openai_oauth_client_id.clone()), agent_enter_sends: v2.interaction.as_ref().and_then(|i| i.agent_enter_sends), buffer_delay_ms: v2 .speech @@ -862,6 +890,12 @@ impl UserSettings { "LLM_MODEL" => self.llm_model = Some(value.to_owned()), "LLM_ASSISTIVE_ENDPOINT" => self.llm_assistive_endpoint = Some(value.to_owned()), "LLM_ASSISTIVE_MODEL" => self.llm_assistive_model = Some(value.to_owned()), + "LLM_ASSISTIVE_PROVIDER" => self.llm_assistive_provider = Some(value.to_owned()), + "LLM_OPENAI_OAUTH_CLIENT_ID" => { + // Empty clears back to "awaiting app registration". + let trimmed = value.trim(); + self.openai_oauth_client_id = (!trimmed.is_empty()).then(|| trimmed.to_owned()); + } "FORMATTING_LEVEL" => self.formatting_level = Some(value.to_owned()), "TRANSCRIPT_TAG_TEMPLATE" => self.transcript_tag_template = Some(value.to_owned()), "LLM_FORMATTING_ENDPOINT" => self.llm_formatting_endpoint = Some(value.to_owned()), @@ -1267,6 +1301,34 @@ mod tests { ); } + #[test] + #[serial] + fn test_oauth_client_id_persists_in_v2_system_section_and_empty_clears() { + let _tmp = setup_isolated_data_dir(); + let mut settings = UserSettings::default(); + settings.set_string("LLM_OPENAI_OAUTH_CLIENT_ID", "app_abc123"); + + let loaded = UserSettings::load(); + assert_eq!(loaded.openai_oauth_client_id.as_deref(), Some("app_abc123")); + + let persisted: serde_json::Value = serde_json::from_str( + &fs::read_to_string(UserSettings::settings_path()).expect("read persisted settings"), + ) + .expect("parse persisted settings"); + assert_eq!( + persisted + .get("system") + .and_then(|v| v.get("openai_oauth_client_id")) + .and_then(|v| v.as_str()), + Some("app_abc123") + ); + + // Empty (or whitespace) clears back to the registration gate. + let mut cleared = loaded; + cleared.set_string("LLM_OPENAI_OAUTH_CLIENT_ID", " "); + assert_eq!(UserSettings::load().openai_oauth_client_id, None); + } + #[test] #[serial] fn test_onboarding_mode_defaults_to_none_when_unset() { diff --git a/core/connectors/github.rs b/core/connectors/github.rs index 978ee1ae..5f6d4385 100644 --- a/core/connectors/github.rs +++ b/core/connectors/github.rs @@ -235,9 +235,9 @@ pub async fn fetch_github_blob(gh: &GitHubRef, token: Option<&str>) -> Result<(V Ok((buf, filename)) } -/// Load GitHub token from Keychain (if available). +/// Load the current GitHub token from an explicit env override or Keychain. pub fn load_github_token() -> Option { - crate::config::keychain::load_key("GITHUB_TOKEN") + crate::config::keychain::runtime_key("GITHUB_TOKEN") } // ═══════════════════════════════════════════════════════════ diff --git a/core/lib.rs b/core/lib.rs index 64c33567..74eb2bc0 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -143,5 +143,5 @@ pub use config::{get_assistive_prompt_path, get_formatting_prompt_path, reset_to pub use llm::{ai_formatting, client}; pub use pipeline::contracts; pub use pipeline::stream_postprocess; -pub use quality::{qube_daemon, qube_report}; +pub use quality::{overlay_quality, qube_daemon, qube_report}; pub use util::{safe_path, status}; diff --git a/core/llm/account_auth/mod.rs b/core/llm/account_auth/mod.rs index 7bf0003d..a07cef25 100644 --- a/core/llm/account_auth/mod.rs +++ b/core/llm/account_auth/mod.rs @@ -1,6 +1,6 @@ //! Provider-account authentication foundation for future "Sign in with ChatGPT". //! -//! Tokens are stored as serialized JSON in the existing CodeScribe Keychain +//! Tokens are stored as serialized JSON in the existing Codescribe Keychain //! bundle under a provider-specific account. No `auth.json` file is written. use std::fmt; @@ -8,6 +8,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; +use crate::config::UserSettings; use crate::config::keychain::{delete_key, load_key, save_key}; use crate::llm::provider::ProviderKind; @@ -22,6 +23,8 @@ pub use pkce::{PkceCodes, challenge_for_verifier, generate_pkce}; pub use server::{LoginServer, ServerOptions, exchange_code_for_tokens, run_login_server}; pub const OPENAI_ACCOUNT_TOKENS_ACCOUNT: &str = "LLM_OPENAI_ACCOUNT_TOKENS"; +/// Router key of the operator-configurable client id (settings.json, non-secret). +pub const OPENAI_CLIENT_ID_SETTING: &str = "LLM_OPENAI_OAUTH_CLIENT_ID"; pub const OPENAI_CLIENT_ID_ENV: &str = "CODESCRIBE_OPENAI_OAUTH_CLIENT_ID"; pub const OPENAI_ISSUER_ENV: &str = "CODESCRIBE_OPENAI_OAUTH_ISSUER"; pub const DEFAULT_ISSUER: &str = "https://auth.openai.com"; @@ -45,7 +48,8 @@ impl fmt::Display for AccountAuthError { match self { AccountAuthError::NoClientId => write!( f, - "{NO_CLIENT_ID_MESSAGE}; set {OPENAI_CLIENT_ID_ENV} after app registration" + "{NO_CLIENT_ID_MESSAGE}; paste the registered client id in Settings → Keys \ + ({OPENAI_CLIENT_ID_SETTING}) or set {OPENAI_CLIENT_ID_ENV}" ), AccountAuthError::UnsupportedProvider(provider) => { write!(f, "provider account auth is not available for {provider}") @@ -120,11 +124,15 @@ pub struct AccountAuthStatus { pub fn account_status(provider: ProviderKind) -> AccountAuthStatus { let client_id_configured = client_id_for_provider(provider).is_ok(); - let signed_in = load_account_tokens(provider).is_ok(); + let tokens = load_account_tokens(provider).ok(); + let signed_in = tokens.is_some(); let message = if !client_id_configured { NO_CLIENT_ID_MESSAGE.to_string() - } else if signed_in { - "signed in".to_string() + } else if let Some(tokens) = tokens { + match id_token_identity(&tokens) { + Some(identity) => format!("signed in as {identity}"), + None => "signed in".to_string(), + } } else { "not signed in".to_string() }; @@ -138,11 +146,45 @@ pub fn account_status(provider: ProviderKind) -> AccountAuthStatus { pub fn client_id_for_provider(provider: ProviderKind) -> Result { ensure_provider_supported(provider)?; - std::env::var(OPENAI_CLIENT_ID_ENV) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or(AccountAuthError::NoClientId) + configured_client_id().ok_or(AccountAuthError::NoClientId) +} + +/// Operator-configured OAuth client id, or `None` (⇒ "awaiting app +/// registration"). Reads the persisted settings snapshot on every call — a Keys +/// panel save takes effect on the very next click, no restart — with the dev +/// env var as the fallback, never the other way around (no frozen env). +pub fn configured_client_id() -> Option { + UserSettings::load() + .openai_oauth_client_id + .and_then(non_empty_trimmed) + .or_else(|| { + std::env::var(OPENAI_CLIENT_ID_ENV) + .ok() + .and_then(non_empty_trimmed) + }) +} + +fn non_empty_trimmed(value: String) -> Option { + let value = value.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +/// Best-effort display identity from the id_token JWT payload (email, else +/// sub). Display-only — the claims are NOT verified here; authorization always +/// rides the access token, never this label. +fn id_token_identity(tokens: &AccountTokens) -> Option { + use base64::Engine; + let payload = tokens.id_token.as_deref()?.split('.').nth(1)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload.as_bytes()) + .ok()?; + let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + ["email", "sub"].iter().find_map(|key| { + claims + .get(key) + .and_then(serde_json::Value::as_str) + .and_then(|value| non_empty_trimmed(value.to_string())) + }) } pub fn issuer_from_env() -> String { @@ -179,16 +221,27 @@ pub fn clear_account_tokens(provider: ProviderKind) -> Result<(), AccountAuthErr ensure_provider_supported(provider)?; let account = token_account(provider)?; delete_key(account).map_err(|error| AccountAuthError::Storage(error.to_string()))?; + // SAFETY: clears the process-env mirror of the tokens (the test/dev + // injection channel read by `load_account_tokens`) so sign-out is not + // undone by a stale override. Sign-out is a single user-driven action, + // not a hot concurrent path. unsafe { std::env::remove_var(account) }; Ok(()) } pub async fn authorization_header(provider: ProviderKind) -> Result { + Ok(format!("Bearer {}", access_token(provider).await?)) +} + +/// Fresh access token for the stored provider account, auto-refreshing within +/// the expiry skew. Raw token (no `Bearer ` prefix) — for request builders +/// that format the Authorization header themselves. +pub async fn access_token(provider: ProviderKind) -> Result { let mut tokens = load_account_tokens(provider)?; if tokens.expires_within(REFRESH_SKEW) { tokens = refresh_tokens(provider, tokens).await?; } - Ok(format!("Bearer {}", tokens.access_token)) + Ok(tokens.access_token) } pub async fn refresh_tokens( @@ -278,15 +331,136 @@ mod tests { use super::*; use serial_test::serial; + /// Point the settings store at an isolated scratch dir so these tests never + /// read (or depend on) the operator's real settings.json. + fn isolated_settings_dir(tag: &str) -> (EnvGuard, tempfile::TempDir) { + let dir = tempfile::Builder::new() + .prefix(&format!("cs_account_auth_{tag}_")) + .tempdir() + .expect("create scratch settings dir"); + (EnvGuard::set_path("CODESCRIBE_DATA_DIR", dir.path()), dir) + } + #[test] #[serial] fn no_client_id_reports_registration_gate() { + let (_data_dir, _dir) = isolated_settings_dir("gate"); let _guard = EnvGuard::unset(OPENAI_CLIENT_ID_ENV); let err = client_id_for_provider(ProviderKind::OpenAiResponses).unwrap_err(); assert!(matches!(err, AccountAuthError::NoClientId)); assert!(err.to_string().contains(NO_CLIENT_ID_MESSAGE)); } + #[test] + #[serial] + fn settings_client_id_beats_env_and_applies_without_restart() { + let (_data_dir, _dir) = isolated_settings_dir("resolution"); + let _env = EnvGuard::set(OPENAI_CLIENT_ID_ENV, "env-client"); + + // Env alone (dev fallback) resolves. + assert_eq!( + client_id_for_provider(ProviderKind::OpenAiResponses).unwrap(), + "env-client" + ); + + // A Keys-panel save lands in settings.json mid-process — the very next + // resolution must see it (fresh read per call, no frozen env). + UserSettings { + openai_oauth_client_id: Some("settings-client".to_string()), + ..Default::default() + } + .save() + .expect("persist client id"); + assert_eq!( + client_id_for_provider(ProviderKind::OpenAiResponses).unwrap(), + "settings-client" + ); + + // Clearing the setting falls back to env, again without restart. + UserSettings { + openai_oauth_client_id: None, + ..Default::default() + } + .save() + .expect("clear client id"); + assert_eq!( + client_id_for_provider(ProviderKind::OpenAiResponses).unwrap(), + "env-client" + ); + } + + /// The three UI states the Keys panel renders, in the order an operator + /// walks them: registration gate → not signed in → signed in as . + #[test] + #[serial] + fn account_status_maps_gate_then_not_signed_in_then_signed_in() { + use base64::Engine; + let (_data_dir, _dir) = isolated_settings_dir("status"); + let _disable = EnvGuard::set("CODESCRIBE_DISABLE_KEYCHAIN", "1"); + let _tokens = EnvGuard::unset(OPENAI_ACCOUNT_TOKENS_ACCOUNT); + let _env = EnvGuard::unset(OPENAI_CLIENT_ID_ENV); + + // 1. No client id anywhere ⇒ registration gate, button disabled. + let status = account_status(ProviderKind::OpenAiResponses); + assert!(!status.client_id_configured); + assert!(!status.signed_in); + assert_eq!(status.message, NO_CLIENT_ID_MESSAGE); + + // 2. Operator pastes a client id in Settings ⇒ gate lifts mid-process. + UserSettings { + openai_oauth_client_id: Some("app_registered".to_string()), + ..Default::default() + } + .save() + .expect("persist client id"); + let status = account_status(ProviderKind::OpenAiResponses); + assert!(status.client_id_configured); + assert!(!status.signed_in); + assert_eq!(status.message, "not signed in"); + + // 3. Stored tokens with an id_token email ⇒ signed in as . + let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(r#"{"email":"maciej@example.com"}"#); + let tokens = AccountTokens::new( + ProviderKind::OpenAiResponses, + "access".to_string(), + Some("refresh".to_string()), + Some(format!("header.{claims}.signature")), + None, + Some(3600), + ); + store_account_tokens(ProviderKind::OpenAiResponses, &tokens).expect("store tokens"); + let status = account_status(ProviderKind::OpenAiResponses); + assert!(status.client_id_configured); + assert!(status.signed_in); + assert_eq!(status.message, "signed in as maciej@example.com"); + } + + #[test] + fn signed_in_status_carries_the_id_token_email_when_present() { + use base64::Engine; + let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(r#"{"email":"maciej@example.com","sub":"user-123"}"#); + let tokens = AccountTokens { + provider: ProviderKind::OpenAiResponses.as_str().to_string(), + access_token: "access".to_string(), + refresh_token: None, + id_token: Some(format!("header.{claims}.signature")), + token_type: "Bearer".to_string(), + expires_at_unix: None, + }; + assert_eq!( + id_token_identity(&tokens).as_deref(), + Some("maciej@example.com") + ); + + let no_id_token = AccountTokens { + id_token: None, + ..tokens + }; + assert_eq!(id_token_identity(&no_id_token), None); + } + #[test] #[serial] fn keychain_mock_round_trips_serialized_account_tokens() { @@ -317,12 +491,21 @@ mod tests { impl EnvGuard { fn set(key: &'static str, value: &str) -> Self { let previous = std::env::var(key).ok(); + // SAFETY: these process-env tests are serialized with `serial`. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + + fn set_path(key: &'static str, value: &std::path::Path) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: these process-env tests are serialized with `serial`. unsafe { std::env::set_var(key, value) }; Self { key, previous } } fn unset(key: &'static str) -> Self { let previous = std::env::var(key).ok(); + // SAFETY: these process-env tests are serialized with `serial`. unsafe { std::env::remove_var(key) }; Self { key, previous } } @@ -331,7 +514,9 @@ mod tests { impl Drop for EnvGuard { fn drop(&mut self) { match &self.previous { + // SAFETY: these process-env tests are serialized with `serial`. Some(value) => unsafe { std::env::set_var(self.key, value) }, + // SAFETY: these process-env tests are serialized with `serial`. None => unsafe { std::env::remove_var(self.key) }, } } diff --git a/core/llm/account_auth/server.rs b/core/llm/account_auth/server.rs index 93475e27..32ab2f5e 100644 --- a/core/llm/account_auth/server.rs +++ b/core/llm/account_auth/server.rs @@ -20,8 +20,8 @@ use base64::Engine; const DEFAULT_PORT: u16 = 1455; const SUCCESS_HTML: &str = r#" -CodeScribe signed in -

CodeScribe signed in

You can close this window.

+Codescribe signed in +

Codescribe signed in

You can close this window.

"#; #[derive(Debug, Clone)] @@ -73,7 +73,11 @@ pub struct ShutdownHandle { impl ShutdownHandle { pub fn shutdown(&self) { - self.shutdown_notify.notify_waiters(); + // `notify_one` (not `notify_waiters`): it stores a permit when the + // server task is momentarily outside its `select!` — e.g. mid-response + // to a browser request — so a cancel landing in that window still stops + // the server on its next loop instead of being silently lost. + self.shutdown_notify.notify_one(); } } @@ -405,6 +409,114 @@ fn send_response_with_disconnect( #[cfg(test)] mod tests { use super::*; + use crate::llm::account_auth::{OPENAI_ACCOUNT_TOKENS_ACCOUNT, load_account_tokens}; + use serial_test::serial; + + /// Full "Sign in with ChatGPT" roundtrip against a mock issuer: the login + /// server hands out the authorize URL, the browser redirect lands on + /// `/auth/callback`, the code is exchanged at the issuer, tokens persist + /// through the (test-env) Keychain path, and `block_until_done` resolves. + /// `#[serial]`: the test-env token store is the process env var. + #[tokio::test] + #[serial] + async fn login_roundtrip_callback_exchanges_code_and_stores_tokens() { + let _disable = EnvGuard::set("CODESCRIBE_DISABLE_KEYCHAIN", "1"); + let _tokens = EnvGuard::unset(OPENAI_ACCOUNT_TOKENS_ACCOUNT); + + let mut issuer = mockito::Server::new_async().await; + let _mock = issuer + .mock("POST", "/oauth/token") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded( + "grant_type".to_string(), + "authorization_code".to_string(), + ), + mockito::Matcher::UrlEncoded("code".to_string(), "auth-code".to_string()), + mockito::Matcher::UrlEncoded("client_id".to_string(), "client".to_string()), + ])) + .with_status(200) + .with_body( + r#"{"access_token":"account-access","refresh_token":"account-refresh","expires_in":3600}"#, + ) + .expect(1) + .create_async() + .await; + + let mut opts = ServerOptions::new("client".to_string()); + opts.issuer = issuer.url(); + opts.port = 0; + opts.force_state = Some("roundtrip-state".to_string()); + let login = run_login_server(opts).await.expect("bind login server"); + assert!(login.auth_url.contains("state=roundtrip-state")); + assert!(login.auth_url.starts_with(&issuer.url())); + + let callback = format!( + "http://127.0.0.1:{}/auth/callback?code=auth-code&state=roundtrip-state", + login.actual_port + ); + // reqwest follows the 302 to /success, which completes the server loop. + let response = reqwest::get(&callback).await.expect("callback request"); + assert!(response.status().is_success()); + login.block_until_done().await.expect("login completes"); + + let stored = + load_account_tokens(ProviderKind::OpenAiResponses).expect("tokens were stored"); + assert_eq!(stored.access_token, "account-access"); + assert_eq!(stored.refresh_token.as_deref(), Some("account-refresh")); + } + + /// A forged `state` must be rejected before any token exchange, and the + /// pending login stays cancellable (cancel ⇒ honest "not completed" error). + #[tokio::test] + async fn callback_with_wrong_state_is_rejected_without_token_exchange() { + let mut opts = ServerOptions::new("client".to_string()); + opts.port = 0; + opts.force_state = Some("expected-state".to_string()); + let login = run_login_server(opts).await.expect("bind login server"); + + let callback = format!( + "http://127.0.0.1:{}/auth/callback?code=auth-code&state=forged", + login.actual_port + ); + let response = reqwest::get(&callback).await.expect("callback request"); + assert_eq!(response.status().as_u16(), 400); + + login.cancel(); + assert!(login.block_until_done().await.is_err()); + } + + #[derive(Debug)] + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: env-touching tests here are serialized with `serial`. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + + fn unset(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: env-touching tests here are serialized with `serial`. + unsafe { std::env::remove_var(key) }; + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + // SAFETY: env-touching tests here are serialized with `serial`. + Some(value) => unsafe { std::env::set_var(self.key, value) }, + // SAFETY: env-touching tests here are serialized with `serial`. + None => unsafe { std::env::remove_var(self.key) }, + } + } + } #[tokio::test] async fn exchange_code_posts_authorization_grant_and_maps_tokens() { diff --git a/core/llm/ai_formatting.rs b/core/llm/ai_formatting.rs index b723aeef..c47895c3 100644 --- a/core/llm/ai_formatting.rs +++ b/core/llm/ai_formatting.rs @@ -27,7 +27,10 @@ use std::sync::{Arc, OnceLock, RwLock}; use std::time::Duration; use tracing::{debug, info, trace, warn}; -use super::provider::{LlmMode, ProviderKind, capability_policy, resolve_provider}; +use crate::config::Config; + +use super::lane_truth; +use super::provider::{LlmMode, ProviderKind, capability_policy}; use super::responses_streaming_manager::{ResponsesStreamingManager, StreamCallbacks}; /// HTTP client for AI providers @@ -104,7 +107,6 @@ const DEFAULT_AI_CLIENT_TIMEOUT_MS: u64 = 90_000; const DEFAULT_AI_CONNECT_TIMEOUT_MS: u64 = 5_000; const DEFAULT_AI_POOL_IDLE_TIMEOUT_MS: u64 = 90_000; const DEFAULT_AI_TCP_KEEPALIVE_MS: u64 = 30_000; -const ANTHROPIC_API_KEY_ENV: &str = "LLM_ANTHROPIC_API_KEY"; const ANTHROPIC_VERSION: &str = "2023-06-01"; const DEFAULT_ANTHROPIC_MAX_TOKENS: u32 = 8192; @@ -203,31 +205,6 @@ fn get_client() -> &'static Client { }) } -/// Read env var by priority list, ensure non-empty, return detailed error -fn get_env_non_empty(candidates: &[&str], what: &str) -> Result { - for key in candidates { - if let Ok(value) = env::var(key) { - let trimmed = value.trim(); - if !trimmed.is_empty() { - return Ok(trimmed.to_string()); - } - } - } - - match candidates { - [single] => anyhow::bail!("{} is required. Set {}.", what, single), - [first, second, ..] => { - anyhow::bail!( - "{} is required. Set {} (or fallback {}).", - what, - first, - second - ) - } - [] => anyhow::bail!("{} is required.", what), - } -} - // ============================================================================ // LLM Configuration - Separate providers for Formatting vs Assistive // ============================================================================ @@ -239,64 +216,35 @@ fn get_env_non_empty(candidates: &[&str], what: &str) -> Result { // // NO legacy variables. Clean contract only. -/// Helper: require mode-specific key (no fallback to shared keys) -fn get_mode_config(specific_key: &str, what: &str) -> Result { - if let Ok(val) = env::var(specific_key) { - let val = val.trim(); - if !val.is_empty() { - return Ok(val.to_string()); - } - } - get_env_non_empty(&[specific_key], what) -} - // ---- FORMATTING mode config ---- fn get_formatting_endpoint() -> Result { - get_mode_config("LLM_FORMATTING_ENDPOINT", "LLM endpoint (formatting)") + Ok(lane_truth::endpoint(LlmMode::Formatting, &Config::load())) } fn get_formatting_model() -> Result { - get_mode_config("LLM_FORMATTING_MODEL", "LLM model (formatting)") + Ok(lane_truth::formatting_identity(&Config::load()).1) } fn get_formatting_api_key() -> Result { - get_mode_config("LLM_FORMATTING_API_KEY", "LLM API key (formatting)") + lane_truth::secret("LLM_FORMATTING_API_KEY") + .context("LLM API key (formatting) is required. Set LLM_FORMATTING_API_KEY.") } // ---- ASSISTIVE mode config ---- fn get_assistive_endpoint() -> Result { - get_mode_config("LLM_ASSISTIVE_ENDPOINT", "LLM endpoint (assistive)") + Ok(lane_truth::assistive_snapshot(&Config::load()).endpoint) } fn get_assistive_model() -> Result { - get_mode_config("LLM_ASSISTIVE_MODEL", "LLM model (assistive)") -} - -fn get_assistive_api_key() -> Result { - get_mode_config("LLM_ASSISTIVE_API_KEY", "LLM API key (assistive)") + Ok(lane_truth::assistive_snapshot(&Config::load()).model) } fn get_anthropic_api_key() -> Result { - if let Ok(value) = env::var(ANTHROPIC_API_KEY_ENV) { - let trimmed = value.trim(); - if !trimmed.is_empty() { - return Ok(trimmed.to_string()); - } - } - - if let Some(value) = crate::config::keychain::load_key(ANTHROPIC_API_KEY_ENV) { - let trimmed = value.trim(); - if !trimmed.is_empty() { - return Ok(trimmed.to_string()); - } - } - - anyhow::bail!( - "Anthropic API key is required. Set {}.", - ANTHROPIC_API_KEY_ENV - ) + let account = ProviderKind::AnthropicMessages.api_key_env_key(); + lane_truth::secret(account) + .with_context(|| format!("Anthropic API key is required. Set {account}.")) } /// Get temperature from env var. Returns None if empty/unset (skip parameter). @@ -1032,7 +980,7 @@ pub async fn format_text_with_status_channels( } else { get_formatting_endpoint().unwrap_or_default() }; - let provider = resolve_provider(if assistive { + let provider = lane_truth::provider(if assistive { LlmMode::Assistive } else { LlmMode::Formatting @@ -1257,28 +1205,46 @@ async fn call_provider_once( } } -fn normalize_anthropic_messages_endpoint(endpoint: &str) -> String { - let trimmed = endpoint.trim().trim_end_matches('/'); - let base = trimmed - .trim_end_matches("/v1/messages") - .trim_end_matches("/v1/responses") - .trim_end_matches("/v1"); - format!("{base}/v1/messages") -} - async fn call_anthropic_messages( user_message: &str, system_prompt: &str, assistive: bool, ) -> Result { - let (configured_endpoint, model) = if assistive { - (get_assistive_endpoint()?, get_assistive_model()?) + let mode = if assistive { + LlmMode::Assistive } else { - (get_formatting_endpoint()?, get_formatting_model()?) + LlmMode::Formatting }; - let endpoint = normalize_anthropic_messages_endpoint(&configured_endpoint); + let configured_endpoint = if assistive { + get_assistive_endpoint()? + } else { + get_formatting_endpoint()? + }; + let model = + lane_truth::model_for_provider(mode, ProviderKind::AnthropicMessages, &Config::load()); let api_key = get_anthropic_api_key()?; - let policy = capability_policy(ProviderKind::AnthropicMessages, &model); + + call_anthropic_messages_resolved( + user_message, + system_prompt, + assistive, + &configured_endpoint, + &model, + &api_key, + ) + .await +} + +async fn call_anthropic_messages_resolved( + user_message: &str, + system_prompt: &str, + assistive: bool, + configured_endpoint: &str, + model: &str, + api_key: &str, +) -> Result { + let endpoint = lane_truth::normalize_anthropic_messages_endpoint(configured_endpoint); + let policy = capability_policy(ProviderKind::AnthropicMessages, model); let temperature = policy.sanitize_temperature(get_temperature(assistive)); let max_tokens = env_u32( "CODESCRIBE_ANTHROPIC_MAX_TOKENS", @@ -1295,7 +1261,7 @@ async fn call_anthropic_messages( ); let request = AnthropicMessagesRequest { - model, + model: model.to_string(), system: Some(system_prompt.to_string()).filter(|value| !value.trim().is_empty()), messages: vec![AnthropicMessage { role: "user", @@ -1307,7 +1273,7 @@ async fn call_anthropic_messages( let response = get_client() .post(&endpoint) - .header("x-api-key", &api_key) + .header("x-api-key", api_key) .header("anthropic-version", ANTHROPIC_VERSION) .header("Content-Type", "application/json") .json(&request) @@ -1372,11 +1338,12 @@ async fn call_llm_endpoint( ) -> Result { // Mode-aware config: formatting vs assistive use different providers let (endpoint, model, api_key) = if assistive { - ( - get_assistive_endpoint()?, - get_assistive_model()?, - get_assistive_api_key()?, - ) + let lane = lane_truth::assistive_snapshot(&Config::load()); + let account = lane.provider.api_key_env_key(); + let api_key = lane + .api_key + .with_context(|| format!("LLM API key (assistive) is required. Set {account}."))?; + (lane.endpoint, lane.model, api_key) } else { ( get_formatting_endpoint()?, @@ -1476,11 +1443,12 @@ async fn call_llm_endpoint_streaming( ) -> Result { // Mode-aware config: formatting vs assistive use different providers let (endpoint, model, api_key) = if assistive { - ( - get_assistive_endpoint()?, - get_assistive_model()?, - get_assistive_api_key()?, - ) + let lane = lane_truth::assistive_snapshot(&Config::load()); + let account = lane.provider.api_key_env_key(); + let api_key = lane + .api_key + .with_context(|| format!("LLM API key (assistive) is required. Set {account}."))?; + (lane.endpoint, lane.model, api_key) } else { ( get_formatting_endpoint()?, @@ -1674,7 +1642,7 @@ pub fn has_api_key() -> bool { return false; } - let provider = resolve_provider(LlmMode::Formatting); + let provider = lane_truth::provider(LlmMode::Formatting); let endpoint_format = detect_format(&endpoint, provider); // OllamaChat doesn't need API key @@ -1703,15 +1671,11 @@ mod tests { use serial_test::serial; const ANTHROPIC_TEST_ENV_KEYS: &[&str] = &[ - "LLM_FORMATTING_PROVIDER", - "LLM_FORMATTING_ENDPOINT", - "LLM_FORMATTING_MODEL", - "LLM_FORMATTING_API_KEY", "LLM_FORMATTING_TEMPERATURE", "LLM_TEMPERATURE", - "LLM_ANTHROPIC_API_KEY", "CODESCRIBE_ANTHROPIC_MAX_TOKENS", ]; + const LANE_TRUTH_TEST_CHILD: &str = "CODESCRIBE_LANE_TRUTH_TEST_CHILD"; struct EnvGuard { key: &'static str, @@ -1849,15 +1813,76 @@ mod tests { assert_eq!(DEFAULT_AI_RETRY_DELAY_MS, 500); } + #[test] + fn lane_configs_read_fresh_truth_after_settings_save() { + if std::env::var_os(LANE_TRUTH_TEST_CHILD).is_none() { + let data_dir = tempfile::TempDir::new().expect("isolated data dir"); + let executable = std::env::current_exe().expect("current core test executable"); + let status = std::process::Command::new(executable) + .arg("--exact") + .arg("llm::ai_formatting::tests::lane_configs_read_fresh_truth_after_settings_save") + .arg("--nocapture") + .env(LANE_TRUTH_TEST_CHILD, "1") + .env("CODESCRIBE_DATA_DIR", data_dir.path()) + .env("CODESCRIBE_DISABLE_KEYCHAIN", "1") + .envs([ + ("LLM_FORMATTING_PROVIDER", "openai-responses"), + ( + "LLM_FORMATTING_ENDPOINT", + "https://stale-formatting.example/v1", + ), + ("LLM_FORMATTING_MODEL", "stale-formatting-model"), + ("LLM_ASSISTIVE_PROVIDER", "openai-responses"), + ( + "LLM_ASSISTIVE_ENDPOINT", + "https://stale-assistive.example/v1", + ), + ("LLM_ASSISTIVE_MODEL", "stale-assistive-model"), + ]) + .status() + .expect("run isolated lane-truth test"); + assert!( + status.success(), + "isolated lane-truth test failed: {status}" + ); + return; + } + + crate::config::UserSettings { + llm_formatting_endpoint: Some("https://fresh-formatting.example/v1".to_string()), + llm_formatting_model: Some("fresh-formatting-model".to_string()), + llm_assistive_provider: Some("openai-responses".to_string()), + llm_assistive_endpoint: Some("https://fresh-assistive.example/v1".to_string()), + llm_assistive_model: Some("fresh-assistive-model".to_string()), + ..Default::default() + } + .save() + .expect("persist lane settings"); + + assert_eq!( + get_formatting_endpoint().expect("formatting endpoint"), + "https://fresh-formatting.example/v1/responses" + ); + assert_eq!( + get_formatting_model().expect("formatting model"), + "fresh-formatting-model" + ); + assert_eq!( + get_assistive_endpoint().expect("assistive endpoint"), + "https://fresh-assistive.example/v1/responses" + ); + assert_eq!( + get_assistive_model().expect("assistive model"), + "fresh-assistive-model" + ); + } + #[tokio::test] #[serial] async fn anthropic_sonnet_request_keeps_temperature() { let mut env = TestEnv::clean(); let mut server = mockito::Server::new_async().await; - env.set("LLM_FORMATTING_ENDPOINT", &server.url()); - env.set("LLM_FORMATTING_MODEL", "claude-sonnet-4-6"); env.set("LLM_FORMATTING_TEMPERATURE", "0.5"); - env.set("LLM_ANTHROPIC_API_KEY", "anthropic-test-key"); let mock = server .mock("POST", "/v1/messages") @@ -1888,9 +1913,16 @@ mod tests { .create_async() .await; - let output = call_anthropic_messages("hello world", "format carefully", false) - .await - .expect("sonnet formatting request should succeed"); + let output = call_anthropic_messages_resolved( + "hello world", + "format carefully", + false, + &server.url(), + "claude-sonnet-4-6", + "anthropic-test-key", + ) + .await + .expect("sonnet formatting request should succeed"); assert_eq!(output.assistant_text, "Hello world."); mock.assert_async().await; @@ -1901,10 +1933,7 @@ mod tests { async fn anthropic_opus_request_strips_temperature() { let mut env = TestEnv::clean(); let mut server = mockito::Server::new_async().await; - env.set("LLM_FORMATTING_ENDPOINT", &server.url()); - env.set("LLM_FORMATTING_MODEL", "claude-opus-4-8"); env.set("LLM_FORMATTING_TEMPERATURE", "0.5"); - env.set("LLM_ANTHROPIC_API_KEY", "anthropic-test-key"); let mock = server .mock("POST", "/v1/messages") @@ -1932,9 +1961,16 @@ mod tests { .create_async() .await; - let output = call_anthropic_messages("hello world", "format carefully", false) - .await - .expect("opus formatting request should succeed without temperature"); + let output = call_anthropic_messages_resolved( + "hello world", + "format carefully", + false, + &server.url(), + "claude-opus-4-8", + "anthropic-test-key", + ) + .await + .expect("opus formatting request should succeed without temperature"); assert_eq!(output.assistant_text, "Hello world."); mock.assert_async().await; @@ -1943,11 +1979,8 @@ mod tests { #[tokio::test] #[serial] async fn anthropic_refusal_stop_reason_is_readable_error() { - let mut env = TestEnv::clean(); + let _env = TestEnv::clean(); let mut server = mockito::Server::new_async().await; - env.set("LLM_FORMATTING_ENDPOINT", &server.url()); - env.set("LLM_FORMATTING_MODEL", "claude-sonnet-4-6"); - env.set("LLM_ANTHROPIC_API_KEY", "anthropic-test-key"); let mock = server .mock("POST", "/v1/messages") @@ -1968,9 +2001,16 @@ mod tests { .create_async() .await; - let err = call_anthropic_messages("hello world", "format carefully", false) - .await - .expect_err("refusal stop_reason should not parse as empty success"); + let err = call_anthropic_messages_resolved( + "hello world", + "format carefully", + false, + &server.url(), + "claude-sonnet-4-6", + "anthropic-test-key", + ) + .await + .expect_err("refusal stop_reason should not parse as empty success"); let message = err.to_string(); assert!(message.contains("Anthropic refusal stop")); @@ -1981,11 +2021,8 @@ mod tests { #[tokio::test] #[serial] async fn anthropic_happy_path_joins_text_content_blocks() { - let mut env = TestEnv::clean(); + let _env = TestEnv::clean(); let mut server = mockito::Server::new_async().await; - env.set("LLM_FORMATTING_ENDPOINT", &server.url()); - env.set("LLM_FORMATTING_MODEL", "claude-sonnet-4-6"); - env.set("LLM_ANTHROPIC_API_KEY", "anthropic-test-key"); let mock = server .mock("POST", "/v1/messages") @@ -2008,9 +2045,16 @@ mod tests { .create_async() .await; - let output = call_anthropic_messages("hello world", "format carefully", false) - .await - .expect("text content blocks should parse"); + let output = call_anthropic_messages_resolved( + "hello world", + "format carefully", + false, + &server.url(), + "claude-sonnet-4-6", + "anthropic-test-key", + ) + .await + .expect("text content blocks should parse"); assert_eq!(output.assistant_text, "Hello world."); mock.assert_async().await; diff --git a/core/llm/key_liveness.rs b/core/llm/key_liveness.rs index db104527..8c7df8df 100644 --- a/core/llm/key_liveness.rs +++ b/core/llm/key_liveness.rs @@ -11,15 +11,12 @@ use reqwest::StatusCode; use reqwest::blocking::{Client, Response}; use serde_json::json; -use crate::config::keychain::{self, KEYCHAIN_ACCOUNTS}; -use crate::config::{ - Config, DEFAULT_ASSISTIVE_MODEL, DEFAULT_FORMATTING_MODEL, DEFAULT_LLM_MODEL, - DEFAULT_OPENAI_RESPONSES_ENDPOINT, -}; +use crate::config::Config; +use crate::config::keychain::KEYCHAIN_ACCOUNTS; +use crate::llm::lane_truth; +use crate::llm::provider::{LlmMode, ProviderKind}; const PROBE_TIMEOUT: Duration = Duration::from_secs(10); -const DEFAULT_ANTHROPIC_ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; -const DEFAULT_ANTHROPIC_MODEL: &str = "claude-opus-4-8"; const ANTHROPIC_VERSION: &str = "2023-06-01"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -37,6 +34,7 @@ pub struct ApiKeyLivenessResult { pub account: String, pub status: ApiKeyLivenessStatus, pub message: String, + pub probed_endpoint: Option, } impl ApiKeyLivenessResult { @@ -45,8 +43,14 @@ impl ApiKeyLivenessResult { account: account.to_string(), status, message: message.into(), + probed_endpoint: None, } } + + fn with_probed_endpoint(mut self, endpoint: String) -> Self { + self.probed_endpoint = Some(endpoint); + self + } } pub fn probe_api_key_liveness(account: &str) -> ApiKeyLivenessResult { @@ -59,7 +63,7 @@ pub fn probe_api_key_liveness(account: &str) -> ApiKeyLivenessResult { } let config = Config::load(); - let Some(api_key) = account_secret(account) else { + let Some(api_key) = lane_truth::secret(account) else { return ApiKeyLivenessResult::new( account, ApiKeyLivenessStatus::Missing, @@ -94,7 +98,7 @@ pub fn probe_api_key_liveness(account: &str) -> ApiKeyLivenessResult { "LLM_API_KEY" | "LLM_FORMATTING_API_KEY" | "LLM_ASSISTIVE_API_KEY" => { probe_openai_key(&client, &config, account, &api_key) } - "LLM_ANTHROPIC_API_KEY" => probe_anthropic_key(&client, account, &api_key), + "LLM_ANTHROPIC_API_KEY" => probe_anthropic_key(&client, &config, account, &api_key), "GITHUB_TOKEN" => probe_github_token(&client, account, &api_key), _ => ApiKeyLivenessResult::new( account, @@ -143,9 +147,8 @@ fn probe_openai_key( account: &str, api_key: &str, ) -> ApiKeyLivenessResult { - let endpoint = openai_endpoint_for_account(config, account); - let endpoint = normalize_openai_responses_endpoint(&endpoint); - let model = openai_model_for_account(account); + let endpoint = lane_truth::endpoint_for_account(config, account); + let model = lane_truth::model_for_account(config, account); let request = json!({ "model": model, "input": [{ @@ -157,23 +160,25 @@ fn probe_openai_key( }); let response = client - .post(endpoint) + .post(&endpoint) .bearer_auth(api_key) .header("x-api-key", api_key) .header("Content-Type", "application/json") .json(&request) .send(); - response_result(account, response) + response_result(account, endpoint, response) } -fn probe_anthropic_key(client: &Client, account: &str, api_key: &str) -> ApiKeyLivenessResult { - let endpoint = env_non_empty("LLM_ANTHROPIC_ENDPOINT") - .unwrap_or_else(|| DEFAULT_ANTHROPIC_ENDPOINT.to_string()); - let endpoint = normalize_anthropic_messages_endpoint(&endpoint); - let model = env_non_empty("LLM_ASSISTIVE_MODEL") - .filter(|m| m.starts_with("claude")) - .unwrap_or_else(|| DEFAULT_ANTHROPIC_MODEL.to_string()); +fn probe_anthropic_key( + client: &Client, + config: &Config, + account: &str, + api_key: &str, +) -> ApiKeyLivenessResult { + let endpoint = lane_truth::anthropic_messages_endpoint(); + let model = + lane_truth::model_for_provider(LlmMode::Assistive, ProviderKind::AnthropicMessages, config); let request = json!({ "model": model, "messages": [{ @@ -184,33 +189,34 @@ fn probe_anthropic_key(client: &Client, account: &str, api_key: &str) -> ApiKeyL }); let response = client - .post(endpoint) + .post(&endpoint) .header("x-api-key", api_key) .header("anthropic-version", ANTHROPIC_VERSION) .header("Content-Type", "application/json") .json(&request) .send(); - response_result(account, response) + response_result(account, endpoint, response) } fn probe_github_token(client: &Client, account: &str, api_key: &str) -> ApiKeyLivenessResult { let endpoint = env_non_empty("CODESCRIBE_GITHUB_PROBE_ENDPOINT") .unwrap_or_else(|| "https://api.github.com/user".to_string()); let response = client - .get(endpoint) + .get(&endpoint) .bearer_auth(api_key) .header("User-Agent", "Codescribe API key liveness probe") .send(); - response_result(account, response) + response_result(account, endpoint, response) } fn response_result( account: &str, + probed_endpoint: String, response: Result, ) -> ApiKeyLivenessResult { - match response { + let result = match response { Ok(response) => { let status = response.status(); let body = response.text().unwrap_or_default(); @@ -222,7 +228,8 @@ fn response_result( ApiKeyLivenessStatus::Network, format!("network error: {error}"), ), - } + }; + result.with_probed_endpoint(probed_endpoint) } fn message_for_status(status: ApiKeyLivenessStatus) -> &'static str { @@ -236,73 +243,6 @@ fn message_for_status(status: ApiKeyLivenessStatus) -> &'static str { } } -fn account_secret(account: &str) -> Option { - env_non_empty(account).or_else(|| { - keychain::load_key(account) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }) -} - -fn openai_endpoint_for_account(config: &Config, account: &str) -> String { - let fallback = || { - env_non_empty("LLM_ENDPOINT") - .or_else(|| config.llm_endpoint.clone()) - .unwrap_or_else(|| DEFAULT_OPENAI_RESPONSES_ENDPOINT.to_string()) - }; - - match account { - "LLM_FORMATTING_API_KEY" => { - env_non_empty("LLM_FORMATTING_ENDPOINT").unwrap_or_else(fallback) - } - "LLM_ASSISTIVE_API_KEY" => env_non_empty("LLM_ASSISTIVE_ENDPOINT").unwrap_or_else(fallback), - _ => fallback(), - } -} - -fn openai_model_for_account(account: &str) -> String { - let is_openai_model = |m: &String| !m.starts_with("claude"); - match account { - "LLM_FORMATTING_API_KEY" => env_non_empty("LLM_FORMATTING_MODEL") - .filter(is_openai_model) - .or_else(|| env_non_empty("LLM_MODEL").filter(is_openai_model)) - .unwrap_or_else(|| DEFAULT_FORMATTING_MODEL.to_string()), - "LLM_ASSISTIVE_API_KEY" => env_non_empty("LLM_ASSISTIVE_MODEL") - .filter(is_openai_model) - .or_else(|| env_non_empty("LLM_MODEL").filter(is_openai_model)) - .unwrap_or_else(|| DEFAULT_ASSISTIVE_MODEL.to_string()), - _ => env_non_empty("LLM_MODEL") - .filter(is_openai_model) - .unwrap_or_else(|| DEFAULT_LLM_MODEL.to_string()), - } -} - -fn normalize_openai_responses_endpoint(endpoint: &str) -> String { - normalize_endpoint( - endpoint, - "/v1/responses", - &["/v1/responses", "/v1/chat/completions", "/v1/completions"], - ) -} - -fn normalize_anthropic_messages_endpoint(endpoint: &str) -> String { - normalize_endpoint(endpoint, "/v1/messages", &["/v1/messages", "/v1/responses"]) -} - -fn normalize_endpoint(endpoint: &str, canonical_suffix: &str, known_suffixes: &[&str]) -> String { - let mut base = endpoint.trim().trim_end_matches('/').to_string(); - for suffix in known_suffixes { - if base.ends_with(suffix) { - base.truncate(base.len() - suffix.len()); - return format!("{base}{canonical_suffix}"); - } - } - if base.ends_with("/v1") { - base.truncate(base.len() - "/v1".len()); - } - format!("{base}{canonical_suffix}") -} - fn env_non_empty(key: &str) -> Option { std::env::var(key) .ok() @@ -313,6 +253,67 @@ fn env_non_empty(key: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + use tempfile::TempDir; + + #[test] + #[serial] + fn openai_probe_reports_the_normalized_endpoint_it_called() { + let data_dir = TempDir::new().expect("isolated data dir"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind probe server"); + let address = listener.local_addr().expect("probe server address"); + let base_endpoint = format!("http://{address}"); + let expected_endpoint = format!("{base_endpoint}/v1/responses"); + + let _data_dir = EnvGuard::set( + "CODESCRIBE_DATA_DIR", + data_dir.path().to_string_lossy().as_ref(), + ); + let _shared_endpoint = EnvGuard::remove("LLM_ENDPOINT"); + let _assistive_endpoint = EnvGuard::set("LLM_ASSISTIVE_ENDPOINT", &base_endpoint); + let _assistive_model = EnvGuard::set("LLM_ASSISTIVE_MODEL", "gpt-probe"); + + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept probe request"); + let mut buffer = [0_u8; 4096]; + let bytes_read = stream.read(&mut buffer).expect("read probe request"); + stream + .write_all( + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", + ) + .expect("write probe response"); + String::from_utf8_lossy(&buffer[..bytes_read]) + .lines() + .next() + .unwrap_or_default() + .to_string() + }); + + let client = Client::builder() + .timeout(PROBE_TIMEOUT) + .connect_timeout(PROBE_TIMEOUT) + .build() + .expect("build probe client"); + let result = probe_openai_key( + &client, + &Config::default(), + "LLM_ASSISTIVE_API_KEY", + "test-key", + ); + + assert_eq!(result.status, ApiKeyLivenessStatus::Invalid); + assert_eq!( + result.probed_endpoint.as_deref(), + Some(expected_endpoint.as_str()) + ); + assert_eq!( + server.join().expect("probe server thread"), + "POST /v1/responses HTTP/1.1" + ); + } #[test] fn classifies_success_as_ok() { @@ -411,4 +412,37 @@ mod tests { ApiKeyLivenessStatus::Network ); } + + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: process-env tests in this module are serialized. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: process-env tests in this module are serialized. + unsafe { std::env::remove_var(key) }; + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: process-env tests in this module are serialized. + unsafe { + match self.previous.as_deref() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } } diff --git a/core/llm/lane_truth.rs b/core/llm/lane_truth.rs new file mode 100644 index 00000000..cfb27759 --- /dev/null +++ b/core/llm/lane_truth.rs @@ -0,0 +1,825 @@ +//! Canonical truth resolution for LLM lane secrets, endpoints, and models. + +use std::str::FromStr; + +use crate::config::keychain; +use crate::config::{ + Config, DEFAULT_ASSISTIVE_MODEL, DEFAULT_FORMATTING_MODEL, DEFAULT_LLM_MODEL, + DEFAULT_OPENAI_RESPONSES_ENDPOINT, UserSettings, +}; +use crate::llm::account_auth; +use crate::llm::provider::{LlmMode, ProviderKind, resolve_provider}; + +/// Resolve a Keychain account without exposing the secret to callers that only +/// need presence. Explicit non-empty process env remains the highest-priority +/// source; an empty or missing env value falls back to the Keychain bundle. +pub fn secret(account: &str) -> Option { + secret_with_keychain(account, keychain::load_key) +} + +fn secret_with_keychain( + account: &str, + load_key: impl FnOnce(&str) -> Option, +) -> Option { + env_non_empty(account).or_else(|| load_key(account).and_then(non_empty)) +} + +/// Resolve and normalize the OpenAI Responses endpoint for one configured LLM +/// lane. Fresh settings are consulted before process env because config +/// bootstrap deliberately leaves seeded env values immutable after startup. +pub fn endpoint(lane: LlmMode, config: &Config) -> String { + endpoint_with_settings(lane, config, &UserSettings::load()) +} + +fn endpoint_with_settings(lane: LlmMode, config: &Config, settings: &UserSettings) -> String { + let (lane_key, lane_setting) = match lane { + LlmMode::Formatting => ( + "LLM_FORMATTING_ENDPOINT", + settings.llm_formatting_endpoint.clone(), + ), + LlmMode::Assistive => ( + "LLM_ASSISTIVE_ENDPOINT", + settings.llm_assistive_endpoint.clone(), + ), + }; + + let resolved = non_empty_option(lane_setting) + .or_else(|| env_non_empty(lane_key)) + .or_else(|| non_empty_option(settings.llm_endpoint.clone())) + .or_else(|| env_non_empty("LLM_ENDPOINT")) + .or_else(|| non_empty_option(config.llm_endpoint.clone())) + .unwrap_or_else(|| DEFAULT_OPENAI_RESPONSES_ENDPOINT.to_string()); + + normalize_openai_responses_endpoint(&resolved) +} + +/// Resolve the OpenAI model for one LLM lane from the same persisted snapshot +/// and env hierarchy as [`endpoint`]. Anthropic model ids are ignored on this +/// Responses-specific path, preserving the existing liveness-probe contract. +pub fn model(lane: LlmMode, config: &Config) -> String { + model_with_settings(lane, config, &UserSettings::load()) +} + +/// Resolve the wire model for an explicit provider without making callers +/// reimplement the fresh-settings hierarchy. The OpenAI branch preserves the +/// Responses-only filtering in [`model`]; the Anthropic branch accepts only +/// Claude model ids and supplies the provider's lane-specific default. +pub fn model_for_provider(lane: LlmMode, provider: ProviderKind, config: &Config) -> String { + model_for_provider_with_settings(lane, provider, config, &UserSettings::load()) +} + +fn model_for_provider_with_settings( + lane: LlmMode, + provider: ProviderKind, + config: &Config, + settings: &UserSettings, +) -> String { + match provider { + ProviderKind::OpenAiResponses => model_with_settings(lane, config, settings), + ProviderKind::AnthropicMessages => anthropic_model_with_settings(lane, settings), + } +} + +fn model_with_settings(lane: LlmMode, _config: &Config, settings: &UserSettings) -> String { + let (lane_key, lane_setting, lane_default) = match lane { + LlmMode::Formatting => ( + "LLM_FORMATTING_MODEL", + settings.llm_formatting_model.clone(), + DEFAULT_FORMATTING_MODEL, + ), + LlmMode::Assistive => ( + "LLM_ASSISTIVE_MODEL", + settings.llm_assistive_model.clone(), + DEFAULT_ASSISTIVE_MODEL, + ), + }; + let openai_model = |candidate: String| (!candidate.starts_with("claude")).then_some(candidate); + + non_empty_option(lane_setting) + .and_then(openai_model) + .or_else(|| env_non_empty(lane_key).and_then(openai_model)) + .or_else(|| non_empty_option(settings.llm_model.clone()).and_then(openai_model)) + .or_else(|| env_non_empty("LLM_MODEL").and_then(openai_model)) + .unwrap_or_else(|| lane_default.to_string()) +} + +/// Resolve the provider identity for a lane from the same persisted-settings +/// truth as [`endpoint`] and [`model`]: a fresh settings value beats a stale +/// bootstrap env, env stays the fallback, and the canonical resolver keeps +/// ownership of parsing plus the protected OpenAI default. +pub fn provider(lane: LlmMode) -> ProviderKind { + provider_with_settings(lane, &UserSettings::load()) +} + +fn provider_with_settings(lane: LlmMode, settings: &UserSettings) -> ProviderKind { + let lane_setting = match lane { + // No persisted formatting-provider setting exists yet; env remains the + // only formatting-lane source. + LlmMode::Formatting => None, + LlmMode::Assistive => settings.llm_assistive_provider.clone(), + }; + non_empty_option(lane_setting) + .and_then(|raw| ProviderKind::from_str(&raw).ok()) + .unwrap_or_else(|| resolve_provider(lane)) +} + +/// Suggested key-optional OpenAI-compatible endpoint (the LibraxisAI public +/// cloud) offered in guidance text when the assistive lane is unconfigured or +/// pointed at a key-requiring cloud without a key. Guidance only — code never +/// silently reroutes traffic here. +pub const SUGGESTED_KEY_OPTIONAL_ENDPOINT: &str = "https://api.libraxis.cloud/v1"; + +const DEFAULT_ANTHROPIC_MESSAGES_ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; +const DEFAULT_ANTHROPIC_FORMATTING_MODEL: &str = "claude-sonnet-4-6"; +const DEFAULT_ANTHROPIC_MODEL: &str = "claude-opus-4-8"; + +/// Everything the agent send path needs to reach the assistive provider, +/// resolved from the same fresh settings → env → Keychain hierarchy as the +/// individual lane resolvers. `api_key: None` means "send without auth +/// headers" — valid for key-optional (self-hosted / LAN) endpoints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssistiveLaneSnapshot { + pub provider: ProviderKind, + pub endpoint: String, + pub model: String, + pub api_key: Option, + /// True when the lane must authenticate with the stored ChatGPT account + /// tokens instead of an API key: OpenAI provider, official (key-requiring) + /// endpoint, no API key anywhere, but "Sign in with ChatGPT" tokens are + /// stored. An explicit API key always wins; account tokens never ride to a + /// non-official endpoint. The send path asks `account_auth` for a fresh + /// bearer per request (auto-refresh), never a frozen token from here. + pub account_auth: bool, +} + +pub fn assistive_snapshot(config: &Config) -> AssistiveLaneSnapshot { + assistive_snapshot_with(config, &UserSettings::load(), keychain::load_key) +} + +fn assistive_snapshot_with( + config: &Config, + settings: &UserSettings, + load_key: impl Fn(&str) -> Option, +) -> AssistiveLaneSnapshot { + let (provider, model) = assistive_identity_with(config, settings); + let key_account = provider.api_key_env_key(); + let endpoint = match provider { + ProviderKind::OpenAiResponses => { + endpoint_with_settings(LlmMode::Assistive, config, settings) + } + ProviderKind::AnthropicMessages => anthropic_messages_endpoint(), + }; + let api_key = secret_with_keychain(key_account, &load_key); + let account_auth = provider == ProviderKind::OpenAiResponses + && api_key.is_none() + && endpoint_requires_api_key(&endpoint) + && secret_with_keychain(account_auth::OPENAI_ACCOUNT_TOKENS_ACCOUNT, &load_key).is_some(); + AssistiveLaneSnapshot { + provider, + endpoint, + model, + api_key, + account_auth, + } +} + +/// Provider identity + wire model for the formatting lane. OpenAI-compatible +/// providers retain the Responses-specific model guard, while Anthropic keeps +/// an explicitly configured Claude model instead of falling through to an +/// unrelated OpenAI default. +pub fn formatting_identity(config: &Config) -> (ProviderKind, String) { + formatting_identity_with(config, &UserSettings::load()) +} + +fn formatting_identity_with(config: &Config, settings: &UserSettings) -> (ProviderKind, String) { + let provider = provider_with_settings(LlmMode::Formatting, settings); + let model = model_for_provider_with_settings(LlmMode::Formatting, provider, config, settings); + (provider, model) +} + +/// Provider identity + wire model for the assistive lane WITHOUT touching the +/// Keychain — safe for hot paths that only label metadata (thread persistence, +/// the vision gate). +pub fn assistive_identity(config: &Config) -> (ProviderKind, String) { + assistive_identity_with(config, &UserSettings::load()) +} + +fn assistive_identity_with(config: &Config, settings: &UserSettings) -> (ProviderKind, String) { + let provider = provider_with_settings(LlmMode::Assistive, settings); + let model = model_for_provider_with_settings(LlmMode::Assistive, provider, config, settings); + (provider, model) +} + +fn anthropic_model_with_settings(lane: LlmMode, settings: &UserSettings) -> String { + let (lane_key, lane_setting, lane_default) = match lane { + LlmMode::Formatting => ( + "LLM_FORMATTING_MODEL", + settings.llm_formatting_model.clone(), + DEFAULT_ANTHROPIC_FORMATTING_MODEL, + ), + LlmMode::Assistive => ( + "LLM_ASSISTIVE_MODEL", + settings.llm_assistive_model.clone(), + DEFAULT_ANTHROPIC_MODEL, + ), + }; + let claude_model = |candidate: String| candidate.starts_with("claude").then_some(candidate); + + non_empty_option(lane_setting) + .and_then(claude_model) + .or_else(|| env_non_empty(lane_key).and_then(claude_model)) + .unwrap_or_else(|| lane_default.to_string()) +} + +/// Ready snapshot of the assistive lane, or the user-facing reason it cannot +/// reach a model. The `Err` string is actionable: it names the lane, the +/// resolved endpoint, and the exact missing piece — never a generic +/// "add an API key". +pub fn assistive_availability(config: &Config) -> Result { + availability_of(assistive_snapshot(config)) +} + +fn availability_of(snapshot: AssistiveLaneSnapshot) -> Result { + if snapshot.api_key.is_some() { + return Ok(snapshot); + } + match snapshot.provider { + ProviderKind::OpenAiResponses if !endpoint_requires_api_key(&snapshot.endpoint) => { + Ok(snapshot) + } + // A signed-in ChatGPT account is a complete credential for the official + // OpenAI endpoint — the agent must work with ONLY that login. + ProviderKind::OpenAiResponses if snapshot.account_auth => Ok(snapshot), + ProviderKind::OpenAiResponses => Err(format!( + "The assistive lane points at {}, which requires an API key, and none is stored \ + (Keychain account LLM_ASSISTIVE_API_KEY). Add a key in Settings, sign in with \ + your ChatGPT account in Settings → Keys, or switch the assistive endpoint in \ + Settings → Engine to a key-optional server such as {}.", + snapshot.endpoint, SUGGESTED_KEY_OPTIONAL_ENDPOINT + )), + ProviderKind::AnthropicMessages if !endpoint_requires_api_key(&snapshot.endpoint) => { + Ok(snapshot) + } + ProviderKind::AnthropicMessages => Err(format!( + "The assistive provider is Anthropic ({}), but no key is stored \ + (Keychain account LLM_ANTHROPIC_API_KEY). Add an Anthropic key in Settings, or \ + switch the assistive provider to an OpenAI-compatible endpoint such as {}.", + snapshot.endpoint, SUGGESTED_KEY_OPTIONAL_ENDPOINT + )), + } +} + +/// Official cloud APIs reject unauthenticated requests outright; every other +/// endpoint (self-hosted, LAN, Libraxis) may be key-optional and gets a clean +/// unauthenticated request instead of a hard refusal at the availability gate. +fn endpoint_requires_api_key(endpoint: &str) -> bool { + let host = endpoint + .split("://") + .nth(1) + .unwrap_or(endpoint) + .split(['/', ':']) + .next() + .unwrap_or_default(); + host.eq_ignore_ascii_case("api.openai.com") || host.eq_ignore_ascii_case("api.anthropic.com") +} + +pub(crate) fn endpoint_for_account(config: &Config, account: &str) -> String { + let settings = UserSettings::load(); + match account { + "LLM_FORMATTING_API_KEY" => endpoint_with_settings(LlmMode::Formatting, config, &settings), + "LLM_ASSISTIVE_API_KEY" => endpoint_with_settings(LlmMode::Assistive, config, &settings), + _ => { + let resolved = non_empty_option(settings.llm_endpoint) + .or_else(|| env_non_empty("LLM_ENDPOINT")) + .or_else(|| non_empty_option(config.llm_endpoint.clone())) + .unwrap_or_else(|| DEFAULT_OPENAI_RESPONSES_ENDPOINT.to_string()); + normalize_openai_responses_endpoint(&resolved) + } + } +} + +pub(crate) fn model_for_account(config: &Config, account: &str) -> String { + let settings = UserSettings::load(); + match account { + "LLM_FORMATTING_API_KEY" => model_with_settings(LlmMode::Formatting, config, &settings), + "LLM_ASSISTIVE_API_KEY" => model_with_settings(LlmMode::Assistive, config, &settings), + _ => { + let openai_model = + |candidate: String| (!candidate.starts_with("claude")).then_some(candidate); + non_empty_option(settings.llm_model) + .and_then(openai_model) + .or_else(|| env_non_empty("LLM_MODEL").and_then(openai_model)) + .unwrap_or_else(|| DEFAULT_LLM_MODEL.to_string()) + } + } +} + +pub fn normalize_openai_responses_endpoint(endpoint: &str) -> String { + normalize_endpoint( + endpoint, + "/v1/responses", + &["/v1/responses", "/v1/chat/completions", "/v1/completions"], + ) +} + +pub(crate) fn normalize_anthropic_messages_endpoint(endpoint: &str) -> String { + normalize_endpoint(endpoint, "/v1/messages", &["/v1/messages", "/v1/responses"]) +} + +pub(crate) fn anthropic_messages_endpoint() -> String { + let endpoint = env_non_empty("LLM_ANTHROPIC_ENDPOINT") + .unwrap_or_else(|| DEFAULT_ANTHROPIC_MESSAGES_ENDPOINT.to_string()); + normalize_anthropic_messages_endpoint(&endpoint) +} + +fn normalize_endpoint(endpoint: &str, canonical_suffix: &str, known_suffixes: &[&str]) -> String { + let mut base = endpoint.trim().trim_end_matches('/').to_string(); + for suffix in known_suffixes { + if base.ends_with(suffix) { + base.truncate(base.len() - suffix.len()); + return format!("{base}{canonical_suffix}"); + } + } + if base.ends_with("/v1") { + base.truncate(base.len() - "/v1".len()); + } + format!("{base}{canonical_suffix}") +} + +fn env_non_empty(key: &str) -> Option { + std::env::var(key).ok().and_then(non_empty) +} + +fn non_empty(value: String) -> Option { + let value = value.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +fn non_empty_option(value: Option) -> Option { + value.and_then(non_empty) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, DEFAULT_ASSISTIVE_MODEL, DEFAULT_FORMATTING_MODEL, UserSettings}; + use crate::llm::provider::{LlmMode, ProviderKind}; + use serial_test::serial; + use tempfile::TempDir; + + #[test] + #[serial] + fn secret_prefers_a_non_empty_env_value() { + let _key = EnvGuard::set("LLM_ASSISTIVE_API_KEY", " env-secret "); + + assert_eq!( + secret_with_keychain("LLM_ASSISTIVE_API_KEY", |_| { + Some("keychain-secret".to_string()) + }), + Some("env-secret".to_string()) + ); + } + + #[test] + #[serial] + fn secret_falls_back_to_keychain_when_env_is_empty_or_unset() { + let empty = EnvGuard::set("LLM_ASSISTIVE_API_KEY", " "); + assert_eq!( + secret_with_keychain("LLM_ASSISTIVE_API_KEY", |_| { + Some(" keychain-secret ".to_string()) + }), + Some("keychain-secret".to_string()) + ); + drop(empty); + + let _unset = EnvGuard::remove("LLM_ASSISTIVE_API_KEY"); + assert_eq!( + secret_with_keychain("LLM_ASSISTIVE_API_KEY", |_| { + Some("keychain-only".to_string()) + }), + Some("keychain-only".to_string()) + ); + } + + #[test] + #[serial] + fn assistive_endpoint_uses_lane_then_shared_then_config_then_default() { + let config = Config { + llm_endpoint: Some("https://config.example/v1".to_string()), + ..Config::default() + }; + let settings = UserSettings::default(); + let lane = EnvGuard::set("LLM_ASSISTIVE_ENDPOINT", "https://lane.example/custom/v1"); + let shared = EnvGuard::set("LLM_ENDPOINT", "https://shared.example/v1"); + + assert_eq!( + endpoint_with_settings(LlmMode::Assistive, &config, &settings), + "https://lane.example/custom/v1/responses" + ); + drop(lane); + let _lane_unset = EnvGuard::remove("LLM_ASSISTIVE_ENDPOINT"); + assert_eq!( + endpoint_with_settings(LlmMode::Assistive, &config, &settings), + "https://shared.example/v1/responses" + ); + drop(shared); + let _shared_unset = EnvGuard::remove("LLM_ENDPOINT"); + assert_eq!( + endpoint_with_settings(LlmMode::Assistive, &config, &settings), + "https://config.example/v1/responses" + ); + + let no_config = Config { + llm_endpoint: None, + ..Config::default() + }; + assert_eq!( + endpoint_with_settings(LlmMode::Assistive, &no_config, &settings), + DEFAULT_OPENAI_RESPONSES_ENDPOINT + ); + } + + #[test] + #[serial] + fn persisted_lane_endpoint_beats_a_stale_bootstrap_env_value() { + let _lane = EnvGuard::set( + "LLM_ASSISTIVE_ENDPOINT", + "https://stale-bootstrap.example/v1", + ); + let _shared = EnvGuard::remove("LLM_ENDPOINT"); + let settings = UserSettings { + llm_assistive_endpoint: Some("https://fresh-settings.example/v1".to_string()), + ..UserSettings::default() + }; + + assert_eq!( + endpoint_with_settings(LlmMode::Assistive, &Config::default(), &settings), + "https://fresh-settings.example/v1/responses" + ); + } + + #[test] + fn responses_endpoint_normalizes_openrouter_and_libraxis_bases() { + assert_eq!( + normalize_openai_responses_endpoint("https://openrouter.ai/api/v1"), + "https://openrouter.ai/api/v1/responses" + ); + assert_eq!( + normalize_openai_responses_endpoint("https://api.libraxis.cloud/v1"), + "https://api.libraxis.cloud/v1/responses" + ); + } + + #[test] + #[serial] + fn lane_models_use_fresh_settings_and_lane_defaults() { + let _lane = EnvGuard::set("LLM_ASSISTIVE_MODEL", "stale-bootstrap-model"); + let _shared = EnvGuard::remove("LLM_MODEL"); + let settings = UserSettings { + llm_assistive_model: Some("fresh-assistive-model".to_string()), + ..UserSettings::default() + }; + + assert_eq!( + model_with_settings(LlmMode::Assistive, &Config::default(), &settings), + "fresh-assistive-model" + ); + assert_eq!( + model_with_settings( + LlmMode::Formatting, + &Config::default(), + &UserSettings::default() + ), + DEFAULT_FORMATTING_MODEL + ); + + let _assistive_unset = EnvGuard::remove("LLM_ASSISTIVE_MODEL"); + assert_eq!( + model_with_settings( + LlmMode::Assistive, + &Config::default(), + &UserSettings::default() + ), + DEFAULT_ASSISTIVE_MODEL + ); + } + + #[test] + #[serial] + fn provider_delegates_to_the_canonical_provider_resolver() { + let _provider = EnvGuard::set("LLM_ASSISTIVE_PROVIDER", "anthropic-messages"); + + assert_eq!( + provider_with_settings(LlmMode::Assistive, &UserSettings::default()), + ProviderKind::AnthropicMessages + ); + } + + #[test] + #[serial] + fn formatting_identity_keeps_a_fresh_claude_model_for_anthropic() { + let _provider = EnvGuard::set("LLM_FORMATTING_PROVIDER", "anthropic-messages"); + let _model = EnvGuard::set("LLM_FORMATTING_MODEL", "claude-stale-bootstrap"); + let settings = UserSettings { + llm_formatting_model: Some("claude-sonnet-4-6".to_string()), + ..UserSettings::default() + }; + + assert_eq!( + formatting_identity_with(&Config::default(), &settings), + ( + ProviderKind::AnthropicMessages, + "claude-sonnet-4-6".to_string() + ) + ); + } + + /// Clear every env var the assistive-lane resolution consults, so the + /// availability tests below are hermetic on any host. + fn lane_env_guards() -> Vec { + vec![ + EnvGuard::remove("LLM_ASSISTIVE_PROVIDER"), + EnvGuard::remove("LLM_ASSISTIVE_ENDPOINT"), + EnvGuard::remove("LLM_ASSISTIVE_MODEL"), + EnvGuard::remove("LLM_ENDPOINT"), + EnvGuard::remove("LLM_MODEL"), + EnvGuard::remove("LLM_ASSISTIVE_API_KEY"), + EnvGuard::remove("LLM_ANTHROPIC_API_KEY"), + EnvGuard::remove("LLM_ANTHROPIC_ENDPOINT"), + EnvGuard::remove(account_auth::OPENAI_ACCOUNT_TOKENS_ACCOUNT), + ] + } + + #[test] + #[serial] + fn signed_in_chatgpt_account_alone_makes_the_official_endpoint_available() { + let _env = lane_env_guards(); + + let snapshot = + assistive_snapshot_with(&Config::default(), &UserSettings::default(), |account| { + (account == account_auth::OPENAI_ACCOUNT_TOKENS_ACCOUNT) + .then(|| r#"{"provider":"openai-responses"}"#.to_string()) + }); + assert!(snapshot.account_auth, "stored tokens must arm account auth"); + assert_eq!(snapshot.api_key, None); + + let ready = availability_of(snapshot).expect("ChatGPT login alone must be enough"); + assert!(ready.account_auth); + } + + #[test] + #[serial] + fn explicit_api_key_wins_over_stored_account_tokens() { + let _env = lane_env_guards(); + + let snapshot = + assistive_snapshot_with(&Config::default(), &UserSettings::default(), |account| { + match account { + "LLM_ASSISTIVE_API_KEY" => Some("kc-secret".to_string()), + account_auth::OPENAI_ACCOUNT_TOKENS_ACCOUNT => { + Some(r#"{"provider":"openai-responses"}"#.to_string()) + } + _ => None, + } + }); + + assert_eq!(snapshot.api_key.as_deref(), Some("kc-secret")); + assert!( + !snapshot.account_auth, + "explicit API key must win over account tokens" + ); + } + + #[test] + #[serial] + fn account_tokens_never_ride_to_a_key_optional_endpoint() { + let _env = lane_env_guards(); + let settings = UserSettings { + llm_assistive_endpoint: Some("https://api.libraxis.cloud/v1".to_string()), + ..UserSettings::default() + }; + + let snapshot = assistive_snapshot_with(&Config::default(), &settings, |account| { + (account == account_auth::OPENAI_ACCOUNT_TOKENS_ACCOUNT) + .then(|| r#"{"provider":"openai-responses"}"#.to_string()) + }); + + assert!( + !snapshot.account_auth, + "account bearer must not leak to non-official endpoints" + ); + // The lane stays available through the key-optional arm, unauthenticated. + let ready = availability_of(snapshot).expect("key-optional endpoint works keyless"); + assert_eq!(ready.api_key, None); + } + + #[test] + #[serial] + fn unconfigured_lane_is_unavailable_with_an_actionable_reason() { + let _env = lane_env_guards(); + + let snapshot = + assistive_snapshot_with(&Config::default(), &UserSettings::default(), |_| None); + let reason = availability_of(snapshot).expect_err("default lane needs a key"); + + assert!( + reason.contains(DEFAULT_OPENAI_RESPONSES_ENDPOINT), + "{reason}" + ); + assert!(reason.contains("LLM_ASSISTIVE_API_KEY"), "{reason}"); + assert!(reason.contains(SUGGESTED_KEY_OPTIONAL_ENDPOINT), "{reason}"); + } + + #[test] + #[serial] + fn key_optional_endpoint_is_available_without_any_api_key() { + let _env = lane_env_guards(); + let settings = UserSettings { + llm_assistive_endpoint: Some("https://api.libraxis.cloud/v1".to_string()), + ..UserSettings::default() + }; + + let snapshot = assistive_snapshot_with(&Config::default(), &settings, |_| None); + let ready = availability_of(snapshot).expect("local-first lane must work keyless"); + + assert_eq!(ready.endpoint, "https://api.libraxis.cloud/v1/responses"); + assert_eq!(ready.api_key, None); + } + + #[test] + #[serial] + fn keychain_only_key_makes_the_official_endpoint_available() { + let _env = lane_env_guards(); + + let snapshot = + assistive_snapshot_with(&Config::default(), &UserSettings::default(), |account| { + (account == "LLM_ASSISTIVE_API_KEY").then(|| "kc-secret".to_string()) + }); + let ready = availability_of(snapshot).expect("keychain key alone must be enough"); + + assert_eq!(ready.api_key.as_deref(), Some("kc-secret")); + assert_eq!(ready.endpoint, DEFAULT_OPENAI_RESPONSES_ENDPOINT); + } + + #[test] + #[serial] + fn anthropic_lane_without_its_key_names_the_anthropic_account() { + let _env = lane_env_guards(); + let settings = UserSettings { + llm_assistive_provider: Some("anthropic-messages".to_string()), + ..UserSettings::default() + }; + + let snapshot = assistive_snapshot_with(&Config::default(), &settings, |_| None); + let reason = availability_of(snapshot).expect_err("anthropic lane requires its key"); + + assert!(reason.contains("LLM_ANTHROPIC_API_KEY"), "{reason}"); + assert!(reason.contains("Anthropic"), "{reason}"); + } + + #[test] + #[serial] + fn self_hosted_anthropic_lane_is_available_without_an_api_key() { + let _env = lane_env_guards(); + let _endpoint = EnvGuard::set("LLM_ANTHROPIC_ENDPOINT", "http://127.0.0.1:8080/v1"); + let settings = UserSettings { + llm_assistive_provider: Some("anthropic-messages".to_string()), + ..UserSettings::default() + }; + + let snapshot = assistive_snapshot_with(&Config::default(), &settings, |_| None); + let ready = availability_of(snapshot).expect("self-hosted Anthropic may be key-optional"); + + assert_eq!(ready.endpoint, "http://127.0.0.1:8080/v1/messages"); + assert_eq!(ready.api_key, None); + } + + #[test] + fn official_api_hosts_require_keys_case_insensitively() { + assert!(endpoint_requires_api_key("https://API.OPENAI.COM/v1")); + assert!(endpoint_requires_api_key( + "https://Api.Anthropic.Com/v1/messages" + )); + assert!(!endpoint_requires_api_key( + "https://openai-compatible.example/v1" + )); + } + + #[test] + #[serial] + fn fresh_settings_endpoint_flips_availability_without_a_restart() { + let _env = lane_env_guards(); + // Stale bootstrap env points at the official cloud; no key anywhere. + let _stale = EnvGuard::set("LLM_ASSISTIVE_ENDPOINT", "https://api.openai.com/v1"); + + let before = + assistive_snapshot_with(&Config::default(), &UserSettings::default(), |_| None); + assert!( + availability_of(before).is_err(), + "official cloud without a key" + ); + + // The user saves a key-optional endpoint in Settings — the very next + // resolution must see it, no restart, env untouched. + let fresh = UserSettings { + llm_assistive_endpoint: Some("https://api.libraxis.cloud/v1".to_string()), + ..UserSettings::default() + }; + let after = availability_of(assistive_snapshot_with(&Config::default(), &fresh, |_| { + None + })) + .expect("fresh settings must flip availability immediately"); + assert_eq!(after.endpoint, "https://api.libraxis.cloud/v1/responses"); + } + + #[test] + #[serial] + fn anthropic_identity_uses_a_claude_model_and_openai_identity_never_does() { + let _env = lane_env_guards(); + + let anthropic = UserSettings { + llm_assistive_provider: Some("anthropic-messages".to_string()), + llm_assistive_model: Some("claude-opus-4-8".to_string()), + ..UserSettings::default() + }; + assert_eq!( + assistive_identity_with(&Config::default(), &anthropic), + ( + ProviderKind::AnthropicMessages, + "claude-opus-4-8".to_string() + ) + ); + + // A leftover claude model id never leaks onto the Responses wire path. + let openai = UserSettings { + llm_assistive_model: Some("claude-opus-4-8".to_string()), + ..UserSettings::default() + }; + assert_eq!( + assistive_identity_with(&Config::default(), &openai), + ( + ProviderKind::OpenAiResponses, + DEFAULT_ASSISTIVE_MODEL.to_string() + ) + ); + } + + #[test] + #[serial] + fn persisted_assistive_provider_beats_a_stale_bootstrap_env_after_reload() { + let data_dir = TempDir::new().expect("isolated data dir"); + let _data_dir = EnvGuard::set( + "CODESCRIBE_DATA_DIR", + data_dir.path().to_string_lossy().as_ref(), + ); + let _provider = EnvGuard::set("LLM_ASSISTIVE_PROVIDER", "openai-responses"); + + UserSettings { + llm_assistive_provider: Some("anthropic-messages".to_string()), + ..Default::default() + } + .save() + .expect("persist assistive provider"); + + assert_eq!( + provider(LlmMode::Assistive), + ProviderKind::AnthropicMessages + ); + } + + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: these process-env tests are serialized with `serial`. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: these process-env tests are serialized with `serial`. + unsafe { std::env::remove_var(key) }; + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.previous.as_deref() { + Some(value) => { + // SAFETY: these process-env tests are serialized with `serial`. + unsafe { std::env::set_var(self.key, value) }; + } + None => { + // SAFETY: these process-env tests are serialized with `serial`. + unsafe { std::env::remove_var(self.key) }; + } + } + } + } +} diff --git a/core/llm/mod.rs b/core/llm/mod.rs index 52efd747..da613e35 100644 --- a/core/llm/mod.rs +++ b/core/llm/mod.rs @@ -2,6 +2,7 @@ pub mod account_auth; pub mod ai_formatting; pub mod client; pub mod key_liveness; +pub mod lane_truth; pub mod model_discovery; pub mod provider; pub mod responses_streaming_manager; diff --git a/core/llm/model_discovery.rs b/core/llm/model_discovery.rs index f0dcdb98..19c28402 100644 --- a/core/llm/model_discovery.rs +++ b/core/llm/model_discovery.rs @@ -16,9 +16,14 @@ use serde::{Deserialize, Serialize}; use tracing::warn; use crate::config::Config; -use crate::config::DEFAULT_OPENAI_RESPONSES_ENDPOINT; -use crate::llm::provider::ProviderKind; - +use crate::llm::lane_truth; +use crate::llm::provider::{LlmMode, ProviderKind}; + +/// 5s client timeout for live /models discovery. +/// P2-08/P2-09: short to keep Settings responsive; no explicit per-call cancel surface +/// (fire-and-forget from UI, last-good cache covers failure). If provider is slow, +/// we degrade to cache rather than hang the picker. Justified as UX bound, not +/// a knob (per charter: no new Settings controls). const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); const CACHE_FILE_NAME: &str = "model_discovery_cache.json"; const ANTHROPIC_MODELS_ENDPOINT: &str = "https://api.anthropic.com/v1/models"; @@ -165,8 +170,8 @@ struct AnthropicModel { /// Discover models for a provider using the already-supported config/key path. /// -/// `Config::load()` is intentionally the first operation: it applies settings, -/// optional `.env`, and Keychain population exactly like the provider runtime. +/// `Config::load()` is intentionally the first operation: it provides the live +/// settings snapshot consumed by `lane_truth` exactly like the provider runtime. /// Missing keys are hard `no_key` failures and do not fall back to stale cache; /// network/http/parse failures return last-good cache when available. pub fn discover_models( @@ -174,7 +179,7 @@ pub fn discover_models( ) -> Result { let config = Config::load(); let key_name = provider.api_key_env_key(); - let api_key = env_non_empty(key_name).ok_or(ModelDiscoveryError::NoKey { + let api_key = lane_truth::secret(key_name).ok_or(ModelDiscoveryError::NoKey { provider, env_key: key_name, })?; @@ -223,10 +228,7 @@ fn fetch_openai_models( api_key: &str, ) -> Result, ModelDiscoveryError> { let provider = ProviderKind::OpenAiResponses; - let endpoint = env_non_empty("LLM_ASSISTIVE_ENDPOINT") - .or_else(|| env_non_empty("LLM_ENDPOINT")) - .or_else(|| config.llm_endpoint.clone()) - .unwrap_or_else(|| DEFAULT_OPENAI_RESPONSES_ENDPOINT.to_string()); + let endpoint = lane_truth::endpoint(LlmMode::Assistive, config); let endpoint = openai_models_endpoint(&endpoint)?; let response = client @@ -486,6 +488,7 @@ fn write_cache( }) } +#[cfg(test)] fn env_non_empty(key: &str) -> Option { std::env::var(key) .ok() @@ -527,7 +530,8 @@ mod tests { .with_body(r#"{"object":"list","data":[{"id":"gpt-live"},{"id":"gpt-other"}]}"#) .create(); - let result = discover_models(ProviderKind::OpenAiResponses).unwrap(); + let result = discover_models(ProviderKind::OpenAiResponses) + .expect("discover_models should succeed in OpenAI test path"); assert_eq!(result.status, ModelDiscoveryStatus::Fresh); assert_eq!( @@ -544,7 +548,8 @@ mod tests { ] ); - let cached = read_cached_models(ProviderKind::OpenAiResponses).unwrap(); + let cached = read_cached_models(ProviderKind::OpenAiResponses) + .expect("read_cached_models should succeed after fresh discovery write"); assert_eq!(cached, result.models); env.keepalive(); } @@ -580,7 +585,8 @@ mod tests { ) .create(); - let result = discover_models(ProviderKind::AnthropicMessages).unwrap(); + let result = discover_models(ProviderKind::AnthropicMessages) + .expect("discover_models should succeed in Anthropic test path"); assert_eq!(result.status, ModelDiscoveryStatus::Fresh); assert_eq!( @@ -615,7 +621,8 @@ mod tests { ); let _mock = server.mock("GET", "/v1/models").expect(0).create(); - let err = discover_models(ProviderKind::AnthropicMessages).unwrap_err(); + let err = discover_models(ProviderKind::AnthropicMessages) + .expect_err("discover_models should fail without key in this Anthropic error test"); assert_eq!(err.code(), "no_key"); assert_eq!(err.provider(), ProviderKind::AnthropicMessages); @@ -638,7 +645,8 @@ mod tests { .with_status(200) .with_body(r#"{"data":[{"id":"gpt-cached"}]}"#) .create(); - let fresh = discover_models(ProviderKind::OpenAiResponses).unwrap(); + let fresh = discover_models(ProviderKind::OpenAiResponses) + .expect("discover_models should succeed for cache freshness test"); assert_eq!(fresh.status, ModelDiscoveryStatus::Fresh); let _fail = server @@ -646,7 +654,8 @@ mod tests { .with_status(503) .with_body("temporarily unavailable") .create(); - let cached = discover_models(ProviderKind::OpenAiResponses).unwrap(); + let cached = discover_models(ProviderKind::OpenAiResponses) + .expect("discover_models should return cached result without network"); assert_eq!( cached.status, diff --git a/core/llm/provider.rs b/core/llm/provider.rs index dffa340a..eb75e1e5 100644 --- a/core/llm/provider.rs +++ b/core/llm/provider.rs @@ -682,6 +682,10 @@ mod tests { let prev_issuer = std::env::var(OPENAI_ISSUER_ENV).ok(); let prev_disable = std::env::var("CODESCRIBE_DISABLE_KEYCHAIN").ok(); let prev_tokens = std::env::var(OPENAI_ACCOUNT_TOKENS_ACCOUNT).ok(); + // Isolate the settings store: client_id resolution reads settings.json + // first, and this test must not see an operator-configured client id. + let prev_data_dir = std::env::var("CODESCRIBE_DATA_DIR").ok(); + let scratch_data_dir = tempfile::TempDir::new().expect("scratch settings dir"); let mut server = mockito::Server::new_async().await; let _refresh = server .mock("POST", "/oauth/token") @@ -706,6 +710,7 @@ mod tests { std::env::set_var("LLM_ASSISTIVE_AUTH_MODE", "provider-account"); std::env::set_var(OPENAI_CLIENT_ID_ENV, "client"); std::env::set_var(OPENAI_ISSUER_ENV, server.url()); + std::env::set_var("CODESCRIBE_DATA_DIR", scratch_data_dir.path()); } let expired = AccountTokens { provider: ProviderKind::OpenAiResponses.as_str().to_string(), @@ -734,6 +739,7 @@ mod tests { restore(OPENAI_ISSUER_ENV, prev_issuer); restore("CODESCRIBE_DISABLE_KEYCHAIN", prev_disable); restore(OPENAI_ACCOUNT_TOKENS_ACCOUNT, prev_tokens); + restore("CODESCRIBE_DATA_DIR", prev_data_dir); } fn restore(key: &str, prev: Option) { diff --git a/core/llm/responses_streaming_manager.rs b/core/llm/responses_streaming_manager.rs index 254b134e..d3df4a1c 100644 --- a/core/llm/responses_streaming_manager.rs +++ b/core/llm/responses_streaming_manager.rs @@ -29,10 +29,17 @@ pub struct StreamCallbacks { pub reasoning: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthHeaderMode { + BearerAndApiKey, + BearerOnly, +} + pub struct ResponsesStreamingManager<'a> { client: &'a Client, endpoint: &'a str, api_key: &'a str, + auth_header_mode: AuthHeaderMode, callbacks: StreamCallbacks, initial_response_timeout: Duration, inter_chunk_timeout: Duration, @@ -51,12 +58,18 @@ impl<'a> ResponsesStreamingManager<'a> { client, endpoint, api_key, + auth_header_mode: AuthHeaderMode::BearerAndApiKey, callbacks, initial_response_timeout, inter_chunk_timeout, } } + pub fn with_auth_header_mode(mut self, auth_header_mode: AuthHeaderMode) -> Self { + self.auth_header_mode = auth_header_mode; + self + } + pub async fn stream(&self, request: &T) -> Result { let endpoint_url = validated_endpoint_url(self.endpoint).context("Invalid Responses API endpoint URL")?; @@ -64,6 +77,7 @@ impl<'a> ResponsesStreamingManager<'a> { // nosemgrep: rust.actix.ssrf.reqwest-taint.reqwest-taint -- URL is validated by `validated_endpoint_url`. self.client.post(endpoint_url.clone()), self.api_key, + self.auth_header_mode, ) .header("Content-Type", "application/json") .header("Accept", "text/event-stream") @@ -380,6 +394,7 @@ impl<'a> ResponsesStreamingManager<'a> { let client = self.client.clone(); let endpoint = self.endpoint.to_string(); let api_key = self.api_key.to_string(); + let auth_header_mode = self.auth_header_mode; let callbacks = self.callbacks.clone(); let initial_response_timeout = self.initial_response_timeout; let inter_chunk_timeout = self.inter_chunk_timeout; @@ -389,6 +404,7 @@ impl<'a> ResponsesStreamingManager<'a> { client, endpoint, api_key, + auth_header_mode, callbacks, initial_response_timeout, inter_chunk_timeout, @@ -419,7 +435,7 @@ impl<'a> ResponsesStreamingManager<'a> { ); let response = self.client.get(&resume_url); - let response = apply_auth_headers(response, self.api_key) + let response = apply_auth_headers(response, self.api_key, self.auth_header_mode) .header("Accept", "text/event-stream") .timeout(STREAM_REQUEST_TIMEOUT) .send() @@ -562,6 +578,7 @@ async fn run_agent_stream( client: Client, endpoint: String, api_key: String, + auth_header_mode: AuthHeaderMode, callbacks: StreamCallbacks, initial_response_timeout: Duration, inter_chunk_timeout: Duration, @@ -574,6 +591,7 @@ async fn run_agent_stream( // nosemgrep: rust.actix.ssrf.reqwest-taint.reqwest-taint -- URL is validated by `validated_endpoint_url`. client.post(endpoint_url), &api_key, + auth_header_mode, ) .header("Content-Type", "application/json") .header("Accept", "text/event-stream") @@ -763,10 +781,22 @@ async fn run_agent_stream( Ok(()) } -fn apply_auth_headers(builder: reqwest::RequestBuilder, api_key: &str) -> reqwest::RequestBuilder { - builder - .header("Authorization", format!("Bearer {}", api_key)) - .header("x-api-key", api_key) +fn apply_auth_headers( + builder: reqwest::RequestBuilder, + api_key: &str, + auth_header_mode: AuthHeaderMode, +) -> reqwest::RequestBuilder { + // Key-optional endpoints (self-hosted / LAN lanes) get a clean + // unauthenticated request instead of a bogus `Bearer ` header; a server + // that does require auth then answers with a readable 401. + if api_key.trim().is_empty() { + return builder; + } + let builder = builder.header("Authorization", format!("Bearer {}", api_key)); + match auth_header_mode { + AuthHeaderMode::BearerAndApiKey => builder.header("x-api-key", api_key), + AuthHeaderMode::BearerOnly => builder, + } } fn validated_endpoint_url(endpoint: &str) -> Result { @@ -1455,9 +1485,10 @@ fn extract_output_channels(output: &[StreamOutputItem]) -> (String, Option anyhow::Result; } -/// Post-processor for raw transcripts. -/// -/// Implementations: `StreamPostProcessor` (semantic gate), `LexiconPostProcessor`. -pub trait PostProcessor: Send { - fn process(&mut self, raw: &RawTranscript) -> Option; -} - /// Sink for transcript deltas (UI, IPC, clipboard, etc). /// /// This decouples the streaming pipeline from presentation concerns. @@ -981,18 +961,6 @@ mod tests { assert!(rt.segments.is_empty()); } - // ── PostprocessResult ── - - #[test] - fn postprocess_result_dropped() { - let r = PostprocessResult { - text: String::new(), - dropped: true, - }; - assert!(r.dropped); - assert!(r.text.is_empty()); - } - // ── EngineEvent ── #[test] diff --git a/core/pipeline/streaming/correction.rs b/core/pipeline/streaming/correction.rs index 19246989..dd695975 100644 --- a/core/pipeline/streaming/correction.rs +++ b/core/pipeline/streaming/correction.rs @@ -144,6 +144,63 @@ pub(crate) fn correction_baseline_text( (String::new(), true) } +/// Merge a corrected re-transcription of the correction window back into the +/// full preview baseline. +/// +/// `window_snapshot` is the text mirror of the exact audio slice that was +/// submitted to the Refine lane (taken in lockstep by `schedule_partial_pass`). +/// The correction therefore only has authority over that slice — everything +/// else in `baseline` must survive verbatim. Returns `None` when the snapshot +/// can no longer be anchored inside the baseline (boundary rewrote it, cap +/// trimmed it); the caller must then suppress the correction instead of +/// destroying accumulated text. +pub(crate) fn merge_corrected_window( + baseline: &str, + window_snapshot: &str, + corrected: &str, +) -> Option { + let baseline_trim = baseline.trim(); + let snapshot = window_snapshot.trim(); + if snapshot.is_empty() { + // No text mirror for the corrected audio (e.g. every partial in the + // slice was dropped). Only safe when there is nothing to protect. + return baseline_trim + .is_empty() + .then(|| corrected.trim().to_string()); + } + if baseline_trim == snapshot { + return Some(corrected.trim().to_string()); + } + if let Some(pos) = baseline_trim.rfind(snapshot) { + // The snapshot sits inside the baseline (typically the tail, or the + // middle when newer previews appended while Refine ran) — splice the + // corrected text into its place and keep the rest untouched. + let prefix = baseline_trim[..pos].trim_end(); + let suffix = baseline_trim[pos + snapshot.len()..].trim_start(); + let corrected = corrected.trim(); + let mut merged = String::with_capacity(prefix.len() + corrected.len() + suffix.len() + 2); + merged.push_str(prefix); + if !merged.is_empty() && !corrected.is_empty() { + merged.push(' '); + } + merged.push_str(corrected); + if !suffix.is_empty() { + if !merged.is_empty() { + merged.push(' '); + } + merged.push_str(suffix); + } + return Some(merged); + } + if snapshot.ends_with(baseline_trim) { + // The corrected audio covers a superset of the current baseline (a + // final boundary replaced accumulated text mid-window) — the refine + // pass legitimately supersedes the whole preview. + return Some(corrected.trim().to_string()); + } + None +} + /// Apply final boundary text while preserving a non-empty preview fallback. /// /// Returns `true` when a boundary has usable content after reconciliation. @@ -222,7 +279,7 @@ pub(crate) fn schedule_partial_pass( correction_suffix_snapshot: &mut Option, suffix_snapshot: &str, boundary_rev: u64, - baseline_text: &str, + window_text: &mut String, speech_ms_since_partial: u64, trigger: PartialPassTrigger, partial_telemetry: &mut PartialPassTelemetry, @@ -232,6 +289,11 @@ pub(crate) fn schedule_partial_pass( return false; } let audio = std::mem::take(correction_audio_buf); + // Take the text mirror in lockstep with the audio slice: expected_text + // must describe exactly the audio submitted below, or the receive-side + // merge (`merge_corrected_window`) has no anchor and the correction would + // have to be suppressed. + let baseline_text = std::mem::take(window_text); let audio_duration_s = audio.len() as f32 / output_sample_rate as f32; if let Some(old) = correction_in_flight.take() { @@ -262,7 +324,7 @@ pub(crate) fn schedule_partial_pass( Ok(handle) => { partial_telemetry.record_run(trigger); *correction_expected_boundary_rev = Some(boundary_rev); - *correction_expected_text = Some(baseline_text.to_string()); + *correction_expected_text = Some(baseline_text); *correction_suffix_snapshot = Some(suffix_snapshot.to_string()); *correction_in_flight = Some(handle); true @@ -278,3 +340,86 @@ pub(crate) fn schedule_partial_pass( } } } + +#[cfg(test)] +mod merge_tests { + use super::merge_corrected_window; + + #[test] + fn corrected_tail_splices_onto_untouched_head() { + // Hold-mode regression: 52s of speech, correction re-decodes only the + // trailing window — the head must survive verbatim. + let merged = merge_corrected_window( + "ala ma kota i psa oraz chomika", + "oraz chomika", + "oraz chomika w klatce", + ); + assert_eq!( + merged.as_deref(), + Some("ala ma kota i psa oraz chomika w klatce") + ); + } + + #[test] + fn corrected_middle_keeps_previews_appended_while_refine_ran() { + // Previews kept landing after the audio take: the snapshot sits in the + // middle of the baseline, and the newer tail must survive. + let merged = merge_corrected_window( + "stara głowa środek okna nowy ogon", + "środek okna", + "środek OKNA poprawiony", + ); + assert_eq!( + merged.as_deref(), + Some("stara głowa środek OKNA poprawiony nowy ogon") + ); + } + + #[test] + fn full_window_replacement_when_snapshot_equals_baseline() { + let merged = merge_corrected_window("cały tekst", "cały tekst", "cały tekst lepszy"); + assert_eq!(merged.as_deref(), Some("cały tekst lepszy")); + } + + #[test] + fn superset_window_supersedes_boundary_rewritten_baseline() { + // Final boundary replaced accumulated text with the commit-lane final; + // the refine window still covers partials + final, so it wins whole. + let merged = merge_corrected_window( + "finalny tekst", + "czesc pierwsza finalny tekst", + "część pierwsza finalny tekst", + ); + assert_eq!(merged.as_deref(), Some("część pierwsza finalny tekst")); + } + + #[test] + fn unanchored_snapshot_suppresses_instead_of_destroying() { + let merged = merge_corrected_window( + "zupełnie inny tekst po granicy", + "stare okno którego już nie ma", + "poprawka starego okna", + ); + assert_eq!(merged, None); + } + + #[test] + fn empty_snapshot_only_replaces_empty_baseline() { + assert_eq!( + merge_corrected_window("", "", "świeży tekst").as_deref(), + Some("świeży tekst") + ); + assert_eq!( + merge_corrected_window("istniejący tekst", "", "cokolwiek"), + None + ); + } + + #[test] + fn repeated_phrase_anchors_to_last_occurrence() { + // rfind: the most recent occurrence is the live window, not an echo + // earlier in the session. + let merged = merge_corrected_window("tak tak tak", "tak", "tak jest"); + assert_eq!(merged.as_deref(), Some("tak tak tak jest")); + } +} diff --git a/core/pipeline/streaming/session.rs b/core/pipeline/streaming/session.rs index fb606f4f..89743ad8 100644 --- a/core/pipeline/streaming/session.rs +++ b/core/pipeline/streaming/session.rs @@ -26,7 +26,8 @@ use crate::vad; use super::correction::{ PARTIAL_PASS_TRIGGER_TIMER_MS, PartialPassTelemetry, PartialPassTriggerState, apply_final_boundary_text, classify_partial_trigger, correction_baseline_text, - correction_is_stale, postprocess_correction_with_snapshot, schedule_partial_pass, + correction_is_stale, merge_corrected_window, postprocess_correction_with_snapshot, + schedule_partial_pass, }; use super::pipeline::{PostprocessDrop, TranscriptionPipeline}; use super::quality_gate::{ @@ -331,9 +332,11 @@ pub(crate) async fn transcription_session( let mut utterance_boundary_suffix = String::new(); // Fix D: Speech-window-scoped text and boundary revision for partial-pass stale guard. - // Unlike accumulated_text (cleared on UtteranceFinal), these track all text - // emitted in the current correction window — giving schedule_partial_pass - // a stable baseline that survives utterance boundaries. + // window_text mirrors correction_audio_buf in lockstep: previews append to + // both, and schedule_partial_pass takes (and clears) both together — so + // correction_expected_text always describes exactly the audio slice that a + // Refine pass re-decodes, which is what lets merge_corrected_window splice + // the correction into accumulated_text instead of replacing it wholesale. let mut window_text = String::new(); let mut boundary_rev: u64 = 0; @@ -505,7 +508,7 @@ pub(crate) async fn transcription_session( &mut correction_suffix_snapshot, &suffix_snapshot, boundary_rev, - &window_text, + &mut window_text, partial_trigger_state.silero_speech_ms_since_partial, trigger, &mut partial_telemetry, @@ -775,7 +778,7 @@ pub(crate) async fn transcription_session( &mut correction_suffix_snapshot, &suffix_snapshot, boundary_rev, - &window_text, + &mut window_text, partial_trigger_state.silero_speech_ms_since_partial, trigger, &mut partial_telemetry, @@ -822,30 +825,55 @@ pub(crate) async fn transcription_session( &expected_text, &window_text, ); - if cleaned != previous_text { - preview_rev += 1; - corrections_applied += 1; - debug!( - rev = preview_rev, - previous_len = previous_text.chars().count(), - corrected_len = cleaned.chars().count(), - "BOUNDARY correction" - ); - event_sink.on_event(&EngineEvent::Correction { - rev: preview_rev, - text: cleaned.clone(), - previous_text, - }); - if correction_after_boundary { + // `cleaned` re-decodes only the audio slice taken by + // schedule_partial_pass — splice it into the full + // baseline instead of replacing the whole preview + // (long hold sessions lost everything before the + // correction window otherwise). + let merged = if correction_after_boundary { + Some(cleaned) + } else { + merge_corrected_window( + &previous_text, + &expected_text, + &cleaned, + ) + }; + match merged { + Some(merged) if merged != previous_text => { + preview_rev += 1; + corrections_applied += 1; debug!( - "Applied correction after boundary without reopening utterance-local preview state" + rev = preview_rev, + previous_len = previous_text.chars().count(), + corrected_len = merged.chars().count(), + "BOUNDARY correction" + ); + event_sink.on_event(&EngineEvent::Correction { + rev: preview_rev, + text: merged.clone(), + previous_text, + }); + if correction_after_boundary { + debug!( + "Applied correction after boundary without reopening utterance-local preview state" + ); + } else { + // Update accumulated text so next Preview builds from corrected state. + accumulated_text = merged; + } + } + Some(_) => { + debug!("Skipping correction emit: no text delta after postprocess"); + } + None => { + partial_telemetry.record_stale(); + debug!( + expected_len = expected_text.chars().count(), + baseline_len = previous_text.chars().count(), + "Suppressing correction: window snapshot no longer anchored in preview baseline" ); - } else { - // Update accumulated text so next Preview builds from corrected state. - accumulated_text = cleaned; } - } else { - debug!("Skipping correction emit: no text delta after postprocess"); } } Err(PostprocessDrop::Hallucination) => { @@ -1158,7 +1186,7 @@ pub(crate) async fn transcription_session( &mut correction_suffix_snapshot, &suffix_snapshot, boundary_rev, - &window_text, + &mut window_text, partial_trigger_state.silero_speech_ms_since_partial, trigger, &mut partial_telemetry, diff --git a/core/pipeline/streaming/tests.rs b/core/pipeline/streaming/tests.rs index 6b7b6318..6942308a 100644 --- a/core/pipeline/streaming/tests.rs +++ b/core/pipeline/streaming/tests.rs @@ -1075,6 +1075,7 @@ async fn test_schedule_partial_pass_coalesces_under_async_scheduler_pressure() { let mut partial_telemetry = PartialPassTelemetry::default(); let mut first_audio = vec![21.0]; + let mut first_window = String::from("draft-a"); assert!(schedule_partial_pass( &scheduler, 16_000, @@ -1086,7 +1087,7 @@ async fn test_schedule_partial_pass_coalesces_under_async_scheduler_pressure() { &mut correction_suffix_snapshot, "suffix-a", 7, - "draft-a", + &mut first_window, PARTIAL_PASS_TRIGGER_SILERO_SPEECH_MS, PartialPassTrigger::Timer, &mut partial_telemetry, @@ -1096,6 +1097,10 @@ async fn test_schedule_partial_pass_coalesces_under_async_scheduler_pressure() { first_audio.is_empty(), "correction audio buffer should be consumed on schedule" ); + assert!( + first_window.is_empty(), + "window text mirror should be taken in lockstep with the audio buffer" + ); assert_eq!( correction_expected_boundary_rev, Some(7), @@ -1117,6 +1122,7 @@ async fn test_schedule_partial_pass_coalesces_under_async_scheduler_pressure() { .id(); let mut second_audio = vec![22.0]; + let mut second_window = String::from("draft-b"); assert!(schedule_partial_pass( &scheduler, 16_000, @@ -1128,7 +1134,7 @@ async fn test_schedule_partial_pass_coalesces_under_async_scheduler_pressure() { &mut correction_suffix_snapshot, "suffix-b", 8, - "draft-b", + &mut second_window, PARTIAL_PASS_TRIGGER_SILERO_SPEECH_MS, PartialPassTrigger::Speech, &mut partial_telemetry, @@ -1269,6 +1275,7 @@ async fn test_schedule_partial_pass_repeated_coalescing_under_async_pressure() { let expected_text = format!("draft-{index}"); let expected_suffix = format!("suffix-{index}"); let mut audio = vec![marker]; + let mut window = expected_text.clone(); assert!(schedule_partial_pass( &scheduler, @@ -1281,7 +1288,7 @@ async fn test_schedule_partial_pass_repeated_coalescing_under_async_pressure() { &mut correction_suffix_snapshot, &expected_suffix, expected_rev, - &expected_text, + &mut window, PARTIAL_PASS_TRIGGER_SILERO_SPEECH_MS + index as u64, trigger, &mut partial_telemetry, diff --git a/core/quality/mod.rs b/core/quality/mod.rs index 02025a7f..0c0245f6 100644 --- a/core/quality/mod.rs +++ b/core/quality/mod.rs @@ -1,2 +1,3 @@ +pub mod overlay_quality; pub mod qube_daemon; pub mod qube_report; diff --git a/core/quality/overlay_quality.rs b/core/quality/overlay_quality.rs new file mode 100644 index 00000000..26efc801 --- /dev/null +++ b/core/quality/overlay_quality.rs @@ -0,0 +1,437 @@ +//! P0-D Quality loop MVP: capture user corrections from overlay FINAL transcript edits. +//! Writes quality records (raw, delivered, edited) to ~/.codescribe/quality/*.jsonl +//! Extracts lexicon candidates (delivered→edited) and appends safe rules to the +//! custom lexicon (lexicon.custom.jsonl) that StreamPostProcessor / apply_lexicon already consumes. +//! +//! Privacy: purely local, no network, no secrets, no audio. +//! No new Settings knobs (defaults on; VoiceLab UI later). + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::config::Config; + +/// Quality record for one user correction on the overlay. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QualityRecord { + /// Unix millis at capture (Copy/Send/Close on edited FINAL). + pub timestamp_ms: u64, + /// Session hint if available (future). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// "overlay" (or "dictation" in future waves). + pub mode: String, + /// Model / engine id if known (e.g. whisper-large, or lane). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Raw STT text (best effort; overlay MVP may pass delivered here too). + #[serde(default)] + pub raw_text: String, + /// The authoritative delivered text shown in FINAL before user edit. + pub delivered_text: String, + /// The text after user manual edit in the overlay TextEditor. + pub edited_text: String, + /// Freeform meta (e.g. {"source":"overlay-final", "action":"copy"}). + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub meta: serde_json::Value, +} + +impl QualityRecord { + pub fn new( + raw_text: String, + delivered_text: String, + edited_text: String, + mode: &str, + model: Option, + action: Option<&str>, + ) -> Self { + let timestamp_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let meta = match action { + Some(a) => serde_json::json!({ "source": "overlay-final", "action": a }), + None => serde_json::json!({ "source": "overlay-final" }), + }; + QualityRecord { + timestamp_ms, + session_id: None, + mode: mode.to_string(), + model, + raw_text, + delivered_text, + edited_text, + meta, + } + } +} + +/// Directory for quality records: ~/.codescribe/quality/ +pub fn quality_dir() -> PathBuf { + Config::config_dir().join("quality") +} + +/// Append a quality record as one JSONL line. Creates dir and file as needed. +/// Uses a single rolling file for MVP (corrections.jsonl); per-session files are future. +pub fn save_quality_record(record: &QualityRecord) -> Result { + let dir = quality_dir(); + fs::create_dir_all(&dir).with_context(|| format!("create quality dir {}", dir.display()))?; + let path = dir.join("corrections.jsonl"); + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("open quality log {}", path.display()))?; + let line = serde_json::to_string(record).context("serialize quality record")?; + writeln!(f, "{}", line).context("write quality record line")?; + Ok(path) +} + +/// Extract candidate lexicon pairs (variant -> canonical) from a user correction. +/// MVP: whole-string when they differ and are short/sensible. +/// Future: token/phrase alignment + confidence threshold. +/// The returned pairs are (misheard_variant, correct_canonical) so that +/// custom lexicon term=canonical, mispronunciations includes variant. +pub fn extract_lexicon_candidates(delivered: &str, edited: &str) -> Vec<(String, String)> { + let d = delivered.trim(); + let e = edited.trim(); + if d.is_empty() || e.is_empty() { + return vec![]; + } + if d.eq_ignore_ascii_case(e) { + return vec![]; + } + // Basic sanity: not too long for a phrase rule, not pure punctuation. + if d.len() > 120 || e.len() > 120 { + return vec![]; + } + let has_alpha_d = d.chars().any(|c| c.is_alphabetic()); + let has_alpha_e = e.chars().any(|c| c.is_alphabetic()); + if !has_alpha_d || !has_alpha_e { + return vec![]; + } + // For the P0-D test case and real edits: treat the differing strings as one phrase pair. + // PostProcessor will treat it as whole-word-ish via its regex builders. + vec![(d.to_string(), e.to_string())] +} + +/// Sensible threshold for accepting a candidate into custom lexicon. +/// MVP: length + not identical after norm; caller can tighten. +pub fn is_sensible_lexicon_candidate(variant: &str, canonical: &str) -> bool { + let v = variant.trim(); + let c = canonical.trim(); + if v.is_empty() || c.is_empty() || v.eq_ignore_ascii_case(c) { + return false; + } + // Avoid giant blobs or single-char flips that are likely typos in the wrong direction. + if v.len() < 2 || c.len() < 2 || v.len() > 80 || c.len() > 80 { + return false; + } + true +} + +/// Append a correction-derived rule to the user's custom lexicon file. +/// Format matches what load_legacy_jsonl_with_terms expects for "custom": +/// {"term": "", "mispronunciations": [""]} +/// The loader already skips unsafe plain-word regressions for custom. +pub fn append_correction_to_custom_lexicon(variant: &str, canonical: &str) -> Result<()> { + if !is_sensible_lexicon_candidate(variant, canonical) { + return Ok(()); + } + let path = Config::config_dir().join("lexicon.custom.jsonl"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).ok(); + } + let entry = serde_json::json!({ + "term": canonical, + "mispronunciations": [variant] + }); + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("open custom lexicon {}", path.display()))?; + let line = serde_json::to_string(&entry).context("serialize lexicon entry")?; + writeln!(f, "{}", line).context("append lexicon rule")?; + Ok(()) +} + +/// High-level: save the quality record for the overlay edit AND feed lexicon candidates. +/// Called from bridge (and tests). Returns the quality file path on success. +/// `action` (e.g. "copy", "send", "close") is carried into meta for future analytics (P2-03 triage over-correct). +pub fn commit_overlay_correction( + raw_text: &str, + delivered_text: &str, + edited_text: &str, + mode: &str, + model: Option, + action: Option<&str>, +) -> Result { + let record = QualityRecord::new( + raw_text.to_string(), + delivered_text.to_string(), + edited_text.to_string(), + mode, + model, + action, + ); + let qpath = save_quality_record(&record)?; + + // Extract + append candidates (safe only). + for (variant, canonical) in extract_lexicon_candidates(delivered_text, edited_text) { + if is_sensible_lexicon_candidate(&variant, &canonical) { + // Best-effort; do not fail the whole commit on lexicon append. + if let Err(e) = append_correction_to_custom_lexicon(&variant, &canonical) { + tracing::warn!( + "quality: failed to append lexicon candidate {} -> {}: {}", + variant, + canonical, + e + ); + } else { + tracing::info!( + "quality: added lexicon candidate {} -> {}", + variant, + canonical + ); + } + } + } + Ok(qpath) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::ffi::OsString; + + struct EnvRestore { + key: &'static str, + previous: Option, + } + + impl EnvRestore { + fn capture(key: &'static str) -> Self { + Self { + key, + previous: std::env::var_os(key), + } + } + } + + impl Drop for EnvRestore { + fn drop(&mut self) { + match &self.previous { + Some(value) => unsafe { std::env::set_var(self.key, value) }, + None => unsafe { std::env::remove_var(self.key) }, + } + } + } + + #[test] + fn test_extract_candidates_basic() { + let cands = extract_lexicon_candidates("uni agentka", "Junie"); + assert_eq!(cands.len(), 1); + assert_eq!(cands[0], ("uni agentka".to_string(), "Junie".to_string())); + } + + #[test] + fn test_extract_ignores_identical_and_empty() { + assert!(extract_lexicon_candidates("foo", "foo").is_empty()); + assert!(extract_lexicon_candidates("", "bar").is_empty()); + assert!(extract_lexicon_candidates("bar", "").is_empty()); + } + + #[test] + fn test_sensible_rejects_too_short_or_long() { + assert!(!is_sensible_lexicon_candidate("a", "b")); + assert!(!is_sensible_lexicon_candidate(&"x".repeat(100), "y")); + } + + #[test] + #[serial] + fn test_commit_writes_record_and_does_not_panic_on_lexicon() { + // P1-02: MUST honor CODESCRIBE_DATA_DIR (the single existing override path, + // verified via loct find --literal) for hermetic test isolation. No twin + // path logic. Prove by writing under temp and asserting the returned path. + let temp_dir = tempfile::tempdir().expect("temp data dir for isolation"); + let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + + // Canonicalize for macOS reality: config_dir() does .canonicalize() on + // CODESCRIBE_DATA_DIR (see loader.rs), turning /var/folders into + // /private/var/folders. Use the same form for the starts_with proof. + let temp_root = temp_dir + .path() + .canonicalize() + .unwrap_or_else(|_| temp_dir.path().to_path_buf()); + + // SAFETY: test-only, #[serial] guarantees exclusive access; mirrors EnvGuard/EnvRestore + // pattern used elsewhere (e.g. lane_truth, stream_postprocess). Process-env mutation + // is the documented way to drive CODESCRIBE_DATA_DIR for hermetic isolation tests. + unsafe { + std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + } + + let p = commit_overlay_correction( + "raw raw", + "uni agentka here", + "Junie here", + "overlay", + Some("whisper".into()), + Some("test"), + ) + .expect("commit should succeed"); + assert!(p.ends_with("corrections.jsonl")); + // Proof of isolation: the quality file landed under the overridden DATA_DIR + // (config_dir + quality_dir respect it; real ~/.codescribe untouched). + assert!( + p.starts_with(&temp_root), + "quality record path must be under the CODESCRIBE_DATA_DIR temp for isolation (got: {})", + p.display() + ); + + // D-02 depth + action/raw wiring (over-correct): deserialize last record and + // assert full fields (raw_text, delivered, edited, meta.action, source). + // Proves the heart of quality loop (capture + meta + lexicon feed) without + // relying on string contains. + let written = std::fs::read_to_string(&p).expect("read written quality log"); + let last_line = written.lines().last().expect("at least one jsonl line"); + let rec: QualityRecord = + serde_json::from_str(last_line).expect("parse quality record jsonl"); + assert_eq!( + rec.raw_text, "raw raw", + "D-05/D-02: raw_text must be wired and recorded" + ); + assert_eq!(rec.delivered_text, "uni agentka here"); + assert_eq!(rec.edited_text, "Junie here"); + assert_eq!(rec.mode, "overlay"); + let meta_action = rec.meta.get("action").and_then(|v| v.as_str()); + assert_eq!( + meta_action, + Some("test"), + "P2-03/P2-07: action must flow to meta" + ); + let meta_source = rec.meta.get("source").and_then(|v| v.as_str()); + assert_eq!(meta_source, Some("overlay-final")); + } + + // Over-correct depth for D-02 / P1-02 / P2-03: explicit action variants + distinct raw_text + // prove the quality heart (record + meta + raw for lexicon v2) under isolation. + #[test] + #[serial] + fn test_commit_records_distinct_raw_and_various_actions() { + let temp_dir = tempfile::tempdir().expect("temp data dir for isolation"); + let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let temp_root = temp_dir + .path() + .canonicalize() + .unwrap_or_else(|_| temp_dir.path().to_path_buf()); + // SAFETY: test-only, #[serial] + EnvRestore; mirrors other env guards. + unsafe { + std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + } + + // "copy" action + distinct raw (real STT vs post-delivered) + let p = commit_overlay_correction( + "raw stt with selection here", + "delivered with selection", + "edited with selection", + "overlay", + Some("whisper-large".into()), + Some("copy"), + ) + .expect("commit copy action"); + assert!( + p.starts_with(&temp_root), + "isolation: must land under temp DATA_DIR" + ); + + let written = std::fs::read_to_string(&p).expect("read quality log"); + let last_line = written.lines().last().expect("record line"); + let rec: QualityRecord = serde_json::from_str(last_line).expect("parse"); + assert_eq!( + rec.raw_text, "raw stt with selection here", + "D-05: distinct raw wired" + ); + assert_eq!(rec.delivered_text, "delivered with selection"); + assert_eq!( + rec.meta.get("action").and_then(|v| v.as_str()), + Some("copy") + ); + assert_eq!( + rec.meta.get("source").and_then(|v| v.as_str()), + Some("overlay-final") + ); + + // "send" action variant + let p2 = commit_overlay_correction( + "another raw", + "delivered2", + "edited2", + "overlay", + None, + Some("send"), + ) + .expect("commit send"); + assert!(p2.starts_with(&temp_root)); + let last2: QualityRecord = serde_json::from_str( + std::fs::read_to_string(&p2) + .unwrap() + .lines() + .last() + .unwrap(), + ) + .unwrap(); + assert_eq!( + last2.meta.get("action").and_then(|v| v.as_str()), + Some("send") + ); + } + + #[test] + #[serial] + fn test_commit_long_edit_records_quality_but_no_lexicon_candidate() { + let temp_dir = tempfile::tempdir().expect("temp"); + let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let temp_root = temp_dir + .path() + .canonicalize() + .unwrap_or_else(|_| temp_dir.path().to_path_buf()); + unsafe { + std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + } + + let long = "x".repeat(150); + let p = commit_overlay_correction( + &long, + "delivered long", + &long, + "overlay", + None, + Some("close"), + ) + .expect("quality record even for long (lexicon guard separate)"); + assert!(p.starts_with(&temp_root)); + + // lexicon candidate rejected by length (is_sensible + extract guard) + // Use the same config resolution the append fn uses (honors DATA_DIR via test guard). + let lex_path = crate::config::Config::config_dir().join("lexicon.custom.jsonl"); + let before = std::fs::read_to_string(&lex_path) + .unwrap_or_default() + .lines() + .count(); + // call extract directly to prove + assert!(extract_lexicon_candidates(&long, &long).is_empty()); + let after = std::fs::read_to_string(&lex_path) + .unwrap_or_default() + .lines() + .count(); + assert_eq!(before, after, "no lexicon growth for long edit"); + } +} diff --git a/core/quality/qube_report.rs b/core/quality/qube_report.rs index e3cf78b6..b45a4236 100644 --- a/core/quality/qube_report.rs +++ b/core/quality/qube_report.rs @@ -16,6 +16,8 @@ use crate::ai_formatting; use crate::audio::load_audio_file; use crate::client; use crate::config::Config; +use crate::llm::lane_truth; +use crate::llm::provider::{LlmMode, ProviderKind}; use crate::pipeline::contracts::RawTranscript; use crate::safe_path::{ safe_canonicalize_bounded, safe_copy_bounded, safe_prepare_path, safe_read_to_string_bounded, @@ -1430,6 +1432,14 @@ fn snapshot_environment( local_transcription: LocalTranscriptionMode, ) -> ReportEnvironment { let config = Config::load(); + let (formatting_provider, formatting_model) = lane_truth::formatting_identity(&config); + let formatting_endpoint = lane_truth::endpoint(LlmMode::Formatting, &config); + let formatting_endpoint = match formatting_provider { + ProviderKind::OpenAiResponses => formatting_endpoint, + ProviderKind::AnthropicMessages => { + lane_truth::normalize_anthropic_messages_endpoint(&formatting_endpoint) + } + }; ReportEnvironment { stt_endpoint: config.stt_endpoint.clone(), stt_api_key_present: config @@ -1437,11 +1447,9 @@ fn snapshot_environment( .as_ref() .map(|v| !v.trim().is_empty()) .unwrap_or(false), - llm_formatting_endpoint: std::env::var("LLM_FORMATTING_ENDPOINT").ok(), - llm_formatting_model: std::env::var("LLM_FORMATTING_MODEL").ok(), - llm_formatting_key_present: std::env::var("LLM_FORMATTING_API_KEY") - .map(|v| !v.trim().is_empty()) - .unwrap_or(false), + llm_formatting_endpoint: Some(formatting_endpoint), + llm_formatting_model: Some(formatting_model), + llm_formatting_key_present: lane_truth::secret("LLM_FORMATTING_API_KEY").is_some(), local_model: Some(config.local_model), whisper_language: Some(config.whisper_language.as_str().to_string()), metrics_reference: metrics_reference.as_str().to_string(), diff --git a/core/state/history.rs b/core/state/history.rs index 273877c3..e51a9150 100644 --- a/core/state/history.rs +++ b/core/state/history.rs @@ -429,6 +429,24 @@ pub fn recent_entries(limit: usize) -> Vec { b_time.cmp(&a_time) }); + // Collapse same-save families: a raw draft and its post-processed final can + // land within the same second as `base.txt` + `base_1.txt` (see the collision + // suffix in save_entry_with_timestamp_and_slug). History surfaces one row per + // dictation — newest file of each (dir, base, kind) family wins. + let mut seen_families: HashSet<(PathBuf, String, &'static str)> = HashSet::new(); + let files: Vec = files + .into_iter() + .filter(|path| { + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(""); + let (kind, base, _) = split_kind_and_index(stem, TranscriptKind::Raw); + let dir = path.parent().map(Path::to_path_buf).unwrap_or_default(); + seen_families.insert((dir, base, kind.suffix())) + }) + .collect(); + // Take the requested limit and create entries for path in files.into_iter().take(limit) { let timestamp = fs::metadata(&path) @@ -910,6 +928,63 @@ mod tests { let _ = fs::remove_file(&entry.path); } + #[test] + #[serial] + fn test_recent_entries_collapse_same_second_save_family() { + let tmp = TempDir::new().expect("tempdir"); + let _guard = EnvGuard::set_to_temp_dir("CODESCRIBE_DATA_DIR", &tmp); + + // Raw draft and its post-processed final land in the same second with + // the same slug → the second save collides into `…_raw_1.txt`. + let now = Local::now(); + let slug_hint = Some("event zwykly nie"); + let draft = save_entry_with_timestamp_and_slug( + "event zwykły nie może", + Some(now), + TranscriptKind::Raw, + slug_hint, + ); + let finalized = save_entry_with_timestamp_and_slug( + "Event zwykły nie może być agentowym.", + Some(now), + TranscriptKind::Raw, + slug_hint, + ); + assert_ne!(draft.path, finalized.path, "collision suffix expected"); + + // A different kind at the same second is a separate history row. + let interpretation = save_entry_with_timestamp_and_slug( + "Odpowiedź asystenta.", + Some(now), + TranscriptKind::AssistantInterpretation, + slug_hint, + ); + + let entries = recent_entries(10); + let raw_rows: Vec<_> = entries + .iter() + .filter(|e| e.kind == TranscriptKind::Raw) + .collect(); + assert_eq!( + raw_rows.len(), + 1, + "same-family raw draft + final must collapse to one row: {:?}", + entries.iter().map(|e| &e.path).collect::>() + ); + assert_eq!( + raw_rows[0].path, finalized.path, + "the newest (post-processed) file wins the family" + ); + assert!( + entries.iter().any(|e| e.path == interpretation.path), + "different kind survives as its own row" + ); + + for path in [&draft.path, &finalized.path, &interpretation.path] { + let _ = fs::remove_file(path); + } + } + #[test] #[serial] fn test_entry_label() { diff --git a/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md b/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md index 7f3cd868..f45d5f94 100644 --- a/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md +++ b/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md @@ -105,6 +105,7 @@ flowchart TB ### Layer specifications **Layer 0 — Apple Live (primary live engine).** + - Activated by default through `CODESCRIBE_STT_ENGINE=auto` on supported macOS, or explicitly via `CODESCRIBE_STT_ENGINE=apple`. - Streams partial recognition tokens to the overlay as fast as `SFSpeechRecognitionTask` emits them. @@ -116,6 +117,7 @@ flowchart TB by Layer 1, never by Layer 0 retrying. **Layer 1 — Whisper Tail Patch (background supplement).** + - Triggered by `chunker` utterance boundary (Silero-driven) — same boundary Apple's `utterance.committed` event would land on. - New module `core/stt/tail_patcher/` runs Whisper (Candle / mlx-audio / OpenAI cloud — configurable) @@ -131,6 +133,7 @@ flowchart TB `EngineEvent::Annotation { kind: TailPatchSkipped, reason }` and leave Layer 0 output unchanged. **Layer 2 — Lexicon + Small LLM Polish.** + - Runs after Layer 1 settles for a given utterance (small debounce, e.g. 300 ms). - Two sub-passes: - **Lexicon** — applies the project lexicon (compatible with `stt-engine`'s 12597-rule @@ -145,6 +148,7 @@ flowchart TB - Both passes emit `EngineEvent::ReplaceRange` events with the same invariant as Layer 1. **Layer 3 — Silero Paralingual Monitor.** + - Always-on alongside Layers 0–2; uses the same Silero stream the chunker already drives. - Two responsibilities: - **Pause annotation.** When `discriminator` sees a within-utterance pause longer than @@ -158,6 +162,7 @@ flowchart TB labels come from a follow-up dataset (operator's screencast corpus is a starting point). **Layer 4 — Final BAM (session-end contextual pass).** + - Triggered on `stop()` / hold-release / toggle-stop. - Runs against the **full session WAV** (recorder always tees one to disk) and the **already-shown text buffer** (the union of all Layer 0–3 events). @@ -175,12 +180,13 @@ These are non-negotiable. Any layer that violates them is broken and ships back landing on `main`. 1. **NEVER REWRITE FROM ZERO.** + - No layer is allowed to `set_text(new_full_buffer)` after the user has seen anything. - The only legal mutations are `Append`, `ReplaceRange { start, end, text }`, `InsertAnnotation { position, text }`, and `Backspace { count }` (legacy `TranscriptDelta`). - Rationale: the user invested attention in what they read. Wiping and retyping breaks trust, breaks copy-paste mid-flow, and breaks the "petarda" promise that made them adopt - Codescribe instead of Apple Dictation alone. Operator's words: *"tracimy twarz"*. + Codescribe instead of Apple Dictation alone. Operator's words: _"tracimy twarz"_. 2. **Layer 0 owns the first commit.** No later layer is allowed to render text before Layer 0 has committed an utterance. If Layer 0 is unavailable (no Apple Speech permission, no macOS Speech @@ -241,17 +247,17 @@ they simply show Layer 0 output. ## What is shipped today, what is missing -| Capability | Today | Needed for layered model | -| --- | --- | --- | -| Apple Speech adapter | ✅ 522 LOC, `CODESCRIBE_STT_ENGINE=auto` default / `apple` override | Settings toggle | -| Whisper adapter | ✅ embedded turbo / runtime fallback | Background tail-patcher entry point | -| Full WAV tee | ✅ always written | Lifecycle hook for Layer 4 | -| Silero VAD + discriminator | ✅ live | Paralingual classifier head (Layer 3) | -| `EngineEvent` vocabulary | ✅ Preview/Correction/UtteranceFinal | + `ReplaceRange`, `InsertAnnotation`, `SessionFinalised` | -| Lexicon | ⚠ libraxis-side (`stt-engine`) | Local lexicon module callable from controller | -| Small LLM call surface | ✅ libraxis / mlx-batch reachable | Inline polish wrapper with utterance-bounded prompts | -| Overlay incremental render | ✅ append/backspace via `TranscriptDelta` | Add `ReplaceRange` + `InsertAnnotation` paths | -| Orchestrator | ❌ | New `app/controller/layered_orchestrator.rs` | +| Capability | Today | Needed for layered model | +| -------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------- | +| Apple Speech adapter | ✅ 522 LOC, `CODESCRIBE_STT_ENGINE=auto` default / `apple` override | Settings toggle | +| Whisper adapter | ✅ embedded turbo / runtime fallback | Background tail-patcher entry point | +| Full WAV tee | ✅ always written | Lifecycle hook for Layer 4 | +| Silero VAD + discriminator | ✅ live | Paralingual classifier head (Layer 3) | +| `EngineEvent` vocabulary | ✅ Preview/Correction/UtteranceFinal | + `ReplaceRange`, `InsertAnnotation`, `SessionFinalised` | +| Lexicon | ⚠ libraxis-side (`stt-engine`) | Local lexicon module callable from controller | +| Small LLM call surface | ✅ libraxis / mlx-batch reachable | Inline polish wrapper with utterance-bounded prompts | +| Overlay incremental render | ✅ append/backspace via `TranscriptDelta` | Add `ReplaceRange` + `InsertAnnotation` paths | +| Orchestrator | ❌ | New `app/controller/layered_orchestrator.rs` | **Scope estimate:** ~500–800 LOC net across `app/controller/`, `core/stt/`, `core/pipeline/contracts.rs`, `core/vad/`. The shape is already in the codebase — this is glue and one new contract event family. @@ -262,6 +268,7 @@ Four phases. Each ships as an independent machete cut behind a feature flag (`CODESCRIBE_LAYERED_TRANSCRIPTION=phase{1,2,3,4}`), defaulting to OFF until phase 4 lands. **Phase 1 — Layer 0 + Layer 1 (Apple primary + Whisper tail patch).** + - Wire Apple as default engine when available; Whisper-as-primary remains the fallback. - New `core/stt/tail_patcher/` module + `EngineEvent::ReplaceRange { source: TailPatch }`. - Overlay gains `ReplaceRange` render path (visible "cursor walks back, patch lands"). @@ -269,6 +276,7 @@ Four phases. Each ships as an independent machete cut behind a feature flag "Bytów to New York" + "framework Vibecrafted" + "Hugging Face" within ~1 s of utterance end. **Phase 2 — Layer 2 (Lexicon + Small LLM polish).** + - Local lexicon module (subset of libraxis 12597 rules, configurable). - Inline LLM call (`Bielik-11B` default; configurable endpoint). - `EngineEvent::ReplaceRange { source: Lexicon | InlineLlm }`. @@ -276,12 +284,14 @@ Four phases. Each ships as an independent machete cut behind a feature flag enumeration gets a list or paragraph break inserted within the same utterance window. **Phase 3 — Layer 3 (Silero paralingual monitor).** + - Pause-to-`…` first (cheap, deterministic). - Non-speech classifier follows; ships with binary speech-vs-noise, label set grows as the classifier improves. MVP labels gated behind confidence floor. - `EngineEvent::InsertAnnotation`. **Phase 4 — Layer 4 (Final BAM).** + - New `core/pipeline/final_bam.rs` runs on `stop()` against full WAV + shown buffer. - Emits the final batch of `ReplaceRange` events, then `SessionFinalised`. - Operator's stop trigger (`make install-app` / hotkey release) remains the human control surface. @@ -289,8 +299,8 @@ Four phases. Each ships as an independent machete cut behind a feature flag ## Non-goals - **Not building a live cursor-paste into arbitrary text fields.** Operator explicit: - *"ja nie muszę mieć wklejane w karetkę, bo pewnie nikt nie zrobi mi backspace + podmianka live - niezależnie gdzie ta karetka stoi"*. The whole theatre runs **inside the overlay**. Final paste + _"ja nie muszę mieć wklejane w karetkę, bo pewnie nikt nie zrobi mi backspace + podmianka live + niezależnie gdzie ta karetka stoi"_. The whole theatre runs **inside the overlay**. Final paste to the active field happens once, at session end, after Layer 4 has committed. - **Not rewriting Whisper from scratch.** This ADR keeps Candle/mlx-audio/OpenAI as interchangeable Layer 1 backends; the choice is configuration, not code. @@ -302,6 +312,7 @@ Four phases. Each ships as an independent machete cut behind a feature flag ## Consequences **Positive.** + - User keeps Apple Dictation's perceived speed and gains Whisper's recall depth in the same flow. - Failures degrade gracefully: Layer 0 down → Whisper-primary; Layer 1 timeout → Layer 0 output stands; Layer 2 unreachable → text stays raw; Layer 3 disabled → no annotations; Layer 4 @@ -313,6 +324,7 @@ Four phases. Each ships as an independent machete cut behind a feature flag - Existing code paths keep working — feature flag means today's users see no change until phase 4. **Negative / costs.** + - Orchestrator adds non-trivial state in `app/controller/` (per-utterance layer status machine). - `ReplaceRange` events change the sink contract; legacy sinks that didn't expect them must be audited (the codebase has 3 main sinks: overlay, IPC broadcast, telemetry — all Option-guarded). @@ -325,6 +337,7 @@ Four phases. Each ships as an independent machete cut behind a feature flag threshold (don't patch if uncertain) is the default safe behaviour. **Operational.** + - Telemetry gains per-layer counters (utterances patched, lexicon hits, LLM calls, annotations inserted, BAM edits). Visible in `/healthz` and Quality dashboard. - Settings gains four toggles (Layer 1, 2, 3, 4) and one engine selector (Apple / Whisper). diff --git a/docs/ADR/2026-05-28-Correction-Continuous-Hands-Off.md b/docs/ADR/2026-05-28-Correction-Continuous-Hands-Off.md index 96208767..b20c724f 100644 --- a/docs/ADR/2026-05-28-Correction-Continuous-Hands-Off.md +++ b/docs/ADR/2026-05-28-Correction-Continuous-Hands-Off.md @@ -7,9 +7,11 @@ ## Kontekst — Dwa odrębne bóle ### Ból nr 1 (pierwotny, najgłębszy) + W trybie **hands-off / toggle** (przeznaczonym od początku do długich, złożonych, wielominutowych wypowiedzi) system **nie buduje jednej ciągłej całości transkryptu**. Zamiast tego: + - Traktuje wypowiedź jako serię niezależnych utterance'ów - Podmienia / nadpisuje fragmenty transkryptu zamiast appendować - W niektórych ścieżkach discarduje pełne audio @@ -17,6 +19,7 @@ Zamiast tego: Efekt: użytkownik, który chce dyktować swobodnie przez 8–15 minut, dostaje pofragmentowany, niestabilny wynik. To jest dokładnie odwrotność pierwotnej intencji tego trybu. ### Ból nr 2 + Nawet gdy pierwszy pass jest "w miarę dobry", późniejsze korekty (Whisper, leksykon, LLM) często przepisują duże fragmenty tekstu, który użytkownik już zobaczył. To łamie zaufanie i poczucie ciągłości ("petarda" znika). Oba problemy istnieją równolegle. Nie da się dobrze rozwiązać drugiego, dopóki pierwszy nie jest pod kontrolą. @@ -61,6 +64,7 @@ To jest ucywilizowanie kontraktu, który obrosł historycznym gównem. **Cel:** W trybie hands-off (non-assistive, RAW lub z opcjonalnym formattingiem) system musi budować **jeden ciągły transkrypt** przez całą sesję, zamiast podmieniać fragmenty co utterance. **Co musi się stać (minimum):** + - W non-assistive ścieżce toggle/hands-off: `append_mode` musi być włączone dla całego nagrania. - `handle_toggle_utterance` (lub równoważny mechanizm) nie może już uruchamiać pełnego pipeline'u z efektem podmiany na overlay i w transkrypcie. - `stop_toggle_recording` musi przestać discardować WAV w ścieżce non-assistive long-form. @@ -70,6 +74,7 @@ To jest ucywilizowanie kontraktu, który obrosł historycznym gównem. **Akceptacja wymagana przed jakąkolwiek dalszą pracą:** Operator musi explicite potwierdzić (słownie lub na piśmie), że: + - W hands-off (non-assistive) po tej zmianie faktycznie dostaje jeden ciągły, appendowany transkrypt. - Różnica między "jak wygląda tekst podczas mówienia" a "jak wygląda tuż przed wklejeniem" jest akceptowalna. - Mechanizm nie łamie istniejących flow (jeśli ktoś jeszcze polega na starym per-utterance zachowaniu). diff --git a/docs/APP_STORE_READINESS.md b/docs/APP_STORE_READINESS.md index 68bf9972..7a55df5a 100644 --- a/docs/APP_STORE_READINESS.md +++ b/docs/APP_STORE_READINESS.md @@ -33,33 +33,33 @@ is a question of **which product** goes to the store. ## The hard constraints (Apple, official) -| # | Constraint | Source | -|---|------------|--------| -| 1 | **App Sandbox is required** for Mac App Store apps. Builds without `com.apple.security.app-sandbox = true` are rejected at submission. | [App Sandbox](https://developer.apple.com/documentation/security/app-sandbox); rejection text confirmed on [Apple Developer Forums](https://developer.apple.com/forums/thread/41400) | -| 2 | **Developer ID + notarization** is the *outside-the-store* lane; the store lane needs an **Apple Distribution / "3rd Party Mac Developer"** signing identity, a `.pkg` (`productbuild`), and an App Store Connect upload (Transporter). | [Notarizing macOS software](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution) | -| 3 | **Privacy manifest (`PrivacyInfo.xcprivacy`)** with declared **required-reason API** usage is mandatory for App Store Connect submissions since **2024-05-01**. | [Privacy updates for App Store submissions](https://developer.apple.com/news/?id=3d8a9yyh), [Reminder: starts May 1](https://developer.apple.com/news/?id=pvszzano) | -| 4 | **App Privacy Details** ("nutrition labels") must be completed in App Store Connect before publishing — including microphone data. | [App Privacy Details](https://developer.apple.com/app-store/app-privacy-details/) | -| 5 | **Apple Events to other apps** under sandbox need a `scripting-targets` entitlement or an `apple-events` **temporary exception** — which is "carefully reviewed, and **most often rejected**" by App Review. | [App Sandbox Temporary Exception Entitlements](https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/AppSandboxTemporaryExceptionEntitlements.html), [QA1888](https://developer.apple.com/library/archive/qa/qa1888/_index.html) | -| 6 | **Accessibility used for non-accessibility purposes** (pasting into / driving other apps) is rejected under **Guideline 2.4.5**. | Apple Developer Forums review reports; App Store Review Guidelines 2.4.5 | -| 7 | **Input Monitoring** (CGEventTap *listen-only*, via `CGPreflightListenEventAccess` / `CGRequestListenEventAccess`) **is available to sandboxed Mac App Store apps**. Global-hotkey *detection* can survive sandbox; *controlling other apps* cannot. | [Xojo: Sandboxing to Notarization](https://blog.xojo.com/2024/08/22/macos-apps-from-sandboxing-to-notarization-the-basics/), [Beyond App Sandbox](https://www.appcoda.com/mac-app-sandbox/) | -| 8 | `allow-unsigned-executable-memory` and `allow-jit` are, per Apple's own entitlement docs, **compatible with both the Mac App Store and Developer ID**. The harder conflict is `disable-library-validation`, which fights the sandbox rule that nested code be team-signed. **Mark as uncertain until validated against a real `productbuild` + App Store Connect upload.** | [allow-unsigned-executable-memory](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.allow-unsigned-executable-memory), [disable-library-validation](https://developer.apple.com/documentation/BundleResources/Entitlements/com.apple.security.cs.disable-library-validation) | +| # | Constraint | Source | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | **App Sandbox is required** for Mac App Store apps. Builds without `com.apple.security.app-sandbox = true` are rejected at submission. | [App Sandbox](https://developer.apple.com/documentation/security/app-sandbox); rejection text confirmed on [Apple Developer Forums](https://developer.apple.com/forums/thread/41400) | +| 2 | **Developer ID + notarization** is the _outside-the-store_ lane; the store lane needs an **Apple Distribution / "3rd Party Mac Developer"** signing identity, a `.pkg` (`productbuild`), and an App Store Connect upload (Transporter). | [Notarizing macOS software](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution) | +| 3 | **Privacy manifest (`PrivacyInfo.xcprivacy`)** with declared **required-reason API** usage is mandatory for App Store Connect submissions since **2024-05-01**. | [Privacy updates for App Store submissions](https://developer.apple.com/news/?id=3d8a9yyh), [Reminder: starts May 1](https://developer.apple.com/news/?id=pvszzano) | +| 4 | **App Privacy Details** ("nutrition labels") must be completed in App Store Connect before publishing — including microphone data. | [App Privacy Details](https://developer.apple.com/app-store/app-privacy-details/) | +| 5 | **Apple Events to other apps** under sandbox need a `scripting-targets` entitlement or an `apple-events` **temporary exception** — which is "carefully reviewed, and **most often rejected**" by App Review. | [App Sandbox Temporary Exception Entitlements](https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/AppSandboxTemporaryExceptionEntitlements.html), [QA1888](https://developer.apple.com/library/archive/qa/qa1888/_index.html) | +| 6 | **Accessibility used for non-accessibility purposes** (pasting into / driving other apps) is rejected under **Guideline 2.4.5**. | Apple Developer Forums review reports; App Store Review Guidelines 2.4.5 | +| 7 | **Input Monitoring** (CGEventTap _listen-only_, via `CGPreflightListenEventAccess` / `CGRequestListenEventAccess`) **is available to sandboxed Mac App Store apps**. Global-hotkey _detection_ can survive sandbox; _controlling other apps_ cannot. | [Xojo: Sandboxing to Notarization](https://blog.xojo.com/2024/08/22/macos-apps-from-sandboxing-to-notarization-the-basics/), [Beyond App Sandbox](https://www.appcoda.com/mac-app-sandbox/) | +| 8 | `allow-unsigned-executable-memory` and `allow-jit` are, per Apple's own entitlement docs, **compatible with both the Mac App Store and Developer ID**. The harder conflict is `disable-library-validation`, which fights the sandbox rule that nested code be team-signed. **Mark as uncertain until validated against a real `productbuild` + App Store Connect upload.** | [allow-unsigned-executable-memory](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.allow-unsigned-executable-memory), [disable-library-validation](https://developer.apple.com/documentation/BundleResources/Entitlements/com.apple.security.cs.disable-library-validation) | --- ## Current state vs. Mac App Store requirement -| Surface | Today (verified in repo) | MAS requirement | Gap | -|---------|--------------------------|-----------------|-----| -| **Sandbox** | `scripts/entitlements.plist` explicitly **disables** App Sandbox (documented as "outside Mac App Store") | `app-sandbox = true` mandatory | **P0** | -| **Entitlements** | `disable-library-validation`, `allow-unsigned-executable-memory`, `allow-dyld-environment-variables` — all required by embedded Whisper/MiniLM dylibs | Sandboxed apps must team-sign nested code; `disable-library-validation` conflicts | **P0/uncertain** | -| **Privacy manifest** | none (`PrivacyInfo.xcprivacy` absent); app reads file mtimes via `std::fs` `metadata().modified()` in `core/state/history.rs`, `core/hf_cache.rs`, `core/attachment.rs`, **and runtime `core/pipeline/stream_postprocess.rs`** (lexicon mtime in config dir) → **FileTimestamp** required-reason category, reason code **C617.1** (metadata of files in the app's own containers) | `PrivacyInfo.xcprivacy` declaring `NSPrivacyAccessedAPICategoryFileTimestamp` / `C617.1` | **P0** (draft template: `scripts/PrivacyInfo.xcprivacy.template`) | -| **App Privacy Details** | only a written `docs/guide/privacy.md`; no App Store Connect record | Nutrition-label questionnaire completed | **P1** (process, blocked on having an app record) | -| **Purpose strings** | Mic, Accessibility, Input Monitoring, Screen Capture, Apple Events — generated in `Makefile` bundle target (lines 127–131) | Mic + Input Monitoring OK; Accessibility/Apple Events review-risky | **P1** | -| **Basic vs Agentic** | Onboarding has Basic (safe default) + Agentic lanes; Agentic probes MCP readiness | Agentic capabilities are sandbox-incompatible | **architecture** | -| **MCP / Vibecrafted** | Agentic mode shells out, spawns MCP, reads broad files | Forbidden under sandbox | **P0 for Agentic SKU** | -| **Signing/upload** | Developer ID → notarytool → stapler → Gatekeeper (DMG); `.github/workflows/release.yml` | Apple Distribution cert → `.pkg` → App Store Connect | **P0** | -| **Release gates / PRView** | PR35 (release-forward) not yet main-ready; signing secrets unset; live release `v0.8.0` ≪ source `0.12.2` | Green release pipeline | **P1** | -| **Cold install smoke** | DMG drag-install + Gatekeeper drill documented in `PUBLIC_RELEASE_CHECKLIST.md` | TestFlight / store install | **P1** | +| Surface | Today (verified in repo) | MAS requirement | Gap | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| **Sandbox** | `scripts/entitlements.plist` explicitly **disables** App Sandbox (documented as "outside Mac App Store") | `app-sandbox = true` mandatory | **P0** | +| **Entitlements** | `disable-library-validation`, `allow-unsigned-executable-memory`, `allow-dyld-environment-variables` — all required by embedded Whisper/MiniLM dylibs | Sandboxed apps must team-sign nested code; `disable-library-validation` conflicts | **P0/uncertain** | +| **Privacy manifest** | none (`PrivacyInfo.xcprivacy` absent); app reads file mtimes via `std::fs` `metadata().modified()` in `core/state/history.rs`, `core/hf_cache.rs`, `core/attachment.rs`, **and runtime `core/pipeline/stream_postprocess.rs`** (lexicon mtime in config dir) → **FileTimestamp** required-reason category, reason code **C617.1** (metadata of files in the app's own containers) | `PrivacyInfo.xcprivacy` declaring `NSPrivacyAccessedAPICategoryFileTimestamp` / `C617.1` | **P0** (draft template: `scripts/PrivacyInfo.xcprivacy.template`) | +| **App Privacy Details** | only a written `docs/guide/privacy.md`; no App Store Connect record | Nutrition-label questionnaire completed | **P1** (process, blocked on having an app record) | +| **Purpose strings** | Mic, Accessibility, Input Monitoring, Screen Capture, Apple Events — generated in `Makefile` bundle target (lines 127–131) | Mic + Input Monitoring OK; Accessibility/Apple Events review-risky | **P1** | +| **Basic vs Agentic** | Onboarding has Basic (safe default) + Agentic lanes; Agentic probes MCP readiness | Agentic capabilities are sandbox-incompatible | **architecture** | +| **MCP / Vibecrafted** | Agentic mode shells out, spawns MCP, reads broad files | Forbidden under sandbox | **P0 for Agentic SKU** | +| **Signing/upload** | Developer ID → notarytool → stapler → Gatekeeper (DMG); `.github/workflows/release.yml` | Apple Distribution cert → `.pkg` → App Store Connect | **P0** | +| **Release gates / PRView** | PR35 (release-forward) not yet main-ready; signing secrets unset; live release `v0.8.0` ≪ source `0.12.2` | Green release pipeline | **P1** | +| **Cold install smoke** | DMG drag-install + Gatekeeper drill documented in `PUBLIC_RELEASE_CHECKLIST.md` | TestFlight / store install | **P1** | --- @@ -119,17 +119,17 @@ The constraints above were re-checked against live Apple/official sources during an ERi (Examine → Research → Implement) pass. Each core claim held; precision was added where the first draft was coarse. -| Claim | Verdict (live) | Source | -|-------|----------------|--------| -| App Sandbox (`com.apple.security.app-sandbox = true`) is required for every Mac App Store app; builds without it are rejected at submission | **Confirmed** | [App Sandbox Entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.app-sandbox); rejection text on [Apple Developer Forums 41400](https://developer.apple.com/forums/thread/41400) | -| MAS lane needs an **Apple Distribution** signing identity + a **3rd Party Mac Developer Installer** `.pkg` via `productbuild`, uploaded with **Transporter** to App Store Connect — distinct from Developer ID + `notarytool` (outside-store lane; `altool` retired 2023-11-01) | **Confirmed** | [Notarizing macOS software](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution), [Distributing software on macOS](https://developer.apple.com/macos/distribution/), [Uploading macOS Builds to App Store Connect (Xojo, 2025)](https://blog.xojo.com/2025/01/14/uploading-macos-builds-to-app-store-connect/) | -| **App Privacy Details** ("nutrition labels") are mandatory to submit new apps/updates, apply to macOS, and require declaring **Audio Data** with purpose + linkage + tracking answers | **Confirmed** | [App Privacy Details](https://developer.apple.com/app-store/app-privacy-details/) | -| **Privacy manifest** (`PrivacyInfo.xcprivacy`) with **required-reason API** declarations is enforced since **2024-05-01**; apps without it are rejected | **Confirmed** | [Privacy updates for App Store submissions](https://developer.apple.com/news/?id=3d8a9yyh), [Reminder: starts May 1](https://developer.apple.com/news/?id=pvszzano) | -| Codescribe's `metadata().modified()` usage maps to **FileTimestamp** category, reason code **C617.1** (metadata of files in the app's own containers); `DDA9.1` is the alternate (show timestamps to the user, no off-device send) | **Confirmed + made precise** | [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | +| Claim | Verdict (live) | Source | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| App Sandbox (`com.apple.security.app-sandbox = true`) is required for every Mac App Store app; builds without it are rejected at submission | **Confirmed** | [App Sandbox Entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.app-sandbox); rejection text on [Apple Developer Forums 41400](https://developer.apple.com/forums/thread/41400) | +| MAS lane needs an **Apple Distribution** signing identity + a **3rd Party Mac Developer Installer** `.pkg` via `productbuild`, uploaded with **Transporter** to App Store Connect — distinct from Developer ID + `notarytool` (outside-store lane; `altool` retired 2023-11-01) | **Confirmed** | [Notarizing macOS software](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution), [Distributing software on macOS](https://developer.apple.com/macos/distribution/), [Uploading macOS Builds to App Store Connect (Xojo, 2025)](https://blog.xojo.com/2025/01/14/uploading-macos-builds-to-app-store-connect/) | +| **App Privacy Details** ("nutrition labels") are mandatory to submit new apps/updates, apply to macOS, and require declaring **Audio Data** with purpose + linkage + tracking answers | **Confirmed** | [App Privacy Details](https://developer.apple.com/app-store/app-privacy-details/) | +| **Privacy manifest** (`PrivacyInfo.xcprivacy`) with **required-reason API** declarations is enforced since **2024-05-01**; apps without it are rejected | **Confirmed** | [Privacy updates for App Store submissions](https://developer.apple.com/news/?id=3d8a9yyh), [Reminder: starts May 1](https://developer.apple.com/news/?id=pvszzano) | +| Codescribe's `metadata().modified()` usage maps to **FileTimestamp** category, reason code **C617.1** (metadata of files in the app's own containers); `DDA9.1` is the alternate (show timestamps to the user, no off-device send) | **Confirmed + made precise** | [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | **Honest nuance:** the hardest-edged 2024-05-01 gate is scoped most strictly to -*third-party SDKs on Apple's commonly-used list*, but the required-reason -*declaration* obligation also covers an app's own first-party usage of those +_third-party SDKs on Apple's commonly-used list_, but the required-reason +_declaration_ obligation also covers an app's own first-party usage of those APIs. Codescribe uses a FileTimestamp API directly, so the manifest is required regardless of SDKs. @@ -155,7 +155,7 @@ assert either way from documentation alone. The operator has made a **board-level product decision**: the Mac App Store is now the **first-choice distribution lane** for codescribe. That does not reverse the technical verdict above — a single binary still cannot be both sandboxed (for -the store) and un-sandboxed (for the agent). What it changes is the *action*: stop +the store) and un-sandboxed (for the agent). What it changes is the _action_: stop treating the Basic MAS SKU as a "maybe later" and treat it as the **primary lane to stand up**, with Developer ID/Agentic as the secondary power-user lane. @@ -170,7 +170,7 @@ MiniLM dylibs are signed ad-hoc / under a foreign team**. Apple's library-valida policy lets a binary link any library signed with **the same team identifier** (or an Apple system library). So the fix is not an entitlement — it is **re-signing every nested dylib/framework under the app's own team ID at bundle time**. Do that -and library validation is satisfied *and* the sandbox's "nested code must be +and library validation is satisfied _and_ the sandbox's "nested code must be team-signed" rule is met, with `disable-library-validation` removed entirely. - Library validation / nested-code signing: [TN2206: macOS Code Signing In Depth](https://developer.apple.com/library/archive/technotes/tn2206/_index.html) @@ -184,10 +184,10 @@ loads under the sandbox. Listen-only **Input Monitoring** (`CGEventTap` via `CGPreflightListenEventAccess` / `CGRequestListenEventAccess`) **is available to sandboxed Mac App Store apps**. It -uses the runtime *Input Monitoring* privilege, not the Accessibility privilege that +uses the runtime _Input Monitoring_ privilege, not the Accessibility privilege that the sandbox blocks. Codescribe already detects hotkeys with `CGEventTap` (not the Accessibility-bound `NSEvent` monitor), so **hotkey detection carries into the -Basic SKU unchanged**. What does *not* carry is *controlling other apps* (paste, +Basic SKU unchanged**. What does _not_ carry is _controlling other apps_ (paste, focus-restore) — that needs Accessibility / Apple Events, which stay in the Developer ID SKU. @@ -217,7 +217,7 @@ required regardless of SDKs — do not defer it. ### Concrete path to the Basic MAS SKU (commands the operator will run later) -These are the *documented target commands*, not run by this pass (they need an +These are the _documented target commands_, not run by this pass (they need an Apple Distribution identity and a sandboxed build profile that do not exist yet): ```bash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c105ca7b..7978fdd3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,14 +13,14 @@ Apple Speech as the live primary and Whisper / lexicon / small LLM / Silero para classifier filling in behind it. The overlay (`app/ui/overlay/`) renders the union of layer events, never wipes and retypes — _NEVER REWRITE FROM ZERO_ is the operator-mandated invariant. -| Layer | Engine | Module | -| --- | --- | --- | -| 0 — Live | Apple `SFSpeechRecognizer` (primary) · Whisper fallback | `core/stt/apple_stt/` + `core/stt/whisper/` | -| 1 — Tail Patch | Whisper background diff | `core/stt/tail_patcher/` (new, Phase 1) | -| 2 — Polish | Lexicon + small LLM | `core/lexicon/` + `core/llm/inline_polish.rs` (new, Phase 2) | -| 3 — Paralingual | Silero classifier head | `core/vad/paralingual_classifier.rs` (new, Phase 3) | -| 4 — Final BAM | Session-end contextual pass | `core/pipeline/final_bam.rs` (new, Phase 4) | -| Orchestrator | — | `app/controller/layered_orchestrator.rs` (new, Phase 1) | +| Layer | Engine | Module | +| --------------- | ------------------------------------------------------- | ------------------------------------------------------------ | +| 0 — Live | Apple `SFSpeechRecognizer` (primary) · Whisper fallback | `core/stt/apple_stt/` + `core/stt/whisper/` | +| 1 — Tail Patch | Whisper background diff | `core/stt/tail_patcher/` (new, Phase 1) | +| 2 — Polish | Lexicon + small LLM | `core/lexicon/` + `core/llm/inline_polish.rs` (new, Phase 2) | +| 3 — Paralingual | Silero classifier head | `core/vad/paralingual_classifier.rs` (new, Phase 3) | +| 4 — Final BAM | Session-end contextual pass | `core/pipeline/final_bam.rs` (new, Phase 4) | +| Orchestrator | — | `app/controller/layered_orchestrator.rs` (new, Phase 1) | Existing files (`core/stt/whisper/`, `core/audio/streaming_recorder.rs`, `core/vad/silero_ort.rs`, `app/ui/overlay/mod.rs`) keep their public APIs — the layered orchestrator reuses them as Layer 1 diff --git a/docs/HOTKEYS_CONTRACT.md b/docs/HOTKEYS_CONTRACT.md index 5c04428a..2af5a13c 100644 --- a/docs/HOTKEYS_CONTRACT.md +++ b/docs/HOTKEYS_CONTRACT.md @@ -60,13 +60,13 @@ flowchart TB **Behavior:** Recording starts on key down, stops on key up **VAD:** DISABLED - user has 100% control via key release -| Mode binding | Keys | Use Case | -| --------------------- | ------------ | -------------------------------- | -| `Dictation=HoldFn` | Fn | **Default** (best for terminals) | -| `Dictation=HoldCtrl` | Ctrl | Terminal-heavy users | -| `Dictation=HoldCtrlAlt` | Ctrl+Option | Power-combo preset | -| `Dictation=HoldCtrlShift` | Ctrl+Shift | Alternate hold dictation | -| `Dictation=HoldCtrlCmd` | Ctrl+Command | macOS power users | +| Mode binding | Keys | Use Case | +| ------------------------- | ------------ | -------------------------------- | +| `Dictation=HoldFn` | Fn | **Default** (best for terminals) | +| `Dictation=HoldCtrl` | Ctrl | Terminal-heavy users | +| `Dictation=HoldCtrlAlt` | Ctrl+Option | Power-combo preset | +| `Dictation=HoldCtrlShift` | Ctrl+Shift | Alternate hold dictation | +| `Dictation=HoldCtrlCmd` | Ctrl+Command | macOS power users | If `Assistive` itself is configured to a hold binding, that binding becomes the assistive hold trigger. @@ -81,6 +81,13 @@ HotkeyInput { key_type: Hold, action: Up, hold_mode: } // Release **Mode modifiers (default Fn):** Shift → Chat, Cmd → Selection (while holding Fn). +**Engine and delivery parity:** Hold and toggle both start +`StreamingRecorder::start_event_session` and fan the same `EngineEvent` stream +through `PresentationEmitter`, IPC, and telemetry sinks. Their intentional +difference is boundary policy (key-up for hold, VAD for toggle), not the STT +engine. A late `Correction` patches the active preview or the matching most +recent committed utterance; it must never create a second delivered utterance. + --- ### 2. Toggle Mode (Hands-Free) @@ -90,12 +97,28 @@ HotkeyInput { key_type: Hold, action: Up, hold_mode: } // Release **VAD:** ENABLED – finalized utterances append to the active draft; `TOGGLE_SILENCE_SEC` of silence (default 5s) sends the accumulated draft without stopping recording -| Mode binding | Keys | Mode | -| ---------------------------- | ------------------------------- | ---------------- | -| `Formatting=DoubleLeftOption` | Left Option double-tap | Formatting | -| `Assistive=DoubleRightOption` | Right Option double-tap | Assistive | -| `Dictation=DoubleCtrl` | Ctrl double-tap | Raw dictation | -| `Disabled` | no toggle for that work mode | Hold-only profile | +| Mode binding | Keys | Mode | +| ----------------------------- | ---------------------------- | ----------------- | +| `Formatting=DoubleLeftOption` | Left Option double-tap | Formatting | +| `Assistive=DoubleRightOption` | Right Option double-tap | Assistive | +| `Dictation=DoubleCtrl` | Ctrl double-tap | Raw dictation | +| `Disabled` | no toggle for that work mode | Hold-only profile | + +If a recording path does **not** carry an explicit hotkey override (`force_raw` +or `force_ai`), the controller resolves delivery from Settings: formatting mode +enabled means the default route is formatting; disabled means raw. Explicit +hotkey bindings still win over that default (`Dictation` forces raw, +`Formatting` forces formatting). + +**Stop latency trade-off (supersedes ADR 2026-05-28 Faza 1 force-RAW):** with +formatting enabled in Settings, a hands-off toggle stop performs one AI +formatting call on the stop path before delivery. This latency is the user's +explicit choice, not a surprise: the overlay reports the phase as `final pass` +while the call runs, a formatting failure falls back to the post-processed raw +text, and users who want a zero-latency stop either disable the formatting +default or use a `Dictation` binding (force raw). The earlier unconditional +force-RAW on this path was removed because it silently erased the Settings +formatting default. **Events:** @@ -204,12 +227,12 @@ flowchart LR Bindings themselves are persisted in `settings.json`. The remaining runtime env surface only tunes detector behavior: -| Variable | Default | Options | Reload | -| ------------------------ | ------- | --------- | ------- | +| Variable | Default | Options | Reload | +| ------------------------ | ------- | --------------- | ------- | | `HOLD_EXCLUSIVE` | `false` | `true`, `false` | RESTART | -| `HOLD_START_DELAY_MS` | `800` | 0-1000 | RESTART | -| `DOUBLE_TAP_INTERVAL_MS` | `200` | 100-450 | RESTART | -| `TOGGLE_SILENCE_SEC` | `5.0` | 0.5-10.0 | RESTART | +| `HOLD_START_DELAY_MS` | `800` | 0-1000 | RESTART | +| `DOUBLE_TAP_INTERVAL_MS` | `200` | 100-450 | RESTART | +| `TOGGLE_SILENCE_SEC` | `5.0` | 0.5-10.0 | RESTART | ### VAD Configuration diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 199f363f..df989b8a 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -133,13 +133,13 @@ Codescribe.app/ ### Info.plist Keys -| Key | Value | Purpose | -| ---------------------------- | --------------------- | ---------------------------- | -| CFBundleIdentifier | com.vetcoders.codescribe | Unique app identifier | -| CFBundleIconFile | AppIcon | Points to AppIcon.icns | -| CFBundleExecutable | Codescribe | Main binary name | -| LSMinimumSystemVersion | 14.0 | Requires macOS Sonoma+ | -| NSMicrophoneUsageDescription | ... | Microphone permission prompt | +| Key | Value | Purpose | +| ---------------------------- | ------------------------ | ---------------------------- | +| CFBundleIdentifier | com.vetcoders.codescribe | Unique app identifier | +| CFBundleIconFile | AppIcon | Points to AppIcon.icns | +| CFBundleExecutable | Codescribe | Main binary name | +| LSMinimumSystemVersion | 14.0 | Requires macOS Sonoma+ | +| NSMicrophoneUsageDescription | ... | Microphone permission prompt | ## Icons diff --git a/docs/LICENSE_NOTES.md b/docs/LICENSE_NOTES.md index 357f949a..6a0b34c7 100644 --- a/docs/LICENSE_NOTES.md +++ b/docs/LICENSE_NOTES.md @@ -9,12 +9,12 @@ including personal use, education, research, and professional services. What this means in practice: -| Scenario | Status | -| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| Personal use, education, research, and internal evaluation | permitted under FSL-1.1-ALv2. | -| Professional services delivered to a licensee using codescribe under the terms | permitted under FSL-1.1-ALv2. | -| Commercial product or service that substitutes for codescribe | not permitted as Competing Use while the FSL terms apply. | -| Future use of each released version | automatically available under Apache-2.0 two years after that version is made available. | +| Scenario | Status | +| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| Personal use, education, research, and internal evaluation | permitted under FSL-1.1-ALv2. | +| Professional services delivered to a licensee using codescribe under the terms | permitted under FSL-1.1-ALv2. | +| Commercial product or service that substitutes for codescribe | not permitted as Competing Use while the FSL terms apply. | +| Future use of each released version | automatically available under Apache-2.0 two years after that version is made available. | If you fork or redistribute codescribe for a permitted purpose, keep the `LICENSE` file or a link to the license terms with your copy, modifications, or diff --git a/docs/OVERLAY_STREAMING.md b/docs/OVERLAY_STREAMING.md index 3969e060..6bb05957 100644 --- a/docs/OVERLAY_STREAMING.md +++ b/docs/OVERLAY_STREAMING.md @@ -13,13 +13,13 @@ The overlay no longer renders a single linear stream of one engine's output. It now renders **five concurrent layers**, each emitting events into the same already-shown text buffer: -| Layer | Engine | Event types | When | -| --- | --- | --- | --- | -| **0 — Live** | Apple `SFSpeechRecognizer` (primary) · Whisper fallback | `Preview`, `Correction`, `UtteranceFinal` | While the user speaks — owns first commit | -| **1 — Tail Patch** | Whisper (Candle / mlx-audio / OpenAI / libraxis) | `ReplaceRange { source: TailPatch }` | ~1 s after each utterance boundary | -| **2 — Polish** | Local lexicon + small LLM (Bielik-11B default) | `ReplaceRange { source: Lexicon \| InlineLlm }` | Debounced after Layer 1 settles | -| **3 — Paralingual** | Silero classifier head | `InsertAnnotation { HesitationPause \| Paralingual }` | Continuously alongside Layers 0–2 | -| **4 — Final BAM** | Session-end contextual pass | `ReplaceRange` (cross-utterance, within bounds) + `SessionFinalised` | On `stop()` / hold-release | +| Layer | Engine | Event types | When | +| ------------------- | ------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------- | +| **0 — Live** | Apple `SFSpeechRecognizer` (primary) · Whisper fallback | `Preview`, `Correction`, `UtteranceFinal` | While the user speaks — owns first commit | +| **1 — Tail Patch** | Whisper (Candle / mlx-audio / OpenAI / libraxis) | `ReplaceRange { source: TailPatch }` | ~1 s after each utterance boundary | +| **2 — Polish** | Local lexicon + small LLM (Bielik-11B default) | `ReplaceRange { source: Lexicon \| InlineLlm }` | Debounced after Layer 1 settles | +| **3 — Paralingual** | Silero classifier head | `InsertAnnotation { HesitationPause \| Paralingual }` | Continuously alongside Layers 0–2 | +| **4 — Final BAM** | Session-end contextual pass | `ReplaceRange` (cross-utterance, within bounds) + `SessionFinalised` | On `stop()` / hold-release | **Hard invariant:** every layer mutates the buffer only through bounded events (`Append`, `ReplaceRange`, `InsertAnnotation`, `Backspace`). No layer is allowed to wipe the diff --git a/docs/WHISPER_LIVE.md b/docs/WHISPER_LIVE.md index 0fcf6463..b862990f 100644 --- a/docs/WHISPER_LIVE.md +++ b/docs/WHISPER_LIVE.md @@ -89,13 +89,13 @@ Practical win: ## Layer mapping for this file -| Section below | Layer it lights up | -| --- | --- | -| Embedded Whisper (build + runtime lookup) | Layer 1 (Tail Patch) backend resolution | -| Streaming transcription, chunker, overlap dedup | Layer 1 background pass on utterance tail | -| Stream postprocess, semantic gate | Pre-diff cleanup feeding Layer 1's `ReplaceRange` decision | -| Cloud STT alternatives | Pluggable Layer 1 backend | -| (NEW, Phase 2) Lexicon + small LLM passes | Layer 2 (Polish) — see ADR §Layer specifications | +| Section below | Layer it lights up | +| ----------------------------------------------- | ---------------------------------------------------------- | +| Embedded Whisper (build + runtime lookup) | Layer 1 (Tail Patch) backend resolution | +| Streaming transcription, chunker, overlap dedup | Layer 1 background pass on utterance tail | +| Stream postprocess, semantic gate | Pre-diff cleanup feeding Layer 1's `ReplaceRange` decision | +| Cloud STT alternatives | Pluggable Layer 1 backend | +| (NEW, Phase 2) Lexicon + small LLM passes | Layer 2 (Polish) — see ADR §Layer specifications | Everything below this point is the same Whisper-Live tech that existed before the ADR — it is **not removed**, just relocated in the architecture: Whisper became the silent partner that makes diff --git a/docs/config-generator.html b/docs/config-generator.html index a392efad..9fe42d0f 100644 --- a/docs/config-generator.html +++ b/docs/config-generator.html @@ -317,7 +317,9 @@

🎤 Speech-to-Text

id="toggle-local-stt" onclick="toggleSwitch(this)" > - Use Local STT (embedded-first Whisper) + Use Local STT (embedded-first Whisper)
@@ -406,8 +408,9 @@

🤖 AI Formatting / Assistive

⌨️ Hotkeys

Mode bindings for Dictation, Formatting, and Assistive now live in - settings.json via Settings → Modes & Shortcuts. - This generator only emits the remaining env-backed timing toggles. + settings.json via + Settings → Modes & Shortcuts. This generator + only emits the remaining env-backed timing toggles.

@@ -657,7 +660,9 @@

📄 Generated .env

}` ); lines.push( - `TOGGLE_SILENCE_SEC=${document.getElementById("toggle-silence").value}` + `TOGGLE_SILENCE_SEC=${ + document.getElementById("toggle-silence").value + }` ); lines.push(``); diff --git a/docs/guide/privacy.md b/docs/guide/privacy.md index b1332d31..d6c3e99f 100644 --- a/docs/guide/privacy.md +++ b/docs/guide/privacy.md @@ -19,14 +19,14 @@ Codescribe is designed with privacy as a core principle. Your audio is processed ### Always Local (Cannot Be Changed) -| Component | Location | Notes | -| --------------- | ----------------------------------------------------- | ------------------------------------- | -| Whisper model | Runtime-resolved local path/cache | Local STT still runs on-device | -| Audio recording | RAM only | Deleted after transcription | -| Transcripts | ~/.codescribe/transcriptions/ | You control retention | -| Configuration | settings.json + optional ~/.codescribe/.env | GUI defaults plus power-user overrides | -| API keys | macOS Keychain | Secrets stay out of plaintext config | -| Prompts | ~/.codescribe/prompts/ | Your custom prompts | +| Component | Location | Notes | +| --------------- | ------------------------------------------- | -------------------------------------- | +| Whisper model | Runtime-resolved local path/cache | Local STT still runs on-device | +| Audio recording | RAM only | Deleted after transcription | +| Transcripts | ~/.codescribe/transcriptions/ | You control retention | +| Configuration | settings.json + optional ~/.codescribe/.env | GUI defaults plus power-user overrides | +| API keys | macOS Keychain | Secrets stay out of plaintext config | +| Prompts | ~/.codescribe/prompts/ | Your custom prompts | ### No Network Required For @@ -207,9 +207,9 @@ Audio files go to `~/.codescribe/audio/`. ### Codescribe Connects To: -| Destination | When | Data | -| -------------- | ------------------ | ------------------ | -| `LLM_ENDPOINT` | AI formatting | Text transcript | +| Destination | When | Data | +| -------------- | ---------------------- | -------------------------------- | +| `LLM_ENDPOINT` | AI formatting | Text transcript | | `STT_ENDPOINT` | Cloud final transcript | Audio after capture (if enabled) | ### Verify Network Activity diff --git a/docs/landing/index.html b/docs/landing/index.html index 392caa14..6f0bcddf 100644 --- a/docs/landing/index.html +++ b/docs/landing/index.html @@ -470,8 +470,8 @@

Codescribe

Speak naturally. See local Whisper live preview. Orchestrate work - with your voice, selected text, files, tools, and workflow context - — right from the app you are already using. + with your voice, selected text, files, tools, and workflow context — + right from the app you are already using.

  • Local-first by default; cloud final paths are optional.
  • -
  • OpenAI Responses powers Formatting and Assistive by default.
  • +
  • + OpenAI Responses powers Formatting and Assistive by default. +
  • Native AppKit overlays, settings, onboarding, and tray.
  • FSL-1.1-ALv2 license with Apache-2.0 future conversion.
diff --git a/docs/truth-contract.md b/docs/truth-contract.md index 5804ec0b..ac79054b 100644 --- a/docs/truth-contract.md +++ b/docs/truth-contract.md @@ -3,33 +3,41 @@ Data: 2026-04-21 Cel: + - Codescribe ma zachowywać intencję użytkownika bez cichego mieszania podglądu, werdyktu i interpretacji. ## Słownik produktu - `Live preview` + - Lokalny, prowizoryczny podgląd pojawiający się w trakcie nagrania. - Nie jest automatycznie równy finalnej prawdzie. - `Committed verdict` + - Ostateczny transcript wybrany po zakończeniu nagrania. - To ten artefakt decyduje o zapisie, auto-paste i sidecarze prawdy. - `Transcript` + - Najwierniejszy możliwy zapis tego, co zostało wypowiedziane. - `Formatted transcript` + - Transcript po bezpiecznej obróbce formatowania. - Jeżeli korekta pogarsza tekst albo nic realnie nie wnosi, raw transcript wygrywa. - `Assistant interpretation` + - Odpowiedź asystenta oparta o transcript lub selekcję. - To nie jest transcript i nie może być etykietowana jak transcript. - `No speech` + - System nie ma wystarczających podstaw, by twierdzić, że w nagraniu była mowa. - `Low confidence` + - Transcript istnieje, ale system ma twarde sygnały, że jakość jest słaba. - `Fallback` diff --git a/macos/Codescribe/App.swift b/macos/Codescribe/App.swift index bbe71411..94f5cc28 100644 --- a/macos/Codescribe/App.swift +++ b/macos/Codescribe/App.swift @@ -133,7 +133,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func wireTrayActions() { model.tray.onAbout = { NSApp.activate(ignoringOtherApps: true) - NSApp.orderFrontStandardAboutPanel(nil) + // Build provenance in the standard About panel (Pensieve-style): + // version/build come from Info.plist keys stamped by scripts/build-app.sh; + // commit + built-at land in the credits block below them. + let info = Bundle.main.infoDictionary ?? [:] + let commit = info["CSBuildCommit"] as? String ?? "dev" + let builtAt = info["CSBuiltAt"] as? String ?? "unknown" + let credits = NSAttributedString( + string: "Commit: \(commit)\nBuilt: \(builtAt)", + attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), + .foregroundColor: NSColor.secondaryLabelColor, + ] + ) + NSApp.orderFrontStandardAboutPanel(options: [.credits: credits]) } model.tray.onHelp = { NSWorkspace.shared.open(Self.helpURL) diff --git a/macos/Codescribe/Core/AppModel.swift b/macos/Codescribe/Core/AppModel.swift index 3f713b22..95cf5944 100644 --- a/macos/Codescribe/Core/AppModel.swift +++ b/macos/Codescribe/Core/AppModel.swift @@ -40,6 +40,14 @@ final class OverlayController: ObservableObject { // Read fresh at show-time so the tray's "Transcription Overlay" toggle takes // effect on the very next dictation (stateless bridge handle — cheap). private let config = CodescribeConfig() + // Stateless read of the Rust-side tray status (same source TrayStatusStore + // listens to) — used to latch whether the CURRENT session is assistive. + private let trayStatusBridge = CodescribeTrayStatus() + /// Latched across the session (preparing → started → stopped) because the + /// Rust controller clears its assistive flag right after the stop pipeline — + /// a single read at finalize would race it. Mid-hold upgrades (Fn → Fn+Shift) + /// flip the tray status while recording, so every lifecycle hook re-polls. + private var sessionWasAssistive = false init(store: AgentChatStore) { state.engine = ControllerDictationEngine() @@ -48,22 +56,36 @@ final class OverlayController: ObservableObject { // appear (and the popover is built once), so it stayed "Recording" after // Finish. These hooks fire for every start/stop path (hotkey, tray, auto). state.onRecordingPreparing = { [weak self] in - self?.showForRecording() + guard let self else { return } + self.sessionWasAssistive = false + self.refreshAssistiveLatch() + self.showForRecording() AppModel.shared.tray.isStartingDictation = true // Block the composer mic while the shared recorder owns the microphone. AppModel.shared.chat.dictationBlocked = true } state.onRecordingStarted = { [weak self] in - self?.showForRecording() + guard let self else { return } + self.refreshAssistiveLatch() + self.showForRecording() AppModel.shared.tray.isRecording = true AppModel.shared.tray.isStartingDictation = false AppModel.shared.chat.dictationBlocked = true } state.onRecordingStopped = { [weak self] in - self?.markStopped() + guard let self else { return } + self.refreshAssistiveLatch() + self.markStopped() AppModel.shared.tray.isRecording = false AppModel.shared.tray.isStartingDictation = false AppModel.shared.chat.dictationBlocked = false + // Assistive sessions hand the transcript to the agent — the overlay's + // job ends at finalize. Fade here instead of waiting for + // on_turn_started, which lags by MCP registration + augmentation + // (5–6 s of a stale FINAL hanging over the chat). + if self.sessionWasAssistive, self.state.mode == .formatted { + self.hideForAgentHandoff() + } } state.onClose = { [weak self] in self?.hide() } state.onSendToAgent = { [weak self, weak store] text in @@ -75,6 +97,7 @@ final class OverlayController: ObservableObject { store.send() self?.hide() } + state.onPlacementChanged = { [weak self] in self?.applyPlacement(animated: true) } state.attach() } @@ -95,35 +118,77 @@ final class OverlayController: ObservableObject { func show() { let panel = panel ?? DictationOverlayWindow.make(state: state, textScale: textScale) self.panel = panel + // A pending fade-out must not leave a freshly shown panel invisible. + panel.alphaValue = 1 + applyPlacement(animated: false) + panel.orderFrontRegardless() + } + + /// Derive and apply the panel's frame from the placement prefs: free motion + /// restores the last dragged origin, anchored derives from the anchor — + /// in ONE setFrame so there is no transient mismatched frame. Clamping the + /// size here covers programmatic sizing, which AppKit's minSize does not. + private func applyPlacement(animated: Bool) { + guard let panel else { return } let screen = NSScreen.main - // Clamp the current size to the active screen (it may have shrunk since the - // size was chosen) and re-centre — in ONE setFrame so there is no transient - // mismatched frame. Enforcing the min here covers programmatic sizing, which - // AppKit's minSize does not. let size = DictationOverlayWindow.clamp(panel.frame.size, to: screen) - if let screen { - let visible = screen.visibleFrame - let origin = NSPoint( - x: visible.midX - size.width / 2, - y: visible.minY + visible.height * 0.22 - ) - panel.setFrame(NSRect(origin: origin, size: size), display: false) + let origin: NSPoint? + if state.freeMotion { + origin = OverlayPlacement.restoredOrigin(size: size, on: screen) ?? panel.frame.origin } else { + origin = OverlayPlacement.origin(for: state.placementAnchor, size: size, on: screen) + } + guard let origin else { panel.setContentSize(size) + return + } + let frame = NSRect(origin: origin, size: size) + if animated, panel.isVisible { + panel.animator().setFrame(frame, display: true) + } else { + panel.setFrame(frame, display: false) } - panel.orderFrontRegardless() } func markStopped() { state.finishControllerRecording() } + private func refreshAssistiveLatch() { + if trayStatusBridge.currentStatus().assistive { + sessionWasAssistive = true + } + } + func hide() { // Persist the user's chosen size for next launch (replaces frame autosave, - // which used to write back the old feedback loop's runaway sizes). + // which used to write back the old feedback loop's runaway sizes) — and, + // in free motion, the dragged origin. if let panel { DictationOverlayWindow.persist(size: panel.frame.size) + if state.freeMotion { + OverlayPlacement.persistOrigin(panel.frame.origin) + } } panel?.orderOut(nil) } + + /// The dictated transcript was handed to the agent (voice turn opened in the + /// chat window). The overlay's job is done — fade it out immediately instead + /// of lingering over the conversation it just fed. + func hideForAgentHandoff() { + guard let panel, panel.isVisible else { return } + DictationOverlayWindow.persist(size: panel.frame.size) + if state.freeMotion { + OverlayPlacement.persistOrigin(panel.frame.origin) + } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + panel.animator().alphaValue = 0 + } completionHandler: { [weak self] in + guard let self, let panel = self.panel else { return } + panel.orderOut(nil) + panel.alphaValue = 1 + } + } } diff --git a/macos/Codescribe/Core/RealChatEngine.swift b/macos/Codescribe/Core/RealChatEngine.swift index 394b8936..f17404e0 100644 --- a/macos/Codescribe/Core/RealChatEngine.swift +++ b/macos/Codescribe/Core/RealChatEngine.swift @@ -16,6 +16,16 @@ final class RealChatEngine: AgentChatEngine { func isAvailable() -> Bool { agent.isAvailable() } + func availabilityDetail() -> String? { + let availability = agent.availability() + if availability.available { return nil } + // The bridge always fills `detail`; the fallback keeps the chat honest + // if an older dylib ever returns an empty reason. + return availability.detail.isEmpty + ? "The assistive model isn't reachable yet — open Settings → Engine to configure the assistive lane." + : availability.detail + } + func streamReply( _ text: String, threadId: String, @@ -124,6 +134,9 @@ final class VoiceDeliveryListener: CsAgentDeliveryListener, @unchecked Sendable func onTurnStarted(threadId: String, userText: String) { DispatchQueue.main.async { MainActor.assumeIsolated { + // The transcript is now the chat's You-bubble — the overlay's job + // is done, so it fades out instead of lingering over the reply. + AppModel.shared.overlay.hideForAgentHandoff() self.revealChat() self.store.ingestVoiceTurn(threadId: threadId, userText: userText) } diff --git a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift index 603e2b62..be7ed2f1 100644 --- a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift +++ b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift @@ -26,6 +26,10 @@ private let attachLog = Logger( protocol AgentChatEngine: AnyObject { /// True when the assistive provider can be built (keys present). func isAvailable() -> Bool + /// Actionable reason the assistive lane cannot reach a model right now, + /// `nil` when a send can proceed. Names the missing lane/endpoint/key so + /// the chat renders honest guidance instead of a generic "add an API key". + func availabilityDetail() -> String? /// Streams a real assistant reply. Callbacks fire on the main actor as tokens /// arrive; returns the final assembled text. /// @@ -525,10 +529,10 @@ final class AgentChatStore: ObservableObject { text: "Engine not wired yet.") return } - // Graceful no-key path. - if !engine.isAvailable() { - finish(assistantID, in: threadID, - text: "I can't reach the model yet — add an API key in Settings to enable assistive replies.") + // Graceful unavailable path — the engine reports WHAT is missing + // (lane, endpoint or key) so the reply is actionable, not generic. + if let unavailableDetail = engine.availabilityDetail() { + finish(assistantID, in: threadID, text: unavailableDetail) return } let start = Date() @@ -1048,6 +1052,7 @@ final class AgentChatStore: ObservableObject { #if DEBUG final class MockChatEngine: AgentChatEngine { func isAvailable() -> Bool { true } + func availabilityDetail() -> String? { nil } func streamReply( _ text: String, threadId: String, diff --git a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift index 293b62d3..0db8f621 100644 --- a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift +++ b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift @@ -89,15 +89,17 @@ struct DictationOverlayView: View { rippling: true ) .padding(.leading, 6) + .accessibilityIdentifier("overlay-phase-status") } else { StaticStatusPill(text: state.statusText, color: state.statusColor) .padding(.leading, 6) + .accessibilityIdentifier("overlay-phase-status") } Spacer(minLength: 0) HStack(spacing: 14) { CSIconView(icon: .mic, size: 15, weight: .medium) CSIconView(icon: .settings, size: 15, weight: .medium) - CSIconView(icon: .more, size: 15, weight: .medium) + placementMenu } .foregroundStyle(CSColor.textFaint) } @@ -105,6 +107,29 @@ struct DictationOverlayView: View { .padding(.vertical, 12) } + /// Placement config under the `…` icon: six screen anchors or free motion. + /// Selecting an anchor exits free motion (the pick's intent is "go there"); + /// the reposition itself is orchestrated via `OverlayState.onPlacementChanged`. + private var placementMenu: some View { + Menu { + Picker("Position", selection: $state.placementAnchor) { + ForEach(OverlayAnchor.allCases) { anchor in + Text(anchor.label).tag(anchor) + } + } + .pickerStyle(.inline) + Divider() + Toggle("Free motion", isOn: $state.freeMotion) + } label: { + CSIconView(icon: .more, size: 15, weight: .medium) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityIdentifier("overlay-placement-menu") + } + // MARK: Mode + meta row private var modeMetaRow: some View { @@ -160,8 +185,8 @@ struct DictationOverlayView: View { private var listeningBody: some View { VStack(alignment: .leading, spacing: 0) { WaveformView( - active: !state.transcribing && (state.audioReady || state.vadActive), - transcribing: state.transcribing + active: !state.transcribing && !state.isFinalPass && (state.audioReady || state.vadActive), + transcribing: state.transcribing || state.isFinalPass ) .padding(.top, 4) .padding(.bottom, 8) @@ -190,6 +215,7 @@ struct DictationOverlayView: View { .lineSpacing(5) .foregroundStyle(CSColor.textBody) .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("overlay-transcript-live") BlinkingCaret() } Color.clear @@ -199,6 +225,7 @@ struct DictationOverlayView: View { .frame(maxWidth: .infinity, alignment: .leading) } .frame(minHeight: transcriptMinHeight) + .accessibilityIdentifier("overlay-transcript-area") .onChange(of: state.listeningDisplay) { _, _ in scrollToTail(proxy) } @@ -228,6 +255,7 @@ struct DictationOverlayView: View { .scrollContentBackground(.hidden) .background(Color.clear) .frame(minHeight: bodyMinHeight) + .accessibilityIdentifier("overlay-transcript-formatted") } /// Terminal outcome for a session that captured no usable speech. Replaces @@ -428,6 +456,7 @@ struct DictationOverlayView: View { Spacer(minLength: 0) Text(state.footerRight) .foregroundStyle(CSColor.textFaintAlt) + .accessibilityIdentifier("overlay-phase-footer") } .csMono(10, .medium) .padding(.horizontal, 20) diff --git a/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift b/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift index 4eeb280c..b92d53ba 100644 --- a/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift +++ b/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift @@ -62,11 +62,14 @@ enum DictationOverlayWindow { /// lines) without the content column overflowing the window and squaring the /// glass corners. Width floor (320) is unchanged. static let minSize = NSSize(width: 320, height: 300) - /// First-launch content size (no persisted value yet). + /// First-launch content size (no persisted value yet). LANDSCAPE rectangle — + /// operator spec: the resting state is a horizontal bar (waveform + a few + /// transcript lines), never a portrait column. Resizing persists, so users + /// who prefer a tall panel drag it once and keep it. static let defaultSize = NSSize(width: 470, height: 330) - /// Bumped v2 → v3 with the slim-down: the old persisted (larger) content sizes - /// must not resurrect the pre-slim frame, so the new default takes effect once. - private static let sizeDefaultsKey = "DictationOverlayPanel.contentSize.v3" + /// Bumped v4 → v5: v4 shipped a portrait default by mistake; the restored + /// landscape default must take effect once over that persisted shape. + private static let sizeDefaultsKey = "DictationOverlayPanel.contentSize.v5" /// Build the floating overlay panel around an injected `OverlayState`. /// The state's `engine`, `onClose`, and `onSendToAgent` are wired by the diff --git a/macos/Codescribe/Screens/Overlay/OverlayPlacement.swift b/macos/Codescribe/Screens/Overlay/OverlayPlacement.swift new file mode 100644 index 00000000..da111c6f --- /dev/null +++ b/macos/Codescribe/Screens/Overlay/OverlayPlacement.swift @@ -0,0 +1,121 @@ +import AppKit + +// Placement model for the dictation overlay panel. +// +// Two modes, deliberately binary (no hidden third state): +// - Anchored (default): the origin is ALWAYS derived from one of six screen +// anchors on every show(). A drag in this mode is ephemeral — the next show +// snaps back to the anchor. Predictability over cleverness. +// - Free motion: the user's last dragged origin is persisted and restored +// (clamped to the visible frame); the anchor is ignored. +// +// Size is persisted independently of either mode (DictationOverlayWindow). + +enum OverlayAnchor: String, CaseIterable, Identifiable { + case topLeft = "top-left" + case topCenter = "top-center" + case topRight = "top-right" + case bottomLeft = "bottom-left" + case bottomCenter = "bottom-center" + case bottomRight = "bottom-right" + + var id: String { rawValue } + + var label: String { + switch self { + case .topLeft: return "Top Left" + case .topCenter: return "Top Center" + case .topRight: return "Top Right" + case .bottomLeft: return "Bottom Left" + case .bottomCenter: return "Bottom Center" + case .bottomRight: return "Bottom Right" + } + } +} + +enum OverlayPlacement { + /// Gap between the panel and the visible-frame edge. The visible frame + /// already excludes the menu bar, so a top anchor sits just under it — + /// `.topRight` lands the panel under the tray icon. + static let margin: CGFloat = 12 + + static let defaultAnchor: OverlayAnchor = .topRight + + private static let anchorKey = "DictationOverlayPanel.anchor.v1" + private static let freeMotionKey = "DictationOverlayPanel.freeMotion.v1" + private static let originKey = "DictationOverlayPanel.origin.v1" + + static var anchor: OverlayAnchor { + get { + guard let raw = UserDefaults.standard.string(forKey: anchorKey), + let stored = OverlayAnchor(rawValue: raw) + else { return defaultAnchor } + return stored + } + set { UserDefaults.standard.set(newValue.rawValue, forKey: anchorKey) } + } + + static var freeMotion: Bool { + get { UserDefaults.standard.bool(forKey: freeMotionKey) } + set { UserDefaults.standard.set(newValue, forKey: freeMotionKey) } + } + + /// Pure anchor→origin math over a visible frame, split from the NSScreen + /// wrapper so it is unit-testable without a display. + static func origin(for anchor: OverlayAnchor, size: NSSize, in visible: NSRect) -> NSPoint { + let x: CGFloat + switch anchor { + case .topLeft, .bottomLeft: + x = visible.minX + margin + case .topCenter, .bottomCenter: + x = visible.midX - size.width / 2 + case .topRight, .bottomRight: + x = visible.maxX - size.width - margin + } + let y: CGFloat + switch anchor { + case .topLeft, .topCenter, .topRight: + y = visible.maxY - size.height - margin + case .bottomLeft, .bottomCenter, .bottomRight: + y = visible.minY + margin + } + return NSPoint(x: x, y: y) + } + + static func origin(for anchor: OverlayAnchor, size: NSSize, on screen: NSScreen?) -> NSPoint? { + guard let visible = screen?.visibleFrame else { return nil } + return origin(for: anchor, size: size, in: visible) + } + + /// Free-motion memory: the last dragged origin, restored on show. + static func persistOrigin(_ point: NSPoint) { + let defaults = UserDefaults.standard + defaults.set(Double(point.x), forKey: originKey + ".x") + defaults.set(Double(point.y), forKey: originKey + ".y") + } + + /// Restore the persisted free-motion origin, clamped so the panel stays + /// fully inside the screen's visible frame (displays may have changed). + static func restoredOrigin(size: NSSize, on screen: NSScreen?) -> NSPoint? { + let defaults = UserDefaults.standard + guard defaults.object(forKey: originKey + ".x") != nil, + defaults.object(forKey: originKey + ".y") != nil + else { return nil } + let raw = NSPoint( + x: defaults.double(forKey: originKey + ".x"), + y: defaults.double(forKey: originKey + ".y") + ) + guard let visible = screen?.visibleFrame else { return raw } + return clampOrigin(raw, size: size, in: visible) + } + + /// Pure clamp, testable without a display. + static func clampOrigin(_ origin: NSPoint, size: NSSize, in visible: NSRect) -> NSPoint { + let maxX = visible.maxX - size.width + let maxY = visible.maxY - size.height + return NSPoint( + x: min(max(origin.x, visible.minX), max(visible.minX, maxX)), + y: min(max(origin.y, visible.minY), max(visible.minY, maxY)) + ) + } +} diff --git a/macos/Codescribe/Screens/Overlay/OverlayState.swift b/macos/Codescribe/Screens/Overlay/OverlayState.swift index f284b481..8510e8be 100644 --- a/macos/Codescribe/Screens/Overlay/OverlayState.swift +++ b/macos/Codescribe/Screens/Overlay/OverlayState.swift @@ -109,11 +109,36 @@ final class OverlayState: ObservableObject { @Published var toast: String? // transient error notice @Published var errorMessage: String? @Published var isFormatting: Bool = false + /// Final pass phase (AI formatting / authoritative assembly after stop). + /// Set on `applySessionFinalised`, cleared on controller finish or reset. + /// Drives "final pass" status while the user still sees the live assembly. + @Published var isFinalPass: Bool = false /// Human-facing notice shown in the `.noSpeech` outcome body. Set when a /// session finalizes without usable text; refined by `on_no_speech`'s reason /// so VAD silence and quality-gate rejection read differently. @Published var noSpeechNotice: String = OverlayState.defaultNoSpeechNotice + // MARK: Panel placement (persisted; the window orchestrator repositions live) + /// Anchored placement: one of six screen anchors, applied on every show(). + /// Picking an anchor exits free motion — the pick's intent is "go there". + @Published var placementAnchor: OverlayAnchor = OverlayPlacement.anchor { + didSet { + guard placementAnchor != oldValue else { return } + OverlayPlacement.anchor = placementAnchor + if freeMotion { freeMotion = false } else { onPlacementChanged?() } + } + } + /// Free motion: the panel keeps (and restores) wherever the user dragged it. + @Published var freeMotion: Bool = OverlayPlacement.freeMotion { + didSet { + guard freeMotion != oldValue else { return } + OverlayPlacement.freeMotion = freeMotion + onPlacementChanged?() + } + } + /// Wired by the orchestrator: re-derive the visible panel's origin now. + var onPlacementChanged: (() -> Void)? + // MARK: Injected collaborators (all optional so #Preview renders standalone) /// The recording core. Injected by the orchestrator. Do NOT instantiate here. var engine: DictationEngine? @@ -142,6 +167,14 @@ final class OverlayState: ObservableObject { /// SAME text the delivery/paste and tray "Copy" use. When present it is the /// FINAL the overlay shows, instead of the raw per-utterance streaming assembly. private var authoritativeFinalText: String? + /// The delivered (pre-user-edit) text at the moment we entered .formatted. + /// Captured for P0-D quality loop: diff delivered→edited on Copy/Send/close. + private var deliveredText: String = "" + /// Best-effort raw STT transcript text (pre-AI formatting / postprocess) for + /// quality records. D-05: wired from authoritative final / STT assembly so + /// lexicon v2 and quality analytics get the real misheard text, not only + /// the (possibly formatted) delivered. Cleared on reset like deliveredText. + private var sttRawText: String = "" /// Once a session is finalized (mode `.formatted` / Idle), the transcript is /// FROZEN. Late streaming events (Preview/Correction/UtteranceFinal/VAD) that the /// engine may still emit during/after teardown are DROPPED instead of mutating @@ -159,6 +192,14 @@ final class OverlayState: ObservableObject { private var warmupWatchdogTask: Task? private static let warmupWatchdogNanos: UInt64 = 4_000_000_000 + // MARK: Auto-hide after passive final (no Copy/Send action) + private var autoHideTask: Task? + /// 12 seconds. Rationale: typical short dictation result is 1–3 sentences. + /// At normal reading speed + reaction to act (Copy/Send/Close) this gives + /// comfortable view time without the overlay remaining "król puszczy" after + /// the user has moved attention elsewhere. No user-facing knob (per spec). + private static let autoHideDelayNanos: UInt64 = 12_000_000_000 + init() {} func attach() { @@ -168,8 +209,11 @@ final class OverlayState: ObservableObject { // MARK: Derived display (one source of truth for the view) var statusText: String { - if mode == .error { return "error" } + if mode == .error { return "failed" } + if mode == .formatted { return "done" } + if mode == .noSpeech { return "no speech" } guard mode == .listening else { return "Idle" } + if isFinalPass { return "final pass" } if transcribing { return "transcribing" } return warmingUp ? "starting" : "recording" } @@ -181,10 +225,10 @@ final class OverlayState: ObservableObject { case .error: return CSColor.terracotta } } - /// Only the live-capture pill ripples. During `transcribing` we swap to the - /// static pill so its repeatForever animation tears down — a second visual - /// cue that capture has ended. - var statusRippling: Bool { mode == .listening && !transcribing && (audioReady || vadActive) } + /// Only the live-capture pill ripples. During `transcribing` / `final pass` we swap + /// to the static pill so its repeatForever animation tears down — a second visual + /// cue that capture has ended and post-processing is in flight. + var statusRippling: Bool { mode == .listening && !transcribing && !isFinalPass && (audioReady || vadActive) } var tagText: String { switch mode { @@ -204,6 +248,7 @@ final class OverlayState: ObservableObject { } var metaText: String { + if isFinalPass { return "final pass · formatting" } switch mode { case .listening: return transcribing ? "finalizing · transcript" : "live preview · raw" case .formatted: return "final · transcript" @@ -213,6 +258,7 @@ final class OverlayState: ObservableObject { } var footerRight: String { if isFormatting { return "formatting" } + if isFinalPass { return "final pass" } if mode == .noSpeech { return "no speech" } if mode == .error { return "error" } if mode == .listening && transcribing { return "transcribing" } @@ -232,7 +278,13 @@ final class OverlayState: ObservableObject { /// "listening…"/"starting…" during capture. The transcribing phase wins over any /// committed text so the post-capture state surfaces "transcribing…" here (not the /// raw streaming assembly) — the main-status counterpart to the header pill. + /// During final pass we keep the assembled transcript visible (user sees result + /// while AI formatting runs) — status/footer communicate the phase. var listeningDisplay: String { + if isFinalPass { + // Keep the captured assembly visible during final pass / AI formatting. + return !liveText.isEmpty ? liveText : "final pass…" + } if transcribing { return "transcribing…" } if !liveText.isEmpty { return liveText } return warmingUp ? "starting…" : "listening…" @@ -307,6 +359,7 @@ final class OverlayState: ObservableObject { let formatted = try await engine.formatText(text: source, language: nil) self.formattedText = formatted.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? source : formatted self.mode = .formatted + self.cancelAutoHide() // User acted (Format); do not auto-hide the result. } catch { self.errorMessage = "Couldn't format transcript: \(error)" self.showToast("Couldn't format transcript") @@ -326,6 +379,7 @@ final class OverlayState: ObservableObject { // is the id-ordered assembly of `UtteranceFinal` events (see liveText). _ = try await engine.stopRecording() recording = false + isFinalPass = false finalizeTranscript() // clears `transcribing` as it flips to `.formatted` } catch { presentTerminalError( @@ -338,17 +392,30 @@ final class OverlayState: ObservableObject { // MARK: Action row func copyToPasteboard() { + // P0-D: capture user correction on FINAL for quality loop + lexicon learning. + captureQualityIfEdited(action: "copy") let pb = NSPasteboard.general pb.clearContents() pb.setString(activeText, forType: .string) + cancelAutoHide() + // Per contract: after Copy the overlay hides (user acted; no linger). + onClose?() } func sendToAgent() { + // P0-D: capture user correction on FINAL for quality loop + lexicon learning. + captureQualityIfEdited(action: "send") + cancelAutoHide() onSendToAgent?(activeText) + // The onSendToAgent closure (wired in OverlayController) also hides; + // the cancel ensures timer is dead even if closure path changes. } func close() { + // P0-D: capture user correction on FINAL for quality loop + lexicon learning. + captureQualityIfEdited(action: "close") cancelWarmupWatchdog() + cancelAutoHide() mockRevealTask?.cancel() toastTask?.cancel() if recording, let engine { @@ -359,15 +426,41 @@ final class OverlayState: ObservableObject { audioReady = false warmingUp = false transcribing = false + isFinalPass = false onClose?() } + // MARK: P0-D quality loop (user edits on FINAL → record + lexicon candidate) + + private func captureQualityIfEdited(action: String) { + guard mode == .formatted else { return } + let delivered = deliveredText.trimmingCharacters(in: .whitespacesAndNewlines) + let edited = formattedText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !edited.isEmpty, delivered != edited else { return } + // Bridge FFI (generated by uniffi) appends the quality JSONL and feeds safe + // candidates to lexicon.custom.jsonl. That is blocking disk I/O, so it runs + // off the main actor — Copy/Send/Close must never wait on the disk. + // Raw is best-effort for MVP. + // D-05 over-correct: use sttRawText (wired from applyFinalTranscript / STT finals) + // as raw_text when available so quality records carry the real pre-formatting + // STT text for lexicon v2 consumers. Falls back to delivered (still better than ""). + let rawForRecord = !sttRawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? sttRawText + : delivered + Task.detached(priority: .utility) { + // Pass action through to meta (over-correct P2-03). try? because FFI throws on err but + // quality write is best-effort; never block UI action. + try? commitOverlayQualityRecord(rawText: rawForRecord, deliveredText: delivered, editedText: edited, action: action) + } + } + func prepareForExternalStart() { handleRecordingPreparing() } func handleRecordingPreparing() { finalized = false + isFinalPass = false mode = .listening warmingUp = true audioReady = false @@ -385,6 +478,7 @@ final class OverlayState: ObservableObject { func handleRecordingStarted() { cancelWarmupWatchdog() finalized = false + isFinalPass = false mode = .listening warmingUp = false audioReady = true @@ -403,6 +497,7 @@ final class OverlayState: ObservableObject { func finishControllerRecording() { cancelWarmupWatchdog() recording = false + isFinalPass = false finalizeTranscript() } @@ -454,15 +549,35 @@ final class OverlayState: ObservableObject { onClose?() } + private func armAutoHideIfNeeded() { + guard mode == .formatted, !finalized /* still visible */ else { return } + cancelAutoHide() + autoHideTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: OverlayState.autoHideDelayNanos) + guard !Task.isCancelled else { return } + // Only auto-dismiss if still in passive formatted state (no action taken). + if let self = self, self.mode == .formatted { + self.onClose?() + } + } + } + + private func cancelAutoHide() { + autoHideTask?.cancel() + autoHideTask = nil + } + private func abortRecordingSession(resetTranscript shouldResetTranscript: Bool = false) { let shouldNotifyStopped = !finalized && (recording || warmingUp || transcribing || audioReady || vadActive) cancelWarmupWatchdog() + cancelAutoHide() recording = false warmingUp = false transcribing = false audioReady = false vadActive = false + isFinalPass = false if shouldResetTranscript { resetTranscript() } @@ -486,6 +601,7 @@ final class OverlayState: ObservableObject { noSpeechNotice = OverlayState.defaultNoSpeechNotice formattedText = "" isFormatting = false + isFinalPass = false errorMessage = message mode = .error finalized = true @@ -590,7 +706,16 @@ final class OverlayState: ObservableObject { } func applySessionFinalised() { - finalizeTranscript() + guard !finalized else { return } + markTranscriptActivity() + // Enter final pass phase (the post-stop AI formatting / authoritative + // assembly). Status shows "final pass", transcript assembly remains + // visible; the controller finish will surface the resolved .formatted. + isFinalPass = true + transcribing = false + // Do not call finalizeTranscript here — that is driven by + // finishControllerRecording (or equivalent terminal) so the phase + // is observable to the user. } /// `on_no_speech` — the engine adjudicated the session with no usable speech. @@ -638,6 +763,12 @@ final class OverlayState: ObservableObject { formattedText = clean mode = .formatted } + if deliveredText.isEmpty, !clean.isEmpty { + deliveredText = clean + } + if sttRawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !clean.isEmpty { + sttRawText = clean + } } /// Single authoritative finalize. `runStop`, `finishControllerRecording`, and @@ -673,11 +804,18 @@ final class OverlayState: ObservableObject { mode = .noSpeech } else { if formattedText != resolved { formattedText = resolved } + if deliveredText.isEmpty { deliveredText = resolved } + if sttRawText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + // Best effort: if no STT raw from per-utterance finals yet, fall back to the + // resolved assembly (still the raw-streaming path, not AI formatted). + sttRawText = resolved + } mode = .formatted } // FREEZE: from here, late streaming events are dropped (see the apply guards) // so nothing keeps mutating @Published state and re-rendering in Idle. finalized = true + isFinalPass = false // Notify the recording-lifecycle sink that the session ended. This is the // stop-side counterpart to `handleRecordingStarted` firing `onRecordingStarted?()`: // the tray otherwise only clears its "Recording" pill via the popover's one-shot @@ -685,6 +823,13 @@ final class OverlayState: ObservableObject { // so redundant re-finalizes (finishControllerRecording + applySessionFinalised) // don't re-fire and churn @Published tray state. if !wasFinalized { onRecordingStopped?() } + + // Arm passive auto-hide for the result view when user takes no action. + // 12s chosen: enough to read 2–4 sentence transcript + decide (Copy/Send/Close), + // short enough not to linger as "król puszczy". Constant (no Settings knob). + if mode == .formatted { + armAutoHideIfNeeded() + } } private var usableAuthoritativeFinalText: String? { @@ -698,10 +843,14 @@ final class OverlayState: ObservableObject { committedSegments = [] committedUtterances = [] authoritativeFinalText = nil + deliveredText = "" + sttRawText = "" pendingNoSpeechMessage = nil noSpeechNotice = OverlayState.defaultNoSpeechNotice finalized = false transcribing = false + isFinalPass = false + cancelAutoHide() } private func markTranscriptActivity() { diff --git a/macos/Codescribe/Screens/Settings/EnginePanel.swift b/macos/Codescribe/Screens/Settings/EnginePanel.swift index be5d8dc1..84b288a3 100644 --- a/macos/Codescribe/Screens/Settings/EnginePanel.swift +++ b/macos/Codescribe/Screens/Settings/EnginePanel.swift @@ -2,9 +2,9 @@ import SwiftUI // Engine panel: runtime truth + engine controls. The key/value runtime rows are // READ-ONLY (sourced from the live CsSettings snapshot, not hardcoded) and the -// permission matrix reflects live status. The "Engine controls" section below the -// runtime rows is editable (F1 layered transcription): STT engine selector + -// layered-transcription toggle, persisted through the promoted-key config router. +// permission matrix reflects live status. "Engine controls" and "LLM lanes" are +// editable: STT/layered controls plus per-lane provider, endpoint, and model +// overrides, all persisted through the promoted-key config router. struct EnginePanel: View { @ObservedObject var model: SettingsViewModel @@ -50,6 +50,11 @@ struct EnginePanel: View { engineControls .padding(.top, 11) + SettingsSectionLabel("LLM lanes") + .padding(.top, 22) + LLMLanesSection(model: model) + .padding(.top, 11) + SettingsSectionLabel("Permission matrix") .padding(.top, 22) LazyVGrid(columns: columns, spacing: 8) { @@ -61,7 +66,7 @@ struct EnginePanel: View { HStack(spacing: 8) { Text("●").font(CSFont.mono(11, .medium)).foregroundStyle(CSColor.olive) - Text("runtime rows reflect the live engine — engine controls apply from the next recording session") + Text("runtime rows reflect the live engine — changes apply on the next recording session or LLM request") .font(CSFont.mono(11, .medium)) .foregroundStyle(CSColor.textFaint) } @@ -99,12 +104,15 @@ struct EnginePanel: View { RuntimeRow(key: "AI formatting", value: model.formattingDescription, tint: false, trailing: .none) divider - RuntimeRow(key: "LLM model", value: model.llmModelDescription, - tint: true, mono: true, trailing: .none) - divider - RuntimeRow(key: "LLM endpoint", value: model.llmEndpointDescription, - tint: false, mono: true, trailing: .none) - divider + ForEach(LLMLane.allCases) { lane in + let laneModel = model.llmLane(lane) + RuntimeRow(key: "\(lane.title) endpoint", value: laneModel.resolvedEndpoint, + tint: false, mono: true, trailing: .none) + divider + RuntimeRow(key: "\(lane.title) model", value: laneModel.resolvedModel, + tint: true, mono: true, trailing: .none) + divider + } RuntimeRow(key: "API keys", value: model.apiKeysDescription, tint: true, trailing: model.apiKeysStored ? .text("secure", CSColor.oliveLight) : .text("missing", CSColor.amber)) @@ -152,7 +160,7 @@ struct EnginePanel: View { } } } label: { - EngineMenuLabel(text: model.sttEngineLabel) + SettingsMenuLabel(text: model.sttEngineLabel) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) @@ -169,20 +177,253 @@ struct EnginePanel: View { } } -// MARK: - Engine dropdown label (mirrors the KeysPanel MenuLabel shape) +// MARK: - Editable LLM lanes -private struct EngineMenuLabel: View { - let text: String +/// Three request lanes sharing one visual grammar while preserving their distinct +/// promoted config keys. Runtime rows above remain the effective read-only truth. +private struct LLMLanesSection: View { + @ObservedObject var model: SettingsViewModel var body: some View { - HStack(spacing: 6) { - Text(text) - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textHigh) - .lineLimit(1) - CSIconView(icon: .chevronUpDown, size: 9, weight: .semibold, color: CSColor.textFaint) + VStack(alignment: .leading, spacing: 14) { + Text("Set provider, endpoint, and model per request path. Leave an override empty to use the resolved fallback.") + .font(CSFont.ui(11.5)) + .lineSpacing(2) + .foregroundStyle(CSColor.textMutedAlt) + + ForEach(LLMLane.allCases) { lane in + LLMLaneEditor(model: model, lane: lane) + if lane != LLMLane.allCases.last { + Rectangle() + .fill(CSColor.hairline(0.05)) + .frame(height: 1) + } + } + } + } +} + +private struct LLMLaneEditor: View { + @ObservedObject var model: SettingsViewModel + let lane: LLMLane + + @State private var endpointDraft = "" + @State private var modelDraft = "" + + private var laneModel: LLMLaneModel { model.llmLane(lane) } + + private var providerLabel: String { + laneModel.provider?.displayName ?? laneModel.providerId + } + + private var currentModelLabel: String { + laneModel.modelOptions.first { $0.id == laneModel.resolvedModel }?.displayName + ?? laneModel.resolvedModel + } + + private var discoveryDotColor: Color { + if laneModel.manualModelReason != nil { return CSColor.textFaint } + switch laneModel.discovery.status { + case "fresh": return CSColor.olive + case "cached": return CSColor.amber + case "no_key", "loading": return CSColor.textFaint + default: return CSColor.terracotta + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(lane.title) + .font(CSFont.ui(14.5, .bold)) + .foregroundStyle(CSColor.textHigh) + Text(lane.subtitle) + .font(CSFont.ui(11.5)) + .foregroundStyle(CSColor.textMutedAlt) + } + + if lane == .assistive { + SettingsControlRow(title: "Provider", subtitle: "Assistive requests only") { + Menu { + ForEach(model.providers, id: \.id) { provider in + Button { + model.setAssistiveProvider(provider.id) + } label: { + if provider.id == laneModel.providerId { + Label(provider.displayName, systemImage: "checkmark") + } else { + Text(provider.displayName) + } + } + } + } label: { + SettingsMenuLabel(text: providerLabel) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityLabel("Assistive provider") + .accessibilityValue(providerLabel) + } + } + + SettingsControlRow(title: "Endpoint", subtitle: lane.endpointKey) { + HStack(spacing: 8) { + overrideTextField( + placeholder: laneModel.resolvedEndpoint, + text: $endpointDraft, + accessibilityLabel: "\(lane.title) LLM endpoint", + onSubmit: saveEndpoint + ) + + saveOverrideButton( + draft: endpointDraft, + accessibilityLabel: "Save \(lane.title) endpoint", + action: saveEndpoint + ) + + resetOverrideButton( + help: "Clear this endpoint override", + accessibilityLabel: "Reset \(lane.title) endpoint" + ) { + endpointDraft = "" + model.setLLMEndpoint("", for: lane) + } + } + .frame(width: 380) + } + + SettingsControlRow(title: "Model", subtitle: lane.modelKey) { + HStack(spacing: 8) { + if laneModel.discovery.status == "loading" + && laneModel.manualModelReason == nil + { + HStack(spacing: 7) { + ProgressView() + .controlSize(.small) + Text("Discovering models…") + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaint) + } + .frame(maxWidth: .infinity, alignment: .trailing) + .accessibilityLabel("Discovering \(lane.title) models") + } else if laneModel.usesDiscoveredPicker { + Menu { + ForEach(laneModel.modelOptions, id: \.id) { option in + Button { + model.setLLMModel(option.id, for: lane) + } label: { + if option.id == laneModel.resolvedModel { + Label(option.displayName, systemImage: "checkmark") + } else { + Text(option.displayName) + } + } + } + } label: { SettingsMenuLabel(text: currentModelLabel) } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(maxWidth: .infinity, alignment: .trailing) + .accessibilityLabel("\(lane.title) model") + .accessibilityValue(currentModelLabel) + } else { + overrideTextField( + placeholder: laneModel.resolvedModel, + text: $modelDraft, + accessibilityLabel: "\(lane.title) model ID", + onSubmit: saveModel + ) + + saveOverrideButton( + draft: modelDraft, + accessibilityLabel: "Save \(lane.title) model", + action: saveModel + ) + } + + resetOverrideButton( + help: "Clear this model override", + accessibilityLabel: "Reset \(lane.title) model" + ) { + modelDraft = "" + model.setLLMModel("", for: lane) + } + } + .frame(width: 380) + } + + HStack(spacing: 8) { + Circle() + .fill(discoveryDotColor.opacity(0.85)) + .frame(width: 7, height: 7) + Text(laneModel.discoveryDescription) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaint) + .lineLimit(2) + } + .padding(.leading, 2) } } + + private func saveEndpoint() { + model.setLLMEndpoint(endpointDraft, for: lane) + endpointDraft = "" + } + + private func saveModel() { + model.setLLMModel(modelDraft, for: lane) + modelDraft = "" + } + + private func overrideTextField( + placeholder: String, + text: Binding, + accessibilityLabel: String, + onSubmit: @escaping () -> Void + ) -> some View { + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(CSFont.mono(11.5, .regular)) + .foregroundStyle(CSColor.textBody) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + .onSubmit(onSubmit) + .accessibilityLabel(accessibilityLabel) + } + + private func saveOverrideButton( + draft: String, + accessibilityLabel: String, + action: @escaping () -> Void + ) -> some View { + Button("Save", action: action) + .font(CSFont.ui(11.5, .semibold)) + .foregroundStyle(draft.isEmpty ? CSColor.textFaint : CSColor.terracottaLight) + .buttonStyle(.plain) + .disabled(draft.isEmpty) + .accessibilityLabel(accessibilityLabel) + } + + private func resetOverrideButton( + help: String, + accessibilityLabel: String, + action: @escaping () -> Void + ) -> some View { + Button("Reset", action: action) + .font(CSFont.ui(11.5, .semibold)) + .foregroundStyle(CSColor.textMutedAlt) + .buttonStyle(.plain) + .help(help) + .accessibilityLabel(accessibilityLabel) + } } // MARK: - Agent workspace roots editor @@ -492,6 +733,41 @@ struct SettingsSectionLabel: View { } } +struct SettingsMenuLabel: View { + let text: String + var mono: Bool = false + var chrome: Bool = false + + var body: some View { + if chrome { + content + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + .contentShape(Rectangle()) + } else { + content + } + } + + private var content: some View { + HStack(spacing: 6) { + Text(text) + .font(mono ? CSFont.mono(12.5, .semibold) : CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textHigh) + .lineLimit(1) + CSIconView(icon: .chevronUpDown, size: 9, weight: .semibold, color: CSColor.textFaint) + } + } +} + #if DEBUG #Preview("Engine panel") { ScrollView { EnginePanel(model: .preview(.engine)) } diff --git a/macos/Codescribe/Screens/Settings/KeysPanel.swift b/macos/Codescribe/Screens/Settings/KeysPanel.swift index 01c4600c..a77afe86 100644 --- a/macos/Codescribe/Screens/Settings/KeysPanel.swift +++ b/macos/Codescribe/Screens/Settings/KeysPanel.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI // Keys panel: write-only API-key management. Secrets are entered through a @@ -32,17 +33,24 @@ struct KeysPanel: View { .padding(.top, 22) VStack(spacing: 8) { ForEach(model.keyAccounts, id: \.self) { account in + let provider = model.providerForKeyAccount(account) KeyRow( account: account, label: SettingsViewModel.keyLabel(for: account), isSet: model.keyStatus.isSet(account: account), probeResult: model.keyProbeResults[account], probePending: model.keyProbePending.contains(account), - accountProvider: model.providerForKeyAccount(account), + accountProvider: provider, + accountLoginPending: provider.map { + model.accountLoginPending.contains($0.id) + } ?? false, + accountLoginNotice: provider.flatMap { model.accountLoginNotices[$0.id] }, onSave: { model.saveKey(account: account, secret: $0) }, onClear: { model.clearKey(account: account) }, onTest: { model.testKey(account: account) }, - onStartAccountLogin: { model.startAccountLogin(providerId: $0) } + onStartAccountLogin: { model.startAccountLogin(providerId: $0) }, + onSignOutAccount: { model.signOutAccount(providerId: $0) }, + onSaveOauthClientId: { model.saveOauthClientId(providerId: $0, value: $1) } ) } } @@ -68,11 +76,10 @@ struct KeysPanel: View { private struct AgentProviderSelector: View { @ObservedObject var model: SettingsViewModel - private var selected: CsProviderOption? { model.selectedProvider } - private var discoveredModels: [CsModelOption] { model.discoveredModels } + private var laneModel: LLMLaneModel { model.llmLane(.assistive) } private var discoveryDotColor: Color { - switch model.modelDiscoveryStatus { + switch laneModel.discovery.status { case "fresh": return CSColor.olive case "cached": return CSColor.amber case "no_key": return CSColor.textFaint @@ -81,22 +88,22 @@ private struct AgentProviderSelector: View { } private var currentModelLabel: String { - let id = model.assistiveModel + let id = laneModel.configuredModel if id.isEmpty { - return discoveredModels.isEmpty ? "No discovered models" : "Choose a model" + return laneModel.modelOptions.isEmpty ? "No discovered models" : "Choose a model" } - return discoveredModels.first { $0.id == id }?.displayName ?? id + return laneModel.modelOptions.first { $0.id == id }?.displayName ?? id } var body: some View { VStack(alignment: .leading, spacing: 10) { SelectorRow(label: "Provider") { Menu { - ForEach(model.availableProviders, id: \.id) { provider in + ForEach(model.providers, id: \.id) { provider in Button { model.setAssistiveProvider(provider.id) } label: { - if provider.id == model.assistiveProviderId { + if provider.id == laneModel.providerId { Label(provider.displayName, systemImage: "checkmark") } else { Text(provider.displayName) @@ -104,7 +111,7 @@ private struct AgentProviderSelector: View { } } } label: { - MenuLabel(text: selected?.displayName ?? model.assistiveProviderId) + SettingsMenuLabel(text: laneModel.provider?.displayName ?? laneModel.providerId, chrome: true) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) @@ -112,11 +119,11 @@ private struct AgentProviderSelector: View { SelectorRow(label: "Model") { Menu { - ForEach(discoveredModels, id: \.id) { option in + ForEach(laneModel.modelOptions, id: \.id) { option in Button { - model.setAssistiveModel(option.id) + model.setLLMModel(option.id, for: .assistive) } label: { - if option.id == model.assistiveModel { + if option.id == laneModel.configuredModel { Label(option.displayName, systemImage: "checkmark") } else { Text(option.displayName) @@ -124,14 +131,14 @@ private struct AgentProviderSelector: View { } } } label: { - MenuLabel(text: currentModelLabel, mono: true) + SettingsMenuLabel(text: currentModelLabel, mono: true, chrome: true) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) - .disabled(discoveredModels.isEmpty) + .disabled(laneModel.modelOptions.isEmpty) } - if let selected { + if let selected = laneModel.provider { HStack(spacing: 8) { Circle() .fill((selected.apiKeySet ? CSColor.olive : CSColor.terracotta).opacity(0.85)) @@ -146,7 +153,7 @@ private struct AgentProviderSelector: View { Circle() .fill(discoveryDotColor.opacity(0.85)) .frame(width: 7, height: 7) - Text(model.modelDiscoveryDescription) + Text(laneModel.discoveryDescription) .font(CSFont.mono(11, .medium)) .foregroundStyle(CSColor.textFaint) .lineLimit(2) @@ -181,32 +188,6 @@ private struct SelectorRow: View { } } -private struct MenuLabel: View { - let text: String - var mono: Bool = false - - var body: some View { - HStack(spacing: 6) { - Text(text) - .font(mono ? CSFont.mono(12.5, .semibold) : CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textHigh) - .lineLimit(1) - CSIconView(icon: .chevronUpDown, size: 9, weight: .semibold, color: CSColor.textFaint) - } - .padding(.horizontal, 11) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .fill(CSColor.surfaceRaised(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) - .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) - ) - .contentShape(Rectangle()) - } -} - // MARK: - Key row private struct KeyRow: View { @@ -216,10 +197,14 @@ private struct KeyRow: View { let probeResult: CsApiKeyProbeResult? let probePending: Bool let accountProvider: CsProviderOption? + let accountLoginPending: Bool + let accountLoginNotice: String? let onSave: (String) -> Void let onClear: () -> Void let onTest: () -> Void let onStartAccountLogin: (String) -> Void + let onSignOutAccount: (String) -> Void + let onSaveOauthClientId: (String, String) -> Void @State private var draft: String = "" @@ -332,7 +317,11 @@ private struct KeyRow: View { if let accountProvider { AccountLoginRow( provider: accountProvider, - onStart: { onStartAccountLogin(accountProvider.id) } + loginPending: accountLoginPending, + loginNotice: accountLoginNotice, + onStart: { onStartAccountLogin(accountProvider.id) }, + onSignOut: { onSignOutAccount(accountProvider.id) }, + onSaveClientId: { onSaveOauthClientId(accountProvider.id, $0) } ) } } @@ -359,14 +348,20 @@ private struct KeyProbeChip: View { let result: CsApiKeyProbeResult private var label: String { + let verdict: String switch result.status { - case .ok: return "Key OK" - case .invalid: return "Invalid key" - case .noQuota: return "No credits (check billing)" - case .network: return "Network error" - case .missing: return "Not set" - case .unsupported: return "Unsupported" + case .ok: verdict = "Key OK" + case .invalid: verdict = "Invalid key" + case .noQuota: verdict = "No credits (check billing)" + case .network: verdict = "Network error" + case .missing: verdict = "Not set" + case .unsupported: verdict = "Unsupported" } + guard let endpoint = result.probedEndpoint, + let host = URL(string: endpoint)?.host, + !host.isEmpty + else { return verdict } + return "\(verdict) @ \(host)" } private var tint: Color { @@ -393,47 +388,108 @@ private struct KeyProbeChip: View { Capsule() .strokeBorder(tint.opacity(0.24), lineWidth: 1) ) - .help(result.message) + .help( + result.probedEndpoint.map { + "\(result.message)\nEndpoint: \($0)" + } ?? result.message + ) } } private struct AccountLoginRow: View { let provider: CsProviderOption + let loginPending: Bool + let loginNotice: String? let onStart: () -> Void + let onSignOut: () -> Void + let onSaveClientId: (String) -> Void + + @State private var clientIdDraft: String = "" private var signedIn: Bool { provider.accountSignedIn } private var accent: Color { signedIn ? CSColor.olive : CSColor.textFaint } - private var statusText: String { - signedIn ? "signed in" : "not signed in" - } var body: some View { - HStack(spacing: 10) { - Circle() - .fill(accent.opacity(0.85)) - .frame(width: 7, height: 7) - Text("ChatGPT account") - .font(CSFont.ui(12.5, .semibold)) - .foregroundStyle(CSColor.textBody) - Text(statusText) - .font(CSFont.mono(10, .semibold)) - .foregroundStyle(accent) - if !provider.accountStatusMessage.isEmpty, provider.accountStatusMessage != statusText { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Circle() + .fill(accent.opacity(0.85)) + .frame(width: 7, height: 7) + Text("ChatGPT account") + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + // Carries "signed in as " / "not signed in" / + // "awaiting app registration" straight from the core. Text(provider.accountStatusMessage) - .font(CSFont.mono(10, .medium)) - .foregroundStyle(CSColor.textFaint) + .font(CSFont.mono(10, .semibold)) + .foregroundStyle(accent) .lineLimit(1) - } - Spacer(minLength: 0) - Button(action: onStart) { - HStack(spacing: 6) { - CSIconView(icon: .accountVerified, size: 12, weight: .semibold) - Text("Sign in with ChatGPT") - .font(CSFont.ui(12, .semibold)) + if let loginNotice, !loginNotice.isEmpty { + Text(loginNotice) + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.terracottaLight) + .lineLimit(1) + .help(loginNotice) + } + Spacer(minLength: 0) + if signedIn { + AccountActionButton( + title: "Sign out", + tint: CSColor.terracottaLight, + enabled: !loginPending, + action: onSignOut + ) + .help("Remove the stored ChatGPT account tokens") + } + Button(action: onStart) { + HStack(spacing: 6) { + if loginPending { + ProgressView() + .controlSize(.small) + .scaleEffect(0.62) + .frame(width: 14, height: 12) + } else { + CSIconView(icon: .accountVerified, size: 12, weight: .semibold) + } + Text(loginPending ? "Waiting for browser…" : "Sign in with ChatGPT") + .font(CSFont.ui(12, .semibold)) + } + .foregroundStyle( + provider.accountLoginEnabled && !loginPending + ? CSColor.oliveLight : CSColor.textFaint + ) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) } - .foregroundStyle(provider.accountLoginEnabled ? CSColor.oliveLight : CSColor.textFaint) - .padding(.horizontal, 12) - .padding(.vertical, 7) + .buttonStyle(.plain) + .disabled(!provider.accountLoginEnabled || loginPending) + .help(provider.accountStatusMessage) + } + + // OAuth client id — non-secret app identity (NOT a credential), so a + // plain TextField with the value visible is correct here. Saving an + // empty field clears back to "awaiting app registration". + HStack(spacing: 8) { + Text("client id") + .font(CSFont.mono(10, .medium)) + .foregroundStyle(CSColor.textFaint) + TextField( + provider.oauthClientId == nil ? "Paste OAuth client id…" : "", + text: $clientIdDraft + ) + .textFieldStyle(.plain) + .font(CSFont.mono(11, .regular)) + .foregroundStyle(CSColor.textBody) + .padding(.horizontal, 9) + .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) .fill(CSColor.surfaceRaised(0.03)) @@ -442,11 +498,47 @@ private struct AccountLoginRow: View { RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) ) + .onSubmit { onSaveClientId(clientIdDraft) } + AccountActionButton( + title: "Save", + tint: CSColor.oliveLight, + enabled: clientIdDraft != (provider.oauthClientId ?? ""), + action: { onSaveClientId(clientIdDraft) } + ) + .help("Store the client id (settings.json) — applies without restart") } - .buttonStyle(.plain) - .disabled(!provider.accountLoginEnabled) - .help(provider.accountStatusMessage) } + .onAppear { clientIdDraft = provider.oauthClientId ?? "" } + .onChange(of: provider.oauthClientId) { _, updated in + clientIdDraft = updated ?? "" + } + } +} + +private struct AccountActionButton: View { + let title: String + let tint: Color + let enabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(CSFont.ui(11.5, .semibold)) + .foregroundStyle(enabled ? tint : CSColor.textFaint) + .padding(.horizontal, 11) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.surfaceRaised(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.hairline(0.08), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(!enabled) } } diff --git a/macos/Codescribe/Screens/Settings/SettingsEngine.swift b/macos/Codescribe/Screens/Settings/SettingsEngine.swift index b88d759f..03bb1b8b 100644 --- a/macos/Codescribe/Screens/Settings/SettingsEngine.swift +++ b/macos/Codescribe/Screens/Settings/SettingsEngine.swift @@ -27,6 +27,9 @@ protocol SettingsEngine { func onboardingMode() -> String? func setOnboardingMode(mode: String) throws + /// Delegates to core lane_truth normalization (eliminates suffix-list dupe in Swift). + func normalizeOpenaiResponsesEndpoint(_ endpoint: String) -> String + // Config writes (auto-tiered by the core router) func updateConfig(key: String, value: String) throws func updateConfigMany(entries: [CsConfigEntry]) throws @@ -42,6 +45,11 @@ protocol SettingsEngine { func availableProviders() -> [CsProviderOption] func discoverModels(providerId: String) -> CsModelDiscovery func startAccountLogin(providerId: String) throws -> CsAccountLoginResult + // Blocks until the in-flight login completes/fails/times out — call from a + // background queue only. Timeout shuts the local callback server down. + func awaitAccountLogin(providerId: String, timeoutSeconds: UInt64) throws -> CsAccountLoginResult + func cancelAccountLogin() + func signOutAccount(providerId: String) throws // Editable BASE prompts func getFormattingPrompt() -> String @@ -71,6 +79,10 @@ final class RealSettingsEngine: SettingsEngine { func onboardingMode() -> String? { config.onboardingMode() } func setOnboardingMode(mode: String) throws { try config.setOnboardingMode(mode: mode) } + func normalizeOpenaiResponsesEndpoint(_ endpoint: String) -> String { + config.normalizeOpenaiResponsesEndpoint(endpoint: endpoint) + } + func updateConfig(key: String, value: String) throws { try config.updateConfig(key: key, value: value) } @@ -95,6 +107,13 @@ final class RealSettingsEngine: SettingsEngine { func startAccountLogin(providerId: String) throws -> CsAccountLoginResult { try config.startAccountLogin(providerId: providerId) } + func awaitAccountLogin(providerId: String, timeoutSeconds: UInt64) throws -> CsAccountLoginResult { + try config.awaitAccountLogin(providerId: providerId, timeoutSeconds: timeoutSeconds) + } + func cancelAccountLogin() { config.cancelAccountLogin() } + func signOutAccount(providerId: String) throws { + try config.signOutAccount(providerId: providerId) + } func getFormattingPrompt() -> String { config.getFormattingPrompt() } func getAssistivePrompt() -> String { config.getAssistivePrompt() } @@ -162,6 +181,29 @@ struct MockSettingsEngine: SettingsEngine { ) } + func normalizeOpenaiResponsesEndpoint(_ endpoint: String) -> String { + // Mock: pass-through or minimal normalize for preview stability. + var base = endpoint.trimmingCharacters(in: .whitespacesAndNewlines.union(.init(charactersIn: "/"))) + for s in ["/v1/responses", "/v1/chat/completions", "/v1/completions"] where base.hasSuffix(s) { + base.removeLast(s.count) + return base + "/v1/responses" + } + if base.hasSuffix("/v1") { base.removeLast(3) } + return base + "/v1/responses" + } + func awaitAccountLogin(providerId: String, timeoutSeconds: UInt64) throws -> CsAccountLoginResult { + CsAccountLoginResult( + providerId: providerId, + status: "idle", + message: "no sign-in in progress", + authUrl: nil, + signedIn: false, + clientIdConfigured: false + ) + } + func cancelAccountLogin() {} + func signOutAccount(providerId: String) throws {} + func getFormattingPrompt() -> String { CsSettings.samplePrompt } func getAssistivePrompt() -> String { CsSettings.sampleAssistivePrompt } func defaultFormattingPrompt() -> String { CsSettings.samplePrompt } @@ -290,7 +332,8 @@ extension CsApiKeyProbeResult { status: account == "STT_API_KEY" ? .unsupported : .ok, message: account == "STT_API_KEY" ? "no cheap liveness probe is available for this STT key" - : "key accepted and quota available" + : "key accepted and quota available", + probedEndpoint: nil ) } } @@ -306,6 +349,7 @@ extension CsProviderOption { accountSignedIn: false, accountLoginEnabled: false, accountStatusMessage: "awaiting app registration", + oauthClientId: nil, models: [] ), CsProviderOption( @@ -316,6 +360,7 @@ extension CsProviderOption { accountSignedIn: false, accountLoginEnabled: false, accountStatusMessage: "provider account login unavailable", + oauthClientId: nil, models: [] ), ] diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index 106c7010..081c03d2 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -46,6 +46,115 @@ enum SettingsDeepLink { } } +enum LLMLane: String, CaseIterable, Identifiable { + case assistive + case formatting + case main + + var id: String { rawValue } + + var title: String { + switch self { + case .assistive: return "Assistive" + case .formatting: return "Formatting" + case .main: return "Main" + } + } + + var subtitle: String { + switch self { + case .assistive: return "Agent and voice-assistant requests" + case .formatting: return "Transcript cleanup and formatting" + case .main: return "Default LLM fallback lane" + } + } + + var endpointKey: String { + switch self { + case .assistive: return "LLM_ASSISTIVE_ENDPOINT" + case .formatting: return "LLM_FORMATTING_ENDPOINT" + case .main: return "LLM_ENDPOINT" + } + } + + var modelKey: String { + switch self { + case .assistive: return "LLM_ASSISTIVE_MODEL" + case .formatting: return "LLM_FORMATTING_MODEL" + case .main: return "LLM_MODEL" + } + } + + var endpointPath: WritableKeyPath { + switch self { + case .assistive: return \CsSettings.llmAssistiveEndpoint + case .formatting: return \CsSettings.llmFormattingEndpoint + case .main: return \CsSettings.llmEndpoint + } + } + + var modelPath: WritableKeyPath { + switch self { + case .assistive: return \CsSettings.llmAssistiveModel + case .formatting: return \CsSettings.llmFormattingModel + case .main: return \CsSettings.llmModel + } + } +} + +/// One read model for a request lane. Both Settings panels consume this snapshot, +/// so provider resolution, model discovery, and manual-entry rules cannot drift. +struct LLMLaneModel { + let lane: LLMLane + let providerId: String + let provider: CsProviderOption? + let resolvedEndpoint: String + let configuredModel: String + let resolvedModel: String + let discoveryEndpoint: String + let discovery: CsModelDiscovery + + var modelOptions: [CsModelOption] { discovery.models } + + var manualModelReason: String? { + guard providerId == "openai-responses" else { return nil } + guard URL(string: resolvedEndpoint)?.host?.lowercased() == "api.openai.com" else { + return "Custom endpoint — enter its model ID manually" + } + guard lane == .assistive || resolvedEndpoint == discoveryEndpoint else { + return "Endpoint differs from OpenAI discovery — enter its model ID manually" + } + return nil + } + + var usesDiscoveredPicker: Bool { + manualModelReason == nil && !modelOptions.isEmpty && discovery.status == "fresh" + } + + var discoveryDescription: String { + if let manualModelReason { return manualModelReason } + switch discovery.status { + case "fresh": + let count = modelOptions.count + return count == 0 + ? "no models returned by provider" + : "\(count) \(count == 1 ? "model" : "models") discovered from provider" + case "cached": + if let message = discovery.message, !message.isEmpty { + return "using cached models — \(message)" + } + return "using cached models" + case "no_key": return "Add API key to discover models" + case "loading": return "discovering models…" + default: + if let message = discovery.message, !message.isEmpty { + return "model discovery failed — \(message)" + } + return "model discovery failed" + } + } +} + /// Clears the app's preferences domain and relaunches a fresh instance. Used by /// the destructive "Reset app data" flow so restored window frames / SwiftUI scene /// state do not survive the wipe. The relaunch is deferred via a detached `open` @@ -69,9 +178,6 @@ enum AppRelaunch { } } -/// View-model owning the Settings screen state. Seeded with mock data so the -/// #Preview renders standalone; the live app injects `RealSettingsEngine` -/// (over the `CodescribeConfig` bridge) + the native permission probe. private struct BackgroundSettingsEngine: @unchecked Sendable { let engine: SettingsEngine } @@ -84,7 +190,7 @@ final class SettingsViewModel: ObservableObject { @Published private(set) var settings: CsSettings @Published private(set) var keyStatus: CsKeyStatus @Published private(set) var providers: [CsProviderOption] - @Published private(set) var modelDiscovery: CsModelDiscovery + @Published private var modelDiscoveries: [String: CsModelDiscovery] = [:] @Published private(set) var configDir: String @Published private(set) var needsOnboarding: Bool @Published private(set) var agentReadiness: CsAgenticReadiness @@ -94,6 +200,12 @@ final class SettingsViewModel: ObservableObject { @Published private(set) var mcpTestPending: Set = [] @Published private(set) var keyProbeResults: [String: CsApiKeyProbeResult] = [:] @Published private(set) var keyProbePending: Set = [] + /// Provider ids with a "Sign in with ChatGPT" flow in flight (browser open, + /// local callback server listening). Guards double-clicks. + @Published private(set) var accountLoginPending: Set = [] + /// Last terminal outcome of an account login per provider ("timeout", + /// "failed" …) — honest status for the row without raising a modal error. + @Published private(set) var accountLoginNotices: [String: String] = [:] @Published var lastError: String? // MARK: - Hotkeys (mode bindings) @@ -116,6 +228,12 @@ final class SettingsViewModel: ObservableObject { private let agentStatus: AgentStatusEngine? private let mcpAdmin: MCPAdminEngine? private let hotkeys: HotkeysEngine? + private var modelDiscoveryGenerations: [String: Int] = [:] + private var assistiveModelEditGeneration = 0 + private var pendingAssistiveModelSelection: ( + providerId: String, + modelEditGeneration: Int + )? init( engine: SettingsEngine? = nil, @@ -137,7 +255,6 @@ final class SettingsViewModel: ObservableObject { self.settings = .sample self.keyStatus = .sampleAllSet self.providers = CsProviderOption.sampleProviders - self.modelDiscovery = CsModelDiscovery.sample(for: CsSettings.sample.llmAssistiveProvider ?? "openai-responses") self.configDir = "" self.needsOnboarding = false self.agentReadiness = .sample @@ -157,7 +274,7 @@ final class SettingsViewModel: ObservableObject { providers = engine.availableProviders() configDir = engine.configDir() needsOnboarding = engine.shouldShowOnboarding() - refreshAssistiveModels() + refreshModelDiscoveries(providerIds: [llmLane(.assistive).providerId, "openai-responses"]) } refreshAgentStatus() reloadMcpServers() @@ -196,13 +313,6 @@ final class SettingsViewModel: ObservableObject { /// Save is allowed only for a changed, conflict-clean draft. var canSaveBindings: Bool { hasPendingBindingChanges && !hasBlockingBindingConflicts } - /// Current draft binding for a mode (falls back to the persisted value). - func draftBinding(for mode: CsWorkMode) -> CsShortcutBinding { - draftBindings.first { $0.mode == mode }?.binding - ?? modeBindings.first { $0.mode == mode }?.binding - ?? .disabled - } - /// Stage a binding change for one mode WITHOUT persisting, then re-validate so /// conflicts surface inline before the user commits. func editDraftBinding(mode: CsWorkMode, binding: CsShortcutBinding) { @@ -335,7 +445,7 @@ final class SettingsViewModel: ObservableObject { guard target.isInteractive else { return } section = target if target == .keys { - refreshAssistiveModels() + refreshAssistiveModelDiscovery() } } @@ -377,8 +487,94 @@ final class SettingsViewModel: ObservableObject { : (settings.sttEndpoint ?? "cloud default") } - var llmModelDescription: String { settings.llmModel ?? "default" } - var llmEndpointDescription: String { settings.llmEndpoint ?? "default" } + /// Effective lane state after provider/shared fallbacks. + func llmLane(_ lane: LLMLane) -> LLMLaneModel { + let providerId = lane == .assistive + ? (settings.llmAssistiveProvider ?? "openai-responses") + : "openai-responses" + let configuredModel = settings[keyPath: lane.modelPath]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let sharedModel = settings[keyPath: LLMLane.main.modelPath]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let resolvedEndpoint = lane == .assistive && providerId == "anthropic-messages" + ? "https://api.anthropic.com/v1/messages" + : resolvedOpenAIEndpoint(for: lane) + let resolvedModel = lane == .assistive && providerId == "anthropic-messages" + ? (configuredModel.hasPrefix("claude") ? configuredModel : "claude-opus-4-8") + : ([configuredModel, sharedModel].first { + !$0.isEmpty && !$0.hasPrefix("claude") + } ?? (lane == .assistive ? "gpt-5.5" : "gpt-4.1")) + let discoveryProviderId = lane == .assistive ? providerId : "openai-responses" + + return LLMLaneModel( + lane: lane, + providerId: providerId, + provider: providers.first { $0.id == providerId } ?? providers.first, + resolvedEndpoint: resolvedEndpoint, + configuredModel: configuredModel, + resolvedModel: resolvedModel, + discoveryEndpoint: lane == .assistive + ? resolvedEndpoint + : resolvedOpenAIEndpoint(for: .assistive), + discovery: modelDiscoveries[discoveryProviderId] + ?? CsModelDiscovery.sample(for: discoveryProviderId) + ) + } + + private func refreshAssistiveModelDiscovery(includeOpenAI: Bool = false) { + let providerId = llmLane(.assistive).providerId + refreshModelDiscoveries(providerIds: includeOpenAI ? [providerId, "openai-responses"] : [providerId]) + } + + private func resolvedOpenAIEndpoint(for lane: LLMLane) -> String { + // P2-05: lane/shared/default resolution stays here (UI settings surface); + // suffix normalization is now delegated to core via FFI (single truth in + // lane_truth::normalize_openai_responses_endpoint, exposed in bridge/config). + let laneValue = settings[keyPath: lane.endpointPath]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let sharedValue = settings[keyPath: LLMLane.main.endpointPath]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let base = !laneValue.isEmpty + ? laneValue + : (!sharedValue.isEmpty + ? sharedValue + : "https://api.openai.com/v1/responses") + + if let engine { + return engine.normalizeOpenaiResponsesEndpoint(base) + } + // Fallback for previews / no-engine (kept tiny; real path always has engine). + // NOTE: suffix list duplication removed (L2 over-correct); core lane_truth::normalize + // (via bridge) is the single source of truth for responses endpoint. Fallback does + // minimal /v1 strip only to avoid duplicating known-suffixes array. + var b = base.trimmingCharacters(in: .whitespacesAndNewlines.union(.init(charactersIn: "/"))) + if b.hasSuffix("/v1") { b.removeLast(3) } + return b + "/v1/responses" + } + + /// Persist an endpoint override for one LLM lane. Whitespace-only input is + /// the reset signal: the core removes the optional JSON path so the next + /// resolved fallback becomes effective immediately. + func setLLMEndpoint(_ value: String, for lane: LLMLane) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + settings[keyPath: lane.endpointPath] = trimmed.isEmpty ? nil : trimmed + persist(lane.endpointKey, trimmed) + refreshAgentStatus() + if lane == .assistive { + refreshAssistiveModelDiscovery(includeOpenAI: true) + } + } + + /// Persist a model override for one LLM lane. Empty clears the JSON override. + func setLLMModel(_ value: String, for lane: LLMLane) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if lane == .assistive { + assistiveModelEditGeneration += 1 + pendingAssistiveModelSelection = nil + } + settings[keyPath: lane.modelPath] = trimmed.isEmpty ? nil : trimmed + persist(lane.modelKey, trimmed) + } var formattingDescription: String { guard settings.aiFormattingEnabled else { return "off" } @@ -412,11 +608,6 @@ final class SettingsViewModel: ObservableObject { persist("FORMATTING_LEVEL", level) } - func setUseLocalStt(_ on: Bool) { - settings.useLocalStt = on - persist("USE_LOCAL_STT", on ? "1" : "0") - } - // MARK: - STT engine / layered transcription (Engine panel controls) /// Selected STT engine id ("auto" | "apple" | "whisper"); absent → auto policy. @@ -495,65 +686,23 @@ final class SettingsViewModel: ObservableObject { var keyAccounts: [String] { engine?.keyAccounts() ?? [] } - // MARK: - Agent provider / model selection (assistive lane) - - /// Provider catalog with per-provider key presence. - var availableProviders: [CsProviderOption] { providers } - - /// Currently selected assistive-lane provider id (falls back to OpenAI). - var assistiveProviderId: String { settings.llmAssistiveProvider ?? "openai-responses" } - - /// Currently configured assistive-lane model id (may be empty until set). - var assistiveModel: String { settings.llmAssistiveModel ?? "" } - - /// The selected provider's catalog entry, if present. - var selectedProvider: CsProviderOption? { - let id = assistiveProviderId - return availableProviders.first { $0.id == id } ?? availableProviders.first - } - - /// Models discovered from the selected provider's live `/models` API. - var discoveredModels: [CsModelOption] { - modelDiscovery.providerId == assistiveProviderId ? modelDiscovery.models : [] - } - - var modelDiscoveryStatus: String { modelDiscovery.status } - - var modelDiscoveryDescription: String { - switch modelDiscovery.status { - case "fresh": - let count = modelDiscovery.models.count - let noun = count == 1 ? "model" : "models" - return count == 0 ? "no models returned by provider" : "\(count) \(noun) discovered from provider" - case "cached": - if let message = modelDiscovery.message, !message.isEmpty { - return "using cached models — \(message)" - } - return "using cached models" - case "no_key": - return "Add API key to discover models" - default: - if let message = modelDiscovery.message, !message.isEmpty { - return "model discovery failed — \(message)" - } - return "model discovery failed" - } - } + // MARK: - Agent provider selection (assistive lane) func setAssistiveProvider(_ id: String) { settings.llmAssistiveProvider = id persist("LLM_ASSISTIVE_PROVIDER", id) - refreshAssistiveModels() // The stored model belonged to the previous provider; keeping it would make // the first send hit a model the new provider doesn't serve (e.g. gpt-5.5 on - // Anthropic). Re-anchor to the new provider's first discovered model, or - // clear it so the provider default applies. - setAssistiveModel(discoveredModels.first?.id ?? "") - } - - func setAssistiveModel(_ id: String) { - settings.llmAssistiveModel = id - persist("LLM_ASSISTIVE_MODEL", id) + // Anthropic). Clear it so the provider default applies immediately, then + // allow only a fresh discovery to re-anchor it. Any manual model edit + // cancels this pending auto-selection. + setLLMModel("", for: .assistive) + pendingAssistiveModelSelection = ( + providerId: id, + modelEditGeneration: assistiveModelEditGeneration + ) + refreshModelDiscoveries(providerIds: [id, "openai-responses"]) + refreshAgentStatus() } func saveKey(account: String, secret: String) { @@ -564,9 +713,10 @@ final class SettingsViewModel: ObservableObject { keyProbeResults[account] = nil keyStatus = engine.keyStatus() providers = engine.availableProviders() - if account == selectedProvider?.apiKeyAccount { - refreshAssistiveModels() + if account == llmLane(.assistive).provider?.apiKeyAccount { + refreshAssistiveModelDiscovery() } + refreshAgentStatus() } catch { lastError = String(describing: error) } @@ -579,9 +729,10 @@ final class SettingsViewModel: ObservableObject { keyProbeResults[account] = nil keyStatus = engine.keyStatus() providers = engine.availableProviders() - if account == selectedProvider?.apiKeyAccount { - refreshAssistiveModels() + if account == llmLane(.assistive).provider?.apiKeyAccount { + refreshAssistiveModelDiscovery() } + refreshAgentStatus() } catch { lastError = String(describing: error) } @@ -611,7 +762,8 @@ final class SettingsViewModel: ObservableObject { self.keyProbeResults[account] = CsApiKeyProbeResult( account: account, status: .network, - message: String(describing: error) + message: String(describing: error), + probedEndpoint: nil ) self.lastError = String(describing: error) } @@ -620,28 +772,174 @@ final class SettingsViewModel: ObservableObject { } func providerForKeyAccount(_ account: String) -> CsProviderOption? { - availableProviders.first { $0.apiKeyAccount == account && $0.id == "openai-responses" } + providers.first { $0.apiKeyAccount == account && $0.id == "openai-responses" } } + /// Full "Sign in with ChatGPT" click-through: start the local callback + /// server, open the authorize URL in the default browser, then await the + /// roundtrip on a background queue. The await result (signed in / failed / + /// timeout) refreshes the provider row — no restart, no zombie port. func startAccountLogin(providerId: String) { guard let engine else { return } + guard !accountLoginPending.contains(providerId) else { return } + + let result: CsAccountLoginResult do { - let result = try engine.startAccountLogin(providerId: providerId) - if let authUrl = result.authUrl, let url = URL(string: authUrl) { - NSWorkspace.shared.open(url) + result = try engine.startAccountLogin(providerId: providerId) + } catch { + lastError = String(describing: error) + return + } + guard let authUrl = result.authUrl, let url = URL(string: authUrl) else { + accountLoginNotices[providerId] = result.message + return + } + + accountLoginPending.insert(providerId) + accountLoginNotices[providerId] = nil + NSWorkspace.shared.open(url) + + let backgroundEngine = BackgroundSettingsEngine(engine: engine) + DispatchQueue.global(qos: .userInitiated).async { [backgroundEngine, providerId] in + let outcome: Result + do { + outcome = .success( + try backgroundEngine.engine.awaitAccountLogin( + providerId: providerId, + // P2-09: 300s chosen as pragmatic cap for OAuth browser roundtrip + // (user may need to 2FA, switch windows, consent). No new Settings + // knob (per charter). Cancel path: second start or sign-out flow + // or app close (server is torn down on timeout/failure). + // Discovery (P2-08) uses the same await; partial cancel support + // exists via pending set + supersede in core. + timeoutSeconds: 300 + ) + ) + } catch { + outcome = .failure(error) } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.accountLoginPending.remove(providerId) + switch outcome { + case .success(let login): + // "signed_in" needs no banner — the row status flips on the + // provider refresh below. Everything else is surfaced as-is. + self.accountLoginNotices[providerId] = + login.status == "signed_in" ? nil : login.message + case .failure(let error): + self.accountLoginNotices[providerId] = String(describing: error) + } + if let engine = self.engine { + self.providers = engine.availableProviders() + } + self.refreshAgentStatus() + } + } + } + + /// Sign out of the provider account (clears the stored tokens). API keys + /// are untouched. + func signOutAccount(providerId: String) { + guard let engine else { return } + do { + try engine.signOutAccount(providerId: providerId) + accountLoginNotices[providerId] = nil providers = engine.availableProviders() + refreshAgentStatus() } catch { lastError = String(describing: error) } } - func refreshAssistiveModels() { + /// Persist the OAuth client id (non-secret; settings.json). Takes effect on + /// the next click — the core re-reads settings per resolution. + func saveOauthClientId(providerId: String, value: String) { + persist("LLM_OPENAI_OAUTH_CLIENT_ID", value.trimmingCharacters(in: .whitespacesAndNewlines)) + accountLoginNotices[providerId] = nil + if let engine { + providers = engine.availableProviders() + } + refreshAgentStatus() + } + + /// The single discovery path for every lane/provider. Generation checks drop + /// stale network results. Provider-switch auto-selection is held separately + /// so a newer endpoint refresh inherits it while a manual model edit cancels it. + private func refreshModelDiscoveries(providerIds: [String]) { + let providerIds = Array(Set(providerIds)) + var generations: [String: Int] = [:] + for providerId in providerIds { + modelDiscoveryGenerations[providerId, default: 0] += 1 + generations[providerId] = modelDiscoveryGenerations[providerId] + } guard let engine else { - modelDiscovery = CsModelDiscovery.sample(for: assistiveProviderId) + for providerId in providerIds { + let discovery = CsModelDiscovery.sample(for: providerId) + modelDiscoveries[providerId] = discovery + applyPendingAssistiveModelSelection( + providerId: providerId, + discovery: discovery + ) + } + return + } + + for providerId in providerIds { + let loading = CsModelDiscovery( + providerId: providerId, + status: "loading", + message: nil, + models: [] + ) + modelDiscoveries[providerId] = loading + } + + let backgroundEngine = BackgroundSettingsEngine(engine: engine) + DispatchQueue.global(qos: .userInitiated).async { [backgroundEngine, providerIds, generations] in + let discoveries = providerIds.map { providerId in + (providerId, backgroundEngine.engine.discoverModels(providerId: providerId)) + } + + DispatchQueue.main.async { [weak self, discoveries, generations] in + guard let self else { return } + for (providerId, discovery) in discoveries { + guard self.modelDiscoveryGenerations[providerId] == generations[providerId] else { + continue + } + self.modelDiscoveries[providerId] = discovery + self.applyPendingAssistiveModelSelection( + providerId: providerId, + discovery: discovery + ) + } + } + } + } + + private func applyPendingAssistiveModelSelection( + providerId: String, + discovery: CsModelDiscovery + ) { + guard let pending = pendingAssistiveModelSelection, + pending.providerId == providerId + else { return } + + let activeProviderId = settings.llmAssistiveProvider ?? "openai-responses" + guard pending.modelEditGeneration == assistiveModelEditGeneration, + activeProviderId == providerId + else { + pendingAssistiveModelSelection = nil return } - modelDiscovery = engine.discoverModels(providerId: assistiveProviderId) + + guard discovery.status == "fresh", + let firstModel = discovery.models.first?.id, + !firstModel.isEmpty + else { return } + + setLLMModel(firstModel, for: .assistive) } // MARK: - Prompts (editable BASE prompts) diff --git a/macos/CodescribeTests/OverlayPlacementTests.swift b/macos/CodescribeTests/OverlayPlacementTests.swift new file mode 100644 index 00000000..48dbc3fa --- /dev/null +++ b/macos/CodescribeTests/OverlayPlacementTests.swift @@ -0,0 +1,61 @@ +import XCTest + +@testable import Codescribe + +/// Pure-geometry contract for the overlay's anchored placement: six anchors +/// over a visible frame, margin respected, and the free-motion clamp keeping a +/// restored origin fully on-screen after display changes. +final class OverlayPlacementTests: XCTestCase { + // A visible frame with a non-zero origin, as on a secondary display — + // anchor math must respect minX/minY, not assume (0,0). + private let visible = NSRect(x: 100, y: 50, width: 1600, height: 900) + private let size = NSSize(width: 320, height: 600) + private let m = OverlayPlacement.margin + + func testTopAnchorsSitUnderTheVisibleTopEdge() { + for anchor in [OverlayAnchor.topLeft, .topCenter, .topRight] { + let origin = OverlayPlacement.origin(for: anchor, size: size, in: visible) + XCTAssertEqual(origin.y, visible.maxY - size.height - m, "\(anchor)") + } + } + + func testBottomAnchorsSitOnTheVisibleBottomEdge() { + for anchor in [OverlayAnchor.bottomLeft, .bottomCenter, .bottomRight] { + let origin = OverlayPlacement.origin(for: anchor, size: size, in: visible) + XCTAssertEqual(origin.y, visible.minY + m, "\(anchor)") + } + } + + func testHorizontalLanesLeftCenterRight() { + let left = OverlayPlacement.origin(for: .topLeft, size: size, in: visible) + let center = OverlayPlacement.origin(for: .topCenter, size: size, in: visible) + let right = OverlayPlacement.origin(for: .topRight, size: size, in: visible) + XCTAssertEqual(left.x, visible.minX + m) + XCTAssertEqual(center.x, visible.midX - size.width / 2) + XCTAssertEqual(right.x, visible.maxX - size.width - m) + } + + func testDefaultAnchorIsTopRightUnderTheTray() { + XCTAssertEqual(OverlayPlacement.defaultAnchor, .topRight) + } + + func testEveryAnchorKeepsThePanelFullyInsideTheVisibleFrame() { + for anchor in OverlayAnchor.allCases { + let origin = OverlayPlacement.origin(for: anchor, size: size, in: visible) + let frame = NSRect(origin: origin, size: size) + XCTAssertTrue(visible.contains(frame), "\(anchor): \(frame) escapes \(visible)") + } + } + + func testClampPullsAnOffscreenFreeMotionOriginBackInside() { + let offscreen = NSPoint(x: visible.maxX + 500, y: visible.minY - 500) + let clamped = OverlayPlacement.clampOrigin(offscreen, size: size, in: visible) + XCTAssertTrue(visible.contains(NSRect(origin: clamped, size: size))) + } + + func testClampIsIdentityForAnOriginAlreadyInside() { + let inside = NSPoint(x: visible.midX, y: visible.minY + 20) + let clamped = OverlayPlacement.clampOrigin(inside, size: size, in: visible) + XCTAssertEqual(clamped, inside) + } +} diff --git a/macos/CodescribeTests/OverlayStateTests.swift b/macos/CodescribeTests/OverlayStateTests.swift new file mode 100644 index 00000000..8bd3ff7c --- /dev/null +++ b/macos/CodescribeTests/OverlayStateTests.swift @@ -0,0 +1,104 @@ +import AppKit +import XCTest +@testable import Codescribe + +@MainActor +final class OverlayStateTests: XCTestCase { + func testTwoUtterancesAppendAndPreviewOnlyReplacesActiveTail() { + let state = OverlayState() + state.handleRecordingPreparing() + state.handleRecordingStarted() + + state.applyPreview("first draft") + state.applyFinal(utteranceId: 1, "first utterance") + state.applyPreview("second draft") + + XCTAssertEqual(state.committedUtterances, ["first utterance"]) + XCTAssertEqual(state.liveText, "first utterance second draft") + + state.applyPreview("second draft revised") + XCTAssertEqual(state.liveText, "first utterance second draft revised") + + state.applyFinal(utteranceId: 2, "second utterance") + XCTAssertEqual(state.committedUtterances, ["first utterance", "second utterance"]) + XCTAssertEqual(state.preview, "") + XCTAssertEqual(state.liveText, "first utterance second utterance") + } + + func testSessionFinalisedStartsFinalPassUntilControllerStops() { + let state = OverlayState() + state.handleRecordingPreparing() + state.handleRecordingStarted() + state.applyFinal(utteranceId: 1, "captured text") + + state.handleRecordingFinalising() + XCTAssertEqual(state.statusText, "transcribing") + + state.applySessionFinalised() + XCTAssertEqual(state.mode, .listening) + XCTAssertEqual(state.statusText, "final pass") + + state.finishControllerRecording() + XCTAssertEqual(state.mode, .formatted) + XCTAssertEqual(state.statusText, "done") + XCTAssertEqual(state.formattedText, "captured text") + } + + func testFailurePhaseIsExplicit() { + let state = OverlayState() + + state.handleError(message: "engine unavailable") + + XCTAssertEqual(state.mode, .error) + XCTAssertEqual(state.statusText, "failed") + } + + func testCopyAndSendDismissTheOverlay() { + let state = OverlayState() + var closeCount = 0 + var sentText: String? + state.onClose = { closeCount += 1 } + state.onSendToAgent = { sentText = $0 } + state.formattedText = "ready transcript" + state.mode = .formatted + + state.copyToPasteboard() + XCTAssertEqual(closeCount, 1) + XCTAssertEqual(NSPasteboard.general.string(forType: .string), "ready transcript") + + state.sendToAgent() + XCTAssertEqual(closeCount, 2) + XCTAssertEqual(sentText, "ready transcript") + } + + func testCaptureQualityIfEditedHitsAsyncPathOnUserEditWithoutBlocking() { + // D-02: exercise quality capture decision (delivered != edited on .formatted) + // and the fire-and-forget Task.detached (Copy/Send/Close must not block on I/O). + // Uses applyFinalTranscript which seeds deliveredText (the pre-edit value). + let state = OverlayState() + var closeCount = 0 + state.onClose = { closeCount += 1 } + + // Seed delivered (raw from final transcript) then user edits formatted. + state.applyFinalTranscript("original delivered transcript here") + state.formattedText = "original delivered transcript here with user fix" + state.mode = .formatted + + // Copy triggers captureQualityIfEdited because texts differ; must return immediately. + state.copyToPasteboard() + XCTAssertEqual(closeCount, 1) + // The async commit to quality + lexicon happens off-main; test reaches here without wait. + } + + func testOverlayPanelUsesNonActivatingStyle() { + let state = OverlayState() + let panel = DictationOverlayWindow.make( + state: state, + textScale: TextScaleController(key: "OverlayStateTests.textScale") + ) + + XCTAssertTrue(panel.styleMask.contains(.nonactivatingPanel)) + XCTAssertTrue(panel.isFloatingPanel) + XCTAssertFalse(panel.canBecomeMain) + } +} diff --git a/macos/project.yml b/macos/project.yml index 9ff70efd..e0fa06f2 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -32,6 +32,13 @@ targets: properties: CFBundleDisplayName: Codescribe CFBundleName: Codescribe + # Build provenance — expanded from build settings at xcodebuild time; + # scripts/build-app.sh passes the real values (Cargo version, git commit, + # commit count, UTC timestamp). settings.base holds bare-Xcode defaults. + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + CSBuildCommit: $(CS_BUILD_COMMIT) + CSBuiltAt: $(CS_BUILT_AT) LSUIElement: true NSMicrophoneUsageDescription: "Codescribe transcribes your speech locally with on-device Whisper." NSAppleEventsUsageDescription: "Codescribe uses Apple Events to bring apps forward and deliver transcribed text into the app you are working in." @@ -40,10 +47,16 @@ targets: base: # Dock/app icon — AppIcon.appiconset in Codescribe/Assets.xcassets. ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + # Build-provenance defaults for bare `xcodebuild` / Xcode runs; the + # verified pipeline (scripts/build-app.sh) overrides all four. + MARKETING_VERSION: 0.0.0-dev + CURRENT_PROJECT_VERSION: "0" + CS_BUILD_COMMIT: dev + CS_BUILT_AT: unknown # Stable dev identity → own TCC/keychain slot across rebuilds. PRODUCT_BUNDLE_IDENTIFIER: com.vetcoders.codescribe CODE_SIGN_STYLE: Manual - CODE_SIGN_IDENTITY: "CodeScribe Dev" + CODE_SIGN_IDENTITY: "Codescribe Dev" ENABLE_HARDENED_RUNTIME: NO SWIFT_VERSION: 5.0 SWIFT_OBJC_BRIDGING_HEADER: $(SRCROOT)/Codescribe/Bridge/codescribe_ffiFFI.h @@ -64,3 +77,23 @@ targets: LIBRARY_SEARCH_PATHS: "$(SRCROOT)/../target/debug" Release: LIBRARY_SEARCH_PATHS: "$(SRCROOT)/../target/release" + CodescribeTests: + type: bundle.unit-test + platform: macOS + sources: [CodescribeTests] + dependencies: + - target: Codescribe + settings: + base: + GENERATE_INFOPLIST_FILE: YES +# Without an explicit scheme the test bundle is unreachable: xcodegen emits no +# scheme for bundle.unit-test targets, and old-style `-target` builds cannot +# resolve SPM dependencies. `xcodebuild test -scheme Codescribe` is the entry. +schemes: + Codescribe: + build: + targets: + Codescribe: all + test: + targets: + - CodescribeTests diff --git a/scripts/bench-stt.sh b/scripts/bench-stt.sh index acbb85d5..d1d6573a 100755 --- a/scripts/bench-stt.sh +++ b/scripts/bench-stt.sh @@ -215,7 +215,7 @@ write_honest_report() { head_short="$(git -C "$repo_root" rev-parse --short=8 HEAD 2>/dev/null || printf 'unknown')" fixture_source="$(fixture_source_label)" { - printf '# CodeScribe STT Baseline Bench\n\n' + printf '# Codescribe STT Baseline Bench\n\n' printf '[!] %s\n\n' "$reason" printf '## Repro command\n\n' printf '```bash\n' @@ -973,7 +973,7 @@ raw_delta_pp = None if unprompted_raw is None or prompted_raw is None else (prom post_delta_pp = None if unprompted_post is None or prompted_post is None else (prompted_post - unprompted_post) * 100.0 lines = [] -lines.append("# CodeScribe STT Real-Path Bench") +lines.append("# Codescribe STT Real-Path Bench") lines.append("") lines.append("## Run Context") lines.append("") diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 34bfa015..dc417dfc 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Build the CodeScribe SwiftUI app from the Rust `codescribe-ffi` bridge. +# Build the Codescribe SwiftUI app from the Rust `codescribe-ffi` bridge. # # This is the single source of truth for the SwiftUI build pipeline. Before it # existed the steps below lived only in tribal memory / a reviewer's shell @@ -75,12 +75,38 @@ if [ "${SKIP_XCODEBUILD:-0}" = "1" ]; then exit 0 fi +# Build provenance (Pensieve-style stamp): version from the workspace Cargo.toml, +# commit + monotonic build number from git, built-at in UTC. All four land in +# Info.plist via the $(VAR) placeholders project.yml declares, so the About +# panel shows exactly what was built, from what, and when. A dirty worktree is +# marked honestly — a stamped build must never masquerade as a clean commit. +STAMP_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "$REPO_ROOT/Cargo.toml" | head -1)" +if git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + STAMP_COMMIT="$(git -C "$REPO_ROOT" rev-parse --short=9 HEAD)" + if [ -n "$(git -C "$REPO_ROOT" status --porcelain --untracked-files=normal --ignore-submodules=none)" ]; then + STAMP_COMMIT="${STAMP_COMMIT}-dirty" + fi + STAMP_BUILD_NUM="$(git -C "$REPO_ROOT" rev-list --count HEAD)" +else + STAMP_COMMIT="nogit" + STAMP_BUILD_NUM="0" +fi +STAMP_BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "==> [5/7] Building app (xcodebuild, $CONFIG)" +echo " stamp: v${STAMP_VERSION} build ${STAMP_BUILD_NUM} commit ${STAMP_COMMIT} built ${STAMP_BUILT_AT}" DERIVED="$REPO_ROOT/macos/build" +# ONLY_ACTIVE_ARCH: cargo emits a single-arch libcodescribe_ffi.dylib, so a +# universal (x86_64+arm64) Release link dies on missing Rust symbols. xcodebuild -project macos/Codescribe.xcodeproj \ -scheme "$SCHEME" -configuration "$CONFIG" \ -derivedDataPath "$DERIVED" \ + ONLY_ACTIVE_ARCH=YES \ CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-NO}" \ + MARKETING_VERSION="$STAMP_VERSION" \ + CURRENT_PROJECT_VERSION="$STAMP_BUILD_NUM" \ + CS_BUILD_COMMIT="$STAMP_COMMIT" \ + CS_BUILT_AT="$STAMP_BUILT_AT" \ build APP="$DERIVED/Build/Products/$CONFIG/$SCHEME.app" diff --git a/tests/e2e_agent_lane_roundtrip.rs b/tests/e2e_agent_lane_roundtrip.rs new file mode 100644 index 00000000..a68c401e --- /dev/null +++ b/tests/e2e_agent_lane_roundtrip.rs @@ -0,0 +1,138 @@ +//! Deterministic single-shot roundtrip through the real agent engine path — the +//! same `create_default_provider()` the Swift chat send uses via the bridge. +//! +//! The test owns an isolated config directory and a local Responses endpoint, +//! so it runs in the ordinary workspace gate without a real API key. Run with: +//! +//! ```bash +//! cargo test --test e2e_agent_lane_roundtrip -- --nocapture +//! ``` +//! +//! This is the regression net for the "I can't reach the model yet" loop: the +//! lane must resolve from CURRENT settings (lane_truth), a key-optional local +//! endpoint must stream without auth headers, and the turn must finish cleanly. + +use codescribe::agent::create_default_provider; +use codescribe_core::agent::{AgentEvent, ContentBlock, Message, Role, StreamOptions}; +use mockito::Matcher; +use serial_test::serial; +use tempfile::TempDir; + +#[tokio::test] +#[serial] +async fn assistive_lane_answers_one_single_shot_turn() { + let data_dir = TempDir::new().expect("isolated Codescribe data directory"); + let mut server = mockito::Server::new_async().await; + let endpoint = format!("{}/v1/responses", server.url()); + let response_body = [ + r#"data: {"type":"response.created","response":{"id":"resp_fixture"}}"#, + "", + r#"data: {"type":"response.output_text.delta","delta":"pong"}"#, + "", + r#"data: {"type":"response.output_text.done","text":"pong"}"#, + "", + r#"data: {"type":"response.completed","response":{"id":"resp_fixture","status":"completed"}}"#, + "", + "data: [DONE]", + "", + ] + .join("\n"); + let mock = server + .mock("POST", "/v1/responses") + .match_header("authorization", Matcher::Missing) + .match_header("x-api-key", Matcher::Missing) + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("fixture-model".to_string()), + Matcher::Regex("Reply with the single word: pong".to_string()), + ])) + .with_status(200) + .with_header("content-type", "text/event-stream") + .with_body(response_body) + .expect(1) + .create_async() + .await; + + let _data_dir = EnvGuard::set( + "CODESCRIBE_DATA_DIR", + data_dir.path().to_string_lossy().as_ref(), + ); + let _disable_keychain = EnvGuard::set("CODESCRIBE_DISABLE_KEYCHAIN", "1"); + let _provider = EnvGuard::set("LLM_ASSISTIVE_PROVIDER", "openai-responses"); + let _endpoint = EnvGuard::set("LLM_ASSISTIVE_ENDPOINT", &endpoint); + let _model = EnvGuard::set("LLM_ASSISTIVE_MODEL", "fixture-model"); + let _api_key = EnvGuard::remove("LLM_ASSISTIVE_API_KEY"); + let _attempt_timeout = EnvGuard::set("CODESCRIBE_AI_ATTEMPT_TIMEOUT_MS", "2000"); + let _chunk_timeout = EnvGuard::set("CODESCRIBE_AI_INTER_CHUNK_TIMEOUT_MS", "2000"); + + let provider = create_default_provider() + .expect("assistive lane must be available (see the reported reason)"); + + let messages = vec![Message::new( + Role::User, + vec![ContentBlock::Text( + "Reply with the single word: pong".to_string(), + )], + )]; + let options = StreamOptions { + model: String::new(), + system_prompt: None, + max_tokens: Some(32), + temperature: None, + reset_chain: false, + }; + + let mut rx = provider + .stream(&messages, &[], &options) + .await + .expect("stream must start"); + + let mut text = String::new(); + let mut clean_done = false; + while let Some(event) = rx.recv().await { + match event { + AgentEvent::TextDelta(delta) => text.push_str(&delta), + AgentEvent::TextDone(done) if !done.trim().is_empty() => text = done, + AgentEvent::ResponseDone { clean, .. } => clean_done = clean, + AgentEvent::Error(error) => panic!("provider error: {error}"), + _ => {} + } + } + + assert!(clean_done, "turn must end on a clean terminal"); + assert_eq!(text.trim(), "pong"); + mock.assert_async().await; + eprintln!("agent replied: {text}"); +} + +struct EnvGuard { + key: &'static str, + previous: Option, +} + +impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: this process-environment test is serialized. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: this process-environment test is serialized. + unsafe { std::env::remove_var(key) }; + Self { key, previous } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: this process-environment test is serialized. + unsafe { + match self.previous.as_deref() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +}