Skip to content

Commit 86c74ff

Browse files
committed
fix(mobile): answer approvals through the detached respond dispatcher
The phone built its own /invoke resume from a stamped records replay. That lands as a NEW turn (the keepalive trips approval-mismatch (history) -> evict + cold), so the parked gate is never matched and the interaction row stays pending — the desktop keeps showing "Approval needed to continue" even after the tool ran. Call POST /sessions/interactions/{id}/respond instead: the backend CAS-flips the row to responded and the interactions worker rebuilds the history server-side and replays the gate's stamped effective config, so the resume lands warm. Approve-all fans out one respond per pending gate; a 409 (already answered) settles to idle instead of erroring. respondInteraction now throws instead of swallowing the failure — a mutation's caller has to tell a real failure from an already-answered gate (isInteractionConflict). Drops the mobile-only invoke plumbing: approvalStamp.ts and the invoke bearer header.
1 parent 856ba09 commit 86c74ff

8 files changed

Lines changed: 129 additions & 252 deletions

File tree

web/mobile/src/features/chat/approvalStamp.ts

Lines changed: 0 additions & 38 deletions
This file was deleted.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type {SessionInteraction} from "@agenta/entities/session"
2+
3+
/** Which pending gates a tap answers: one gate (by transcript approval id) or every gate. */
4+
export type ApprovalTarget = {all: true} | {all?: false; approvalId: string}
5+
6+
/**
7+
* Pick the interaction rows to respond to.
8+
*
9+
* The transcript's approval id is the row's `token` (both come from the runner's
10+
* `interaction_request` event id), but `/sessions/interactions/{id}/respond` keys on the
11+
* row's `id` — so a row without an `id` is unanswerable and is dropped.
12+
*/
13+
export const selectApprovalTargets = (
14+
rows: SessionInteraction[] | null | undefined,
15+
target: ApprovalTarget,
16+
): SessionInteraction[] => {
17+
const pending = (rows ?? []).filter((row) => row.kind === "user_approval" && !!row.id)
18+
if (target.all) return pending
19+
return pending.filter((row) => row.token === target.approvalId)
20+
}

web/mobile/src/features/chat/useApprovalActions.ts

Lines changed: 45 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,40 @@
11
import {useCallback, useEffect, useRef, useState} from "react"
22

3-
import {loadSessionMessages} from "@agenta/chat/assets"
4-
import {getPendingApprovals} from "@agenta/chat/model"
53
import {
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

1611
export 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+
1819
export 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
*/
5939
export 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.")

web/mobile/src/lib/auth.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -87,25 +87,6 @@ export async function signInWithEmailPassword(
8787
}
8888
}
8989

90-
/**
91-
* `Authorization` for an invoke, mirroring the desktop's `getJWT()`
92-
* (web/oss/src/services/api.ts). The cookie alone authenticates the invoke, but the SDK
93-
* resolves the model connection by fetching the vault with the caller's Authorization
94-
* header ONLY — without it the run proceeds with no injected credential and the model
95-
* rejects it ("no connection resolved for provider …").
96-
*/
97-
export async function getAuthorizationHeader(): Promise<Record<string, string>> {
98-
if (typeof window === "undefined") return {}
99-
ensureAuthInit()
100-
try {
101-
if (!(await Session.doesSessionExist())) return {}
102-
const jwt = await Session.getAccessToken()
103-
return jwt ? {Authorization: `Bearer ${jwt}`} : {}
104-
} catch {
105-
return {}
106-
}
107-
}
108-
10990
/**
11091
* Attempt a cookie-based session refresh. Resolves false when there is no
11192
* refresh token or the backend rejects it — the caller's signed-out verdict

web/mobile/tests/unit/approvalStamp.test.ts

Lines changed: 0 additions & 63 deletions
This file was deleted.

0 commit comments

Comments
 (0)