From 10c3903b865a005e3e8d1c22db674ebc89052050 Mon Sep 17 00:00:00 2001 From: Matthew Erhard Date: Thu, 8 Oct 2026 16:58:45 -0400 Subject: [PATCH] fix: set $callers in tool and prompt handler processes Handlers ran in a bare spawn with no $callers, so mocking and stubbing libraries and the Ecto SQL sandbox could only reach them in global or shared mode, ruling out async tests through Phantom.Test. --- CHANGELOG.md | 7 +++++ lib/phantom/router.ex | 2 ++ test/phantom/handler_callers_test.exs | 43 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 test/phantom/handler_callers_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 56080d1..68c32fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +- Tool and prompt handlers run with the dispatching process in `$callers`, as + in a `Task`. Mocking and stubbing libraries and the Ecto SQL sandbox, which + find their owner through `$callers`, now reach handlers called with + `Phantom.Test` without global or shared mode. + ## 0.7.1 (2026-10-08) - A GET SSE stream for a session that already has one replaces it instead of diff --git a/lib/phantom/router.ex b/lib/phantom/router.ex index 0eac457..e90d7f0 100644 --- a/lib/phantom/router.ex +++ b/lib/phantom/router.ex @@ -2028,9 +2028,11 @@ defmodule Phantom.Router do request_id = request.id parent_pid = session.pid task_session = %{session | request: %{request | spec: spec}} + callers = [self() | Process.get(:"$callers", [])] worker = spawn(fn -> + Process.put(:"$callers", callers) # The isolated handler uses these process keys to return its eventual # result to the transport process that owns the current request. Process.put(:phantom_adopter, parent_pid) diff --git a/test/phantom/handler_callers_test.exs b/test/phantom/handler_callers_test.exs new file mode 100644 index 0000000..28017b2 --- /dev/null +++ b/test/phantom/handler_callers_test.exs @@ -0,0 +1,43 @@ +defmodule Phantom.HandlerCallersTest do + use ExUnit.Case, async: true + + defmodule Router do + use Phantom.Router, name: "HandlerCallers", vsn: "1.0" + + require Phantom.Prompt, as: Prompt + alias Phantom.Tool + + tool :stubbed_tool, description: "Replies with a stubbed HTTP response" + prompt :stubbed_prompt, description: "Replies with a stubbed HTTP response" + + def stubbed_tool(_params, session) do + {:reply, Tool.text(fetch_stub()), session} + end + + def stubbed_prompt(_params, session) do + {:reply, Prompt.response(user: Prompt.text(fetch_stub())), session} + end + + defp fetch_stub, do: Req.get!(plug: {Req.Test, __MODULE__}, retry: false).body + end + + import Phantom.Test + + setup do + Phantom.Test.start(router: Router) + Req.Test.stub(Router, &Req.Test.text(&1, "stubbed")) + {:ok, session: build_session(Router)} + end + + test "a tool handler can use stubs owned by the calling test", %{session: session} do + session + |> call_tool(:stubbed_tool, %{}) + |> assert_tool_text("stubbed") + end + + test "a prompt handler can use stubs owned by the calling test", %{session: session} do + session + |> get_prompt(:stubbed_prompt, %{}) + |> assert_prompt_message(text: "stubbed") + end +end