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..96c171680c 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,255 @@ log = get_module_logger(__name__) +def _user_attachment_blocks(attributes: Dict[str, Any]) -> List[Dict[str, Any]]: + """Attachment blocks for one user record, in the runner's wire shape. + + Keys the runner omits when absent are omitted here too: a `null` filename is not the same + wire value as no filename. + """ + blocks: List[Dict[str, Any]] = [] + for attachment in attributes.get("attachments") or []: + if not isinstance(attachment, dict): + continue + attachment_id = attachment.get("attachmentId") + if not isinstance(attachment_id, str) or not attachment_id: + continue + block: Dict[str, Any] = {"type": "attachment", "attachmentId": attachment_id} + for wire_key, stored_key in ( + ("filename", "filename"), + ("mimeType", "mediaType"), + ("size", "size"), + ): + value = attachment.get(stored_key) + if value is not None: + block[wire_key] = value + blocks.append(block) + return blocks + + +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 + # A tool_result record stores only the call id, but the runner's cold replay renders each + # result as "[ returned: ...]" and matches approval nudges by tool name + # (`approvalRenderHints`). Carry the name forward from the call, exactly as the runner's own + # `reconstructMessages` does — without it every replayed result is an anonymous "tool". + call_names: Dict[str, str] = {} + + 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": + raw_text = attributes.get("text") + text = raw_text if isinstance(raw_text, str) else "" + attachments = _user_attachment_blocks(attributes) + if not text and not attachments: + continue + close_assistant() + # Attachments ride the user record and rebuild as blocks followed by exactly one + # text block, matching `services/runner/src/sessions/reconstruct.ts`. A turn that + # was only files still replays: dropping it would hand the model a different + # context than the one the human approved against. + content: Any = ( + [*attachments, {"type": "text", "text": text}] if attachments else text + ) + messages.append({"role": "user", "content": content}) + 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("id"): + call_names[attributes["id"]] = 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"] + if attributes["id"] in call_names: + block["toolName"] = call_names[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 id stored on the gate row itself; else the + token (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). + + The row is consulted before the token because warm-resume matching is strict on + ``toolCallId``: whenever record replay lags or comes back empty, the token would miss the + parked gate and drop an answerable turn to a cold replay for no reason. The row carries the + harness call id from the moment the gate was created (`buildInteractionData`), so it is + available even when no record is. + """ + 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 + + stored_request = getattr(interaction.data, "request", None) + stored = getattr(stored_request, "tool_call_id", None) + if isinstance(stored, str) and stored: + return stored + + 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 + if request is None: + return {"name": None, "args": None} + return {"name": request.tool, "args": request.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) + + gated_call = next( + ( + block + for message in messages + if isinstance(message.get("content"), list) + for block in message["content"] + if block.get("type") == "tool_call" and block.get("toolCallId") == gated_id + ), + None, + ) + has_gated_call = gated_call is not None + shape = _gated_call_shape(records, interaction) + 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. + 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, + }, + } + # The runner renders the resume nudge as "Call again with the same arguments" and + # matches stale-vs-live approvals by name. An unnamed envelope renders the literal word + # "tool", which names nothing the model can call — it then narrates a fabricated execution + # instead of re-issuing the call. + gated_name = (gated_call or {}).get("toolName") or shape.get("name") + if gated_name: + envelope["toolName"] = gated_name + 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 +292,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 +351,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..ccdb519691 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,23 @@ 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, + build_wire_messages, ) -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 +35,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 +213,326 @@ 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, plus the + # toolName the cold replay needs to name the call in its resume nudge. + assert blocks[-1] == { + "type": "tool_result", + "toolCallId": "tc-1", + "toolName": "bash", + "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"} + assert blocks[1]["toolName"] == "bash" + + +async def test_replayed_tool_results_carry_the_call_s_tool_name(): + """A tool_result record stores only the call id. The runner's cold replay renders results as + "[ returned: ...]" and matches approval nudges by name, so the name must be carried + forward from the tool_call — otherwise every replayed result is an anonymous "tool" and the + resume nudge tells the model to call something that does not exist. + """ + project_id = uuid4() + records = _approval_records(project_id) + [ + _record( + project_id, + rtype="tool_result", + attributes={"type": "tool_result", "id": "tc-1", "output": "ok"}, + index=3, + ), + ] + + messages = build_wire_messages(records) + + blocks = messages[1]["content"] + result = next(block for block in blocks if block["type"] == "tool_result") + assert result["toolName"] == "bash" + assert result["output"] == "ok" + + +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"} + + +async def test_the_stored_call_id_anchors_the_envelope_when_records_are_missing(): + """Warm-resume matching is strict on `toolCallId`. With no records to replay, falling + straight through to the token misses the parked gate and degrades an answerable turn to a + cold replay -- even though the row has carried the harness call id since gate creation.""" + project_id = uuid4() + interaction = _make_interaction( + kind=SessionInteractionKind.user_approval, + request={ + "tool": "bash", + "args": {"command": "rm -rf ./build"}, + "tool_call_id": "toolu_stored_1", + }, + ) + 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}, + ) + + blocks = dispatch_fn.await_args.kwargs["request"].data.inputs["messages"][0][ + "content" + ] + assert blocks[0]["toolCallId"] == "toolu_stored_1", ( + "the token is the last resort, not the second one" + ) + assert blocks[-1]["toolCallId"] == "toolu_stored_1" + + +async def test_an_explicit_client_id_still_outranks_the_stored_one(): + project_id = uuid4() + interaction = _make_interaction( + kind=SessionInteractionKind.user_approval, + request={"tool": "bash", "args": {}, "tool_call_id": "toolu_stored_1"}, + ) + 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, "tool_call_id": "toolu_from_client"}, + ) + + blocks = dispatch_fn.await_args.kwargs["request"].data.inputs["messages"][0][ + "content" + ] + assert blocks[0]["toolCallId"] == "toolu_from_client" + + +def test_user_records_replay_their_attachments(): + """A detached approval reconstructs the model's context. Dropping the files off a user turn + hands the agent a different conversation than the one the human approved against, and an + attachment-only turn vanishes entirely.""" + project_id = uuid4() + records = [ + _record( + project_id, + source="user", + rtype="message", + attributes={ + "type": "message", + "text": "review this", + "attachments": [ + { + "attachmentId": "att-1", + "filename": "spec.pdf", + "mediaType": "application/pdf", + "size": 12, + } + ], + }, + ), + ] + + messages = build_wire_messages(records) + + assert messages == [ + { + "role": "user", + "content": [ + { + "type": "attachment", + "attachmentId": "att-1", + "filename": "spec.pdf", + "mimeType": "application/pdf", + "size": 12, + }, + {"type": "text", "text": "review this"}, + ], + } + ] + + +def test_an_attachment_only_user_record_still_replays(): + project_id = uuid4() + records = [ + _record( + project_id, + source="user", + rtype="message", + attributes={ + "type": "message", + "attachments": [{"attachmentId": "att-1"}], + }, + ), + ] + + assert build_wire_messages(records) == [ + { + "role": "user", + "content": [ + {"type": "attachment", "attachmentId": "att-1"}, + {"type": "text", "text": ""}, + ], + } + ] + + +def test_a_user_record_with_neither_text_nor_attachments_is_skipped(): + project_id = uuid4() + records = [ + _record( + project_id, source="user", rtype="message", attributes={"type": "message"} + ), + ] + + assert build_wire_messages(records) == [] 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() 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). diff --git a/docs/design/agenta-mobile/plans/2026-07-27-m3-live-relay.md b/docs/design/agenta-mobile/plans/2026-07-27-m3-live-relay.md new file mode 100644 index 0000000000..9031272dc7 --- /dev/null +++ b/docs/design/agenta-mobile/plans/2026-07-27-m3-live-relay.md @@ -0,0 +1,271 @@ +# M3 — per-session live relay (grounded design) + +**Status:** PLANNED · **Date:** 2026-07-27 · **Branch:** `feat/agenta-mobile-wave-1` +**Parent:** `2026-07-27-mobile-approvals-steering.md` §3 Phase M3, §4b decision 1 (the standing +follow-up: an open desktop must eventually live-update a turn the phone resumed, and vice versa). +**Problem:** live tokens are the HTTP response of whichever client POSTed the turn. Every event +IS persisted producer-side regardless of watchers. M3 lets ANY authenticated client subscribe to +a session's live event flow (SSE) so no one has to own the turn to see it move. +All findings code-trace verified (file:line); nothing executed live. PLANNING ONLY. + +--- + +## 1. Grounded findings + +### 1.1 The tee is record-granularity by construction — tokens never reach it + +The producer-side persistence path, end to end: + +1. **Runner:** `buildPersistingEmitter` (`services/runner/src/sessions/persist.ts:160-354`) + forwards every raw `AgentEvent` to `liveEmit` (the invoke response stream) but **coalesces + before persisting**: `message_start/delta/end` accumulate and persist ONCE as + `{type:"message", text}` on `message_end` (persist.ts:234-263); `thought_*` likewise + (persist.ts:264-293); `tool_call` arg-snapshots accumulate into one open slot persisted on + close/idle-TTL (persist.ts:206-232, `OPEN_TOOL_TTL_MS` = 3s, :145). **Individual deltas are + never POSTed anywhere.** Each coalesced record goes through a per-session ordered + fire-and-forget chain (`persistEvent`, persist.ts:95-122) → `POST /sessions/records/ingest` + (persist.ts:49), 3 retries then drop (persist.ts:27-28, 85-88). The run is never blocked on + persistence (header invariants, persist.ts:13-17). +2. **API ingest:** `ingest_record_event` (`api/oss/src/apis/fastapi/sessions/router.py:510-540`) + — `RUN_SESSIONS` check → `publish_record` → `XADD streams:records` on the **durable** Redis + (`api/oss/src/core/sessions/records/streaming.py:51-97`; maxlen 100k, zlib+orjson, 64KB + attribute cap). +3. **Worker:** `RecordsWorker` (`api/oss/src/tasks/asyncio/sessions/records_worker.py:19-160`), + hosted by `api/entrypoints/worker_streams.py:80-86` in the worker-streams process — + `XREADGROUP` batches (max 50, `max_delay_ms=250`, idle block 5000ms), groups by project, EE + quota check, then `append_many` upsert into Postgres + (`api/oss/src/dbs/postgres/sessions/records/dao.py:56-97`). + +**Consequences for a relay:** + +- A tee off this path carries **coalesced records** (`message`/`thought`/`tool_call`/ + `tool_result`/`interaction_request`…), not token chunks. Token-level fidelity is not available + here at any price. +- The runner has **no Redis client** (`services/runner/package.json:22-37` — no redis/ioredis + dep; the contract file `src/sessions/contract.ts` mirrors wire *shapes* only). Token-level + relay would mean either per-delta HTTP POSTs (the coalescing exists precisely to avoid that + volume) or brand-new runner→Redis infrastructure. +- Two zero-risk tee points exist, both already off the run's request path: + - **(a) ingest handler, post-XADD** — the record payload is in hand; but it fires BEFORE the + Postgres write (worker is async), so a notified client that revalidates immediately can + miss the row. + - **(b) `RecordsWorker.process_batch`, post-`append_many`** — fires strictly AFTER the DB + write; batching gives free debounce (≤ ~4 notifications/s/session worst case at + `max_delay_ms=250`, typically far less); adds zero latency/failure risk to persistence + (a failed publish is log-and-continue after the append already committed). + +### 1.2 The record log is NOT append-only — a DB cursor is unsound + +- `record_id` is runner-supplied uuid5 (tool-family, retry-stable) or backend-minted **uuid4** + (`api/oss/src/dbs/postgres/sessions/records/mappings.py:16-20`) — NOT time-ordered. The + parent plan's §C sketch ("uuid7 record id = natural resume token") is **wrong**. +- `append_many` is an upsert on `(project_id, record_id)` that **updates rows in place** + (dao.py:84-97) — a re-sent tool_call record mutates an old row whose `created_at` sits behind + any cursor. Read order is `created_at ASC, record_index ASC` (dao.py:112). +- `get_records` has no windowing at all — it is always the whole log (dao.py:99-116; + `query_records` router.py:468-489 passes only `session_id`). + +So "replay records from cursor, then follow the channel" (parent §C.1) is not sound as +specced. A payload-carrying relay would need client-side upsert-by-record_id semantics AND +either an `updated_at`-based delta query or accept-missed-mutations. A change-notification +relay sidesteps all of it: the notification carries no data, the client re-reads the whole log +through the existing (IDB-persisted, deduped) query. + +### 1.3 Transport: Redis pub/sub exists in both flavors; the API has no SSE yet + +- Two Redis planes: **volatile** (`CacheEngine`/`LockEngine`, + `api/oss/src/dbs/redis/shared/engine.py:9-83`) and **durable** (`StreamsEngine`, + engine.py:85-105, `REDIS_URI_DURABLE` — `api/oss/src/utils/env.py:1259`). They may be + different instances — publisher and subscriber must pick ONE plane. +- Pub/sub precedent: exactly one — the attach-steal `displaced:` channel publish + (`api/oss/src/dbs/redis/sessions/locks.py:178-196` via `LockEngine.publish`; naming + + tenant-boundary rules in `api/oss/src/dbs/redis/sessions/contract.py:7-19, 60-61`). Zero + subscribers anywhere (re-verified: no `.subscribe()`/`pubsub()` in api). Pattern to mirror: + project-scoped channel names, payload shape defined in `contract.py`. +- **No SSE endpoint exists.** `StreamingResponse` is used only for zip/file downloads + (`api/oss/src/apis/fastapi/mounts/utils.py:104-152`) and testsets. No `sse-starlette` dep + (`api/pyproject.toml`) — plain `StreamingResponse(media_type="text/event-stream")` on + fastapi 0.139 suffices; heartbeat comment frames replace the library. +- Auth on a long-lived GET: `auth_middleware` (`api/oss/src/middlewares/auth.py:134`, + registered `api/entrypoints/routers.py:469` via `app.middleware("http")`) accepts Bearer, + ApiKey, AND the `sAccessToken` cookie (auth.py:290), and sets + `request.state.{user_id,project_id}` once at request start — the SSE handler then does the + same `check_action_access(VIEW_SESSIONS)` as `query_records` (router.py:475-480). Auth is + evaluated once at connect; scope holds for the connection's lifetime (standard SSE; cap the + connection age server-side if that ever matters). +- Proxy path: Traefik routes `/api` → api:8000 with a strip-prefix middleware only + (`hosting/docker-compose/oss/docker-compose.dev.yml:188-194`); no custom responding + timeouts configured. Heartbeats every ~15s keep any idle timeout (Traefik or client) happy. + Same-origin `/api` + cookie auth ⇒ native `EventSource` works on both mobile and desktop + with zero custom headers. + +### 1.4 Client side: the push-invalidate seam already exists on both surfaces + +- **The gap is even named in code:** `@agenta/entities` + `src/session/state/records.ts:5` — "no live backend channel for records". The write-atom to + fire is `revalidateSessionRecordsAtom` (records.ts:111-121); the shared query + (`sessionRecordsQueryFamily`, records.ts:41-50; staleTime 15s, IDB-persisted, + always-revalidate-on-restore) dedupes every surface. +- **Mobile already polls exactly the loop a push would trigger:** `useSessionTranscript`'s + `tick()` (`web/mobile/src/features/chat/useSessionTranscript.ts:42-68`) = invalidate via + `revalidateSessionRecordsAtom` + `loadSessionMessages` re-read, on a timer set by + `ChatScreen` (`web/mobile/src/features/chat/ChatScreen.tsx:31-42`): 4s while a decision + settles, 7.5s while running/pending, 0 idle, foreground-only. The relay replaces the timer's + *trigger*, not the machinery. +- **Desktop:** records adoption is already server-push-shaped — `useAgentConversation` + revalidate-on-open adopts the server transcript only when strictly ahead and never over a + live stream (`web/packages/agenta-chat/src/hooks/useAgentConversation.ts:300-324`, + count-based, `busyRef`-guarded). A push-invalidate feeds the same `loadSessionMessages` → + guarded-adopt path. Desktop needs NO change for the M3 BE to ship. +- **Chunk-format reality:** the v6 UIMessage chunk stream desktop's transport consumes is + minted in the SDK's vercel projection + (`sdks/python/agenta/sdk/agents/adapters/vercel/routing.py`) from runner NDJSON — that + projection exists only in the invoke response path. The relay tee sits upstream of it. A + record-payload relay would feed `transcriptToMessages`-shaped records + (`web/packages/agenta-chat/src/assets/transcriptToMessages.ts:114-170` handles exactly the + persisted `record_type` set), so an incremental reducer is *conceivable* — but it is new FE + work plus the §1.2 upsert/dedupe problem, for paragraph-granularity updates (records land at + message-END, never mid-message, per §1.1 coalescing). +- **Liveness poll** (`web/mobile/src/features/sessions/useLivenessPoll.ts:13-22`, mirror of + desktop `web/oss/src/components/AgentChatSlice/state/liveness.ts:29-45`): one project-scoped + query, 15s-while-alive, stops when idle. Cheap; out of scope to replace in v1 (see open + question 2). + +### 1.5 Lifecycle facts + +- Pub/sub crosses API replicas via the shared Redis instance; the SSE connection lives on + whichever replica served the GET — **no sticky-session trap** (no replica-local state; the + notification originates in the worker process, not the API replica). Locally everything is + single-instance (`docker-compose.dev.yml`; the worker-streams process is separate, + worker_streams.py:122-167). If `RecordsWorker` is ever scaled, consumer-group members each + publish for their own batches — still correct. +- Cost when nobody is subscribed: `PUBLISH` to zero subscribers is an O(1) Redis op returning + 0 — **cheap publish to nobody**, ≤ ~4/s/session while a turn runs, zero when idle (no + ingest ⇒ no batches ⇒ no publish). Conditional-publish (subscriber counting) is not worth + its complexity. +- Backpressure: the notification degenerates to a boolean "changed" per session — coalesce in + a per-connection queue (drop duplicates while one is unsent). A slow client can never build + a meaningful backlog. +- Reconnect: `EventSource` auto-reconnects; the client revalidates once on every `open` — + that single rule covers all missed notifications, so the server needs **no replay, no + cursor, no delivery guarantees**. (This is the property that makes the notification variant + ~10x simpler than the payload variant.) + +--- + +## 2. Two-fidelity analysis + +### Fidelity A — change-notification relay (recommended) + +SSE event says "records changed for session X"; clients revalidate through the existing +records query. Publish from `RecordsWorker` post-append (§1.1 tee point b) so the +notification is strictly DB-write-ordered — the revalidating client always sees the new rows. + +- **Latency:** message-end → ingest POST → worker batch (≤ ~300ms when events are flowing) → + publish → client refetch ≈ **1-2s end-to-end**, vs today's up-to-7.5s mobile poll and + never-until-reopen desktop. Records land at message/tool granularity anyway (§1.1), so this + is within one "paragraph" of the best any relay off this tee can do. +- **New code:** one publish call in the worker, one SSE endpoint, one mobile hook. No new + client reducer, no cursor, no delivery semantics, no contract between relay payload and + `transcriptToMessages`. +- **Cost note:** each notification triggers a whole-log refetch (~200KB on long sessions, + backend-slow — parent plan §5). Net requests go DOWN vs the 4-7.5s poll (fetch only on + change, batch-debounced), but the per-fetch weight is unchanged. If that ever hurts, a + `since`/windowing param on `query_records` is an independent, later optimization (imperfect + under §1.2 upserts; would need `updated_at`-delta). + +### Fidelity B — record-payload relay (M3.5, only if ever needed) + +Publish the record body (tee point a, ingest handler) on the channel; clients apply +incrementally. Honest accounting: needs a new incremental records→UIMessage reducer in +`@agenta/chat` with upsert-by-record_id semantics (§1.2), dedupe against the periodic full +refetch, and a replay story for reconnect that the DB cannot cleanly provide (§1.2) — in +exchange for saving the refetch, NOT for finer granularity (still message-end-level; §1.1). +Skip unless the whole-log refetch cost becomes the measured bottleneck. + +### Fidelity C — token-level relay (rejected) + +Deltas exist only inside the runner process and its invoke HTTP response (§1.1). Getting them +out requires per-delta runner→API POSTs (the exact volume the coalescing was built to avoid) +or a new runner→Redis dependency (§1.1), PLUS a client-side AgentEvent→UIMessage incremental +projector that exists today only in Python (§1.4). All of that to upgrade "new paragraph +appears in ~1-2s" to "characters tick" on a *watched* (not owned) turn. Not an M3.5 — a +separate product decision (open question 1). + +**Recommendation: Fidelity A now.** It kills the poll latency, reuses the poll's own +revalidation machinery as the event handler, ships with no desktop changes required, and its +one moving part (the SSE endpoint) is the piece every later fidelity needs anyway. + +--- + +## 3. Task list + +Conventions: new env vars via `env.py` (api/AGENTS.md); channel name + payload shape added to +`contract.py` beside the displaced channel (project-scoped key rule, contract.py:14-19); +domain layering per api/AGENTS.md. + +### BE + +- **T1 — contract + publish.** Add `records_changed_channel(project_id, session_id)` + (`records-changed::session:`) and its payload shape + (`{session_id, turn_id?}`) to `api/oss/src/dbs/redis/sessions/contract.py`. In + `RecordsWorker.process_batch` (`records_worker.py:143-160`), after each successful + `append_many`, publish ONCE per distinct `(project_id, session_id)` in that project batch, + using the worker's existing durable redis client (`worker_streams.py:134-138`). + Log-and-continue on publish failure — persistence is already committed and must not be + re-driven by relay errors. Unit test with fakeredis: batch with 2 sessions ⇒ 2 publishes, + each after append; append failure ⇒ no publish for that batch. +- **T2 — SSE watch endpoint.** `GET /sessions/streams/watch?session_id=` on the + `StreamsRouter` (`api/oss/src/apis/fastapi/sessions/router.py`): `check_action_access` + (`VIEW_SESSIONS`, mirroring router.py:475-480), validate `session_id` + (contract.py:121-131), then `StreamingResponse(media_type="text/event-stream")` that + subscribes a durable-Redis pubsub (`get_streams_engine()`) to the session channel and + yields `event: records-changed` frames, with a `: heartbeat` comment every 15s and clean + teardown on client disconnect. v1: one pubsub connection per SSE connection (simplest; + revisit with a per-process shared listener + local fan-out only if connection counts + grow). Heartbeat interval as an env-backed setting in `env.py`. Test: endpoint yields the + event after a publish, heartbeats while idle, 403 without VIEW_SESSIONS. +- **T3 — spec surface.** Set `operation_id` and mount the route so it lands in OpenAPI; + regenerate the Fern client for the types, but consumption is native `EventSource` (Fern + does not model SSE) — document that in the route docstring. + +### Mobile (the poll this replaces: §1.4) + +- **T4 — `useSessionWatch(sessionId, projectId)`** in `web/mobile/src/features/chat/`: + `EventSource` on `/api/sessions/streams/watch?session_id=&project_id=` (cookie auth, + same-origin); on `records-changed` → exactly `tick()`'s body + (`useSessionTranscript.ts:47-62`): `revalidateSessionRecordsAtom` + `loadSessionMessages`; + on `open` → one revalidation (missed-event coverage); teardown on background/unmount + (visibility rules as today). `ChatScreen` cadence (`ChatScreen.tsx:38-42`) becomes: SSE + open ⇒ slow safety-net poll (30s); SSE errored/unsupported ⇒ today's 4s/7.5s cadence + unchanged (the fallback IS the current behavior — no regression path). + +### Deferred (explicitly not in M3) + +- **Desktop consumption** — wire the same SSE into `revalidateSessionRecordsAtom` + + liveness invalidation post-FE-queue, like all desktop work. The reconciler + (`useAgentConversation.ts:300-324`) needs no changes. The M3 BE ships without this. +- **M3.5 payload relay** — only if the whole-log refetch is measured as the bottleneck (§2B). +- **Liveness/turn-settled over the relay** — see open question 2. + +## 4. Open questions for Arda + +1. **Is ~1-2s paragraph-level "live" enough as the durable cross-device answer, or is + character-level streaming on watched turns a product requirement someday?** The former is + M3 as planned; the latter is Fidelity C — new runner-side producer infrastructure, worth + knowing about before anyone assumes M3.5 gets there (it does not; §2C). +2. **Should the watch channel also carry turn lifecycle (running/settled/approval-pending) to + retire the 15s liveness + interactions polls, or stay records-only in v1?** Records-only + is strictly simpler and the badges' polls are cheap; folding lifecycle in later means + either a second event type on the same channel (easy) or a project-wide channel for list + badges (new naming decision). + +## 5. Decisions (Arda, 2026-07-27) + +1. **Paragraph-level (~1-2s) change-notification SSE: YES** for the current iteration. + Token-by-token streaming (runner Redis client + per-delta publishing) stays rejected — + revisit only if a future product need demands cursor-level liveness on mobile. +2. **Lifecycle events on the same channel: YES** — the watch stream also carries turn + lifecycle (running/ended/approval-pending), so mobile retires all three polls + (records tick, liveness, actionable-interactions) in favor of one EventSource; the + polls remain as the documented no-regression fallback when the stream is down. 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..905c415845 --- /dev/null +++ b/docs/design/agenta-mobile/plans/2026-07-27-mobile-approvals-steering.md @@ -0,0 +1,337 @@ +# 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.) + +## 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 + 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`. 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"; 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 new file mode 100644 index 0000000000..3f81b1dfa2 --- /dev/null +++ b/web/mobile/src/features/chat/ApprovalCard.tsx @@ -0,0 +1,69 @@ +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} +
+ ) +} diff --git a/web/mobile/src/features/chat/ChatHeader.tsx b/web/mobile/src/features/chat/ChatHeader.tsx index eaccb97f60..85ebef2439 100644 --- a/web/mobile/src/features/chat/ChatHeader.tsx +++ b/web/mobile/src/features/chat/ChatHeader.tsx @@ -18,11 +18,11 @@ export const ChatHeader = ({ staleTime: 30_000, }) return ( -
+
Back diff --git a/web/mobile/src/features/chat/ChatScreen.tsx b/web/mobile/src/features/chat/ChatScreen.tsx index 2f489077a6..20901c2a14 100644 --- a/web/mobile/src/features/chat/ChatScreen.tsx +++ b/web/mobile/src/features/chat/ChatScreen.tsx @@ -1,11 +1,20 @@ -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" +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 = ({ @@ -17,7 +26,20 @@ 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 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 const executedFor = useMemo(() => createExecutedToolIdentityCache(), [sessionId]) @@ -25,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") { @@ -33,20 +57,37 @@ export const ChatScreen = ({ body = } else { body = ( -
+
{turns .filter((turn) => !turn.hidden) .map((turn) => ( - + ))}
) } return ( -
+
- {body} + {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..673e124459 --- /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} + + ) +} diff --git a/web/mobile/src/features/chat/TurnRow.tsx b/web/mobile/src/features/chat/TurnRow.tsx index 53cf3a70b2..e45e648a23 100644 --- a/web/mobile/src/features/chat/TurnRow.tsx +++ b/web/mobile/src/features/chat/TurnRow.tsx @@ -1,7 +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 +}) => (
( if (item.kind === "part") { if (item.part.type === "text") { return ( -

+

{item.part.text}

) } if (item.part.type === "reasoning") { return ( -

- {item.part.text} -

+
+ + Thoughts + +

+ {item.part.text} +

+
) } return null @@ -31,12 +50,24 @@ export const TurnRow = ({turn}: {turn: TurnViewModel}) => ( return (
{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 ( + + ) + } const summary = rowSummary(part) return ( -

+

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

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..33cce6f987 --- /dev/null +++ b/web/mobile/src/features/chat/useApprovalActions.ts @@ -0,0 +1,169 @@ +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]) + + // 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 + 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 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.", + ) + } + 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: 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.") + } 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/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} } 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} +} diff --git a/web/mobile/src/features/context/ContextResolver.tsx b/web/mobile/src/features/context/ContextResolver.tsx index 98eba8f28e..9daa260516 100644 --- a/web/mobile/src/features/context/ContextResolver.tsx +++ b/web/mobile/src/features/context/ContextResolver.tsx @@ -81,7 +81,7 @@ export const ContextResolver = () => {