diff --git a/lib/kodo/agent/execution_route_change.ex b/lib/kodo/agent/execution_route_change.ex new file mode 100644 index 0000000..a24e671 --- /dev/null +++ b/lib/kodo/agent/execution_route_change.ex @@ -0,0 +1,72 @@ +defmodule Kodo.Agent.ExecutionRouteChange do + @moduledoc "Validates atomic execution-route changes against approved compatibility records." + + alias Kodo.Agent.ModelMapping + + @routes ~w(openai openai_codex) + @approved_records [] + + @doc "Returns records backed by successful exact-identity compatibility evidence." + def approved_records, do: @approved_records + + def change(mapping, destination, records \\ @approved_records) + + def change(mapping, destination, records) when destination in @routes and is_list(records) do + mapping = ModelMapping.snapshot(mapping) + + affected = + Enum.filter(mapping["roles"], fn {_role, role_mapping} -> + role_mapping["execution_route"] in @routes and + role_mapping["execution_route"] != destination + end) + + with false <- affected == [], + [] <- incompatible_roles(affected, destination, records) do + {:ok, apply_route(mapping, affected, destination)} + else + true -> {:error, :execution_route_unchanged} + roles when is_list(roles) -> {:error, {:incompatible_execution_route, roles}} + end + end + + def change(_mapping, _destination, _records), do: {:error, :unsupported_execution_route} + + defp incompatible_roles(affected, destination, records) do + for {role, role_mapping} <- affected, + not compatible?(role, role_mapping, destination, records), + do: role + end + + defp compatible?(role, role_mapping, destination, records) do + Enum.any?(records, fn record -> + record.role == role and + record.source == role_mapping["execution_route"] and + record.destination == destination and + record.selector == role_mapping["model_selector"] and + record.role_contract == role_mapping["role_contract"] and + record.status == :approved + end) + end + + defp apply_route(mapping, affected, destination) do + affected_roles = MapSet.new(affected, &elem(&1, 0)) + + roles = + Map.new(mapping["roles"], fn {role, role_mapping} -> + if MapSet.member?(affected_roles, role) do + selector = role_mapping["model_selector"] + + {role, + role_mapping + |> Map.put("provider", destination) + |> Map.put("execution_route", destination) + |> Map.put("model", "#{destination}:#{selector}") + |> put_in(["sources", "model"], "session_route_change")} + else + {role, role_mapping} + end + end) + + %{mapping | "roles" => roles} + end +end diff --git a/lib/kodo/agent/loop.ex b/lib/kodo/agent/loop.ex index 181a34d..7e89861 100644 --- a/lib/kodo/agent/loop.ex +++ b/lib/kodo/agent/loop.ex @@ -89,7 +89,7 @@ defmodule Kodo.Agent.Loop do response, calls, adapter, - projection.model_mapping || legacy_mapping(projection.model), + turn_mapping(projection), budgets, ownership ) do @@ -125,8 +125,7 @@ defmodule Kodo.Agent.Loop do end defp review_final_answer(text, response, context) do - mapping = - context.projection.model_mapping || legacy_mapping(context.projection.model) + mapping = turn_mapping(context.projection) primary_invocation_id = response.payload["invocation_id"] @@ -165,10 +164,11 @@ defmodule Kodo.Agent.Loop do defp run_final_review(text, primary_invocation_id, context) do review = ModelMapping.role!(context.mapping, :review) + review_model = ModelMapping.request_model(review) contract = Roles.fetch!(:review, review["role_contract"]) with {:ok, capability_validation} <- - context.adapter.validate_model(review["model"], review, contract), + context.adapter.validate_model(review_model, review, contract), {:ok, request} <- resolve_request(context.session_id, review), :ok <- Phoenix.PubSub.subscribe( @@ -458,9 +458,14 @@ defmodule Kodo.Agent.Loop do end end + defp turn_mapping(projection) do + projection.turn_model_mapping || projection.model_mapping || legacy_mapping(projection.model) + end + defp infer(session_id, projection, events, adapter, budgets, invocation, ownership) do - mapping = projection.model_mapping || legacy_mapping(projection.model) + mapping = turn_mapping(projection) primary = ModelMapping.role!(mapping, :primary) + primary_model = ModelMapping.request_model(primary) contract = Roles.fetch!(:primary, primary["role_contract"]) tools = @@ -472,7 +477,7 @@ defmodule Kodo.Agent.Loop do with :ok <- within_budget(invocation, usage(current_turn(events)), budgets), {:ok, capability_validation} <- - adapter.validate_model(primary["model"], primary, contract), + adapter.validate_model(primary_model, primary, contract), {:ok, request} <- resolve_request(session_id, primary), {:ok, invocation_id} <- start_invocation( @@ -487,7 +492,11 @@ defmodule Kodo.Agent.Loop do :ok <- rehoming_boundary(), {:ok, response} <- Sessions.dispatch_if_owner(ownership, fn -> - generate(adapter, request, transcript(events, contract), tools, + generate( + adapter, + request, + transcript(events, contract, primary["execution_route"] || primary["provider"]), + tools, timeout: budgets[:model_timeout], reasoning: primary["reasoning"] ) @@ -506,7 +515,7 @@ defmodule Kodo.Agent.Loop do end end - defp transcript(events, contract) do + defp transcript(events, contract, current_route) do system = %{"role" => "system", "content" => contract.prompt} primary_invocations = @@ -514,24 +523,54 @@ defmodule Kodo.Agent.Loop do |> Enum.filter(&(&1.type == "model_invocation_started")) |> MapSet.new(& &1.payload["invocation_id"]) + invocation_routes = + events + |> Enum.filter(&(&1.type == "model_invocation_started")) + |> Map.new(&{&1.payload["invocation_id"], &1.payload["provider"]}) + Enum.reduce(events, [system], fn event, messages -> - case transcript_message(event, primary_invocations) do + case transcript_message(event, primary_invocations, invocation_routes, current_route) do nil -> messages message -> messages ++ [message] end end) end - defp transcript_message(%{type: "user_message", payload: payload}, _primary_invocations), - do: %{"role" => "user", "content" => payload["content"]} + defp transcript_message( + %{type: "user_message", payload: payload}, + _primary_invocations, + _invocation_routes, + _current_route + ), + do: %{"role" => "user", "content" => payload["content"]} - defp transcript_message(%{type: "model_response", payload: payload}, _primary_invocations), - do: assistant_message(payload) + defp transcript_message( + %{type: "model_response", payload: payload}, + _primary_invocations, + invocation_routes, + current_route + ) do + if invocation_routes[payload["invocation_id"]] == current_route do + assistant_message(payload) + else + visible_assistant_message(payload) + end + end - defp transcript_message(%{type: "review_feedback", payload: payload}, _primary_invocations), - do: %{"role" => "user", "content" => payload["content"]} + defp transcript_message( + %{type: "review_feedback", payload: payload}, + _primary_invocations, + _invocation_routes, + _current_route + ), + do: %{"role" => "user", "content" => payload["content"]} - defp transcript_message(%{type: type, payload: payload}, primary_invocations) + defp transcript_message( + %{type: type, payload: payload}, + primary_invocations, + _invocation_routes, + _current_route + ) when type in ["tool_completed", "tool_failed"] do if MapSet.member?(primary_invocations, payload["invocation_id"]) do content = @@ -546,9 +585,17 @@ defmodule Kodo.Agent.Loop do end end - defp transcript_message(_event, _primary_invocations), do: nil + defp transcript_message(_event, _primary_invocations, _invocation_routes, _current_route), + do: nil defp assistant_message(%{"assistant" => nil} = payload) do + visible_assistant_message(payload) + end + + defp assistant_message(%{"assistant" => provider_state}), + do: %{"role" => "assistant", "provider_state" => provider_state} + + defp visible_assistant_message(payload) do %{ "role" => "assistant", "content" => payload["text"] || "", @@ -556,9 +603,6 @@ defmodule Kodo.Agent.Loop do } end - defp assistant_message(%{"assistant" => provider_state}), - do: %{"role" => "assistant", "provider_state" => provider_state} - defp start_invocation( session_id, continuation, @@ -809,10 +853,11 @@ defmodule Kodo.Agent.Loop do defp run_search(question, parent_call, context) do search = ModelMapping.role!(context.mapping, :search) + search_model = ModelMapping.request_model(search) contract = Roles.fetch!(:search, search["role_contract"]) with {:ok, capability_validation} <- - context.adapter.validate_model(search["model"], search, contract) do + context.adapter.validate_model(search_model, search, contract) do state = %{ parent_call: parent_call, search: search, @@ -1634,7 +1679,8 @@ defmodule Kodo.Agent.Loop do defp resolve_request(session_id, role) do with {:ok, scope} <- Sessions.owner_scope(session_id), - {:ok, model, reference} <- LLM.resolve_integration(scope, role["model"]) do + {:ok, model, reference} <- + LLM.resolve_integration(scope, ModelMapping.request_model(role)) do {:ok, %{scope: scope, model: model, reference: reference}} end end diff --git a/lib/kodo/agent/model_mapping.ex b/lib/kodo/agent/model_mapping.ex index 0413f10..9c2f9fa 100644 --- a/lib/kodo/agent/model_mapping.ex +++ b/lib/kodo/agent/model_mapping.ex @@ -60,6 +60,53 @@ defmodule Kodo.Agent.ModelMapping do def normalize(_mapping), do: balanced() + @doc "Freezes explicit route and selector identity into every role for durable turn replay." + def snapshot(mapping) do + mapping = normalize(mapping) + + roles = + Map.new(mapping["roles"], fn {role, role_mapping} -> + contract = Roles.fetch!(role_atom(role), role_mapping["role_contract"]) + + {role, + role_mapping + |> Map.put("execution_route", role_mapping["provider"]) + |> Map.put("model_selector", model_selector(role_mapping["model"])) + |> Map.put("capability_contract", capability_contract(contract))} + end) + + %{mapping | "roles" => roles} + end + + @doc "Builds the provider-specific request model from an immutable route snapshot." + def request_model(%{"execution_route" => route, "model_selector" => selector}) + when is_binary(route) and route != "" and is_binary(selector) and selector != "" do + "#{route}:#{selector}" + end + + def request_model(%{"model" => model}), do: model + + defp capability_contract(contract) do + %{ + "id" => contract.id, + "toolset_version" => contract.toolset_version, + "requirements" => %{ + "tools" => contract.capabilities.tools, + "structured_output" => stringify(contract.capabilities.structured_output), + "min_context" => contract.capabilities.min_context, + "input_modalities" => Enum.map(contract.capabilities.input_modalities, &to_string/1) + } + } + end + + defp role_atom("primary"), do: :primary + defp role_atom("search"), do: :search + defp role_atom("review"), do: :review + + defp stringify(value) when is_boolean(value), do: value + defp stringify(value) when is_atom(value), do: Atom.to_string(value) + defp stringify(value), do: value + defp normalize_role(current, persisted) do normalized = Enum.reduce(["model", "reasoning"], current, fn field, mapping -> @@ -124,4 +171,11 @@ defmodule Kodo.Agent.ModelMapping do {:error, _reason} -> nil end end + + defp model_selector(model) do + case String.split(model, ":", parts: 2) do + [_provider, selector] -> selector + [selector] -> selector + end + end end diff --git a/lib/kodo/integrations.ex b/lib/kodo/integrations.ex index 4070e2b..bb3959c 100644 --- a/lib/kodo/integrations.ex +++ b/lib/kodo/integrations.ex @@ -90,6 +90,45 @@ defmodule Kodo.Integrations do end end + @doc false + def admit_execution_route_change(%Scope{user: user}, provider) do + integration = + Integration + |> where( + [integration], + integration.user_id == ^user.id and integration.provider == ^provider and + integration.active + ) + |> lock("FOR UPDATE") + |> Repo.one() + + case integration do + %Integration{ + connection_status: "connected", + validation_status: status, + encrypted_credentials: encrypted + } + when status != "invalid" and not is_nil(encrypted) -> + {:ok, integration} + + %Integration{} -> + {:error, :destination_integration_unavailable} + + nil -> + {:error, :destination_integration_required} + end + end + + @doc false + def audit_execution_route_change( + %Scope{user: user}, + %Integration{user_id: user_id} = integration + ) + when user.id == user_id do + audit!(user.id, integration, "execution_route_changed") + :ok + end + def list_audit_events(%Scope{user: user}) do AuditEvent |> where([event], event.actor_user_id == ^user.id) diff --git a/lib/kodo/integrations/audit_event.ex b/lib/kodo/integrations/audit_event.ex index b1e3fdd..fe72589 100644 --- a/lib/kodo/integrations/audit_event.ex +++ b/lib/kodo/integrations/audit_event.ex @@ -21,6 +21,7 @@ defmodule Kodo.Integrations.AuditEvent do refresh_invalid_grant refresh_reauthorization_required refresh_failed + execution_route_changed ) @primary_key {:id, :binary_id, autogenerate: true} diff --git a/lib/kodo/sessions.ex b/lib/kodo/sessions.ex index 6f16772..4e0b9f2 100644 --- a/lib/kodo/sessions.ex +++ b/lib/kodo/sessions.ex @@ -4,20 +4,23 @@ defmodule Kodo.Sessions do import Ecto.Query alias Kodo.Accounts.Scope + alias Kodo.Accounts.User + alias Kodo.Agent.ExecutionRouteChange + alias Kodo.Agent.ModelMapping + alias Kodo.Agent.ModelSettings alias Kodo.Cluster.Discovery alias Kodo.Cluster.InstanceManager alias Kodo.Cluster.Instances alias Kodo.Cluster.Placement alias Kodo.ControlPlaneTelemetry - alias Kodo.Agent.ModelSettings - alias Kodo.Accounts.User + alias Kodo.Integrations alias Kodo.LLM.ProviderError alias Kodo.Repo + alias Kodo.Runners.Runner alias Kodo.Sessions.Event alias Kodo.Sessions.Ownership alias Kodo.Sessions.Projection alias Kodo.Sessions.Session - alias Kodo.Runners.Runner @before_first_event_sequence 0 @initial_event_version 1 @@ -795,6 +798,20 @@ defmodule Kodo.Sessions do do_create_session(scope.user, attrs) end + @doc "Queues an approved billing-route revision for turns accepted after this event." + def change_execution_route(%Scope{} = scope, session_id, destination) do + case Repo.transaction(fn -> + change_execution_route_locked(scope, session_id, destination) + end) do + {:ok, event} = result -> + broadcast(event) + result + + error -> + error + end + end + defp do_create_session(user, attrs) do request_id = attrs[:client_request_id] || attrs["client_request_id"] @@ -981,7 +998,17 @@ defmodule Kodo.Sessions do %{"role" => "user", "content" => content} |> maybe_put_request_id(client_request_id) - with {:ok, message} <- + route_snapshot = turn_route_snapshot(session) + + with {:ok, snapshot} <- + append_locked( + session, + "turn_route_snapshot", + route_snapshot, + source: "system" + ), + session = %{session | next_event_sequence: session.next_event_sequence + 1}, + {:ok, message} <- append_locked( session, "user_message", @@ -992,13 +1019,43 @@ defmodule Kodo.Sessions do {:ok, session} <- session |> Session.status_changeset("running") |> Repo.update(), {:ok, status_event} <- append_locked(session, "session_status_changed", %{"status" => "running"}, []) do - [message, status_event] + [snapshot, message, status_event] else {:error, changeset} -> Repo.rollback(changeset) end end end + defp change_execution_route_locked(scope, session_id, destination) do + session = lock_user_session!(scope, session_id) + projection = session.id |> events_after() |> Projection.from_events() + + mapping = + projection.model_mapping || + ModelMapping.balanced([ + {"session", %{primary: %{model: session.model}}} + ]) + + with {:ok, changed_mapping} <- ExecutionRouteChange.change(mapping, destination), + {:ok, integration} <- Integrations.admit_execution_route_change(scope, destination), + {:ok, event} <- + append_locked( + session, + "execution_route_changed", + %{ + "route_revision" => projection.route_revision + 1, + "destination" => destination, + "model_mapping" => changed_mapping + }, + source: "user" + ), + :ok <- Integrations.audit_execution_route_change(scope, integration) do + event + else + {:error, reason} -> Repo.rollback(reason) + end + end + defp turn_request_recorded?(_session_id, request_id) when not is_binary(request_id), do: false defp turn_request_recorded?(session_id, request_id) do @@ -1016,6 +1073,21 @@ defmodule Kodo.Sessions do defp maybe_put_request_id(payload, _request_id), do: payload + defp turn_route_snapshot(session) do + projection = session.id |> events_after() |> Projection.from_events() + + mapping = + projection.model_mapping || + ModelMapping.balanced([ + {"session", %{primary: %{model: session.model}}} + ]) + + %{ + "route_revision" => projection.route_revision, + "model_mapping" => ModelMapping.snapshot(mapping) + } + end + defp append_events_locked(session_id, event_specs) do ownership = event_specs @@ -1231,6 +1303,7 @@ defmodule Kodo.Sessions do "runner_id" => session.runner_id, "model" => session.model, "model_mapping" => model_mapping, + "route_revision" => 1, "approval_policy" => session.approval_policy, "status" => session.status }, @@ -1245,16 +1318,16 @@ defmodule Kodo.Sessions do defp resolve_session_model(attrs, override_layers) do case fetch_attr(attrs, :model) do :error -> - mapping = Kodo.Agent.ModelMapping.balanced(override_layers) - primary = Kodo.Agent.ModelMapping.role!(mapping, :primary) + mapping = ModelMapping.balanced(override_layers) + primary = ModelMapping.role!(mapping, :primary) {mapping, put_session_model(attrs, primary["model"])} {:ok, model} when is_binary(model) and model != "" -> layers = override_layers ++ [{"session", %{primary: %{model: model}}}] - {Kodo.Agent.ModelMapping.balanced(layers), attrs} + {ModelMapping.balanced(layers), attrs} {:ok, _invalid} -> - {Kodo.Agent.ModelMapping.balanced(override_layers), attrs} + {ModelMapping.balanced(override_layers), attrs} end end @@ -1314,6 +1387,23 @@ defmodule Kodo.Sessions do end end + defp lock_user_session!(%Scope{user: user}, session_id) do + case Ecto.UUID.cast(session_id) do + {:ok, session_id} -> + Session + |> where([session], session.id == ^session_id and session.user_id == ^user.id) + |> lock("FOR UPDATE") + |> Repo.one() + |> case do + %Session{} = session -> session + nil -> Repo.rollback(:session_not_found) + end + + :error -> + Repo.rollback(:session_not_found) + end + end + defp owner_alive?(nil), do: true defp owner_alive?(%Ownership{owner_boot_id: nil}), do: true diff --git a/lib/kodo/sessions/projection.ex b/lib/kodo/sessions/projection.ex index d935aff..8d25fb7 100644 --- a/lib/kodo/sessions/projection.ex +++ b/lib/kodo/sessions/projection.ex @@ -11,6 +11,9 @@ defmodule Kodo.Sessions.Projection do :runner_id, :model, :model_mapping, + route_revision: 1, + turn_route_revision: nil, + turn_model_mapping: nil, approval_policy: "standard", status: "idle", pending_approval_id: nil, @@ -70,11 +73,28 @@ defmodule Kodo.Sessions.Projection do runner_id: payload["runner_id"], model: payload["model"], model_mapping: normalize_model_mapping(payload["model_mapping"]), + route_revision: payload["route_revision"] || 1, approval_policy: payload["approval_policy"] || "standard", status: payload["status"] } end + defp reduce(projection, "execution_route_changed", payload) do + %{ + projection + | model_mapping: normalize_model_mapping(payload["model_mapping"]), + route_revision: payload["route_revision"] + } + end + + defp reduce(projection, "turn_route_snapshot", payload) do + %{ + projection + | turn_model_mapping: normalize_model_mapping(payload["model_mapping"]), + turn_route_revision: payload["route_revision"] + } + end + defp reduce(projection, "session_status_changed", payload) do %{projection | status: payload["status"]} end diff --git a/rfd/0005/IMPLEMENTATION.org b/rfd/0005/IMPLEMENTATION.org index 93f7dd1..10f72c5 100644 --- a/rfd/0005/IMPLEMENTATION.org +++ b/rfd/0005/IMPLEMENTATION.org @@ -397,15 +397,18 @@ manufactured after that no-go decision. compatibility record for its capability contract; show billing confirmation rather than requiring sudo, and reject the whole atomic role set when any role is incompatible. Emit a credential-free route-change audit event. -- [ ] Under the existing durable session lock, assign each accepted turn a route + The durable change boundary and audit event are implemented, but no interaction + is exposed because the live ~gpt-5.4~ record is explicitly rejected: Platform + returned ~gpt-5.4-2026-03-05~ while ChatGPT returned ~gpt-5.4~. +- [X] Under the existing durable session lock, assign each accepted turn a route revision and persist its complete per-role model, execution-route, and capability-contract snapshot. Write confirmed route changes as pending revisions under the same lock and apply them only to later accepted turns. -- [ ] Use the persisted turn snapshot for primary, search, review, every tool +- [X] Use the persisted turn snapshot for primary, search, review, every tool continuation, and process/node restart recovery; replay normalized visible assistant/tool history after a route change without provider-specific reasoning, signature, or cache state. -- [ ] Route requests whose persisted turn snapshot selects ~openai_codex~ only +- [X] Route requests whose persisted turn snapshot selects ~openai_codex~ only through the user's ChatGPT integration and prohibit silent fallback to OpenAI API billing. - [ ] Test authorization ownership and expiry, polling backoff, two-node refresh @@ -427,7 +430,7 @@ manufactured after that no-go decision. cross-route replay, no automatic route fallback, same-origin and cross-origin redirect rejection, malicious verification URLs, absence of a credential-bearing follow-up request, and secret redaction. -- [ ] Run the Phase 0 live compatibility suite against the production path and +- [X] Run the Phase 0 live compatibility suite against the production path and do not declare ChatGPT/Codex implementation complete while a required capability fails; classify later contract failures as regressions and never fall back to Platform billing. diff --git a/test/kodo/agent/execution_route_change_test.exs b/test/kodo/agent/execution_route_change_test.exs new file mode 100644 index 0000000..c35a952 --- /dev/null +++ b/test/kodo/agent/execution_route_change_test.exs @@ -0,0 +1,66 @@ +defmodule Kodo.Agent.ExecutionRouteChangeTest do + use ExUnit.Case, async: true + + alias Kodo.Agent.ExecutionRouteChange + alias Kodo.Agent.ModelMapping + + test "rejects the live-tested gpt-5.4 route pair without an approved identity record" do + mapping = mapping_for_selector("gpt-5.4") + + assert {:error, {:incompatible_execution_route, ["primary", "review", "search"]}} = + ExecutionRouteChange.change(mapping, "openai_codex") + + assert ModelMapping.role!(mapping, :primary)["provider"] == "openai" + end + + test "changes every affected role atomically when all exact selectors are approved" do + mapping = mapping_for_selector("gpt-4o-mini") + records = records_for(mapping, "openai_codex") + + assert {:ok, changed} = + ExecutionRouteChange.change(mapping, "openai_codex", records) + + for role <- [:primary, :search, :review] do + role_mapping = ModelMapping.role!(changed, role) + assert role_mapping["model"] == "openai_codex:gpt-4o-mini" + assert role_mapping["model_selector"] == "gpt-4o-mini" + assert role_mapping["execution_route"] == "openai_codex" + assert role_mapping["role_contract"] == "alpha-v1" + end + end + + test "rejects the whole role set when one role has no approved record" do + mapping = mapping_for_selector("gpt-4o-mini") + records = records_for(mapping, "openai_codex") |> tl() + + assert {:error, {:incompatible_execution_route, [missing_role]}} = + ExecutionRouteChange.change(mapping, "openai_codex", records) + + assert missing_role in ~w(primary review search) + assert ModelMapping.role!(mapping, :primary)["provider"] == "openai" + end + + defp mapping_for_selector(selector) do + ModelMapping.balanced([ + {"test", + %{ + primary: %{model: "openai:#{selector}"}, + search: %{model: "openai:#{selector}"}, + review: %{model: "openai:#{selector}"} + }} + ]) + end + + defp records_for(mapping, destination) do + Enum.map(mapping["roles"], fn {role, role_mapping} -> + %{ + role: role, + source: "openai", + destination: destination, + selector: role_mapping["model"] |> String.split(":", parts: 2) |> List.last(), + role_contract: role_mapping["role_contract"], + status: :approved + } + end) + end +end diff --git a/test/kodo/agent/loop_test.exs b/test/kodo/agent/loop_test.exs index 164cc94..dee42bf 100644 --- a/test/kodo/agent/loop_test.exs +++ b/test/kodo/agent/loop_test.exs @@ -92,6 +92,125 @@ defmodule Kodo.Agent.LoopTest do refute Enum.any?(Sessions.events_after(session.id), &(&1.type == "model_invocation_started")) end + test "never falls back to Platform billing for a ChatGPT turn snapshot", %{ + session: session, + ownership: ownership + } do + [created] = Sessions.events_after(session.id) + + mapping = + update_in(created.payload["model_mapping"], ["roles"], fn roles -> + Map.new(roles, fn {role, role_mapping} -> + {role, + role_mapping + |> Map.put("execution_route", "openai_codex") + |> Map.put("model_selector", "gpt-5.4")} + end) + end) + + {:ok, _snapshot} = + Sessions.append_event( + session.id, + "turn_route_snapshot", + %{"route_revision" => 2, "model_mapping" => mapping}, + source: "system", + ownership: ownership + ) + + {:ok, _message} = + Sessions.append_event( + session.id, + "user_message", + %{"role" => "user", "content" => "final answer"}, + ownership: ownership + ) + + assert {:error, %Kodo.LLM.ProviderError{} = error} = + Loop.run(session.id, + adapter: Kodo.Test.FakeLLM, + budgets: budgets([]), + ownership: ownership + ) + + assert error.kind == :integration_required + assert error.provider == "openai_codex" + + refute Enum.any?(Sessions.events_after(session.id), &(&1.type == "model_invocation_started")) + end + + test "drops provider-private assistant state when replay crosses execution routes", %{ + runner: runner, + session: session, + ownership: ownership + } do + previous_test_pid = Application.get_env(:kodo, :fake_llm_test_pid) + Application.put_env(:kodo, :fake_llm_test_pid, self()) + on_exit(fn -> restore_env(:fake_llm_test_pid, previous_test_pid) end) + {:ok, _registration} = Registry.register(Kodo.RunnerRegistry, runner.id, nil) + old_invocation_id = Ecto.UUID.generate() + + {:ok, _invocation} = + Sessions.append_event( + session.id, + "model_invocation_started", + %{"invocation_id" => old_invocation_id, "provider" => "openai_codex"}, + ownership: ownership + ) + + {:ok, _response} = + Sessions.append_event( + session.id, + "model_response", + %{ + "invocation_id" => old_invocation_id, + "text" => "Visible prior answer", + "tool_calls" => [], + "assistant" => %{ + "reasoning_signature" => "provider-private-signature", + "cache_state" => "provider-private-cache" + } + }, + ownership: ownership + ) + + {:ok, _message} = + Sessions.append_event( + session.id, + "user_message", + %{"role" => "user", "content" => "capture route replay"}, + ownership: ownership + ) + + loop = + Task.async(fn -> + Loop.run(session.id, + adapter: Kodo.Test.FakeLLM, + budgets: budgets([]), + ownership: ownership + ) + end) + + assert_receive {:llm_messages, messages} + + assert %{ + "role" => "assistant", + "content" => "Visible prior answer", + "tool_calls" => [] + } in messages + + refute inspect(messages) =~ "provider-private" + + assert_receive {:tool_request, review_request} + + broadcast_success(runner, review_request, %{ + "result" => "output", + "content" => "clean diff", + "truncated" => false + }) + + assert {:ok, "The fix is complete."} = Task.await(loop) + end + test "records the resolved role and model mapping for an invocation", %{ session: session, ownership: ownership diff --git a/test/kodo/sessions_test.exs b/test/kodo/sessions_test.exs index 973c187..ca98e7c 100644 --- a/test/kodo/sessions_test.exs +++ b/test/kodo/sessions_test.exs @@ -1,7 +1,9 @@ defmodule Kodo.SessionsTest do use Kodo.DataCase + alias Kodo.Agent.ExecutionRouteChange alias Kodo.Cluster.Instances + alias Kodo.Integrations alias Kodo.Runners alias Kodo.Sessions alias Kodo.Sessions.Session @@ -317,8 +319,36 @@ defmodule Kodo.SessionsTest do }) request_id = Ecto.UUID.generate() - assert {:ok, [message, status]} = Sessions.begin_turn(session.id, "Fix it", request_id) + + assert {:ok, [snapshot, message, status]} = + Sessions.begin_turn(session.id, "Fix it", request_id) + + assert snapshot.type == "turn_route_snapshot" + assert snapshot.source == "system" + assert snapshot.payload["route_revision"] == 1 + + assert snapshot.payload["model_mapping"]["roles"]["primary"]["model"] == + "openai:gpt-4o-mini" + + assert snapshot.payload["model_mapping"]["roles"]["primary"]["execution_route"] == + "openai" + + assert snapshot.payload["model_mapping"]["roles"]["primary"]["model_selector"] == + "gpt-4o-mini" + + assert snapshot.payload["model_mapping"]["roles"]["primary"]["capability_contract"] == %{ + "id" => "alpha-v1", + "toolset_version" => "workspace-v5", + "requirements" => %{ + "tools" => true, + "structured_output" => false, + "min_context" => 100_000, + "input_modalities" => ["text"] + } + } + assert message.type == "user_message" + assert message.sequence == snapshot.sequence + 1 assert status.type == "session_status_changed" assert status.sequence == message.sequence + 1 assert Sessions.get_session!(session.id).status == "running" @@ -328,6 +358,115 @@ defmodule Kodo.SessionsTest do assert Enum.count(Sessions.events_after(session.id), &(&1.type == "user_message")) == 1 end + test "rejects route changes without approved exact-identity evidence", %{ + runner: runner, + scope: scope + } do + {:ok, session} = + Sessions.create_session(scope, %{ + runner_id: runner.id, + title: "No compatible route", + model: "openai:gpt-5.4" + }) + + assert {:error, {:incompatible_execution_route, ["primary", "review", "search"]}} = + Sessions.change_execution_route(scope, session.id, "openai_codex") + + refute Enum.any?( + Sessions.events_after(session.id), + &(&1.type == "execution_route_changed") + ) + + assert Integrations.list_audit_events(scope) == [] + end + + test "does not reveal another user's session through route changes", %{ + runner: runner, + scope: scope + } do + other_scope = user_scope_fixture() + + {:ok, session} = + Sessions.create_session(scope, %{ + runner_id: runner.id, + title: "Private route", + model: "openai:gpt-5.4" + }) + + assert {:error, :session_not_found} = + Sessions.change_execution_route(other_scope, session.id, "openai_codex") + + refute Enum.any?( + Sessions.events_after(session.id), + &(&1.type == "execution_route_changed") + ) + end + + test "applies a persisted route revision only to subsequently accepted turns", %{ + runner: runner, + scope: scope + } do + {:ok, session} = + Sessions.create_session(scope, %{ + runner_id: runner.id, + title: "Pending route", + model: "openai_codex:gpt-5.4" + }) + + {:ok, [first_snapshot, _message, _status]} = Sessions.begin_turn(session.id, "First") + + records = [ + %{ + role: "primary", + source: "openai_codex", + destination: "openai", + selector: "gpt-5.4", + role_contract: "alpha-v1", + status: :approved + } + ] + + [created | _events] = Sessions.events_after(session.id) + + assert {:ok, changed_mapping} = + ExecutionRouteChange.change( + created.payload["model_mapping"], + "openai", + records + ) + + assert {:ok, route_event} = + Sessions.append_event( + session.id, + "execution_route_changed", + %{ + "route_revision" => 2, + "destination" => "openai", + "model_mapping" => changed_mapping + }, + source: "user" + ) + + assert first_snapshot.payload["route_revision"] == 1 + + assert get_in(first_snapshot.payload, ["model_mapping", "roles", "primary", "execution_route"]) == + "openai_codex" + + assert route_event.payload["route_revision"] == 2 + assert route_event.source == "user" + + assert {:ok, {_session, _event}} = Sessions.set_status(session.id, "idle") + assert {:ok, [second_snapshot, _message, _status]} = Sessions.begin_turn(session.id, "Second") + assert second_snapshot.payload["route_revision"] == 2 + + assert get_in(second_snapshot.payload, [ + "model_mapping", + "roles", + "primary", + "execution_route" + ]) == "openai" + end + test "advances ownership epochs and fences every stale state mutation", %{ runner: runner, scope: scope diff --git a/test/support/fake_llm.ex b/test/support/fake_llm.ex index 84ccc54..a71a8d2 100644 --- a/test/support/fake_llm.ex +++ b/test/support/fake_llm.ex @@ -53,6 +53,11 @@ defmodule Kodo.Test.FakeLLM do send(test_pid, {:llm_request, model, hd(messages), tools, opts}) end + if last["content"] == "capture route replay" do + test_pid = Application.fetch_env!(:kodo, :fake_llm_test_pid) + send(test_pid, {:llm_messages, messages}) + end + cond do last["content"] == "retry provider transcript" -> test_pid = Application.fetch_env!(:kodo, :fake_llm_test_pid) @@ -106,6 +111,7 @@ defmodule Kodo.Test.FakeLLM do end defp initial(%{"content" => "capture contract"}), do: final("The fix is complete.") + defp initial(%{"content" => "capture route replay"}), do: final("The fix is complete.") defp initial(%{"content" => "final answer"}), do: final("Ready for review.") defp initial(%{"content" => "force final turn"}) do