11import { useCallback , useEffect , useRef , useState } from "react"
22
3- import { loadSessionMessages } from "@agenta/chat/assets"
4- import { getPendingApprovals } from "@agenta/chat/model"
53import {
6- buildAgentResumeRequest ,
7- resolveInvocationUrl ,
8- type AgentResumeReference ,
9- } from "@agenta/chat/transport"
10- import { queryInteractions } from "@agenta/entities/session"
4+ isInteractionConflict ,
5+ queryInteractions ,
6+ respondInteraction ,
7+ } from "@agenta/entities/session"
118
12- import { getAuthorizationHeader } from "@/lib/auth"
13-
14- import { stampApprovalResponses } from "./approvalStamp"
9+ import { selectApprovalTargets , type ApprovalTarget } from "./approvalTargets"
1510
1611export type ResumePhase = "idle" | "resuming" | "error"
1712
13+ /** Fern's `AgentaApiError` message is transport jargon — show the status instead. */
14+ const respondErrorText = ( error : unknown ) : string => {
15+ const status = ( error as { statusCode ?: number } | null ) ?. statusCode
16+ return status ? `Approval failed (HTTP ${ status } ).` : "Approval failed."
17+ }
18+
1819export interface ApprovalActions {
1920 phase : ResumePhase
2021 errorText : string | null
2122 /** Answer one gate. Deny also resumes (the runner needs the denial round-trip). */
2223 respond : ( args : { approvalId : string ; approved : boolean } ) => void
23- /** Approve every pending gate — all responses ride ONE resume POST . */
24+ /** Approve every pending gate — one respond call per gate (the endpoint is per-interaction) . */
2425 approveAll : ( ) => void
2526}
2627
27- /** Keep only `{id, slug, version}` string fields of the interaction row's role-keyed refs. */
28- const sanitizeReferences = (
29- raw : Record < string , unknown > | null | undefined ,
30- ) : Record < string , AgentResumeReference > | null => {
31- if ( ! raw ) return null
32- const out : Record < string , AgentResumeReference > = { }
33- for ( const [ key , value ] of Object . entries ( raw ) ) {
34- if ( ! value || typeof value !== "object" ) continue
35- const { id, slug, version} = value as Record < string , unknown >
36- const ref : AgentResumeReference = { }
37- if ( typeof id === "string" ) ref . id = id
38- if ( typeof slug === "string" ) ref . slug = slug
39- if ( typeof version === "string" ) ref . version = version
40- if ( Object . keys ( ref ) . length > 0 ) out [ key ] = ref
41- }
42- return Object . keys ( out ) . length > 0 ? out : null
43- }
44-
45- /** The gated turn's stamped effective config, or null when the row predates stamping. */
46- const sanitizeParameters = ( raw : unknown ) : Record < string , unknown > | null => {
47- if ( ! raw || typeof raw !== "object" || Array . isArray ( raw ) ) return null
48- const params = raw as Record < string , unknown >
49- return Object . keys ( params ) . length > 0 ? params : null
50- }
51-
5228/**
53- * Approve/deny pending HITL gates from the phone — the lite resume path (plan M1.3):
54- * fresh records → stamp `approval-responded` on the tail → ONE resume invoke POST
55- * (`buildAgentResumeRequest`), fire-and-forget. No live SSE consumption: the response is
56- * drained in the background and the tightened records poll repaints the transcript until the
57- * turn settles (`phase` drops back to idle once no gate is pending).
29+ * Approve/deny pending HITL gates from the phone via the DETACHED respond dispatcher:
30+ * `POST /sessions/interactions/{id}/respond`. The backend CAS-flips the row to `responded`,
31+ * then the interactions worker rebuilds the turn's history from the durable records and
32+ * replays the gate's stamped effective config, so the parked run resumes WARM.
33+ *
34+ * Never hand-build an `/invoke` resume here: an invoke carrying stamped messages runs as a
35+ * NEW turn (`approval-mismatch (history)` → evict + cold) and leaves the interaction row
36+ * `pending`, so the gate never clears. Fire-and-forget: no stream is consumed, and the
37+ * records poll repaints the transcript until `pendingCount` drops to 0.
5838 */
5939export const useApprovalActions = ( {
6040 sessionId,
@@ -77,7 +57,7 @@ export const useApprovalActions = ({
7757 }
7858 } , [ pendingCount ] )
7959
80- // Failure-path re-arm: if the resume was accepted but the run dies before the gate
60+ // Failure-path re-arm: if the respond was accepted but the run dies before the gate
8161 // resolves, the poll never settles us — drop back to idle so the buttons re-arm.
8262 useEffect ( ( ) => {
8363 if ( phase !== "resuming" ) return
@@ -86,99 +66,43 @@ export const useApprovalActions = ({
8666 } , [ phase ] )
8767
8868 const submit = useCallback (
89- async ( target : { all : true } | { all ?: false ; approvalId : string } , approved : boolean ) => {
69+ async ( target : ApprovalTarget , approved : boolean ) => {
9070 if ( busyRef . current ) return
9171 busyRef . current = true
9272 setPhase ( "resuming" )
9373 setErrorText ( null )
9474 try {
95- // Never stamp a stale tail — re-read the durable records first.
96- const messages = ( await loadSessionMessages ( sessionId ) ) ?? [ ]
97- const pending = getPendingApprovals ( messages )
98- if ( pending . length === 0 ) {
99- throw new Error ( "No pending approval found — the turn may have moved on." )
100- }
101- const ids = target . all ? pending . map ( ( p ) => p . approvalId ) : [ target . approvalId ]
102- const stamped = stampApprovalResponses ( messages , ids , approved )
103- if ( stamped === messages ) {
104- throw new Error ( "This approval is no longer pending — refresh and retry." )
105- }
106- // The interaction row stores the run's role-keyed workflow references and,
107- // when the runner stamped it, the turn's effective config.
108- const interactions = await queryInteractions ( {
75+ // Never answer a stale gate — re-read the actionable rows (pending + in TTL).
76+ const rows = await queryInteractions ( {
10977 sessionId,
11078 projectId,
11179 actionableOnly : true ,
11280 } )
113- const withRefs = ( interactions ?? [ ] ) . filter (
114- ( row ) => row . data ?. references && Object . keys ( row . data . references ) . length > 0 ,
115- )
116- // Bind to the answered gate's own row when possible — two parked runs on
117- // different revisions in one session must not resume with the wrong config.
118- const answeredId = target . all ? undefined : target . approvalId
119- const matched = answeredId
120- ? withRefs . find ( ( row ) => row . token === answeredId )
121- : undefined
122- const row = matched ?? withRefs [ 0 ]
123- const references = sanitizeReferences ( row ?. data ?. references )
124- // Replay the turn's own config when the runner stamped it — references alone
125- // hydrate the variant's HEAD, which is a different model and, worse, a
126- // different tool-permission map than the gate was approved under. Rows without
127- // it (legacy, over-cap, pre-restart runner) fall back to hydration silently.
128- const parameters = sanitizeParameters ( row ?. data ?. parameters )
129- if ( ! references ) {
81+ const targets = selectApprovalTargets ( rows , target )
82+ if ( targets . length === 0 ) {
13083 throw new Error (
131- "This approval carries no workflow reference — answer on desktop." ,
84+ target . all
85+ ? "No pending approval found — the turn may have moved on."
86+ : "This approval is no longer pending — refresh and retry." ,
13287 )
13388 }
134- const invocationUrl = await resolveInvocationUrl ( {
135- projectId,
136- revisionId :
137- references . workflow_revision ?. id ?? references . application_revision ?. id ,
138- workflowId : references . workflow ?. id ?? references . application ?. id ,
139- } )
140- if ( ! invocationUrl ) {
141- throw new Error ( "Could not resolve the agent's invoke URL." )
142- }
143- const request = buildAgentResumeRequest ( {
144- invocationUrl,
145- references,
146- sessionId,
147- messages : stamped ,
148- parameters,
149- projectId,
150- applicationId : references . application ?. id ?? undefined ,
151- } )
152- const authHeader = await getAuthorizationHeader ( )
153- const response = await fetch ( request . invocationUrl , {
154- method : "POST" ,
155- headers : {
156- ...request . headers ,
157- ...authHeader ,
158- "Content-Type" : "application/json" ,
159- } ,
160- body : JSON . stringify ( request . requestBody ) ,
161- credentials : "include" ,
162- } )
163- if ( ! response . ok ) {
164- throw new Error ( `Resume failed (HTTP ${ response . status } ).` )
165- }
166- // Fire-and-forget, but NEVER cancel: cancelling the body aborts the request,
167- // and the agent service treats that disconnect as "stop" — the resumed run
168- // dies ~200ms in and the gate stays pending (observed live). Drain instead.
169- void ( async ( ) => {
170- const reader = response . body ?. getReader ( )
171- if ( ! reader ) return
89+ let answered = 0
90+ for ( const row of targets ) {
17291 try {
173- for ( ; ; ) {
174- const { done} = await reader . read ( )
175- if ( done ) return
176- }
177- } catch {
178- // Connection dropped (screen locked, network change) — the run
179- // continues server-side; records polling picks the result up.
92+ await respondInteraction ( {
93+ interactionId : row . id as string ,
94+ projectId,
95+ answer : { approved} ,
96+ } )
97+ answered += 1
98+ } catch ( err ) {
99+ // Someone (desktop, another tab) already answered this gate — benign.
100+ if ( ! isInteractionConflict ( err ) ) throw new Error ( respondErrorText ( err ) )
180101 }
181- } ) ( )
102+ }
103+ // Every target was already answered: nothing is resuming, so re-arm now
104+ // instead of waiting out the 60s timeout.
105+ if ( answered === 0 ) setPhase ( "idle" )
182106 } catch ( err ) {
183107 setPhase ( "error" )
184108 setErrorText ( err instanceof Error ? err . message : "Resume failed." )
0 commit comments