@@ -374,95 +374,118 @@ defmodule Phantom.Plug do
374374 end
375375
376376 defp continue ( state ) do
377- stream_fun = state . stream_fun
378-
379- params =
380- cond do
381- state . conn . method == "GET" -> [ ]
382- is_map_key ( state . conn . body_params , "_json" ) -> state . conn . body_params [ "_json" ]
383- true -> List . wrap ( state . conn . body_params )
384- end
385-
386377 { state , exceptions } =
387- Enum . reduce (
388- params ,
389- { state , [ ] } ,
390- fn
391- _request , { % { conn: % { halted: true } } = state_acc , exceptions_acc } ->
392- { state_acc , exceptions_acc }
393-
394- request , { state_acc , exceptions_acc } ->
395- case Request . build ( request ) do
396- { :ok , request } ->
397- state_acc = maybe_track_response ( state_acc , request )
398- state_acc = put_in ( state_acc . conn , maybe_track_session_stream ( state_acc . conn ) )
399-
400- state_acc =
401- put_in ( state_acc . session , % {
402- state_acc . conn . private . phantom . session
403- | elicit: elicit_fun ( state_acc . conn , stream_fun )
404- } )
405-
406- try do
407- case state_acc . session . router . dispatch_method ( [
408- request . method ,
409- request . params ,
410- request ,
411- state_acc . session
412- ] ) do
413- { :noreply , % Session { } = session_acc } ->
414- requests = Map . put ( session_acc . requests , request . id , request . response )
415- state_acc = put_in ( state_acc . session , % { session_acc | requests: requests } )
416- { state_acc , exceptions_acc }
417-
418- { :reply , result , % Session { } = session_acc } ->
419- request = Request . result ( request , "message" , result )
420- state_acc = put_in ( state_acc . session , session_acc )
421-
422- state_acc =
423- stream_fun . ( state_acc , request . id , request . type , request . response )
424-
425- { state_acc , exceptions_acc }
426-
427- { :error , error , % Session { } = session_acc } ->
428- error = Request . error ( request . id , error )
429- state_acc = put_in ( state_acc . session , session_acc )
430- state_acc = stream_fun . ( state_acc , error [ :id ] , "message" , error )
431- { state_acc , exceptions_acc }
432-
433- { :error , error } ->
434- error = Request . error ( request . id , error )
435- state_acc = stream_fun . ( state_acc , error [ :id ] , "message" , error )
436- { state_acc , exceptions_acc }
437-
438- _response ->
439- error = Request . error ( request . id , Request . internal_error ( ) )
440- state_acc = stream_fun . ( state_acc , error [ :id ] , "message" , error )
441- { state_acc , exceptions_acc }
442- end
443- rescue
444- exception ->
445- error =
446- Request . error (
447- request . id ,
448- Request . internal_error ( Exception . message ( exception ) )
449- )
450-
451- exceptions_acc = [ { request , exception , __STACKTRACE__ } | exceptions_acc ]
452- state_acc = stream_fun . ( state_acc , request . id , "message" , error )
453- { state_acc , exceptions_acc }
454- end
455-
456- { :error , error } ->
457- state_acc = stream_fun . ( state_acc , error . id , "message" , error . response )
458- { state_acc , exceptions_acc }
459- end
460- end
461- )
378+ state
379+ |> incoming_requests ( )
380+ |> Enum . reduce ( { state , [ ] } , & process_request / 2 )
462381
463382 maybe_reraise ( state , exceptions )
464383 end
465384
385+ defp incoming_requests ( % { conn: % { method: "GET" } } ) , do: [ ]
386+
387+ defp incoming_requests ( % { conn: % { body_params: % { "_json" => batch } } } ) , do: batch
388+
389+ defp incoming_requests ( % { conn: % { body_params: body } } ) , do: List . wrap ( body )
390+
391+ # Skip subsequent requests in a batch after the conn is halted.
392+ defp process_request ( _request , { % { conn: % { halted: true } } = state , exceptions } ) ,
393+ do: { state , exceptions }
394+
395+ defp process_request ( raw_request , { state , exceptions } ) do
396+ case Request . build ( raw_request ) do
397+ { :ok , request } ->
398+ state
399+ |> prepare_for_dispatch ( request )
400+ |> dispatch_or_reject ( request , exceptions )
401+
402+ { :error , error } ->
403+ state = state . stream_fun . ( state , error . id , "message" , error . response )
404+ { state , exceptions }
405+ end
406+ end
407+
408+ defp prepare_for_dispatch ( state , request ) do
409+ state = maybe_track_response ( state , request )
410+ state = put_in ( state . conn , maybe_track_session_stream ( state . conn ) )
411+
412+ put_in ( state . session , % {
413+ state . conn . private . phantom . session
414+ | elicit: elicit_fun ( state . conn , state . stream_fun , request )
415+ } )
416+ end
417+
418+ defp dispatch_or_reject ( state , request , exceptions ) do
419+ session_id = state . session . id
420+
421+ case claim_in_flight ( session_id , request ) do
422+ :duplicate ->
423+ error = Request . error ( request . id , Request . duplicate_request ( ) )
424+ state = state . stream_fun . ( state , error [ :id ] , "message" , error )
425+ { state , exceptions }
426+
427+ :ok ->
428+ run_dispatch ( state , request , exceptions )
429+ end
430+ end
431+
432+ defp run_dispatch ( state , request , exceptions ) do
433+ result =
434+ state . session . router . dispatch_method ( [
435+ request . method ,
436+ request . params ,
437+ request ,
438+ state . session
439+ ] )
440+
441+ handle_dispatch_result ( result , state , request , exceptions )
442+ rescue
443+ exception ->
444+ error =
445+ Request . error ( request . id , Request . internal_error ( Exception . message ( exception ) ) )
446+
447+ state = state . stream_fun . ( state , request . id , "message" , error )
448+ release_in_flight ( state . session . id , request )
449+ { state , [ { request , exception , __STACKTRACE__ } | exceptions ] }
450+ end
451+
452+ defp handle_dispatch_result ( { :noreply , % Session { } = session } , state , request , exceptions ) do
453+ # Async tool — in-flight claim stays held until `Session.respond/2`
454+ # eventually casts to the session GenServer and untracks.
455+ requests = Map . put ( session . requests , request . id , request . response )
456+ { put_in ( state . session , % { session | requests: requests } ) , exceptions }
457+ end
458+
459+ defp handle_dispatch_result ( { :reply , result , % Session { } = session } , state , request , exceptions ) do
460+ request = Request . result ( request , "message" , result )
461+ state = put_in ( state . session , session )
462+ state = state . stream_fun . ( state , request . id , request . type , request . response )
463+ release_in_flight ( state . session . id , request )
464+ { state , exceptions }
465+ end
466+
467+ defp handle_dispatch_result ( { :error , error , % Session { } = session } , state , request , exceptions ) do
468+ error = Request . error ( request . id , error )
469+ state = put_in ( state . session , session )
470+ state = state . stream_fun . ( state , error [ :id ] , "message" , error )
471+ release_in_flight ( state . session . id , request )
472+ { state , exceptions }
473+ end
474+
475+ defp handle_dispatch_result ( { :error , error } , state , request , exceptions ) do
476+ error = Request . error ( request . id , error )
477+ state = state . stream_fun . ( state , error [ :id ] , "message" , error )
478+ release_in_flight ( state . session . id , request )
479+ { state , exceptions }
480+ end
481+
482+ defp handle_dispatch_result ( _response , state , request , exceptions ) do
483+ error = Request . error ( request . id , Request . internal_error ( ) )
484+ state = state . stream_fun . ( state , error [ :id ] , "message" , error )
485+ release_in_flight ( state . session . id , request )
486+ { state , exceptions }
487+ end
488+
466489 defp cors_preflight ( % Plug.Conn { halted: true } = conn , _opts ) , do: conn
467490
468491 defp cors_preflight ( % Plug.Conn { method: "OPTIONS" } = conn , opts ) do
@@ -715,43 +738,51 @@ defmodule Phantom.Plug do
715738 "#{ method } #{ info } "
716739 end
717740
718- defp elicit_fun ( % Plug.Conn { } = conn , stream_fun ) do
741+ defp elicit_fun ( % Plug.Conn { } = conn , stream_fun , tool_call_request ) do
742+ session_id = conn . private . phantom . session . id
743+ tool_call_id = tool_call_request . id
744+
719745 fn elicitation , timeout ->
720- { :ok , request } =
721- Request . build ( % {
722- "id" => UUIDv7 . generate ( ) ,
723- "jsonrpc" => "2.0" ,
724- "method" => "elicitation/create" ,
725- "params" => Phantom.Elicit . to_json ( elicitation )
726- } )
727-
728- ref = make_ref ( )
729-
730- Phantom.Tracker . track_request ( self ( ) , request . id , % {
731- type: :elicitation ,
732- reply_ref: ref ,
733- reply_pid: self ( )
734- } )
735-
736- if elicitation . mode == :url do
737- Phantom.Tracker . track_request ( self ( ) , elicitation . elicitation_id , % { type: :elicitation } )
738- end
746+ { request , ref } = Phantom.Elicit . prepare_request ( session_id , tool_call_id , elicitation )
739747
740748 state = % { conn: conn }
741749 stream_fun . ( state , request . id , "message" , Request . to_json ( request ) )
742750
743- receive do
744- { :phantom_elicitation_response , ^ ref , response } ->
745- Phantom.Tracker . untrack_request ( request . id )
746- { :ok , response }
747- after
748- timeout ->
749- Phantom.Tracker . untrack_request ( request . id )
750- :timeout
751- end
751+ await_elicitation_response ( request . id , ref , timeout )
752+ end
753+ end
754+
755+ defp await_elicitation_response ( request_id , ref , timeout ) do
756+ receive do
757+ { :phantom_elicitation_response , ^ ref , response } ->
758+ Phantom.Tracker . untrack_request ( request_id )
759+ { :ok , response }
760+ after
761+ timeout ->
762+ Phantom.Tracker . untrack_request ( request_id )
763+ :timeout
752764 end
753765 end
754766
767+ # Methods that dispatch to user-defined handlers and may have
768+ # side effects (including elicitation). Other methods — tools/list,
769+ # prompts/list, initialize, ping — are idempotent and safe to
770+ # re-dispatch, so we don't dedupe them (retries on reconnect stay
771+ # working).
772+ @ dedupable_methods ~w[ tools/call prompts/get]
773+
774+ defp claim_in_flight ( session_id , % Request { id: id , method: method } )
775+ when method in @ dedupable_methods and not is_nil ( id ) ,
776+ do: Phantom.Tracker . track_in_flight ( session_id , id )
777+
778+ defp claim_in_flight ( _session_id , _request ) , do: :ok
779+
780+ defp release_in_flight ( session_id , % Request { id: id , method: method } )
781+ when method in @ dedupable_methods and not is_nil ( id ) ,
782+ do: Phantom.Tracker . untrack_in_flight ( session_id , id )
783+
784+ defp release_in_flight ( _session_id , _request ) , do: :ok
785+
755786 defp inherit_session_meta ( % Session { } = session ) do
756787 case Phantom.Tracker . get_session_meta ( session . id ) do
757788 % { client_capabilities: caps } when is_map ( caps ) ->
0 commit comments