diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbc3474b6..c9b6f840e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,8 +138,8 @@ jobs: if-no-files-found: error # ── macOS arm64 (Apple Silicon): OTP release + Rust TUI ────────────── - # Secondary target. Publishing does NOT block on it (see publish.needs) - # so a flaky mac build never holds the Linux release hostage. + # Its artifacts are REQUIRED to publish (see the gate in `publish`): macOS is + # a shipping platform, and install.sh fetches osa-macos-arm64.tar.gz by name. build-macos-arm64: # macos-15, not macos-14: the macOS 14 images entered deprecation on # 2026-07-06, have brownout windows through October that terminate the job, @@ -180,10 +180,28 @@ jobs: mix local.rebar --force mix deps.get --only prod + # Run from the repo root — both scripts derive every path from their own + # location ($0), never from the caller's cwd. ScreenShare/build.sh did NOT + # until v1.0.178: it passed cwd-relative swiftc inputs, died here on every + # tagged release, and took the whole macOS lane down with it (#238). + # + # The `-nt` check is the other half of that lesson. priv/helpers/ is + # TRACKED, so a helper that failed to deploy leaves the stale checked-in + # binary behind and a mere existence test passes; `mix release` below then + # bundles the stale copy and ships it silently. Comparing against a stamp + # taken before the builds asserts the binaries were actually rewritten now. - name: Build bundled macOS desktop helpers run: | + stamp="$(mktemp)" native/macos/ScreenShare/build.sh native/macos/AccessibilityHelper/build.sh + for h in priv/helpers/osa-screen-capture-darwin priv/helpers/osa-accessibility-darwin; do + [ -s "$h" ] && [ "$h" -nt "$stamp" ] || { + echo "::error::macOS helper was not (re)built this run: $h" + exit 1 + } + done + ls -l priv/helpers/ # Stamp the tag version — see the linux job for the rationale. - name: Stamp version from tag @@ -239,8 +257,8 @@ jobs: if-no-files-found: error # ── Windows x64: OTP release (ERTS-bundled) + Rust TUI ─────────────── - # Secondary target. Publishing does NOT block on it (see publish.needs) - # so a flaky Windows build never holds the Linux release hostage. + # Its artifacts are REQUIRED to publish (see the gate in `publish`): install.ps1 + # fetches osa-windows-x64.zip by name. build-windows-x64: # `windows-latest` is a floating label (currently windows-2025); setup-beam # supports OTP 21-29 on both windows-2022 and windows-2025, so there is no @@ -331,16 +349,17 @@ jobs: if-no-files-found: error # ── Publish GitHub Release ─────────────────────────────────────────── - # Depends ONLY on the Linux x64 build (the immediate consumer). The macOS - # and Windows jobs still run in parallel; their assets are attached if - # present, skipped if the build failed (fail_on_unmatched_files defaults - # to false). + # Publishes ONLY a complete release: every platform's assets, or nothing. + # See the "Verify every expected asset" gate below for why. publish: # Wait for ALL platform builds so every artifact is present before attaching # (previously only needed linux-x64, which raced: publish ran the instant # linux finished and missed the windows artifact that landed seconds later). - # if: always() keeps the release going even if one platform fails — it - # attaches whatever artifacts did get produced. + # + # if: always() runs this job even when a platform build failed — NOT to + # publish anyway, but so the gate below can name the exact assets that went + # missing and fail with that message. Skipping the job instead would leave + # only a red build-* job and no statement about the release itself. needs: [build-linux-x64, build-macos-arm64, build-windows-x64] if: always() # Safe to move to 24.04 ahead of the build jobs: this one ships no compiled @@ -372,6 +391,56 @@ jobs: done ls -la + # ── Gate: a partial release must never publish green ───────────────── + # + # v1.0.177 shipped with ZERO macOS assets and a green Release page. The + # macOS lane died early (a build script that only worked from its own + # directory), so its tarball and TUI never existed — and NOTHING in the + # publish path noticed: `if: always()` kept this job going and + # softprops/action-gh-release defaults fail_on_unmatched_files:false, so + # it attached whatever files did show up and called it done. + # install.sh downloads osa-macos-arm64.tar.gz by name, so every new macOS + # install and every macOS auto-update broke, silently, for real users. + # + # The asymmetry is the whole point: a tag is cheap and re-runnable, a + # published-but-broken Release is neither — it is immediately visible to + # installers and to `osa update`. So the expected asset set is verified + # HERE, before the Release exists, and a gap fails the job with the names + # of the missing files. A broken platform is still perfectly visible (its + # own job is red, and this step says which assets it owed); it just can no + # longer produce a green, half-empty release. + # + # Fix the platform build and re-run the workflow — the same tag then + # publishes complete. Shipping deliberately without a platform stays + # possible, but only as a conscious manual act (`gh release create`), + # never as the silent default. + - name: Verify every expected asset is present + run: | + missing="" + for f in \ + osa-linux-x64.tar.gz \ + osagent-tui-linux-x64 \ + osa-macos-arm64.tar.gz \ + osagent-tui-macos-arm64 \ + osa-windows-x64.zip \ + osagent-tui-windows-x64.exe + do + # -s, not -e: a 0-byte tarball is a missing asset wearing a filename. + # The .sha256 sidecar is checked too — install.sh verifies against it, + # so an asset without one is not installable either. + for a in "$f" "$f.sha256"; do + [ -s "release-assets/$a" ] || missing="${missing} ${a}" + done + done + if [ -n "${missing}" ]; then + echo "::error::Refusing to publish ${GITHUB_REF_NAME} — missing release assets:${missing}" + echo "No GitHub Release was created. Check the failed build-* job above," + echo "fix it, and re-run this workflow for the same tag." + exit 1 + fi + echo "All expected assets present:" + ls -1 release-assets + - name: Create GitHub Release and upload assets uses: softprops/action-gh-release@v3 with: @@ -380,6 +449,10 @@ jobs: draft: false prerelease: ${{ contains(github.ref_name, '-') }} generate_release_notes: true + # Backstop for the gate above: if this list and the gate's list ever + # drift, an unmatched pattern fails the job instead of quietly + # attaching one file fewer. + fail_on_unmatched_files: true files: | release-assets/osa-linux-x64.tar.gz release-assets/osa-linux-x64.tar.gz.sha256 diff --git a/VERSION b/VERSION index 092b1318f..354f83d3f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.177 \ No newline at end of file +1.0.178 \ No newline at end of file diff --git a/bin/osa b/bin/osa index b2564fd76..9807f3f9d 100755 --- a/bin/osa +++ b/bin/osa @@ -148,6 +148,62 @@ _port_in_use() { fi } +# ── Daemon ownership: whose backend is that, really? ───────────── +# +# Auto-isolation (below) keys a workspace off a hash of the launch directory +# and remembers the port it chose in that workspace's own backend.port. But a +# FILE outlives the process it describes: a daemon that dies without cleaning +# up leaves a port NUMBER behind, and the OS is free to hand that number to the +# next process that asks for one — including another workspace's OSA daemon. +# +# "Something healthy answers on that port" is therefore not evidence that the +# something is ours. It used to be the entire test, which is how one folder +# could attach to a different folder's backend and `osa stop` could kill it. +# So ownership is asked of the daemon itself: /health reports `workspace`, the +# OSA_ORIGINAL_CWD it was started with — the exact string this script exported +# when it launched that daemon — and adoption requires it to equal ours. + +# The launch directory a daemon claims, or empty when it does not answer or +# does not report one (any daemon older than this field). Never fails the +# caller: "it did not say" is an answer the callers below handle explicitly. +_daemon_workspace() { + local body="" + if command -v curl >/dev/null 2>&1; then + body="$(curl -sf --max-time 3 "http://localhost:${1}/health" 2>/dev/null || true)" + elif command -v wget >/dev/null 2>&1; then + body="$(wget -qO- --timeout=3 "http://localhost:${1}/health" 2>/dev/null || true)" + fi + [ -n "$body" ] || return 0 + printf '%s' "$body" \ + | sed -n 's/.*"workspace"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1 +} + +# Same directory? Both sides are absolute (the daemon reports a Path.expand'ed +# string, ours is $PWD), so this only has to absorb a trailing slash. Two empty +# paths are NOT "the same" — an unknown workspace must never match ours. +_same_dir() { + local a="${1:-}" b="${2:-}" + [ -n "$a" ] && [ -n "$b" ] && [ "${a%/}" = "${b%/}" ] +} + +# Version-independent ownership proof, for daemons too old to report a +# workspace: is the PID this workspace recorded for its OWN daemon the process +# currently listening on that port? Deliberately positive-only — a "no" does +# not mean the daemon is foreign, only that we cannot prove it is ours, which +# is exactly how the caller treats it. False positives would need a recycled +# PID to also hold that listener; false negatives just cost a second daemon. +_pidfile_owns_port() { + local pid owner + command -v lsof >/dev/null 2>&1 || return 1 + [ -f "$1" ] || return 1 + pid="$(cat "$1" 2>/dev/null || true)" + [ -n "$pid" ] || return 1 + for owner in $(lsof -ti ":${2}" -sTCP:LISTEN 2>/dev/null || true); do + if [ "$owner" = "$pid" ]; then return 0; fi + done + return 1 +} + # Elixir version check (>= 1.17) check_elixir_version() { local ver major minor @@ -227,21 +283,45 @@ done # Skip entirely when the user explicitly sets either port name or OSA_HOME (the power- # user escape hatch). `--dev` also bypasses: the dev profile is a single # fixed instance on 19001. +_OSA_AUTO_WS=0 if [ -z "$_EXPLICIT_PORT" ] && [ "${OSA_HOME:-${HOME}/.osa}" = "${HOME}/.osa" ]; then _dev_flag=0 for _a in "$@"; do [ "$_a" = "--dev" ] && _dev_flag=1; done if [ "$_dev_flag" -eq 0 ]; then + # Auto-isolation is live for this run. Recorded so the stop path knows it + # is dealing with a port this script CHOSE, not one the operator named. + _OSA_AUTO_WS=1 + # Hash the working directory into a short, stable slug. _ws_hash=$(printf '%s' "$OSA_ORIGINAL_CWD" | shasum -a 256 | cut -c1-12) _ws_home="${HOME}/.osa/workspaces/${_ws_hash}" _ws_port_file="${_ws_home}/run/backend.port" - # Does this workspace already have a daemon? Check its port file + health. + # Does this workspace already have a daemon? Its port file names a port — + # but the port file outlives the daemon, so a healthy answer there is not + # proof of ownership (see _daemon_workspace above). Ask who it belongs to. if [ -f "$_ws_port_file" ]; then _ws_port="$(cat "$_ws_port_file" 2>/dev/null || true)" if [ -n "$_ws_port" ] && _http_ok "http://localhost:${_ws_port}/health" 2>/dev/null; then - PORT="$_ws_port" - OSA_HOME="$_ws_home" + _ws_owner="$(_daemon_workspace "$_ws_port")" + if _same_dir "$_ws_owner" "$OSA_ORIGINAL_CWD"; then + # It says it is ours, and it is. + PORT="$_ws_port" + OSA_HOME="$_ws_home" + elif [ -z "$_ws_owner" ] && _pidfile_owns_port "${_ws_home}/run/backend.pid" "$_ws_port"; then + # It predates the `workspace` field, so it cannot identify itself — + # and refusing every such daemon would orphan the warm backend of + # every user upgrading past this version, leaving it running on a + # port nobody adopts again. Adopt it only on independent proof: the + # PID we recorded for THIS workspace is the one holding that listener. + # The skew repair further down then restarts it onto the new build. + PORT="$_ws_port" + OSA_HOME="$_ws_home" + elif [ -n "$_ws_owner" ]; then + # It answered, and named someone else's folder. Say so once — this is + # the case that used to silently hijack another workspace's backend. + echo -e "${DIM}:${_ws_port} now belongs to ${_ws_owner} — starting a separate backend for this folder.${RESET}" >&2 + fi fi fi @@ -531,6 +611,39 @@ stop_daemon() { echo -e "${GREEN}✓${RESET} Backend stopped." } +# ── Last gate before anything gets killed ─────────────────────────────────── +# +# Auto-isolation adopts a daemon only after it confirms the workspace, but that +# decision and this kill are separated by everything in between: our daemon can +# exit and another workspace's can take the port number in the gap. So the +# question is asked again, right here, against the process that is answering +# NOW. 0 = safe to stop, 1 = it belongs to someone else, leave it alone. +# +# Two deliberate exemptions, both "the operator named this target explicitly": +# +# * $_OSA_AUTO_WS = 0 — OSA_PORT / OSA_HOME / --dev bypass isolation entirely. +# Those are documented single-instance escape hatches; `osa stop --dev` must +# keep stopping the dev daemon on :19001 no matter which folder it is run +# from, which is precisely a cross-workspace stop, deliberately. +# * A daemon that answers but reports no workspace (older than the field). +# It is unidentifiable, not foreign, and refusing would leave `osa stop` +# permanently unable to clean up after any pre-1.0.178 daemon. Silence is +# permission here; the adoption path is where an unidentifiable daemon has +# to earn its way in, and it does that against the pidfile instead. +_stop_target_is_ours() { + local owner + [ "${_OSA_AUTO_WS:-0}" = "1" ] || return 0 + owner="$(_daemon_workspace "$PORT")" + [ -n "$owner" ] || return 0 + _same_dir "$owner" "$OSA_ORIGINAL_CWD" && return 0 + + echo -e "${YELLOW}⚠${RESET} ${BOLD}Refusing to stop the backend on :${PORT} — it is not this folder's.${RESET}" >&2 + echo -e " ${DIM}that daemon's folder:${RESET} ${owner}" >&2 + echo -e " ${DIM}this folder:${RESET} ${OSA_ORIGINAL_CWD}" >&2 + echo -e " ${DIM}Stop it where it belongs:${RESET} ${CYAN}cd ${owner} && osa stop${RESET}" >&2 + return 1 +} + # Stop the backend and POLL until :$PORT really stops answering. # # `stop_daemon` signals and waits on the PID, but the PID is not the contract — @@ -543,8 +656,14 @@ stop_daemon() { # Pass "quiet" to swallow stop_daemon's own pid/progress chatter — the launch # path repairs skew as routine housekeeping and should read as one calm line, # not as a three-line incident report about a process the user never asked about. +# +# Refuses outright when :$PORT is answering for a DIFFERENT workspace — see +# _stop_target_is_ours. Killing by port is the one irreversible act in this +# script, so ownership is re-checked here and not merely inherited from the +# adoption decision several hundred lines earlier. _stop_backend_confirmed() { local n=0 + _stop_target_is_ours || return 1 if [ "${1:-}" = "quiet" ]; then stop_daemon >/dev/null 2>&1 || true else diff --git a/lib/optimal_system_agent/agent/loop/goal_verifier.ex b/lib/optimal_system_agent/agent/loop/goal_verifier.ex index ef9198636..b36eeffcf 100644 --- a/lib/optimal_system_agent/agent/loop/goal_verifier.ex +++ b/lib/optimal_system_agent/agent/loop/goal_verifier.ex @@ -381,47 +381,98 @@ defmodule OptimalSystemAgent.Agent.Loop.GoalVerifier do # Text-only/research goals may have no writes and therefore skip the normal # panel cost gate. Before another automatic turn, classify whether the next # useful action belongs to the human instead. Completion still needs a panel. + # + # Gated like `maybe_gate/1`, and for the same reason: this spends a real + # triage round-trip on the same provider that just drove the turn, so it must + # answer to the same operator switch and the same per-turn spend counters. + # Ungated it fired on EVERY tool-call-free generation inside a goal turn — one + # extra provider call per synthetic continuation, which doubled what a goal + # turn costs in money and latency (a budget of 3 continuations bought 8 + # round-trips, not 4) — and it kept spending them under an explicit + # `goal_verifier_enabled: false`, the one setting whose entire job is to buy + # silence from this module. + # + # Two of `skip_reason/1`'s conditions are deliberately NOT applied here. + # `:no_work` and `:trivial` are SIZE heuristics — "did this turn move enough + # of the workspace to be worth an adversarial panel" — and a research or + # authoring goal ("draft the thesis for Steven's approval") satisfies neither + # while being precisely the case whose next useful action belongs to a human. + # Skipping on size here would delete this path's whole reason to exist; that + # is what the paragraph above this function has always said. The remaining + # guards are about budget and operator control, not size, and apply verbatim. + @spec maybe_wait_for_user(map(), String.t()) :: map() def maybe_wait_for_user(state, content) do sid = Map.get(state, :session_id) - if is_binary(sid) and GoalTracker.enabled?(state) and GoalTracker.goal_loop?(sid) and - GoalTracker.continue?(sid) do - expected = GoalTracker.verification_token(sid) + cond do + not (is_binary(sid) and GoalTracker.enabled?(state) and GoalTracker.goal_loop?(sid) and + GoalTracker.continue?(sid)) -> + state - probe = - Map.put( - state, - :messages, - Map.get(state, :messages, []) ++ [%{role: "assistant", content: content}] - ) + not activated?(state) -> + state - case triage(probe) do - {:awaiting_user, meta} -> - GoalTracker.request_decision(sid, meta.request, expected) - state + Map.get(state, :goal_verifier_paused, false) -> + state - {:candidate_complete, _} -> - if Map.get(state, :goal_verifier_runs, 0) < max_runs() do - {result, verified} = verify(probe) - - case GoalTracker.advance_if_current( - sid, - expected, - result, - Map.get(state, :total_tool_calls) - ) do - {:ok, _} -> Map.put(verified, :messages, Map.get(state, :messages, [])) - _ -> state - end - else - state - end + (reason = wait_skip_reason(state)) != nil -> + log_skip(state, reason) + state - _ -> + not GoalTracker.reverify_due?(sid) -> + state + + true -> + wait_triage(state, content, sid) + end + end + + # `skip_reason/1` minus the two size heuristics. `:no_session`, + # `:goal_inactive` and `:no_goal` are already decided by the tracker guard in + # `maybe_wait_for_user/2`, so what is left is the pair of spend guards: the + # per-turn verification run cap and the stall early-exit. + defp wait_skip_reason(state) do + cond do + Map.get(state, :goal_verifier_runs, 0) >= max_runs() -> :run_cap + stalled?(state) -> :stalled + true -> nil + end + end + + defp wait_triage(state, content, sid) do + expected = GoalTracker.verification_token(sid) + + probe = + Map.put( + state, + :messages, + Map.get(state, :messages, []) ++ [%{role: "assistant", content: content}] + ) + + case triage(probe) do + {:awaiting_user, meta} -> + GoalTracker.request_decision(sid, meta.request, expected) + state + + {:candidate_complete, _} -> + if Map.get(state, :goal_verifier_runs, 0) < max_runs() do + {result, verified} = verify(probe) + + case GoalTracker.advance_if_current( + sid, + expected, + result, + Map.get(state, :total_tool_calls) + ) do + {:ok, _} -> Map.put(verified, :messages, Map.get(state, :messages, [])) + _ -> state + end + else state - end - else - state + end + + _ -> + state end end diff --git a/lib/optimal_system_agent/agent/loop/llm_client.ex b/lib/optimal_system_agent/agent/loop/llm_client.ex index e0dd6e431..538c456f1 100644 --- a/lib/optimal_system_agent/agent/loop/llm_client.ex +++ b/lib/optimal_system_agent/agent/loop/llm_client.ex @@ -101,12 +101,17 @@ defmodule OptimalSystemAgent.Agent.Loop.LLMClient do def capped_retry_delay_ms(_), do: 0 - # Map the session's speed priority to a provider processing tier. Adapters - # gate and translate this option, so unsupported providers ignore it. :loose → "flex" (~50% - # cheaper, slower — right for long-horizon background work); :immediate → - # "priority" (faster). The interactive `/fast` toggle also opts ordinary - # foreground turns into OpenAI Fast processing; provider adapters that do - # not support service tiers simply ignore the option. + # Map the session's speed priority to a provider processing tier, or to `nil` + # when this provider has no tier OSA can ask it for. + # + # `nil` is an answer here, not a gap. The previous shape resolved a tier for + # every provider on the theory that adapters gate and translate the option so + # unsupported providers ignore it, and no API does that: an unrecognized + # `service_tier` is a validation error, and one endpoint (ChatGPT Codex) + # rejects the FIELD itself with an empty 400. So the tier is resolved once, + # here, against the vocabulary each provider actually has, and the provider + # boundaries re-check it (`Anthropic.apply_service_tier/2`, `OpenAICompat`'s + # `@service_tiers`) for callers that never come through this path. defp maybe_put_service_tier(opts, state) do case service_tier_for(state) do nil -> opts @@ -114,16 +119,53 @@ defmodule OptimalSystemAgent.Agent.Loop.LLMClient do end end + # The acceleration tier OSA may request from a provider, and the word that + # provider uses for it. Absence is the meaningful part: it says OSA has no + # verified way to ask THIS provider to go faster, so `/fast` changes nothing + # on it and the command surface has to say so. + # + # * `openai` takes "priority", its documented paid-acceleration tier. + # * `anthropic` and `groq` both define "auto" as "use priority/performance + # capacity when this account has it, otherwise standard capacity", which + # is what makes `/fast` safe without a capacity commitment. + # * `openai_codex` is absent even though it is an OpenAI endpoint: the + # ChatGPT backend does not accept the `service_tier` field at all, so + # `OpenAICodex.request_opts/2` strips it and a tier resolved here would + # be thrown away one layer down. + # * `google`, `bedrock`, `xai` and `openrouter` are absent because neither + # a field name nor an accepted value has been verified against them. + @fast_tiers %{openai: "priority", anthropic: "auto", groq: "auto"} + + # The cheaper-and-slower counterpart, for long-horizon background work + # (`:loose`). Same rule: only where "flex" is in the provider's own + # vocabulary, which is why it is a shorter list than @fast_tiers. + @flex_tiers %{openai: "flex", groq: "flex"} + + @doc """ + The acceleration tier `provider` accepts, or `nil` when it has none. + + Public because `/fast` has to tell the user which of the two they got. + Announcing "enabled" on a provider that cannot accelerate is a claim OSA + cannot keep: nothing about the request changes and the turn runs at exactly + the speed it would have anyway. + """ + @spec fast_tier_for(atom()) :: String.t() | nil + def fast_tier_for(provider), do: Map.get(@fast_tiers, provider) + + @doc "The providers `/fast` genuinely accelerates, for user-facing copy." + @spec fast_tier_providers() :: [atom()] + def fast_tier_providers, do: @fast_tiers |> Map.keys() |> Enum.sort() + @doc false def service_tier_for(state) when is_map(state) do provider = Map.get(state, :provider) case Map.get(state, :priority) do :loose -> - "flex" + Map.get(@flex_tiers, provider) :immediate -> - "priority" + fast_tier_for(provider) _ -> if fast_service_tier?(Map.get(state, :session_id)), @@ -134,17 +176,6 @@ defmodule OptimalSystemAgent.Agent.Loop.LLMClient do def service_tier_for(_), do: nil - # Both Anthropic and Groq define `auto` as "use priority/performance capacity - # when this account has it, otherwise fall back to standard capacity". That - # makes `/fast` safe for accounts without a paid capacity commitment. - defp fast_tier_for(provider) when provider in [:anthropic, :groq], do: "auto" - - defp fast_tier_for(provider) - when provider in [:openai, :openai_codex, :xai, :google, :openrouter, :bedrock], - do: "priority" - - defp fast_tier_for(_), do: nil - @doc "Whether provider Fast processing is enabled independently of reasoning effort." def fast_service_tier? do OptimalSystemAgent.Settings.get(:openai_fast_service_tier) == true @@ -161,12 +192,33 @@ defmodule OptimalSystemAgent.Agent.Loop.LLMClient do enabled end - def toggle_fast_service_tier(session_id) do + def toggle_fast_service_tier(session_id) when is_binary(session_id) and session_id != "" do enabled = not fast_service_tier?(session_id) OptimalSystemAgent.Settings.set_session_for(session_id, :openai_fast_service_tier, enabled) enabled end + # No usable session id. `set_session_for(nil, …)` writes the daemon-wide + # `:global` session row, and every session resolves the global rows UNDER its + # own, so one caller toggling `/fast` with a missing id would silently switch + # fast processing on for every concurrent session in the daemon. Defer to the + # arity that resolves the CALLER's own session from the process dictionary, + # which reaches `:global` only when there is genuinely no session anywhere to + # scope to. That last case stays possible on purpose (a one-shot headless run + # has no session), but it does not stay quiet: a daemon-wide flip of an + # interactive per-session toggle is exactly the kind of thing nobody can + # explain afterwards. + def toggle_fast_service_tier(_session_id) do + if OptimalSystemAgent.Settings.current_session() == :global do + Logger.warning( + "[llm] /fast was toggled with no session in context, so it applies daemon-wide: " <> + "every session without a setting of its own will read it" + ) + end + + toggle_fast_service_tier() + end + defp retry_after_cap_ms do case Application.get_env(:optimal_system_agent, :retry_after_cap_ms, @retry_after_cap_ms) do n when is_integer(n) and n > 0 -> n @@ -444,9 +496,18 @@ defmodule OptimalSystemAgent.Agent.Loop.LLMClient do # Priority tiers are entitlement- and model-dependent. A provider may reject # the tier even though it supports the field generally. Fall back once to the # identical request at its normal tier; never alter reasoning, tools, or model. + # + # Two ways a provider says no, and only one of them is in words. The second, + # a 4xx with no message at all, is read as a tier rejection ONLY here, where + # the request is already known to have carried a `service_tier`: an empty 400 + # on a request that never mentioned a tier keeps its own meaning and is + # returned untouched. The cost of being wrong is one repeated round-trip on a + # turn that was already failing; the cost of not reading it was a permanent + # dead end on any endpoint that reports unsupported fields silently. defp maybe_retry_without_service_tier({:error, reason}, messages, opts, request) when is_function(request, 2) do - if Keyword.has_key?(opts, :service_tier) and tier_rejection?(reason) do + if Keyword.has_key?(opts, :service_tier) and + (tier_rejection?(reason) or bodyless_client_error?(reason)) do Logger.warning("[llm] Fast tier unavailable; retrying at the provider's default tier") request.(messages, Keyword.delete(opts, :service_tier)) else @@ -470,6 +531,31 @@ defmodule OptimalSystemAgent.Agent.Loop.LLMClient do ]) end + @doc """ + A 4xx that arrived carrying no message at all. + + Deliberately NOT folded into `tier_rejection?/1`. On its own an empty 400 + says nothing about tiers, and classifying it as one everywhere would swallow + unrelated failures; it is read at the single call site that already knows a + `service_tier` was attached to the request. + + It has to be read somewhere, though, because the endpoint most likely to + refuse a tier is also the one that explains itself least: the ChatGPT Codex + backend answers an unsupported request field with a bare HTTP 400 and an + empty body, which reaches this module as the reason string `"HTTP 400: "`. + No vocabulary match can fire on that, so the turn simply dead-ended, once per + turn, for as long as the `/fast` toggle stayed on. + """ + @spec bodyless_client_error?(term()) :: boolean() + def bodyless_client_error?(reason) when is_binary(reason) do + case Regex.run(~r/^\s*HTTP\s+4\d\d\s*:\s*(.*)$/s, reason) do + [_, detail] -> String.trim(detail) == "" + _ -> false + end + end + + def bodyless_client_error?(_), do: false + defp repair_field(%{} = result, key) do case Map.get(result, key) do v when is_binary(v) -> Map.put(result, key, Mojibake.repair(v)) diff --git a/lib/optimal_system_agent/agent/loop/react_loop.ex b/lib/optimal_system_agent/agent/loop/react_loop.ex index b91f0daae..de86d3eca 100644 --- a/lib/optimal_system_agent/agent/loop/react_loop.ex +++ b/lib/optimal_system_agent/agent/loop/react_loop.ex @@ -1157,7 +1157,23 @@ defmodule OptimalSystemAgent.Agent.Loop.ReactLoop do finish_turn(content, state) + # The triage above (`maybe_wait_for_user/2`) decided the next useful action + # belongs to the human, so the turn stops here and returns the waiting + # notice instead of the answer. + # + # `content` is appended FIRST, because the answer has already streamed to + # the user and `Loop.run_and_reply/1` records only the RETURNED string as + # this turn's assistant message. Halting on the bare state wrote + # "Waiting for your decision - not complete." into the transcript in the + # place of the answer the user had just read: it was absent from + # `state.messages`, absent from the persisted session, and absent from + # context after `/goal approve` — the model resumed with no record of what + # it had proposed, asked to act on an approval of something it could no + # longer see. Every sibling clause in this `cond` appends the answer before + # it continues; a clause that stops must do the same before it stops. GoalTracker.awaiting_user?(state.session_id) -> + state = %{state | messages: state.messages ++ [%{role: "assistant", content: content}]} + TerminalSource.halt(GoalTracker.waiting_message(state.session_id), state, :control) prose_continue?(state) and state.auto_continues < 2 and diff --git a/lib/optimal_system_agent/channels/cli/commands.ex b/lib/optimal_system_agent/channels/cli/commands.ex index 17d7e60e2..323e4e1cb 100644 --- a/lib/optimal_system_agent/channels/cli/commands.ex +++ b/lib/optimal_system_agent/channels/cli/commands.ex @@ -3182,16 +3182,52 @@ defmodule OptimalSystemAgent.Channels.CLI.Commands do IO.puts("") enabled = LLMClient.toggle_fast_service_tier(session_id) + provider = OptimalSystemAgent.Runtime.Identity.provider() + tier = LLMClient.fast_tier_for(provider) mode = if enabled, do: "enabled", else: "disabled" IO.puts(" #{@green}✓#{@reset} Provider Fast processing #{@bold}#{mode}#{@reset}") - IO.puts(" #{@dim}Uses the selected provider's supported acceleration tier.#{@reset}") + + # The toggle is a session setting, but whether it DOES anything is a + # property of the provider serving the turn. Most providers have no + # acceleration tier OSA can request, so the old blanket line ("uses the + # selected provider's supported acceleration tier") reported success for a + # switch that changed nothing about the request. Say which one this is. + cond do + not enabled -> + IO.puts(" #{@dim}Turns run at #{provider_label(provider)}'s default tier.#{@reset}") + + tier -> + IO.puts( + " #{@dim}Asking #{provider_label(provider)} for its \"#{tier}\" tier on every turn.#{@reset}" + ) + + true -> + IO.puts( + " #{@yellow}!#{@reset} #{provider_label(provider)} has no acceleration tier OSA can " <> + "request, so this changes nothing here." + ) + + IO.puts( + " #{@dim}It takes effect on: #{fast_tier_provider_list()}. " <> + "The setting stays on for when you switch.#{@reset}" + ) + end + IO.puts(" #{@dim}Reasoning effort and tool budgets are unchanged.#{@reset}") IO.puts("") session_id end + defp provider_label(nil), do: "the current provider" + defp provider_label(provider), do: to_string(provider) + + defp fast_tier_provider_list do + OptimalSystemAgent.Agent.Loop.LLMClient.fast_tier_providers() + |> Enum.map_join(", ", &to_string/1) + end + # What the iteration ceiling ACTUALLY is, not what the effort ladder says. # # Effort no longer governs run length: it sets thinking depth, response diff --git a/lib/optimal_system_agent/channels/http.ex b/lib/optimal_system_agent/channels/http.ex index 0f89130e3..bf991dac4 100644 --- a/lib/optimal_system_agent/channels/http.ex +++ b/lib/optimal_system_agent/channels/http.ex @@ -175,6 +175,16 @@ defmodule OptimalSystemAgent.Channels.HTTP do status: "ok", version: version, uptime_seconds: uptime, + # The launch directory this daemon was started in, so `bin/osa` can + # prove a listening daemon belongs to THIS workspace before adopting it + # or stopping it. `original_cwd/0` is the boot-captured launch dir (set + # from OSA_ORIGINAL_CWD, which the launcher exports at bin/osa:78) — NOT + # `Cwd.get/0`, which is session-scoped and moves per turn, and NOT + # `File.cwd!()`, which is the OSA source tree since the daemon starts + # with `cd "$ROOT"`. Without this field a healthy daemon on the expected + # port is indistinguishable from another folder's, which is how one + # workspace could attach to — and `osa stop` could kill — another's. + workspace: OptimalSystemAgent.Workspace.Cwd.original_cwd(), provider: provider, model: model_name, context_window: context_window, diff --git a/lib/optimal_system_agent/providers/bedrock.ex b/lib/optimal_system_agent/providers/bedrock.ex index ceeb8b92f..448e6704c 100644 --- a/lib/optimal_system_agent/providers/bedrock.ex +++ b/lib/optimal_system_agent/providers/bedrock.ex @@ -155,8 +155,16 @@ defmodule OptimalSystemAgent.Providers.Bedrock do def build_request_body(messages, model, opts \\ []) do {system, conversation} = split_system(messages) + # No service tier on this body, deliberately. The shape it used to send, + # `"serviceTier" => %{"type" => tier}` at the Converse top level, is not a + # field this module ever verified against the live API, and the tier it + # carried ("priority") is OpenAI's vocabulary rather than anything AWS + # documents. Converse's documented latency knob is `performanceConfig`, a + # different field with a different shape. Guessing costs a rejected request + # plus the tier-less retry behind it on every `/fast` turn, which is worse + # than not having the feature, so this stays out until a live call + # confirms the field name and its accepted values. %{"messages" => format_messages(conversation)} - |> maybe_put_service_tier(Keyword.get(opts, :service_tier)) |> put_unless_empty("system", Enum.map(system, &%{"text" => &1})) |> put_inference_config(opts) |> put_tool_config(opts) @@ -171,11 +179,6 @@ defmodule OptimalSystemAgent.Providers.Bedrock do |> ImageBudget.apply(provider: :bedrock) end - # Bedrock Converse expects a tagged object, not the bare tier string used by - # OpenAI-compatible and Gemini APIs. - defp maybe_put_service_tier(body, nil), do: body - defp maybe_put_service_tier(body, tier), do: Map.put(body, "serviceTier", %{"type" => tier}) - defp do_chat(auth, model, messages, opts) do body = build_request_body(messages, model, opts) payload = Jason.encode!(body) diff --git a/lib/optimal_system_agent/providers/google.ex b/lib/optimal_system_agent/providers/google.ex index ad3ce157b..63a9178bb 100644 --- a/lib/optimal_system_agent/providers/google.ex +++ b/lib/optimal_system_agent/providers/google.ex @@ -127,7 +127,16 @@ defmodule OptimalSystemAgent.Providers.Google do |> maybe_add_system_instruction(system_instruction) |> maybe_add_generation_config(model, opts) |> maybe_add_tools(opts) - |> maybe_put(:serviceTier, Keyword.get(opts, :service_tier)) + # No `serviceTier` here, deliberately. What used to be forwarded is + # whatever tier the loop resolved for the session, and for Gemini that is + # "priority", which is OpenAI's word: OSA has verified neither the field + # name nor any accepted value against this API. The request came back + # rejected rather than ignored, so a `/fast` turn on Gemini paid for a + # wasted round-trip plus the tier-less retry behind it, every turn, for + # acceleration the account never received. Re-add only with a field AND a + # value confirmed against the live API, the way + # `Anthropic.apply_service_tier/2` allowlists Anthropic's own vocabulary. + # Gemini's 20 MB request ceiling — the budget knows the `contents`/`parts` # envelope now, so this is a real gate rather than a no-op. |> OptimalSystemAgent.Providers.ImageBudget.gate_unsupported(:google, model) diff --git a/lib/optimal_system_agent/providers/openai_codex.ex b/lib/optimal_system_agent/providers/openai_codex.ex index 08b9741bb..d5f73d468 100644 --- a/lib/optimal_system_agent/providers/openai_codex.ex +++ b/lib/optimal_system_agent/providers/openai_codex.ex @@ -96,7 +96,8 @@ defmodule OptimalSystemAgent.Providers.OpenAICodex do @doc """ Configured context budget, falling back to Codex's advertised client maximum. An explicit per-model override is a client budget, not proof of account - entitlement. Never expand a small model or exceed its published model window. + entitlement. Never expand a small model, and never exceed either the model's + published window or what this transport accepts. """ def context_window(model) do context_window(model, OptimalSystemAgent.Settings.get("codex_context_windows", %{})) @@ -107,7 +108,7 @@ defmodule OptimalSystemAgent.Providers.OpenAICodex do fallback = Map.get(@context_windows, model) requested = if is_map(overrides), do: Map.get(overrides, model) published = OptimalSystemAgent.Providers.OpenAIModels.model(model) - ceiling = if published, do: published.ctx, else: fallback + ceiling = ceiling(published, fallback) if is_integer(requested) and requested > 0 and is_integer(ceiling) do min(requested, ceiling) @@ -116,6 +117,20 @@ defmodule OptimalSystemAgent.Providers.OpenAICodex do end end + # An override has to clear BOTH ceilings, not just the published one. The + # model card carries the PUBLIC API's window (1.05M across the 5.6 family); + # Codex is a different transport with its own, smaller advertised maximum + # (872k), so a budget built to the public number over-fills this endpoint by + # up to 178k tokens. That failure is silent in the worst way: compaction + # never fires, because OSA believes the context still fits, and the request + # then dies at the wire with the same undiagnosable empty 400 Codex answers + # every unsupported request with. + defp ceiling(%{ctx: ctx}, fallback) when is_integer(ctx) and is_integer(fallback), + do: min(ctx, fallback) + + defp ceiling(%{ctx: ctx}, _fallback) when is_integer(ctx), do: ctx + defp ceiling(_published, fallback), do: fallback + @doc "True when a ChatGPT plan is connected. Pure read — never refreshes." @spec configured?() :: boolean() def configured?, do: Auth.status().connected? @@ -242,6 +257,17 @@ defmodule OptimalSystemAgent.Providers.OpenAICodex do # agent loop supplies `max_tokens` for every provider, so strip it at this # provider boundary and let Codex enforce its own output ceiling. |> Keyword.delete(:max_tokens) + # `service_tier` is that same defect wearing a different field name: one + # more public Responses field this endpoint does not accept, answered with + # the same bodyless 400 whose reason string ("HTTP 400: ") names nothing. + # It matters more than it looks, because `/fast` is a persistent + # per-session toggle: without this delete the first `/fast` turn on a Codex + # plan fails and so does every turn after it, with no error text anyone can + # act on. Stripped here rather than where the tier is resolved because this + # is the one chokepoint every Codex-bound request passes through, the + # `openai` -> `openai_codex` fallback hop included (Registry's + # `cross_provider_opts/1` drops only `:model`). + |> Keyword.delete(:service_tier) end defp auth_error(reason) do diff --git a/lib/optimal_system_agent/providers/openai_compat.ex b/lib/optimal_system_agent/providers/openai_compat.ex index 6bb4e176f..4646b1946 100644 --- a/lib/optimal_system_agent/providers/openai_compat.ex +++ b/lib/optimal_system_agent/providers/openai_compat.ex @@ -1311,25 +1311,48 @@ defmodule OptimalSystemAgent.Providers.OpenAICompat do end end - # OpenAI o-series reasoning models (o1/o3/o4) reject the classic `max_tokens` - # field and require `max_completion_tokens`. Every other OpenAI-compatible - # provider uses `max_tokens`. Route the value to the right key so o-series - # calls don't 400 ("max_tokens is not supported with this model"). - # Processing tiers are not universally OpenAI-compatible. Send the field only - # to providers whose native API documents it; local and unknown compatibility - # endpoints continue to receive no extra field. + # Processing tiers are not universally OpenAI-compatible, and the vocabulary + # is per-provider even where the FIELD is shared: "priority" is OpenAI's + # word, "performance" is Groq's, "standard_only" is Anthropic's. Forwarding + # one provider's word to another is not a harmless no-op, because an + # unrecognized `service_tier` is a validation error rather than an ignored + # field: the turn pays for a rejected request plus the tier-less retry behind + # it, every turn, for acceleration the account never receives. + # + # Hence an allowlist PER PROVIDER, not a list of providers that get whatever + # the loop resolved. Same shape as `Anthropic.apply_service_tier/2`. Anything + # outside a provider's own vocabulary is dropped, which lands the request on + # that provider's default tier: exactly where the fallback retry would have + # put it, minus the extra round-trip. + # + # `:xai` and `:openrouter` are absent on purpose. Neither documents a tier + # vocabulary OSA has verified, and an unknown `service_tier` came back from + # them as an HTTP 422. Add either one back only with a value checked against + # the live API. + @service_tiers %{ + # OpenAI's documented enum. + openai: ["auto", "default", "flex", "priority", "scale"], + # Groq's own words. "auto" means "use performance capacity when this + # account has it, otherwise on-demand", which is why `/fast` resolves to it + # for Groq instead of to OpenAI's "priority". + groq: ["auto", "on_demand", "flex", "performance"] + } + defp maybe_add_service_tier(body, opts) do tier = Keyword.get(opts, :service_tier) provider = Keyword.get(opts, :provider, image_provider(opts)) - if is_binary(tier) and tier != "" and - provider in [:openai, :xai, :openrouter, :groq] do + if tier in Map.get(@service_tiers, provider, []) do Map.put(body, :service_tier, tier) else body end end + # OpenAI o-series reasoning models (o1/o3/o4) reject the classic `max_tokens` + # field and require `max_completion_tokens`. Every other OpenAI-compatible + # provider uses `max_tokens`. Route the value to the right key so o-series + # calls don't 400 ("max_tokens is not supported with this model"). defp maybe_add_max_tokens(body, model, opts) do case Keyword.get(opts, :max_tokens) do nil -> diff --git a/lib/optimal_system_agent/settings.ex b/lib/optimal_system_agent/settings.ex index acd58e3bc..4bb39904b 100644 --- a/lib/optimal_system_agent/settings.ex +++ b/lib/optimal_system_agent/settings.ex @@ -102,10 +102,7 @@ defmodule OptimalSystemAgent.Settings do user → project → local → flag file → session. """ def merged do - [layer(:user), layer(:project), layer(:local), layer(:flag)] - |> Enum.reduce(%{}, &deep_merge(&2, &1)) - |> deep_merge(get_all_session()) - |> harden_if_unparseable() + cascade([layer(:user), layer(:project), layer(:local), layer(:flag)], get_all_session()) end @doc "Deep-merge two settings maps: maps merge recursively, lists concat + dedupe, scalars override." @@ -183,20 +180,24 @@ defmodule OptimalSystemAgent.Settings do def set_session_for(_session_id, key, value), do: put_session(:global, key, value) - @doc "Get a setting using the session layer for a SPECIFIC session id." + @doc """ + Get a setting using the session layer for a SPECIFIC session id. + + Resolved through the SAME cascade as `get/2`, with the session layer scoped + to `session_id` instead of to the calling process. Reading only the ETS + session rows, which is what this used to do, made the two arities disagree + about the same key: a value set in `~/.osa/settings.json` or in the + `OSA_SETTINGS` flag file was honored by `get/2` and invisible here, so a + setting like `openai_fast_service_tier` was on or off depending on which + reader a call site happened to reach for. + + Mirrors `get/2` exactly, trust posture included: the project layer is NOT + gated here. A security-relevant key needs `get_trusted/2` (which has no + session-scoped arity yet) rather than this one. + """ @spec get_session_for(String.t() | nil, atom() | String.t(), term()) :: term() def get_session_for(session_id, key, default \\ nil) do - session = - case session_id do - sid when is_binary(sid) and sid != "" -> - global = session_rows({{:session, :"$1"}, :"$2"}) - Map.merge(global, session_rows({{:session, sid, :"$1"}, :"$2"})) - - _ -> - session_rows({{:session, :"$1"}, :"$2"}) - end - - case Map.fetch(session, to_string(key)) do + case Map.fetch(merged_for(session_id), to_string(key)) do {:ok, value} -> value :error -> default end @@ -408,10 +409,10 @@ defmodule OptimalSystemAgent.Settings do Use for any security-relevant setting; `merged/0` stays as-is for display. """ def merged_trusted do - [layer(:user), trusted_layer(:project), trusted_layer(:local), layer(:flag)] - |> Enum.reduce(%{}, &deep_merge(&2, &1)) - |> deep_merge(get_all_session()) - |> harden_if_unparseable() + cascade( + [layer(:user), trusted_layer(:project), trusted_layer(:local), layer(:flag)], + get_all_session() + ) end @doc "`get/2` resolved through `merged_trusted/0`." @@ -504,17 +505,42 @@ defmodule OptimalSystemAgent.Settings do # per-session tool policy expressible at all. defp get_all_session do try do - global = session_rows({{:session, :"$1"}, :"$2"}) - - case current_session() do - :global -> global - sid -> Map.merge(global, session_rows({{:session, sid, :"$1"}, :"$2"})) - end + session_layer(current_session()) rescue _ -> %{} end end + # The session layer as seen from ONE scope: the daemon-wide `:global` rows, + # then the rows written for that session id, which shadow them. + defp session_layer(:global), do: session_rows({{:session, :"$1"}, :"$2"}) + + defp session_layer(session_id) do + Map.merge( + session_rows({{:session, :"$1"}, :"$2"}), + session_rows({{:session, session_id, :"$1"}, :"$2"}) + ) + end + + # `merged/0` with the session layer resolved for an EXPLICIT session id + # instead of for the calling process, so a caller can read a setting on + # behalf of a session it is not running inside. + defp merged_for(session_id) do + scope = if is_binary(session_id) and session_id != "", do: session_id, else: :global + + cascade([layer(:user), layer(:project), layer(:local), layer(:flag)], session_layer(scope)) + end + + # The file layers deep-merged lowest to highest, then the session layer on + # top, then the fail-closed hardening. Every resolved view of the settings + # goes through here so none of them can drift apart from the others. + defp cascade(file_layers, session_layer) do + file_layers + |> Enum.reduce(%{}, &deep_merge(&2, &1)) + |> deep_merge(session_layer) + |> harden_if_unparseable() + end + defp session_rows(pattern) do :ets.match(:osa_settings, pattern) |> Enum.reduce(%{}, fn [key, value], acc -> Map.put(acc, to_string(key), value) end) diff --git a/native/macos/ScreenShare/build.sh b/native/macos/ScreenShare/build.sh index a8a6d76fa..f89908156 100755 --- a/native/macos/ScreenShare/build.sh +++ b/native/macos/ScreenShare/build.sh @@ -15,13 +15,26 @@ # osa-screen-capture-darwin (the name the Elixir MacOS adapter looks for) # # CI: This script is called by release.yml instead of `swift build -c release`. +# +# EVERY path below is derived from SCRIPT_DIR, never from the caller's cwd. +# release.yml runs `native/macos/ScreenShare/build.sh` from the REPO ROOT, and +# until v1.0.178 the swiftc inputs were cwd-relative ("Sources/ScreenShare/…"). +# That made the step fail with "error opening input file" on every tagged +# release, which skipped the remaining macOS steps (stamp, mix release, tarball, +# TUI, upload) and published a release with no macOS assets at all (#238). +# The sibling AccessibilityHelper/build.sh was always written this way; this one +# now matches it. Keep it that way — a cwd-relative path here is a release-time +# outage, not a local inconvenience. set -e MODE="${1:-release}" ARCH="${ARCH:-$(uname -m)}" -OUT_DIR=".build/${MODE}" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +SRC_DIR="${SCRIPT_DIR}/Sources/ScreenShare" +OUT_DIR="${SCRIPT_DIR}/.build/${MODE}" BINARY="${OUT_DIR}/ScreenShare" +PRIV_HELPERS="${SCRIPT_DIR}/../../../priv/helpers" mkdir -p "$OUT_DIR" @@ -32,6 +45,7 @@ fi echo "[build.sh] Building ScreenShare mode=${MODE} arch=${ARCH} → ${BINARY}" +# shellcheck disable=SC2086 swiftc \ $OPT_FLAGS \ -target "${ARCH}-apple-macosx13.0" \ @@ -39,18 +53,16 @@ swiftc \ -framework CoreMedia \ -framework CoreVideo \ -framework Network \ - Sources/ScreenShare/FrameEncoder.swift \ - Sources/ScreenShare/VncServer.swift \ - Sources/ScreenShare/Capture.swift \ - Sources/ScreenShare/main.swift \ + "${SRC_DIR}/FrameEncoder.swift" \ + "${SRC_DIR}/VncServer.swift" \ + "${SRC_DIR}/Capture.swift" \ + "${SRC_DIR}/main.swift" \ -o "${BINARY}" echo "[build.sh] Done: $(ls -lh "${BINARY}" | awk '{print $5, $NF}')" # Copy to priv/helpers/ with the OSA release binary name (release mode only). # CI uses this path; local dev can also benefit from the auto-copy. -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PRIV_HELPERS="${SCRIPT_DIR}/../../../priv/helpers" if [ -d "${PRIV_HELPERS}" ] && [ "${MODE}" = "release" ]; then cp "${BINARY}" "${PRIV_HELPERS}/osa-screen-capture-darwin" chmod +x "${PRIV_HELPERS}/osa-screen-capture-darwin" diff --git a/priv/rust/tui/Cargo.lock b/priv/rust/tui/Cargo.lock index 6059363fa..c7623fdff 100644 --- a/priv/rust/tui/Cargo.lock +++ b/priv/rust/tui/Cargo.lock @@ -1806,7 +1806,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "osa-tui" -version = "1.0.177" +version = "1.0.178" dependencies = [ "anyhow", "arboard", diff --git a/priv/rust/tui/Cargo.toml b/priv/rust/tui/Cargo.toml index 7c11ce45a..67ab07561 100644 --- a/priv/rust/tui/Cargo.toml +++ b/priv/rust/tui/Cargo.toml @@ -4,7 +4,7 @@ name = "osa-tui" # `config::version_source_tests` fails the build if they diverge. This literal is # only ever a last-resort fallback: build.rs prefers $OSA_VERSION (release CI), # then the VERSION file. -version = "1.0.177" +version = "1.0.178" edition = "2021" [[bin]] diff --git a/priv/rust/tui/src/app/handle_actions.rs b/priv/rust/tui/src/app/handle_actions.rs index 7f58940be..b91981d7a 100644 --- a/priv/rust/tui/src/app/handle_actions.rs +++ b/priv/rust/tui/src/app/handle_actions.rs @@ -1943,6 +1943,33 @@ impl App { }); } + /// Resolve this folder's session once the onboarding wizard is out of the + /// way — the first-run counterpart of the `OnboardingStatus` onboarded + /// branch. + /// + /// **This is what made a brand-new install swallow every message.** Session + /// resolution happens in exactly two places: that onboarded branch and + /// [`Self::create_session`]. When `needs_onboarding` is true the wizard + /// branch runs instead, so neither fires and `dir_session_resolved` stays + /// false — and `startup_session_pending` is a `!resolved ||` gate. Finishing + /// setup did not clear it (`OnboardingComplete` set the identity and sent + /// the bootstrap greeting straight through `submit_prompt`, which bypasses + /// the gate entirely), and neither did cancelling out of the wizard. So + /// every message the new user typed after setup was enqueued and never + /// drained. That is the entire first-run experience, for every new user. + /// + /// Called from all three ways the wizard can exit — completed, failed, and + /// cancelled — because a user left at an Idle prompt with no session is + /// wedged the same way whichever door they came through. + /// + /// Idempotent, matching the onboarded branch's own `!dir_session_resolved` + /// guard: it must never replace a session the user already has. + pub(crate) fn resolve_session_after_onboarding(&mut self) { + if !self.dir_session_resolved { + self.create_session(); + } + } + /// Apply a `--model` / `--provider` launch flag to the session that just /// became current. /// diff --git a/priv/rust/tui/src/app/handle_backend.rs b/priv/rust/tui/src/app/handle_backend.rs index 5cc7b2a52..66fc9bc6c 100644 --- a/priv/rust/tui/src/app/handle_backend.rs +++ b/priv/rust/tui/src/app/handle_backend.rs @@ -1583,10 +1583,26 @@ impl App { crate::components::toast::ToastLevel::Info, ); } + // No drain here on purpose: `start_sse` above reattaches the + // stream to this id, and `SseConnected` is the boundary that + // drains the queue. Sending a queued prompt from here would + // race the attach and stream the answer into a session + // nothing is listening on yet. } Err(e) => { + // **Reopen the gate.** `create_session` closes it + // (`session_creation_pending = true`) and, until this line, + // only the `Ok` arm above ever reopened it — so a failed + // create left `startup_session_pending` true forever. From + // there `submit_input` enqueued every message and returned, + // and `maybe_dequeue_message` early-returned at every + // turn-completion site: the user typed, watched a dim queued + // line pile up, and nothing was ever sent, with no way out + // short of restarting. A failed create is a failed create — + // it must not also be a wedged session. + self.session_creation_pending = false; self.toasts.push( - format!("Session create failed: {}", e), + format!("Session create failed: {} \u{2014} /new to retry", e), crate::components::toast::ToastLevel::Error, ); } @@ -3379,11 +3395,29 @@ impl App { self.transition(AppState::Idle); } + // First run has no session yet — the wizard branch replaced + // the resolution step. Without this every message typed + // after setup is enqueued forever. + self.resolve_session_after_onboarding(); + // Auto-send bootstrap message — agent speaks first - // This kicks off the BOOTSTRAP.md identity ritual - self.submit_prompt("Hey, I just set you up. What's good?"); + // This kicks off the BOOTSTRAP.md identity ritual. + // + // Through `submit_input`, not `submit_prompt`: the session + // requested above does not exist yet, and `submit_prompt` + // would fire the greeting at the provisional id that + // `SessionCreated` is about to replace — the very race the + // startup gate exists to prevent. `submit_input` respects + // that gate, so the greeting queues and goes out the moment + // the real id lands. + self.submit_input("Hey, I just set you up. What's good?"); } Err(e) => { + // Setup failed, but the dialog handler already dismissed the + // wizard before POSTing, so the user is sitting at an Idle + // prompt that the unresolved startup gate is still swallowing + // input from. Give them a session to retry in. + self.resolve_session_after_onboarding(); self.toasts.push( format!("Onboarding failed: {}", e), crate::components::toast::ToastLevel::Error, diff --git a/priv/rust/tui/src/app/handle_dialogs.rs b/priv/rust/tui/src/app/handle_dialogs.rs index e9767a12e..9944969ed 100644 --- a/priv/rust/tui/src/app/handle_dialogs.rs +++ b/priv/rust/tui/src/app/handle_dialogs.rs @@ -263,6 +263,11 @@ impl App { self.discard_overlay_return(); self.transition(AppState::Idle); self.onboarding = None; + // Escaping the wizard drops the user at an ordinary + // prompt, but the branch that opened it never resolved + // this folder's session — so without this the startup + // gate enqueues everything they type and never sends it. + self.resolve_session_after_onboarding(); } } } diff --git a/priv/rust/tui/src/render/sanitize.rs b/priv/rust/tui/src/render/sanitize.rs index 8e33285ef..d17f3d05d 100644 --- a/priv/rust/tui/src/render/sanitize.rs +++ b/priv/rust/tui/src/render/sanitize.rs @@ -517,6 +517,44 @@ mod tests { )); } + /// **ESC followed by a multi-byte char must not abort the session.** + /// + /// Both escape scrubbers advance by `util::escape_len_at`'s byte length, and + /// the catch-all two-byte escape returned 2 whatever followed ESC. When that + /// was a UTF-8 lead byte the slice landed mid-codepoint: + /// + /// ```text + /// byte index 2 is not a char boundary; it is inside '✓' (bytes 1..4) + /// ``` + /// + /// This path carries RAW tool output (`tools::bash` → `collapse` → + /// `scrub_terminal_output`), so `printf '\033✓'` in a command, or an ANSI + /// sequence a log clipped mid-escape, killed the TUI. + #[test] + fn an_escape_before_a_multibyte_char_does_not_panic() { + // The reported string, exactly. + assert_eq!(&*scrub_terminal_output("\u{1b}\u{2713} done"), " done"); + + // The same input through the span backstop, the other escape scrubber. + assert_eq!(scrub_rendered_span("\u{1b}\u{2713} done"), " done"); + + // Every multi-byte width, at the head, in the middle and at the end — + // and a clipped CSI/OSC whose parameters run into non-ASCII text. + for hostile in [ + "\u{1b}\u{2713}", + "a\u{1b}\u{20ac}b", + "a\u{1b}\u{1f600}", + "\u{1b}\u{e9}tape", + "a\u{1b}[38;5;\u{2713}b", + "a\u{1b}]0;\u{2713}", + "\u{1b}\u{2713}\u{1b}\u{2713}\u{1b}", + ] { + let out = scrub_terminal_output(hostile); + assert!(!out.contains('\u{1b}'), "ESC survived: {out:?}"); + let _ = scrub_rendered_span(hostile); + } + } + #[test] fn the_optional_variant_preserves_none() { assert_eq!(scrub_untrusted_line_opt(None), None); diff --git a/priv/rust/tui/src/util.rs b/priv/rust/tui/src/util.rs index ac3149705..07da6c90b 100644 --- a/priv/rust/tui/src/util.rs +++ b/priv/rust/tui/src/util.rs @@ -71,6 +71,8 @@ pub fn fit_cols(s: &str, max_cols: usize) -> String { /// visible text, and then the trailing `ESC \` eats the real output after it. /// * **DCS/SOS/PM/APC** — `ESC P|X|^|_` … terminated by `ST` (tmux passthrough). /// * **Two-byte** — `ESC `, the catch-all (includes a bare `ESC \`). +/// +/// The length returned is always a char boundary, so callers can slice with it. pub fn escape_len_at(s: &str, at: usize) -> Option { let b = s.as_bytes(); if b.get(at) != Some(&0x1b) { @@ -103,8 +105,14 @@ pub fn escape_len_at(s: &str, at: usize) -> Option { } Some(b.len() - at) } - // Anything else is a two-byte escape (`ESC \`, `ESC =`, charset selects…). - _ => Some(2), + // Anything else is a two-byte escape (`ESC \`, `ESC =`, charset selects…) + // — except when the byte after ESC is a UTF-8 lead byte, where "two + // bytes" lands INSIDE a char. Every caller slices at the length returned + // here, so `printf '\033✓'` or an ANSI sequence clipped mid-escape by a + // log used to abort the process on a char-boundary panic. Consume the + // following char whole: still the fail-closed direction (the char is + // attributed to the escape and dropped), and always a boundary. + _ => Some(1 + s[at + 1..].chars().next().map_or(1, char::len_utf8)), } } @@ -876,4 +884,34 @@ mod tests { fn fit_arg_summary_leaves_short_values_untouched() { assert_eq!(fit_arg_summary("smoke-e2e", 60), "smoke-e2e"); } + + /// **Every length this returns must be a char boundary.** + /// + /// The catch-all two-byte escape returned 2 regardless of what followed + /// ESC, so `printf '\033✓'` — or any ANSI sequence a log clipped mid-escape + /// — made every caller slice into the middle of a codepoint and abort the + /// session: `byte index 2 is not a char boundary; it is inside '✓'`. Raw + /// tool output reaches these scanners unsanitised, so the input is the + /// attacker's to choose. + #[test] + fn escape_len_never_splits_a_codepoint() { + for tail in ["\u{2713} done", "\u{20ac}", "\u{1f600}!", "\u{e9}", "=b"] { + let s = format!("a\u{1b}{tail}"); + let len = escape_len_at(&s, 1).expect("ESC at byte 1"); + assert!( + s.is_char_boundary(1 + len), + "escape_len_at cut inside a codepoint of {s:?}: len={len}" + ); + // The callers' loop, which is what actually panicked. + let _ = &s[1 + len..]; + } + } + + /// `cols` walks the same lengths, so it inherited the panic. A width + /// measurement must never abort — it runs on every frame of every row. + #[test] + fn cols_survives_an_escape_before_a_multibyte_char() { + assert_eq!(cols("\u{1b}\u{2713} done"), 5); // ESC+✓ consumed, " done" measured + assert_eq!(cols("a\u{1b}\u{1f600}b"), 2); + } } diff --git a/test/agent/loop/goal_handoff_test.exs b/test/agent/loop/goal_handoff_test.exs index 1fb9ff20d..6d2d09b40 100644 --- a/test/agent/loop/goal_handoff_test.exs +++ b/test/agent/loop/goal_handoff_test.exs @@ -28,6 +28,133 @@ defmodule OptimalSystemAgent.Agent.Loop.GoalHandoffTest do refute ReactLoop.goal_continue_due?(state) end + # `maybe_wait_for_user/2` spends a real triage round-trip on the same provider + # that just drove the turn, so it has to answer to the same operator switch + # and the same per-turn spend counters as `maybe_gate/1`. Ungated it fired on + # every tool-call-free generation inside a goal turn: an anchored goal with a + # 3-continuation budget bought 8 provider round-trips instead of 4, and an + # explicit `goal_verifier_enabled: false` — the setting whose whole job is to + # buy silence from this module — stopped none of them. + test "the handoff triage answers to the verifier switch and its spend guards", %{ + sid: sid, + request: r + } do + keys = [:goal_verifier_enabled, :goal_verifier_triage_runner] + previous = Map.new(keys, &{&1, Application.fetch_env(:optimal_system_agent, &1)}) + + on_exit(fn -> + for {key, value} <- previous do + case value do + {:ok, v} -> Application.put_env(:optimal_system_agent, key, v) + :error -> Application.delete_env(:optimal_system_agent, key) + end + end + end) + + parent = self() + + Application.put_env(:optimal_system_agent, :goal_verifier_triage_runner, fn _ -> + send(parent, :triaged) + {:ok, Jason.encode!(Map.put(r, "status", "awaiting_user"))} + end) + + state = %{session_id: sid, goal_mode: true, messages: []} + + Application.put_env(:optimal_system_agent, :goal_verifier_enabled, false) + GoalVerifier.maybe_wait_for_user(state, "Draft is ready; please review.") + + refute_received :triaged, + "an explicit goal_verifier_enabled: false must buy silence on this path too" + + Application.put_env(:optimal_system_agent, :goal_verifier_enabled, true) + + # The per-turn run cap (3) and the stall early-exit (2) are budget guards, + # not size heuristics — unlike `:no_work`/`:trivial`, which this path exists + # to bypass for goals that write nothing. + GoalVerifier.maybe_wait_for_user(Map.put(state, :goal_verifier_runs, 3), "Draft is ready.") + refute_received :triaged, "the per-turn verification run cap must bound the triage too" + + GoalVerifier.maybe_wait_for_user( + Map.put(state, :goal_verifier_stall_count, 2), + "Draft is ready." + ) + + refute_received :triaged, "a stalled goal must not keep paying for triage" + + # And with every guard satisfied it still does its job. + GoalVerifier.maybe_wait_for_user(state, "Draft is ready; please review.") + assert_received :triaged + assert GoalTracker.awaiting_user?(sid) + end + + # The handoff clause in `ReactLoop.handle_result/3` returns the waiting notice + # INSTEAD of the answer, and `Loop.run_and_reply/1` records only the returned + # string as the turn's assistant message. So the answer the user watched + # stream — the very artifact they are being asked to approve — has to be put + # into the transcript by the halting clause itself, or it is lost from + # history, from the persisted session, and from the context the model resumes + # with after `/goal approve`. + test "the answer that triggered the handoff stays in history", %{sid: sid, request: r} do + keys = [ + :default_provider, + :mock_provider_final_text, + :max_iterations, + :goal_verifier_enabled, + :goal_verifier_triage_runner, + :proactive_compaction_enabled + ] + + previous = Map.new(keys, &{&1, Application.fetch_env(:optimal_system_agent, &1)}) + + on_exit(fn -> + for {key, value} <- previous do + case value do + {:ok, v} -> Application.put_env(:optimal_system_agent, key, v) + :error -> Application.delete_env(:optimal_system_agent, key) + end + end + end) + + answer = "Draft v1 is at thesis.md — the argument now runs from the survey data." + + Application.put_env(:optimal_system_agent, :default_provider, :mock) + Application.put_env(:optimal_system_agent, :mock_provider_final_text, answer) + Application.put_env(:optimal_system_agent, :max_iterations, 12) + Application.put_env(:optimal_system_agent, :goal_verifier_enabled, true) + Application.put_env(:optimal_system_agent, :proactive_compaction_enabled, false) + + Application.put_env(:optimal_system_agent, :goal_verifier_triage_runner, fn _ -> + {:ok, Jason.encode!(Map.put(r, "status", "awaiting_user"))} + end) + + state = + Map.from_struct(%OptimalSystemAgent.Agent.Loop{ + session_id: sid, + provider: :mock, + model: "mock-model-1.0", + iteration: 0, + auto_continues: 0, + messages: [%{role: "user", content: "draft the thesis"}], + tools: [], + permission_mode: :ask, + permission_tier: :full, + working_dir: File.cwd!() + }) + + OptimalSystemAgent.Test.MockProvider.reset_round_trips() + {reply, final} = ReactLoop.run(state) + + assert GoalTracker.awaiting_user?(sid) + assert reply =~ "Waiting for your decision" + + assert Enum.any?(final.messages, &(&1[:role] == "assistant" and &1[:content] == answer)), + "the handoff halted on the waiting notice and left the model's own answer out " <> + "of the transcript: #{inspect(final.messages)}" + + assert OptimalSystemAgent.Test.MockProvider.round_trips() == 1, + "handing off to the user must cost exactly the one generation the user read" + end + test "HTTP commands expose pending decision and clear leaves no active goal", %{ sid: sid, request: r diff --git a/test/channels/http/command_execute_test.exs b/test/channels/http/command_execute_test.exs index bd190e325..edb20fbbf 100644 --- a/test/channels/http/command_execute_test.exs +++ b/test/channels/http/command_execute_test.exs @@ -102,7 +102,7 @@ defmodule OptimalSystemAgent.Channels.HTTP.CommandExecuteTest do refute OptimalSystemAgent.Agent.Loop.LLMClient.fast_service_tier?(other_session_id) assert OptimalSystemAgent.Agent.Loop.LLMClient.service_tier_for(%{ - provider: :openai_codex, + provider: :openai, session_id: session_id }) == "priority" @@ -116,13 +116,20 @@ defmodule OptimalSystemAgent.Channels.HTTP.CommandExecuteTest do session_id: session_id }) == "auto" - assert OptimalSystemAgent.Agent.Loop.LLMClient.service_tier_for(%{ - provider: :ollama, - session_id: session_id - }) == nil + # A provider only gets a tier when OSA has a verified way to ask IT to go + # faster. `openai_codex` is the sharp case: it is an OpenAI endpoint, but + # the ChatGPT backend does not accept the `service_tier` field at all and + # `OpenAICodex.request_opts/2` strips it, so resolving one here would be a + # value invented for a request that will never carry it. + for provider <- [:openai_codex, :google, :bedrock, :xai, :openrouter, :ollama] do + assert OptimalSystemAgent.Agent.Loop.LLMClient.service_tier_for(%{ + provider: provider, + session_id: session_id + }) == nil + end assert OptimalSystemAgent.Agent.Loop.LLMClient.service_tier_for(%{ - provider: :openai_codex, + provider: :openai, session_id: other_session_id }) == nil @@ -130,6 +137,56 @@ defmodule OptimalSystemAgent.Channels.HTTP.CommandExecuteTest do refute OptimalSystemAgent.Agent.Loop.LLMClient.fast_service_tier?(session_id) end + test "/fast says so when the current provider cannot accelerate" do + session_id = "fast-honest-#{System.unique_integer([:positive])}" + previous = Application.get_env(:optimal_system_agent, :default_provider) + Application.put_env(:optimal_system_agent, :default_provider, :ollama) + + on_exit(fn -> + if previous, + do: Application.put_env(:optimal_system_agent, :default_provider, previous), + else: Application.delete_env(:optimal_system_agent, :default_provider) + + OptimalSystemAgent.Settings.clear_session(session_id) + end) + + output = execute("fast", session_id).resp_body |> Jason.decode!() |> Map.get("output") + + # The setting really is on, so the confirmation stands... + assert output =~ "enabled" + assert OptimalSystemAgent.Agent.Loop.LLMClient.fast_service_tier?(session_id) + + # ...but nothing about an ollama request changes, and the user is told that + # instead of being left to infer acceleration that never arrives. + assert output =~ "ollama has no acceleration tier" + assert output =~ "It takes effect on: anthropic, groq, openai" + + assert OptimalSystemAgent.Agent.Loop.LLMClient.service_tier_for(%{ + provider: :ollama, + session_id: session_id + }) == nil + end + + test "/fast names the tier it will ask for when the provider can accelerate" do + session_id = "fast-real-#{System.unique_integer([:positive])}" + previous = Application.get_env(:optimal_system_agent, :default_provider) + Application.put_env(:optimal_system_agent, :default_provider, :anthropic) + + on_exit(fn -> + if previous, + do: Application.put_env(:optimal_system_agent, :default_provider, previous), + else: Application.delete_env(:optimal_system_agent, :default_provider) + + OptimalSystemAgent.Settings.clear_session(session_id) + end) + + output = execute("fast", session_id).resp_body |> Jason.decode!() |> Map.get("output") + + assert output =~ "enabled" + assert output =~ ~s(Asking anthropic for its "auto" tier) + refute output =~ "no acceleration tier" + end + test "fast-tier fallback only recognizes acceleration-specific errors" do refute OptimalSystemAgent.Agent.Loop.LLMClient.tier_rejection?( "HTTP 400: invalid tool schema" diff --git a/test/channels/http/health_workspace_test.exs b/test/channels/http/health_workspace_test.exs new file mode 100644 index 000000000..bcb734a09 --- /dev/null +++ b/test/channels/http/health_workspace_test.exs @@ -0,0 +1,55 @@ +defmodule OptimalSystemAgent.Channels.HTTP.HealthWorkspaceTest do + @moduledoc """ + `bin/osa` decides whether a listening daemon is THIS workspace's — and + therefore whether it may adopt it or let `osa stop` kill it — by comparing + `/health`'s `workspace` against its own launch directory (#245). When the + field was absent, "a healthy daemon answers on the expected port" was the + whole test, so one folder could attach to another folder's backend. + + `LauncherWorkspaceIsolationTest` pins the wiring by source-grep; this pins the + behaviour: `/health` actually returns the boot-captured launch directory that + `Workspace.Cwd.original_cwd/0` reports. + """ + use ExUnit.Case, async: false + use Plug.Test + + alias OptimalSystemAgent.Channels.HTTP + alias OptimalSystemAgent.Workspace.Cwd + + @opts HTTP.init([]) + + setup do + # original_cwd/0 is boot-captured in :persistent_term; restore it after. + original = Cwd.original_cwd() + on_exit(fn -> Cwd.set_original_cwd(original) end) + :ok + end + + defp health_workspace do + :get + |> conn("/health") + |> HTTP.call(@opts) + |> then(& &1.resp_body) + |> Jason.decode!() + |> Map.get("workspace") + end + + test "reports the boot-captured launch directory" do + Cwd.set_original_cwd("/tmp") + assert health_workspace() == "/tmp" + end + + test "reports an expanded absolute path, since bin/osa compares absolutes" do + Cwd.set_original_cwd(".") + workspace = health_workspace() + assert workspace == Path.expand(".") + assert String.starts_with?(workspace, "/") + end + + test "is always a non-nil string, so the launcher never has to defend against null" do + Cwd.set_original_cwd("/tmp") + workspace = health_workspace() + assert is_binary(workspace) + refute is_nil(workspace) + end +end diff --git a/test/providers/codex_context_override_test.exs b/test/providers/codex_context_override_test.exs index 9ffa2c58f..b2d24b00a 100644 --- a/test/providers/codex_context_override_test.exs +++ b/test/providers/codex_context_override_test.exs @@ -6,8 +6,10 @@ defmodule OptimalSystemAgent.Providers.CodexContextOverrideTest do session = "codex-window-#{System.unique_integer([:positive])}" Process.put(:osa_session_id, session) + # Under the 872k Codex transport maximum, so what this test observes is the + # override travelling, not the ceiling clamping it. OptimalSystemAgent.Settings.set_session_for(session, "codex_context_windows", %{ - "gpt-6-astra" => 1_000_000 + "gpt-6-astra" => 800_000 }) on_exit(fn -> OptimalSystemAgent.Settings.clear_session(session) end) @@ -23,22 +25,26 @@ defmodule OptimalSystemAgent.Providers.CodexContextOverrideTest do working_dir: "/tmp" } - assert OptimalSystemAgent.Agent.Loop.ContextWindow.resolve(state) == {:ok, 1_000_000} - assert OptimalSystemAgent.Agent.Context.token_budget(state).max_tokens == 1_000_000 + assert OptimalSystemAgent.Agent.Loop.ContextWindow.resolve(state) == {:ok, 800_000} + assert OptimalSystemAgent.Agent.Context.token_budget(state).max_tokens == 800_000 assert OptimalSystemAgent.Providers.Registry.effective_context_window("gpt-6-astra", :openai) == 1_050_000 end - test "explicit million-token budgets apply only to the named models" do + test "a million-token budget is capped at what the Codex transport accepts" do overrides = Map.new( ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"], &{&1, 1_000_000} ) + # The 1.05M on these model cards is the PUBLIC API's window. Codex + # advertises 872k for the same ids, and a budget built to the public number + # over-fills this endpoint: compaction never fires, and the turn dies at + # the wire with an empty HTTP 400 instead. for model <- Map.keys(overrides) do - assert OpenAICodex.context_window(model, overrides) == 1_000_000 + assert OpenAICodex.context_window(model, overrides) == 872_000 end assert OpenAICodex.context_window("gpt-5.3-codex-spark", overrides) == 128_000 @@ -50,7 +56,16 @@ defmodule OptimalSystemAgent.Providers.CodexContextOverrideTest do assert OpenAICodex.context_window("gpt-6-astra", invalid) == 872_000 end - assert OpenAICodex.context_window("gpt-6-astra", %{"gpt-6-astra" => 9_000_000}) == 1_050_000 + assert OpenAICodex.context_window("gpt-6-astra", %{"gpt-6-astra" => 9_000_000}) == 872_000 + + # The gap that used to slip through: between the transport maximum and the + # published model card, `min/2` against the card alone let the override + # stand and over-budgeted the endpoint by up to 178k tokens. + assert OpenAICodex.context_window("gpt-6-astra", %{"gpt-6-astra" => 900_000}) == 872_000 + + # Clamping is one-directional. An override BELOW the transport maximum is a + # client budget and still applies. + assert OpenAICodex.context_window("gpt-6-astra", %{"gpt-6-astra" => 500_000}) == 500_000 assert OpenAICodex.context_window("gpt-5.3-codex-spark", %{"gpt-5.3-codex-spark" => 1_000_000}) == 128_000 diff --git a/test/providers/codex_fast_tier_test.exs b/test/providers/codex_fast_tier_test.exs new file mode 100644 index 000000000..353f42d0b --- /dev/null +++ b/test/providers/codex_fast_tier_test.exs @@ -0,0 +1,130 @@ +defmodule OptimalSystemAgent.Providers.CodexFastTierTest do + @moduledoc """ + `/fast` against a ChatGPT plan, asserted at the wire. + + The ChatGPT Codex endpoint answers an unsupported request field with an + EMPTY HTTP 400: no code, no message, nothing to match on. `service_tier` is + such a field there even though the public Responses API accepts it, so a + `/fast` turn died at the boundary and, because `/fast` is a persistent + per-session toggle, so did every turn after it. The tier-less retry could not + save the turn either, since the reason string it was handed was `"HTTP 400: "` + and no tier vocabulary appears in that. + + These tests therefore assert the BODY that leaves OSA rather than the opts a + call site assembles: the fallback hop from `openai` to `openai_codex` carries + the tier along too (`Registry.cross_provider_opts/1` drops only `:model`), so + the only claim worth making is about what reaches the endpoint. + """ + + use ExUnit.Case, async: false + + alias OptimalSystemAgent.Agent.Loop.LLMClient + alias OptimalSystemAgent.Auth.SubscriptionStore + alias OptimalSystemAgent.Providers.OpenAICodex + + @ok_response %{ + "output" => [ + %{"type" => "message", "content" => [%{"type" => "output_text", "text" => "ok"}]} + ], + "usage" => %{"input_tokens" => 5, "output_tokens" => 2} + } + + setup do + dir = Path.join(System.tmp_dir!(), "osa-codex-fast-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + prev_home = System.get_env("OSA_HOME") + System.put_env("OSA_HOME", dir) + + on_exit(fn -> + if prev_home, do: System.put_env("OSA_HOME", prev_home), else: System.delete_env("OSA_HOME") + File.rm_rf(dir) + end) + + SubscriptionStore.put("openai_codex", %{ + "access_token" => "tok", + "account_id" => "acct_1", + # An hour of runway, so nothing proactive fires and the test exercises + # the request path rather than the refresh path. + "expires_at" => System.system_time(:second) + 3600, + "base_url" => "https://stub.invalid/backend-api/codex" + }) + + :ok + end + + # Captures the encoded request body and answers with a minimal Responses + # success, so a stripped field shows up as a missing key rather than as a + # transport error that could have any number of causes. + defp capture do + test = self() + + fn conn -> + {:ok, raw, conn} = Plug.Conn.read_body(conn) + send(test, {:wire_body, Jason.decode!(raw)}) + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.resp(200, Jason.encode!(@ok_response)) + end + end + + describe "the Codex request boundary" do + test "a /fast turn puts no service_tier on the wire" do + assert {:ok, %{content: "ok"}} = + OpenAICodex.chat([%{role: "user", content: "hi"}], + service_tier: "priority", + max_tokens: 4096, + req_options: [plug: capture(), retry: false] + ) + + assert_received {:wire_body, body} + refute Map.has_key?(body, "service_tier") + # The field this endpoint was already known to reject the same way. Both + # deletions live in the same place for the same reason. + refute Map.has_key?(body, "max_output_tokens") + assert body["store"] == false + end + + test "the streaming path strips it too" do + assert :ok = + OpenAICodex.chat_stream( + [%{role: "user", content: "hi"}], + fn _event -> :ok end, + service_tier: "priority", + max_tokens: 4096, + req_options: [plug: capture(), retry: false] + ) + + assert_received {:wire_body, body} + refute Map.has_key?(body, "service_tier") + assert body["stream"] == true + end + end + + describe "an empty 4xx is readable as a refused tier" do + test "a 4xx with no detail at all is recognized" do + assert LLMClient.bodyless_client_error?("HTTP 400: ") + assert LLMClient.bodyless_client_error?("HTTP 400:") + assert LLMClient.bodyless_client_error?("HTTP 422: ") + end + + test "a 4xx that explains itself keeps its own meaning" do + refute LLMClient.bodyless_client_error?("HTTP 400: invalid tool schema") + refute LLMClient.bodyless_client_error?("HTTP 403: account suspended") + end + + test "server errors and untagged reasons are left alone" do + refute LLMClient.bodyless_client_error?("HTTP 500: ") + refute LLMClient.bodyless_client_error?("Connection failed: timed out") + refute LLMClient.bodyless_client_error?({:rate_limited, 30}) + refute LLMClient.bodyless_client_error?(nil) + end + + test "the vocabulary matcher still refuses to classify it on its own" do + # Which is why the empty-400 case is read only where a `service_tier` is + # already known to have been attached to the request: on any other + # request that same reason means something else entirely. + refute LLMClient.tier_rejection?("HTTP 400: ") + end + end +end diff --git a/test/providers/fast_service_tier_settings_test.exs b/test/providers/fast_service_tier_settings_test.exs new file mode 100644 index 000000000..2abcc9e41 --- /dev/null +++ b/test/providers/fast_service_tier_settings_test.exs @@ -0,0 +1,98 @@ +defmodule OptimalSystemAgent.Providers.FastServiceTierSettingsTest do + @moduledoc """ + The two readers of `openai_fast_service_tier` have to agree. + + `fast_service_tier?/0` resolves the full settings cascade, while + `fast_service_tier?/1` (the arity the turn path uses) read only the in-memory + session rows. So `openai_fast_service_tier` set in `~/.osa/settings.json` or + in an `OSA_SETTINGS` flag file was on for one reader and off for the other, + which is the worst version of a boolean: a headless run configured for fast + processing quietly did not get it. + """ + + use ExUnit.Case, async: false + + alias OptimalSystemAgent.Agent.Loop.LLMClient + alias OptimalSystemAgent.Settings + + setup do + dir = Path.join(System.tmp_dir!(), "osa-fast-settings-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + + session = "fast-cascade-#{System.unique_integer([:positive])}" + other = "fast-other-#{System.unique_integer([:positive])}" + prev_flag = Application.get_env(:optimal_system_agent, :settings_flag_path) + + on_exit(fn -> + if prev_flag, + do: Application.put_env(:optimal_system_agent, :settings_flag_path, prev_flag), + else: Application.delete_env(:optimal_system_agent, :settings_flag_path) + + Settings.clear_session(session) + Settings.clear_session(other) + # The daemon-wide row too: left behind it would turn fast processing on + # for every session in every test that runs after this one. + Settings.delete_session_for(nil, :openai_fast_service_tier) + Settings.reset_cache() + File.rm_rf(dir) + end) + + # A machine-authored layer (the `--settings` / OSA_SETTINGS path), which is + # the supported way for a headless run to impose policy. + flag = Path.join(dir, "settings.json") + File.write!(flag, Jason.encode!(%{"openai_fast_service_tier" => true})) + + %{dir: dir, flag: flag, session: session, other: other} + end + + defp use_flag_file(flag) do + Application.put_env(:optimal_system_agent, :settings_flag_path, flag) + Settings.reset_cache() + end + + test "a file-layer setting reaches the per-session reader too", ctx do + use_flag_file(ctx.flag) + + assert LLMClient.fast_service_tier?() + assert LLMClient.fast_service_tier?(ctx.session) + + # And therefore reaches the turn path, which is the only reader that + # decides whether a request actually carries a tier. + assert LLMClient.service_tier_for(%{provider: :anthropic, session_id: ctx.session}) == "auto" + end + + test "a session row still shadows the file layer", ctx do + use_flag_file(ctx.flag) + Settings.set_session_for(ctx.session, :openai_fast_service_tier, false) + + refute LLMClient.fast_service_tier?(ctx.session) + assert LLMClient.fast_service_tier?(ctx.other) + end + + test "with no file layer the session rows still decide", ctx do + Application.delete_env(:optimal_system_agent, :settings_flag_path) + Settings.reset_cache() + + refute LLMClient.fast_service_tier?(ctx.session) + assert LLMClient.toggle_fast_service_tier(ctx.session) + assert LLMClient.fast_service_tier?(ctx.session) + refute LLMClient.fast_service_tier?(ctx.other) + end + + test "a toggle with no session id scopes to the caller's own session", ctx do + Application.delete_env(:optimal_system_agent, :settings_flag_path) + Settings.reset_cache() + + # What the turn pipeline publishes for the process it runs the turn on. + Process.put(:osa_session_id, ctx.session) + + assert LLMClient.toggle_fast_service_tier(nil) + assert LLMClient.fast_service_tier?(ctx.session) + + # The point of the guard: a missing id must not write the daemon-wide row + # that every OTHER session resolves underneath its own. + refute LLMClient.fast_service_tier?(ctx.other) + after + Process.delete(:osa_session_id) + end +end diff --git a/test/providers/google_system_hoist_test.exs b/test/providers/google_system_hoist_test.exs index 68def701a..bdd88b5f3 100644 --- a/test/providers/google_system_hoist_test.exs +++ b/test/providers/google_system_hoist_test.exs @@ -47,13 +47,17 @@ defmodule OptimalSystemAgent.Providers.GoogleSystemHoistTest do defp text_of(content), do: Enum.map_join(content["parts"], "", &(&1["text"] || "")) describe "extract_system — leading vs mid-conversation system messages" do - test "priority service tier is placed at the GenerateContent top level" do + test "no service tier is placed on a GenerateContent request" do + # "priority" is OpenAI's tier vocabulary, and neither that value nor the + # `serviceTier` field itself was ever verified against this API. Sending + # it bought a rejected request plus the tier-less retry behind it on + # every `/fast` turn, for acceleration the account never received. body = Google.build_request_body([%{role: "user", content: "hi"}], @model, service_tier: "priority" ) - assert body.serviceTier == "priority" + refute Map.has_key?(body, :serviceTier) refute Map.has_key?(body.generationConfig, :serviceTier) end diff --git a/test/providers/multimodal_request_assembly_test.exs b/test/providers/multimodal_request_assembly_test.exs index e11b9c4c5..c632d5cc2 100644 --- a/test/providers/multimodal_request_assembly_test.exs +++ b/test/providers/multimodal_request_assembly_test.exs @@ -112,13 +112,17 @@ defmodule OptimalSystemAgent.Providers.MultimodalRequestAssemblyTest do # ── Bedrock Converse ────────────────────────────────────────────────────── describe "Bedrock.build_request_body/3" do - test "priority service tier is placed at the Converse top level" do + test "no service tier is placed on a Converse request" do + # `"serviceTier" => %{"type" => tier}` was a guessed shape carrying a + # guessed value: Converse's documented latency knob is `performanceConfig`, + # and "priority" is OpenAI's word rather than anything AWS defines. Until + # a live call confirms both, no tier goes on this body at all. body = Bedrock.build_request_body([%{role: "user", content: "hello"}], "amazon.nova-pro-v1:0", service_tier: "priority" ) - assert body["serviceTier"] == %{"type" => "priority"} + refute Map.has_key?(body, "serviceTier") end test "the image is carried as a Converse image block, not dropped" do diff --git a/test/providers/openai_compat_test.exs b/test/providers/openai_compat_test.exs index 4c20440a6..2775b458a 100644 --- a/test/providers/openai_compat_test.exs +++ b/test/providers/openai_compat_test.exs @@ -845,17 +845,43 @@ defmodule OptimalSystemAgent.Providers.OpenAICompatTest do assert body.service_tier == "flex" end - test "sends service_tier to documented compatible providers" do - body = + test "sends each provider only a tier from its OWN vocabulary" do + groq = OpenAICompat.build_stream_body("llama", @msgs, provider: :groq, service_tier: "auto") + assert groq.service_tier == "auto" + + flex = OpenAICompat.build_stream_body("llama", @msgs, provider: :groq, service_tier: "flex") + assert flex.service_tier == "flex" + end + + test "drops a tier the provider does not define rather than forwarding it" do + # "priority" is OpenAI's word. Groq's vocabulary is + # on_demand/flex/auto/performance, so sending it there bought a rejected + # request plus the tier-less retry behind it on every turn. + groq = + OpenAICompat.build_stream_body("llama", @msgs, provider: :groq, service_tier: "priority") + + refute Map.has_key?(groq, :service_tier) + + # Anthropic's word, on an OpenAI-compatible route. + openai = + OpenAICompat.build_stream_body("gpt-5", @msgs, + provider: :openai, + service_tier: "standard_only" + ) + + refute Map.has_key?(openai, :service_tier) + end + + test "sends no tier to providers with no verified vocabulary" do + # Both were observed rejecting an unknown service_tier with HTTP 422, and + # neither documents a value OSA has checked against the live API. + xai = OpenAICompat.build_stream_body("grok-4.6", @msgs, provider: :xai, service_tier: "priority" ) - assert body.service_tier == "priority" - - groq = OpenAICompat.build_stream_body("llama", @msgs, provider: :groq, service_tier: "auto") - assert groq.service_tier == "auto" + refute Map.has_key?(xai, :service_tier) openrouter = OpenAICompat.build_stream_body("openai/gpt-5", @msgs, @@ -863,7 +889,7 @@ defmodule OptimalSystemAgent.Providers.OpenAICompatTest do service_tier: "priority" ) - assert openrouter.service_tier == "priority" + refute Map.has_key?(openrouter, :service_tier) end test "never sends service_tier to unsupported compatible backends" do diff --git a/test/providers/service_tier_vocabulary_test.exs b/test/providers/service_tier_vocabulary_test.exs new file mode 100644 index 000000000..8a6e91edd --- /dev/null +++ b/test/providers/service_tier_vocabulary_test.exs @@ -0,0 +1,89 @@ +defmodule OptimalSystemAgent.Providers.ServiceTierVocabularyTest do + @moduledoc """ + A processing tier is a per-provider word, not a portable one. + + `priority` is OpenAI's, `performance` is Groq's, `standard_only` is + Anthropic's. The agent loop resolves ONE tier for the turn, so without a + check at each provider boundary one provider's vocabulary is forwarded raw to + another, where an unrecognized `service_tier` is a validation error rather + than an ignored field. The turn then pays for a rejected request plus the + tier-less retry behind it, every turn, for acceleration the account never + receives. + + Anthropic already had the allowlist (see `anthropic_service_tier_test.exs`); + these are the boundaries that did not. + """ + + use ExUnit.Case, async: true + + alias OptimalSystemAgent.Providers.Bedrock + alias OptimalSystemAgent.Providers.Google + alias OptimalSystemAgent.Providers.OpenAICompat + + @msgs [%{role: "user", content: "hi"}] + + describe "OpenAI-compatible routes" do + test "OpenAI keeps its own documented vocabulary" do + for tier <- ["auto", "default", "flex", "priority", "scale"] do + body = + OpenAICompat.build_stream_body("gpt-5", @msgs, provider: :openai, service_tier: tier) + + assert body.service_tier == tier + end + end + + test "Groq keeps the words Groq defines" do + for tier <- ["auto", "on_demand", "flex", "performance"] do + body = OpenAICompat.build_stream_body("llama", @msgs, provider: :groq, service_tier: tier) + + assert body.service_tier == tier + end + end + + test "a tier from another provider is dropped, not forwarded" do + dropped = [ + {:groq, "priority"}, + {:groq, "standard_only"}, + {:openai, "on_demand"}, + {:openai, "performance"}, + {:openai, "standard_only"} + ] + + for {provider, tier} <- dropped do + body = + OpenAICompat.build_stream_body("m", @msgs, provider: provider, service_tier: tier) + + refute Map.has_key?(body, :service_tier), + "#{provider} should not be sent #{tier}" + end + end + + test "providers with no verified vocabulary are sent no tier at all" do + for provider <- [:xai, :openrouter, :lmstudio, :ollama] do + body = + OpenAICompat.build_stream_body("m", @msgs, provider: provider, service_tier: "priority") + + refute Map.has_key?(body, :service_tier) + end + end + end + + describe "providers whose tier wire format was never verified" do + test "Gemini requests carry no serviceTier" do + body = Google.build_request_body(@msgs, "gemini-3-pro", service_tier: "priority") + + refute Map.has_key?(body, :serviceTier) + refute Map.has_key?(body, "serviceTier") + end + + test "Bedrock Converse requests carry no serviceTier" do + body = + Bedrock.build_request_body(@msgs, "anthropic.claude-opus-5", service_tier: "priority") + + refute Map.has_key?(body, "serviceTier") + refute Map.has_key?(body, :serviceTier) + # The rest of the body is untouched by the removal. + assert is_list(body["messages"]) + end + end +end diff --git a/test/scripts/launcher_workspace_isolation_test.exs b/test/scripts/launcher_workspace_isolation_test.exs new file mode 100644 index 000000000..9a64c086f --- /dev/null +++ b/test/scripts/launcher_workspace_isolation_test.exs @@ -0,0 +1,213 @@ +defmodule OptimalSystemAgent.Scripts.LauncherWorkspaceIsolationTest do + @moduledoc """ + bin/osa must never adopt — or kill — another workspace's daemon (#245). + + Auto-isolation remembers the port it picked for a directory in that + workspace's own `run/backend.port`. The file outlives the process it + describes, so a daemon that dies without cleaning up leaves a port NUMBER + behind that the OS is free to hand to the next asker, including a different + workspace's OSA daemon. `bin/osa` used to adopt on "something healthy answers + there", which attached one folder to another folder's backend and let + `osa stop` kill it. + + These tests drive the REAL shell functions, extracted from `bin/osa` at + runtime so they cannot drift from a copy, against a live stub of `GET /health`. + """ + use ExUnit.Case, async: true + + @launcher Path.expand("../../bin/osa", __DIR__) + @http_channel Path.expand("../../lib/optimal_system_agent/channels/http.ex", __DIR__) + + @ownership_fns ~w(_daemon_workspace _same_dir _pidfile_owns_port _stop_target_is_ours) + + # ── The two halves of the contract ────────────────────────────────────── + + test "/health reports the daemon's own launch directory" do + source = File.read!(@http_channel) + + assert source =~ "workspace: OptimalSystemAgent.Workspace.Cwd.original_cwd()", + """ + GET /health must carry the daemon's workspace, or bin/osa's ownership + check below is inert and #245 is only half fixed. + + Add to the Jason.encode! map in `get "/health"`: + + workspace: OptimalSystemAgent.Workspace.Cwd.original_cwd(), + + It must be `original_cwd/0` — the launch directory the daemon was + started with. NOT `Cwd.get/0` (session-scoped, changes per turn) and + NOT `File.cwd!()` (the OSA source tree, because the daemon starts with + `cd "$ROOT"`). + """ + end + + test "adoption requires the daemon to name this workspace" do + source = File.read!(@launcher) + + # Health alone must no longer be sufficient evidence of ownership. + assert source =~ ~S[_ws_owner="$(_daemon_workspace "$_ws_port")"] + assert source =~ ~S[if _same_dir "$_ws_owner" "$OSA_ORIGINAL_CWD"; then] + + # ...and the kill path re-checks rather than trusting that decision. + assert source =~ "_stop_target_is_ours || return 1" + end + + # ── What the helpers actually do ──────────────────────────────────────── + + test "reads the workspace a live daemon reports" do + port = stub_health(~s({"status":"ok","version":"1.0.178","workspace":"/tmp/wsA"})) + assert {"/tmp/wsA", 0} = sh(~s(_daemon_workspace #{port})) + end + + test "reports nothing for a daemon that predates the workspace field" do + port = stub_health(~s({"status":"ok","version":"1.0.177"})) + assert {"", 0} = sh(~s(_daemon_workspace #{port})) + end + + test "reports nothing when the port is dead" do + assert {"", 0} = sh(~s(_daemon_workspace #{free_port()})) + end + + test "path equality tolerates a trailing slash but never an unknown" do + assert {_, 0} = sh(~s(_same_dir /tmp/wsA /tmp/wsA)) + assert {_, 0} = sh(~s(_same_dir /tmp/wsA/ /tmp/wsA)) + assert {_, 0} = sh(~s(_same_dir / /)) + assert {_, 1} = sh(~s(_same_dir /tmp/wsA /tmp/wsB)) + # An unidentified daemon must not be mistaken for ours. + assert {_, 1} = sh(~s(_same_dir "" /tmp/wsA)) + assert {_, 1} = sh(~s(_same_dir "" "")) + end + + # ── The gate in front of the kill ─────────────────────────────────────── + + test "stops our own workspace's daemon" do + port = stub_health(~s({"status":"ok","workspace":"/tmp/wsA"})) + assert {_, 0} = sh(stop_check(port, "/tmp/wsA", auto_ws: 1)) + end + + test "refuses to stop a daemon that names another folder" do + port = stub_health(~s({"status":"ok","workspace":"/tmp/wsA"})) + {out, code} = sh(stop_check(port, "/tmp/wsB", auto_ws: 1)) + + assert code == 1 + assert out =~ "Refusing to stop the backend" + # The message has to name the other folder — otherwise the user has no way + # to tell which of their sessions is holding the port. + assert out =~ "/tmp/wsA" + assert out =~ "cd /tmp/wsA && osa stop" + end + + test "an operator-named port (--dev, OSA_PORT, OSA_HOME) is exempt" do + # `osa stop --dev` must keep stopping :19001 from any directory: that is a + # deliberate cross-workspace stop of a documented single-instance profile. + port = stub_health(~s({"status":"ok","workspace":"/tmp/wsA"})) + assert {_, 0} = sh(stop_check(port, "/tmp/wsB", auto_ws: 0)) + end + + test "a daemon too old to identify itself may still be stopped" do + # Refusing here would leave `osa stop` unable to clean up after any + # pre-1.0.178 daemon. Adoption is where such a daemon has to earn its way in. + port = stub_health(~s({"status":"ok","version":"1.0.177"})) + assert {_, 0} = sh(stop_check(port, "/tmp/wsB", auto_ws: 1)) + end + + test "a dead port has nothing to protect" do + assert {_, 0} = sh(stop_check(free_port(), "/tmp/wsB", auto_ws: 1)) + end + + # ── Fallback proof for daemons that cannot identify themselves ────────── + + test "the recorded pid holding the listener proves the daemon is ours" do + port = stub_health(~s({"status":"ok"})) + dir = tmp_dir() + pidfile = Path.join(dir, "backend.pid") + + # The stub listener is owned by this BEAM, so os_getpid/0 IS the port owner. + File.write!(pidfile, "#{System.pid()}\n") + assert {_, 0} = sh(~s(_pidfile_owns_port #{pidfile} #{port})) + + File.write!(pidfile, "999999\n") + assert {_, 1} = sh(~s(_pidfile_owns_port #{pidfile} #{port})) + + assert {_, 1} = sh(~s(_pidfile_owns_port #{Path.join(dir, "absent.pid")} #{port})) + end + + # ── Harness ───────────────────────────────────────────────────────────── + + defp stop_check(port, cwd, auto_ws: auto) do + ~s(PORT=#{port} OSA_ORIGINAL_CWD=#{cwd} _OSA_AUTO_WS=#{auto} _stop_target_is_ours) + end + + # Run `command` with the ownership helpers lifted verbatim out of bin/osa. + defp sh(command) do + source = File.read!(@launcher) + preamble = "BOLD=; DIM=; YELLOW=; CYAN=; RESET=\n" + fns = Enum.map_join(@ownership_fns, "\n", &shell_fn(source, &1)) + + {out, code} = + System.cmd("bash", ["-c", preamble <> fns <> "\n" <> command], stderr_to_stdout: true) + + {String.trim(out), code} + end + + # A shell function definition from `name() {` to the closing `}` in column 0. + defp shell_fn(source, name) do + lines = String.split(source, "\n") + start = Enum.find_index(lines, &(&1 == "#{name}() {")) + assert start, "bin/osa no longer defines #{name}()" + len = lines |> Enum.drop(start) |> Enum.find_index(&(&1 == "}")) + lines |> Enum.slice(start, len + 1) |> Enum.join("\n") + end + + # Minimal GET /health, on an ephemeral port, for the life of the test. + defp stub_health(body) do + {:ok, listen} = + :gen_tcp.listen(0, [ + :binary, + ip: {127, 0, 0, 1}, + packet: :raw, + active: false, + reuseaddr: true + ]) + + {:ok, port} = :inet.port(listen) + spawn(fn -> serve(listen, body) end) + on_exit(fn -> :gen_tcp.close(listen) end) + port + end + + defp serve(listen, body) do + case :gen_tcp.accept(listen) do + {:ok, socket} -> + _ = :gen_tcp.recv(socket, 0, 2_000) + + :gen_tcp.send(socket, [ + "HTTP/1.1 200 OK\r\n", + "Content-Type: application/json\r\n", + "Content-Length: #{byte_size(body)}\r\n", + "Connection: close\r\n\r\n", + body + ]) + + :gen_tcp.close(socket) + serve(listen, body) + + {:error, _closed} -> + :ok + end + end + + defp free_port do + {:ok, socket} = :gen_tcp.listen(0, ip: {127, 0, 0, 1}) + {:ok, port} = :inet.port(socket) + :gen_tcp.close(socket) + port + end + + defp tmp_dir do + dir = Path.join(System.tmp_dir!(), "osa-ws-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf(dir) end) + dir + end +end