diff --git a/lib/minga_agent/credentials.ex b/lib/minga_agent/credentials.ex index d984bb495..e7070420f 100644 --- a/lib/minga_agent/credentials.ex +++ b/lib/minga_agent/credentials.ex @@ -20,15 +20,24 @@ defmodule MingaAgent.Credentials do @typedoc "Source where a key was found." @type key_source :: :env | :file | :oauth | nil + alias MingaAgent.Credentials.Snapshot + + @typedoc "Live Ollama availability, kept separate from local configuration." + @type ollama_availability :: :pending | :available | {:unavailable, term()} + + @typedoc "Owner-visible credential readiness." + @type readiness :: :checking | :configured | :unconfigured + defmodule ProviderStatus do @moduledoc false - @enforce_keys [:provider, :configured] - defstruct [:provider, :configured, :source] + @enforce_keys [:provider, :configured, :availability] + defstruct [:provider, :configured, :source, :availability] @type t :: %__MODULE__{ provider: String.t(), configured: boolean(), - source: :env | :file | :local | :oauth | nil + source: :env | :file | :local | :oauth | nil, + availability: :not_applicable | MingaAgent.Credentials.ollama_availability() } end @@ -120,51 +129,102 @@ defmodule MingaAgent.Credentials do end @doc """ - Returns the auth status for all known providers plus Ollama and OpenAI OAuth. + Acquires one secret-free local credential snapshot. + + The credentials file is read once. Environment values retain precedence over + stored values, and only their configured source is retained. + """ + @spec snapshot(keyword()) :: Snapshot.t() + def snapshot(opts \\ []) do + stored = acquire_stored_credentials(opts) + + sources = + Map.new(@known_providers, fn provider -> + {provider, configured_source(provider, stored, opts)} + end) + |> Map.reject(fn {_provider, source} -> is_nil(source) end) + + Snapshot.new( + sources, + auth_probe(opts, :oauth_probe, &oauth_configured?/0), + ollama_host(opts) + ) + end + + @doc """ + Returns local auth status plus an explicit Ollama availability state. Each entry shows whether a key is configured and where it was found (`:env`, `:file`, `:oauth`, `:local`, or `nil`). Keys themselves are never exposed. + Calling this function never probes the network; Ollama is `:pending` until an + owner executes `ollama_availability/2` outside its mailbox. """ @spec status(keyword()) :: [provider_status()] - def status(opts \\ []) do + def status(opts \\ []) when is_list(opts), do: opts |> snapshot() |> status(:pending) + + @doc "Returns status entries from one acquired snapshot and live Ollama result." + @spec status(Snapshot.t(), ollama_availability()) :: [provider_status()] + def status(%Snapshot{} = snapshot, availability) do standard = Enum.map(@known_providers, fn provider -> - case resolve(provider, opts) do - {:ok, _key, source} -> - %ProviderStatus{provider: provider, configured: true, source: source} - - :error -> - %ProviderStatus{provider: provider, configured: false, source: nil} + case Snapshot.provider_source(snapshot, provider) do + source when source in [:env, :file] -> + %ProviderStatus{ + provider: provider, + configured: true, + source: source, + availability: :not_applicable + } + + nil -> + %ProviderStatus{ + provider: provider, + configured: false, + source: nil, + availability: :not_applicable + } end end) oauth_status = - if auth_probe(opts, :oauth_probe, &oauth_configured?/0) do - %ProviderStatus{provider: "openai_codex", configured: true, source: :oauth} + if snapshot.oauth_configured do + %ProviderStatus{ + provider: "openai_codex", + configured: true, + source: :oauth, + availability: :not_applicable + } else - %ProviderStatus{provider: "openai_codex", configured: false, source: nil} + %ProviderStatus{ + provider: "openai_codex", + configured: false, + source: nil, + availability: :not_applicable + } end - ollama_up = auth_probe(opts, :ollama_probe, &ollama_available?/0) - ollama_status = %ProviderStatus{ provider: "ollama", - configured: ollama_up, - source: if(ollama_up, do: :local, else: nil) + configured: availability == :available, + source: if(availability == :available, do: :local, else: nil), + availability: availability } standard ++ [oauth_status, ollama_status] end @doc """ - Returns true if any provider has a configured API key. + Returns true if any local API-key or OAuth credential is configured. + + This predicate never probes the network. Owners combine it with an explicit + `ollama_availability/2` result when automatic local discovery is relevant. """ - @spec any_configured?(keyword()) :: boolean() - def any_configured?(opts \\ []) do - Enum.any?(@known_providers, fn p -> resolve(p, opts) != :error end) or - auth_probe(opts, :oauth_probe, &oauth_configured?/0) or - auth_probe(opts, :ollama_probe, &ollama_available?/0) - end + @spec any_configured?(keyword() | Snapshot.t()) :: boolean() + def any_configured?(source \\ []) + + def any_configured?(opts) when is_list(opts), do: opts |> snapshot() |> any_configured?() + + def any_configured?(%Snapshot{} = snapshot), do: Snapshot.locally_configured?(snapshot) @doc """ Extracts the provider name from a model string like "anthropic:claude-sonnet-4-20250514". @@ -201,8 +261,18 @@ defmodule MingaAgent.Credentials do then falls back to the default localhost URL. """ @spec ollama_host() :: String.t() - def ollama_host do - System.get_env(@ollama_host_var) || @ollama_default_host + def ollama_host, do: ollama_host([]) + + @doc "Returns the Ollama host using an optional captured environment map." + @spec ollama_host(keyword()) :: String.t() + def ollama_host(opts) when is_list(opts) do + env = Keyword.get(opts, :env, %{}) + + case Map.fetch(env, @ollama_host_var) do + {:ok, host} when is_binary(host) and host != "" -> host + {:ok, _missing} -> @ollama_default_host + :error -> System.get_env(@ollama_host_var) || @ollama_default_host + end end @doc """ @@ -212,20 +282,23 @@ defmodule MingaAgent.Credentials do Returns false on connection errors or timeouts. """ @spec ollama_available?() :: boolean() - # NOTE: This check blocks the calling process for up to 2 seconds when Ollama - # isn't running. Called during resolve_auto/0 and status/0, so agent startup - # may be delayed by that amount if Ollama is unreachable. - def ollama_available? do - host = ollama_host() - - case :httpc.request(:get, {~c"#{host}/api/tags", []}, [{:timeout, 2000}], []) do - {:ok, {{_, 200, _}, _, _}} -> true - _ -> false - end + def ollama_available?, do: ollama_availability(snapshot()) == :available + + @doc "Checks live Ollama availability for a previously acquired snapshot." + @spec ollama_availability(Snapshot.t(), keyword()) :: ollama_availability() + def ollama_availability(%Snapshot{} = snapshot, opts \\ []) do + result = + case Keyword.get(opts, :ollama_probe) do + probe when is_function(probe, 1) -> probe.(snapshot.ollama_host) + probe when is_function(probe, 0) -> probe.() + nil -> request_ollama_tags(snapshot.ollama_host) + end + + normalize_ollama_result(result) rescue - ArgumentError -> false + error -> {:unavailable, {:exception, error.__struct__}} catch - :exit, _ -> false + :exit, reason -> {:unavailable, {:exit, reason}} end @spec auth_probe(keyword(), atom(), (-> boolean())) :: boolean() @@ -235,6 +308,58 @@ defmodule MingaAgent.Credentials do |> then(& &1.()) end + @spec acquire_stored_credentials(keyword()) :: map() + defp acquire_stored_credentials(opts) do + path = credentials_path(opts) + + result = + case Keyword.get(opts, :credentials_reader) do + reader when is_function(reader, 1) -> reader.(path) + nil -> read_credentials_file(path) + end + + case result do + {:ok, credentials} when is_map(credentials) -> credentials + _error -> %{} + end + end + + @spec configured_source(provider(), map(), keyword()) :: :env | :file | nil + defp configured_source(provider, stored, opts) do + case resolve_from_env(provider, opts) do + {:ok, _key} -> :env + :error -> configured_file_source(stored, provider) + end + end + + @spec configured_file_source(map(), provider()) :: :file | nil + defp configured_file_source(stored, provider) do + case Map.get(stored, provider) do + key when is_binary(key) and key != "" -> :file + _missing -> nil + end + end + + @spec request_ollama_tags(String.t()) :: term() + defp request_ollama_tags(host) do + :httpc.request(:get, {~c"#{host}/api/tags", []}, [{:timeout, 2000}], []) + end + + @spec normalize_ollama_result(term()) :: ollama_availability() + defp normalize_ollama_result(true), do: :available + defp normalize_ollama_result(:available), do: :available + defp normalize_ollama_result(false), do: {:unavailable, :probe_failed} + defp normalize_ollama_result({:unavailable, _reason} = unavailable), do: unavailable + + defp normalize_ollama_result({:ok, {{_version, 200, _message}, _headers, _body}}), + do: :available + + defp normalize_ollama_result({:ok, {{_version, status, _message}, _headers, _body}}), + do: {:unavailable, {:http_status, status}} + + defp normalize_ollama_result({:error, reason}), do: {:unavailable, reason} + defp normalize_ollama_result(other), do: {:unavailable, {:unexpected_result, other}} + @doc """ Returns the path to `~/.config/minga/oauth.json` (XDG-aware). """ diff --git a/lib/minga_agent/credentials/availability_check.ex b/lib/minga_agent/credentials/availability_check.ex new file mode 100644 index 000000000..b2b6e5e2a --- /dev/null +++ b/lib/minga_agent/credentials/availability_check.ex @@ -0,0 +1,133 @@ +defmodule MingaAgent.Credentials.AvailabilityCheck do + @moduledoc """ + Session-owned lifecycle for one correlated live credential availability check. + + The value owns request identity and worker metadata. `MingaAgent.Session` + performs Task and timer effects, then installs their references through these + transitions. Superseding a check returns the old worker resources for explicit + cleanup before the new request starts. + """ + + alias MingaAgent.Credentials.Snapshot + + @enforce_keys [:generation, :phase] + defstruct generation: 0, phase: :idle + + @type request :: %{ + token: reference(), + generation: non_neg_integer(), + session_id: String.t(), + snapshot: Snapshot.t(), + model_name: String.t() + } + @type cleanup :: {Task.t(), reference()} | nil + @type phase :: :idle | {:pending, request()} | {:running, request(), Task.t(), reference()} + @type t :: %__MODULE__{generation: non_neg_integer(), phase: phase()} + + @doc "Creates an idle availability-check lifecycle." + @spec new() :: t() + def new, do: %__MODULE__{generation: 0, phase: :idle} + + @doc "Invalidates old work and prepares the newest availability request." + @spec request(t(), Snapshot.t(), String.t(), String.t()) :: {t(), request(), cleanup()} + def request(%__MODULE__{} = check, %Snapshot{} = snapshot, model_name, session_id) + when is_binary(model_name) and is_binary(session_id) do + generation = check.generation + 1 + + request = %{ + token: make_ref(), + generation: generation, + session_id: session_id, + snapshot: snapshot, + model_name: model_name + } + + {%__MODULE__{generation: generation, phase: {:pending, request}}, request, cleanup(check)} + end + + @doc "Installs the supervised worker and timeout for the current request." + @spec install(t(), reference(), Task.t(), reference()) :: {:ok, t()} | :stale + def install( + %__MODULE__{phase: {:pending, %{token: token} = request}} = check, + token, + %Task{} = task, + timer_ref + ) + when is_reference(timer_ref) do + {:ok, %{check | phase: {:running, request, task, timer_ref}}} + end + + def install(%__MODULE__{}, _token, %Task{}, _timer_ref), do: :stale + + @doc "Accepts the correlated worker result and returns to idle." + @spec complete(t(), reference(), reference(), term()) :: + {:ok, request(), term(), reference(), t()} | :stale + def complete( + %__MODULE__{phase: {:running, %{token: token} = request, %Task{ref: task_ref}, timer_ref}} = + check, + task_ref, + token, + result + ) do + {:ok, request, result, timer_ref, %{check | phase: :idle}} + end + + def complete(%__MODULE__{}, _task_ref, _token, _result), do: :stale + + @doc "Accepts a current worker exit and returns to idle." + @spec worker_down(t(), reference(), term()) :: + {:ok, request(), term(), reference(), t()} | :stale + def worker_down( + %__MODULE__{phase: {:running, request, %Task{ref: task_ref}, timer_ref}} = check, + task_ref, + reason + ) do + {:ok, request, reason, timer_ref, %{check | phase: :idle}} + end + + def worker_down(%__MODULE__{}, _task_ref, _reason), do: :stale + + @doc "Accepts a current timeout and returns the worker for termination." + @spec timeout(t(), reference()) :: {:ok, request(), Task.t(), t()} | :stale + def timeout( + %__MODULE__{phase: {:running, %{token: token} = request, task, _timer_ref}} = check, + token + ) do + {:ok, request, task, %{check | phase: :idle}} + end + + def timeout(%__MODULE__{}, _token), do: :stale + + @doc "Settles a current request whose worker could not start." + @spec start_failed(t(), reference()) :: {:ok, request(), t()} | :stale + def start_failed( + %__MODULE__{phase: {:pending, %{token: token} = request}} = check, + token + ) do + {:ok, request, %{check | phase: :idle}} + end + + def start_failed(%__MODULE__{}, _token), do: :stale + + @doc "Returns whether the owner has a pending or running check." + @spec checking?(t()) :: boolean() + def checking?(%__MODULE__{phase: :idle}), do: false + def checking?(%__MODULE__{}), do: true + + @doc "Returns the request awaiting worker startup." + @spec pending_request(t()) :: request() | nil + def pending_request(%__MODULE__{phase: {:pending, request}}), do: request + def pending_request(%__MODULE__{}), do: nil + + @doc "Invalidates the current check and returns resources requiring cleanup." + @spec stop(t()) :: {t(), cleanup()} + def stop(%__MODULE__{} = check) do + {%{check | generation: check.generation + 1, phase: :idle}, cleanup(check)} + end + + @spec cleanup(t()) :: cleanup() + defp cleanup(%__MODULE__{phase: {:running, _request, task, timer_ref}}), + do: {task, timer_ref} + + defp cleanup(%__MODULE__{}), do: nil +end diff --git a/lib/minga_agent/credentials/snapshot.ex b/lib/minga_agent/credentials/snapshot.ex new file mode 100644 index 000000000..4ed2ee34d --- /dev/null +++ b/lib/minga_agent/credentials/snapshot.ex @@ -0,0 +1,41 @@ +defmodule MingaAgent.Credentials.Snapshot do + @moduledoc """ + Secret-free local credential classification captured from one acquisition. + + The snapshot records only credential sources and OAuth presence. API key + values never leave `MingaAgent.Credentials` and cannot appear in status, + events, errors, or diagnostics. + """ + + @enforce_keys [:provider_sources, :oauth_configured, :ollama_host] + defstruct [:provider_sources, :oauth_configured, :ollama_host] + + @type source :: :env | :file + @type t :: %__MODULE__{ + provider_sources: %{optional(String.t()) => source()}, + oauth_configured: boolean(), + ollama_host: String.t() + } + + @doc "Builds a secret-free credential snapshot." + @spec new(%{optional(String.t()) => source()}, boolean(), String.t()) :: t() + def new(provider_sources, oauth_configured, ollama_host) + when is_map(provider_sources) and is_boolean(oauth_configured) and is_binary(ollama_host) do + %__MODULE__{ + provider_sources: provider_sources, + oauth_configured: oauth_configured, + ollama_host: ollama_host + } + end + + @doc "Returns the configured source for one API-key provider." + @spec provider_source(t(), String.t()) :: source() | nil + def provider_source(%__MODULE__{provider_sources: sources}, provider) when is_binary(provider), + do: Map.get(sources, provider) + + @doc "Returns whether any local API-key or OAuth credential is configured." + @spec locally_configured?(t()) :: boolean() + def locally_configured?(%__MODULE__{} = snapshot) do + map_size(snapshot.provider_sources) > 0 or snapshot.oauth_configured + end +end diff --git a/lib/minga_agent/session.ex b/lib/minga_agent/session.ex index 4123b8023..2edf4ecc2 100644 --- a/lib/minga_agent/session.ex +++ b/lib/minga_agent/session.ex @@ -23,6 +23,8 @@ defmodule MingaAgent.Session do alias Minga.Extension.CodeLease alias MingaAgent.Config, as: AgentConfig alias MingaAgent.Credentials + alias MingaAgent.Credentials.AvailabilityCheck + alias MingaAgent.Credentials.Snapshot, as: CredentialSnapshot alias MingaAgent.Event alias MingaAgent.EventLog alias MingaAgent.EventLog.Failure @@ -66,6 +68,12 @@ defmodule MingaAgent.Session do @typedoc "Callback that reports whether credentials are currently configured." @type credentials_configured_fn :: (-> boolean()) + @typedoc "Callback that acquires one local credential snapshot." + @type credentials_snapshot_fn :: (-> CredentialSnapshot.t()) + + @typedoc "Callback that performs live Ollama discovery outside the Session mailbox." + @type credential_probe_fn :: (CredentialSnapshot.t() -> Credentials.ollama_availability()) + @typedoc "Remote attachment role." @type attachment_role :: SubscriberLifecycle.role() @@ -83,13 +91,20 @@ defmodule MingaAgent.Session do event_log_server: GenServer.server(), event_log_failure: event_log_failure() | nil, provider: ProviderLifecycle.t(), - credentials_configured_fn: credentials_configured_fn(), + credentials_configured_fn: credentials_configured_fn() | nil, + credentials_snapshot_fn: credentials_snapshot_fn(), + credential_probe_fn: credential_probe_fn(), + credential_task_supervisor: GenServer.server(), + credential_probe_timeout_ms: pos_integer(), + credential_availability: AvailabilityCheck.t(), + credential_readiness: Credentials.readiness(), turn_execution: TurnExecution.t(), transcript: Transcript.t(), subscriber_lifecycle: SubscriberLifecycle.t(), tool_approval_policy: tool_approval_policy(), idle_gc_timeout_ms: non_neg_integer(), persistence: Persistence.t(), + pending_model_change: String.t() | nil, pending_thinking_level: String.t() | nil, notifier: module() | {module(), term()}, background_subagent: boolean(), @@ -101,6 +116,7 @@ defmodule MingaAgent.Session do } @provider_stop_timeout_ms 1_000 + @credential_probe_timeout_ms 2_500 @provider_restart_options [ provider_restart_backoff_base_ms: :base_delay_ms, provider_restart_backoff_max_ms: :max_delay_ms, @@ -227,7 +243,8 @@ defmodule MingaAgent.Session do pending_approval: map() | nil, error: String.t() | nil, active_tool_name: String.t() | nil, - credentials_configured: boolean() + credentials_configured: boolean(), + credential_readiness: Credentials.readiness() } @doc "Returns a snapshot of session state for the editor to rebuild AgentState." @@ -430,7 +447,8 @@ defmodule MingaAgent.Session do so callers may pass a larger `timeout` when a slow provider startup should not surface as a call timeout. """ - @spec set_model(GenServer.server(), String.t(), timeout()) :: :ok | {:error, term()} + @spec set_model(GenServer.server(), String.t(), timeout()) :: + :ok | {:pending, :credential_discovery} | {:error, term()} def set_model(session, model, timeout \\ 5_000) when is_binary(model) do GenServer.call(session, {:set_model, model}, timeout) end @@ -633,8 +651,11 @@ defmodule MingaAgent.Session do Keyword.get(opts, :provider_opts, []) ) - credentials_configured_fn = - Keyword.get(opts, :credentials_configured_fn, &Credentials.any_configured?/0) + credentials_configured_fn = Keyword.get(opts, :credentials_configured_fn) + credentials_snapshot_fn = Keyword.get(opts, :credentials_snapshot_fn, &Credentials.snapshot/0) + + credential_probe_fn = + Keyword.get(opts, :credential_probe_fn, &Credentials.ollama_availability/1) initial_thinking_level = Keyword.get(opts, :thinking_level) timestamp = Calendar.strftime(DateTime.utc_now(), "%H:%M:%S UTC") @@ -642,11 +663,13 @@ defmodule MingaAgent.Session do session_id = Keyword.get(opts, :session_id, generate_session_id()) model_name = session_model_name(opts, provider_opts) resolved_provider_resolution = resolve_provider(opts) + credential_snapshot = credentials_snapshot_fn.() credentials_configured? = session_credentials_configured?( resolved_provider_resolution.module, provider_opts, + credential_snapshot, credentials_configured_fn ) @@ -683,6 +706,14 @@ defmodule MingaAgent.Session do now = DateTime.utc_now() + {credential_availability, credential_readiness} = + initial_credential_availability( + credential_snapshot, + model_name, + session_id, + credentials_configured? + ) + state = %{ session_id: session_id, workdir: Keyword.get(opts, :workdir), @@ -690,6 +721,14 @@ defmodule MingaAgent.Session do event_log_failure: nil, provider: provider, credentials_configured_fn: credentials_configured_fn, + credentials_snapshot_fn: credentials_snapshot_fn, + credential_probe_fn: credential_probe_fn, + credential_task_supervisor: + Keyword.get(opts, :credential_task_supervisor, Minga.Eval.TaskSupervisor), + credential_probe_timeout_ms: + Keyword.get(opts, :credential_probe_timeout_ms, @credential_probe_timeout_ms), + credential_availability: credential_availability, + credential_readiness: credential_readiness, turn_execution: TurnExecution.new(), transcript: Transcript.new( @@ -700,6 +739,7 @@ defmodule MingaAgent.Session do tool_approval_policy: Keyword.get(opts, :tool_approval_policy, :interactive), idle_gc_timeout_ms: Keyword.get_lazy(opts, :idle_gc_timeout_ms, &idle_gc_timeout_ms/0), persistence: Persistence.new(Keyword.get(opts, :persist?, true)), + pending_model_change: nil, pending_thinking_level: initial_thinking_level, notifier: Keyword.get(opts, :notifier, Notifier), background_subagent: Keyword.get(opts, :background_subagent, false), @@ -725,6 +765,7 @@ defmodule MingaAgent.Session do send(self(), :start_provider) end + state = maybe_start_pending_credential_check(state) {:ok, maybe_schedule_idle_gc(state)} end @@ -809,6 +850,9 @@ defmodule MingaAgent.Session do end def handle_call(:new_session, _from, state) do + restart_credential_check? = AvailabilityCheck.checking?(state.credential_availability) + state = stop_credential_check(state) + if ProviderLifecycle.pid(state.provider) do state.provider.module.new_session(ProviderLifecycle.pid(state.provider)) end @@ -849,7 +893,11 @@ defmodule MingaAgent.Session do |> reject_execution_approval(source_execution) |> announce_turn_status(execution) - state = %{state | turn_execution: execution} + state = + state + |> Map.put(:turn_execution, execution) + |> maybe_restart_credential_check(restart_credential_check?) + {:reply, :ok, notify_messages_changed(state)} end @@ -931,7 +979,8 @@ defmodule MingaAgent.Session do public_pending_approval(TurnExecution.pending_approval(state.turn_execution)), error: TurnExecution.error(state.turn_execution), active_tool_name: TurnExecution.active_tool_name(state.turn_execution), - credentials_configured: state.credentials_configured + credentials_configured: state.credentials_configured, + credential_readiness: state.credential_readiness } {:reply, snapshot, state} @@ -990,6 +1039,9 @@ defmodule MingaAgent.Session do {provider, :ok} when is_pid(provider) -> {:reply, :ok, state} + {_provider, {:pending, :credential_discovery}} -> + {:reply, {:error, :credential_discovery_pending}, state} + {_provider, :ok} when state.credentials_configured == false -> {:reply, {:error, :credentials_not_configured}, state} @@ -1192,21 +1244,10 @@ defmodule MingaAgent.Session do {state, refresh_result} = state |> update_model_configuration(model) + |> remember_pending_model_change(model) |> refresh_credentials_state_result() - result = - case {ProviderLifecycle.pid(state.provider), refresh_result} do - {nil, {:error, reason}} -> - {:error, reason} - - {nil, :ok} -> - :ok - - {provider, _refresh_result} -> - dispatch_optional(state.provider.module, :set_model, [provider, model]) - end - - {:reply, result, state} + {:reply, refresh_result, state} end def handle_call({:toggle_tool_collapse, message_id}, _from, state) do @@ -1297,14 +1338,16 @@ defmodule MingaAgent.Session do @impl GenServer @spec handle_info(term(), state()) :: {:noreply, state()} | {:stop, term(), state()} def handle_info(:start_provider, state) do - {:noreply, refresh_credentials_state(state)} + {state, _result} = maybe_start_provider_result(state) + {:noreply, state} end def handle_info({:start_provider, token}, state) do case ProviderLifecycle.retry_due(state.provider, token) do {:start, lifecycle} -> state = %{state | provider: lifecycle} - {:noreply, refresh_credentials_state(state)} + {state, _result} = refresh_credentials_state_result(state) + {:noreply, state} {:stale, _lifecycle} -> {:noreply, state} @@ -1316,6 +1359,36 @@ defmodule MingaAgent.Session do {:noreply, state} end + def handle_info( + {task_ref, {token, availability}}, + %{credential_availability: credential_availability} = state + ) + when is_reference(task_ref) and is_reference(token) do + case AvailabilityCheck.complete(credential_availability, task_ref, token, availability) do + {:ok, request, availability, timer_ref, credential_availability} -> + Process.demonitor(task_ref, [:flush]) + cancel_credential_timer(timer_ref) + + state = %{state | credential_availability: credential_availability} + {:noreply, apply_credential_availability(state, request, availability)} + + :stale -> + {:noreply, state} + end + end + + def handle_info({:credential_probe_timeout, token}, state) when is_reference(token) do + case AvailabilityCheck.timeout(state.credential_availability, token) do + {:ok, request, task, credential_availability} -> + Task.shutdown(task, :brutal_kill) + state = %{state | credential_availability: credential_availability} + {:noreply, settle_credential_unavailable(state, request, :timeout)} + + :stale -> + {:noreply, state} + end + end + def handle_info( {:event_log_commit, _receipt, _event_type, {:persisted, _event_id}}, state @@ -1375,7 +1448,15 @@ defmodule MingaAgent.Session do end def handle_info({:DOWN, ref, :process, pid, reason}, state) do - {:noreply, handle_subscriber_down(state, pid, ref, reason)} + case AvailabilityCheck.worker_down(state.credential_availability, ref, reason) do + {:ok, request, reason, timer_ref, credential_availability} -> + cancel_credential_timer(timer_ref) + state = %{state | credential_availability: credential_availability} + {:noreply, settle_credential_unavailable(state, request, {:worker_exit, reason})} + + :stale -> + {:noreply, handle_subscriber_down(state, pid, ref, reason)} + end end def handle_info({:timeout, timer_ref, :idle_gc}, state) do @@ -1798,6 +1879,10 @@ defmodule MingaAgent.Session do TurnExecution.prompt_kind(), state() ) :: {:reply, :ok | {:queued, TurnExecution.prompt_kind()} | {:error, term()}, state()} + defp handle_prompt(_content, _kind, %{credential_readiness: :checking} = state) do + {:reply, {:error, :credential_discovery_pending}, state} + end + defp handle_prompt(_content, _kind, %{credentials_configured: false} = state) do # No usable provider yet. Refuse locally so callers can preserve the draft instead of clearing it. {:reply, {:error, :credentials_not_configured}, state} @@ -1809,11 +1894,20 @@ defmodule MingaAgent.Session do %{provider: provider} = state ) when ProviderLifecycle.is_detached(provider) do - state = refresh_credentials_state(state) + {state, refresh_result} = refresh_credentials_state_result(state) - case ProviderLifecycle.pid(state.provider) do - nil -> {:reply, {:error, :provider_not_ready}, state} - _ -> handle_prompt(content, kind, state) + case {ProviderLifecycle.pid(state.provider), refresh_result} do + {_provider, {:pending, :credential_discovery}} -> + {:reply, {:error, :credential_discovery_pending}, state} + + {_provider, {:error, reason}} -> + {:reply, {:error, reason}, state} + + {nil, :ok} -> + {:reply, {:error, :provider_not_ready}, state} + + {_provider, :ok} -> + handle_prompt(content, kind, state) end end @@ -2180,7 +2274,7 @@ defmodule MingaAgent.Session do @spec notify_subscriber_connected(pid(), state()) :: :ok defp notify_subscriber_connected(pid, state) do - send(pid, {:agent_event, self(), {:credentials_status, state.credentials_configured}}) + send(pid, {:agent_event, self(), {:credentials_status, state.credential_readiness}}) notify_retained_event_log_failure(pid, state.event_log_failure) end @@ -2982,33 +3076,264 @@ defmodule MingaAgent.Session do defp format_error({:spawn_failed, msg}), do: "Failed to start agent: #{msg}" defp format_error(reason), do: inspect(reason) - # Recomputes whether any provider credential is configured, stores it, and - # tells subscribers so the UI can reflect a truthful "not configured" state. - # `Credentials.any_configured?/0` may block briefly (Ollama probe) so this - # runs in the session process, off the render path. + # Acquires one local snapshot in the Session mailbox, then delegates live + # Ollama discovery to a supervised worker. The local acquisition is bounded + # filesystem/environment work and never performs network I/O. @spec refresh_credentials_state(state()) :: state() defp refresh_credentials_state(state) do {state, _result} = refresh_credentials_state_result(state) state end - @spec refresh_credentials_state_result(state()) :: {state(), :ok | {:error, term()}} + @spec refresh_credentials_state_result(state()) :: + {state(), :ok | {:pending, :credential_discovery} | {:error, term()}} defp refresh_credentials_state_result(state) do - # Only the native provider resolves its own credentials from the - # environment. Custom providers, and any caller that injects its own - # `:llm_client` (tests, embedded transports), manage their own auth, so - # treat them as always ready. + snapshot = state.credentials_snapshot_fn.() + configured? = session_credentials_configured?( state.provider.module, state.provider.opts, + snapshot, state.credentials_configured_fn ) - state = %{state | credentials_configured: configured?} + refresh_credentials_from_snapshot(state, snapshot, configured?) + end + + @spec refresh_credentials_from_snapshot(state(), CredentialSnapshot.t(), boolean()) :: + {state(), :ok | {:pending, :credential_discovery} | {:error, term()}} + defp refresh_credentials_from_snapshot(state, _snapshot, true) do + state = stop_credential_check(state) + state = install_credential_readiness(state, :configured) {state, result} = maybe_start_provider_result(state) - broadcast(state, {:credentials_status, configured?}) - {state, result} + {state, model_result} = apply_pending_model_change(state) + {broadcast_credential_readiness(state), first_error(result, model_result)} + end + + defp refresh_credentials_from_snapshot(state, snapshot, false) do + {credential_availability, request, cleanup} = + AvailabilityCheck.request( + state.credential_availability, + snapshot, + state.provider.model_name, + state.session_id + ) + + cleanup_credential_worker(cleanup) + + state = %{ + state + | credential_availability: credential_availability, + credentials_configured: false, + credential_readiness: :checking + } + + state = broadcast_credential_readiness(state) + + case start_credential_check(state, request) do + {:ok, state} -> {state, {:pending, :credential_discovery}} + {:error, reason, state} -> {state, {:error, reason}} + end + end + + @spec initial_credential_availability( + CredentialSnapshot.t(), + String.t(), + String.t(), + boolean() + ) :: {AvailabilityCheck.t(), Credentials.readiness()} + defp initial_credential_availability(_snapshot, _model_name, _session_id, true) do + {AvailabilityCheck.new(), :configured} + end + + defp initial_credential_availability(snapshot, model_name, session_id, false) do + {credential_availability, _request, _cleanup} = + AvailabilityCheck.request(AvailabilityCheck.new(), snapshot, model_name, session_id) + + {credential_availability, :checking} + end + + @spec maybe_start_pending_credential_check(state()) :: state() + defp maybe_start_pending_credential_check(state) do + case AvailabilityCheck.pending_request(state.credential_availability) do + nil -> + state + + request -> + case start_credential_check(state, request) do + {:ok, state} -> state + {:error, _reason, state} -> state + end + end + end + + @spec start_credential_check(state(), AvailabilityCheck.request()) :: + {:ok, state()} | {:error, term(), state()} + defp start_credential_check(state, request) do + probe_fn = state.credential_probe_fn + + task = + Task.Supervisor.async(state.credential_task_supervisor, fn -> + {request.token, probe_fn.(request.snapshot)} + end) + + timer_ref = + Process.send_after( + self(), + {:credential_probe_timeout, request.token}, + state.credential_probe_timeout_ms + ) + + case AvailabilityCheck.install( + state.credential_availability, + request.token, + task, + timer_ref + ) do + {:ok, credential_availability} -> + {:ok, %{state | credential_availability: credential_availability}} + + :stale -> + cleanup_credential_worker({task, timer_ref}) + {:error, :credential_discovery_superseded, state} + end + catch + :exit, reason -> + credential_check_start_failed(state, request, reason) + end + + @spec credential_check_start_failed(state(), AvailabilityCheck.request(), term()) :: + {:error, term(), state()} + defp credential_check_start_failed(state, request, reason) do + case AvailabilityCheck.start_failed(state.credential_availability, request.token) do + {:ok, request, credential_availability} -> + state = %{state | credential_availability: credential_availability} + + {:error, {:credential_discovery_start_failed, reason}, + settle_credential_unavailable(state, request, {:start_failed, reason})} + + :stale -> + {:error, :credential_discovery_superseded, state} + end + end + + @spec apply_credential_availability( + state(), + AvailabilityCheck.request(), + Credentials.ollama_availability() + ) :: state() + defp apply_credential_availability(state, request, :available) do + if credential_request_current?(state, request) do + provider_was_running? = is_pid(ProviderLifecycle.pid(state.provider)) + state = install_credential_readiness(state, :configured) + {state, _result} = maybe_start_provider_result(state) + + state = + if provider_was_running? do + {state, _result} = apply_pending_model_change(state) + state + else + %{state | pending_model_change: nil} + end + + broadcast_credential_readiness(state) + else + state + end + end + + defp apply_credential_availability(state, request, {:unavailable, reason}) do + settle_credential_unavailable(state, request, reason) + end + + defp apply_credential_availability(state, request, unexpected) do + settle_credential_unavailable(state, request, {:unexpected_result, unexpected}) + end + + @spec settle_credential_unavailable(state(), AvailabilityCheck.request(), term()) :: state() + defp settle_credential_unavailable(state, request, _reason) do + if credential_request_current?(state, request) do + state + |> install_credential_readiness(:unconfigured) + |> broadcast_credential_readiness() + else + state + end + end + + @spec install_credential_readiness(state(), Credentials.readiness()) :: state() + defp install_credential_readiness(state, readiness) do + %{ + state + | credential_readiness: readiness, + credentials_configured: readiness == :configured + } + end + + @spec broadcast_credential_readiness(state()) :: state() + defp broadcast_credential_readiness(state) do + broadcast(state, {:credentials_status, state.credential_readiness}) + end + + @spec stop_credential_check(state()) :: state() + defp stop_credential_check(state) do + {credential_availability, cleanup} = AvailabilityCheck.stop(state.credential_availability) + cleanup_credential_worker(cleanup) + %{state | credential_availability: credential_availability} + end + + @spec credential_request_current?(state(), AvailabilityCheck.request()) :: boolean() + defp credential_request_current?(state, request) do + request.session_id == state.session_id and request.model_name == state.provider.model_name + end + + @spec maybe_restart_credential_check(state(), boolean()) :: state() + defp maybe_restart_credential_check(state, true), do: refresh_credentials_state(state) + defp maybe_restart_credential_check(state, false), do: state + + @spec remember_pending_model_change(state(), String.t()) :: state() + defp remember_pending_model_change(state, model) do + if is_pid(ProviderLifecycle.pid(state.provider)) do + %{state | pending_model_change: model} + else + %{state | pending_model_change: nil} + end + end + + @spec apply_pending_model_change(state()) :: {state(), :ok | {:error, term()}} + defp apply_pending_model_change(%{pending_model_change: nil} = state), do: {state, :ok} + + defp apply_pending_model_change(%{pending_model_change: model} = state) do + state = %{state | pending_model_change: nil} + + case ProviderLifecycle.pid(state.provider) do + nil -> + {state, :ok} + + provider -> + {state, dispatch_optional(state.provider.module, :set_model, [provider, model])} + end + end + + @spec first_error(:ok | {:error, term()}, :ok | {:error, term()}) :: + :ok | {:error, term()} + defp first_error({:error, _reason} = error, _other), do: error + defp first_error(:ok, result), do: result + + @spec cleanup_credential_worker(AvailabilityCheck.cleanup()) :: :ok + defp cleanup_credential_worker(nil), do: :ok + + defp cleanup_credential_worker({task, timer_ref}) do + cancel_credential_timer(timer_ref) + Task.shutdown(task, :brutal_kill) + :ok + end + + @spec cancel_credential_timer(reference()) :: :ok + defp cancel_credential_timer(timer_ref) do + _cancelled? = Process.cancel_timer(timer_ref) + :ok end @spec update_model_configuration(state(), String.t()) :: state() @@ -3493,14 +3818,30 @@ defmodule MingaAgent.Session do # Determines which provider module to use. If an explicit `:provider` option is # passed (common in tests and from existing code), use that as a config-owned provider. Otherwise, delegate # to the ProviderResolver which checks config and the provider registry. - @spec session_credentials_configured?(module(), keyword(), credentials_configured_fn()) :: - boolean() - defp session_credentials_configured?(provider_module, provider_opts, credentials_configured_fn) do + @spec session_credentials_configured?( + module(), + keyword(), + CredentialSnapshot.t(), + credentials_configured_fn() | nil + ) :: boolean() + defp session_credentials_configured?( + provider_module, + provider_opts, + snapshot, + credentials_configured_fn + ) do provider_module != MingaAgent.Providers.Native or Keyword.has_key?(provider_opts, :llm_client) or - credentials_configured_fn.() + configured_override?(credentials_configured_fn, snapshot) end + @spec configured_override?(credentials_configured_fn() | nil, CredentialSnapshot.t()) :: + boolean() + defp configured_override?(configured_fn, _snapshot) when is_function(configured_fn, 0), + do: configured_fn.() + + defp configured_override?(nil, snapshot), do: Credentials.any_configured?(snapshot) + @spec provider_startable?(module(), keyword(), String.t()) :: boolean() defp provider_startable?(provider_module, provider_opts, model_name) do provider_module != MingaAgent.Providers.Native or @@ -3620,6 +3961,8 @@ defmodule MingaAgent.Session do defp restore_loaded_session(state, data) do case persist_current_before_replacement(state, data.id) do :ok -> + restart_credential_check? = AvailabilityCheck.checking?(state.credential_availability) + state = stop_credential_check(state) state = cancel_save_timer(state) loaded_at = parse_datetime(Map.get(data, :last_message_at)) || DateTime.utc_now() @@ -3660,10 +4003,12 @@ defmodule MingaAgent.Session do persistence: persistence, provider: lifecycle, turn_execution: execution, + pending_model_change: nil, created_at: loaded_at } apply_loaded_model_to_provider(state) + state = maybe_restart_credential_check(state, restart_credential_check?) finish_loaded_session_restore(state, data) {:error, reason} -> @@ -3838,6 +4183,7 @@ defmodule MingaAgent.Session do }) dispatch_session_end(state, reason) + state = stop_credential_check(state) state = release_subscriber_lifecycle(state) _stopped_state = stop_provider_lifecycle(state) diff --git a/lib/minga_editor/agent/auth_status_effect.ex b/lib/minga_editor/agent/auth_status_effect.ex new file mode 100644 index 000000000..6b16cdae1 --- /dev/null +++ b/lib/minga_editor/agent/auth_status_effect.ex @@ -0,0 +1,178 @@ +defmodule MingaEditor.Agent.AuthStatusEffect do + @moduledoc """ + Bounded live Ollama discovery for `/auth`. + + Local credential status is published before this effect is scheduled. The + Editor effect scheduler runs the network probe off the Editor mailbox with a + latest-wins policy and timeout. Application is correlated to the original + session, so a late result cannot publish into a replacement session. + """ + + @behaviour MingaEditor.Effect + + alias MingaAgent.Credentials + alias MingaAgent.Credentials.Snapshot + alias MingaAgent.Session + alias MingaEditor.Effect.Outcome + alias MingaEditor.Effect.Policy + alias MingaEditor.Effect.Request + alias MingaEditor.EffectScheduler + alias MingaEditor.Shell.Runtime + alias MingaEditor.Shell.Traditional.NoticeWorkflow + alias MingaEditor.State, as: EditorState + + @resource {:auth_status, :active_session} + @timeout_ms 2_500 + + @type probe :: :default | {module(), atom(), [term()]} + + @enforce_keys [:snapshot, :session, :session_id, :probe] + defstruct [:snapshot, :session, :session_id, :probe] + + @type t :: %__MODULE__{ + snapshot: Snapshot.t(), + session: pid() | nil, + session_id: String.t() | nil, + probe: probe() + } + + @doc "Builds one latest-wins live status request." + @spec request(Snapshot.t(), pid() | nil, String.t() | nil, keyword()) :: Request.t() + def request(%Snapshot{} = snapshot, session, session_id, opts \\ []) + when (is_pid(session) and (is_binary(session_id) or is_nil(session_id))) or + (is_nil(session) and is_nil(session_id)) do + effect = %__MODULE__{ + snapshot: snapshot, + session: session, + session_id: session_id, + probe: Keyword.get(opts, :probe, :default) + } + + Request.new(effect, @resource, Policy.latest_wins(), + timeout_ms: Keyword.get(opts, :timeout_ms, @timeout_ms) + ) + end + + @doc "Schedules live status and settles admission failures immediately." + @spec schedule(EditorState.t(), Snapshot.t(), pid() | nil, keyword()) :: EditorState.t() + def schedule(%EditorState{} = state, %Snapshot{} = snapshot, session, opts \\ []) do + request = request(snapshot, session, capture_session_id(session), opts) + + case state.effect_scheduler do + nil -> apply_failure(state, request.effect, :scheduler_unavailable) + scheduler -> schedule_on(state, scheduler, request) + end + end + + @impl true + @spec run(t()) :: {:ok, Credentials.ollama_availability()} | {:error, term()} + def run(%__MODULE__{snapshot: snapshot, probe: :default}) do + {:ok, Credentials.ollama_availability(snapshot)} + end + + def run(%__MODULE__{snapshot: snapshot, probe: {module, function, args}}) + when is_atom(module) and is_atom(function) and is_list(args) do + {:ok, apply(module, function, [snapshot | args])} + rescue + error -> {:error, {:probe_exception, error.__struct__}} + catch + :exit, reason -> {:error, {:probe_exit, reason}} + end + + @impl true + @spec apply(EditorState.t(), Outcome.t()) :: {EditorState.t(), Outcome.t()} + def apply( + %EditorState{} = state, + %Outcome{request: %{effect: %__MODULE__{} = effect}, value: {:completed, availability}} = + outcome + ) do + if current_session?(state, effect.session, effect.session_id) do + {publish(state, effect.session, availability), outcome} + else + {state, Outcome.stale(outcome, :agent_session_changed)} + end + end + + def apply( + %EditorState{} = state, + %Outcome{request: %{effect: %__MODULE__{} = effect}, value: {:failed, reason}} = outcome + ) do + if current_session?(state, effect.session, effect.session_id) do + {publish(state, effect.session, {:unavailable, reason}), outcome} + else + {state, Outcome.stale(outcome, :agent_session_changed)} + end + end + + def apply(%EditorState{} = state, %Outcome{} = outcome), do: {state, outcome} + + @impl true + @spec render?(Outcome.t()) :: boolean() + def render?(%Outcome{value: {status, _payload}}) when status in [:completed, :failed], do: true + def render?(%Outcome{}), do: false + + @spec schedule_on(EditorState.t(), GenServer.server(), Request.t()) :: EditorState.t() + defp schedule_on(state, scheduler, request) do + case EffectScheduler.schedule(scheduler, request) do + {:ok, _request_id, _disposition} -> state + {:error, reason} -> apply_failure(state, request.effect, reason) + end + catch + :exit, reason -> apply_failure(state, request.effect, {:scheduler_unavailable, reason}) + end + + @spec apply_failure(EditorState.t(), t(), term()) :: EditorState.t() + defp apply_failure(state, effect, reason) do + if current_session?(state, effect.session, effect.session_id) do + publish(state, effect.session, {:unavailable, reason}) + else + state + end + end + + @spec current_session?(EditorState.t(), pid() | nil, String.t() | nil) :: boolean() + defp current_session?(state, nil, nil), do: is_nil(Runtime.active_session(state.shell_runtime)) + + defp current_session?(state, session, session_id) when is_pid(session) do + Runtime.active_session(state.shell_runtime) == session and session_alive?(session) and + capture_session_id(session) == session_id + end + + defp current_session?(_state, _session, _session_id), do: false + + @spec capture_session_id(pid() | nil) :: String.t() | nil + defp capture_session_id(nil), do: nil + + defp capture_session_id(session) when is_pid(session) do + Session.session_id(session) + catch + :exit, _reason -> nil + end + + @spec session_alive?(pid()) :: boolean() + defp session_alive?(session) when node(session) != node(), do: true + defp session_alive?(session), do: Process.alive?(session) + + @spec publish(EditorState.t(), pid() | nil, Credentials.ollama_availability()) :: + EditorState.t() + defp publish(state, session, availability) when is_pid(session) do + Session.add_system_message(session, availability_message(availability)) + NoticeWorkflow.publish(state, availability_notice(availability)) + catch + :exit, _reason -> state + end + + defp publish(state, nil, availability) do + NoticeWorkflow.publish(state, availability_notice(availability)) + end + + @spec availability_message(Credentials.ollama_availability()) :: String.t() + defp availability_message(:available), do: "Ollama availability: ✓ available (local)" + defp availability_message({:unavailable, _reason}), do: "Ollama availability: ✗ unavailable" + defp availability_message(:pending), do: "Ollama availability: … checking" + + @spec availability_notice(Credentials.ollama_availability()) :: String.t() + defp availability_notice(:available), do: "Ollama available" + defp availability_notice({:unavailable, _reason}), do: "Ollama unavailable" + defp availability_notice(:pending), do: "Checking Ollama availability" +end diff --git a/lib/minga_editor/agent/session_event_workflow.ex b/lib/minga_editor/agent/session_event_workflow.ex index 9809e01ea..4942f3e49 100644 --- a/lib/minga_editor/agent/session_event_workflow.ex +++ b/lib/minga_editor/agent/session_event_workflow.ex @@ -58,12 +58,13 @@ defmodule MingaEditor.Agent.SessionEventWorkflow do |> MingaEditor.schedule_render(16) end - @doc "Updates whether provider credentials are configured and schedules a render." - @spec credentials_status(EditorState.t(), boolean()) :: EditorState.t() - def credentials_status(%EditorState{} = state, configured?) when is_boolean(configured?) do + @doc "Updates explicit credential readiness and schedules a render." + @spec credentials_status(EditorState.t(), MingaAgent.Credentials.readiness()) :: EditorState.t() + def credentials_status(%EditorState{} = state, readiness) + when readiness in [:checking, :configured, :unconfigured] do state |> TraditionalWorkflow.install_agent_panel( - Panel.set_credentials_configured(state.workspace.agent_ui.panel, configured?) + Panel.set_credential_readiness(state.workspace.agent_ui.panel, readiness) ) |> MingaEditor.schedule_render(16) end diff --git a/lib/minga_editor/agent/slash_command.ex b/lib/minga_editor/agent/slash_command.ex index f0d2a2d19..fb7029e0c 100644 --- a/lib/minga_editor/agent/slash_command.ex +++ b/lib/minga_editor/agent/slash_command.ex @@ -18,6 +18,7 @@ defmodule MingaEditor.Agent.SlashCommand do alias MingaAgent.SessionExport alias MingaAgent.Skills alias MingaEditor.Agent.UIState + alias MingaEditor.Agent.AuthStatusEffect alias MingaEditor.Remote.SessionClient alias Minga.Config alias MingaEditor.Commands.Agent, as: AgentCommands @@ -724,14 +725,15 @@ defmodule MingaEditor.Agent.SlashCommand do # ── Auth command ───────────────────────────────────────────────────────────── - @spec do_auth(state(), String.t()) :: state() - defp do_auth(state, "") do - # No args: show status for all providers - statuses = Credentials.status() + @doc false + @spec auth_status(state(), keyword()) :: state() + def auth_status(state, opts \\ []) do + snapshot = Keyword.get_lazy(opts, :snapshot, &Credentials.snapshot/0) + statuses = Credentials.status(snapshot, :pending) lines = Enum.map_join(statuses, "\n", fn s -> - icon = if s.configured, do: "✓", else: "✗" + icon = auth_status_icon(s) source_hint = format_source_hint(s) url_hint = dashboard_url_hint(s.provider) " #{icon} #{provider_display_name(s.provider)}#{source_hint}#{url_hint}" @@ -742,9 +744,14 @@ defmodule MingaEditor.Agent.SlashCommand do message = "API key status:\n#{lines}#{endpoint_info}\n\nUse /auth to add a key.\nUse /auth revoke to remove one." - emit_system_message(state, message) + state = emit_system_message(state, message) + session = Runtime.active_session(state.shell_runtime) + AuthStatusEffect.schedule(state, snapshot, session, Keyword.get(opts, :effect_opts, [])) end + @spec do_auth(state(), String.t()) :: state() + defp do_auth(state, ""), do: auth_status(state) + defp do_auth(state, args) do parts = String.split(String.trim(args), " ", parts: 3) @@ -1560,6 +1567,11 @@ defmodule MingaEditor.Agent.SlashCommand do defp format_source_hint(%{source: nil}), do: "" defp format_source_hint(%{source: source}), do: " (#{source})" + @spec auth_status_icon(Credentials.ProviderStatus.t()) :: String.t() + defp auth_status_icon(%{availability: :pending}), do: "…" + defp auth_status_icon(%{configured: true}), do: "✓" + defp auth_status_icon(%{configured: false}), do: "✗" + # Returns a short URL hint for unconfigured providers in the /auth status display. # Configured providers don't need the hint (user already has a key). @spec dashboard_url_hint(String.t()) :: String.t() diff --git a/lib/minga_editor/agent/ui_state/panel.ex b/lib/minga_editor/agent/ui_state/panel.ex index 7901dfc5c..f247e3aef 100644 --- a/lib/minga_editor/agent/ui_state/panel.ex +++ b/lib/minga_editor/agent/ui_state/panel.ex @@ -12,6 +12,7 @@ defmodule MingaEditor.Agent.UIState.Panel do """ alias MingaAgent.Config, as: AgentConfig + alias MingaAgent.Credentials alias MingaEditor.Agent.Transcript alias Minga.Editing.Scroll alias MingaEditor.Agent.UIState.TranscriptProjection @@ -34,7 +35,8 @@ defmodule MingaEditor.Agent.UIState.Panel do transcript: TranscriptProjection.t(), mention_completion: MingaAgent.FileMention.completion() | nil, pasted_blocks: [paste_block()], - credentials_configured: boolean() + credentials_configured: boolean(), + credential_readiness: Credentials.readiness() } defstruct visible: false, @@ -50,7 +52,8 @@ defmodule MingaEditor.Agent.UIState.Panel do transcript: TranscriptProjection.new(), mention_completion: nil, pasted_blocks: [], - credentials_configured: false + credentials_configured: false, + credential_readiness: :unconfigured @doc "Creates a new panel state with truthful model defaults." @spec new() :: t() @@ -60,7 +63,8 @@ defmodule MingaEditor.Agent.UIState.Panel do %__MODULE__{ provider_name: AgentConfig.extract_provider_prefix(model), model_name: model, - credentials_configured: false + credentials_configured: false, + credential_readiness: :unconfigured } end @@ -100,7 +104,21 @@ defmodule MingaEditor.Agent.UIState.Panel do @spec set_credentials_configured(t(), boolean()) :: t() def set_credentials_configured(%__MODULE__{} = panel, configured?) do panel = ensure_configured_model(panel) - %{panel | credentials_configured: configured?} + readiness = if configured?, do: :configured, else: :unconfigured + %{panel | credentials_configured: configured?, credential_readiness: readiness} + end + + @doc "Sets explicit credential readiness while retaining the compatibility boolean." + @spec set_credential_readiness(t(), Credentials.readiness()) :: t() + def set_credential_readiness(%__MODULE__{} = panel, readiness) + when readiness in [:checking, :configured, :unconfigured] do + panel = ensure_configured_model(panel) + + %{ + panel + | credentials_configured: readiness == :configured, + credential_readiness: readiness + } end @doc "Sets the displayed provider name." diff --git a/lib/minga_editor/commands/agent.ex b/lib/minga_editor/commands/agent.ex index d9ce34f55..72d3e614f 100644 --- a/lib/minga_editor/commands/agent.ex +++ b/lib/minga_editor/commands/agent.ex @@ -48,7 +48,12 @@ defmodule MingaEditor.Commands.Agent do @type state :: EditorState.t() @type prompt_readiness :: - :no_model | :credentials_missing | :starting | {:startup_failed, String.t()} | :ready + :no_model + | :credentials_checking + | :credentials_missing + | :starting + | {:startup_failed, String.t()} + | :ready @type prompt_submit_status :: :ready | {:blocked, String.t()} @doc "Legacy alias for `toggle_agent_split/1`." @@ -593,6 +598,9 @@ defmodule MingaEditor.Commands.Agent do {:error, :provider_not_ready} -> NoticeWorkflow.publish(state, provider_starting_status()) + {:error, :credential_discovery_pending} -> + NoticeWorkflow.publish(state, credential_discovery_pending_status()) + {:error, :credentials_not_configured} -> NoticeWorkflow.publish(state, credentials_missing_status()) @@ -686,6 +694,8 @@ defmodule MingaEditor.Commands.Agent do @spec providerless_prompt_readiness(pid()) :: prompt_readiness() defp providerless_prompt_readiness(session) do case Session.editor_snapshot(session) do + %{credential_readiness: :checking} -> :credentials_checking + %{credential_readiness: :unconfigured} -> :credentials_missing %{credentials_configured: false} -> :credentials_missing %{error: error} when is_binary(error) and error != "" -> {:startup_failed, error} _snapshot -> :starting @@ -703,6 +713,9 @@ defmodule MingaEditor.Commands.Agent do defp prompt_readiness_submit_status(:credentials_missing), do: {:blocked, credentials_missing_status()} + defp prompt_readiness_submit_status(:credentials_checking), + do: {:blocked, credential_discovery_pending_status()} + defp prompt_readiness_submit_status(:starting), do: {:blocked, provider_starting_status()} defp prompt_readiness_submit_status({:startup_failed, error}) do @@ -719,6 +732,11 @@ defmodule MingaEditor.Commands.Agent do "No provider credentials are configured for this model. Your prompt was preserved. Run /auth or /login to set one up." end + @spec credential_discovery_pending_status() :: String.t() + defp credential_discovery_pending_status do + "Checking local Ollama availability. Your prompt was preserved." + end + @spec provider_starting_status() :: String.t() defp provider_starting_status do "Agent provider still starting. Your prompt was preserved." @@ -790,6 +808,9 @@ defmodule MingaEditor.Commands.Agent do {:error, :provider_not_ready} -> NoticeWorkflow.publish(state, provider_starting_status()) + {:error, :credential_discovery_pending} -> + NoticeWorkflow.publish(state, credential_discovery_pending_status()) + {:error, :credentials_not_configured} -> NoticeWorkflow.publish(state, credentials_missing_status()) @@ -1231,6 +1252,11 @@ defmodule MingaEditor.Commands.Agent do Session.add_system_message(session, "Model: #{model}") NoticeWorkflow.publish(state, "Model: #{model}") + {:pending, :credential_discovery} -> + message = "Model change accepted: #{model}. Checking local Ollama availability." + Session.add_system_message(session, message) + NoticeWorkflow.publish(state, message) + {:error, reason} when is_binary(reason) -> NoticeWorkflow.publish(state, reason) diff --git a/test/minga_agent/credentials_test.exs b/test/minga_agent/credentials_test.exs index 2f39e7a41..de06294dc 100644 --- a/test/minga_agent/credentials_test.exs +++ b/test/minga_agent/credentials_test.exs @@ -121,6 +121,60 @@ defmodule MingaAgent.CredentialsTest do assert oai.source == :env assert ggl.configured == false end + + test "acquires the credential file once and retains environment precedence", %{opts: opts} do + test_pid = self() + + opts = + opts + |> Keyword.put(:env, Map.put(@nil_env, "ANTHROPIC_API_KEY", "env-secret")) + |> Keyword.put(:credentials_reader, fn _path -> + send(test_pid, :credentials_file_read) + {:ok, %{"anthropic" => "stored-secret", "openai" => "stored-openai-secret"}} + end) + + snapshot = Credentials.snapshot(opts) + statuses = Credentials.status(snapshot, :pending) + + assert_received :credentials_file_read + refute_received :credentials_file_read + assert Enum.find(statuses, &(&1.provider == "anthropic")).source == :env + assert Enum.find(statuses, &(&1.provider == "openai")).source == :file + refute inspect(statuses) =~ "env-secret" + refute inspect(statuses) =~ "stored-secret" + end + + test "distinguishes pending and completed unavailable Ollama status", %{opts: opts} do + snapshot = Credentials.snapshot(opts) + + pending = Credentials.status(snapshot, :pending) |> Enum.find(&(&1.provider == "ollama")) + + unavailable = + Credentials.status(snapshot, {:unavailable, :timeout}) + |> Enum.find(&(&1.provider == "ollama")) + + assert pending.availability == :pending + refute pending.configured + assert unavailable.availability == {:unavailable, :timeout} + refute unavailable.configured + end + + test "malformed storage remains unconfigured and OAuth remains independently configured", %{ + opts: opts + } do + malformed_opts = + opts + |> Keyword.put(:credentials_reader, fn _path -> {:error, :malformed_json} end) + |> Keyword.put(:oauth_probe, fn -> true end) + + snapshot = Credentials.snapshot(malformed_opts) + statuses = Credentials.status(snapshot, {:unavailable, :connection_refused}) + + assert Credentials.any_configured?(snapshot) + assert Enum.find(statuses, &(&1.provider == "openai_codex")).configured + refute Enum.find(statuses, &(&1.provider == "anthropic")).configured + refute Enum.find(statuses, &(&1.provider == "ollama")).configured + end end describe "any_configured?/0" do @@ -132,6 +186,33 @@ defmodule MingaAgent.CredentialsTest do :ok = Credentials.store("anthropic", "some-key", opts) assert Credentials.any_configured?(opts) end + + test "does not execute the live Ollama probe", %{opts: opts} do + test_pid = self() + opts = Keyword.put(opts, :ollama_probe, fn -> send(test_pid, :unexpected_probe) end) + + refute Credentials.any_configured?(opts) + refute_received :unexpected_probe + end + end + + describe "ollama_availability/2" do + test "executes an explicit probe against the captured host", %{opts: opts} do + snapshot = + Credentials.snapshot( + Keyword.put(opts, :env, Map.put(@nil_env, "OLLAMA_HOST", "http://ollama.test")) + ) + + assert :available = + Credentials.ollama_availability(snapshot, + ollama_probe: fn host -> host == "http://ollama.test" end + ) + + assert {:unavailable, :held_open} = + Credentials.ollama_availability(snapshot, + ollama_probe: fn _host -> {:unavailable, :held_open} end + ) + end end describe "provider_from_model/1" do diff --git a/test/minga_agent/session_lifecycle_test.exs b/test/minga_agent/session_lifecycle_test.exs index 031af0137..d034582c2 100644 --- a/test/minga_agent/session_lifecycle_test.exs +++ b/test/minga_agent/session_lifecycle_test.exs @@ -578,7 +578,7 @@ defmodule MingaAgent.SessionLifecycleTest do describe "subscribe/unsubscribe" do test "stops receiving events after unsubscribe" do session = start_subscribed_session() - assert_receive {:agent_event, ^session, {:credentials_status, true}} + assert_receive {:agent_event, ^session, {:credentials_status, :configured}} :ok = Session.unsubscribe(session) diff --git a/test/minga_agent/session_recovery_test.exs b/test/minga_agent/session_recovery_test.exs index 4d90f2d93..e9ab2c12e 100644 --- a/test/minga_agent/session_recovery_test.exs +++ b/test/minga_agent/session_recovery_test.exs @@ -3,9 +3,13 @@ defmodule MingaAgent.SessionRecoveryTest do alias Minga.Test.StubProvider alias MingaAgent.Config, as: AgentConfig + alias MingaAgent.Credentials.AvailabilityCheck + alias MingaAgent.Credentials.Snapshot, as: CredentialSnapshot alias MingaAgent.Providers.Native alias MingaAgent.Session alias MingaAgent.Session.ProviderLifecycle + alias MingaAgent.SessionStore + alias MingaAgent.TurnUsage # Provider startup runs synchronously inside the Session process # (`start_provider/1` -> `provider_module.start_link/1` -> `Native.init/1`). @@ -34,6 +38,15 @@ defmodule MingaAgent.SessionRecoveryTest do defp start_session(opts, initial_credentials_state) do {checker, credentials_configured_fn} = start_credential_checker(initial_credentials_state) + test_pid = self() + + credential_probe_fn = fn _snapshot -> + send(test_pid, {:credential_probe_started, self()}) + + receive do + {:release_credential_probe, ^test_pid} -> {:unavailable, :test} + end + end provider_opts = Keyword.get(opts, :provider_opts, []) @@ -44,8 +57,16 @@ defmodule MingaAgent.SessionRecoveryTest do opts |> Keyword.put(:provider_opts, provider_opts) |> Keyword.put(:credentials_configured_fn, credentials_configured_fn) + |> Keyword.put(:credential_probe_fn, credential_probe_fn) ) + if not initial_credentials_state and Keyword.get(opts, :provider) == Native do + assert_receive {:credential_probe_started, worker}, 1_000 + monitor = Process.monitor(worker) + send(worker, {:release_credential_probe, self()}) + assert_receive {:DOWN, ^monitor, :process, ^worker, :normal}, 1_000 + end + # `start_link/1` schedules `:start_provider` when credentials are already # configured; wait it out here so callers observe a settled session. await_provider_startup(session) @@ -165,6 +186,332 @@ defmodule MingaAgent.SessionRecoveryTest do end end + defp empty_credential_snapshot do + CredentialSnapshot.new(%{}, false, "http://ollama.test") + end + + defp start_held_discovery_session(opts \\ []) do + test_pid = self() + + {:ok, sequence} = Agent.start_link(fn -> 0 end) + + probe = fn _snapshot -> + index = Agent.get_and_update(sequence, fn value -> {value + 1, value + 1} end) + send(test_pid, {:credential_probe_started, index, self()}) + + receive do + {:credential_probe_result, result} -> result + :crash_credential_probe -> exit(:probe_crash) + end + end + + session_opts = + [ + provider: Native, + provider_opts: [model: AgentConfig.unconfigured_model(), skip_api_key_env: true], + credentials_snapshot_fn: &empty_credential_snapshot/0, + credential_probe_fn: probe + ] + |> Keyword.merge(opts) + + session = start_supervised!({Session, session_opts}) + {session, sequence} + end + + defp start_running_held_model_session do + {checker, credentials_configured_fn} = start_credential_checker(true) + test_pid = self() + {:ok, sequence} = Agent.start_link(fn -> 0 end) + + probe = fn _snapshot -> + index = Agent.get_and_update(sequence, fn value -> {value + 1, value + 1} end) + send(test_pid, {:credential_probe_started, index, self()}) + + receive do + {:credential_probe_result, result} -> result + end + end + + session = + start_supervised!( + {Session, + provider: Native, + provider_opts: [model: "anthropic:model-a", skip_api_key_env: true], + credentials_configured_fn: credentials_configured_fn, + credentials_snapshot_fn: &empty_credential_snapshot/0, + credential_probe_fn: probe} + ) + + await_provider_startup(session) + provider = Session.get_provider(session) + assert is_pid(provider) + Agent.update(checker, fn _configured? -> false end) + {session, provider} + end + + test "held credential discovery leaves Session queries and prompt refusal responsive" do + {session, _sequence} = start_held_discovery_session() + assert_receive {:credential_probe_started, 1, worker}, 1_000 + + assert Session.status(session) == :idle + + assert %{credential_readiness: :checking, credentials_configured: false} = + Session.editor_snapshot(session) + + messages = Session.messages(session) + + assert {:error, :credential_discovery_pending} = + Session.send_prompt(session, "draft must remain with caller") + + assert Session.messages(session) == messages + + assert :ok = Session.subscribe(session, self()) + assert_receive {:agent_event, ^session, {:credentials_status, :checking}} + + send(worker, {:credential_probe_result, {:unavailable, :held_open}}) + assert_receive {:agent_event, ^session, {:credentials_status, :unconfigured}}, 1_000 + end + + test "model change invalidates a late discovery result and keeps only the newest worker" do + {session, _sequence} = start_held_discovery_session() + assert_receive {:credential_probe_started, 1, first_worker}, 1_000 + first_monitor = Process.monitor(first_worker) + + first_state = :sys.get_state(session) + {:running, first_request, first_task, _timer_ref} = first_state.credential_availability.phase + + assert {:pending, :credential_discovery} = + Session.set_model(session, "ollama:llama3") + + assert_receive {:DOWN, ^first_monitor, :process, ^first_worker, :killed}, 1_000 + assert_receive {:credential_probe_started, 2, second_worker}, 1_000 + + send(session, {first_task.ref, {first_request.token, :available}}) + assert Session.editor_snapshot(session).credential_readiness == :checking + assert Session.get_provider(session) == nil + + assert :ok = Session.subscribe(session, self()) + assert_receive {:agent_event, ^session, {:credentials_status, :checking}} + + send(second_worker, {:credential_probe_result, {:unavailable, :newest_result}}) + assert_receive {:agent_event, ^session, {:credentials_status, :unconfigured}}, 1_000 + assert Session.get_provider(session) == nil + end + + test "new_session stops and replaces a held probe with the new logical session identity" do + {session, _sequence} = start_held_discovery_session() + assert_receive {:credential_probe_started, 1, first_worker}, 1_000 + first_monitor = Process.monitor(first_worker) + old_state = :sys.get_state(session) + {:running, old_request, old_task, _timer_ref} = old_state.credential_availability.phase + old_session_id = old_state.session_id + + assert :ok = Session.new_session(session) + assert_receive {:DOWN, ^first_monitor, :process, ^first_worker, :killed}, 1_000 + assert_receive {:credential_probe_started, 2, second_worker}, 1_000 + + new_state = :sys.get_state(session) + {:running, new_request, _new_task, _new_timer_ref} = new_state.credential_availability.phase + refute new_state.session_id == old_session_id + assert new_request.session_id == new_state.session_id + + send(session, {old_task.ref, {old_request.token, :available}}) + assert Session.editor_snapshot(session).credential_readiness == :checking + send(second_worker, {:credential_probe_result, {:unavailable, :current_session}}) + end + + @tag :tmp_dir + test "load_session stops and replaces a held probe with the loaded logical identity", %{ + tmp_dir: dir + } do + assert :ok = + SessionStore.save( + %{ + id: "loaded-logical-session", + timestamp: "2026-09-16T00:00:00Z", + model_name: "ollama:loaded", + provider_name: "ollama", + messages: [{:system, "Loaded", :info}], + usage: %TurnUsage{} + }, + dir + ) + + {session, _sequence} = + start_held_discovery_session(session_store_dir: dir, persist?: false) + + assert_receive {:credential_probe_started, 1, first_worker}, 1_000 + first_monitor = Process.monitor(first_worker) + old_state = :sys.get_state(session) + {:running, old_request, old_task, _timer_ref} = old_state.credential_availability.phase + + assert :ok = Session.load_session(session, "loaded-logical-session") + assert_receive {:DOWN, ^first_monitor, :process, ^first_worker, :killed}, 1_000 + assert_receive {:credential_probe_started, 2, second_worker}, 1_000 + + loaded_state = :sys.get_state(session) + + {:running, loaded_request, _loaded_task, _loaded_timer_ref} = + loaded_state.credential_availability.phase + + assert loaded_state.session_id == "loaded-logical-session" + assert loaded_request.session_id == "loaded-logical-session" + assert loaded_request.model_name == "ollama:loaded" + + send(session, {old_task.ref, {old_request.token, :available}}) + assert Session.editor_snapshot(session).credential_readiness == :checking + send(second_worker, {:credential_probe_result, {:unavailable, :loaded_session}}) + end + + test "repeated refreshes retain only one active discovery worker" do + {session, _sequence} = start_held_discovery_session() + assert_receive {:credential_probe_started, 1, first_worker}, 1_000 + first_monitor = Process.monitor(first_worker) + + assert :ok = Session.refresh_credentials(session) + assert_receive {:DOWN, ^first_monitor, :process, ^first_worker, :killed}, 1_000 + assert_receive {:credential_probe_started, 2, second_worker}, 1_000 + second_monitor = Process.monitor(second_worker) + + assert :ok = Session.refresh_credentials(session) + assert_receive {:DOWN, ^second_monitor, :process, ^second_worker, :killed}, 1_000 + assert_receive {:credential_probe_started, 3, third_worker}, 1_000 + + state = :sys.get_state(session) + {:running, _request, task, _timer_ref} = state.credential_availability.phase + assert task.pid == third_worker + refute Process.alive?(first_worker) + refute Process.alive?(second_worker) + assert Process.alive?(third_worker) + + send(third_worker, {:credential_probe_result, {:unavailable, :done}}) + end + + test "a local credential change invalidates held discovery before its late result" do + {checker, credentials_configured_fn} = start_credential_checker(false) + + {session, _sequence} = + start_held_discovery_session(credentials_configured_fn: credentials_configured_fn) + + assert_receive {:credential_probe_started, 1, worker}, 1_000 + worker_monitor = Process.monitor(worker) + first_state = :sys.get_state(session) + {:running, first_request, first_task, _timer_ref} = first_state.credential_availability.phase + + Agent.update(checker, fn _configured? -> true end) + assert :ok = Session.refresh_credentials(session) + assert_receive {:DOWN, ^worker_monitor, :process, ^worker, :killed}, 1_000 + assert :configured = Session.editor_snapshot(session).credential_readiness + + send(session, {first_task.ref, {first_request.token, {:unavailable, :late_result}}}) + assert :configured = Session.editor_snapshot(session).credential_readiness + end + + test "credential discovery timeout kills the worker and settles readiness" do + {session, _sequence} = start_held_discovery_session() + assert_receive {:credential_probe_started, 1, worker}, 1_000 + worker_monitor = Process.monitor(worker) + + assert :ok = Session.subscribe(session, self()) + assert_receive {:agent_event, ^session, {:credentials_status, :checking}} + + state = :sys.get_state(session) + {:running, request, _task, _timer_ref} = state.credential_availability.phase + send(session, {:credential_probe_timeout, request.token}) + + assert_receive {:DOWN, ^worker_monitor, :process, ^worker, :killed}, 1_000 + assert_receive {:agent_event, ^session, {:credentials_status, :unconfigured}}, 1_000 + refute AvailabilityCheck.checking?(:sys.get_state(session).credential_availability) + end + + test "credential discovery worker crash settles readiness" do + {session, _sequence} = start_held_discovery_session() + assert_receive {:credential_probe_started, 1, worker}, 1_000 + + assert :ok = Session.subscribe(session, self()) + assert_receive {:agent_event, ^session, {:credentials_status, :checking}} + + send(worker, :crash_credential_probe) + assert_receive {:agent_event, ^session, {:credentials_status, :unconfigured}}, 1_000 + refute AvailabilityCheck.checking?(:sys.get_state(session).credential_availability) + end + + test "Session shutdown reclaims its credential discovery worker" do + {session, _sequence} = start_held_discovery_session() + assert_receive {:credential_probe_started, 1, worker}, 1_000 + worker_monitor = Process.monitor(worker) + + :ok = GenServer.stop(session) + assert_receive {:DOWN, ^worker_monitor, :process, ^worker, :killed}, 1_000 + end + + test "abrupt Session death reclaims its credential discovery worker" do + {session, _sequence} = start_held_discovery_session() + assert_receive {:credential_probe_started, 1, worker}, 1_000 + session_monitor = Process.monitor(session) + worker_monitor = Process.monitor(worker) + + Process.exit(session, :kill) + + assert_receive {:DOWN, ^session_monitor, :process, ^session, :killed}, 1_000 + assert_receive {:DOWN, ^worker_monitor, :process, ^worker, :killed}, 1_000 + end + + test "available Ollama discovery marks credentials configured and starts the chosen provider once" do + {session, _sequence} = + start_held_discovery_session( + provider_opts: [model: "ollama:llama3", skip_api_key_env: true] + ) + + assert_receive {:credential_probe_started, 1, worker}, 1_000 + assert :ok = Session.subscribe(session, self()) + assert_receive {:agent_event, ^session, {:credentials_status, :checking}} + + send(worker, {:credential_probe_result, :available}) + assert_receive {:agent_event, ^session, {:credentials_status, :configured}}, 1_000 + assert is_pid(Session.get_provider(session)) + end + + test "an injected native LLM client remains ready without live discovery" do + test_pid = self() + llm_client = fn _model, _messages, _opts -> {:ok, []} end + + session = + start_supervised!( + {Session, + provider: Native, + provider_opts: [ + model: "anthropic:test", + llm_client: llm_client, + skip_api_key_env: true + ], + credentials_snapshot_fn: &empty_credential_snapshot/0, + credential_probe_fn: fn _snapshot -> + send(test_pid, :unexpected_credential_probe) + :available + end} + ) + + await_provider_startup(session) + assert Session.editor_snapshot(session).credential_readiness == :configured + assert is_pid(Session.get_provider(session)) + refute_received :unexpected_credential_probe + end + + test "failed credential worker startup settles instead of leaving checking state" do + session = + start_supervised!( + {Session, + provider: Native, + provider_opts: [model: AgentConfig.unconfigured_model(), skip_api_key_env: true], + credentials_snapshot_fn: &empty_credential_snapshot/0, + credential_task_supervisor: :missing_credential_task_supervisor} + ) + + assert %{credential_readiness: :unconfigured, credentials_configured: false} = + Session.editor_snapshot(session) + end + test "refresh_credentials/1 starts a provider only after credentials flip true and model is concrete" do {session, checker} = start_session( @@ -176,7 +523,14 @@ defmodule MingaAgent.SessionRecoveryTest do assert Session.editor_snapshot(session).credentials_configured == false assert {:error, :credentials_not_configured} = Session.send_prompt(session, "draft prompt") - assert :ok = Session.set_model(session, "anthropic:claude-sonnet-4-20250514") + assert {:pending, :credential_discovery} = + Session.set_model(session, "anthropic:claude-sonnet-4-20250514") + + assert_receive {:credential_probe_started, worker}, 1_000 + monitor = Process.monitor(worker) + send(worker, {:release_credential_probe, self()}) + assert_receive {:DOWN, ^monitor, :process, ^worker, :normal}, 1_000 + await_provider_startup(session) assert Session.get_provider(session) == nil Agent.update(checker, fn _ -> true end) @@ -207,6 +561,89 @@ defmodule MingaAgent.SessionRecoveryTest do assert Session.subagent_context(session).provider_name == "anthropic" end + test "set_model/2 applies a pending model exactly once after current discovery succeeds" do + {session, provider} = start_running_held_model_session() + :erlang.trace(provider, true, [:receive]) + + assert {:pending, :credential_discovery} = Session.set_model(session, "ollama:model-b") + assert_receive {:credential_probe_started, 1, worker}, 1_000 + refute_receive {:trace, ^provider, :receive, {:"$gen_call", _from, {:set_model, _model}}} + + send(worker, {:credential_probe_result, :available}) + + assert_receive {:trace, ^provider, :receive, + {:"$gen_call", _from, {:set_model, "ollama:model-b"}}}, + 1_000 + + assert {:ok, %{model: %{id: "ollama:model-b"}}} = Native.get_state(provider) + + refute_receive {:trace, ^provider, :receive, + {:"$gen_call", _from, {:set_model, "ollama:model-b"}}}, + 50 + end + + test "a stale successful discovery never applies an obsolete pending model" do + {session, provider} = start_running_held_model_session() + :erlang.trace(provider, true, [:receive]) + + assert {:pending, :credential_discovery} = Session.set_model(session, "ollama:model-b") + assert_receive {:credential_probe_started, 1, first_worker}, 1_000 + first_state = :sys.get_state(session) + {:running, first_request, first_task, _timer_ref} = first_state.credential_availability.phase + + assert {:pending, :credential_discovery} = Session.set_model(session, "ollama:model-c") + assert_receive {:credential_probe_started, 2, second_worker}, 1_000 + + send(session, {first_task.ref, {first_request.token, :available}}) + assert Session.editor_snapshot(session).credential_readiness == :checking + assert {:ok, %{model: %{id: "anthropic:model-a"}}} = Native.get_state(provider) + + refute_receive {:trace, ^provider, :receive, + {:"$gen_call", _from, {:set_model, "ollama:model-b"}}} + + refute_receive {:trace, ^provider, :receive, + {:"$gen_call", _from, {:set_model, "ollama:model-c"}}} + + refute Process.alive?(first_worker) + send(second_worker, {:credential_probe_result, {:unavailable, :newest}}) + end + + test "an unavailable current discovery does not apply its pending model" do + {session, provider} = start_running_held_model_session() + :erlang.trace(provider, true, [:receive]) + + assert {:pending, :credential_discovery} = Session.set_model(session, "ollama:model-b") + assert_receive {:credential_probe_started, 1, worker}, 1_000 + assert :ok = Session.subscribe(session, self()) + assert_receive {:agent_event, ^session, {:credentials_status, :checking}} + send(worker, {:credential_probe_result, {:unavailable, :offline}}) + + assert_receive {:agent_event, ^session, {:credentials_status, :unconfigured}}, 1_000 + assert {:ok, %{model: %{id: "anthropic:model-a"}}} = Native.get_state(provider) + + refute_receive {:trace, ^provider, :receive, + {:"$gen_call", _from, {:set_model, "ollama:model-b"}}} + end + + test "set_model/2 reports pending discovery even when a provider is already running" do + {session, checker} = + start_session( + [provider: Native, provider_opts: [model: "anthropic:claude-sonnet-4-20250514"]], + true + ) + + assert is_pid(Session.get_provider(session)) + Agent.update(checker, fn _configured? -> false end) + + assert {:pending, :credential_discovery} = + Session.set_model(session, "ollama:llama3") + + assert_receive {:credential_probe_started, worker}, 1_000 + monitor = Process.monitor(worker) + send(worker, {:release_credential_probe, self()}) + assert_receive {:DOWN, ^monitor, :process, ^worker, :normal}, 1_000 + end + test "subagent_context uses session provider metadata for native providers" do {session, _checker} = start_session( @@ -496,7 +933,7 @@ defmodule MingaAgent.SessionRecoveryTest do ) assert :ok = Session.subscribe(unconfigured_session, self()) - assert_receive {:agent_event, ^unconfigured_session, {:credentials_status, false}} + assert_receive {:agent_event, ^unconfigured_session, {:credentials_status, :unconfigured}} {configured_session, _checker} = start_session( @@ -505,7 +942,7 @@ defmodule MingaAgent.SessionRecoveryTest do ) assert :ok = Session.subscribe(configured_session, self()) - assert_receive {:agent_event, ^configured_session, {:credentials_status, true}} + assert_receive {:agent_event, ^configured_session, {:credentials_status, :configured}} end test "set_model/2 updates provider metadata for provider-qualified models" do @@ -515,7 +952,14 @@ defmodule MingaAgent.SessionRecoveryTest do false ) - assert :ok = Session.set_model(session, "anthropic:claude-sonnet-4-20250514") + assert {:pending, :credential_discovery} = + Session.set_model(session, "anthropic:claude-sonnet-4-20250514") + + assert_receive {:credential_probe_started, worker}, 1_000 + monitor = Process.monitor(worker) + send(worker, {:release_credential_probe, self()}) + assert_receive {:DOWN, ^monitor, :process, ^worker, :normal}, 1_000 + await_provider_startup(session) metadata = Session.metadata(session) assert metadata.model_name == "anthropic:claude-sonnet-4-20250514" diff --git a/test/minga_editor/agent/focused_event_workflows_test.exs b/test/minga_editor/agent/focused_event_workflows_test.exs index cb0d5f0b5..484fefc1f 100644 --- a/test/minga_editor/agent/focused_event_workflows_test.exs +++ b/test/minga_editor/agent/focused_event_workflows_test.exs @@ -293,7 +293,7 @@ defmodule MingaEditor.Agent.FocusedEventWorkflowsTest do test "credentials status alone updates presentation and schedules a render" do state = event_state() panel = state.workspace.agent_ui.panel - state = SessionEventWorkflow.credentials_status(state, true) + state = SessionEventWorkflow.credentials_status(state, :configured) assert state.workspace.agent_ui.panel.credentials_configured assert state.workspace.agent_ui.panel.transcript.version == panel.transcript.version diff --git a/test/minga_editor/agent/slash_command_test.exs b/test/minga_editor/agent/slash_command_test.exs index 0fdfb0535..98f89a269 100644 --- a/test/minga_editor/agent/slash_command_test.exs +++ b/test/minga_editor/agent/slash_command_test.exs @@ -2,10 +2,12 @@ defmodule MingaEditor.Agent.SlashCommandTest do use ExUnit.Case, async: true alias MingaAgent.Memory + alias MingaAgent.Credentials.Snapshot, as: CredentialSnapshot alias MingaAgent.Session alias MingaAgent.SessionStore alias MingaAgent.TurnUsage alias MingaEditor.Agent.SlashCommand + alias MingaEditor.Agent.AuthStatusEffect alias MingaEditor.Agent.UIState alias MingaEditor.State, as: EditorState alias MingaAgent.RuntimeState @@ -13,6 +15,8 @@ defmodule MingaEditor.Agent.SlashCommandTest do alias MingaEditor.State.Tab alias MingaEditor.State.TabBar alias MingaEditor.VimState + alias MingaEditor.Effect.Outcome + alias MingaEditor.EffectScheduler @moduletag :tmp_dir @@ -70,8 +74,28 @@ defmodule MingaEditor.Agent.SlashCommandTest do def init(_opts), do: {:ok, %{}} end - defp start_session do - start_supervised!({Session, provider: NoopProvider, provider_opts: []}) + defmodule HeldAuthProbe do + @spec availability(CredentialSnapshot.t(), pid()) :: + MingaAgent.Credentials.ollama_availability() + def availability(_snapshot, test_pid) do + send(test_pid, {:auth_probe_started, self()}) + + receive do + {:auth_probe_result, result} -> result + end + end + end + + defp start_session(opts \\ []) do + session_opts = Keyword.merge([provider: NoopProvider, provider_opts: []], opts) + start_supervised!({Session, session_opts}) + end + + defp start_effect_scheduler do + task_supervisor = start_supervised!(Task.Supervisor) + scheduler = start_supervised!({EffectScheduler, task_supervisor: task_supervisor}) + :ok = EffectScheduler.attach(scheduler, self()) + scheduler end describe "slash_command?/1" do @@ -337,6 +361,219 @@ defmodule MingaEditor.Agent.SlashCommandTest do assert picker_ui.source == MingaEditor.UI.Picker.AgentSessionSource end + test "/auth publishes local pending status while its held probe leaves Editor work responsive" do + session = start_session() + scheduler = start_effect_scheduler() + snapshot = CredentialSnapshot.new(%{"openai" => :env}, false, "http://ollama.test") + state = %{mock_state(session: session) | effect_scheduler: scheduler} + + state = + SlashCommand.auth_status(state, + snapshot: snapshot, + effect_opts: [probe: {HeldAuthProbe, :availability, [self()]}] + ) + + assert_receive {:effect_lifecycle, %Outcome{value: :running}}, 1_000 + assert_receive {:auth_probe_started, worker}, 1_000 + + assert Enum.any?(Session.messages(session), fn + {:system, message, :info} -> + message =~ "API key status" and message =~ "Openai (env)" and + message =~ "Ollama" + + _other -> + false + end) + + assert {:ok, help_state} = SlashCommand.execute(state, "/help") + assert help_state.shell_runtime.state.notice.message == "Commands listed in chat" + + send(worker, {:auth_probe_result, :available}) + assert_receive {:effect_result, ^scheduler, %Outcome{} = outcome}, 1_000 + assert :ok = EffectScheduler.claim(scheduler, outcome) + {state, final_outcome} = AuthStatusEffect.apply(state, outcome) + EffectScheduler.finalize(scheduler, final_outcome) + + assert state.shell_runtime.state.notice.message == "Ollama available" + + assert Enum.any?(Session.messages(session), fn + {:system, "Ollama availability: ✓ available (local)", :info} -> true + _other -> false + end) + end + + test "/auth ignores a late result after active-session replacement" do + original_session = start_session() + + replacement_session = + start_supervised!( + Supervisor.child_spec( + {Session, provider: NoopProvider, provider_opts: []}, + id: {:replacement_session, make_ref()} + ) + ) + + scheduler = start_effect_scheduler() + snapshot = CredentialSnapshot.new(%{}, false, "http://ollama.test") + original_state = %{mock_state(session: original_session) | effect_scheduler: scheduler} + + _state = + SlashCommand.auth_status(original_state, + snapshot: snapshot, + effect_opts: [probe: {HeldAuthProbe, :availability, [self()]}] + ) + + assert_receive {:effect_lifecycle, %Outcome{value: :running}}, 1_000 + assert_receive {:auth_probe_started, worker}, 1_000 + send(worker, {:auth_probe_result, :available}) + assert_receive {:effect_result, ^scheduler, %Outcome{} = outcome}, 1_000 + assert :ok = EffectScheduler.claim(scheduler, outcome) + + replacement_state = %{ + mock_state(session: replacement_session) + | effect_scheduler: scheduler + } + + {_state, final_outcome} = AuthStatusEffect.apply(replacement_state, outcome) + assert {:stale, :agent_session_changed} = final_outcome.value + EffectScheduler.finalize(scheduler, final_outcome) + + refute Enum.any?(Session.messages(original_session), fn + {:system, message, :info} -> message =~ "Ollama availability: ✓" + _other -> false + end) + end + + test "/auth ignores a late result after new_session reuses the active PID" do + session = start_session() + scheduler = start_effect_scheduler() + snapshot = CredentialSnapshot.new(%{}, false, "http://ollama.test") + state = %{mock_state(session: session) | effect_scheduler: scheduler} + original_session_id = Session.session_id(session) + + _state = + SlashCommand.auth_status(state, + snapshot: snapshot, + effect_opts: [probe: {HeldAuthProbe, :availability, [self()]}] + ) + + assert_receive {:effect_lifecycle, %Outcome{value: :running}}, 1_000 + assert_receive {:auth_probe_started, worker}, 1_000 + assert :ok = Session.new_session(session) + refute Session.session_id(session) == original_session_id + + send(worker, {:auth_probe_result, :available}) + assert_receive {:effect_result, ^scheduler, %Outcome{} = outcome}, 1_000 + assert :ok = EffectScheduler.claim(scheduler, outcome) + {_state, final_outcome} = AuthStatusEffect.apply(state, outcome) + assert {:stale, :agent_session_changed} = final_outcome.value + EffectScheduler.finalize(scheduler, final_outcome) + + refute Enum.any?(Session.messages(session), fn + {:system, message, :info} -> message =~ "Ollama availability: ✓" + _other -> false + end) + end + + test "/auth ignores a late result after load_session reuses the active PID", %{tmp_dir: dir} do + assert :ok = + SessionStore.save( + %{ + id: "auth-loaded-session", + timestamp: "2026-09-16T00:00:00Z", + model_name: "test-model", + provider_name: "test", + messages: [{:system, "Loaded auth session", :info}], + usage: %TurnUsage{} + }, + dir + ) + + session = start_session(session_store_dir: dir, persist?: false) + scheduler = start_effect_scheduler() + snapshot = CredentialSnapshot.new(%{}, false, "http://ollama.test") + state = %{mock_state(session: session) | effect_scheduler: scheduler} + + _state = + SlashCommand.auth_status(state, + snapshot: snapshot, + effect_opts: [probe: {HeldAuthProbe, :availability, [self()]}] + ) + + assert_receive {:effect_lifecycle, %Outcome{value: :running}}, 1_000 + assert_receive {:auth_probe_started, worker}, 1_000 + assert :ok = Session.load_session(session, "auth-loaded-session") + assert Session.session_id(session) == "auth-loaded-session" + + send(worker, {:auth_probe_result, :available}) + assert_receive {:effect_result, ^scheduler, %Outcome{} = outcome}, 1_000 + assert :ok = EffectScheduler.claim(scheduler, outcome) + {_state, final_outcome} = AuthStatusEffect.apply(state, outcome) + assert {:stale, :agent_session_changed} = final_outcome.value + EffectScheduler.finalize(scheduler, final_outcome) + + refute Enum.any?(Session.messages(session), fn + {:system, message, :info} -> message =~ "Ollama availability: ✓" + _other -> false + end) + end + + test "/auth timeout kills its worker and publishes terminal unavailable status" do + session = start_session() + scheduler = start_effect_scheduler() + snapshot = CredentialSnapshot.new(%{}, false, "http://ollama.test") + state = %{mock_state(session: session) | effect_scheduler: scheduler} + + _state = + SlashCommand.auth_status(state, + snapshot: snapshot, + effect_opts: [probe: {HeldAuthProbe, :availability, [self()]}] + ) + + assert_receive {:effect_lifecycle, %Outcome{request: request, value: :running}}, 1_000 + assert_receive {:auth_probe_started, worker}, 1_000 + worker_monitor = Process.monitor(worker) + + send(scheduler, {:effect_timeout, request.id}) + + assert_receive {:DOWN, ^worker_monitor, :process, ^worker, :killed}, 1_000 + + assert_receive {:effect_result, ^scheduler, %Outcome{value: {:failed, :timeout}} = outcome}, + 1_000 + + assert :ok = EffectScheduler.claim(scheduler, outcome) + {state, final_outcome} = AuthStatusEffect.apply(state, outcome) + EffectScheduler.finalize(scheduler, final_outcome) + assert state.shell_runtime.state.notice.message == "Ollama unavailable" + end + + test "repeated /auth status requests keep only the newest probe" do + session = start_session() + scheduler = start_effect_scheduler() + snapshot = CredentialSnapshot.new(%{}, false, "http://ollama.test") + state = %{mock_state(session: session) | effect_scheduler: scheduler} + effect_opts = [probe: {HeldAuthProbe, :availability, [self()]}] + + state = SlashCommand.auth_status(state, snapshot: snapshot, effect_opts: effect_opts) + assert_receive {:effect_lifecycle, %Outcome{value: :running}}, 1_000 + assert_receive {:auth_probe_started, first_worker}, 1_000 + first_monitor = Process.monitor(first_worker) + + _state = SlashCommand.auth_status(state, snapshot: snapshot, effect_opts: effect_opts) + + assert_receive {:DOWN, ^first_monitor, :process, ^first_worker, :killed}, 1_000 + assert_receive {:effect_lifecycle, %Outcome{value: {:canceled, :superseded}}}, 1_000 + assert_receive {:effect_lifecycle, %Outcome{value: :running}}, 1_000 + assert_receive {:auth_probe_started, second_worker}, 1_000 + assert Process.alive?(second_worker) + + send(second_worker, {:auth_probe_result, {:unavailable, :newest}}) + assert_receive {:effect_result, ^scheduler, %Outcome{} = outcome}, 1_000 + assert :ok = EffectScheduler.claim(scheduler, outcome) + {_state, final_outcome} = AuthStatusEffect.apply(state, outcome) + EffectScheduler.finalize(scheduler, final_outcome) + end + test "/plan enters plan mode for the active session" do session = start_session() {:ok, state} = SlashCommand.execute(mock_state(session: session), "/plan") diff --git a/test/minga_editor/commands/agent_commands_test.exs b/test/minga_editor/commands/agent_commands_test.exs index 2da5dfbbf..ab041cdc0 100644 --- a/test/minga_editor/commands/agent_commands_test.exs +++ b/test/minga_editor/commands/agent_commands_test.exs @@ -58,6 +58,28 @@ defmodule MingaEditor.Commands.AgentCommandsTest do end end + defmodule PendingModelSession do + use GenServer + + @spec start_link(pid()) :: GenServer.on_start() + def start_link(test_pid), do: GenServer.start_link(__MODULE__, test_pid) + + @impl GenServer + def init(test_pid), do: {:ok, test_pid} + + @impl GenServer + def handle_call({:set_model, model}, _from, test_pid) do + send(test_pid, {:pending_model_accepted, model}) + {:reply, {:pending, :credential_discovery}, test_pid} + end + + @impl GenServer + def handle_cast({:add_system_message, message, :info}, test_pid) do + send(test_pid, {:pending_model_message, message}) + {:noreply, test_pid} + end + end + # ── Helpers ────────────────────────────────────────────────────────────── defp command!(name) do @@ -240,12 +262,20 @@ defmodule MingaEditor.Commands.AgentCommandsTest do def handle_call(:get_provider, _from, state), do: {:reply, Map.get(state, :provider), state} def handle_call(:editor_snapshot, _from, state) do + credentials_configured = Map.get(state, :credentials_configured, true) + snapshot = %{ status: Map.get(state, :status, :idle), pending_approval: nil, error: Map.get(state, :error), active_tool_name: nil, - credentials_configured: Map.get(state, :credentials_configured, true) + credentials_configured: credentials_configured, + credential_readiness: + Map.get( + state, + :credential_readiness, + if(credentials_configured, do: :configured, else: :unconfigured) + ) } {:reply, snapshot, state} @@ -394,6 +424,36 @@ defmodule MingaEditor.Commands.AgentCommandsTest do "draft prompt" end + test "blocks submit and preserves the draft while credential discovery is pending" do + {:ok, session} = + ReadinessSession.start_link( + provider: nil, + credentials_configured: false, + credential_readiness: :checking, + notify: self() + ) + + state = + base_state(session: session) + |> AgentCommands.input_paste("draft prompt") + |> replace_panel(fn panel -> + panel + |> Panel.set_credentials_configured(true) + |> Panel.set_model_name("ollama:llama3") + |> Panel.set_provider_name("ollama") + end) + + new_state = AgentCommands.submit_prompt(state) + + assert new_state.shell_runtime.state.notice.message == + "Checking local Ollama availability. Your prompt was preserved." + + assert MingaEditor.Agent.PromptBuffer.prompt_text(new_state.workspace.agent_ui.panel) == + "draft prompt" + + refute_receive {:readiness_session_prompt, _prompt} + end + test "blocks submit as starting when credentials exist but no provider is attached yet" do {:ok, session} = ReadinessSession.start_link(provider: nil) @@ -543,7 +603,8 @@ defmodule MingaEditor.Commands.AgentCommandsTest do test "preserves the prompt when an attached session rejects locally" do for {error, expected_message} <- [ {:provider_not_ready, "Agent provider still starting"}, - {:credentials_not_configured, "No provider credentials are configured"} + {:credentials_not_configured, "No provider credentials are configured"}, + {:credential_discovery_pending, "Checking local Ollama availability"} ] do {:ok, session} = Session.start_link( @@ -894,6 +955,24 @@ defmodule MingaEditor.Commands.AgentCommandsTest do end end + describe "set_model/2" do + test "reports an accepted model change as pending instead of an error" do + {:ok, session} = PendingModelSession.start_link(self()) + + state = AgentCommands.set_model(base_state(session: session), "ollama:model-b") + + assert_receive {:pending_model_accepted, "ollama:model-b"} + + assert_receive {:pending_model_message, + "Model change accepted: ollama:model-b. Checking local Ollama availability."} + + assert state.workspace.agent_ui.panel.model_name == "ollama:model-b" + + assert state.shell_runtime.state.notice.message == + "Model change accepted: ollama:model-b. Checking local Ollama availability." + end + end + # ── scope_* guard functions ────────────────────────────────────────────── # These functions guard on agentic/panel state. Test the guard behavior. diff --git a/test/minga_editor/state/event_routing_test.exs b/test/minga_editor/state/event_routing_test.exs index 1db006eef..9cdd26846 100644 --- a/test/minga_editor/state/event_routing_test.exs +++ b/test/minga_editor/state/event_routing_test.exs @@ -53,7 +53,7 @@ defmodule MingaEditor.State.EventRoutingTest do assert %WorkspaceAgent{agent_ui: workspace_ui} = workspace.payload assert workspace_ui.panel.transcript.version == 0 - state = AgentEvents.dispatch(state, {:credentials_status, true}) + state = AgentEvents.dispatch(state, {:credentials_status, :configured}) assert state.workspace.agent_ui.panel.credentials_configured end