defmodule OpenAgentsWeb.ThreadControllerTest do
@moduledoc """
The three routes that open, read, and revoke a thread.
A thread is the unit of agent work (`docs/taxonomy.md`), and these routes are
the only way a caller reaches one. They are also where the abuse controls
live, so the refusals are tested as carefully as the successes.
"""
use OpenAgentsWeb.ConnCase, async: false
alias OpenAgents.Inference
alias OpenAgents.Inference.Credit
alias OpenAgents.Inference.Grant
alias OpenAgents.Inference.{Models, Pricing}
alias OpenAgents.Repo
alias OpenAgents.Threads
alias OpenAgents.UnpricedLane
# `gpt-5.6-luna` was the shipped unpriced lane until it was withdrawn. What it
# demonstrated is unchanged, so the lane is admitted for one test at a time.
defp admit_unpriced_lane do
previous = UnpricedLane.admit!()
on_exit(fn -> UnpricedLane.restore(previous) end)
UnpricedLane.id()
end
describe "POST /api/v1/threads" do
test "opens a thread and returns a grant that names it", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-open")
|> post(~p"/api/v1/threads", %{"objective" => "Rename the fence."})
|> json_response(201)
assert %{"thread" => thread, "grant" => grant} = body
assert thread["status"] == "open"
assert thread["objective"] == "Rename the fence."
assert thread["generation"] == 1
assert is_binary(thread["id"])
assert String.starts_with?(grant["token"], "sig_")
assert grant["url"] =~ "/api/inference/proxy"
assert grant["limits"]["max_calls"] == Threads.ceilings().max_calls
assert grant["limits"]["max_total_tokens"] == Threads.ceilings().max_total_tokens
# The cost ceiling is what this account has left of its credit, so
# opening another thread does not mint another allowance.
minted = Repo.get_by!(Grant, thread_id: thread["id"])
assert grant["limits"]["max_cost_microusd"] == Credit.remaining(minted.owner_visitor_id)
assert minted.conversation_id == nil
assert minted.status == "active"
end
test "the thread's ceilings are its own, not the delegation ceilings", %{conn: conn} do
grant =
conn
|> put_chat_api_token("thread-ceilings")
|> post(~p"/api/v1/threads", %{"objective" => "Measure the budget."})
|> json_response(201)
|> Map.fetch!("grant")
delegation = %{
"max_calls" => Application.fetch_env!(:openagents, :inference_grant_max_calls),
"max_total_tokens" =>
Application.fetch_env!(:openagents, :inference_grant_max_total_tokens),
"max_cost_microusd" =>
Application.fetch_env!(:openagents, :inference_grant_max_cost_microusd)
}
refute grant["limits"] == delegation
end
test "a caller may narrow the admitted execution shape", %{conn: conn} do
thread =
conn
|> put_chat_api_token("thread-shape")
|> post(~p"/api/v1/threads", %{
"objective" => "Edit the file.",
"reasoning" => "low",
"permission_profile" => "workspace_write"
})
|> json_response(201)
|> Map.fetch!("thread")
assert thread["reasoning_effort"] == "low"
assert thread["permission_profile"] == "workspace_write"
end
test "records the repository the opener names and returns it", %{conn: conn} do
thread =
conn
|> put_chat_api_token("thread-repository")
|> post(~p"/api/v1/threads", %{
"objective" => "openagents coder in OpenAgentsInc/openagents.com on main",
"repository" => "OpenAgentsInc/openagents.com"
})
|> json_response(201)
|> Map.fetch!("thread")
assert thread["repository"] == "OpenAgentsInc/openagents.com"
# No foreign key and no format rule: a thread may concern a repository
# the forge does not host, so the recorded string is the opener's own.
assert Repo.get!(OpenAgents.Threads.Thread, thread["id"]).repository ==
"OpenAgentsInc/openagents.com"
end
test "a thread without a repository records none and reports null", %{conn: conn} do
thread =
conn
|> put_chat_api_token("thread-no-repository")
|> post(~p"/api/v1/threads", %{"objective" => "No repository named."})
|> json_response(201)
|> Map.fetch!("thread")
assert Map.fetch!(thread, "repository") == nil
end
test "a blank repository is refused rather than recorded as noise", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-blank-repository")
|> post(~p"/api/v1/threads", %{"objective" => "Blank it.", "repository" => " "})
|> json_response(422)
assert body["code"] == "validation_failed"
assert Map.has_key?(body["errors"], "repository")
end
test "a repository over the bound is refused", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-long-repository")
|> post(~p"/api/v1/threads", %{
"objective" => "Bound it.",
"repository" => String.duplicate("a", 201)
})
|> json_response(422)
assert Map.has_key?(body["errors"], "repository")
end
test "a caller may open a thread on another routed model", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-glm-5.3-flash")
|> post(~p"/api/v1/threads", %{
"objective" => "Delegate the edit.",
"model" => "glm-5.3-flash"
})
|> json_response(201)
assert body["grant"]["model"] == "glm-5.3-flash"
end
test "a thread names the default model when its caller names none", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-default-model")
|> post(~p"/api/v1/threads", %{"objective" => "Take the default."})
|> json_response(201)
assert body["grant"]["model"] == OpenAgents.Inference.Models.default_id()
end
test "a catalog model whose provider is not configured is refused, never substituted",
%{conn: conn} do
# Both shipped models sit on the Vercel gateway, so taking that lane down
# takes the whole catalog with it and the refusal would have nothing left
# to name. A lane on a second adapter is admitted first, which is what
# makes "refused, never substituted" a claim with teeth: there is another
# model available and the request still does not get it.
_survivor = admit_unpriced_lane()
previous = Application.get_env(:openagents, :vercel_gateway_provider)
Application.put_env(
:openagents,
:vercel_gateway_provider,
OpenAgents.Providers.UnconfiguredTestProvider
)
on_exit(fn -> Application.put_env(:openagents, :vercel_gateway_provider, previous) end)
body =
conn
|> put_chat_api_token("thread-unavailable-model")
|> post(~p"/api/v1/threads", %{
"objective" => "Ask for the unconfigured lane.",
"model" => "glm-5.3-flash"
})
|> json_response(503)
assert body["code"] == "model_unavailable"
assert Map.has_key?(body["errors"], "model")
# The refusal names what is currently available. Unconfiguring this lane
# takes every model on it, which is more than one now, so the check is
# that each surviving model is named rather than that the default is —
# the default may be on the lane that just went dark.
available =
Enum.filter(OpenAgents.Inference.Models.all(), &OpenAgents.Inference.Models.available?/1)
assert available != []
for model <- available, do: assert(body["message"] =~ model.id)
end
test "a model the proxy cannot route is refused, naming the field", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-bad-model")
|> post(~p"/api/v1/threads", %{
"objective" => "Ask for the impossible.",
"model" => "attacker/gpt-9-ultra"
})
|> json_response(422)
assert body["code"] == "validation_failed"
assert Map.has_key?(body["errors"], "model")
end
test "an objective is required", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-no-objective")
|> post(~p"/api/v1/threads", %{})
|> json_response(422)
assert body["code"] == "validation_failed"
assert Map.has_key?(body["errors"], "objective")
end
test "a reasoning effort outside the enum is refused rather than replaced", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-bad-reasoning")
|> post(~p"/api/v1/threads", %{
"objective" => "Ask for the impossible.",
"reasoning" => "not-a-legal-value"
})
|> json_response(422)
assert body["code"] == "validation_failed"
assert Map.has_key?(body["errors"], "reasoning")
end
test "a permission profile outside the enum is refused", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-bad-profile")
|> post(~p"/api/v1/threads", %{
"objective" => "Ask for the impossible.",
"permission_profile" => "root"
})
|> json_response(422)
assert Map.has_key?(body["errors"], "permission_profile")
end
test "opening more concurrent threads than the cap allows is refused", %{conn: conn} do
limit = 2
previous = Application.get_env(:openagents, :maximum_open_threads_per_account)
Application.put_env(:openagents, :maximum_open_threads_per_account, limit)
on_exit(fn ->
Application.put_env(:openagents, :maximum_open_threads_per_account, previous)
end)
authenticated = put_chat_api_token(conn, "thread-cap")
for index <- 1..limit do
assert authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Concurrent #{index}."})
|> json_response(201)
end
body =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "One too many."})
|> json_response(429)
assert body["code"] == "thread_quota_reached"
assert body["message"] =~ "#{limit}"
assert [message] = body["errors"]["threads"]
assert message =~ "#{limit}"
end
# An account that has spent its credit has nothing to mint a grant against,
# and a thread without authority is not a thread anyone can work, so the
# refusal names the money rather than reading as a transient failure.
test "an account that has spent its credit is refused with what it spent", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-credit")
opened =
authenticated
|> post(~p"/api/v1/threads", %{
"objective" => "Spend it all.",
"model" => "gemini-3.7-flash"
})
|> json_response(201)
grant = Repo.get_by!(Grant, thread_id: opened["thread"]["id"])
allowance = Credit.allowance(grant.owner_visitor_id)
{:ok, paid_model} = Models.fetch("gemini-3.7-flash")
rate = Pricing.effective_pricing(paid_model).output_per_million_tokens
{:ok, _metered} =
Inference.record_usage(grant, %{
"output_tokens" => div(allowance * 1_000_000, rate)
})
assert Credit.remaining(grant.owner_visitor_id) == 0
body =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "One more, on empty."})
|> json_response(402)
assert body["code"] == "credit_exhausted"
# The account's own allowance, not a deployment-wide figure: it is
# recorded per account, so the refusal names what this one was granted.
# Read from the same source the refusal reads, because a hard-coded
# dollar amount here would go red the next time the grant changes without
# anything being wrong.
granted = "$#{:erlang.float_to_binary(allowance / 1_000_000, decimals: 2)}"
assert body["message"] =~ granted
assert [message] = body["errors"]["credit"]
assert message =~ granted
end
test "the cap counts one account's threads, never another's", %{conn: conn} do
previous = Application.get_env(:openagents, :maximum_open_threads_per_account)
Application.put_env(:openagents, :maximum_open_threads_per_account, 1)
on_exit(fn ->
Application.put_env(:openagents, :maximum_open_threads_per_account, previous)
end)
assert conn
|> put_chat_api_token("thread-cap-mine")
|> post(~p"/api/v1/threads", %{"objective" => "Mine."})
|> json_response(201)
assert conn
|> put_chat_api_token("thread-cap-yours")
|> post(~p"/api/v1/threads", %{"objective" => "Yours."})
|> json_response(201)
end
test "an anonymous caller is refused with the envelope", %{conn: conn} do
body = conn |> post(~p"/api/v1/threads", %{"objective" => "No."}) |> json_response(401)
assert body["code"] == "unauthenticated"
assert is_map(body["errors"])
end
end
describe "GET /api/v1/threads/:thread_id" do
test "reports usage, and reports no remainder where there is no ceiling", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-read")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Report on me."})
|> json_response(201)
id = created["thread"]["id"]
grant = Repo.get_by!(Grant, thread_id: id)
{:ok, _spent} = Inference.record_usage(grant, %{"input_tokens" => 10, "output_tokens" => 5})
body = authenticated |> get(~p"/api/v1/threads/#{id}") |> json_response(200)
assert body["thread"]["status"] == "open"
assert body["grant"]["status"] == "active"
assert body["grant"]["call_count"] == 1
assert body["grant"]["usage"]["total_tokens"] == 15
# A thread's grant sets no call or token ceiling, so there is no
# remainder to count down. `null` is what the client already reads as
# "no limit"; a number here would have been invented.
assert is_nil(Threads.ceilings().max_calls)
assert is_nil(body["grant"]["limits"]["max_total_tokens"])
assert is_nil(body["grant"]["remaining"]["calls"])
assert is_nil(body["grant"]["remaining"]["total_tokens"])
# Cost is still ceiled, at what the account's credit has left, and its
# remainder is a real figure.
assert body["grant"]["remaining"]["cost_microusd"] ==
body["grant"]["limits"]["max_cost_microusd"] -
body["grant"]["usage"]["estimated_cost_microusd"]
refute Map.has_key?(body["grant"], "token")
end
test "another account's thread is not found", %{conn: conn} do
created =
conn
|> put_chat_api_token("thread-owner")
|> post(~p"/api/v1/threads", %{"objective" => "Private work."})
|> json_response(201)
body =
conn
|> put_chat_api_token("thread-stranger")
|> get(~p"/api/v1/threads/#{created["thread"]["id"]}")
|> json_response(404)
assert body["code"] == "not_found"
assert body["message"] == "Not Found"
end
test "an unknown id and another account's id refuse identically", %{conn: conn} do
created =
conn
|> put_chat_api_token("thread-owner-two")
|> post(~p"/api/v1/threads", %{"objective" => "Private work."})
|> json_response(201)
stranger = put_chat_api_token(conn, "thread-stranger-two")
theirs =
stranger |> get(~p"/api/v1/threads/#{created["thread"]["id"]}") |> json_response(404)
absent =
stranger
|> get(~p"/api/v1/threads/00000000-0000-4000-8000-000000000001")
|> json_response(404)
assert Map.drop(theirs, ["request_id"]) == Map.drop(absent, ["request_id"])
end
test "a thread's authority carries no deadline, so time alone does not end it", %{
conn: conn
} do
# What this replaces: the grant expired on a wall clock, the thread was
# closed as `authority_expired`, and a coding session that was mid-work
# was told to start a new one because an hour had passed.
authenticated = put_chat_api_token(conn, "thread-no-clock")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Outlive me."})
|> json_response(201)
body =
authenticated
|> get(~p"/api/v1/threads/#{created["thread"]["id"]}")
|> json_response(200)
assert body["grant"]["status"] == "active"
assert body["thread"]["status"] == "open"
assert is_nil(body["thread"]["error_code"])
assert {:ok, _resolved} = Inference.resolve(created["grant"]["token"])
end
test "a thread left holding no authority reports it as spent, never as expired", %{
conn: conn
} do
# The slot still has to come back — an open thread that can never work
# again would hold the account's ceiling forever. What a reader is told
# is that the authority was spent, which is true, rather than that it
# expired, which is a concept this no longer has.
authenticated = put_chat_api_token(conn, "thread-expiry-read")
elapsed_ttl()
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Outlive me."})
|> json_response(201)
body =
authenticated
|> get(~p"/api/v1/threads/#{created["thread"]["id"]}")
|> json_response(200)
assert body["grant"]["status"] == "expired"
assert body["thread"]["status"] == "failed"
assert body["thread"]["error_code"] == "authority_spent"
refute body["thread"]["report"] =~ "expired"
end
test "the slot is released by revoking, not by waiting", %{conn: conn} do
cap(1)
authenticated = put_chat_api_token(conn, "thread-expiry-cap")
first =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "First."})
|> json_response(201)
assert Threads.open_count(github_user("api-token-thread-expiry-cap")) == 1
# Waiting does not free it. There is no clock to wait out.
refused =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Second."})
|> json_response(429)
assert refused["code"] == "thread_quota_reached"
# Saying so does.
_deleted = authenticated |> delete(~p"/api/v1/threads/#{first["thread"]["id"]}")
second =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Second."})
|> json_response(201)
assert second["grant"]["token"] != first["grant"]["token"]
end
end
describe "DELETE /api/v1/threads/:thread_id" do
test "revokes the grant immediately", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-revoke")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Stop me."})
|> json_response(201)
id = created["thread"]["id"]
token = created["grant"]["token"]
assert {:ok, _usable} = Inference.resolve(token)
body = authenticated |> delete(~p"/api/v1/threads/#{id}") |> json_response(200)
assert body["thread"]["status"] == "cancelled"
assert body["grant"]["status"] == "revoked"
assert {:error, :grant_revoked} = Inference.resolve(token)
assert Threads.active_grants(%OpenAgents.Threads.Thread{id: id}) == []
end
test "revoking twice leaves the thread terminal", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-revoke-twice")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Stop me twice."})
|> json_response(201)
id = created["thread"]["id"]
assert authenticated |> delete(~p"/api/v1/threads/#{id}") |> json_response(200)
body = authenticated |> delete(~p"/api/v1/threads/#{id}") |> json_response(200)
assert body["thread"]["status"] == "cancelled"
assert body["grant"]["status"] == "revoked"
end
test "another account cannot revoke a thread it did not open", %{conn: conn} do
created =
conn
|> put_chat_api_token("thread-revoke-owner")
|> post(~p"/api/v1/threads", %{"objective" => "Mine alone."})
|> json_response(201)
body =
conn
|> put_chat_api_token("thread-revoke-stranger")
|> delete(~p"/api/v1/threads/#{created["thread"]["id"]}")
|> json_response(404)
assert body["code"] == "not_found"
assert {:ok, _still_usable} = Inference.resolve(created["grant"]["token"])
end
end
describe "POST /api/v1/threads/:thread_id/report" do
setup %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-report")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Answer, then say so."})
|> json_response(201)
%{
authenticated: authenticated,
id: created["thread"]["id"],
token: created["grant"]["token"]
}
end
test "a thread that reported is recorded as having reported, not cancelled", %{
authenticated: conn,
id: id,
token: token
} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/report", %{
"status" => "succeeded",
"report" => "The answer is 4."
})
|> json_response(200)
assert body["thread"]["status"] == "succeeded"
assert body["thread"]["error_code"] == nil
assert body["thread"]["report"] == "The answer is 4."
assert body["thread"]["report_type"] == "outcome"
# Reporting revokes, exactly as cancelling does: authority does not
# outlive the thread's end (THREAD-001).
assert {:error, :grant_revoked} = Inference.resolve(token)
assert body["grant"]["status"] == "revoked"
end
test "the server never guesses the outcome: a report with no status is refused", %{
authenticated: conn,
id: id
} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/report", %{"report" => "Something happened."})
|> json_response(422)
assert body["code"] == "validation_failed"
assert body["errors"]["status"] != nil
assert conn
|> get(~p"/api/v1/threads/#{id}")
|> json_response(200)
|> get_in([
"thread",
"status"
]) == "open"
end
test "a run that failed cannot be recorded as a success", %{authenticated: conn, id: id} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/report", %{
"status" => "succeeded",
"report" => "It worked.",
"error_code" => "max_steps"
})
|> json_response(422)
assert body["code"] == "validation_failed"
assert body["errors"]["error_code"] != nil
end
test "a failure has to name why", %{authenticated: conn, id: id} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/report", %{
"status" => "failed",
"report" => "It did not work."
})
|> json_response(422)
assert body["code"] == "validation_failed"
assert body["errors"]["error_code"] != nil
end
test "a failed run is recorded as failed, with its reason", %{authenticated: conn, id: id} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/report", %{
"status" => "failed",
"report" => "The turn budget ran out before an answer.",
"error_code" => "max_steps"
})
|> json_response(200)
assert body["thread"]["status"] == "failed"
assert body["thread"]["error_code"] == "max_steps"
assert body["thread"]["report_type"] == "failure"
end
test "an interrupted run reports as cancelled, naming the interruption", %{
authenticated: conn,
id: id
} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/report", %{
"status" => "cancelled",
"report" => "The operator interrupted the session.",
"error_code" => "interrupted"
})
|> json_response(200)
assert body["thread"]["status"] == "cancelled"
assert body["thread"]["error_code"] == "interrupted"
end
test "a status outside the terminal three is refused", %{authenticated: conn, id: id} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/report", %{
"status" => "open",
"report" => "Still going."
})
|> json_response(422)
assert body["errors"]["status"] != nil
end
test "a blank report is refused", %{authenticated: conn, id: id} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/report", %{"status" => "succeeded", "report" => " "})
|> json_response(422)
assert body["errors"]["report"] != nil
end
test "an at-least-once client may resend the same report", %{authenticated: conn, id: id} do
report = %{"status" => "succeeded", "report" => "The answer is 4."}
assert conn |> post(~p"/api/v1/threads/#{id}/report", report) |> json_response(200)
body = conn |> post(~p"/api/v1/threads/#{id}/report", report) |> json_response(200)
assert body["thread"]["status"] == "succeeded"
end
test "a second, different report is refused rather than overwriting the first", %{
authenticated: conn,
id: id
} do
assert conn
|> post(~p"/api/v1/threads/#{id}/report", %{
"status" => "succeeded",
"report" => "The answer is 4."
})
|> json_response(200)
body =
conn
|> post(~p"/api/v1/threads/#{id}/report", %{
"status" => "failed",
"report" => "Actually it broke.",
"error_code" => "max_steps"
})
|> json_response(422)
assert body["code"] == "thread_terminal"
standing = conn |> get(~p"/api/v1/threads/#{id}") |> json_response(200)
assert standing["thread"]["status"] == "succeeded"
end
test "another account cannot report on a thread it did not open", %{conn: conn, id: id} do
body =
conn
|> put_chat_api_token("thread-report-stranger")
|> post(~p"/api/v1/threads/#{id}/report", %{
"status" => "succeeded",
"report" => "Not mine."
})
|> json_response(404)
assert body["code"] == "not_found"
end
end
describe "spending a thread's grant" do
test "the grant reaches the model exactly as a conversation-fenced one does", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-spend")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Answer one question."})
|> json_response(201)
proxied =
conn
|> put_req_header("authorization", "Bearer #{created["grant"]["token"]}")
|> put_req_header("content-type", "application/json")
|> post(
~p"/api/inference/proxy",
Jason.encode!(%{
"model" => created["grant"]["model"],
"messages" => [%{"role" => "user", "content" => "hello there"}],
"stream" => true
})
)
assert proxied.status == 200
body = authenticated |> get(~p"/api/v1/threads/#{created["thread"]["id"]}")
grant = json_response(body, 200)["grant"]
assert grant["call_count"] == 1
assert grant["usage"]["total_tokens"] > 0
end
test "a revoked thread's grant no longer reaches the model", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-spend-revoked")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Answer, then stop."})
|> json_response(201)
assert authenticated
|> delete(~p"/api/v1/threads/#{created["thread"]["id"]}")
|> json_response(200)
proxied =
conn
|> put_req_header("authorization", "Bearer #{created["grant"]["token"]}")
|> put_req_header("content-type", "application/json")
|> post(
~p"/api/inference/proxy",
Jason.encode!(%{
"messages" => [%{"role" => "user", "content" => "hello there"}]
})
)
assert proxied.status == 403
end
end
defp cap(limit) do
previous = Application.get_env(:openagents, :maximum_open_threads_per_account)
Application.put_env(:openagents, :maximum_open_threads_per_account, limit)
on_exit(fn ->
Application.put_env(:openagents, :maximum_open_threads_per_account, previous)
end)
end
# A grant's expiry is immutable once minted, which is the point: nothing can
# move a clock it has already committed to. So the TTL is set before the mint
# rather than the row edited after it, and the returned function puts the
# configured TTL back.
defp elapsed_ttl do
previous = Application.get_env(:openagents, :thread_grant_ttl_seconds)
Application.put_env(:openagents, :thread_grant_ttl_seconds, -1)
restore = fn -> Application.put_env(:openagents, :thread_grant_ttl_seconds, previous) end
on_exit(restore)
restore
end
describe "a thread's transcript" do
setup %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-transcript")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Remember me."})
|> json_response(201)
%{authenticated: authenticated, id: created["thread"]["id"]}
end
test "records an event and advances the count", %{authenticated: conn, id: id} do
before = conn |> get(~p"/api/v1/threads/#{id}") |> json_response(200)
body =
conn
|> post(~p"/api/v1/threads/#{id}/events", %{
"event_type" => "turn.user",
"payload" => %{"text" => "list the open issues"}
})
|> json_response(201)
# Opening a thread records its own lifecycle event, so the count is a
# delta rather than a total.
assert body["thread"]["event_count"] == before["thread"]["event_count"] + 1
end
test "reads the transcript back, oldest first", %{authenticated: conn, id: id} do
for text <- ["first", "second", "third"] do
conn
|> post(~p"/api/v1/threads/#{id}/events", %{
"event_type" => "turn.user",
"payload" => %{"text" => text}
})
|> json_response(201)
end
body = conn |> get(~p"/api/v1/threads/#{id}/events") |> json_response(200)
# The server's copy is the only copy: a client reads this back rather than
# keeping its own, so two machines on one thread see one transcript.
texts =
body["events"]
|> Enum.filter(&(&1["event_type"] == "turn.user"))
|> Enum.map(& &1["payload"]["text"])
assert texts == ["first", "second", "third"]
assert Enum.all?(body["events"], &(&1["schema"] == "openagents.thread.event.v1"))
end
test "continues from an event already read", %{authenticated: conn, id: id} do
for text <- ["one", "two", "three"] do
conn
|> post(~p"/api/v1/threads/#{id}/events", %{
"event_type" => "turn.user",
"payload" => %{"text" => text}
})
|> json_response(201)
end
first = conn |> get(~p"/api/v1/threads/#{id}/events?limit=2") |> json_response(200)
cursor = List.last(first["events"])["id"]
rest = conn |> get(~p"/api/v1/threads/#{id}/events?after=#{cursor}") |> json_response(200)
# A working session records a turn and every tool it ran, which passes
# the listing cap inside an hour. Without a cursor its history could not
# be read back at all.
assert length(first["events"]) == 2
assert Enum.all?(rest["events"], &(&1["id"] > cursor))
assert Enum.map(rest["events"], & &1["payload"]["text"]) |> List.last() == "three"
end
test "records a payload far larger than the old ceiling", %{authenticated: conn, id: id} do
# A single reasoning block observed in a live session is 38,791
# characters. Under the inherited 16 KB ceiling the only way to record one
# was to split it and reassemble it on every read.
reasoning = String.duplicate("thinking about the problem. ", 2_000)
assert byte_size(reasoning) > 16_384
conn
|> post(~p"/api/v1/threads/#{id}/events", %{
"event_type" => "turn.reasoning",
"payload" => %{"text" => reasoning}
})
|> json_response(201)
body = conn |> get(~p"/api/v1/threads/#{id}/events") |> json_response(200)
stored =
body["events"]
|> Enum.find(&(&1["event_type"] == "turn.reasoning"))
|> get_in(["payload", "text"])
# Stored whole, so the transcript reproduces the session rather than a
# summary of it.
assert stored == reasoning
end
test "accepts an event whose type is the whole of it", %{authenticated: conn, id: id} do
# Some events carry nothing but their type, and the route defaults an
# absent payload to an empty object. The remaining floor is that the
# column holds valid JSON, not that the JSON is interesting.
body =
conn
|> post(~p"/api/v1/threads/#{id}/events", %{"event_type" => "turn.started"})
|> json_response(201)
assert body["thread"]["event_count"] > 0
end
test "returns the created event, whose id is the cursor", %{authenticated: conn, id: id} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/events", %{
"event_type" => "turn.user",
"payload" => %{"text" => "echo me back"}
})
|> json_response(201)
# A writer that never learns its event's id cannot continue from it or
# dedup its own append against a later read, so the 201 carries the event
# rather than only the thread it landed on.
assert is_integer(body["event"]["id"])
assert body["event"]["event_type"] == "turn.user"
assert body["event"]["payload"] == %{"text" => "echo me back"}
assert is_binary(body["event"]["inserted_at"])
read = conn |> get(~p"/api/v1/threads/#{id}/events") |> json_response(200)
assert List.last(read["events"])["id"] == body["event"]["id"]
end
test "refuses an event with no type", %{authenticated: conn, id: id} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/events", %{"payload" => %{"text" => "x"}})
|> json_response(422)
# The code is the machine's half of the refusal, symmetric with
# `thread_terminal`: a client drops the event without parsing prose.
assert body["code"] == "event_invalid"
assert body["errors"]["event_type"] != nil
end
test "refuses to append to a revoked thread", %{authenticated: conn, id: id} do
conn |> delete(~p"/api/v1/threads/#{id}") |> json_response(200)
body =
conn
|> post(~p"/api/v1/threads/#{id}/events", %{"event_type" => "turn.user"})
|> json_response(422)
# A transcript that keeps growing after the report was written is not the
# transcript the report describes.
assert body["code"] == "thread_terminal"
end
test "appends a batch in order and returns the created events", %{
authenticated: conn,
id: id
} do
before = conn |> get(~p"/api/v1/threads/#{id}") |> json_response(200)
body =
conn
|> post(~p"/api/v1/threads/#{id}/events", %{
"events" => [
%{"event_type" => "turn.user", "payload" => %{"text" => "first"}},
%{"event_type" => "tool.ran", "payload" => %{"tool" => "bash"}},
%{"event_type" => "turn.assistant", "payload" => %{"text" => "third"}}
]
})
|> json_response(201)
# A tool-heavy turn no longer costs one round trip per event, and the
# created events come back in the order they landed so the writer learns
# every id it just wrote.
assert Enum.map(body["events"], & &1["event_type"]) ==
["turn.user", "tool.ran", "turn.assistant"]
ids = Enum.map(body["events"], & &1["id"])
assert ids == Enum.sort(ids)
assert Enum.all?(body["events"], &is_binary(&1["inserted_at"]))
assert body["thread"]["event_count"] == before["thread"]["event_count"] + 3
read = conn |> get(~p"/api/v1/threads/#{id}/events") |> json_response(200)
assert Enum.take(read["events"], -3) |> Enum.map(& &1["id"]) == ids
end
test "a batch with one invalid event records nothing", %{authenticated: conn, id: id} do
before = conn |> get(~p"/api/v1/threads/#{id}") |> json_response(200)
# The second entry passes the route's parse — its type is non-blank — and
# is refused by the database's 80-character ceiling, so the refusal
# proves the transaction rolled the first entry back with it.
body =
conn
|> post(~p"/api/v1/threads/#{id}/events", %{
"events" => [
%{"event_type" => "turn.user", "payload" => %{"text" => "landed?"}},
%{"event_type" => String.duplicate("x", 81), "payload" => %{}}
]
})
|> json_response(422)
assert body["code"] == "event_invalid"
assert body["errors"]["events[1].event_type"] != nil
after_refusal = conn |> get(~p"/api/v1/threads/#{id}") |> json_response(200)
assert after_refusal["thread"]["event_count"] == before["thread"]["event_count"]
end
test "a batch entry with no type is refused naming its position", %{
authenticated: conn,
id: id
} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/events", %{
"events" => [
%{"event_type" => "turn.user"},
%{"payload" => %{"text" => "no type"}}
]
})
|> json_response(422)
assert body["code"] == "event_invalid"
assert body["errors"]["events[1].event_type"] != nil
end
test "an empty batch is refused rather than answered created", %{
authenticated: conn,
id: id
} do
body =
conn
|> post(~p"/api/v1/threads/#{id}/events", %{"events" => []})
|> json_response(422)
assert body["code"] == "event_invalid"
assert body["errors"]["events"] != nil
end
test "a batch over the cap is refused with its own code", %{authenticated: conn, id: id} do
previous = Application.get_env(:openagents, :maximum_thread_event_batch)
Application.put_env(:openagents, :maximum_thread_event_batch, 2)
on_exit(fn ->
Application.put_env(:openagents, :maximum_thread_event_batch, previous)
end)
body =
conn
|> post(~p"/api/v1/threads/#{id}/events", %{
"events" =>
for index <- 1..3 do
%{"event_type" => "turn.user", "payload" => %{"index" => index}}
end
})
|> json_response(422)
# Over the cap is not an invalid event — every entry may be well formed —
# so it carries its own code, and the sentence names the split.
assert body["code"] == "event_batch_too_large"
assert body["message"] =~ "2"
assert [sentence] = body["errors"]["events"]
assert sentence =~ "3 events"
end
test "refuses a batch to a revoked thread as one refusal", %{authenticated: conn, id: id} do
conn |> delete(~p"/api/v1/threads/#{id}") |> json_response(200)
body =
conn
|> post(~p"/api/v1/threads/#{id}/events", %{
"events" => [
%{"event_type" => "turn.user", "payload" => %{"text" => "late"}},
%{"event_type" => "turn.assistant", "payload" => %{"text" => "later"}}
]
})
|> json_response(422)
assert body["code"] == "thread_terminal"
end
test "does not read another account's transcript", %{authenticated: conn, id: id} do
conn
|> post(~p"/api/v1/threads/#{id}/events", %{"event_type" => "turn.user"})
|> json_response(201)
stranger = put_chat_api_token(build_conn(), "thread-stranger")
assert stranger |> get(~p"/api/v1/threads/#{id}/events") |> json_response(404)
end
end
describe "GET /api/v1/threads" do
test "lists the account's threads, newest first", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-index")
for objective <- ["older", "newer"] do
authenticated
|> post(~p"/api/v1/threads", %{"objective" => objective})
|> json_response(201)
end
body = authenticated |> get(~p"/api/v1/threads") |> json_response(200)
# A client that outlives its process needs a way back to the work it was
# doing, and the account is the only place that knows.
assert Enum.map(body["threads"], & &1["objective"]) == ["newer", "older"]
end
test "?repository= narrows the listing to that repository, exactly", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-repository-filter")
for {objective, repository} <- [
{"here", "OpenAgentsInc/openagents.com"},
{"elsewhere", "OpenAgentsInc/openagents"},
{"nowhere", nil}
] do
authenticated
|> post(
~p"/api/v1/threads",
%{"objective" => objective}
|> Map.merge(if repository, do: %{"repository" => repository}, else: %{})
)
|> json_response(201)
end
body =
authenticated
|> get(~p"/api/v1/threads?repository=OpenAgentsInc/openagents.com")
|> json_response(200)
# An exact match on the recorded field, so a resume picker filters
# structurally instead of parsing the objective sentence back.
assert Enum.map(body["threads"], & &1["objective"]) == ["here"]
assert Enum.map(body["threads"], & &1["repository"]) == ["OpenAgentsInc/openagents.com"]
unfiltered = authenticated |> get(~p"/api/v1/threads") |> json_response(200)
assert length(unfiltered["threads"]) == 3
end
test "does not list another account's threads", %{conn: conn} do
put_chat_api_token(conn, "thread-mine")
|> post(~p"/api/v1/threads", %{"objective" => "mine"})
|> json_response(201)
body =
build_conn()
|> put_chat_api_token("thread-theirs")
|> get(~p"/api/v1/threads")
|> json_response(200)
assert body["threads"] == []
end
end
describe "POST /api/v1/threads/:thread_id/grants" do
test "re-mints authority on the same thread and revokes the old grant", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-remint")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Resume me."})
|> json_response(201)
id = created["thread"]["id"]
old_token = created["grant"]["token"]
assert {:ok, _usable} = Inference.resolve(old_token)
body = authenticated |> post(~p"/api/v1/threads/#{id}/grants") |> json_response(201)
assert body["thread"]["id"] == id
assert body["thread"]["status"] == "open"
new_token = body["grant"]["token"]
assert is_binary(new_token) and new_token != old_token
assert {:ok, _usable} = Inference.resolve(new_token)
assert {:error, :grant_revoked} = Inference.resolve(old_token)
end
test "a thread that reported can be resumed: the re-grant reopens it", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-remint-reported")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Report, exit, come back."})
|> json_response(201)
id = created["thread"]["id"]
assert authenticated
|> post(~p"/api/v1/threads/#{id}/report", %{
"status" => "succeeded",
"report" => "The answer is 4."
})
|> json_response(200)
body = authenticated |> post(~p"/api/v1/threads/#{id}/grants") |> json_response(201)
assert body["thread"]["status"] == "open"
assert body["thread"]["error_code"] == nil
assert body["thread"]["generation"] == 2
assert {:ok, _usable} = Inference.resolve(body["grant"]["token"])
# What the thread reported before is in the transcript, so resuming
# loses nothing.
events =
authenticated
|> get(~p"/api/v1/threads/#{id}/events")
|> json_response(200)
|> Map.fetch!("events")
reopened = Enum.find(events, &(&1["event_type"] == "thread.reopened"))
assert reopened["payload"]["report"] == "The answer is 4."
assert reopened["payload"]["status"] == "succeeded"
# And the resumed session can append its turns again.
assert authenticated
|> post(~p"/api/v1/threads/#{id}/events", %{
"event_type" => "turn.user",
"payload" => %{"text" => "and again?"}
})
|> json_response(201)
end
test "a cancelled thread refuses with thread_terminal", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-remint-terminal")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Close me first."})
|> json_response(201)
id = created["thread"]["id"]
_cancelled = authenticated |> delete(~p"/api/v1/threads/#{id}") |> json_response(200)
refused = authenticated |> post(~p"/api/v1/threads/#{id}/grants") |> json_response(422)
assert refused["code"] == "thread_terminal"
end
test "another account's thread is not found", %{conn: conn} do
owner = put_chat_api_token(conn, "thread-remint-owner")
created =
owner
|> post(~p"/api/v1/threads", %{"objective" => "Mine alone."})
|> json_response(201)
id = created["thread"]["id"]
stranger = put_chat_api_token(recycle(conn), "thread-remint-stranger")
assert stranger |> post(~p"/api/v1/threads/#{id}/grants") |> json_response(404)
end
end
describe "the local lane" do
# The transcript-only lane (THREAD-001, issue #243): the model is the
# vendor string a local runtime serves, checked against no catalog and no
# provider, and no grant is ever minted. The other half of the contract is
# below: a create that names no lane is byte-for-byte the granted create
# it always was.
@vendor_model "ollama:qwen3.8:27b-mtp-q8_0"
test "opens a transcript-only thread: no grant, no token, no catalog check",
%{conn: conn} do
body =
conn
|> put_chat_api_token("thread-local-open")
|> post(~p"/api/v1/threads", %{
"objective" => "Record a local run.",
"lane" => "local",
"model" => @vendor_model
})
|> json_response(201)
# The response carries the thread and nothing that spends: no grant key,
# no token anywhere in the body.
assert Map.keys(body) == ["thread"]
refute inspect(body) =~ "sig_"
thread = body["thread"]
assert thread["status"] == "open"
# Never minted, so the fence never moved: generation 0, where a granted
# create returns 1.
assert thread["generation"] == 0
stored = Repo.get!(OpenAgents.Threads.Thread, thread["id"])
assert stored.lane == "local"
assert stored.model == @vendor_model
assert Repo.get_by(Grant, thread_id: thread["id"]) == nil
end
test "a local-lane thread requires a model, and a blank one is refused", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-local-no-model")
absent =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "No model named.", "lane" => "local"})
|> json_response(422)
assert absent["code"] == "validation_failed"
assert Map.has_key?(absent["errors"], "model")
blank =
authenticated
|> post(~p"/api/v1/threads", %{
"objective" => "Blank model.",
"lane" => "local",
"model" => " "
})
|> json_response(422)
assert Map.has_key?(blank["errors"], "model")
end
test "the vendor string is bounded, not admitted", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-local-model-bound")
|> post(~p"/api/v1/threads", %{
"objective" => "Bound the vendor string.",
"lane" => "local",
"model" => String.duplicate("a", 201)
})
|> json_response(422)
assert Map.has_key?(body["errors"], "model")
end
test "a lane outside the admitted pair is refused with a validation error", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-local-unknown-lane")
|> post(~p"/api/v1/threads", %{"objective" => "Wrong lane.", "lane" => "gym"})
|> json_response(422)
assert body["code"] == "validation_failed"
assert [message] = body["errors"]["lane"]
assert message =~ "thread, local"
not_a_string =
conn
|> put_chat_api_token("thread-local-lane-shape")
|> post(~p"/api/v1/threads", %{"objective" => "Wrong shape.", "lane" => 7})
|> json_response(422)
assert Map.has_key?(not_a_string["errors"], "lane")
end
test "requesting a grant for a local-lane thread is refused", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-local-mint")
created =
authenticated
|> post(~p"/api/v1/threads", %{
"objective" => "Ask for authority the lane forbids.",
"lane" => "local",
"model" => @vendor_model
})
|> json_response(201)
id = created["thread"]["id"]
refused = authenticated |> post(~p"/api/v1/threads/#{id}/grants") |> json_response(422)
assert refused["code"] == "thread_lane_local"
refute inspect(refused) =~ "sig_"
# The refusal changed nothing: still open, still ungranted.
assert Repo.get!(OpenAgents.Threads.Thread, id).status == "open"
assert Repo.get_by(Grant, thread_id: id) == nil
end
test "events record and read back exactly as thread-lane events do", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-local-events")
created =
authenticated
|> post(~p"/api/v1/threads", %{
"objective" => "Write the transcript here.",
"lane" => "local",
"model" => @vendor_model
})
|> json_response(201)
id = created["thread"]["id"]
recorded =
authenticated
|> post(~p"/api/v1/threads/#{id}/events", %{
"event_type" => "turn.user",
"payload" => %{"text" => "Fix the bug"}
})
|> json_response(201)
assert recorded["event"]["event_type"] == "turn.user"
body = authenticated |> get(~p"/api/v1/threads/#{id}/events") |> json_response(200)
assert Enum.map(body["events"], & &1["event_type"]) == ["thread.opened", "turn.user"]
# The transcript itself says no authority backs it.
assert [opened | _rest] = body["events"]
assert opened["payload"]["lane"] == "local"
end
test "DELETE ends a local-lane thread like any other", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-local-delete")
created =
authenticated
|> post(~p"/api/v1/threads", %{
"objective" => "End the record.",
"lane" => "local",
"model" => @vendor_model
})
|> json_response(201)
id = created["thread"]["id"]
ended = authenticated |> delete(~p"/api/v1/threads/#{id}") |> json_response(200)
assert ended["thread"]["status"] == "cancelled"
end
test "a create without a lane is the granted create it always was", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-local-regression")
body =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "Unchanged for existing callers."})
|> json_response(201)
# The same two keys, the same mint: a grant with a plaintext token, the
# fence at 1, and the stored lane is the granted one.
assert Map.keys(body) == ["grant", "thread"]
assert String.starts_with?(body["grant"]["token"], "sig_")
assert body["thread"]["generation"] == 1
assert Repo.get!(OpenAgents.Threads.Thread, body["thread"]["id"]).lane == "thread"
# And the lane-less create still admits only the catalog: a vendor
# string that opens a local-lane thread is refused here (PROVIDER-002).
refused =
authenticated
|> post(~p"/api/v1/threads", %{
"objective" => "A vendor string without the lane.",
"model" => @vendor_model
})
|> json_response(422)
assert Map.has_key?(refused["errors"], "model")
end
test "naming the thread lane explicitly is the granted create too", %{conn: conn} do
body =
conn
|> put_chat_api_token("thread-local-explicit")
|> post(~p"/api/v1/threads", %{
"objective" => "The default lane, spelled out.",
"lane" => "thread"
})
|> json_response(201)
assert String.starts_with?(body["grant"]["token"], "sig_")
assert Repo.get!(OpenAgents.Threads.Thread, body["thread"]["id"]).lane == "thread"
end
end
# METER-001. The thread read is where a client learns what a session cost,
# so it is where an unpriced lane has to stop looking like a free one.
describe "reporting cost the deployment cannot price" do
test "a thread on an unpriced model reports a null cost, not a zero", %{conn: conn} do
unpriced = admit_unpriced_lane()
authenticated = put_chat_api_token(conn, "thread-cost-unpriced")
created =
authenticated
|> post(~p"/api/v1/threads", %{
"objective" => "Run the coder's own lane.",
"model" => unpriced
})
|> json_response(201)
id = created["thread"]["id"]
grant = Repo.get_by!(Grant, thread_id: id)
{:ok, _spent} =
Inference.record_usage(grant, %{"input_tokens" => 900, "output_tokens" => 80})
body = authenticated |> get(~p"/api/v1/threads/#{id}") |> json_response(200)
# The key exists and is null. A client that renders it has to handle the
# null rather than print a zero it was never given.
assert Map.has_key?(body["thread"]["spend"]["cost"], "microusd")
assert is_nil(body["thread"]["spend"]["cost"]["microusd"])
assert body["thread"]["spend"]["cost"]["basis"] == "unpriced"
assert body["thread"]["spend"]["cost"]["unpriced_calls"] == 1
assert body["thread"]["spend"]["cost"]["unpriced_models"] == [unpriced]
# And the same refusal on the grant: no cost spent, no cost remainder.
assert body["grant"]["pricing"]["basis"] == "unpriced"
assert body["grant"]["pricing"]["id"] == "unpriced"
assert body["grant"]["pricing"]["billable"] == false
assert is_nil(body["grant"]["spent"]["cost_microusd"])
assert is_nil(body["grant"]["remaining"]["cost_microusd"])
# Tokens were still measured, so the call is evidenced even unpriced.
assert body["grant"]["spent"]["total_tokens"] == 980
end
test "a priced thread reports its cost, labelled by the basis of its rates", %{conn: conn} do
authenticated = put_chat_api_token(conn, "thread-cost-priced")
created =
authenticated
|> post(~p"/api/v1/threads", %{"objective" => "A lane with rates."})
|> json_response(201)
id = created["thread"]["id"]
grant = Repo.get_by!(Grant, thread_id: id)
{:ok, _spent} = Inference.record_usage(grant, %{"input_tokens" => 1_000_000})
# A million input tokens costs exactly the default lane's per-million
# rate, whatever the catalog's head happens to be.
pricing = Pricing.effective_pricing(Models.default())
rate = pricing.input_per_million_tokens
body = authenticated |> get(~p"/api/v1/threads/#{id}") |> json_response(200)
assert body["thread"]["spend"]["cost"]["microusd"] == rate
assert body["thread"]["spend"]["cost"]["basis"] == Pricing.basis_of(pricing)
assert body["thread"]["spend"]["cost"]["unpriced_models"] == []
assert body["grant"]["pricing"]["basis"] == Pricing.basis_of(pricing)
assert body["grant"]["pricing"]["billable"] ==
(Pricing.basis_of(pricing) == "declared")
assert body["grant"]["spent"]["cost_microusd"] == rate
end
end
end