From 460ce16007266b2d256da436e34701bfd5231e4f Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 14:12:09 +0300 Subject: [PATCH 01/22] docs(mobile): approvals and steering plan (grounded round-trip + phased path) --- .../2026-07-27-mobile-approvals-steering.md | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md diff --git a/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md b/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md new file mode 100644 index 0000000000..4adaabeeee --- /dev/null +++ b/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md @@ -0,0 +1,320 @@ +# Mobile approvals + steering — design & plan + +**Status:** PLANNED · **Date:** 2026-07-27 · **Branch:** `feat/agenta-mobile-wave-1` +**Goal:** from a phone, on a session whose agent runs in the cloud: (1) see that a turn is +running and an approval is pending with enough context to decide, (2) approve/deny and have the +agent proceed, (3) stop, and steer where feasible — all WITHOUT being the SSE stream holder. +Raw-UI ethos applies (flows/logic, no polish). All findings below are code-trace verified +(file:line); nothing was executed live. + +--- + +## 1. Grounded findings + +### 1.1 There is no server-side session SSE to "watch" — the stream is the invoke response + +The live token stream is the HTTP response of the invoke request itself: browser → +agent service (`{serviceUrl}/invoke`, SDK-served, vercel UI-message projection — +`sdks/python/agenta/sdk/agents/adapters/vercel/routing.py`) → runner `POST /stream` NDJSON +(`services/runner/src/server.ts:908-1044`). Exactly one HTTP client per turn gets tokens. +`/sessions/*` is a coordination plane (Redis locks + Postgres rows) plus a durable +records/interactions plane; no event data flows through it live. + +**The "single watcher" constraint precisely:** `ATTACH` (`POST /sessions/streams/`, no inputs + +`force` — command matrix at `api/oss/src/core/sessions/streams/service.py:90-99`) mints a +`watcher_id` and **steals** the attach lock unconditionally (`steal_attached`, +`api/oss/src/dbs/redis/sessions/locks.py:178-196`, 60s TTL), publishing on a `displaced:` +pub/sub channel that **nothing subscribes to** (verified absence). The attach lock carries **no +data** — an "attached" watcher still reads content by polling records. Two clients today: +a second SEND gets **409 `SessionTurnInUse`** (router.py:148-155); a second "watcher" silently +steals bookkeeping and neither gets the other's tokens. So "stream takeover" of live tokens is +not a thing that exists to take over. + +### 1.2 Unwatched runs make progress and persist everything + +For session-owned runs, client disconnect does NOT abort (`server.ts:929-947` — only sets +`clientDisconnected`; non-session runs do abort). Every stream event is persisted +producer-side regardless of listeners: `buildPersistingEmitter` POSTs each event to +`POST /sessions/records/ingest` (`services/runner/src/sessions/persist.ts:1-130`, wired +`server.ts:996-1017`) → Redis stream `streams:records` +(`api/oss/src/core/sessions/records/streaming.py:52+`) → `RecordsWorker` → Postgres. An alive +watchdog heartbeats `POST /sessions/streams/heartbeat` every 30s (`sessions/alive.ts:60-223`). + +### 1.3 The approval round-trip, end to end + +1. **Origination (runner):** harness permission reverse-RPC → `pauseUserApproval` + (`services/runner/src/engines/sandbox_agent/acp-interactions.ts:166-200`) emits stream event + `{type:"interaction_request", kind:"user_approval", payload:{toolCallId, toolCall, + availableReplies, options}}`, creates a durable **interactions row** (kind `user_approval`, + status `pending`, `data.request={tool,args}` + stored workflow `references` — + `services/runner/src/sessions/interactions.ts:55-93` → `POST /sessions/interactions/`), and + the turn ends `stopReason:"paused"`. The sandbox **parks warm** in the in-process + `SessionPool` (`awaiting_approval`, TTL `approvalTtlMs` = **5 min**, + `session-identity.ts:31,34`; `server.ts:427-455`). After TTL: sandbox evicted, the pending + row stays actionable for **7 days** (`interactions/dao.py:31`, 209-214). +2. **Durable visibility (twice over):** the `interaction_request` event is a session record + (replayable), and the interactions row is queryable via `POST /sessions/interactions/query` + `{query:{session_id?, actionable_only:true}}` — `session_id` is OPTIONAL + (`api/oss/src/core/sessions/interactions/dtos.py:74-81`, dao.py:185-214), so **one + project-wide query returns every pending approval** — the list-badge primitive. +3. **Client display:** live = `approval-requested` tool part on the invoke SSE; cold = + records replay reconstructs the same part (`@agenta/chat` `assets/transcriptToMessages.ts:196-224` + sets `state:"approval-requested"`, `approval:{id}`) → `useApprovalDock` + (`hooks/useApprovalDock.ts`) shows tool name + exact payload. +4. **Response (desktop today):** NOT a side-channel POST. `handleApprovalResponse` → + AI SDK `addToolApprovalResponse` → `sendAutomaticallyWhen` + (`agentShouldResumeAfterApproval`, approve AND deny both resume) → a **fresh + `POST {serviceUrl}/invoke`** with the full history carrying the `{approved: boolean, + interactionToken?}` tool_result envelope (`@agenta/chat` `hooks/useAgentConversation.ts:203-233, + 372-378`; envelope match `services/runner/src/session-identity.ts:274-291`). +5. **Runner resume:** parked match → `respondPermission("once"|"reject")` resumes the SAME warm + sandbox (`server.ts:667-795`, `acp-interactions.ts:242-280`); no parked match (TTL expired, + restart) → **cold replay** of the transcript where `extractApprovalDecisions` consumes the + stored envelopes (`services/runner/src/responder.ts:368-541`). Runner then marks the row + `resolved` via `POST /sessions/interactions/transition` (`interactions.ts:100-124`). +6. **Consequence (the load-bearing fact):** answering an approval is a plain HTTP POST that any + authenticated client can make; the OLD stream is irrelevant (it already ended at the pause). + Desktop proves this daily: a reload-restored `approval-requested` tail answered cold + genuinely resumes (`useAgentConversation.ts:369-371`). **Whoever answers becomes the new + stream holder** — the resume tokens come back as that POST's response. + +### 1.4 The out-of-band respond endpoint exists but has no producer + +`POST /sessions/interactions/{interaction_id}/respond` `{answer:{...}}` (router.py:767-860): +CAS `pending → responded` (exactly-once), then a taskiq worker rebuilds a +`WorkflowServiceRequest` from the row's stored `references`/`selector` with +`data.inputs = answer` and fires a **detached** invoke — nobody holds the stream +(`api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py:31-76`; +detached start: `api/oss/src/core/workflows/service.py:593-665`). This is purpose-built for the +mobile case. **Gap:** no call site composes `answer` anywhere; the payload contract (what +`inputs` must contain for the agent service to produce messages the runner's decision map +recognizes) is UNVERIFIED — the one true unknown in this plan. + +### 1.5 A lite resume request is feasible without the workflowMolecule + +`buildAgentRequest` needs the hydrated molecule (invocationUrl, draft-aware config, isDirty — +`@agenta/playground` `state/execution/agentRequest.ts:300-412`) — that's why mobile live-send was +scoped out (flows-lite fact 9). But the resolver hydrates config **server-side** when an invoke +carries `references` and NO `data.parameters` +(`sdks/python/agenta/sdk/middlewares/running/resolver.py:575-596`). Session rows already carry +the latest turn's references (WP0 R3), and the invoke URL is `{revision.data.url|uri}/invoke` +(`@agenta/entities` `workflow/state/runnableSetup.ts:246-261`) — one Fern revision fetch. So a +mobile resume can send `{session_id, references, data:{inputs:{messages}}}` and skip molecule +hydration entirely. Caveat: a run started from a DIRTY desktop draft resumes with the +committed revision's config, not the draft (references-only hydration). + +### 1.6 Auth, stop, steer + +- **Auth:** the invoke routing middleware accepts the `sAccessToken` **cookie** (forwarded to + `/api/access/permissions/check` — `sdks/python/agenta/sdk/middlewares/routing/auth.py:98-116`), + and reads `project_id` from query params. Mobile's cookie-lite auth works on `/invoke`; + desktop's Bearer JWT is not required. +- **Stop (cancel-steer worktree, committed unmerged, 4 commits on `feat/agent-cancel-steer`):** + warm Stop = `POST /sessions/streams/` `{session_id}` (no inputs/force ⇒ `cancel` mode: + drop alive/running locks, `service.py:140-156`); the runner notices `is_current_turn:false` + on its next heartbeat (≤30s worst case) → cooperative abort with `stopReason:"cancelled"` + and `INTERRUPTED_BY_USER` on open tool calls (worktree `run-turn.ts:98-101, 751-845`, + `tracing/otel.ts:71-76`). Plain HTTP, already wrapped as `commandSessionStream` in + `@agenta/entities/session` (api.ts:398-421) — **directly mobile-reusable**. Without that + branch landed, cancel still aborts the run via the pre-existing heartbeat path but settles as + an errored turn instead of a clean "cancelled". Hard kill = `DELETE /sessions/streams/`. +- **Steer:** there is NO mid-turn message injection anywhere (verified grep, api + runner). The + control-plane `steer` command = force-cancel the running turn + start a new one + (`service.py:121-138`). The worktree's "Steer" is FE-only: **deny an approval with a redirect + instruction** — deny via `addToolApprovalResponse`, then queue the note as the next prompt + (worktree `ApprovalDock.tsx:167-244`, `AgentConversation.tsx:1073-1088`), OSS-app-only (not in + `@agenta/chat`), flag-gated OFF (`NEXT_PUBLIC_AGENT_CHAT_STEER`) because the harness has no + reject-with-feedback channel and the model flails on a bare deny (#5444 is the runner-level + fix). "Steer while running" on desktop is just the client-side queue (`useAgentChatQueue`). +- **Polling precedent:** desktop's dot poll is ONE project-scoped + `querySessionStreams({isAlive:true})`, low-priority, 15s while anything is alive, stops when + idle, refetch-on-focus (`web/oss/src/components/AgentChatSlice/state/liveness.ts:29-45`). + Records queries: staleTime 15s, IDB-persisted, guaranteed revalidation + (`@agenta/entities` `session/state/records.ts:21-50`). + +--- + +## 2. Options analysis + +### A. Poll-based approval surface (no BE changes) + +Mobile polls the coordination + durable planes; answers ride the same resume-invoke desktop +uses (verified non-stream). What it gives: + +- **Detect:** project-wide streams poll (running badge, 1 req/15s while alive, 0 when idle — + the desktop pattern verbatim) + project-wide `interactions/query {actionable_only:true}` + (pending-approval badge, 1 req/poll). Open-session transcript: existing records + revalidation. Latency to SEE an approval: one poll interval (records persist at pause time, + so ~5-30s depending on cadence). Battery/network: two small POSTs per interval, only while + something is alive — negligible next to one SSE held open. +- **Act:** approve/deny = records → messages (`loadSessionMessages`) + append the response + + lite resume-invoke (§1.5). Approve→agent-proceeds latency: immediate (warm park) — the + runner resumes the same sandbox if within 5 min; else cold replay (slower start, same + result). Mobile receives the resume stream as the POST response — it can render it live via + `@agenta/chat`'s own `useChat` machinery or fire-and-forget and fall back to record polling. +- **Stop:** `commandSessionStream` cancel — plain POST (≤30s cooperative latency). +- **Steer-lite:** deny-with-redirect (mirror the worktree behavior) and/or queue a message for + after settle. Same flag caveat as desktop. +- **What breaks / rough edges:** desktop, if open, does not live-update when mobile answers — + its reconciler adopts server transcripts only on open/revalidate and only when strictly ahead + (`useAgentConversation.ts:303-324`); it catches up on next open or records refetch. The + 5-min warm-park TTL means most phone answers (picked up later) hit the cold-replay path — + works, just slower. + +### B. "Stream takeover" + +**Not viable as imagined — there is no transferable stream** (§1.1). The attach command only +moves a 60s bookkeeping lock; it delivers zero tokens, and the displaced channel has no +subscribers, so desktop wouldn't even find out. What remains of B is already inside A: any turn +mobile INITIATES (send, approval resume) makes mobile the stream holder with live tokens for +free. Forcing takeover of a turn desktop holds would require the `steer` command = force-cancel +the running turn — destructive, not a watcher feature. Real takeover of live tokens ≈ building +C. Verdict: fold B into A ("you get live tokens for turns you start"), don't build an attach UI. + +### C. Multi-watcher fan-out (the right later fix) + +The producer side already exists: every event is teed through `POST /sessions/records/ingest` +which publishes to Redis (`records/streaming.py`). Honest scope: + +1. **API:** publish each ingested event on a per-session channel (one addition in the ingest + path), plus a new `GET /sessions/streams/watch?session_id=&cursor=` SSE endpoint: replay + records from cursor (uuid7 record id = natural resume token), then follow the channel. N + watchers, no runner changes, no lock semantics changes. +2. **Client:** an incremental records→UIMessage reducer (today `transcriptToMessages` is + whole-log; incremental application is new FE work in `@agenta/chat`). +3. **Auth/infra:** SSE auth (cookie fine), Traefik idle-timeout sanity, heartbeat comments. + +A few days of BE+FE work; also fixes desktop multi-tab and desktop-catching-up-live (§A's +rough edge). Not needed for the mobile MVP because approvals/stop/steer are all plain HTTP. + +### Push notifications (future, leave a seam) + +The single choke point where "approval pending" becomes durable is interaction-row creation +(`POST /sessions/interactions/` handler, router.py:597). A web-push dispatch hooks there +(row → subscription lookup → push). Do not build now; keep the mobile approval screen +deep-linkable (`/m/w/{ws}/p/{proj}/sessions/{id}`, already in the gate URL map) so a +notification later just carries a URL. + +### Recommendation + +**A now (two phases: read-only surface, then act), C later, B never as such.** A's polling is +the desktop's own proven pattern, its answer path is the exact POST desktop already exercises +daily, and the WP0/WP3a work already delivered every primitive it needs. Phase 2's +interactions-respond wiring (§1.4) is the only genuinely new BE work worth doing before C, and +it's small. + +--- + +## 3. Phased task list + +Raw-UI ethos throughout: plain buttons/text, no new shadcn installs, no motion. Constraints +from flows-lite apply (no OSS/EE app edits; packages allowed; operator steps written down, not +run). + +### Phase M0 — see it (FE only, no BE changes) + +- **M0.1** `web/mobile/src/features/sessions/useLivenessPoll.ts`: mirror + `liveness.ts:29-45` — project-scoped `querySessionStreams({isAlive:true})`, 15s-while-alive, + stop-when-idle, refetch-on-focus. Raw "running" text badge on `SessionRow` (flags already on + the rows). +- **M0.2** `useActionableInteractions.ts`: project-wide + `queryInteractions({actionableOnly:true})` (already exported from `@agenta/entities/session`, + api/api.ts:104-129) on the same poll cadence; map `session_id → count`; raw "needs approval" + badge on rows + a count chip on the sessions screen header. +- **M0.3** Chat screen: pending-approval card renders already via records replay + (`buildTurnViewModels` — verify the `approval-requested` part surfaces in the raw TurnRow; + add a raw highlighted "Approval pending" block with tool name + `JSON.stringify(input)`). + While pending/running: poll records at 5-10s (drop to the default 15s staleTime otherwise). + Buttons disabled with "Answer on desktop for now" until M1 lands. + +### Phase M1 — act on it (FE + package work, still no BE changes) + +- **M1.1** (package) `@agenta/playground` or `@agenta/chat`: `buildAgentResumeRequest({ + invocationUrl, references, sessionId, messages})` — the lite builder (§1.5): references-only + body, no `data.parameters`, cookie-auth headers (`Accept: text/event-stream`, + `x-ag-messages-format: vercel`), `project_id` on the query string (the middleware reads it — + auth.py:106-116; do NOT copy desktop's Authorization-gated omission). Unit tests against the + invariant that no `parameters` key is emitted. +- **M1.2** (package) small helper to resolve `invocationUrl` from a revision id via Fern + (mirror `getSessionsClient` accessor pattern) + the `data.url|uri → /invoke` rule + (runnableSetup.ts:246-261). Input: `references[0].id` off the session row / interactions row. +- **M1.3** (mobile) approve/deny actions: load fresh records → messages, stamp the + `approval-responded` part (reuse the shape `transcriptToMessages` produces), POST the resume + via M1.1. v1 delivery decision (open question 2): fire-and-forget + tighten the records poll + to ~3-5s until the turn settles, OR consume the response stream with `useChat`. Raw UI: two + buttons + "Resuming…" line. Approve-all = iterate gates (mirror `useApprovalDock.approveAll` + semantics; all responses ride ONE resume POST since they're all parts of the same tail). +- **M1.4** (mobile) Stop button on a running session: `commandSessionStream({sessionId, + projectId})` (cancel mode). Show "Stopping… (can take up to 30s)" and let the liveness poll + confirm. **Dependency flag:** clean `"cancelled"` settle needs `feat/agent-cancel-steer` + landed; before that the turn ends as an error record — acceptable raw-UI interim, note in UI + copy. +- **M1.5** (mobile) Steer-lite, flag-gated with the SAME env flag name as desktop + (`NEXT_PUBLIC_AGENT_CHAT_STEER`): deny-with-redirect (deny + prepend the instruction to the + next send) — mirror the worktree's envelope exactly so the two implementations converge. + **Dependency flag:** UX blocked on the same harness limitation; do not enable by default + until #5444 (runner reject-with-feedback) exists. + +### Phase M2 — BE: wire the out-of-band respond path (small, separable) + +- **M2.1** (BE) Define + implement the `answer` contract for + `POST /sessions/interactions/{id}/respond` (§1.4): the dispatcher must produce + `data.inputs` such that the agent service composes a message history carrying the + `{approved, interactionToken}` tool_result for the gated `toolCallId` (what the decision map + reads — `session-identity.ts:274-291`). Likely: the dispatcher (not the client) loads the + session records server-side and appends the response — keeping the client payload to + `{approved: boolean, tool_call_id, message?}`. Add a pytest that runs the CAS + dispatch and + asserts the runner-visible envelope. Coordinate with the sessions feature owner (JP) — the + plumbing was built then deprioritized. +- **M2.2** (FE) Switch mobile M1.3 to `respondInteraction` (already in + `@agenta/entities/session`, api.ts:176-199): no transcript reconstruction, no revision fetch, + detached (nobody holds the stream — the battery-optimal path). Keep M1.3 as fallback. +- **M2.3** (BE, optional) `respond` accepts a `message` for deny-with-redirect so steer-lite + also goes out-of-band. + +### Phase M3 — BE: multi-watcher live relay (per §C; separate design doc when scheduled) + +Per-session live channel published from records ingest + `watch` SSE endpoint with +record-id cursor; FE incremental records reducer in `@agenta/chat`. Benefits both mobile and +desktop multi-tab. Not gating anything above. + +### Phase M4 seam — push notifications + +Web-push dispatch at interaction creation; deep link to the session URL. Requires M2's respond +path for the "approve from the notification" dream, else it just opens the chat screen. + +--- + +## 4. Open questions for Arda + +1. **Stream ownership on mobile answer (v1):** answering from the phone makes the phone the new + stream holder; an open desktop won't live-update the resumed turn (it catches up on + reopen/refetch). Acceptable until M3? (The alternative is blocking mobile approvals on M3.) +2. **Fire-and-forget vs live-consume on approve:** consume the resume SSE on the phone (live + tokens; dies if the phone locks — run continues regardless) or fire-and-forget + 3-5s + records polling until settle? F&F is simpler and battery-friendlier; live feels better. +3. **Polling cadence:** desktop-mirror (15s) for list badges + 5s only while a chat screen with + a running/pending turn is foregrounded — OK, or stricter? +4. **Steer v1 semantics:** is deny-with-redirect (behind the same off-by-default flag as + desktop) worth shipping on mobile before the runner's reject-with-feedback (#5444), or skip + steer entirely in v1 and ship only queue-next-message? +5. **Warm-park TTL:** most phone answers will land after the 5-min `approvalTtlMs` → cold + replay (slower resume). Bump the TTL when a pending interaction exists, or accept? +6. **M2 ownership:** the interactions respond contract touches JP's deferred design — should M2 + be proposed to him now (it is the clean mobile path AND the push-notification prerequisite), + or do we ship M1's resume-invoke path and wait? +7. **Always-allow:** desktop's "always allow this tool" is an app-layer config write-through — + out of scope for mobile v1? (Approve-all within a turn IS in scope, M1.3.) + +## 5. Dependencies and conflicts + +- **`feat/agent-cancel-steer` (unmerged):** M1.4's clean cancel and M1.5's flag/envelope mirror + depend on it landing; nothing here edits the same files (mobile + packages only), so no + conflict — but land it first or accept error-shaped cancels in the interim. +- **Stale FOLLOWUP comment:** `@agenta/entities` session api.ts:392-396 ("cancel/steer would be + a no-op stub") predates the cancel-steer branch — update when that branch lands. +- **Flows-lite T1-T6** (packages wired into mobile, sessions list, read-only replay) are the + substrate for everything above; M0 assumes they are merged. +- **Records-poll cost:** the records query is the heavy one (~200KB on long sessions, backend + noted slow). The M0.3/M1.3 tightened cadence must be foreground-only + only while + running/pending; back off on `visibilitychange`. From 410e638cd4903c34ef2232e51ffe1a5782d0b2d6 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 20:42:32 +0300 Subject: [PATCH 02/22] docs(mobile): record approvals-plan decisions and the two standing follow-ups --- .../2026-07-27-mobile-approvals-steering.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md b/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md index 4adaabeeee..905c415845 100644 --- a/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md +++ b/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md @@ -306,6 +306,23 @@ path for the "approve from the notification" dream, else it just opens the chat 7. **Always-allow:** desktop's "always allow this tool" is an app-layer config write-through — out of scope for mobile v1? (Approve-all within a turn IS in scope, M1.3.) +## 4b. Decisions (Arda, 2026-07-27) + +1. **Stream ownership on mobile answer: ACCEPTED for v1.** ⚠️ **FOLLOW-UP (do not forget): + M3 live relay** is the durable fix — an open desktop must eventually live-update a turn + the phone resumed. +2. **Fire-and-forget on approve** — no live SSE consumption on the phone; poll/records + refresh the transcript until settle. +3. **Steer-lite: WAIT** — do not ship deny-with-redirect now; wait for the runner's + reject-with-feedback (#5444). ⚠️ **FOLLOW-UP (do not forget): Arda may ask for this + implementation next**; the M1.5 task stays specced and unbuilt. +4. **Warm-park TTL: BUMP** when a pending interaction exists (phone-latency answers should + warm-resume, not cold-replay). +5. **M2: build it in this workstream** ("finish this yourself") — do not hand to JP. +6. **Always-allow: out of scope** for v1 (approve-all within a turn IS in scope). + +Execution scope now: M0 + M1 (minus M1.5 steer) + TTL bump + M2. + ## 5. Dependencies and conflicts - **`feat/agent-cancel-steer` (unmerged):** M1.4's clean cancel and M1.5's flag/envelope mirror From fc3954d26c122e929a9628b692b0c0e87fe61058 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 20:55:30 +0300 Subject: [PATCH 03/22] feat(runner): widen the approval warm-park window to 30 minutes An awaiting_approval park is exactly the pending-interaction case: the turn paused on a human gate and the sandbox waits warm. Phone-latency answers (mobile approvals, plan 4b-4) mostly landed after the old 5-minute window and degraded to cold replay; 30 minutes keeps them on the warm respondPermission resume. Still bounded by the mount-credential expiry check and overridable via AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS. --- .../src/engines/sandbox_agent/session-identity.ts | 8 +++++++- services/runner/src/server.ts | 2 +- services/runner/tests/unit/session-pool.test.ts | 15 +++++++++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/services/runner/src/engines/sandbox_agent/session-identity.ts b/services/runner/src/engines/sandbox_agent/session-identity.ts index ce954d5404..e7500d93c7 100644 --- a/services/runner/src/engines/sandbox_agent/session-identity.ts +++ b/services/runner/src/engines/sandbox_agent/session-identity.ts @@ -37,7 +37,13 @@ const APPROVAL_TTL_ENV = "AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS"; const POOL_MAX_ENV = "AGENTA_RUNNER_SESSION_POOL_MAX"; const DEFAULT_TTL_MS = 60_000; -const DEFAULT_APPROVAL_TTL_MS = 300_000; +// Thirty minutes. An approval park by definition has a pending interaction row waiting on a +// human, and answers increasingly arrive from a phone minutes later (mobile approvals plan, +// 2026-07-27 §4b-4): a 5-minute window pushed most of those onto the slower cold-replay path. +// The window is still bounded by the mount-credential expiry check, expiry degrades to cold +// (never fails the turn), and an awaiting_approval entry keeps holding a pool slot — override +// via AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS if warm slots are contended. +const DEFAULT_APPROVAL_TTL_MS = 1_800_000; const DEFAULT_POOL_MAX = 8; const DAYTONA_TTL_ENV = "AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS"; const DAYTONA_POOL_MAX_ENV = "AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM"; diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 3b871214aa..789fc25aff 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -516,7 +516,7 @@ export async function runWithKeepalive( // A parked prompt that REJECTS while the session sits in awaiting_approval means the harness // or sandbox died mid-park; the dead session must not occupy a pool slot until the approval TTL - // (5 minutes by default) expires. Identity-checked: the handler evicts only while THIS exact + // (30 minutes by default) expires. Identity-checked: the handler evicts only while THIS exact // entry is still parked at the key. A rejection that lands after a successful checkout (the // resume is in flight and owns the environment; its own try/catch handles the failure) or // after a supersede is not ours and does nothing. `evict` is idempotent through the session's diff --git a/services/runner/tests/unit/session-pool.test.ts b/services/runner/tests/unit/session-pool.test.ts index c737a43065..270adc26fc 100644 --- a/services/runner/tests/unit/session-pool.test.ts +++ b/services/runner/tests/unit/session-pool.test.ts @@ -154,15 +154,26 @@ describe("readKeepaliveConfig", () => { } }); - it("defaults: on, 60s idle, 5m approval, cap 8", () => { + it("defaults: on, 60s idle, 30m approval, cap 8", () => { + // The approval window is the pending-interaction park: 30 minutes so a phone-latency + // answer warm-resumes instead of cold-replaying (mobile approvals plan §4b-4). assert.deepEqual(readKeepaliveConfig("local"), { enabled: true, ttlMs: 60_000, - approvalTtlMs: 300_000, + approvalTtlMs: 1_800_000, poolMax: 8, }); }); + it("approval TTL stays env-overridable, with invalid values falling back", () => { + process.env.AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS = "300000"; + assert.equal(readKeepaliveConfig("local").approvalTtlMs, 300_000); + process.env.AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS = "0"; + assert.equal(readKeepaliveConfig("local").approvalTtlMs, 1_800_000); + process.env.AGENTA_RUNNER_SESSION_APPROVAL_TTL_MS = "nope"; + assert.equal(readKeepaliveConfig("local").approvalTtlMs, 1_800_000); + }); + it("reads truthy spellings for the flag and positive ints for the numbers", () => { process.env.AGENTA_RUNNER_SESSION_KEEPALIVE = "true"; process.env.AGENTA_RUNNER_SESSION_TTL_MS = "5000"; From 07a8b1db869e62d76303158cbd5a374c588f983e Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 20:55:41 +0300 Subject: [PATCH 04/22] feat(mobile): M0.1 project liveness poll + running badge on session rows One project-scoped querySessionStreams({isAlive:true}) poll mirroring the desktop liveness pattern: low-priority, 15s while anything is alive, stops when idle, re-checks on focus. Session rows read a fresh running/live badge off the shared poll, falling back to the list row's own flags until it resolves. --- .../features/sessions/SessionListScreen.tsx | 6 ++- .../src/features/sessions/SessionRow.tsx | 20 +++++++++- .../src/features/sessions/useLivenessPoll.ts | 37 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 web/mobile/src/features/sessions/useLivenessPoll.ts diff --git a/web/mobile/src/features/sessions/SessionListScreen.tsx b/web/mobile/src/features/sessions/SessionListScreen.tsx index dab1ac9b85..12573507a0 100644 --- a/web/mobile/src/features/sessions/SessionListScreen.tsx +++ b/web/mobile/src/features/sessions/SessionListScreen.tsx @@ -1,4 +1,4 @@ -import {useEffect, useState} from "react" +import {useEffect, useMemo, useState} from "react" import {clearLastContext} from "@/lib/context" @@ -6,6 +6,7 @@ import {classifyPageFailure} from "./pageFailure" import {SessionRow} from "./SessionRow" import {SessionSearchBar} from "./SessionSearchBar" import {SessionListEmpty, SessionListError, SessionListLoading} from "./states/SessionListStates" +import {livenessBySession, useLivenessPoll} from "./useLivenessPoll" import {useSessionsInfinite} from "./useSessionsInfinite" /** Sessions list: server-side search, id+activity cursor paging, archived rows hidden. */ @@ -24,6 +25,8 @@ export const SessionListScreen = ({ }, [input]) const query = useSessionsInfinite(projectId, search) + const liveness = useLivenessPoll(projectId) + const liveBadges = useMemo(() => livenessBySession(liveness.data), [liveness.data]) const pages = query.data?.pages ?? [] const {failed, laterPageFailed} = classifyPageFailure(pages, query.isError) @@ -51,6 +54,7 @@ export const SessionListScreen = ({ key={session.id} session={session} href={`/w/${workspaceId}/p/${projectId}/sessions/${session.session_id}`} + liveness={liveBadges ? (liveBadges.get(session.session_id) ?? null) : undefined} /> ))} {laterPageFailed || query.hasNextPage ? ( diff --git a/web/mobile/src/features/sessions/SessionRow.tsx b/web/mobile/src/features/sessions/SessionRow.tsx index ed4902f9ff..bebfdf2291 100644 --- a/web/mobile/src/features/sessions/SessionRow.tsx +++ b/web/mobile/src/features/sessions/SessionRow.tsx @@ -1,6 +1,8 @@ import type {SessionStream} from "@agenta/entities/session" import Link from "next/link" +import type {SessionLivenessBadge} from "./useLivenessPoll" + /** Raw relative time ("3h ago") — enough for the LITE phase, no dayjs. */ const timeAgo = (iso: string | null | undefined): string | null => { if (!iso) return null @@ -15,14 +17,28 @@ const timeAgo = (iso: string | null | undefined): string | null => { return `${Math.floor(hours / 24)}d ago` } -export const SessionRow = ({session, href}: {session: SessionStream; href: string}) => { +export const SessionRow = ({ + session, + href, + liveness, +}: { + session: SessionStream + href: string + /** Fresh badge from the shared liveness poll; `undefined` = poll unresolved (fall back to + * the list row's own flags), `null` = poll resolved and this session is idle. */ + liveness?: SessionLivenessBadge | null +}) => { const agentLabel = session.references?.[0]?.slug ?? session.references?.[0]?.id ?? "—" const activity = timeAgo(session.updated_at ?? session.created_at) + const badge = + liveness === undefined ? (session.flags?.is_alive ? "alive" : null) : (liveness ?? null) return ( {session.name ?? "Untitled session"} - {session.flags?.is_alive ? ( + {badge === "running" ? ( + running + ) : badge === "alive" ? ( live ) : null} {session.deleted_at ? ( diff --git a/web/mobile/src/features/sessions/useLivenessPoll.ts b/web/mobile/src/features/sessions/useLivenessPoll.ts new file mode 100644 index 0000000000..be75ca9f32 --- /dev/null +++ b/web/mobile/src/features/sessions/useLivenessPoll.ts @@ -0,0 +1,37 @@ +import {querySessionStreams, type SessionStream} from "@agenta/entities/session" +import {useQuery} from "@tanstack/react-query" + +/** Shared key so other polls (interactions) can read the alive set from the cache. */ +export const livenessQueryKey = (projectId: string) => + ["mobile", "session-liveness", projectId] as const + +/** + * Backend liveness for the project's sessions — mirrors the desktop pattern + * (oss AgentChatSlice state/liveness.ts): ONE project-scoped `is_alive=true` query backs every + * badge, low-priority, 15s while anything is alive, stops when idle, re-checks on focus. + */ +export const useLivenessPoll = (projectId: string) => + useQuery({ + queryKey: livenessQueryKey(projectId), + queryFn: ({signal}) => + querySessionStreams({projectId, isAlive: true, abortSignal: signal, lowPriority: true}), + enabled: Boolean(projectId), + staleTime: 10_000, + refetchInterval: (query) => ((query.state.data?.length ?? 0) > 0 ? 15_000 : false), + refetchOnWindowFocus: true, + }) + +/** Coarse badge state for one session, derived from the shared poll. */ +export type SessionLivenessBadge = "running" | "alive" + +/** `session_id → badge` off the poll result; `undefined` while the poll hasn't resolved. */ +export const livenessBySession = ( + streams: SessionStream[] | null | undefined, +): Map | undefined => { + if (streams === undefined || streams === null) return undefined + const map = new Map() + for (const stream of streams) { + map.set(stream.session_id, stream.flags?.is_running ? "running" : "alive") + } + return map +} From 8b2e8c98ee7acd4a2397f5d35a963636709a3bbc Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 21:03:23 +0300 Subject: [PATCH 05/22] feat(mobile): M0.2 project-wide pending-approval badges on the session list queryInteractions in @agenta/entities/session now allows omitting session_id (the backend already treats it as optional), so ONE actionable_only query returns every pending approval in the project. Mobile polls it on the liveness cadence (15s while pending or alive, stop when idle, refetch on focus) and renders a needs-approval badge per row plus a pending count in the header. --- .../features/sessions/SessionListScreen.tsx | 19 +++++++- .../src/features/sessions/SessionRow.tsx | 6 +++ .../sessions/useActionableInteractions.ts | 48 +++++++++++++++++++ .../agenta-entities/src/session/api/api.ts | 14 ++++-- 4 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 web/mobile/src/features/sessions/useActionableInteractions.ts diff --git a/web/mobile/src/features/sessions/SessionListScreen.tsx b/web/mobile/src/features/sessions/SessionListScreen.tsx index 12573507a0..5a7b7f56da 100644 --- a/web/mobile/src/features/sessions/SessionListScreen.tsx +++ b/web/mobile/src/features/sessions/SessionListScreen.tsx @@ -6,6 +6,7 @@ import {classifyPageFailure} from "./pageFailure" import {SessionRow} from "./SessionRow" import {SessionSearchBar} from "./SessionSearchBar" import {SessionListEmpty, SessionListError, SessionListLoading} from "./states/SessionListStates" +import {pendingCountBySession, useActionableInteractions} from "./useActionableInteractions" import {livenessBySession, useLivenessPoll} from "./useLivenessPoll" import {useSessionsInfinite} from "./useSessionsInfinite" @@ -27,6 +28,12 @@ export const SessionListScreen = ({ const query = useSessionsInfinite(projectId, search) const liveness = useLivenessPoll(projectId) const liveBadges = useMemo(() => livenessBySession(liveness.data), [liveness.data]) + const interactions = useActionableInteractions(projectId) + const pendingBySession = useMemo( + () => pendingCountBySession(interactions.data), + [interactions.data], + ) + const pendingTotal = interactions.data?.length ?? 0 const pages = query.data?.pages ?? [] const {failed, laterPageFailed} = classifyPageFailure(pages, query.isError) @@ -54,7 +61,10 @@ export const SessionListScreen = ({ key={session.id} session={session} href={`/w/${workspaceId}/p/${projectId}/sessions/${session.session_id}`} - liveness={liveBadges ? (liveBadges.get(session.session_id) ?? null) : undefined} + liveness={ + liveBadges ? (liveBadges.get(session.session_id) ?? null) : undefined + } + pendingApprovals={pendingBySession?.get(session.session_id) ?? 0} /> ))} {laterPageFailed || query.hasNextPage ? ( @@ -77,8 +87,13 @@ export const SessionListScreen = ({ return (
-
+
+ {pendingTotal > 0 ? ( +

+ {pendingTotal} approval{pendingTotal === 1 ? "" : "s"} pending +

+ ) : null}
{body}
diff --git a/web/mobile/src/features/sessions/SessionRow.tsx b/web/mobile/src/features/sessions/SessionRow.tsx index bebfdf2291..4d0736301f 100644 --- a/web/mobile/src/features/sessions/SessionRow.tsx +++ b/web/mobile/src/features/sessions/SessionRow.tsx @@ -21,12 +21,15 @@ export const SessionRow = ({ session, href, liveness, + pendingApprovals = 0, }: { session: SessionStream href: string /** Fresh badge from the shared liveness poll; `undefined` = poll unresolved (fall back to * the list row's own flags), `null` = poll resolved and this session is idle. */ liveness?: SessionLivenessBadge | null + /** Pending HITL approvals for this session (project-wide interactions poll). */ + pendingApprovals?: number }) => { const agentLabel = session.references?.[0]?.slug ?? session.references?.[0]?.id ?? "—" const activity = timeAgo(session.updated_at ?? session.created_at) @@ -41,6 +44,9 @@ export const SessionRow = ({ ) : badge === "alive" ? ( live ) : null} + {pendingApprovals > 0 ? ( + needs approval + ) : null} {session.deleted_at ? ( ended ) : null} diff --git a/web/mobile/src/features/sessions/useActionableInteractions.ts b/web/mobile/src/features/sessions/useActionableInteractions.ts new file mode 100644 index 0000000000..879e983c72 --- /dev/null +++ b/web/mobile/src/features/sessions/useActionableInteractions.ts @@ -0,0 +1,48 @@ +import { + queryInteractions, + type SessionInteraction, + type SessionStream, +} from "@agenta/entities/session" +import {useQuery, useQueryClient} from "@tanstack/react-query" + +import {livenessQueryKey} from "./useLivenessPoll" + +export const actionableInteractionsQueryKey = (projectId: string) => + ["mobile", "actionable-interactions", projectId] as const + +/** + * Every pending HITL request across the project in ONE query (`session_id` omitted, + * `actionable_only: true`) — the list-badge primitive. Same cadence rules as the liveness poll: + * 15s while anything is pending OR alive (a running turn is what mints new gates), stops when + * idle, re-checks on focus. + */ +export const useActionableInteractions = (projectId: string) => { + const queryClient = useQueryClient() + return useQuery({ + queryKey: actionableInteractionsQueryKey(projectId), + queryFn: ({signal}) => + queryInteractions({projectId, actionableOnly: true, abortSignal: signal}), + enabled: Boolean(projectId), + staleTime: 10_000, + refetchInterval: (query) => { + if ((query.state.data?.length ?? 0) > 0) return 15_000 + const alive = queryClient.getQueryData( + livenessQueryKey(projectId), + ) + return (alive?.length ?? 0) > 0 ? 15_000 : false + }, + refetchOnWindowFocus: true, + }) +} + +/** `session_id → pending count` off the poll result; `undefined` while it hasn't resolved. */ +export const pendingCountBySession = ( + interactions: SessionInteraction[] | null | undefined, +): Map | undefined => { + if (interactions === undefined || interactions === null) return undefined + const map = new Map() + for (const interaction of interactions) { + map.set(interaction.session_id, (map.get(interaction.session_id) ?? 0) + 1) + } + return map +} diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 68561c3d12..3ea4f85305 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -89,7 +89,11 @@ export interface SessionScopedParams { abortSignal?: AbortSignal } -export interface QueryInteractionsParams extends SessionScopedParams { +export interface QueryInteractionsParams extends Omit { + /** Omit for a PROJECT-WIDE query — the backend treats `session_id` as optional, so one call + * returns every matching interaction across the project (the pending-approvals badge + * primitive). */ + sessionId?: string kind?: SessionInteractionKind status?: SessionInteractionStatusCode /** Only requests still awaiting an answer. */ @@ -97,9 +101,9 @@ export interface QueryInteractionsParams extends SessionScopedParams { } /** - * List a session's HITL interactions (pending approvals etc.). Used to know whether a - * record-rendered request is still actionable — NOT as the render source (the record renders - * the question; interactions hold the answer-state). + * List HITL interactions (pending approvals etc.) — one session's, or the whole project's when + * `sessionId` is omitted. Used to know whether a record-rendered request is still actionable — + * NOT as the render source (the record renders the question; interactions hold the answer-state). */ export async function queryInteractions({ sessionId, @@ -110,7 +114,7 @@ export async function queryInteractions({ status, actionableOnly, }: QueryInteractionsParams): Promise { - if (!projectId || !sessionId) return null + if (!projectId) return null const data = await callFern("[queryInteractions]", () => getSessionsClient().queryInteractions( From 70ed624c9ce23b75d504cdd7be455064a9fbe420 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 21:05:31 +0300 Subject: [PATCH 06/22] feat(api): compose the approval answer for the detached interactions respond path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /sessions/interactions/{id}/respond existed but had no producer of a runner-consumable answer: the dispatcher forwarded the raw client payload as data.inputs, which the agent service cannot turn into a resumable conversation. The dispatcher now composes the resume conversation server-side for user_approval interactions (mobile approvals plan M2.1): it replays the session's durable records into wire messages and appends the {approved, interactionToken} tool_result envelope bound to the gated toolCallId — the exact shape the runner's decision map and warm approval-park resume read. The client payload stays {approved, tool_call_id?, message?}; an optional message rides as a trailing user note (deny-with-redirect, M2.3). The envelope lands on the last assistant message, never a new user prompt, so a warm-parked sandbox keeps its history-fingerprint match and resumes live; with no records the gated call anchor is synthesized from the interaction row so cold replay can still bind the decision by name+args. Wiring: the dispatcher gains the records service in both compositions (API producer and queue worker), and the route's no-worker fallback now goes through the dispatcher so both paths share one composition. --- api/entrypoints/routers.py | 2 + api/entrypoints/worker_queues.py | 13 +- api/oss/src/apis/fastapi/sessions/models.py | 3 + api/oss/src/apis/fastapi/sessions/router.py | 19 +- .../sessions/interactions_dispatcher.py | 240 ++++++++++++++++- .../sessions/test_interactions_dispatcher.py | 245 +++++++++++++++++- .../test_respond_interaction_enqueue.py | 51 ++++ 7 files changed, 562 insertions(+), 11 deletions(-) diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index 60879503d1..34cbe49015 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -854,6 +854,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: _interactions_dispatcher = InteractionsDispatcher( workflows_service=workflows_service, interactions_service=interactions_service, + records_service=records_service, dispatch_fn=_dispatch_detached_run, ) @@ -1098,6 +1099,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: turns_service=session_turns_service, sessions_service=sessions_service, respond_task=_interactions_worker.respond_interaction, + interactions_dispatcher=_interactions_dispatcher, ) # PLATFORM ADMIN --------------------------------------------------------------- diff --git a/api/entrypoints/worker_queues.py b/api/entrypoints/worker_queues.py index e6b8622557..a397ca550b 100644 --- a/api/entrypoints/worker_queues.py +++ b/api/entrypoints/worker_queues.py @@ -49,6 +49,7 @@ from oss.src.core.evaluators.service import EvaluatorsService, SimpleEvaluatorsService from oss.src.core.queries.service import QueriesService from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.records.service import RecordsService from oss.src.core.testcases.service import TestcasesService from oss.src.core.testsets.service import SimpleTestsetsService, TestsetsService from oss.src.core.tracing.service import TracingService @@ -67,7 +68,11 @@ QueryVariantDBE, ) from oss.src.dbs.postgres.sessions.interactions.dao import SessionInteractionsDAO -from oss.src.dbs.postgres.shared.engine import get_transactions_engine +from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO +from oss.src.dbs.postgres.shared.engine import ( + get_analytics_engine, + get_transactions_engine, +) from oss.src.dbs.postgres.testcases.dbes import TestcaseBlobDBE from oss.src.dbs.postgres.testsets.dbes import ( TestsetArtifactDBE, @@ -214,6 +219,11 @@ def _build_interactions_broker() -> tuple[AsyncBroker, int]: environments_service.embeds_service = embeds_service interactions_service = SessionInteractionsService(interactions_dao=interactions_dao) + # Approval answers replay the session's durable records into the resume conversation; + # records live on the analytics engine (same as the API composition in routers.py). + records_service = RecordsService( + records_dao=RecordsDAO(engine=get_analytics_engine()), + ) async def _dispatch_detached_run(*, project_id, user_id, request) -> str: result = await workflows_service.invoke_workflow_detached( @@ -226,6 +236,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: interactions_dispatcher = InteractionsDispatcher( workflows_service=workflows_service, interactions_service=interactions_service, + records_service=records_service, dispatch_fn=_dispatch_detached_run, ) InteractionsWorker(broker=broker, dispatcher=interactions_dispatcher) diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index 2ac7817168..936e1d3735 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -159,6 +159,9 @@ class SessionInteractionsResponse(BaseModel): class SessionInteractionRespondRequest(BaseModel): + # For a user_approval interaction the answer is {approved: bool, tool_call_id?: str, + # message?: str} — the dispatcher composes the full resume conversation server-side + # (interactions_dispatcher.compose_approval_messages). Other kinds pass through as-is. answer: Optional[Dict[str, Any]] = None diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 5792e8c550..1fb8b12b34 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -639,10 +639,15 @@ def __init__( interactions_service: SessionInteractionsService, workflows_service: WorkflowsService, respond_task: Optional[Any] = None, + # InteractionsDispatcher (typed loosely, like respond_task: the API layer does not + # import the tasks layer). When present, the no-worker respond fallback goes through + # it so both paths share ONE answer-composition implementation. + interactions_dispatcher: Optional[Any] = None, ) -> None: self.interactions_service = interactions_service self.workflows_service = workflows_service self.respond_task = respond_task + self.interactions_dispatcher = interactions_dispatcher self.router = APIRouter() @@ -910,8 +915,9 @@ async def respond_interaction( detail="Interaction is no longer pending", ) - # Enqueue onto the interactions worker when wired; otherwise fall back to an - # inline blocking invoke (keeps the route usable in minimal/test compositions). + # Enqueue onto the interactions worker when wired; otherwise fall back to the + # dispatcher directly (same answer composition, fired in-process), or as a last + # resort an inline blocking invoke (keeps minimal/test compositions usable). if self.respond_task is not None: await self.respond_task.kiq( project_id=str(project_id), @@ -919,6 +925,13 @@ async def respond_interaction( interaction_id=str(interaction_id), answer=answer, ) + elif self.interactions_dispatcher is not None: + await self.interactions_dispatcher.respond( + project_id=UUID(str(project_id)), + user_id=UUID(str(user_id)), + interaction_id=interaction_id, + answer=answer, + ) else: references = ( { @@ -1672,6 +1685,7 @@ def __init__( turns_service: SessionTurnsService, sessions_service: SessionsService, respond_task: Optional[Any] = None, + interactions_dispatcher: Optional[Any] = None, ) -> None: self.streams = SessionStreamsRouter( service=streams_service, @@ -1682,6 +1696,7 @@ def __init__( interactions_service=interactions_service, workflows_service=workflows_service, respond_task=respond_task, + interactions_dispatcher=interactions_dispatcher, ) self.attachments = SessionAttachmentsRouter( attachments_service=attachments_service, diff --git a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py index 5f007e0836..c20c0786f9 100644 --- a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py +++ b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py @@ -1,7 +1,28 @@ -from typing import Any, Callable, Optional +"""Respond-via-invoke: turn a stored human answer into a detached workflow run. + +For a ``user_approval`` interaction the dispatcher composes the runner-visible +resume conversation SERVER-SIDE (mobile approvals plan, M2.1): it replays the +session's durable records into wire messages and appends the approval envelope +``{approved, interactionToken}`` as a ``tool_result`` block bound to the gated +``toolCallId`` — the exact shape the runner's decision map reads +(``services/runner/src/responder.ts`` ``storedApprovalDecisionOf`` / +``session-identity.ts`` ``approvalDecisionForToolCall``). The client payload +stays minimal: ``{approved: bool, tool_call_id?, message?}``. + +Every other interaction kind keeps the original passthrough contract +(``data.inputs = answer``). +""" + +from typing import Any, Callable, Dict, List, Optional from uuid import UUID -from oss.src.core.sessions.interactions.dtos import SessionInteractionData +from oss.src.core.sessions.interactions.dtos import ( + SessionInteraction, + SessionInteractionData, + SessionInteractionKind, +) +from oss.src.core.sessions.records.dtos import SessionRecord +from oss.src.core.sessions.records.service import RecordsService from oss.src.core.sessions.interactions.service import SessionInteractionsService from oss.src.core.workflows.dtos import ( WorkflowServiceRequest, @@ -14,6 +35,185 @@ log = get_module_logger(__name__) +def build_wire_messages(records: List[SessionRecord]) -> List[Dict[str, Any]]: + """Replay durable session records into runner wire messages. + + Mirrors the frontend's ``transcriptToMessages`` grouping: a ``user`` record opens a + user message; a contiguous run of agent records folds into one assistant message whose + content blocks carry text and resolved tool turns. Non-conversation records (thoughts, + usage, errors, interaction bookkeeping) are skipped — they are renderable history, not + replayable conversation. + """ + messages: List[Dict[str, Any]] = [] + assistant_blocks: Optional[List[Dict[str, Any]]] = None + + def close_assistant() -> None: + nonlocal assistant_blocks + assistant_blocks = None + + def assistant() -> List[Dict[str, Any]]: + nonlocal assistant_blocks + if assistant_blocks is None: + assistant_blocks = [] + messages.append({"role": "assistant", "content": assistant_blocks}) + return assistant_blocks + + for record in records: + attributes = record.attributes or {} + record_type = record.record_type or attributes.get("type") + + if record.record_source == "user": + text = attributes.get("text") + if isinstance(text, str) and text: + close_assistant() + messages.append({"role": "user", "content": text}) + continue + + if record_type == "message": + text = attributes.get("text") + if isinstance(text, str) and text: + assistant().append({"type": "text", "text": text}) + elif record_type == "tool_call": + block: Dict[str, Any] = {"type": "tool_call"} + if attributes.get("id"): + block["toolCallId"] = attributes["id"] + if attributes.get("name"): + block["toolName"] = attributes["name"] + if attributes.get("input") is not None: + block["input"] = attributes["input"] + assistant().append(block) + elif record_type == "tool_result": + block = {"type": "tool_result"} + if attributes.get("id"): + block["toolCallId"] = attributes["id"] + output = attributes.get("data") + if output is None: + output = attributes.get("output") + if output is not None: + block["output"] = output + if attributes.get("isError") is not None: + block["isError"] = attributes["isError"] + assistant().append(block) + # Everything else (thought, usage, error, done, data, file, interaction_request, + # interaction_response) is not part of the replayable conversation. + + return messages + + +def resolve_gated_tool_call_id( + records: List[SessionRecord], + interaction: SessionInteraction, + answer: Dict[str, Any], +) -> str: + """The tool-call id the envelope must bind to. + + Precedence: the client's explicit ``tool_call_id``; else the persisted + ``interaction_request`` record whose event id is the interaction token (its payload + carries the gated ``toolCallId``); else the token itself (the runner's event id falls + back to the tool-call id when the permission id is empty, so this stays a valid anchor + for the synthesized-history path). + """ + explicit = answer.get("tool_call_id") + if isinstance(explicit, str) and explicit: + return explicit + + for record in records: + attributes = record.attributes or {} + record_type = record.record_type or attributes.get("type") + if record_type != "interaction_request": + continue + if attributes.get("id") != interaction.token: + continue + payload = attributes.get("payload") or {} + tool_call_id = payload.get("toolCallId") + if isinstance(tool_call_id, str) and tool_call_id: + return tool_call_id + + return interaction.token + + +def _gated_call_shape( + records: List[SessionRecord], + interaction: SessionInteraction, +) -> Dict[str, Any]: + """Recover the gated call's name+args (the runner's cold-replay anchor).""" + for record in records: + attributes = record.attributes or {} + record_type = record.record_type or attributes.get("type") + if record_type != "interaction_request": + continue + if attributes.get("id") != interaction.token: + continue + tool_call = (attributes.get("payload") or {}).get("toolCall") or {} + name = tool_call.get("resolvedName") or tool_call.get("title") + args = tool_call.get("rawInput") + if name or args is not None: + return {"name": name, "args": args} + + data: Optional[SessionInteractionData] = interaction.data + request = (data.request if data else None) or {} + return {"name": request.get("tool"), "args": request.get("args")} + + +def compose_approval_messages( + records: List[SessionRecord], + interaction: SessionInteraction, + answer: Dict[str, Any], +) -> List[Dict[str, Any]]: + """The full resume conversation: replayed history + the approval envelope. + + The envelope rides as a ``tool_result`` block on the LAST assistant message (never a + new user message — the runner's history fingerprint counts user prompts, and the + envelope's tool-call id dedupes against the already-present ``tool_call`` block, so a + warm-parked sandbox still fingerprint-matches and resumes live). An optional + deny-with-redirect ``message`` is appended as a trailing user message, which the + fingerprint's prior-conversation slice excludes. + """ + messages = build_wire_messages(records) + gated_id = resolve_gated_tool_call_id(records, interaction, answer) + + has_gated_call = any( + block.get("type") == "tool_call" and block.get("toolCallId") == gated_id + for message in messages + if isinstance(message.get("content"), list) + for block in message["content"] + ) + if not has_gated_call: + # No durable tool_call record (e.g. records unavailable): synthesize the anchor the + # runner's call-shape index needs to bind the envelope to name+args. + shape = _gated_call_shape(records, interaction) + block = {"type": "tool_call", "toolCallId": gated_id} + if shape.get("name"): + block["toolName"] = shape["name"] + if shape.get("args") is not None: + block["input"] = shape["args"] + messages.append({"role": "assistant", "content": [block]}) + + envelope = { + "type": "tool_result", + "toolCallId": gated_id, + "output": { + "approved": bool(answer.get("approved")), + "interactionToken": interaction.token, + }, + } + tail = messages[-1] if messages else None + if ( + tail is not None + and tail.get("role") == "assistant" + and isinstance(tail.get("content"), list) + ): + tail["content"].append(envelope) + else: + messages.append({"role": "assistant", "content": [envelope]}) + + note = answer.get("message") + if isinstance(note, str) and note.strip(): + messages.append({"role": "user", "content": note}) + + return messages + + class InteractionsDispatcher: """Respond-via-invoke logic. When dispatch_fn is supplied, fires detached (no blocking await).""" @@ -22,12 +222,42 @@ def __init__( *, workflows_service: WorkflowsService, interactions_service: SessionInteractionsService, + records_service: Optional[RecordsService] = None, dispatch_fn: Optional[Callable] = None, ) -> None: self.workflows_service = workflows_service self.interactions_service = interactions_service + self.records_service = records_service self._dispatch_fn = dispatch_fn + async def _compose_inputs( + self, + *, + project_id: UUID, + interaction: SessionInteraction, + answer: Any, + ) -> Dict[str, Any]: + if ( + interaction.kind == SessionInteractionKind.user_approval + and isinstance(answer, dict) + and isinstance(answer.get("approved"), bool) + ): + records: List[SessionRecord] = [] + if self.records_service is not None: + try: + records = await self.records_service.get_records( + project_id=project_id, + session_id=interaction.session_id, + ) + except Exception as e: # degrade to synthesized-anchor replay + log.warning( + "[interactions] records replay unavailable for " + f"session={interaction.session_id}: {e}" + ) + return {"messages": compose_approval_messages(records, interaction, answer)} + + return answer if isinstance(answer, dict) else {"value": answer} + async def respond( self, *, @@ -51,7 +281,11 @@ async def respond( selector = ( data.selector.model_dump(mode="json") if data and data.selector else None ) - inputs = answer if isinstance(answer, dict) else {"value": answer} + inputs = await self._compose_inputs( + project_id=project_id, + interaction=interaction, + answer=answer, + ) invoke_request = WorkflowServiceRequest( references=references, diff --git a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py index 8088fb29ee..41ba60875f 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py +++ b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py @@ -1,4 +1,5 @@ -"""Unit tests for InteractionsDispatcher — blocking and detached dispatch paths.""" +"""Unit tests for InteractionsDispatcher — blocking and detached dispatch paths, +plus the M2 approval-answer composition (records replay -> runner-visible envelope).""" from types import SimpleNamespace from uuid import uuid4 @@ -7,17 +8,22 @@ from oss.src.apis.fastapi.sessions.models import SessionInteractionCreateRequest from oss.src.core.sessions.interactions.dtos import SessionInteractionKind +from oss.src.core.sessions.records.dtos import SessionRecord from oss.src.tasks.asyncio.sessions.interactions_dispatcher import ( InteractionsDispatcher, ) -def _make_interaction(*, with_refs=True): +def _make_interaction( + *, + with_refs=True, + kind=SessionInteractionKind.user_input, + request=None, +): from oss.src.core.sessions.interactions.dtos import ( SessionInteraction, SessionInteractionData, - SessionInteractionKind, SessionInteractionStatus, ) from oss.src.core.shared.dtos import Reference @@ -28,9 +34,76 @@ def _make_interaction(*, with_refs=True): project_id=uuid4(), session_id="sess-test-1", token="tok-abc", - kind=SessionInteractionKind.user_input, + kind=kind, status=SessionInteractionStatus.pending, - data=SessionInteractionData(references=refs, selector=None), + data=SessionInteractionData(references=refs, selector=None, request=request), + ) + + +def _record(project_id, *, source="agent", rtype, attributes, index=0): + return SessionRecord( + record_id=uuid4(), + session_id="sess-test-1", + project_id=project_id, + record_index=index, + record_type=rtype, + record_source=source, + attributes=attributes, + ) + + +def _approval_records(project_id, *, token="tok-abc", tool_call_id="tc-1"): + """A one-turn approval transcript: user prompt, gated tool call, pending gate.""" + return [ + _record( + project_id, + source="user", + rtype="message", + attributes={"type": "message", "text": "run the migration"}, + index=0, + ), + _record( + project_id, + rtype="tool_call", + attributes={ + "type": "tool_call", + "id": tool_call_id, + "name": "bash", + "input": {"command": "alembic upgrade head"}, + }, + index=1, + ), + _record( + project_id, + rtype="interaction_request", + attributes={ + "type": "interaction_request", + "id": token, + "kind": "user_approval", + "payload": { + "toolCallId": tool_call_id, + "toolCall": { + "toolCallId": tool_call_id, + "resolvedName": "bash", + "rawInput": {"command": "alembic upgrade head"}, + }, + }, + }, + index=2, + ), + ] + + +def _dispatcher_with(interaction, records, dispatch_fn): + interactions_service = MagicMock() + interactions_service.fetch_interaction = AsyncMock(return_value=interaction) + records_service = MagicMock() + records_service.get_records = AsyncMock(return_value=records) + return InteractionsDispatcher( + workflows_service=MagicMock(), + interactions_service=interactions_service, + records_service=records_service, + dispatch_fn=dispatch_fn, ) @@ -139,3 +212,165 @@ async def test_respond_detached_calls_dispatch_fn_not_invoke(): # blocking path must NOT be called workflows_service.invoke_workflow.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# M2: approval answers compose the runner-visible resume conversation +# --------------------------------------------------------------------------- + + +async def test_approval_respond_composes_resume_messages_from_records(): + """The dispatched inputs must be a replayable conversation ending in the + {approved, interactionToken} tool_result the runner's decision map reads, + bound to the gated toolCallId — and must never carry data.parameters (the + resolver hydrates config from references server-side only when absent).""" + project_id = uuid4() + interaction = _make_interaction(kind=SessionInteractionKind.user_approval) + records = _approval_records(project_id) + dispatch_fn = AsyncMock() + dispatcher = _dispatcher_with(interaction, records, dispatch_fn) + + await dispatcher.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + request = dispatch_fn.await_args.kwargs["request"] + assert request.session_id == "sess-test-1" + assert request.data.parameters is None + messages = request.data.inputs["messages"] + + assert messages[0] == {"role": "user", "content": "run the migration"} + assert messages[1]["role"] == "assistant" + blocks = messages[1]["content"] + assert blocks[0] == { + "type": "tool_call", + "toolCallId": "tc-1", + "toolName": "bash", + "input": {"command": "alembic upgrade head"}, + } + # The envelope: exactly what storedApprovalDecisionOf (responder.ts) parses. + assert blocks[-1] == { + "type": "tool_result", + "toolCallId": "tc-1", + "output": {"approved": True, "interactionToken": "tok-abc"}, + } + # No extra user message was introduced (prompt count parity for warm resume). + assert sum(1 for m in messages if m["role"] == "user") == 1 + + +async def test_denial_with_message_appends_a_trailing_user_note(): + project_id = uuid4() + interaction = _make_interaction(kind=SessionInteractionKind.user_approval) + dispatch_fn = AsyncMock() + dispatcher = _dispatcher_with( + interaction, _approval_records(project_id), dispatch_fn + ) + + await dispatcher.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": False, "message": "use --dry-run instead"}, + ) + + messages = dispatch_fn.await_args.kwargs["request"].data.inputs["messages"] + envelope = messages[-2]["content"][-1] + assert envelope["output"] == {"approved": False, "interactionToken": "tok-abc"} + assert messages[-1] == {"role": "user", "content": "use --dry-run instead"} + + +async def test_approval_respond_without_records_synthesizes_the_anchor(): + """No durable records (minimal composition, ingest failure): the dispatcher must still + emit a tool_call block sharing the envelope's id so the runner's call-shape index can + bind the decision to name+args on cold replay.""" + project_id = uuid4() + interaction = _make_interaction( + kind=SessionInteractionKind.user_approval, + request={"tool": "bash", "args": {"command": "rm -rf ./build"}}, + ) + dispatch_fn = AsyncMock() + dispatcher = _dispatcher_with(interaction, [], dispatch_fn) + + await dispatcher.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + messages = dispatch_fn.await_args.kwargs["request"].data.inputs["messages"] + assert len(messages) == 1 + blocks = messages[0]["content"] + assert blocks[0] == { + "type": "tool_call", + "toolCallId": "tok-abc", + "toolName": "bash", + "input": {"command": "rm -rf ./build"}, + } + assert blocks[1]["output"] == {"approved": True, "interactionToken": "tok-abc"} + + +async def test_explicit_tool_call_id_wins_over_the_records_lookup(): + project_id = uuid4() + interaction = _make_interaction(kind=SessionInteractionKind.user_approval) + dispatch_fn = AsyncMock() + dispatcher = _dispatcher_with( + interaction, _approval_records(project_id), dispatch_fn + ) + + await dispatcher.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True, "tool_call_id": "tc-9"}, + ) + + messages = dispatch_fn.await_args.kwargs["request"].data.inputs["messages"] + envelope = messages[-1]["content"][-1] + assert envelope["toolCallId"] == "tc-9" + # tc-9 has no tool_call record, so the anchor was synthesized for it. + assert any( + block.get("type") == "tool_call" and block.get("toolCallId") == "tc-9" + for message in messages + if isinstance(message["content"], list) + for block in message["content"] + ) + + +async def test_non_approval_answers_still_pass_through_unchanged(): + project_id = uuid4() + interaction = _make_interaction(kind=SessionInteractionKind.user_input) + dispatch_fn = AsyncMock() + dispatcher = _dispatcher_with( + interaction, _approval_records(project_id), dispatch_fn + ) + + await dispatcher.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"reply": "yes"}, + ) + + assert dispatch_fn.await_args.kwargs["request"].data.inputs == {"reply": "yes"} + + +async def test_approval_answer_without_a_boolean_verdict_passes_through(): + project_id = uuid4() + interaction = _make_interaction(kind=SessionInteractionKind.user_approval) + dispatch_fn = AsyncMock() + dispatcher = _dispatcher_with( + interaction, _approval_records(project_id), dispatch_fn + ) + + await dispatcher.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": "yep"}, + ) + + assert dispatch_fn.await_args.kwargs["request"].data.inputs == {"approved": "yep"} diff --git a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py index 514036411d..a063615ffc 100644 --- a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py +++ b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py @@ -163,3 +163,54 @@ async def transition_interaction(self, *, transition): assert exc_info.value.status_code == 409 respond_task.kiq.assert_not_awaited() + + +async def test_no_worker_fallback_routes_through_the_dispatcher(): + """Without a respond_task the route must reuse the dispatcher (the one + answer-composition implementation), not the raw inline invoke.""" + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="sess-1", + token="tok-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + + service = _RacyInteractionsService(interaction=interaction) + workflows_service = AsyncMock() + dispatcher = AsyncMock() + + router = InteractionsRouter( + interactions_service=service, + workflows_service=workflows_service, + respond_task=None, + interactions_dispatcher=dispatcher, + ) + + app = FastAPI() + request = _make_authed_request(app, project_id, user_id) + body = SessionInteractionRespondRequest(answer={"approved": True}) + + with patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ): + await router.respond_interaction( + request=request, + interaction_id=interaction_id, + body=body, + ) + + dispatcher.respond.assert_awaited_once_with( + project_id=project_id, + user_id=user_id, + interaction_id=interaction_id, + answer={"approved": True}, + ) + workflows_service.invoke_workflow.assert_not_awaited() From 002825d5a1db45e5d59b50e3e412481ad26632d4 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 21:05:35 +0300 Subject: [PATCH 07/22] feat(mobile): M0.3 approval-pending card + tightened records poll in the chat screen Records replay already reconstructs the approval-requested tool part; the chat transcript now renders it as a highlighted raw card (tool name + exact JSON payload) with disabled Approve/Deny buttons until the resume path lands. While the foregrounded screen shows a pending approval or a running turn the records poll tightens to 7.5s (invalidate + shared-cache re-read), skipping ticks when the tab is hidden; otherwise the default staleTime governs. --- web/mobile/src/features/chat/ApprovalCard.tsx | 27 ++++++++++++++ web/mobile/src/features/chat/ChatScreen.tsx | 22 +++++++++-- web/mobile/src/features/chat/TurnRow.tsx | 17 +++++++-- .../src/features/chat/useSessionTranscript.ts | 37 ++++++++++++++++++- 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 web/mobile/src/features/chat/ApprovalCard.tsx diff --git a/web/mobile/src/features/chat/ApprovalCard.tsx b/web/mobile/src/features/chat/ApprovalCard.tsx new file mode 100644 index 0000000000..16572a33cf --- /dev/null +++ b/web/mobile/src/features/chat/ApprovalCard.tsx @@ -0,0 +1,27 @@ +/** Raw highlighted pending-approval block: tool name + exact payload. Read-only until M1 + * wires the resume path — buttons stay disabled with honest copy. */ +export const ApprovalCard = ({toolName, input}: {toolName: string; input: unknown}) => ( +
+

Approval pending — {toolName}

+
+            {JSON.stringify(input, null, 2)}
+        
+
+ + +
+

Answer on desktop for now.

+
+) diff --git a/web/mobile/src/features/chat/ChatScreen.tsx b/web/mobile/src/features/chat/ChatScreen.tsx index 2f489077a6..9624a01cc1 100644 --- a/web/mobile/src/features/chat/ChatScreen.tsx +++ b/web/mobile/src/features/chat/ChatScreen.tsx @@ -1,6 +1,12 @@ -import {useMemo} from "react" +import {useMemo, useState} from "react" -import {buildTurnViewModels, createExecutedToolIdentityCache} from "@agenta/chat/model" +import { + buildTurnViewModels, + createExecutedToolIdentityCache, + getPendingApprovals, +} from "@agenta/chat/model" + +import {useLivenessPoll} from "../sessions/useLivenessPoll" import {ChatHeader} from "./ChatHeader" import {ChatEmpty, ChatLoading} from "./states/ChatStates" @@ -17,7 +23,17 @@ export const ChatScreen = ({ projectId: string workspaceId: string }) => { - const {messages, state} = useSessionTranscript(sessionId) + // Tightened records cadence only while this foregrounded screen shows a running or pending + // turn; derived from the previous render's messages, so it settles one render behind. + const [pollMs, setPollMs] = useState(0) + const {messages, state} = useSessionTranscript(sessionId, pollMs) + const liveness = useLivenessPoll(projectId) + const running = Boolean( + liveness.data?.find((s) => s.session_id === sessionId)?.flags?.is_running, + ) + const pendingCount = useMemo(() => getPendingApprovals(messages).length, [messages]) + const nextPollMs = pendingCount > 0 || running ? 7_500 : 0 + if (nextPollMs !== pollMs) setPollMs(nextPollMs) // One identity cache per session mount (the screen is keyed by sessionId). // eslint-disable-next-line react-hooks/exhaustive-deps const executedFor = useMemo(() => createExecutedToolIdentityCache(), [sessionId]) diff --git a/web/mobile/src/features/chat/TurnRow.tsx b/web/mobile/src/features/chat/TurnRow.tsx index 53cf3a70b2..101939244d 100644 --- a/web/mobile/src/features/chat/TurnRow.tsx +++ b/web/mobile/src/features/chat/TurnRow.tsx @@ -1,5 +1,7 @@ import {partToolName, rowSummary, type TurnViewModel} from "@agenta/chat/model" +import {ApprovalCard} from "./ApprovalCard" + /** One transcript turn: raw aligned text parts, one-line tool summaries, raw error line. */ export const TurnRow = ({turn}: {turn: TurnViewModel}) => (
@@ -31,12 +33,19 @@ export const TurnRow = ({turn}: {turn: TurnViewModel}) => ( return (
{item.parts.map((part, i) => { + const key = part.toolCallId ?? `${item.index}-${i}` + if (part.state === "approval-requested") { + return ( + + ) + } const summary = rowSummary(part) return ( -

+

{partToolName(part)} — {part.state ?? "pending"} {summary ? ` · ${summary}` : ""}

diff --git a/web/mobile/src/features/chat/useSessionTranscript.ts b/web/mobile/src/features/chat/useSessionTranscript.ts index b0e62a497f..d565a9589e 100644 --- a/web/mobile/src/features/chat/useSessionTranscript.ts +++ b/web/mobile/src/features/chat/useSessionTranscript.ts @@ -1,14 +1,20 @@ import {useEffect, useState} from "react" import {loadSessionMessages} from "@agenta/chat/assets" +import {revalidateSessionRecordsAtom} from "@agenta/entities/session" import type {UIMessage} from "ai" +import {getDefaultStore} from "jotai" /** * Read-only transcript for one session: server record replay via `loadSessionMessages` * (IndexedDB-restored, revalidation re-delivered through `onRefreshed`). `null` history * collapses into "empty" — raw text covers both no-messages and history-unavailable. + * + * `pollMs` > 0 tightens the cadence (a running turn / pending approval): each tick marks the + * records stale and re-reads through the shared cache. Foreground-only — a hidden tab skips + * ticks entirely (the records query is the heavy one; see the plan's cost note). */ -export const useSessionTranscript = (sessionId: string) => { +export const useSessionTranscript = (sessionId: string, pollMs = 0) => { const [messages, setMessages] = useState([]) const [state, setState] = useState<"loading" | "ready" | "empty">("loading") useEffect(() => { @@ -32,5 +38,34 @@ export const useSessionTranscript = (sessionId: string) => { cancelled = true } }, [sessionId]) + + useEffect(() => { + if (!pollMs) return + let cancelled = false + let inFlight = false + const store = getDefaultStore() + const tick = () => { + if (document.visibilityState !== "visible" || inFlight) return + inFlight = true + // Invalidate first so the shared-cache read refetches instead of serving staleTime. + store.set(revalidateSessionRecordsAtom, sessionId) + void loadSessionMessages(sessionId) + .then((msgs) => { + if (!cancelled && msgs && msgs.length > 0) { + setMessages(msgs) + setState("ready") + } + }) + .finally(() => { + inFlight = false + }) + } + const handle = setInterval(tick, pollMs) + return () => { + cancelled = true + clearInterval(handle) + } + }, [sessionId, pollMs]) + return {messages, state} } From 16c177a2746172fa559389ed188d3342dcf323f9 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 21:07:54 +0300 Subject: [PATCH 08/22] feat(chat): M1.1 lite references-only agent resume request builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildAgentResumeRequest composes the invoke body for answering a HITL approval without the hydrated workflow molecule: {session_id, references, data.inputs .messages} with stream Accept + vercel format headers, and project_id ALWAYS on the query string (the routing middleware reads it for cookie auth). The body never carries data.parameters — that absence is what triggers server-side reference hydration in the SDK resolver — and a unit test pins the invariant. --- .../src/transport/agentResumeRequest.ts | 78 +++++++++++++++++++ .../agenta-chat/src/transport/index.ts | 1 + .../unit/transport/agentResumeRequest.test.ts | 55 +++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 web/packages/agenta-chat/src/transport/agentResumeRequest.ts create mode 100644 web/packages/agenta-chat/tests/unit/transport/agentResumeRequest.test.ts diff --git a/web/packages/agenta-chat/src/transport/agentResumeRequest.ts b/web/packages/agenta-chat/src/transport/agentResumeRequest.ts new file mode 100644 index 0000000000..722212c3d3 --- /dev/null +++ b/web/packages/agenta-chat/src/transport/agentResumeRequest.ts @@ -0,0 +1,78 @@ +/** + * Lite agent resume request — the references-only invoke body for answering a HITL approval + * without the hydrated workflow molecule (mobile, or any client that can't run the full + * `buildAgentRequest` pipeline). + * + * Load-bearing invariant: the body carries NO `data.parameters`. The SDK resolver hydrates the + * config server-side ONLY when the request has `references` and no `data.parameters` + * (`sdks/python/agenta/sdk/middlewares/running/resolver.py` `needs_reference_hydration`), so + * emitting a `parameters` key — even empty — would skip hydration and run an unconfigured + * draft. The unit test pins this. + */ + +/** A `{id, slug, version}` platform reference (values may be partial). */ +export interface AgentResumeReference { + id?: string | null + slug?: string | null + version?: string | null +} + +export interface AgentResumeRequestArgs { + /** The service invoke endpoint (`{serviceUrl}/invoke`) — see `resolveInvocationUrl`. */ + invocationUrl: string + /** Role-keyed workflow refs (`workflow`/`workflow_variant`/`workflow_revision`, or the + * `application_*` family) — sent verbatim for server-side reference hydration. */ + references: Record | null + sessionId: string + /** The full v6 UIMessage history with the approval decision stamped on the tail. */ + messages: unknown[] + /** ALWAYS rides the query string — the invoke routing middleware reads it for cookie-auth + * permission checks (auth.py). Do not copy desktop's Authorization-gated omission. */ + projectId?: string + applicationId?: string +} + +export interface AgentResumeRequest { + invocationUrl: string + headers: Record + requestBody: { + session_id: string + references: Record | null + data: {inputs: {messages: unknown[]}} + } +} + +const withQuery = (url: string, params: Record): string => { + const qs = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value) qs.set(key, value) + } + const suffix = qs.toString() + return suffix ? `${url}${url.includes("?") ? "&" : "?"}${suffix}` : url +} + +/** Compose the references-only resume invoke request (see module docstring). */ +export const buildAgentResumeRequest = ({ + invocationUrl, + references, + sessionId, + messages, + projectId, + applicationId, +}: AgentResumeRequestArgs): AgentResumeRequest => ({ + invocationUrl: withQuery(invocationUrl, { + application_id: applicationId, + project_id: projectId, + }), + headers: { + // The stream Accept keeps `/invoke` on the v6 SSE channel; a fire-and-forget caller + // simply drains the response. The vercel format header selects the UIMessage ingest. + Accept: "text/event-stream", + "x-ag-messages-format": "vercel", + }, + requestBody: { + session_id: sessionId, + references, + data: {inputs: {messages}}, + }, +}) diff --git a/web/packages/agenta-chat/src/transport/index.ts b/web/packages/agenta-chat/src/transport/index.ts index 25aea9a794..21477dfb5d 100644 --- a/web/packages/agenta-chat/src/transport/index.ts +++ b/web/packages/agenta-chat/src/transport/index.ts @@ -1 +1,2 @@ export * from "./AgentChatTransport" +export * from "./agentResumeRequest" diff --git a/web/packages/agenta-chat/tests/unit/transport/agentResumeRequest.test.ts b/web/packages/agenta-chat/tests/unit/transport/agentResumeRequest.test.ts new file mode 100644 index 0000000000..ab7134a19e --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/transport/agentResumeRequest.test.ts @@ -0,0 +1,55 @@ +import {describe, expect, it} from "vitest" + +import {buildAgentResumeRequest} from "../../../src/transport/agentResumeRequest" + +const baseArgs = { + invocationUrl: "https://host/services/agent/v0/invoke", + references: {workflow_revision: {id: "rev-1"}}, + sessionId: "session-1", + messages: [{id: "m1", role: "user", parts: [{type: "text", text: "hi"}]}], +} + +describe("buildAgentResumeRequest", () => { + it("never emits a data.parameters key (references-only server-side hydration)", () => { + const req = buildAgentResumeRequest(baseArgs) + expect("parameters" in req.requestBody.data).toBe(false) + // Belt-and-braces: the serialized wire body must not carry the key either. + expect(JSON.stringify(req.requestBody)).not.toContain('"parameters"') + }) + + it("carries the session id, references, and messages under data.inputs", () => { + const req = buildAgentResumeRequest(baseArgs) + expect(req.requestBody.session_id).toBe("session-1") + expect(req.requestBody.references).toEqual({workflow_revision: {id: "rev-1"}}) + expect(req.requestBody.data.inputs.messages).toBe(baseArgs.messages) + }) + + it("sends the stream Accept and vercel messages-format headers", () => { + const req = buildAgentResumeRequest(baseArgs) + expect(req.headers).toEqual({ + Accept: "text/event-stream", + "x-ag-messages-format": "vercel", + }) + }) + + it("puts project_id on the query string unconditionally (cookie-auth path)", () => { + const req = buildAgentResumeRequest({...baseArgs, projectId: "proj-1"}) + expect(req.invocationUrl).toBe("https://host/services/agent/v0/invoke?project_id=proj-1") + }) + + it("appends application_id alongside project_id when provided", () => { + const req = buildAgentResumeRequest({ + ...baseArgs, + projectId: "proj-1", + applicationId: "app-1", + }) + const url = new URL(req.invocationUrl) + expect(url.searchParams.get("project_id")).toBe("proj-1") + expect(url.searchParams.get("application_id")).toBe("app-1") + }) + + it("leaves the URL untouched when no scope params are provided", () => { + const req = buildAgentResumeRequest(baseArgs) + expect(req.invocationUrl).toBe(baseArgs.invocationUrl) + }) +}) From dd446e1a71256d9a92be21cfb06283062ee1f2bc Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 21:10:20 +0300 Subject: [PATCH 09/22] feat(chat): M1.2 resolve an agent invoke URL from workflow references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveInvocationUrl fetches the revision through the Fern-backed retrieveWorkflowRevision (revision-id ref preferred, workflow-id fallback, one call carries both) and applies the data.url|uri -> /invoke rule mirrored from the entities invocationUrl atom — no molecule store required, so the lite resume path can derive its endpoint from a session or interaction row. --- .../agenta-chat/src/transport/index.ts | 1 + .../src/transport/resolveInvocationUrl.ts | 57 ++++++++++++++++ .../transport/resolveInvocationUrl.test.ts | 68 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 web/packages/agenta-chat/src/transport/resolveInvocationUrl.ts create mode 100644 web/packages/agenta-chat/tests/unit/transport/resolveInvocationUrl.test.ts diff --git a/web/packages/agenta-chat/src/transport/index.ts b/web/packages/agenta-chat/src/transport/index.ts index 21477dfb5d..0adee290b3 100644 --- a/web/packages/agenta-chat/src/transport/index.ts +++ b/web/packages/agenta-chat/src/transport/index.ts @@ -1,2 +1,3 @@ export * from "./AgentChatTransport" export * from "./agentResumeRequest" +export * from "./resolveInvocationUrl" diff --git a/web/packages/agenta-chat/src/transport/resolveInvocationUrl.ts b/web/packages/agenta-chat/src/transport/resolveInvocationUrl.ts new file mode 100644 index 0000000000..df049fe84b --- /dev/null +++ b/web/packages/agenta-chat/src/transport/resolveInvocationUrl.ts @@ -0,0 +1,57 @@ +/** + * Resolve an agent's invoke endpoint from workflow references alone — ONE Fern revision fetch, + * no molecule hydration. Pairs with `buildAgentResumeRequest` for the lite resume path. + * + * The URL rule mirrors `@agenta/entities` `workflow/state/runnableSetup.ts` + * (`invocationUrlAtomFamily`): prefer the stored `data.url`, else build from the agenta + * `data.uri` (`agenta:{kind}:{key}:{version}` → `{origin}/services/{key}/{version}`), then + * append `/invoke`. Kept local because the state atom needs a seeded molecule store. + */ +import {retrieveWorkflowRevision} from "@agenta/entities/workflow" +import {getAgentaApiUrl} from "@agenta/shared/api" + +/** `agenta:{kind}:{key}:{version}` → `{origin}/services/{key}/{version}`, or null. */ +const serviceUrlFromUri = (uri: string | null | undefined): string | null => { + if (!uri || !uri.startsWith("agenta:")) return null + const apiUrl = getAgentaApiUrl() + if (!apiUrl) return null + const origin = apiUrl.replace(/\/api\/?$/, "") + const parts = uri.replace(/^agenta:/, "").split(":") + if (parts.length < 3) return null + const [, ...rest] = parts + return `${origin}/services/${rest.join("/")}` +} + +/** Apply the `data.url|uri → /invoke` rule to a fetched revision. Exported for tests. */ +export const invocationUrlFromRevisionData = ( + data: {url?: string | null; uri?: string | null} | null | undefined, +): string | null => { + const serviceUrl = data?.url?.replace(/\/+$/, "") ?? serviceUrlFromUri(data?.uri) + return serviceUrl ? `${serviceUrl}/invoke` : null +} + +export interface ResolveInvocationUrlArgs { + projectId: string + /** The exact revision that ran (`workflow_revision`/`application_revision` ref id). */ + revisionId?: string | null + /** Fallback identity: the workflow artifact id — resolves to its latest revision. */ + workflowId?: string | null +} + +/** + * Fetch the revision by reference (revision id preferred, workflow id fallback — one call + * carries both) and derive its `/invoke` URL. Returns `null` when nothing resolves. + */ +export async function resolveInvocationUrl({ + projectId, + revisionId, + workflowId, +}: ResolveInvocationUrlArgs): Promise { + if (!projectId || (!revisionId && !workflowId)) return null + const revision = await retrieveWorkflowRevision({ + projectId, + workflowRef: workflowId ? {id: workflowId} : undefined, + workflowRevisionRef: revisionId ? {id: revisionId} : undefined, + }) + return invocationUrlFromRevisionData(revision?.data ?? null) +} diff --git a/web/packages/agenta-chat/tests/unit/transport/resolveInvocationUrl.test.ts b/web/packages/agenta-chat/tests/unit/transport/resolveInvocationUrl.test.ts new file mode 100644 index 0000000000..fd9825c713 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/transport/resolveInvocationUrl.test.ts @@ -0,0 +1,68 @@ +import {beforeEach, describe, expect, it, vi} from "vitest" + +// The Fern-backed revision fetch and the host config are boundary concerns — stub both so the +// URL rule is tested hermetically (no molecule store, no network). +let revisionResult: {data?: {url?: string | null; uri?: string | null} | null} | null +const retrieveWorkflowRevision = vi.fn(async () => revisionResult) +vi.mock("@agenta/entities/workflow", () => ({ + retrieveWorkflowRevision: (...args: unknown[]) => retrieveWorkflowRevision(...args), +})) +vi.mock("@agenta/shared/api", () => ({ + getAgentaApiUrl: () => "https://host/api", +})) + +const {invocationUrlFromRevisionData, resolveInvocationUrl} = + await import("../../../src/transport/resolveInvocationUrl") + +describe("invocationUrlFromRevisionData", () => { + it("prefers the stored url, trimming trailing slashes", () => { + expect(invocationUrlFromRevisionData({url: "https://host/services/agent/"})).toBe( + "https://host/services/agent/invoke", + ) + }) + + it("builds from an agenta uri when there is no url (kind segment stripped)", () => { + expect(invocationUrlFromRevisionData({uri: "agenta:custom:my-agent:v0"})).toBe( + "https://host/services/my-agent/v0/invoke", + ) + }) + + it("returns null when neither url nor a parsable uri exists", () => { + expect(invocationUrlFromRevisionData(null)).toBeNull() + expect(invocationUrlFromRevisionData({uri: "not-agenta"})).toBeNull() + expect(invocationUrlFromRevisionData({uri: "agenta:only-two"})).toBeNull() + }) +}) + +describe("resolveInvocationUrl", () => { + beforeEach(() => { + revisionResult = null + retrieveWorkflowRevision.mockClear() + }) + + it("passes both refs in ONE fetch (revision id preferred by the backend)", async () => { + revisionResult = {data: {url: "https://host/services/agent"}} + const url = await resolveInvocationUrl({ + projectId: "proj-1", + revisionId: "rev-1", + workflowId: "wf-1", + }) + expect(url).toBe("https://host/services/agent/invoke") + expect(retrieveWorkflowRevision).toHaveBeenCalledTimes(1) + expect(retrieveWorkflowRevision).toHaveBeenCalledWith({ + projectId: "proj-1", + workflowRef: {id: "wf-1"}, + workflowRevisionRef: {id: "rev-1"}, + }) + }) + + it("returns null without a fetch when no identifying ref is available", async () => { + expect(await resolveInvocationUrl({projectId: "proj-1"})).toBeNull() + expect(retrieveWorkflowRevision).not.toHaveBeenCalled() + }) + + it("returns null when the revision does not resolve", async () => { + revisionResult = null + expect(await resolveInvocationUrl({projectId: "proj-1", revisionId: "rev-x"})).toBeNull() + }) +}) From 76333b4d4daf14b41b8ba64e78d63521a6039a5c Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 21:13:52 +0300 Subject: [PATCH 10/22] feat(mobile): M1.3 approve/deny pending approvals from the phone The approval card's buttons go live: fresh records are re-read, the decision is stamped onto the tail as the approval-responded shape transcriptToMessages produces (the SDK folds it into the {approved, interactionToken} tool_result envelope), and ONE references-only resume POST fires via buildAgentResumeRequest with the interaction row's role-keyed workflow refs + resolveInvocationUrl. Fire-and-forget per the plan decision: the response is drained in the background and the records poll (tightened to 4s while resuming) repaints the transcript until the turn settles. Deny also resumes; approve-all answers every gate in the same single POST. --- web/mobile/src/features/chat/ApprovalCard.tsx | 94 ++++++++--- web/mobile/src/features/chat/ChatScreen.tsx | 13 +- web/mobile/src/features/chat/TurnRow.tsx | 17 +- web/mobile/src/features/chat/approvalStamp.ts | 38 +++++ .../src/features/chat/useApprovalActions.ts | 157 ++++++++++++++++++ web/mobile/tests/unit/approvalStamp.test.ts | 63 +++++++ 6 files changed, 353 insertions(+), 29 deletions(-) create mode 100644 web/mobile/src/features/chat/approvalStamp.ts create mode 100644 web/mobile/src/features/chat/useApprovalActions.ts create mode 100644 web/mobile/tests/unit/approvalStamp.test.ts diff --git a/web/mobile/src/features/chat/ApprovalCard.tsx b/web/mobile/src/features/chat/ApprovalCard.tsx index 16572a33cf..f66bdcd9d7 100644 --- a/web/mobile/src/features/chat/ApprovalCard.tsx +++ b/web/mobile/src/features/chat/ApprovalCard.tsx @@ -1,27 +1,69 @@ -/** Raw highlighted pending-approval block: tool name + exact payload. Read-only until M1 - * wires the resume path — buttons stay disabled with honest copy. */ -export const ApprovalCard = ({toolName, input}: {toolName: string; input: unknown}) => ( -
-

Approval pending — {toolName}

-
-            {JSON.stringify(input, null, 2)}
-        
-
- - +import type {ApprovalActions} from "./useApprovalActions" + +/** + * Raw highlighted pending-approval block: tool name + exact payload + Approve/Deny (and + * Approve-all when several gates are pending). Without `actions` it degrades to the read-only + * M0 card ("answer on desktop"). Raw UI on purpose — flows over polish. + */ +export const ApprovalCard = ({ + toolName, + input, + approvalId, + pendingCount = 0, + actions, +}: { + toolName: string + input: unknown + /** The gate's interaction id off the tool part (`approval.id`). */ + approvalId?: string + /** Gates pending on the paused turn — >1 surfaces the Approve-all button. */ + pendingCount?: number + actions?: ApprovalActions +}) => { + const actionable = Boolean(actions && approvalId) + const busy = actions?.phase === "resuming" + const disabled = !actionable || busy + return ( +
+

Approval pending — {toolName}

+
+                {JSON.stringify(input, null, 2)}
+            
+
+ + + {actionable && pendingCount > 1 ? ( + + ) : null} +
+ {busy ?

Resuming…

: null} + {actions?.phase === "error" && actions.errorText ? ( +

{actions.errorText}

+ ) : null} + {!actionable ? ( +

Answer on desktop for now.

+ ) : null}
-

Answer on desktop for now.

-
-) + ) +} diff --git a/web/mobile/src/features/chat/ChatScreen.tsx b/web/mobile/src/features/chat/ChatScreen.tsx index 9624a01cc1..6d0289307d 100644 --- a/web/mobile/src/features/chat/ChatScreen.tsx +++ b/web/mobile/src/features/chat/ChatScreen.tsx @@ -11,6 +11,7 @@ import {useLivenessPoll} from "../sessions/useLivenessPoll" import {ChatHeader} from "./ChatHeader" import {ChatEmpty, ChatLoading} from "./states/ChatStates" import {TurnRow} from "./TurnRow" +import {useApprovalActions} from "./useApprovalActions" import {useSessionTranscript} from "./useSessionTranscript" /** Read-only replay screen — mount it with `key={sessionId}` so per-session state resets. */ @@ -32,7 +33,10 @@ export const ChatScreen = ({ liveness.data?.find((s) => s.session_id === sessionId)?.flags?.is_running, ) const pendingCount = useMemo(() => getPendingApprovals(messages).length, [messages]) - const nextPollMs = pendingCount > 0 || running ? 7_500 : 0 + const approvals = useApprovalActions({sessionId, projectId, pendingCount}) + // ~4s while a fired decision settles (fire-and-forget — records carry the resume). + const nextPollMs = + approvals.phase === "resuming" ? 4_000 : pendingCount > 0 || running ? 7_500 : 0 if (nextPollMs !== pollMs) setPollMs(nextPollMs) // One identity cache per session mount (the screen is keyed by sessionId). // eslint-disable-next-line react-hooks/exhaustive-deps @@ -53,7 +57,12 @@ export const ChatScreen = ({ {turns .filter((turn) => !turn.hidden) .map((turn) => ( - + ))}
) diff --git a/web/mobile/src/features/chat/TurnRow.tsx b/web/mobile/src/features/chat/TurnRow.tsx index 101939244d..4aa882b7ba 100644 --- a/web/mobile/src/features/chat/TurnRow.tsx +++ b/web/mobile/src/features/chat/TurnRow.tsx @@ -1,9 +1,19 @@ import {partToolName, rowSummary, type TurnViewModel} from "@agenta/chat/model" import {ApprovalCard} from "./ApprovalCard" +import type {ApprovalActions} from "./useApprovalActions" /** One transcript turn: raw aligned text parts, one-line tool summaries, raw error line. */ -export const TurnRow = ({turn}: {turn: TurnViewModel}) => ( +export const TurnRow = ({ + turn, + approvalActions, + pendingApprovals = 0, +}: { + turn: TurnViewModel + /** Resume actions for pending-approval cards (absent = read-only cards). */ + approvalActions?: ApprovalActions + pendingApprovals?: number +}) => (
( {item.parts.map((part, i) => { const key = part.toolCallId ?? `${item.index}-${i}` if (part.state === "approval-requested") { + const approvalId = (part as {approval?: {id?: string}}).approval + ?.id return ( ) } diff --git a/web/mobile/src/features/chat/approvalStamp.ts b/web/mobile/src/features/chat/approvalStamp.ts new file mode 100644 index 0000000000..1e560c67e6 --- /dev/null +++ b/web/mobile/src/features/chat/approvalStamp.ts @@ -0,0 +1,38 @@ +import type {UIMessage} from "ai" + +/** + * Stamp approval decisions onto the transcript tail — the exact shape + * `transcriptToMessages` produces for a replayed `interaction_response` + * (`state: "approval-responded"`, `approval: {id, approved}`), which the SDK's vercel + * adapter folds into the `{approved, interactionToken}` tool_result envelope the runner's + * decision map reads. Returns the SAME array when nothing matched (caller treats that as + * "gate already gone"). + */ +export const stampApprovalResponses = ( + messages: UIMessage[], + approvalIds: readonly string[], + approved: boolean, +): UIMessage[] => { + if (messages.length === 0) return messages + const tailIndex = messages.length - 1 + const tail = messages[tailIndex] + if (tail.role !== "assistant") return messages + const targets = new Set(approvalIds) + let touched = false + const parts = (tail.parts ?? []).map((part) => { + const p = part as {state?: string; approval?: {id?: string}} + if (p.state === "approval-requested" && p.approval?.id && targets.has(p.approval.id)) { + touched = true + return { + ...part, + state: "approval-responded", + approval: {id: p.approval.id, approved}, + } as typeof part + } + return part + }) + if (!touched) return messages + const next = messages.slice() + next[tailIndex] = {...tail, parts} + return next +} diff --git a/web/mobile/src/features/chat/useApprovalActions.ts b/web/mobile/src/features/chat/useApprovalActions.ts new file mode 100644 index 0000000000..14927b6a91 --- /dev/null +++ b/web/mobile/src/features/chat/useApprovalActions.ts @@ -0,0 +1,157 @@ +import {useCallback, useEffect, useRef, useState} from "react" + +import {loadSessionMessages} from "@agenta/chat/assets" +import {getPendingApprovals} from "@agenta/chat/model" +import { + buildAgentResumeRequest, + resolveInvocationUrl, + type AgentResumeReference, +} from "@agenta/chat/transport" +import {queryInteractions} from "@agenta/entities/session" + +import {stampApprovalResponses} from "./approvalStamp" + +export type ResumePhase = "idle" | "resuming" | "error" + +export interface ApprovalActions { + phase: ResumePhase + errorText: string | null + /** Answer one gate. Deny also resumes (the runner needs the denial round-trip). */ + respond: (args: {approvalId: string; approved: boolean}) => void + /** Approve every pending gate — all responses ride ONE resume POST. */ + approveAll: () => void +} + +/** Keep only `{id, slug, version}` string fields of the interaction row's role-keyed refs. */ +const sanitizeReferences = ( + raw: Record | null | undefined, +): Record | null => { + if (!raw) return null + const out: Record = {} + for (const [key, value] of Object.entries(raw)) { + if (!value || typeof value !== "object") continue + const {id, slug, version} = value as Record + const ref: AgentResumeReference = {} + if (typeof id === "string") ref.id = id + if (typeof slug === "string") ref.slug = slug + if (typeof version === "string") ref.version = version + if (Object.keys(ref).length > 0) out[key] = ref + } + return Object.keys(out).length > 0 ? out : null +} + +/** + * Approve/deny pending HITL gates from the phone — the lite resume path (plan M1.3): + * fresh records → stamp `approval-responded` on the tail → ONE references-only invoke POST + * (`buildAgentResumeRequest`), fire-and-forget. No live SSE consumption: the response is + * drained in the background and the tightened records poll repaints the transcript until the + * turn settles (`phase` drops back to idle once no gate is pending). + */ +export const useApprovalActions = ({ + sessionId, + projectId, + pendingCount, +}: { + sessionId: string + projectId: string + /** Pending gates currently visible in the transcript — drives the resuming→idle reset. */ + pendingCount: number +}): ApprovalActions => { + const [phase, setPhase] = useState("idle") + const [errorText, setErrorText] = useState(null) + const busyRef = useRef(false) + + // The records poll caught the interaction_response (or the turn moved on) — settle. + useEffect(() => { + if (pendingCount === 0) { + setPhase((current) => (current === "resuming" ? "idle" : current)) + } + }, [pendingCount]) + + const submit = useCallback( + async (target: {all: true} | {all?: false; approvalId: string}, approved: boolean) => { + if (busyRef.current) return + busyRef.current = true + setPhase("resuming") + setErrorText(null) + try { + // Never stamp a stale tail — re-read the durable records first. + const messages = (await loadSessionMessages(sessionId)) ?? [] + const pending = getPendingApprovals(messages) + if (pending.length === 0) { + throw new Error("No pending approval found — the turn may have moved on.") + } + const ids = target.all ? pending.map((p) => p.approvalId) : [target.approvalId] + const stamped = stampApprovalResponses(messages, ids, approved) + if (stamped === messages) { + throw new Error("This approval is no longer pending — refresh and retry.") + } + // The interaction row stores the run's role-keyed workflow references — + // the resolver hydrates config from them server-side (references-only body). + const interactions = await queryInteractions({ + sessionId, + projectId, + actionableOnly: true, + }) + const references = sanitizeReferences( + interactions?.find( + (row) => + row.data?.references && Object.keys(row.data.references).length > 0, + )?.data?.references, + ) + if (!references) { + throw new Error( + "This approval carries no workflow reference — answer on desktop.", + ) + } + const invocationUrl = await resolveInvocationUrl({ + projectId, + revisionId: + references.workflow_revision?.id ?? references.application_revision?.id, + workflowId: references.workflow?.id ?? references.application?.id, + }) + if (!invocationUrl) { + throw new Error("Could not resolve the agent's invoke URL.") + } + const request = buildAgentResumeRequest({ + invocationUrl, + references, + sessionId, + messages: stamped, + projectId, + applicationId: references.application?.id ?? undefined, + }) + const response = await fetch(request.invocationUrl, { + method: "POST", + headers: {...request.headers, "Content-Type": "application/json"}, + body: JSON.stringify(request.requestBody), + credentials: "include", + }) + if (!response.ok) { + throw new Error(`Resume failed (HTTP ${response.status}).`) + } + // Fire-and-forget: drain the stream in the background so the browser doesn't + // cancel the request; the run continues server-side regardless. + void response.text().catch(() => undefined) + } catch (err) { + setPhase("error") + setErrorText(err instanceof Error ? err.message : "Resume failed.") + } finally { + busyRef.current = false + } + }, + [sessionId, projectId], + ) + + const respond = useCallback( + ({approvalId, approved}: {approvalId: string; approved: boolean}) => { + void submit({approvalId}, approved) + }, + [submit], + ) + const approveAll = useCallback(() => { + void submit({all: true}, true) + }, [submit]) + + return {phase, errorText, respond, approveAll} +} diff --git a/web/mobile/tests/unit/approvalStamp.test.ts b/web/mobile/tests/unit/approvalStamp.test.ts new file mode 100644 index 0000000000..10fcf2299f --- /dev/null +++ b/web/mobile/tests/unit/approvalStamp.test.ts @@ -0,0 +1,63 @@ +import type {UIMessage} from "ai" +import {describe, expect, it} from "vitest" + +import {stampApprovalResponses} from "../../src/features/chat/approvalStamp" + +const gate = (id: string) => ({ + type: "tool-run_command", + toolCallId: `call-${id}`, + state: "approval-requested", + input: {command: "ls"}, + approval: {id}, +}) + +const transcript = (parts: unknown[]): UIMessage[] => + [ + {id: "u1", role: "user", parts: [{type: "text", text: "go"}]}, + {id: "a1", role: "assistant", parts}, + ] as unknown as UIMessage[] + +describe("stampApprovalResponses", () => { + it("stamps the targeted gate with the transcriptToMessages response shape", () => { + const messages = transcript([gate("appr-1")]) + const next = stampApprovalResponses(messages, ["appr-1"], true) + expect(next).not.toBe(messages) + const part = (next[1].parts as Record[])[0] + expect(part.state).toBe("approval-responded") + expect(part.approval).toEqual({id: "appr-1", approved: true}) + // Untouched fields survive — the SDK keys the envelope by toolCallId + input. + expect(part.toolCallId).toBe("call-appr-1") + expect(part.input).toEqual({command: "ls"}) + }) + + it("stamps every listed gate in one pass (approve-all rides ONE resume)", () => { + const messages = transcript([gate("a"), gate("b")]) + const next = stampApprovalResponses(messages, ["a", "b"], true) + const parts = next[1].parts as Record[] + expect(parts.map((p) => p.state)).toEqual(["approval-responded", "approval-responded"]) + }) + + it("records a deny as approved: false (deny also resumes)", () => { + const next = stampApprovalResponses(transcript([gate("a")]), ["a"], false) + expect((next[1].parts as Record[])[0].approval).toEqual({ + id: "a", + approved: false, + }) + }) + + it("returns the same array when the gate is gone or the tail is not an assistant turn", () => { + const noGate = transcript([{type: "text", text: "done"}]) + expect(stampApprovalResponses(noGate, ["a"], true)).toBe(noGate) + const userTail = [ + {id: "u1", role: "user", parts: [{type: "text", text: "hi"}]}, + ] as unknown as UIMessage[] + expect(stampApprovalResponses(userTail, ["a"], true)).toBe(userTail) + expect(stampApprovalResponses([], ["a"], true)).toEqual([]) + }) + + it("does not mutate the input messages", () => { + const messages = transcript([gate("a")]) + stampApprovalResponses(messages, ["a"], true) + expect((messages[1].parts as Record[])[0].state).toBe("approval-requested") + }) +}) From c2de8f36e8c44e156f388ab2603182fc850df439 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 21:14:51 +0300 Subject: [PATCH 11/22] feat(mobile): M1.4 stop a running session (cooperative cancel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A running turn surfaces a raw Stop button in the chat screen: the no-inputs commandSessionStream call drops the running locks (cancel mode) and the runner aborts on its next heartbeat, up to 30s later — the liveness poll confirms and unmounts the strip. Until feat/agent-cancel-steer lands the turn settles as an error record rather than a clean cancelled state; the UI copy says so. --- web/mobile/src/features/chat/ChatScreen.tsx | 7 ++++ web/mobile/src/features/chat/StopButton.tsx | 46 +++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 web/mobile/src/features/chat/StopButton.tsx diff --git a/web/mobile/src/features/chat/ChatScreen.tsx b/web/mobile/src/features/chat/ChatScreen.tsx index 6d0289307d..6a210f97ba 100644 --- a/web/mobile/src/features/chat/ChatScreen.tsx +++ b/web/mobile/src/features/chat/ChatScreen.tsx @@ -10,6 +10,7 @@ import {useLivenessPoll} from "../sessions/useLivenessPoll" import {ChatHeader} from "./ChatHeader" import {ChatEmpty, ChatLoading} from "./states/ChatStates" +import {StopButton} from "./StopButton" import {TurnRow} from "./TurnRow" import {useApprovalActions} from "./useApprovalActions" import {useSessionTranscript} from "./useSessionTranscript" @@ -71,6 +72,12 @@ export const ChatScreen = ({ return (
+ {running ? ( +
+ A turn is running + +
+ ) : null} {body}
) diff --git a/web/mobile/src/features/chat/StopButton.tsx b/web/mobile/src/features/chat/StopButton.tsx new file mode 100644 index 0000000000..de38cb416c --- /dev/null +++ b/web/mobile/src/features/chat/StopButton.tsx @@ -0,0 +1,46 @@ +import {useState} from "react" + +import {commandSessionStream} from "@agenta/entities/session" + +/** + * Cooperative Stop for a running turn: the no-inputs/no-force stream command drops the + * running locks and the runner aborts on its next heartbeat (≤30s). The liveness poll + * confirms — the button unmounts when the session stops reading as running. Until + * feat/agent-cancel-steer lands the turn settles as an error record, not a clean + * "cancelled"; the copy says so. + */ +export const StopButton = ({sessionId, projectId}: {sessionId: string; projectId: string}) => { + const [state, setState] = useState<"idle" | "stopping" | "failed">("idle") + const onStop = async () => { + setState("stopping") + try { + const result = await commandSessionStream({sessionId, projectId}) + if (!result) setState("failed") + } catch { + // A rejection (offline, 5xx) must land on "failed" like a null result. Without this + // the button sits on "Stopping…" forever and the user has no way to retry. + setState("failed") + } + } + if (state === "stopping") { + return ( +

+ Stopping… can take up to 30s; the turn may settle as an error for now. +

+ ) + } + return ( + + + {state === "failed" ? ( + Stop failed — try again. + ) : null} + + ) +} From 41b761bd7e1b5ffa14a77fa1635c88f20fbe26fc Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 21:49:30 +0300 Subject: [PATCH 12/22] fix(mobile): review fixes for the approval resume path --- .../src/features/chat/useApprovalActions.ts | 28 +++++++++++++------ .../agenta-entities/src/session/api/api.ts | 9 +++--- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/web/mobile/src/features/chat/useApprovalActions.ts b/web/mobile/src/features/chat/useApprovalActions.ts index 14927b6a91..33cce6f987 100644 --- a/web/mobile/src/features/chat/useApprovalActions.ts +++ b/web/mobile/src/features/chat/useApprovalActions.ts @@ -68,6 +68,14 @@ export const useApprovalActions = ({ } }, [pendingCount]) + // Failure-path re-arm: if the resume was accepted but the run dies before the gate + // resolves, the poll never settles us — drop back to idle so the buttons re-arm. + useEffect(() => { + if (phase !== "resuming") return + const handle = setTimeout(() => setPhase("idle"), 60_000) + return () => clearTimeout(handle) + }, [phase]) + const submit = useCallback( async (target: {all: true} | {all?: false; approvalId: string}, approved: boolean) => { if (busyRef.current) return @@ -93,12 +101,16 @@ export const useApprovalActions = ({ projectId, actionableOnly: true, }) - const references = sanitizeReferences( - interactions?.find( - (row) => - row.data?.references && Object.keys(row.data.references).length > 0, - )?.data?.references, + const withRefs = (interactions ?? []).filter( + (row) => row.data?.references && Object.keys(row.data.references).length > 0, ) + // Bind to the answered gate's own row when possible — two parked runs on + // different revisions in one session must not resume with the wrong config. + const answeredId = target.all ? undefined : target.approvalId + const matched = answeredId + ? withRefs.find((row) => row.token === answeredId) + : undefined + const references = sanitizeReferences((matched ?? withRefs[0])?.data?.references) if (!references) { throw new Error( "This approval carries no workflow reference — answer on desktop.", @@ -130,9 +142,9 @@ export const useApprovalActions = ({ if (!response.ok) { throw new Error(`Resume failed (HTTP ${response.status}).`) } - // Fire-and-forget: drain the stream in the background so the browser doesn't - // cancel the request; the run continues server-side regardless. - void response.text().catch(() => undefined) + // Fire-and-forget: release the stream immediately — session runs survive + // client disconnect, and holding the SSE open for the whole turn is waste. + void response.body?.cancel().catch(() => undefined) } catch (err) { setPhase("error") setErrorText(err instanceof Error ? err.message : "Resume failed.") diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 3ea4f85305..9c8df5ffa2 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -393,11 +393,10 @@ export interface CommandSessionStreamParams extends SessionScopedParams { * delivered out-of-band (see the agent-chat transport). Use `force` to steal the lock, * `detached` for fire-and-forget. * - * FOLLOWUP(sessions,lifecycle): steer/cancel/attach are NOT surfaced in the user-facing chat on - * purpose — on the product path they only edit Redis locks; the runner doesn't cooperatively - * cancel/steer, and there's no live-turn re-watch, so wiring them into chat would be a no-op stub. - * The chat's send/stop (via `/invoke` + useChat abort) and `killSession` are the real ops. Revisit - * when the runner cooperates. See docs/designs/sessions/frontend-integration.md. + * FOLLOWUP(sessions,lifecycle): steer/attach remain unwired in the user-facing desktop chat; + * cancel IS consumed by the mobile StopButton (cooperative ≤30s; clean "cancelled" settle + * arrives with the agent-cancel-steer runner work). There's still no live-turn re-watch. + * See docs/designs/sessions/frontend-integration.md. */ export async function commandSessionStream({ sessionId, From e37b526d41d678d141f117c3848bfdb4847087e7 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 21:50:15 +0300 Subject: [PATCH 13/22] docs(mobile): record the flows-lite, auth-lite, and approvals execution --- docs/design/agenta-mobile/README.md | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/design/agenta-mobile/README.md b/docs/design/agenta-mobile/README.md index cd1aab0aa8..e357e7c835 100644 --- a/docs/design/agenta-mobile/README.md +++ b/docs/design/agenta-mobile/README.md @@ -216,6 +216,36 @@ breakdown and grounding facts. operator step against a running stack (flag-on run is likewise an operator step — see Open items below). +## Flows-lite + auth-lite + approvals (2026-07-26/27) — EXECUTED + +The postponed-fidelity phase Arda redirected into ("keep ui look basic / raw … focus on +navigation / flows / logic"). All raw-UI; the radix-primitives track re-skins later. + +- **Flows-lite** ([plan](./plans/2026-07-26-mobile-flows-lite.md), 6 tasks + review fixes, + commits `cf2792299f…9137760079`): @agenta/* packages wired into the mobile app + both + container layers; AppProviders (default-store jotai + queryClientAtom + sdk host) + + route→projectIdAtom ContextSync; root workspace/project resolution (stored → single → + desktop-continuity → raw picker); sessions list (querySessions windowed cursor, debounced + search, `includeArchived:false`); read-only transcript replay (loadSessionMessages + + buildTurnViewModels). Reviewed: approve after 4 fixes. **Live-verified by Arda.** +- **Auth-lite** (commits `2a2f91af61…f0809ee1c4`): gate maps `/auth`→`/m/auth` + (`/auth/callback` stays desktop — OAuth must land there); headless supertokens-web-js + (desktop-identical appInfo); refresh-before-verdict + the **provider-scope + `ensureAuthInit()`** (the SuperTokens fetch interceptor must install before ANY API call — + live QA caught the sessions query 401ing without it); raw email/password `/m/auth` page + (OTP/SSO → "use desktop" notice). +- **Approvals** ([plan](./plans/2026-07-27-mobile-approvals-steering.md) — read §1: there is + NO server-side session SSE; approval answers are fresh `/invoke` POSTs; §4b decisions; + 9 commits `53e1fa427f…2a2ba33f2a` + review fixes `02c36566aa`): M0 badges/polls, M1 + approve/deny/approve-all via the references-only lite resume builder (fire-and-forget) + + Stop, runner warm-park TTL 5→30min, M2 detached respond composition (api). Reviewed: + approve; both high-risk contracts traced end-to-end. **Live-unverified:** warm-vs-cold on a + detached respond (probe: answer via `/respond`, check runner logs `resume key=` vs + `approval-mismatch`); runner process restart required to activate the TTL. +- **⚠️ Standing follow-ups (Arda: do not forget):** M3 live relay (desktop live-updating a + phone-resumed turn) and steer-lite (M1.5 specced+unbuilt, gated on runner + reject-with-feedback #5444; Arda may request next). + ## Resume runbook (from here) 1. **Plan wave-2** against the real code (WP2 auth/drawer → WP3b skin → WP4 pages → WP5 gate). From fd50967b6abcc0299fc31f6bca66c61f4cda8fa3 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 22:00:35 +0300 Subject: [PATCH 14/22] fix(mobile): pin the chat and list headers and contain scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat header and the sessions search bar scrolled away with the page because both screens used document scroll (min-h-dvh columns). Make each screen an h-dvh flex column with a shrink-0 header and a flex-1 overflow-y-auto transcript/list scroller, with overscroll-contain so reaching the edge of the scroller does not chain into pull-to-refresh. An inner scroller loses the browser's native scroll restoration, so the sessions list records its scrollTop per project and restores it once per mount — back-navigation from a chat lands where the user left off (the infinite-query cache still holds the loaded pages). --- web/mobile/src/features/chat/ChatHeader.tsx | 2 +- web/mobile/src/features/chat/ChatScreen.tsx | 6 +++--- .../features/sessions/SessionListScreen.tsx | 14 ++++++++++--- .../sessions/useSessionListScrollRestore.ts | 21 +++++++++++++++++++ 4 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 web/mobile/src/features/sessions/useSessionListScrollRestore.ts diff --git a/web/mobile/src/features/chat/ChatHeader.tsx b/web/mobile/src/features/chat/ChatHeader.tsx index eaccb97f60..492751dd0b 100644 --- a/web/mobile/src/features/chat/ChatHeader.tsx +++ b/web/mobile/src/features/chat/ChatHeader.tsx @@ -18,7 +18,7 @@ export const ChatHeader = ({ staleTime: 30_000, }) return ( -
+
+
{running ? ( -
+
A turn is running
) : null} - {body} +
{body}
) } diff --git a/web/mobile/src/features/sessions/SessionListScreen.tsx b/web/mobile/src/features/sessions/SessionListScreen.tsx index 5a7b7f56da..fd1fd5d37a 100644 --- a/web/mobile/src/features/sessions/SessionListScreen.tsx +++ b/web/mobile/src/features/sessions/SessionListScreen.tsx @@ -8,6 +8,7 @@ import {SessionSearchBar} from "./SessionSearchBar" import {SessionListEmpty, SessionListError, SessionListLoading} from "./states/SessionListStates" import {pendingCountBySession, useActionableInteractions} from "./useActionableInteractions" import {livenessBySession, useLivenessPoll} from "./useLivenessPoll" +import {useSessionListScrollRestore} from "./useSessionListScrollRestore" import {useSessionsInfinite} from "./useSessionsInfinite" /** Sessions list: server-side search, id+activity cursor paging, archived rows hidden. */ @@ -26,6 +27,7 @@ export const SessionListScreen = ({ }, [input]) const query = useSessionsInfinite(projectId, search) + const scroll = useSessionListScrollRestore(projectId, !query.isPending) const liveness = useLivenessPoll(projectId) const liveBadges = useMemo(() => livenessBySession(liveness.data), [liveness.data]) const interactions = useActionableInteractions(projectId) @@ -86,8 +88,8 @@ export const SessionListScreen = ({ } return ( -
-
+
+
{pendingTotal > 0 ? (

@@ -95,7 +97,13 @@ export const SessionListScreen = ({

) : null}
- {body} +
+ {body} +
) } diff --git a/web/mobile/src/features/sessions/useSessionListScrollRestore.ts b/web/mobile/src/features/sessions/useSessionListScrollRestore.ts new file mode 100644 index 0000000000..88a83b5309 --- /dev/null +++ b/web/mobile/src/features/sessions/useSessionListScrollRestore.ts @@ -0,0 +1,21 @@ +import {useCallback, useLayoutEffect, useRef} from "react" + +// Inner scrollers get no browser scroll restoration on back-navigation. +const savedPositions = new Map() + +/** Records the list scroller's position and restores it once per mount (when content is ready). */ +export const useSessionListScrollRestore = (key: string, ready: boolean) => { + const ref = useRef(null) + const restoredRef = useRef(false) + useLayoutEffect(() => { + const el = ref.current + if (!ready || restoredRef.current || !el) return + el.scrollTop = savedPositions.get(key) ?? 0 + restoredRef.current = true + }, [key, ready]) + const onScroll = useCallback(() => { + const el = ref.current + if (el) savedPositions.set(key, el.scrollTop) + }, [key]) + return {ref, onScroll} +} From 79ea6ae499dbe6f59068b32042e95cd8e757acd0 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 22:03:07 +0300 Subject: [PATCH 15/22] fix(mobile): keep the transcript pinned to the latest message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat transcript opened at the oldest message and stayed there while the records poll appended new ones. Pin the scroller to the bottom on first content and after each poll delivery, but only while the user is already within 80px of the bottom — scrolling up to read history is never yanked back down. Plain scrollTop math on the transcript scroller, no libraries. --- web/mobile/src/features/chat/ChatScreen.tsx | 11 +++++++++- .../features/chat/useTranscriptAutoScroll.ts | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 web/mobile/src/features/chat/useTranscriptAutoScroll.ts diff --git a/web/mobile/src/features/chat/ChatScreen.tsx b/web/mobile/src/features/chat/ChatScreen.tsx index 01550b2906..05f13fd175 100644 --- a/web/mobile/src/features/chat/ChatScreen.tsx +++ b/web/mobile/src/features/chat/ChatScreen.tsx @@ -14,6 +14,7 @@ import {StopButton} from "./StopButton" import {TurnRow} from "./TurnRow" import {useApprovalActions} from "./useApprovalActions" import {useSessionTranscript} from "./useSessionTranscript" +import {useTranscriptAutoScroll} from "./useTranscriptAutoScroll" /** Read-only replay screen — mount it with `key={sessionId}` so per-session state resets. */ export const ChatScreen = ({ @@ -46,6 +47,8 @@ export const ChatScreen = ({ () => buildTurnViewModels(messages, {busy: false, executedFor}), [messages, executedFor], ) + // Keyed on `turns` (new array per poll) so streamed growth also re-pins. + const autoScroll = useTranscriptAutoScroll(turns) let body if (state === "loading") { @@ -78,7 +81,13 @@ export const ChatScreen = ({
) : null} -
{body}
+
+ {body} +
) } diff --git a/web/mobile/src/features/chat/useTranscriptAutoScroll.ts b/web/mobile/src/features/chat/useTranscriptAutoScroll.ts new file mode 100644 index 0000000000..6a294a1d64 --- /dev/null +++ b/web/mobile/src/features/chat/useTranscriptAutoScroll.ts @@ -0,0 +1,21 @@ +import {useCallback, useLayoutEffect, useRef} from "react" + +const NEAR_BOTTOM_PX = 80 + +/** Starts the transcript at the latest message; follows appends only while already near the bottom. */ +export const useTranscriptAutoScroll = (content: unknown) => { + const ref = useRef(null) + // Starts true so the first content render pins to the latest message. + const nearBottomRef = useRef(true) + const onScroll = useCallback(() => { + const el = ref.current + if (!el) return + nearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX + }, []) + useLayoutEffect(() => { + const el = ref.current + if (!el || !nearBottomRef.current) return + el.scrollTop = el.scrollHeight + }, [content]) + return {ref, onScroll} +} From 8f00fdedf1abba04923980e4e760d8b3cc5447a1 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 27 Jul 2026 22:06:14 +0300 Subject: [PATCH 16/22] fix(mobile): safe-area, input-zoom, and tap-target mechanics - 16px font (text-base) on the search, email, and password inputs so iOS Safari stops auto-zooming the page on focus. - env(safe-area-inset-bottom) padding on the transcript tail, the sessions scroller, and the root escape-hatch footer so the home indicator never covers the last row or link (viewport-fit=cover is already set in _app). - min-h-11 (~44px) hit areas on Approve/Deny/Approve-all, Stop, both Retry buttons, the project picker rows, and the sign-in submit; padding-with-negative-margin hit areas on the Back and Sign in links. - overscroll containment on the approval payload pre scroller. --- web/mobile/src/features/auth/SignInScreen.tsx | 6 +++--- web/mobile/src/features/chat/ApprovalCard.tsx | 8 ++++---- web/mobile/src/features/chat/ChatHeader.tsx | 2 +- web/mobile/src/features/chat/ChatScreen.tsx | 2 +- web/mobile/src/features/chat/StopButton.tsx | 2 +- web/mobile/src/features/context/ContextResolver.tsx | 4 ++-- web/mobile/src/features/context/WorkspaceProjectList.tsx | 2 +- .../src/features/context/states/SignedOutNotice.tsx | 2 +- web/mobile/src/features/sessions/SessionListScreen.tsx | 2 +- web/mobile/src/features/sessions/SessionSearchBar.tsx | 2 +- .../src/features/sessions/states/SessionListStates.tsx | 2 +- 11 files changed, 17 insertions(+), 17 deletions(-) diff --git a/web/mobile/src/features/auth/SignInScreen.tsx b/web/mobile/src/features/auth/SignInScreen.tsx index 6c289ef93c..8f93c90217 100644 --- a/web/mobile/src/features/auth/SignInScreen.tsx +++ b/web/mobile/src/features/auth/SignInScreen.tsx @@ -54,7 +54,7 @@ export const SignInScreen = () => { placeholder="Email" value={email} onChange={(event) => setEmail(event.target.value)} - className="border-border bg-background rounded-md border px-3 py-2 text-sm" + className="border-border bg-background rounded-md border px-3 py-2 text-base" /> { placeholder="Password" value={password} onChange={(event) => setPassword(event.target.value)} - className="border-border bg-background rounded-md border px-3 py-2 text-sm" + className="border-border bg-background rounded-md border px-3 py-2 text-base" /> {error ?

{error}

: null} diff --git a/web/mobile/src/features/chat/ApprovalCard.tsx b/web/mobile/src/features/chat/ApprovalCard.tsx index f66bdcd9d7..3f81b1dfa2 100644 --- a/web/mobile/src/features/chat/ApprovalCard.tsx +++ b/web/mobile/src/features/chat/ApprovalCard.tsx @@ -26,14 +26,14 @@ export const ApprovalCard = ({ return (

Approval pending — {toolName}

-
+            
                 {JSON.stringify(input, null, 2)}