diff --git a/.gitleaksignore b/.gitleaksignore index 5c796507b58..82da7adbdc9 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -158,6 +158,8 @@ ad74134f522cde71f860cb59b6363a8fdf0a64c6:ee/setup_agenta_web.sh:generic-api-key: 590578c803d94d8ccb1a6ca977471f3d44b43fc3:hosting/helm/oss/templates/config/app-configmap.yaml:generic-api-key:45 1d8f08b267675726441fcaaae24572bb635c5eac:api/oss/src/utils/env.py:generic-api-key:53 55f27e52327062382beb299b162f94895268d766:web/oss/public/__ENV.js:generic-api-key:1 +012ae6318c880d19944ffe1ff51740da0613cd8a:services/runner/tests/unit/server.test.ts:generic-api-key:586 +e22c629b3113e522f4fdd918a9b0971e62145056:services/runner/tests/unit/server.test.ts:generic-api-key:889 c98a5da1a33d2c0986e3c66329eaa5237fbccf3d:hosting/docker-compose/ee/aws/docker-compose.oss.prod.yml:generic-api-key:73 bf0cd42bffc2581b1df6f56fa6e4b20ff9b68c33:hosting/docker-compose/ee/aws/docker-compose.oss.aws.yml:generic-api-key:61 52cd40cefd3121eea2e21205e8208712b093529a:core/hosting/docker-compose/ee/docker-compose.dev.yml:generic-api-key:18 diff --git a/api/ee/src/middlewares/throttling.py b/api/ee/src/middlewares/throttling.py index d8acc6afe22..30e1785ecbc 100644 --- a/api/ee/src/middlewares/throttling.py +++ b/api/ee/src/middlewares/throttling.py @@ -6,6 +6,7 @@ from oss.src.utils.caching import get_cache, set_cache from oss.src.utils.logging import get_module_logger +from oss.src.middlewares.auth import SECRET_RESOLVE_GRANT, request_has_grant from oss.src.utils.throttling import Algorithm, check_throttles from ee.src.core.access.entitlements.types import ( @@ -40,6 +41,14 @@ def _normalize_path(request: Request) -> str: return path +def _is_runner_record_ingest(request: Request, method: str, path: str) -> bool: + return ( + method == Method.POST.value + and path == "/sessions/records/ingest" + and request_has_grant(request, SECRET_RESOLVE_GRANT) + ) + + def _matches_endpoint( method: str, path: str, @@ -168,6 +177,11 @@ async def throttling_middleware(request: Request, call_next): if hasattr(request.state, "admin") and request.state.admin: return await call_next(request) + method = request.method.lower() + path = _normalize_path(request) + if _is_runner_record_ingest(request, method, path): + return await call_next(request) + organization_id = ( request.state.organization_id if hasattr(request.state, "organization_id") @@ -221,10 +235,6 @@ async def throttling_middleware(request: Request, call_next): if not throttles: return await call_next(request) - method = request.method.lower() - - path = _normalize_path(request) - # log.debug( # "[throttling] START", org=organization_id, plan=plan, method=method, path=path # ) diff --git a/api/ee/tests/pytest/unit/test_throttling.py b/api/ee/tests/pytest/unit/test_throttling.py new file mode 100644 index 00000000000..e8166864430 --- /dev/null +++ b/api/ee/tests/pytest/unit/test_throttling.py @@ -0,0 +1,101 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import Request, Response + +from ee.src.core.access.entitlements.types import ( + Bucket, + Category, + Mode, + Throttle, + Tracker, +) +from ee.src.middlewares.throttling import throttling_middleware +from oss.src.middlewares.auth import SECRET_RESOLVE_GRANT + + +def _request(path: str, *, grants: tuple[str, ...] = ()) -> Request: + request = Request( + { + "type": "http", + "method": "POST", + "path": path, + "root_path": "/api" if path.startswith("/api/") else "", + "headers": [], + } + ) + request.state.organization_id = "organization-1" + request.state.token_grants = grants + return request + + +async def test_runner_record_ingest_bypasses_plan_throttle(): + request = _request( + "/api/sessions/records/ingest", + grants=(SECRET_RESOLVE_GRANT,), + ) + call_next = AsyncMock(return_value=Response(status_code=204)) + + with ( + patch( + "ee.src.middlewares.throttling._get_plan", new_callable=AsyncMock + ) as get_plan, + patch( + "ee.src.middlewares.throttling.check_throttles", + new_callable=AsyncMock, + ) as check_throttles, + ): + response = await throttling_middleware(request, call_next) + + assert response.status_code == 204 + call_next.assert_awaited_once_with(request) + get_plan.assert_not_awaited() + check_throttles.assert_not_awaited() + + +@pytest.mark.parametrize( + ("path", "grants"), + [ + ("/sessions/records/ingest", ()), + ("/sessions/query", (SECRET_RESOLVE_GRANT,)), + ], +) +async def test_throttle_still_counts_browser_ingest_and_other_runner_routes( + path: str, + grants: tuple[str, ...], +): + request = _request(path, grants=grants) + call_next = AsyncMock(return_value=Response(status_code=204)) + standard = Throttle( + categories=[Category.STANDARD], + mode=Mode.INCLUDE, + bucket=Bucket(capacity=10, rate=10), + ) + allowed = SimpleNamespace( + allow=True, + tokens_remaining=9, + retry_after_seconds=0, + ) + + with ( + patch( + "ee.src.middlewares.throttling._get_plan", + new_callable=AsyncMock, + return_value="test-plan", + ) as get_plan, + patch( + "ee.src.middlewares.throttling.get_plan_entitlements", + return_value={Tracker.THROTTLES: [standard]}, + ), + patch( + "ee.src.middlewares.throttling.check_throttles", + new_callable=AsyncMock, + return_value=[allowed], + ) as check_throttles, + ): + response = await throttling_middleware(request, call_next) + + assert response.status_code == 204 + get_plan.assert_awaited_once_with("organization-1") + check_throttles.assert_awaited_once() diff --git a/api/entrypoints/worker_streams.py b/api/entrypoints/worker_streams.py index 65abd998ed1..e9125c1eb00 100644 --- a/api/entrypoints/worker_streams.py +++ b/api/entrypoints/worker_streams.py @@ -27,6 +27,10 @@ from oss.src.core.secrets.services import VaultService from oss.src.core.sessions.interactions.service import SessionInteractionsService from oss.src.core.sessions.records.service import RecordsService +from oss.src.core.sessions.records.streaming import ( + LIVE_FRAME_STREAM_NAME, + RECORD_STREAM_NAME, +) from oss.src.core.tracing.service import TracingService from oss.src.dbs.postgres.events.dao import EventsDAO from oss.src.dbs.postgres.secrets.dao import SecretsDAO @@ -37,6 +41,7 @@ from oss.src.dbs.redis.sessions.watch import SessionsWatchPublisher from oss.src.tasks.asyncio.events.worker import EventsWorker from oss.src.tasks.asyncio.sessions.records_worker import RecordsWorker +from oss.src.tasks.asyncio.sessions.live_relay_worker import LiveRelayWorker from oss.src.tasks.asyncio.shared.consumer import StreamConsumer from oss.src.tasks.asyncio.tracing.worker import TracingWorker from oss.src.tasks.asyncio.webhooks.dispatcher import WebhooksDispatcher @@ -85,7 +90,7 @@ async def _build_records_worker(redis_client: Redis) -> StreamConsumer: return RecordsWorker( service=RecordsService(records_dao=RecordsDAO()), redis_client=redis_client, - stream_name="streams:records", + stream_name=RECORD_STREAM_NAME, consumer_group="worker-records", # M3 live relay: post-append change notifications on the durable plane, # reusing this process's durable connection. @@ -103,6 +108,14 @@ async def _build_records_worker(redis_client: Redis) -> StreamConsumer: ) +async def _build_live_relay_worker(redis_client: Redis) -> StreamConsumer: + return LiveRelayWorker( + redis_client=redis_client, + stream_name=LIVE_FRAME_STREAM_NAME, + consumer_group="worker-session-live-relay", + ) + + async def _build_events_worker(redis_client: Redis) -> StreamConsumer: events_service = EventsService(events_dao=EventsDAO()) @@ -136,6 +149,22 @@ async def _build_events_worker(redis_client: Redis) -> StreamConsumer: ) +async def _initialize_consumer(consumer: StreamConsumer) -> None: + await consumer.create_consumer_group() + removed = await prune_idle_consumers( + url=env.redis.uri_durable, + queue_name=consumer.stream_name, + consumer_group_name=consumer.consumer_group, + keep=consumer.consumer_name, + ) + if removed: + log.info( + "[STREAMS] Pruned idle consumers", + stream=consumer.stream_name, + removed=removed, + ) + + async def main_async() -> int: try: streams = _selected_streams() @@ -165,19 +194,19 @@ async def main_async() -> int: ] for consumer in consumers: - await consumer.create_consumer_group() - removed = await prune_idle_consumers( - url=env.redis.uri_durable, - queue_name=consumer.stream_name, - consumer_group_name=consumer.consumer_group, - keep=consumer.consumer_name, - ) - if removed: - log.info( - "[STREAMS] Pruned idle consumers", - stream=consumer.stream_name, - removed=removed, + await _initialize_consumer(consumer) + + if env.sessions.shared_reader and "records" in streams: + try: + live_relay = await _build_live_relay_worker(redis_client) + await _initialize_consumer(live_relay) + except Exception: + log.error( + "[STREAMS] Live relay disabled after initialization failure", + exc_info=True, ) + else: + consumers.append(live_relay) log.info("[STREAMS] Starting worker-streams", selected=streams) diff --git a/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000006_add_session_sequence_cursors.py b/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000006_add_session_sequence_cursors.py new file mode 100644 index 00000000000..ca869d9ff9d --- /dev/null +++ b/api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000006_add_session_sequence_cursors.py @@ -0,0 +1,63 @@ +"""add session sequence cursors + +Revision ID: oss000000006 +Revises: oss000000005 +Create Date: 2026-09-04 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "oss000000006" +down_revision: Union[str, None] = "oss000000005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "session_sequence_cursors", + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("latest_sequence", sa.BigInteger(), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + nullable=True, + ), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("created_by_id", sa.UUID(), nullable=True), + sa.Column("updated_by_id", sa.UUID(), nullable=True), + sa.Column("deleted_by_id", sa.UUID(), nullable=True), + sa.PrimaryKeyConstraint("project_id", "session_id"), + ) + op.add_column("records", sa.Column("sequence", sa.BigInteger(), nullable=True)) + with op.get_context().autocommit_block(): + op.create_index( + "ux_records_session_id_sequence", + "records", + ["project_id", "session_id", "sequence"], + unique=True, + postgresql_concurrently=True, + ) + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.drop_index( + "ux_records_session_id_sequence", + table_name="records", + postgresql_concurrently=True, + ) + op.drop_column("records", "sequence") + op.drop_table("session_sequence_cursors") diff --git a/api/oss/src/apis/fastapi/sessions/live_events.py b/api/oss/src/apis/fastapi/sessions/live_events.py new file mode 100644 index 00000000000..b7e24188c40 --- /dev/null +++ b/api/oss/src/apis/fastapi/sessions/live_events.py @@ -0,0 +1,188 @@ +import asyncio +import json +import math +from contextlib import suppress +from typing import Any, AsyncIterator, Awaitable, Callable, Optional + +from oss.src.core.sessions.records.dtos import ( + SessionDurableEvent, + SessionDurableEventsReplay, +) + +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + +HEARTBEAT_FRAME = ": heartbeat\n\n" +RELAY_CLOSE_EVENT = "relay-close" + + +def retry_frame(retry_milliseconds: int) -> str: + return f"retry: {retry_milliseconds}\n\n" + + +def ready_frame(*, watermark: Optional[int] = None) -> str: + payload = {} if watermark is None else {"watermark": watermark} + return f"event: ready\ndata: {json.dumps(payload)}\n\n" + + +def close_frame(*, reason: str, reconnect: bool) -> str: + payload = json.dumps({"reason": reason, "reconnect": reconnect}) + return f"event: {RELAY_CLOSE_EVENT}\ndata: {payload}\n\n" + + +def format_live_frame(raw: Any) -> Optional[str]: + try: + payload = json.loads(raw) + except (ValueError, TypeError): + return None + if not isinstance(payload, dict) or payload.get("kind") not in {"frame", "event"}: + return None + return f"data: {json.dumps(payload)}\n\n" + + +def format_durable_event(event: SessionDurableEvent) -> str: + return f"data: {event.model_dump_json()}\n\n" + + +async def live_event_stream( + *, + channel: str, + pubsub_factory: Callable[[], Any], + authorization_check: Callable[[], Awaitable[bool]], + authorization_recheck_seconds: float, + heartbeat_seconds: float, + retry_milliseconds: int, + buffer_limit: int, + after: int = 0, + replay_query: Optional[ + Callable[[int], Awaitable[SessionDurableEventsReplay]] + ] = None, +) -> AsyncIterator[str]: + queue: asyncio.Queue[str] = asyncio.Queue(maxsize=max(1, buffer_limit)) + stopped = asyncio.Event() + pubsub = pubsub_factory() + cursor = after + seen_event_ids: set[str] = set() + + def force_close(*, reason: str, reconnect: bool) -> None: + while not queue.empty(): + with suppress(asyncio.QueueEmpty): + queue.get_nowait() + queue.put_nowait(close_frame(reason=reason, reconnect=reconnect)) + stopped.set() + + def enqueue(frame: str) -> None: + try: + queue.put_nowait(frame) + except asyncio.QueueFull: + force_close(reason="slow_reader", reconnect=True) + + async def replay() -> int: + nonlocal cursor + if replay_query is None: + return cursor + result = await replay_query(cursor) + for event in result.events: + if event.frame_or_event_id in seen_event_ids: + continue + if event.sequence is not None and event.sequence <= cursor: + continue + seen_event_ids.add(event.frame_or_event_id) + # Replay is finite and must backpressure; only live producers may outrun readers. + await queue.put(format_durable_event(event)) + if event.sequence is not None: + cursor = event.sequence + cursor = max(cursor, result.watermark) + return cursor + + async def pump() -> None: + try: + await pubsub.subscribe(channel) + await queue.put(retry_frame(retry_milliseconds)) + # Unlike a live event's batch maximum, ready reports the authoritative session + # cursor returned by replay (or the requested `after` cursor without replay). + watermark = await replay() + await queue.put(ready_frame(watermark=watermark)) + loop = asyncio.get_running_loop() + last_authorization_check = loop.time() + poll_seconds = min( + 1.0, + heartbeat_seconds, + authorization_recheck_seconds, + ) + idle_polls_per_heartbeat = max( + 1, math.ceil(heartbeat_seconds / poll_seconds) + ) + idle_polls = 0 + + while not stopped.is_set(): + message = await pubsub.get_message( + ignore_subscribe_messages=True, + timeout=poll_seconds, + ) + now = loop.time() + if now - last_authorization_check >= authorization_recheck_seconds: + last_authorization_check = now + try: + authorized = await authorization_check() + except Exception: + authorized = False + if not authorized: + force_close(reason="authorization_revoked", reconnect=False) + return + + if message is None: + idle_polls += 1 + if idle_polls >= idle_polls_per_heartbeat: + idle_polls = 0 + enqueue(HEARTBEAT_FRAME) + continue + idle_polls = 0 + if message.get("type") != "message": + continue + try: + payload = json.loads(message.get("data")) + except (ValueError, TypeError): + continue + if ( + replay_query is not None + and isinstance(payload, dict) + and payload.get("kind") == "event" + ): + await replay() + continue + frame = format_live_frame(message.get("data")) + if frame is not None: + enqueue(frame) + except asyncio.CancelledError: + raise + except Exception: + log.warning( + "[SESSION-LIVE] relay reader failed", channel=channel, exc_info=True + ) + force_close(reason="relay_unavailable", reconnect=True) + finally: + try: + await pubsub.unsubscribe(channel) + except Exception: + log.warning("[SESSION-LIVE] pubsub unsubscribe failed", channel=channel) + try: + await pubsub.aclose() + except Exception: + log.warning("[SESSION-LIVE] pubsub close failed", channel=channel) + + task = asyncio.create_task(pump()) + try: + while True: + frame = await queue.get() + yield frame + if frame.startswith(f"event: {RELAY_CLOSE_EVENT}"): + break + if task.done() and queue.empty(): + break + finally: + stopped.set() + task.cancel() + with suppress(asyncio.CancelledError): + await task diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index a7ef9af42fe..2b577657656 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Annotated, Any, Dict, List, Literal, Optional +from typing import Annotated, Any, Dict, List, Literal, Optional, Union from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -13,7 +13,11 @@ SessionStream, SessionStreamQueryFlags, ) -from oss.src.core.sessions.records.dtos import SessionRecord +from oss.src.core.sessions.records.dtos import ( + SessionLiveFrame, + SessionRecord, + SessionRecordsReadState, +) from oss.src.core.sessions.interactions.dtos import ( SessionInteraction, SessionInteractionData, @@ -148,13 +152,41 @@ class SessionStreamsResponse(BaseModel): # --------------------------------------------------------------------------- +class SessionTranscriptWindowing(BaseModel): + """Deliberate exception to the shared cursor `Windowing`. + + `through_sequence` pins the snapshot, so offset paging over an append-only log inside that + bound is stable: a concurrent append raises `latest_sequence`, never the page contents. The + transcript reader also needs to seek within one pinned snapshot, which a forward-only cursor + cannot express. + """ + + offset: int = Field(default=0, ge=0) + limit: int = Field(default=100, ge=1, le=200) + through_sequence: int = Field(ge=0) + + class SessionRecordQueryRequest(BaseModel): session_id: str + windowing: Optional[SessionTranscriptWindowing] = None class SessionRecordsQueryResponse(BaseModel): count: int records: List[SessionRecord] + windowing: Optional[SessionTranscriptWindowing] = None + + +class SessionSnapshotPending(BaseModel): + inputs: List[Any] = Field(default_factory=list) + interactions: List[SessionInteraction] = Field(default_factory=list) + + +class SessionSnapshotResponse(BaseModel): + session: SessionStream + execution: Optional[SessionTurn] = None + pending: SessionSnapshotPending + read: SessionRecordsReadState class SessionRecordResponse(BaseModel): @@ -335,6 +367,7 @@ class SessionTurnsResponse(BaseModel): class SessionRecordIngestRequest(BaseModel): # project scope comes from the caller's credential, never the body session_id: str + kind: Optional[Literal["frame"]] = None # Optional stable id (uuid5) from the producer; absent when it has no stable key. record_id: Optional[UUID] = None record_index: Optional[int] = None @@ -346,6 +379,55 @@ class SessionRecordIngestRequest(BaseModel): # Both forward-fill only (tracing-DB rule) — absent on producers that predate this. turn_id: Optional[str] = None span_id: Optional[OTelSpanId] = None + version: Optional[Literal[1]] = None + execution_id: Optional[str] = None + frame_or_event_id: Optional[str] = None + frame_index: Optional[int] = Field(default=None, ge=0) + entity_id: Optional[str] = None + type: Optional[str] = None + payload: Optional[Dict[str, Any]] = None + created_at: Optional[datetime] = None + + @model_validator(mode="after") + def validate_live_frame(self) -> "SessionRecordIngestRequest": + if self.kind != "frame": + return self + required = ( + "version", + "execution_id", + "frame_or_event_id", + "frame_index", + "entity_id", + "type", + "payload", + "created_at", + ) + missing = [name for name in required if getattr(self, name) is None] + if missing: + raise ValueError(f"frame fields missing: {', '.join(missing)}") + SessionLiveFrame( + version=self.version, + kind="frame", + session_id=self.session_id, + execution_id=self.execution_id, + frame_or_event_id=self.frame_or_event_id, + frame_index=self.frame_index, + entity_id=self.entity_id, + type=self.type, + payload=self.payload, + created_at=self.created_at, + ) + return self + + +SessionRecordIngestBatch = Annotated[ + List[SessionRecordIngestRequest], + Field(min_length=1), +] +SessionRecordIngestBody = Union[ + SessionRecordIngestRequest, + SessionRecordIngestBatch, +] # --------------------------------------------------------------------------- diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index b587e34fcb4..96281be9e4f 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -41,9 +41,15 @@ from oss.src.utils.exceptions import intercept_exceptions from oss.src.utils.logging import get_module_logger -from oss.src.dbs.redis.sessions.contract import project_watch_channel, watch_channel -from oss.src.dbs.redis.shared.engine import get_streams_engine +from oss.src.dbs.redis.sessions.contract import ( + live_events_channel, + project_watch_channel, + watch_channel, +) +from oss.src.dbs.redis.shared.engine import get_lock_engine, get_streams_engine +from oss.src.dbs.redis.sessions.locks import get_running_owner from oss.src.apis.fastapi.sessions.watch import watch_event_stream +from oss.src.apis.fastapi.sessions.live_events import live_event_stream from oss.src.core.access.permissions.types import Permission from oss.src.core.access.permissions.service import check_action_access @@ -80,8 +86,12 @@ SessionCommandNotFound, ) from oss.src.core.sessions.records.service import RecordsService -from oss.src.core.sessions.records.dtos import SessionRecordEvent -from oss.src.core.sessions.records.streaming import publish_record +from oss.src.core.sessions.records.dtos import ( + MAX_LIVE_FRAME_BYTES, + SessionLiveFrame, + SessionRecordEvent, +) +from oss.src.core.sessions.records.streaming import publish_live_frame, publish_record from oss.src.core.sessions.interactions.dtos import ( SessionInteractionCreate, SessionInteractionKind, @@ -144,10 +154,13 @@ SessionStreamResponse, SessionStreamsResponse, # records - SessionRecordIngestRequest, + SessionRecordIngestBody, SessionRecordQueryRequest, SessionRecordResponse, SessionRecordsQueryResponse, + SessionSnapshotPending, + SessionSnapshotResponse, + SessionTranscriptWindowing, # interactions SessionInteractionCancelStaleRequest, SessionInteractionCreateRequest, @@ -318,9 +331,11 @@ def __init__( *, service: SessionStreamsService, interactions_service: SessionInteractionsService, + records_service: Optional[RecordsService] = None, ) -> None: self._service = service self._interactions_service = interactions_service + self._records_service = records_service self.router = APIRouter() # Unified collection surface on /sessions/streams/, keyed by ?session_id=. @@ -384,6 +399,14 @@ def __init__( tags=["Sessions"], response_model=None, ) + self.router.add_api_route( + "/sessions/{session_id}/events", + self.session_events, + methods=["GET"], + operation_id="watch_session_events", + tags=["Sessions"], + response_model=None, + ) self.router.add_api_route( "/sessions/watch", self.watch_project, @@ -686,6 +709,60 @@ async def watch_session_stream( }, ) + @intercept_exceptions() + async def session_events( + self, + request: Request, + session_id: str, + after: int = Query(default=0, ge=0), + ) -> StreamingResponse: + if not env.sessions.shared_reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + _validate_session_id_http(session_id) + project_id = str(request.state.project_id) + user_id = str(request.state.user_id) + + async def authorized() -> bool: + return await check_action_access( + user_uid=user_id, + project_id=project_id, + permission=Permission.VIEW_SESSIONS, + ) + + if not await authorized(): + raise FORBIDDEN_EXCEPTION + if self._records_service is None: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) + + async def replay(cursor: int): + return await self._records_service.get_events_after( + project_id=UUID(project_id), + session_id=session_id, + after=cursor, + ) + + stream = live_event_stream( + channel=live_events_channel(project_id, session_id), + pubsub_factory=lambda: get_streams_engine().get_redis().pubsub(), + authorization_check=authorized, + authorization_recheck_seconds=env.sessions.live_auth_recheck_seconds, + heartbeat_seconds=env.sessions.watch_heartbeat_seconds, + retry_milliseconds=env.sessions.watch_retry_milliseconds, + buffer_limit=env.sessions.live_reader_buffer_limit, + after=after, + replay_query=replay, + ) + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={ + "Cache-Control": "no-store", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + @intercept_exceptions() async def watch_project( self, @@ -778,10 +855,35 @@ async def query_records( ): raise FORBIDDEN_EXCEPTION - records = await self.records_service.get_records( - project_id=UUID(request.state.project_id), - session_id=query_request.session_id, - ) + records = ( + await self.records_service.get_records( + project_id=UUID(request.state.project_id), + session_id=query_request.session_id, + ) + if query_request.windowing is None + else None + ) + if query_request.windowing is not None: + page = await self.records_service.get_records_page( + project_id=UUID(request.state.project_id), + session_id=query_request.session_id, + offset=query_request.windowing.offset, + limit=query_request.windowing.limit, + through_sequence=query_request.windowing.through_sequence, + ) + return SessionRecordsQueryResponse( + count=len(page.records), + records=page.records, + windowing=SessionTranscriptWindowing( + offset=page.next_offset + if page.next_offset is not None + else page.offset, + limit=page.limit, + through_sequence=page.through_sequence, + ) + if page.next_offset is not None + else None, + ) return SessionRecordsQueryResponse( count=len(records), records=records, @@ -810,7 +912,7 @@ async def get_record_event( async def ingest_record_event( self, request: Request, - body: SessionRecordIngestRequest, + body: SessionRecordIngestBody, ) -> dict: project_id = request.state.project_id if not await check_action_access( @@ -820,6 +922,76 @@ async def ingest_record_event( ): raise FORBIDDEN_EXCEPTION + if isinstance(body, list): + if not body or any(item.kind != "frame" for item in body): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Batched record ingest accepts live frames only.", + ) + frames = body + else: + frames = [body] if body.kind == "frame" else [] + + if frames: + first = frames[0] + _validate_session_id_http(first.session_id) + if any( + frame.session_id != first.session_id + or frame.execution_id != first.execution_id + for frame in frames[1:] + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A live frame batch must share one session and execution.", + ) + content_length = request.headers.get("content-length") + if content_length is not None: + try: + request_size = int(content_length) + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Content-Length must be an integer.", + ) from error + if request_size < 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Content-Length cannot be negative.", + ) + if request_size > MAX_LIVE_FRAME_BYTES: + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + detail=( + f"Live frame request exceeds {MAX_LIVE_FRAME_BYTES} bytes." + ), + ) + current_execution_id = await get_running_owner( + get_lock_engine(), + project_id=str(project_id), + session_id=first.session_id, + ) + if current_execution_id != first.execution_id: + raise FORBIDDEN_EXCEPTION + for frame in frames: + await publish_live_frame( + organization_id=UUID(request.state.organization_id), + project_id=UUID(project_id), + frame=SessionLiveFrame( + version=frame.version, + kind="frame", + session_id=frame.session_id, + execution_id=frame.execution_id, + frame_or_event_id=frame.frame_or_event_id, + frame_index=frame.frame_index, + entity_id=frame.entity_id, + type=frame.type, + payload=frame.payload, + created_at=frame.created_at, + ), + ) + return {"ok": True} + + assert not isinstance(body, list) await publish_record( organization_id=UUID(request.state.organization_id), project_id=UUID(project_id), @@ -1733,8 +1905,20 @@ class SessionsRootRouter: three mutations. """ - def __init__(self, *, sessions_service: SessionsService) -> None: + def __init__( + self, + *, + sessions_service: SessionsService, + streams_service: Optional[SessionStreamsService] = None, + records_service: Optional[RecordsService] = None, + interactions_service: Optional[SessionInteractionsService] = None, + turns_service: Optional[SessionTurnsService] = None, + ) -> None: self.sessions_service = sessions_service + self.streams_service = streams_service + self.records_service = records_service + self.interactions_service = interactions_service + self.turns_service = turns_service self.router = APIRouter() self.router.add_api_route( @@ -1775,6 +1959,73 @@ def __init__(self, *, sessions_service: SessionsService) -> None: response_model_exclude_none=True, tags=["Sessions"], ) + self.router.add_api_route( + "/sessions/{session_id}", + self.get_session_snapshot, + methods=["GET"], + operation_id="get_session_snapshot", + status_code=status.HTTP_200_OK, + response_model=SessionSnapshotResponse, + response_model_exclude_none=True, + tags=["Sessions"], + ) + + @intercept_exceptions() + @_handle_session_exceptions() + async def get_session_snapshot( + self, + request: Request, + session_id: str, + ) -> SessionSnapshotResponse: + if not env.sessions.shared_reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + _validate_session_id_http(session_id) + if not await check_action_access( + user_uid=str(request.state.user_id), + project_id=str(request.state.project_id), + permission=Permission.VIEW_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + if not all( + ( + self.streams_service, + self.records_service, + self.interactions_service, + self.turns_service, + ) + ): + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) + + project_id = UUID(str(request.state.project_id)) + session = await self.streams_service.fetch( + project_id=project_id, + session_id=session_id, + ) + if session is None: + raise SessionStreamNotFound(session_id) + read = await self.records_service.get_read_state( + project_id=project_id, + session_id=session_id, + ) + if getattr(session, "history_incomplete", False): + read = read.model_copy(update={"history_complete": False}) + execution = await self.turns_service.latest_turn( + project_id=project_id, + session_id=session_id, + ) + interactions = await self.interactions_service.query_interactions( + project_id=project_id, + query=SessionInteractionQuery( + session_id=session_id, + status=SessionInteractionStatus.pending, + ), + ) + return SessionSnapshotResponse( + session=sanitize_session_stream(session), + execution=execution, + pending=SessionSnapshotPending(interactions=interactions), + read=read, + ) @intercept_exceptions() async def query_sessions( @@ -2161,6 +2412,7 @@ def __init__( self.streams = SessionStreamsRouter( service=streams_service, interactions_service=interactions_service, + records_service=records_service, ) self.records = RecordsRouter(records_service=records_service) self.interactions = InteractionsRouter( @@ -2177,5 +2429,11 @@ def __init__( mounts_service=mounts_service, ) self.turns = SessionTurnsRouter(turns_service=turns_service) - self.root = SessionsRootRouter(sessions_service=sessions_service) + self.root = SessionsRootRouter( + sessions_service=sessions_service, + streams_service=streams_service, + records_service=records_service, + interactions_service=interactions_service, + turns_service=turns_service, + ) self.control = SessionControlRouter(commands_service=commands_service) diff --git a/api/oss/src/apis/fastapi/sessions/utils.py b/api/oss/src/apis/fastapi/sessions/utils.py index 766831c1608..119412ef91d 100644 --- a/api/oss/src/apis/fastapi/sessions/utils.py +++ b/api/oss/src/apis/fastapi/sessions/utils.py @@ -15,6 +15,7 @@ from oss.src.dbs.postgres.sessions.streams.mappings import ( SESSION_RESERVED_TAG_NAMESPACE, ) +from oss.src.utils.env import env SessionStreamT = TypeVar("SessionStreamT", bound=SessionStream) @@ -266,4 +267,11 @@ def sanitize_session_stream( ) -> Optional[SessionStreamT]: if stream is None: return None - return stream.model_copy(update={"tags": sanitize_session_tags(stream.tags)}) + return stream.model_copy( + update={ + "tags": sanitize_session_tags(stream.tags), + "capabilities": stream.capabilities.model_copy( + update={"shared_reader": env.sessions.shared_reader} + ), + } + ) diff --git a/api/oss/src/core/sessions/records/dtos.py b/api/oss/src/core/sessions/records/dtos.py index ce0e1089d0f..d86bc391799 100644 --- a/api/oss/src/core/sessions/records/dtos.py +++ b/api/oss/src/core/sessions/records/dtos.py @@ -1,14 +1,16 @@ from datetime import datetime -from typing import Optional, Any, Dict +from typing import Annotated, Optional, Any, Dict, Literal, Union from uuid import UUID -from pydantic import BaseModel, Field +from orjson import dumps +from pydantic import BaseModel, Field, model_validator from oss.src.core.shared.dtos import Lifecycle, OTelSpanId # The DAO truncates at the SQL level (`left(attributes->>'text', ...)`) — this bound # just keeps the DTO honest about that contract for any other producer. SESSION_MESSAGE_PREVIEW_TEXT_LIMIT = 240 +MAX_LIVE_FRAME_BYTES = 64 * 1024 # The runner's terminal per-turn record type, mirrored from # services/runner/src/protocol.ts (`{ type: "done" }`). Also spelled in the records DAO and @@ -48,12 +50,175 @@ class SessionRecordEvent(BaseModel): quarantined_at: Optional[datetime] = None +class SessionLiveFrame(BaseModel): + version: Literal[1] + kind: Literal["frame"] + session_id: str + execution_id: str + frame_or_event_id: str + frame_index: int = Field(ge=0) + entity_id: str + type: str + payload: Dict[str, Any] + created_at: datetime + + @model_validator(mode="after") + def validate_serialized_size(self) -> "SessionLiveFrame": + size = len(dumps(self.model_dump(mode="json"))) + if size > MAX_LIVE_FRAME_BYTES: + raise ValueError( + f"serialized live frame exceeds {MAX_LIVE_FRAME_BYTES} bytes" + ) + return self + + +class SessionExecutionError(BaseModel): + code: str + message: str + retryable: bool + details: Optional[Dict[str, Any]] = None + + +class ExecutionStartedPayload(BaseModel): + started_at: datetime + + +class ExecutionStoppedPayload(BaseModel): + stopped_at: datetime + reason: str + command_id: Optional[str] = None + + +class ExecutionFailedPayload(BaseModel): + failed_at: datetime + error: SessionExecutionError + + +class ExecutionLostPayload(BaseModel): + lost_at: datetime + reason: str + history_complete: Literal[False] + + +class MessageCompletedPayload(BaseModel): + message_id: str + role: str + content: Any + finish_reason: Optional[str] = None + + +class ToolCompletedPayload(BaseModel): + tool_call_id: str + name: str + input: Any + output: Any = None + error: Any = None + status: str + + +class InteractionChangedPayload(BaseModel): + interaction_id: str + kind: Optional[str] = None + + +class SessionDurableEventBase(BaseModel): + """Durable relay wire envelope. + + ``watermark`` is a non-negative integer. On a live event it is the highest sequence + committed for that session in the publishing records-worker batch; on the SSE ``ready`` + frame the same field name is the authoritative session sequence cursor after replay. A + client that receives a ready frame without it keeps the requested ``after`` cursor. + """ + + version: Literal[1] = 1 + kind: Literal["event"] = "event" + session_id: str + execution_id: str + frame_or_event_id: str + entity_id: str + sequence: Optional[int] = Field(default=None, ge=1) + watermark: int = Field(ge=0) + created_at: datetime + + +class ExecutionStartedEvent(SessionDurableEventBase): + type: Literal["execution.started"] + payload: ExecutionStartedPayload + + +class ExecutionStoppedEvent(SessionDurableEventBase): + type: Literal["execution.stopped"] + payload: ExecutionStoppedPayload + + +class ExecutionFailedEvent(SessionDurableEventBase): + type: Literal["execution.failed"] + payload: ExecutionFailedPayload + + +class ExecutionLostEvent(SessionDurableEventBase): + type: Literal["execution.lost"] + payload: ExecutionLostPayload + + +class MessageCompletedEvent(SessionDurableEventBase): + type: Literal["message.completed"] + payload: MessageCompletedPayload + + +class ToolCompletedEvent(SessionDurableEventBase): + type: Literal["tool.completed"] + payload: ToolCompletedPayload + + +class InteractionRequestedEvent(SessionDurableEventBase): + type: Literal["interaction.requested"] + payload: InteractionChangedPayload + + +class InteractionRespondedEvent(SessionDurableEventBase): + type: Literal["interaction.responded"] + payload: InteractionChangedPayload + + +SessionDurableEvent = Annotated[ + Union[ + ExecutionStartedEvent, + ExecutionStoppedEvent, + ExecutionFailedEvent, + ExecutionLostEvent, + MessageCompletedEvent, + ToolCompletedEvent, + InteractionRequestedEvent, + InteractionRespondedEvent, + ], + Field(discriminator="type"), +] + + +class SessionDurableEventsReplay(BaseModel): + events: list[SessionDurableEvent] + watermark: int = Field(ge=0) + + +SESSION_DURABLE_EVENT_TYPES = { + "execution.started", + "execution.stopped", + "execution.failed", + "execution.lost", + "message.completed", + "tool.completed", +} + + class SessionRecord(Lifecycle): record_id: UUID session_id: str project_id: UUID + sequence: Optional[int] = None + record_index: Optional[int] = None timestamp: Optional[datetime] = None record_type: Optional[str] = None @@ -86,3 +251,21 @@ class SessionMessagePreview(BaseModel): class SessionRecordQuery(BaseModel): session_id: str + + +class SessionRecordsReadState(BaseModel): + latest_sequence: int = Field(ge=0) + history_complete: bool + + +class SessionRecordsPage(BaseModel): + records: list[SessionRecord] + offset: int = Field(ge=0) + limit: int = Field(ge=1) + next_offset: Optional[int] = Field(default=None, ge=0) + through_sequence: int = Field(ge=0) + + +class SessionRecordsReplay(BaseModel): + records: list[SessionRecord] + watermark: int = Field(ge=0) diff --git a/api/oss/src/core/sessions/records/events.py b/api/oss/src/core/sessions/records/events.py new file mode 100644 index 00000000000..1a105e5d5bb --- /dev/null +++ b/api/oss/src/core/sessions/records/events.py @@ -0,0 +1,197 @@ +from typing import Any, Dict, List, Optional + +from pydantic import TypeAdapter, ValidationError + +from oss.src.core.sessions.records.dtos import ( + SESSION_DURABLE_EVENT_TYPES, + InteractionRequestedEvent, + InteractionRespondedEvent, + MessageCompletedEvent, + SessionDurableEvent, + SessionRecord, + ToolCompletedEvent, +) + + +_EVENT_ADAPTER = TypeAdapter(SessionDurableEvent) + + +def _event_base( + record: SessionRecord, + *, + entity_id: str, + include_legacy: bool, + watermark: int, +) -> Optional[Dict[str, Any]]: + created_at = record.timestamp or record.created_at + execution_id = record.turn_id or (record.attributes or {}).get("execution_id") + if ( + (record.sequence is None and not include_legacy) + or created_at is None + or not execution_id + ): + return None + return { + "version": 1, + "kind": "event", + "session_id": record.session_id, + "execution_id": str(execution_id), + "frame_or_event_id": str(record.record_id), + "entity_id": entity_id, + "sequence": record.sequence, + "watermark": watermark, + "created_at": created_at, + } + + +def _direct_event( + record: SessionRecord, *, include_legacy: bool, watermark: int +) -> Optional[SessionDurableEvent]: + if record.record_type not in SESSION_DURABLE_EVENT_TYPES: + return None + attributes = dict(record.attributes or {}) + payload = attributes.pop("payload", None) + # `attributes` is an open dict filled from the ingest wire, so `payload` can be any JSON + # value. A non-dict one must read as absent: `.get` on it raises outside the try below, + # and that failure poisons the whole batch on every redelivery. + if not isinstance(payload, dict): + attributes.pop("type", None) + attributes.pop("execution_id", None) + payload = attributes + entity_id = ( + payload.get("message_id") + or payload.get("tool_call_id") + or record.turn_id + or attributes.get("execution_id") + ) + base = _event_base( + record, + entity_id=str(entity_id or record.record_id), + include_legacy=include_legacy, + watermark=watermark, + ) + if base is None: + return None + try: + return _EVENT_ADAPTER.validate_python( + {**base, "type": record.record_type, "payload": payload} + ) + except ValidationError: + return None + + +def durable_events_from_records( + records: List[SessionRecord], + *, + include_legacy: bool = False, + watermark: Optional[int] = None, +) -> List[SessionDurableEvent]: + events: List[SessionDurableEvent] = [] + tool_calls: Dict[tuple[str, str], Dict[str, Any]] = {} + resolved_watermark = ( + watermark + if watermark is not None + else max((record.sequence or 0 for record in records), default=0) + ) + + for record in records: + # Some DAO decorators and legacy test doubles return commit sentinels rather than hydrated + # rows. They still count as committed appends, but cannot describe a durable relay event. + if not isinstance(record, SessionRecord): + continue + attributes = record.attributes or {} + direct = _direct_event( + record, + include_legacy=include_legacy, + watermark=resolved_watermark, + ) + if direct is not None: + events.append(direct) + continue + + entity_id = str( + attributes.get("message_id") + or attributes.get("tool_call_id") + or attributes.get("id") + or record.record_id + ) + base = _event_base( + record, + entity_id=entity_id, + include_legacy=include_legacy, + watermark=resolved_watermark, + ) + if base is None: + continue + + if record.record_type in {"interaction_request", "interaction_response"}: + payload = { + "interaction_id": entity_id, + "kind": attributes.get("kind"), + } + if record.record_type == "interaction_request": + events.append( + InteractionRequestedEvent( + **base, + type="interaction.requested", + payload=payload, + ) + ) + else: + events.append( + InteractionRespondedEvent( + **base, + type="interaction.responded", + payload=payload, + ) + ) + continue + + if record.record_type == "message": + role = ( + "assistant" if record.record_source == "agent" else record.record_source + ) + events.append( + MessageCompletedEvent( + **base, + type="message.completed", + payload={ + "message_id": entity_id, + "role": role or "assistant", + "content": attributes.get( + "content", attributes.get("text", "") + ), + "finish_reason": attributes.get("finish_reason"), + }, + ) + ) + continue + + tool_key = (str(record.turn_id), entity_id) + if record.record_type == "tool_call": + tool_calls[tool_key] = attributes + continue + if record.record_type != "tool_result": + continue + + call = tool_calls.get(tool_key, {}) + is_error = bool(attributes.get("isError")) + output = attributes.get("data", attributes.get("output")) + events.append( + ToolCompletedEvent( + **base, + type="tool.completed", + payload={ + "tool_call_id": entity_id, + "name": str( + call.get("name") or attributes.get("name") or "unknown" + ), + "input": call.get("input", attributes.get("input")), + "output": None if is_error else output, + "error": output if is_error else None, + "status": "error" if is_error else "completed", + }, + ) + ) + + return events diff --git a/api/oss/src/core/sessions/records/interfaces.py b/api/oss/src/core/sessions/records/interfaces.py index 0fe78b0e325..a6e516a4005 100644 --- a/api/oss/src/core/sessions/records/interfaces.py +++ b/api/oss/src/core/sessions/records/interfaces.py @@ -5,6 +5,9 @@ SessionMessagePreview, SessionRecord, SessionRecordEvent, + SessionRecordsPage, + SessionRecordsReplay, + SessionRecordsReadState, ) @@ -32,6 +35,34 @@ async def get_records( ) -> List[SessionRecord]: raise NotImplementedError + async def get_records_page( + self, + *, + project_id: UUID, + session_id: str, + offset: int, + limit: int, + through_sequence: int, + ) -> SessionRecordsPage: + raise NotImplementedError + + async def get_read_state( + self, + *, + project_id: UUID, + session_id: str, + ) -> SessionRecordsReadState: + raise NotImplementedError + + async def get_records_after( + self, + *, + project_id: UUID, + session_id: str, + after: int, + ) -> SessionRecordsReplay: + raise NotImplementedError + async def get_event( self, *, diff --git a/api/oss/src/core/sessions/records/service.py b/api/oss/src/core/sessions/records/service.py index a1ab2d52044..cdbac8957d9 100644 --- a/api/oss/src/core/sessions/records/service.py +++ b/api/oss/src/core/sessions/records/service.py @@ -6,12 +6,16 @@ RECORD_SETTLED_BY_ATTRIBUTE, SETTLED_BY_WATCHDOG, TERMINAL_RECORD_TYPE, + SessionDurableEventsReplay, SessionMessagePreview, SessionRecord, SessionRecordEvent, + SessionRecordsPage, + SessionRecordsReadState, ) from oss.src.core.sessions.executions.dtos import SessionExecutionSettlement from oss.src.core.sessions.executions.interfaces import SessionExecutionsDAOInterface +from oss.src.core.sessions.records.events import durable_events_from_records from oss.src.core.sessions.records.interfaces import RecordsDAOInterface from oss.src.utils.env import env from oss.src.utils.logging import get_module_logger @@ -300,6 +304,55 @@ async def get_event( record_id=record_id, ) + async def get_records_page( + self, + *, + project_id: UUID, + session_id: str, + offset: int, + limit: int, + through_sequence: int, + ) -> SessionRecordsPage: + return await self.records_dao.get_records_page( + project_id=project_id, + session_id=session_id, + offset=offset, + limit=limit, + through_sequence=through_sequence, + ) + + async def get_read_state( + self, + *, + project_id: UUID, + session_id: str, + ) -> SessionRecordsReadState: + return await self.records_dao.get_read_state( + project_id=project_id, + session_id=session_id, + ) + + async def get_events_after( + self, + *, + project_id: UUID, + session_id: str, + after: int, + ): + replay = await self.records_dao.get_records_after( + project_id=project_id, + session_id=session_id, + after=after, + ) + return SessionDurableEventsReplay( + events=durable_events_from_records( + replay.records, + include_legacy=after == 0, + watermark=replay.watermark, + ), + watermark=replay.watermark, + ) + async def latest_message_per_session( self, *, diff --git a/api/oss/src/core/sessions/records/streaming.py b/api/oss/src/core/sessions/records/streaming.py index 722d31c1296..bbb42c68918 100644 --- a/api/oss/src/core/sessions/records/streaming.py +++ b/api/oss/src/core/sessions/records/streaming.py @@ -1,5 +1,6 @@ import zlib -from typing import Optional +from datetime import datetime, timezone +from typing import Literal, Optional, Union from uuid import UUID from orjson import dumps, loads @@ -10,7 +11,12 @@ except ImportError: AsyncpgUUID = None -from oss.src.core.sessions.records.dtos import SessionRecordEvent +from oss.src.core.sessions.records.dtos import ( + MAX_LIVE_FRAME_BYTES, + SessionDurableEvent, + SessionLiveFrame, + SessionRecordEvent, +) from oss.src.dbs.redis.shared.engine import get_streams_engine from oss.src.utils.env import env from oss.src.utils.logging import get_module_logger @@ -24,6 +30,10 @@ _TRUNCATION_MARKER = "…[truncated]" +RECORD_STREAM_NAME = "streams:records" +LIVE_FRAME_STREAM_NAME = "streams:session-live-frames" +_FRAME_TRIM_INTERVAL = 64 + def _orjson_default(obj): if AsyncpgUUID is not None and isinstance(obj, AsyncpgUUID): @@ -82,6 +92,24 @@ def _get_redis(): return engine.get_redis() if engine else None +async def trim_live_stream(redis) -> None: + """Trim expired disposable frames independently of the durable record queue.""" + age_boundary = int( + ( + datetime.now(timezone.utc).timestamp() + - env.sessions.live_frame_max_age_seconds + ) + * 1000 + ) + # Approximate: the frames are disposable, so a few extra entries per listpack cost nothing + # and exact trimming makes every call O(N) in the evicted entries. + await redis.xtrim( + LIVE_FRAME_STREAM_NAME, + minid=f"{age_boundary}-0", + approximate=True, + ) + + class RecordMessage(BaseModel): """Wire format for the dedicated record Redis stream.""" @@ -91,10 +119,122 @@ class RecordMessage(BaseModel): record_event: SessionRecordEvent +class LiveFrameMessage(BaseModel): + organization_id: Optional[UUID] = None + project_id: UUID + kind: Literal["frame"] = "frame" + frame: SessionLiveFrame + + +class DurableEventMessage(BaseModel): + organization_id: Optional[UUID] = None + project_id: UUID + kind: Literal["event"] = "event" + event: SessionDurableEvent + + +LiveRelayMessage = Union[LiveFrameMessage, DurableEventMessage] + + +def deserialize_live_relay_message(*, payload: bytes) -> LiveRelayMessage: + raw = loads(zlib.decompress(payload)) + if raw.get("kind") == "frame": + return LiveFrameMessage.model_validate(raw) + if raw.get("kind") == "event": + return DurableEventMessage.model_validate(raw) + raise ValueError("message is not a live relay envelope") + + def deserialize_record(*, payload: bytes) -> RecordMessage: - payload = zlib.decompress(payload) - raw = loads(payload) - return RecordMessage.model_validate(raw) + return RecordMessage.model_validate(loads(zlib.decompress(payload))) + + +async def _append_live_relay_message(redis, *, message: dict) -> None: + await redis.xadd( + name=LIVE_FRAME_STREAM_NAME, + fields={"data": zlib.compress(dumps(message, default=_orjson_default))}, + maxlen=env.sessions.live_stream_maxlen, + # Approximate for the same reason as `trim_live_stream`: this runs on every relay write. + approximate=True, + ) + + +async def publish_live_frame( + *, + organization_id: Optional[UUID] = None, + project_id: UUID, + frame: SessionLiveFrame, +) -> bool: + redis = _get_redis() + if redis is None: + log.warning("[RECORDS] Durable Redis not configured; frame not published") + return False + + try: + frame_bytes = dumps(frame.model_dump(mode="json"), default=_orjson_default) + if len(frame_bytes) > MAX_LIVE_FRAME_BYTES: + log.warning( + "[RECORDS] Live frame exceeds size limit", + session_id=frame.session_id, + execution_id=frame.execution_id, + frame_index=frame.frame_index, + ) + return False + message = { + "organization_id": str(organization_id) if organization_id else None, + "project_id": str(project_id), + "kind": "frame", + "frame": frame.model_dump(mode="json"), + } + await _append_live_relay_message(redis, message=message) + if frame.frame_index % _FRAME_TRIM_INTERVAL == 0: + try: + await trim_live_stream(redis) + except Exception: + log.warning("[RECORDS] Live stream trim failed", exc_info=True) + return True + except Exception: + log.error( + "[RECORDS] Failed to publish frame", + session_id=frame.session_id, + execution_id=frame.execution_id, + frame_index=frame.frame_index, + exc_info=True, + ) + return False + + +async def publish_durable_event( + *, + organization_id: Optional[UUID] = None, + project_id: UUID, + event: SessionDurableEvent, +) -> bool: + redis = _get_redis() + if redis is None: + log.warning( + "[RECORDS] Durable Redis not configured; durable event not published" + ) + return False + + try: + message = { + "organization_id": str(organization_id) if organization_id else None, + "project_id": str(project_id), + "kind": "event", + "event": event.model_dump(mode="json"), + } + await _append_live_relay_message(redis, message=message) + return True + except Exception: + log.error( + "[RECORDS] Failed to publish durable event", + session_id=event.session_id, + execution_id=event.execution_id, + sequence=event.sequence, + exc_info=True, + ) + return False async def publish_record( @@ -146,7 +286,7 @@ async def publish_record( event_bytes = zlib.compress(event_bytes) await redis.xadd( - name="streams:records", + name=RECORD_STREAM_NAME, fields={"data": event_bytes}, maxlen=MAXLEN_STREAMS_RECORDS, approximate=True, diff --git a/api/oss/src/core/sessions/streams/dtos.py b/api/oss/src/core/sessions/streams/dtos.py index 0998cc93b52..1953728b317 100644 --- a/api/oss/src/core/sessions/streams/dtos.py +++ b/api/oss/src/core/sessions/streams/dtos.py @@ -34,10 +34,18 @@ class SessionStreamQueryFlags(BaseModel): is_attached: Optional[bool] = None +class SessionCapabilities(BaseModel): + shared_reader: bool = Field( + default=False, + description="Deployment-wide shared-reader switch; version one has no project allowlist.", + ) + + class SessionStream(Identifier, Header, Lifecycle): project_id: UUID session_id: str flags: SessionStreamFlags = SessionStreamFlags() + capabilities: SessionCapabilities = SessionCapabilities() tags: Optional[Dict[str, Any]] = None meta: Optional[Dict[str, Any]] = None turn_id: Optional[str] = None diff --git a/api/oss/src/dbs/postgres/sessions/records/dao.py b/api/oss/src/dbs/postgres/sessions/records/dao.py index c91ece59ec6..019deb5ceac 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dao.py +++ b/api/oss/src/dbs/postgres/sessions/records/dao.py @@ -1,7 +1,7 @@ from typing import Dict, List, Optional, Sequence, Set, Tuple from uuid import UUID -from sqlalchemy import func, select, tuple_ +from sqlalchemy import case, func, or_, select, tuple_, update from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession @@ -12,14 +12,21 @@ SessionMessagePreview, SessionRecord, SessionRecordEvent, + SessionRecordsPage, + SessionRecordsReplay, + SessionRecordsReadState, ) from oss.src.core.sessions.records.interfaces import RecordsDAOInterface -from oss.src.dbs.postgres.sessions.records.dbes import RecordDBE +from oss.src.dbs.postgres.sessions.records.dbes import ( + RecordDBE, + SessionSequenceCursorDBE, +) from oss.src.dbs.postgres.sessions.records.mappings import ( map_record_event_to_dbe, map_record_dbe_to_dto, ) from oss.src.dbs.postgres.shared.engine import AnalyticsEngine, get_analytics_engine +from oss.src.utils.env import env class RecordsDAO(RecordsDAOInterface): @@ -48,7 +55,11 @@ async def _append( event: SessionRecordEvent, session: AsyncSession, ) -> Optional[SessionRecord]: - stmt = RecordsDAO._upsert_stmt(values_list=[RecordsDAO._values(event=event)]) + values = RecordsDAO._values(event=event) + if env.sessions.sequence_writes: + return await RecordsDAO._append_sequenced(values=values, session=session) + + stmt = RecordsDAO._upsert_stmt(values_list=[values]) result = await session.execute(stmt) await session.flush() @@ -57,6 +68,53 @@ async def _append( return None return map_record_dbe_to_dto(dbe=row) + @staticmethod + async def _append_sequenced( + *, + values: dict, + session: AsyncSession, + ) -> Optional[SessionRecord]: + insert_stmt = ( + insert(RecordDBE) + .values(values) + .on_conflict_do_nothing(index_elements=["project_id", "record_id"]) + .returning(RecordDBE.record_id) + ) + inserted_id = (await session.execute(insert_stmt)).scalar_one_or_none() + if inserted_id is None: + result = await session.execute( + RecordsDAO._upsert_stmt(values_list=[values]) + ) + await session.flush() + row = result.scalars().first() + return map_record_dbe_to_dto(dbe=row) if row is not None else None + + cursor_insert = insert(SessionSequenceCursorDBE).values( + project_id=values["project_id"], + session_id=values["session_id"], + latest_sequence=1, + ) + cursor_stmt = cursor_insert.on_conflict_do_update( + index_elements=["project_id", "session_id"], + set_={ + "latest_sequence": SessionSequenceCursorDBE.latest_sequence + 1, + "updated_at": func.now(), + }, + ).returning(SessionSequenceCursorDBE.latest_sequence) + sequence = (await session.execute(cursor_stmt)).scalar_one() + record_stmt = ( + update(RecordDBE) + .where( + RecordDBE.project_id == values["project_id"], + RecordDBE.record_id == values["record_id"], + ) + .values(sequence=sequence) + .returning(RecordDBE) + ) + row = (await session.execute(record_stmt)).scalars().one() + await session.flush() + return map_record_dbe_to_dto(dbe=row) + async def append_many( self, *, @@ -72,6 +130,25 @@ async def append_many( ) async with self.engine.session() as session: + if env.sessions.sequence_writes: + records = [] + # Stable session order prevents mixed-session transactions from deadlocking. + ordered_values = sorted( + values_list, + key=lambda values: ( + str(values["project_id"]), + values["session_id"], + ), + ) + for values in ordered_values: + record = await self._append_sequenced( + values=values, session=session + ) + if record is not None: + records.append(record) + await session.commit() + return records + stmt = self._upsert_stmt(values_list=values_list) result = await session.execute(stmt) await session.commit() @@ -180,6 +257,156 @@ async def get_records( dbes = (await session.execute(stmt)).scalars().all() return [map_record_dbe_to_dto(dbe=dbe) for dbe in dbes] + @staticmethod + def _transcript_order(): + return ( + case((RecordDBE.sequence.is_(None), 0), else_=1), + case((RecordDBE.sequence.is_(None), RecordDBE.timestamp), else_=None) + .asc() + .nullslast(), + case((RecordDBE.sequence.is_(None), RecordDBE.created_at), else_=None) + .asc() + .nullslast(), + case((RecordDBE.sequence.is_(None), RecordDBE.record_index), else_=None) + .asc() + .nullslast(), + RecordDBE.sequence.asc().nullslast(), + ) + + async def get_records_page( + self, + *, + project_id: UUID, + session_id: str, + offset: int, + limit: int, + through_sequence: int, + ) -> SessionRecordsPage: + async with self.engine.session() as session: + stmt = ( + select(RecordDBE) + .where( + RecordDBE.project_id == project_id, + RecordDBE.session_id == session_id, + RecordDBE.deleted_at.is_(None), + RecordDBE.quarantined_at.is_(None), + or_( + RecordDBE.sequence.is_(None), + RecordDBE.sequence <= through_sequence, + ), + ) + .order_by(*self._transcript_order()) + .offset(offset) + .limit(limit + 1) + ) + rows = list((await session.execute(stmt)).scalars().all()) + + has_more = len(rows) > limit + records = [map_record_dbe_to_dto(dbe=row) for row in rows[:limit]] + return SessionRecordsPage( + records=records, + offset=offset, + limit=limit, + next_offset=offset + limit if has_more else None, + through_sequence=through_sequence, + ) + + async def get_read_state( + self, + *, + project_id: UUID, + session_id: str, + ) -> SessionRecordsReadState: + async with self.engine.session() as session: + latest_sequence = await session.scalar( + select(SessionSequenceCursorDBE.latest_sequence).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + record_count, first_sequenced_at = ( + await session.execute( + select( + func.count(RecordDBE.record_id), + func.min(RecordDBE.created_at).filter( + RecordDBE.sequence.is_not(None) + ), + ).where( + RecordDBE.project_id == project_id, + RecordDBE.session_id == session_id, + RecordDBE.deleted_at.is_(None), + ) + ) + ).one() + null_after_cutover = False + if first_sequenced_at is not None: + null_after_cutover = bool( + await session.scalar( + select(func.count(RecordDBE.record_id)).where( + RecordDBE.project_id == project_id, + RecordDBE.session_id == session_id, + RecordDBE.deleted_at.is_(None), + RecordDBE.sequence.is_(None), + RecordDBE.created_at >= first_sequenced_at, + ) + ) + ) + + history_complete = record_count == 0 or ( + latest_sequence is not None and not null_after_cutover + ) + return SessionRecordsReadState( + latest_sequence=latest_sequence or 0, + history_complete=history_complete, + ) + + async def get_records_after( + self, + *, + project_id: UUID, + session_id: str, + after: int, + ) -> SessionRecordsReplay: + async with self.engine.session() as session: + watermark = ( + await session.scalar( + select(SessionSequenceCursorDBE.latest_sequence).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + or 0 + ) + sequence_filter = ( + or_( + RecordDBE.sequence.is_(None), + RecordDBE.sequence.between(1, watermark), + ) + if after == 0 + else RecordDBE.sequence.between(after + 1, watermark) + ) + rows = list( + ( + await session.execute( + select(RecordDBE) + .where( + RecordDBE.project_id == project_id, + RecordDBE.session_id == session_id, + RecordDBE.deleted_at.is_(None), + RecordDBE.quarantined_at.is_(None), + sequence_filter, + ) + .order_by(*self._transcript_order()) + ) + ) + .scalars() + .all() + ) + return SessionRecordsReplay( + records=[map_record_dbe_to_dto(dbe=row) for row in rows], + watermark=watermark, + ) + async def latest_message_per_session( self, *, diff --git a/api/oss/src/dbs/postgres/sessions/records/dbas.py b/api/oss/src/dbs/postgres/sessions/records/dbas.py index a80167e60d4..cd60fcc5aa6 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dbas.py +++ b/api/oss/src/dbs/postgres/sessions/records/dbas.py @@ -1,6 +1,6 @@ import uuid_utils.compat as uuid -from sqlalchemy import Column, UUID, TIMESTAMP, String, Integer +from sqlalchemy import BigInteger, Column, UUID, TIMESTAMP, String, Integer from sqlalchemy.dialects.postgresql import JSONB @@ -37,6 +37,11 @@ class RecordDBA: nullable=False, ) + sequence = Column( + BigInteger, + nullable=True, + ) + # Producer-stamped per-turn ordinal and the in-session ordering key (record_id is # no longer time-ordered). Restarts at 0 each cold turn, so reads tiebreak with # created_at (ingest time) ahead of it — see get_records. diff --git a/api/oss/src/dbs/postgres/sessions/records/dbes.py b/api/oss/src/dbs/postgres/sessions/records/dbes.py index 78de94b5df3..2064b4705f3 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dbes.py +++ b/api/oss/src/dbs/postgres/sessions/records/dbes.py @@ -1,8 +1,22 @@ -from sqlalchemy import PrimaryKeyConstraint, Index +from sqlalchemy import ( + BigInteger, + Column, + Index, + PrimaryKeyConstraint, + String, +) from oss.src.dbs.postgres.shared.base import Base from oss.src.dbs.postgres.sessions.records.dbas import RecordDBA, RecordTurnSpanDBA -from oss.src.dbs.postgres.shared.dbas import ProjectScopeDBA, LifecycleDBA +from oss.src.dbs.postgres.shared.dbas import LifecycleDBA, ProjectScopeDBA + + +class SessionSequenceCursorDBE(Base, ProjectScopeDBA, LifecycleDBA): + __tablename__ = "session_sequence_cursors" + __table_args__ = (PrimaryKeyConstraint("project_id", "session_id"),) + + session_id = Column(String, nullable=False) + latest_sequence = Column(BigInteger, nullable=False) class RecordDBE( @@ -33,4 +47,11 @@ class RecordDBE( "session_id", "turn_id", ), + Index( + "ux_records_session_id_sequence", + "project_id", + "session_id", + "sequence", + unique=True, + ), ) diff --git a/api/oss/src/dbs/postgres/sessions/records/mappings.py b/api/oss/src/dbs/postgres/sessions/records/mappings.py index dd758b5110f..a1e4eac5d47 100644 --- a/api/oss/src/dbs/postgres/sessions/records/mappings.py +++ b/api/oss/src/dbs/postgres/sessions/records/mappings.py @@ -18,6 +18,7 @@ def map_record_event_to_dbe( project_id=event.project_id, session_id=event.session_id, record_id=event.record_id or uuid.uuid4(), + sequence=None, record_index=event.record_index, timestamp=event.timestamp, record_type=event.record_type, @@ -34,6 +35,7 @@ def map_record_dbe_to_dto(*, dbe: RecordDBE) -> SessionRecord: record_id=dbe.record_id, session_id=dbe.session_id, project_id=dbe.project_id, + sequence=dbe.sequence, record_index=dbe.record_index, timestamp=dbe.timestamp, record_type=dbe.record_type, diff --git a/api/oss/src/dbs/redis/sessions/contract.py b/api/oss/src/dbs/redis/sessions/contract.py index b62c8d05e69..ce308a292ca 100644 --- a/api/oss/src/dbs/redis/sessions/contract.py +++ b/api/oss/src/dbs/redis/sessions/contract.py @@ -157,6 +157,10 @@ def project_watch_channel(project_id: str) -> str: return f"watch:{project_id}:project" +def live_events_channel(project_id: str, session_id: str) -> str: + return f"events:{project_id}:session:{session_id}" + + def make_watch_records_changed_payload(*, session_id: str) -> dict: return {"type": WATCH_EVENT_RECORDS_CHANGED, "session_id": session_id} diff --git a/api/oss/src/tasks/asyncio/sessions/live_relay_worker.py b/api/oss/src/tasks/asyncio/sessions/live_relay_worker.py new file mode 100644 index 00000000000..ba9b55f1f7c --- /dev/null +++ b/api/oss/src/tasks/asyncio/sessions/live_relay_worker.py @@ -0,0 +1,70 @@ +from datetime import datetime, timezone +from typing import Dict, List, Tuple + +from orjson import dumps + +from oss.src.core.sessions.records.streaming import ( + LiveFrameMessage, + deserialize_live_relay_message, +) +from oss.src.dbs.redis.sessions.contract import live_events_channel +from oss.src.tasks.asyncio.shared.consumer import StreamConsumer +from oss.src.utils.env import env +from oss.src.utils.logging import get_module_logger + +log = get_module_logger(__name__) + + +class LiveRelayWorker(StreamConsumer): + log_prefix = "[SESSION-LIVE-RELAY]" + + async def create_consumer_group(self): + try: + await self.redis.xgroup_create( + name=self.stream_name, + groupname=self.consumer_group, + id="$", + mkstream=True, + ) + except Exception as exc: + if "BUSYGROUP" not in str(exc): + raise + + async def process_batch( + self, + batch: List[Tuple[bytes, Dict[bytes, bytes]]], + ) -> Tuple[int, List[bytes]]: + processed_ids: List[bytes] = [] + published = 0 + cutoff = ( + datetime.now(timezone.utc).timestamp() + - env.sessions.live_frame_max_age_seconds + ) + + for msg_id, data in batch: + processed_ids.append(msg_id) + try: + message = deserialize_live_relay_message(payload=data[b"data"]) + envelope = ( + message.frame + if isinstance(message, LiveFrameMessage) + else message.event + ) + created_at = envelope.created_at + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + if created_at.timestamp() < cutoff: + continue + await self.redis.publish( + live_events_channel(str(message.project_id), envelope.session_id), + dumps(envelope.model_dump(mode="json")), + ) + published += 1 + except Exception: + log.warning( + "[SESSION-LIVE-RELAY] Frame relay failed", + msg_id=repr(msg_id), + exc_info=True, + ) + + return published, processed_ids diff --git a/api/oss/src/tasks/asyncio/sessions/records_worker.py b/api/oss/src/tasks/asyncio/sessions/records_worker.py index 042a2a659f6..a6c2a36c195 100644 --- a/api/oss/src/tasks/asyncio/sessions/records_worker.py +++ b/api/oss/src/tasks/asyncio/sessions/records_worker.py @@ -5,9 +5,13 @@ from sqlalchemy.exc import DataError, IntegrityError from oss.src.core.sessions.interactions.service import SessionInteractionsService -from oss.src.core.sessions.records.dtos import TERMINAL_RECORD_TYPE +from oss.src.core.sessions.records.dtos import SessionRecord, TERMINAL_RECORD_TYPE from oss.src.core.sessions.records.service import RecordsService -from oss.src.core.sessions.records.streaming import deserialize_record +from oss.src.core.sessions.records.events import durable_events_from_records +from oss.src.core.sessions.records.streaming import ( + deserialize_record, + publish_durable_event, +) from oss.src.core.sessions.watch.interfaces import SessionsWatchPublisherInterface from oss.src.utils.common import is_ee from oss.src.utils.logging import get_module_logger @@ -182,8 +186,8 @@ async def _append( *, project_id: UUID, entries: List[Tuple[bytes, Any]], - ) -> Tuple[int, Optional[Exception]]: - """One `append_many` call. Returns rows written and any failure.""" + ) -> Tuple[List[SessionRecord], Optional[Exception]]: + """One `append_many` call. Returns committed rows and any failure.""" try: results = await self.service.append_many( events=[msg.record_event for _, msg in entries], @@ -203,7 +207,7 @@ async def _append( {f"{row.session_id}:{row.turn_id}" for row in quarantined} ), ) - return len(results), None + return results, None except Exception as exc: log.error( "[RECORDS] Failed to append event batch", @@ -211,34 +215,34 @@ async def _append( size=len(entries), exc_info=True, ) - return 0, exc + return [], exc async def _append_committed( self, *, project_id: UUID, entries: List[Tuple[bytes, Any]], - ) -> Tuple[int, List[bytes]]: - """Write a project group and report the message ids that are durable. + ) -> Tuple[List[SessionRecord], List[bytes]]: + """Write a project group and report the rows and message ids that are durable. `append_many` is one statement in one transaction, so a row-specific database rejection takes the whole group down with it. Only that failure class triggers one-record writes to isolate the rejected row. Connection, timeout, and unknown failures leave the entire group pending for Redis reclaim instead of multiplying calls during an outage. """ - appended, failure = await self._append(project_id=project_id, entries=entries) + results, failure = await self._append(project_id=project_id, entries=entries) if failure is None: self._permanent_failure_ids.difference_update( msg_id for msg_id, _ in entries ) - return appended, [msg_id for msg_id, _ in entries] + return results, [msg_id for msg_id, _ in entries] if not isinstance(failure, ROW_REJECTION_ERRORS): - return 0, [] + return [], [] if len(entries) == 1: self._permanent_failure_ids.add(entries[0][0]) - return 0, [] + return [], [] log.warning( "[RECORDS] Batch append failed, retrying one record at a time", @@ -246,14 +250,14 @@ async def _append_committed( size=len(entries), ) - total_appended = 0 + committed_records: List[SessionRecord] = [] committed_ids: List[bytes] = [] for entry in entries: - appended, failure = await self._append( + results, failure = await self._append( project_id=project_id, entries=[entry] ) if failure is None: - total_appended += appended + committed_records.extend(results) committed_ids.append(entry[0]) self._permanent_failure_ids.discard(entry[0]) elif isinstance(failure, ROW_REJECTION_ERRORS): @@ -265,7 +269,7 @@ async def _append_committed( committed=len(committed_ids), pending=len(entries) - len(committed_ids), ) - return total_appended, committed_ids + return committed_records, committed_ids async def process_batch( self, @@ -376,11 +380,11 @@ async def process_batch( acked_ids.extend(msg_id for msg_id, _ in entries) continue - appended, committed_ids = await self._append_committed( + results, committed_ids = await self._append_committed( project_id=project_batch["project_id"], entries=entries, ) - total_appended += appended + total_appended += len(results) acked_ids.extend(committed_ids) if not committed_ids: @@ -388,6 +392,30 @@ async def process_batch( committed = set(committed_ids) committed_events = [msg for msg_id, msg in entries if msg_id in committed] + results_by_session: Dict[str, List[SessionRecord]] = {} + for result in results: + if isinstance(result, SessionRecord): + results_by_session.setdefault(result.session_id, []).append(result) + + for session_results in results_by_session.values(): + # Live events carry this batch's committed maximum; replay/ready uses the + # authoritative session cursor and may therefore be higher. + watermark = max( + (result.sequence or 0 for result in session_results), default=0 + ) + visible_results = [ + result + for result in session_results + if result.quarantined_at is None + ] + for event in durable_events_from_records( + visible_results, watermark=watermark + ): + await publish_durable_event( + organization_id=project_batch["organization_id"], + project_id=project_batch["project_id"], + event=event, + ) # Strictly post-append, and BEFORE the relay tee: a client woken by the records # notification below must already see the cancelled gate, not re-render it. diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 88af636cbad..7006b9ca525 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -1553,8 +1553,13 @@ class SessionsRedisConfig(BaseModel): Defaults mirror the golden fixture (services/runner/tests/fixtures/sessions/ redis_contract.json) shared with the TypeScript runner. Do not change a default without updating that fixture and the TS side in lockstep. + + AGENTA_SESSIONS_SEQUENCE_WRITES independently gates atomic record sequencing. """ + sequence_writes: bool = ( + os.getenv("AGENTA_SESSIONS_SEQUENCE_WRITES") or "false" + ).lower() in _TRUTHY alive_ttl_seconds: int = ( _parse_optional_positive_int_env("AGENTA_SESSIONS_REDIS_ALIVE_TTL_SECONDS") or 3600 @@ -1587,6 +1592,25 @@ class SessionsRedisConfig(BaseModel): _parse_optional_positive_int_env("AGENTA_SESSIONS_REDIS_CONCURRENCY_LIMIT") or 1000 ) + live_stream_maxlen: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_LIVE_STREAM_MAXLEN") + or 100_000 + ) + live_frame_max_age_seconds: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS") + or 900 + ) + shared_reader: bool = ( + os.getenv("AGENTA_SESSIONS_SHARED_READER") or "false" + ).lower() in _TRUTHY + live_auth_recheck_seconds: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS") + or 60 + ) + live_reader_buffer_limit: int = ( + _parse_optional_positive_int_env("AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT") + or 256 + ) # API-side only (SSE watch endpoint keep-alive cadence) — NOT part of the # runner golden fixture; safe to tune without touching the TS side. watch_heartbeat_seconds: int = ( diff --git a/api/oss/tests/pytest/integration/sessions/test_records_replay_postgres.py b/api/oss/tests/pytest/integration/sessions/test_records_replay_postgres.py new file mode 100644 index 00000000000..f06adb89959 --- /dev/null +++ b/api/oss/tests/pytest/integration/sessions/test_records_replay_postgres.py @@ -0,0 +1,223 @@ +import uuid +from datetime import datetime, timezone + +import pytest +import pytest_asyncio +from sqlalchemy import delete + +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.core.sessions.records.dtos import SessionRecordEvent +from oss.src.core.sessions.records.service import RecordsService +from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO +from oss.src.dbs.postgres.sessions.records.dbes import ( + RecordDBE, + SessionSequenceCursorDBE, +) +from oss.src.dbs.postgres.shared.engine import get_analytics_engine +from oss.src.utils.env import env + + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +@pytest_asyncio.fixture(autouse=True) +async def _fresh_analytics_engine(monkeypatch): + monkeypatch.setattr(env.sessions, "sequence_writes", True) + engine_module._analytics_engine = None + yield + if engine_module._analytics_engine is not None: + await engine_module._analytics_engine.close() + engine_module._analytics_engine = None + + +def _event( + project_id, + session_id, + text="", + *, + record_type="message", + attributes=None, +): + return SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=uuid.uuid4(), + turn_id="execution-1", + record_type=record_type, + record_source="agent", + attributes=attributes or {"type": "message", "text": text}, + ) + + +async def test_snapshot_n_followed_by_events_after_n_loses_no_commit(): + project_id = uuid.uuid4() + session_id = f"replay-{uuid.uuid4()}" + dao = RecordsDAO(engine=get_analytics_engine()) + service = RecordsService(records_dao=dao) + + try: + await dao.append(event=_event(project_id, session_id, "one")) + snapshot = await dao.get_read_state( + project_id=project_id, session_id=session_id + ) + await dao.append_many( + events=[ + _event( + project_id, session_id, record_type="done", attributes={"ok": True} + ), + _event(project_id, session_id, "three"), + _event( + project_id, + session_id, + record_type="usage", + attributes={"tokens": 1}, + ), + _event( + project_id, + session_id, + record_type="tool_call", + attributes={"id": "tool-1", "name": "read", "input": {}}, + ), + _event( + project_id, + session_id, + record_type="tool_result", + attributes={"id": "tool-1", "output": "ok"}, + ), + _event( + project_id, + session_id, + record_type="execution.stopped", + attributes={ + "stopped_at": datetime.now(timezone.utc).isoformat(), + "reason": "completed", + }, + ), + _event( + project_id, + session_id, + record_type="thought", + attributes={"text": "x"}, + ), + _event(project_id, session_id, "nine"), + ] + ) + + replay = await service.get_events_after( + project_id=project_id, + session_id=session_id, + after=snapshot.latest_sequence, + ) + + assert snapshot.latest_sequence == 1 + assert [event.sequence for event in replay.events] == [3, 6, 7, 9] + assert replay.events[0].payload.content == "three" + assert replay.events[-1].payload.content == "nine" + assert replay.events[-1].watermark == 9 + assert replay.watermark == 9 + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + + +async def test_quarantined_records_are_absent_from_snapshot_pages_and_replay(): + project_id = uuid.uuid4() + session_id = f"quarantine-{uuid.uuid4()}" + dao = RecordsDAO(engine=get_analytics_engine()) + service = RecordsService(records_dao=dao) + + try: + await dao.append(event=_event(project_id, session_id, "visible-before")) + await dao.append( + event=_event(project_id, session_id, "refused-tail").model_copy( + update={"quarantined_at": datetime.now(timezone.utc)} + ) + ) + await dao.append(event=_event(project_id, session_id, "visible-after")) + + read = await dao.get_read_state(project_id=project_id, session_id=session_id) + page = await dao.get_records_page( + project_id=project_id, + session_id=session_id, + offset=0, + limit=10, + through_sequence=read.latest_sequence, + ) + replay = await service.get_events_after( + project_id=project_id, + session_id=session_id, + after=0, + ) + + assert read.latest_sequence == 3 + assert [record.sequence for record in page.records] == [1, 3] + assert [event.sequence for event in replay.events] == [1, 3] + assert [event.payload.content for event in replay.events] == [ + "visible-before", + "visible-after", + ] + assert replay.watermark == 3 + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + + +async def test_legacy_session_replays_ordered_history_and_is_incomplete(): + project_id = uuid.uuid4() + session_id = f"legacy-{uuid.uuid4()}" + dao = RecordsDAO(engine=get_analytics_engine()) + service = RecordsService(records_dao=dao) + now = datetime.now(timezone.utc) + + try: + async with get_analytics_engine().session() as session: + session.add_all( + [ + RecordDBE( + project_id=project_id, + record_id=uuid.uuid4(), + session_id=session_id, + turn_id="execution-legacy", + record_index=index, + timestamp=now, + record_type="message", + record_source="agent", + attributes={"type": "message", "text": text}, + ) + for index, text in enumerate(("first", "second")) + ] + ) + + read = await dao.get_read_state(project_id=project_id, session_id=session_id) + replay = await service.get_events_after( + project_id=project_id, + session_id=session_id, + after=0, + ) + + assert read.latest_sequence == 0 + assert read.history_complete is False + assert replay.watermark == 0 + assert [event.sequence for event in replay.events] == [None, None] + assert [event.payload.content for event in replay.events] == ["first", "second"] + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) diff --git a/api/oss/tests/pytest/integration/sessions/test_records_sequence_postgres.py b/api/oss/tests/pytest/integration/sessions/test_records_sequence_postgres.py new file mode 100644 index 00000000000..69719a3f479 --- /dev/null +++ b/api/oss/tests/pytest/integration/sessions/test_records_sequence_postgres.py @@ -0,0 +1,213 @@ +import asyncio +import uuid + +import pytest +from sqlalchemy import delete, select + +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.core.sessions.records.dtos import SessionRecordEvent +from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO +from oss.src.dbs.postgres.sessions.records.dbes import ( + RecordDBE, + SessionSequenceCursorDBE, +) +from oss.src.dbs.postgres.shared.engine import get_analytics_engine +from oss.src.utils.env import env + + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +@pytest.fixture(autouse=True) +async def _fresh_analytics_engine(monkeypatch): + monkeypatch.setattr(env.sessions, "sequence_writes", True) + # Close whatever an earlier module left behind; teardown only knows this fixture's engine. + previous = engine_module._analytics_engine + if previous is not None: + await previous.close() + engine_module._analytics_engine = None + yield + if engine_module._analytics_engine is not None: + await engine_module._analytics_engine.close() + engine_module._analytics_engine = None + + +async def test_concurrent_inserts_allocate_distinct_sequences_and_retry_keeps_cursor(): + project_id = uuid.uuid4() + session_id = f"sequence-{uuid.uuid4()}" + record_ids = [uuid.uuid4(), uuid.uuid4()] + dao = RecordsDAO(engine=get_analytics_engine()) + + def event(record_id: uuid.UUID, text: str) -> SessionRecordEvent: + return SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=record_id, + record_type="message", + record_source="agent", + attributes={"type": "message", "text": text}, + ) + + try: + first, second = await asyncio.gather( + dao.append(event=event(record_ids[0], "first")), + dao.append(event=event(record_ids[1], "second")), + ) + assert sorted([first.sequence, second.sequence]) == [1, 2] + + retried = await dao.append(event=event(record_ids[0], "first")) + assert retried.sequence == first.sequence + + async with get_analytics_engine().session() as session: + cursor = await session.scalar( + select(SessionSequenceCursorDBE.latest_sequence).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + sequences = list( + ( + await session.scalars( + select(RecordDBE.sequence) + .where(RecordDBE.session_id == session_id) + .order_by(RecordDBE.sequence) + ) + ).all() + ) + assert cursor == 2 + assert sequences == [1, 2] + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + + +async def test_sequence_allocation_is_scoped_by_project(): + project_ids = [uuid.uuid4(), uuid.uuid4()] + session_id = f"shared-session-{uuid.uuid4()}" + dao = RecordsDAO(engine=get_analytics_engine()) + + try: + records = [] + for project_id in project_ids: + records.append( + await dao.append( + event=SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=uuid.uuid4(), + record_type="message", + record_source="agent", + attributes={"type": "message", "text": "hello"}, + ) + ) + ) + + assert [record.sequence for record in records] == [1, 1] + + async with get_analytics_engine().session() as session: + cursors = list( + ( + await session.scalars( + select(SessionSequenceCursorDBE.latest_sequence) + .where( + SessionSequenceCursorDBE.project_id.in_(project_ids), + SessionSequenceCursorDBE.session_id == session_id, + ) + .order_by(SessionSequenceCursorDBE.project_id) + ) + ).all() + ) + + assert cursors == [1, 1] + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id.in_(project_ids)) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id.in_(project_ids), + SessionSequenceCursorDBE.session_id == session_id, + ) + ) + + +async def test_reverse_order_batches_lock_sessions_without_deadlock(monkeypatch): + project_id = uuid.uuid4() + session_ids = [f"lock-a-{uuid.uuid4()}", f"lock-b-{uuid.uuid4()}"] + dao = RecordsDAO(engine=get_analytics_engine()) + original_append = RecordsDAO._append_sequenced + append_counts = {} + + async def append_with_first_lock_pause(*, values, session): + record = await original_append(values=values, session=session) + task = asyncio.current_task() + append_counts[task] = append_counts.get(task, 0) + 1 + if append_counts[task] == 1: + await asyncio.sleep(0.1) + return record + + monkeypatch.setattr( + RecordsDAO, + "_append_sequenced", + staticmethod(append_with_first_lock_pause), + ) + + def event(session_id: str, record_index: int) -> SessionRecordEvent: + return SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=uuid.uuid4(), + record_index=record_index, + record_type="message", + record_source="agent", + attributes={"type": "message", "text": session_id}, + ) + + first_batch = [event(session_ids[0], 0), event(session_ids[1], 0)] + second_batch = [event(session_ids[1], 1), event(session_ids[0], 1)] + + try: + first, second = await asyncio.wait_for( + asyncio.gather( + dao.append_many(events=first_batch), + dao.append_many(events=second_batch), + ), + timeout=5, + ) + + assert len(first) == 2 + assert len(second) == 2 + async with get_analytics_engine().session() as session: + rows = ( + await session.execute( + select(RecordDBE.session_id, RecordDBE.sequence) + .where(RecordDBE.project_id == project_id) + .order_by(RecordDBE.session_id, RecordDBE.sequence) + ) + ).all() + assert rows == [ + (session_ids[0], 1), + (session_ids[0], 2), + (session_ids[1], 1), + (session_ids[1], 2), + ] + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id.in_(session_ids), + ) + ) diff --git a/api/oss/tests/pytest/integration/sessions/test_records_snapshot_postgres.py b/api/oss/tests/pytest/integration/sessions/test_records_snapshot_postgres.py new file mode 100644 index 00000000000..71b9a383021 --- /dev/null +++ b/api/oss/tests/pytest/integration/sessions/test_records_snapshot_postgres.py @@ -0,0 +1,81 @@ +import uuid + +import pytest +import pytest_asyncio +from sqlalchemy import delete + +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.core.sessions.records.dtos import SessionRecordEvent +from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO +from oss.src.dbs.postgres.sessions.records.dbes import ( + RecordDBE, + SessionSequenceCursorDBE, +) +from oss.src.dbs.postgres.shared.engine import get_analytics_engine +from oss.src.utils.env import env + + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +@pytest_asyncio.fixture(autouse=True) +async def _fresh_analytics_engine(monkeypatch): + monkeypatch.setattr(env.sessions, "sequence_writes", True) + engine_module._analytics_engine = None + yield + if engine_module._analytics_engine is not None: + await engine_module._analytics_engine.close() + engine_module._analytics_engine = None + + +async def test_snapshot_watermark_pages_transcript_without_admitting_later_rows(): + project_id = uuid.uuid4() + session_id = f"snapshot-{uuid.uuid4()}" + dao = RecordsDAO(engine=get_analytics_engine()) + + def event(text: str) -> SessionRecordEvent: + return SessionRecordEvent( + project_id=project_id, + session_id=session_id, + record_id=uuid.uuid4(), + record_type="message", + record_source="agent", + attributes={"type": "message", "text": text}, + ) + + try: + await dao.append_many(events=[event("one"), event("two")]) + read = await dao.get_read_state(project_id=project_id, session_id=session_id) + assert read.latest_sequence == 2 + assert read.history_complete is True + + first = await dao.get_records_page( + project_id=project_id, + session_id=session_id, + offset=0, + limit=1, + through_sequence=read.latest_sequence, + ) + await dao.append(event=event("later")) + second = await dao.get_records_page( + project_id=project_id, + session_id=session_id, + offset=first.next_offset, + limit=1, + through_sequence=read.latest_sequence, + ) + + assert [record.sequence for record in first.records] == [1] + assert [record.sequence for record in second.records] == [2] + assert second.next_offset is None + finally: + async with get_analytics_engine().session() as session: + await session.execute( + delete(RecordDBE).where(RecordDBE.project_id == project_id) + ) + await session.execute( + delete(SessionSequenceCursorDBE).where( + SessionSequenceCursorDBE.project_id == project_id, + SessionSequenceCursorDBE.session_id == session_id, + ) + ) diff --git a/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_chain.py b/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_chain.py new file mode 100644 index 00000000000..99b47f0d0d7 --- /dev/null +++ b/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_chain.py @@ -0,0 +1,31 @@ +import ast +from pathlib import Path + + +VERSIONS_DIR = ( + Path(__file__).resolve().parents[4] + / "databases/postgres/migrations/tracing_oss/versions" +) + + +def _revision_link(path: Path) -> tuple[str, str]: + assignments = {} + for node in ast.parse(path.read_text()).body: + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + if node.target.id in {"revision", "down_revision"}: + assignments[node.target.id] = ast.literal_eval(node.value) + return assignments["revision"], assignments["down_revision"] + + +def test_tracing_chain_has_one_head_with_watchdog_migration(): + watchdog_migration = VERSIONS_DIR / "oss000000005_add_records_quarantined_at.py" + session_migration = VERSIONS_DIR / "oss000000006_add_session_sequence_cursors.py" + links = dict(map(_revision_link, (watchdog_migration, session_migration))) + + heads = set(links) - set(links.values()) + + assert links == { + "oss000000005": "oss000000004", + "oss000000006": "oss000000005", + } + assert heads == {"oss000000006"} diff --git a/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_migration.py b/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_migration.py new file mode 100644 index 00000000000..4788f4482c0 --- /dev/null +++ b/api/oss/tests/pytest/unit/migrations/test_tracing_session_sequence_migration.py @@ -0,0 +1,191 @@ +import asyncio +from pathlib import Path + +from alembic import command +from alembic.config import Config +import asyncpg +import pytest + +from oss.src.dbs.postgres.shared import config as postgres_config + + +DATABASE_NAME = "agenta_m2idx_tracing" +ADMIN_DSN = "postgresql://username:password@localhost:5444/postgres" +DATABASE_DSN = f"postgresql://username:password@localhost:5444/{DATABASE_NAME}" +SQLALCHEMY_URL = ( + f"postgresql+asyncpg://username:password@localhost:5444/{DATABASE_NAME}" +) +VERSIONS_ROOT = ( + Path(__file__).resolve().parents[4] / "databases/postgres/migrations/tracing_oss" +) + + +async def _recreate_database() -> None: + admin = await asyncpg.connect(ADMIN_DSN) + try: + await admin.execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname=$1 AND pid<>pg_backend_pid()", + DATABASE_NAME, + ) + await admin.execute(f'DROP DATABASE IF EXISTS "{DATABASE_NAME}"') + await admin.execute(f'CREATE DATABASE "{DATABASE_NAME}"') + finally: + await admin.close() + + +async def _drop_database() -> None: + admin = await asyncpg.connect(ADMIN_DSN) + try: + await admin.execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname=$1 AND pid<>pg_backend_pid()", + DATABASE_NAME, + ) + await admin.execute(f'DROP DATABASE IF EXISTS "{DATABASE_NAME}"') + finally: + await admin.close() + + +async def _prepare_records_table(row_count: int) -> None: + connection = await asyncpg.connect(DATABASE_DSN) + try: + await connection.execute( + """ + CREATE TABLE records ( + project_id UUID NOT NULL, + record_id UUID NOT NULL, + session_id VARCHAR NOT NULL, + payload INTEGER NOT NULL, + PRIMARY KEY (project_id, record_id) + ); + CREATE TABLE alembic_version_oss ( + version_num VARCHAR(32) NOT NULL + ); + INSERT INTO alembic_version_oss (version_num) + VALUES ('oss000000005'); + """ + ) + if row_count: + await connection.execute( + """ + INSERT INTO records (project_id, record_id, session_id, payload) + SELECT + '00000000-0000-0000-0000-000000000001'::uuid, + md5(value::text)::uuid, + 'session-' || (value % 32), + value + FROM generate_series(1, $1) AS value + """, + row_count, + ) + finally: + await connection.close() + + +async def _migration_result() -> tuple[int, int | None, int, str]: + connection = await asyncpg.connect(DATABASE_DSN) + try: + row = await connection.fetchrow( + """ + SELECT + count(*) AS row_count, + sum(payload) AS payload_sum, + count(*) FILTER (WHERE sequence IS NULL) AS null_sequences + FROM records + """ + ) + index_definition = await connection.fetchval( + """ + SELECT indexdef + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'records' + AND indexname = 'ux_records_session_id_sequence' + """ + ) + return ( + row["row_count"], + row["payload_sum"], + row["null_sequences"], + index_definition, + ) + finally: + await connection.close() + + +async def _downgrade_result() -> tuple[int, int | None, bool, bool, bool]: + connection = await asyncpg.connect(DATABASE_DSN) + try: + row = await connection.fetchrow( + "SELECT count(*) AS row_count, sum(payload) AS payload_sum FROM records" + ) + index_exists = await connection.fetchval( + "SELECT to_regclass('public.ux_records_session_id_sequence') IS NOT NULL" + ) + sequence_exists = await connection.fetchval( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'records' + AND column_name = 'sequence' + ) + """ + ) + cursor_table_exists = await connection.fetchval( + "SELECT to_regclass('public.session_sequence_cursors') IS NOT NULL" + ) + return ( + row["row_count"], + row["payload_sum"], + index_exists, + sequence_exists, + cursor_table_exists, + ) + finally: + await connection.close() + + +@pytest.fixture +def scratch_tracing_database(): + try: + asyncio.run(_recreate_database()) + except (OSError, asyncpg.PostgresConnectionError) as exc: + pytest.skip(f"scratch Postgres is unavailable: {exc}") + + try: + yield + finally: + asyncio.run(_drop_database()) + + +def test_session_sequence_migration_preserves_records_and_creates_index( + monkeypatch, + scratch_tracing_database, +): + monkeypatch.setattr(postgres_config, "POSTGRES_URI_TRACING", SQLALCHEMY_URL) + + for row_count in (0, 4096): + asyncio.run(_recreate_database()) + asyncio.run(_prepare_records_table(row_count)) + + alembic_config = Config() + alembic_config.set_main_option("script_location", str(VERSIONS_ROOT)) + command.upgrade(alembic_config, "oss000000006") + + actual_count, payload_sum, null_sequences, index_definition = asyncio.run( + _migration_result() + ) + expected_sum = row_count * (row_count + 1) // 2 if row_count else None + assert actual_count == row_count + assert payload_sum == expected_sum + assert null_sequences == row_count + assert index_definition is not None + assert "UNIQUE INDEX" in index_definition + assert "(project_id, session_id, sequence)" in index_definition + + command.downgrade(alembic_config, "oss000000005") + downgrade_result = asyncio.run(_downgrade_result()) + assert downgrade_result == (row_count, expected_sum, False, False, False) diff --git a/api/oss/tests/pytest/unit/sessions/test_durable_events.py b/api/oss/tests/pytest/unit/sessions/test_durable_events.py new file mode 100644 index 00000000000..ee638e58b8d --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_durable_events.py @@ -0,0 +1,148 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +from oss.src.core.sessions.records.dtos import SessionRecord +from oss.src.core.sessions.records.events import durable_events_from_records + + +def _record(*, sequence: int, record_type: str, attributes: dict, source="agent"): + return SessionRecord( + project_id=uuid4(), + session_id="session-1", + record_id=uuid4(), + sequence=sequence, + turn_id="execution-1", + record_type=record_type, + record_source=source, + attributes=attributes, + created_at=datetime.now(timezone.utc), + ) + + +def test_maps_message_and_completed_tool_to_versioned_durable_events(): + records = [ + _record( + sequence=1, + record_type="message", + source="user", + attributes={"type": "message", "id": "message-1", "text": "hello"}, + ), + _record( + sequence=2, + record_type="tool_call", + attributes={ + "type": "tool_call", + "id": "tool-1", + "name": "read", + "input": {"path": "README.md"}, + }, + ), + _record( + sequence=3, + record_type="tool_result", + attributes={"type": "tool_result", "id": "tool-1", "output": "ok"}, + ), + ] + + events = durable_events_from_records(records) + + assert [event.type for event in events] == ["message.completed", "tool.completed"] + assert [event.sequence for event in events] == [1, 3] + assert [event.watermark for event in events] == [3, 3] + assert events[0].payload.role == "user" + assert events[1].payload.name == "read" + assert events[1].payload.input == {"path": "README.md"} + assert events[1].payload.output == "ok" + + +def test_accepts_the_six_direct_event_types_and_ignores_unknown_types(): + records = [ + _record( + sequence=1, + record_type="execution.started", + attributes={"started_at": datetime.now(timezone.utc).isoformat()}, + ), + _record( + sequence=2, + record_type="future.event", + attributes={"value": True}, + ), + ] + + events = durable_events_from_records(records) + + assert len(events) == 1 + assert events[0].type == "execution.started" + assert events[0].sequence == 1 + assert events[0].watermark == 2 + + +def test_maps_interaction_records_to_durable_lifecycle_events(): + records = [ + _record( + sequence=1, + record_type="interaction_request", + attributes={ + "type": "interaction_request", + "id": "interaction-1", + "kind": "client_tool", + }, + ), + _record( + sequence=2, + record_type="interaction_response", + attributes={ + "type": "interaction_response", + "id": "interaction-1", + "kind": "user_approval", + }, + ), + ] + + events = durable_events_from_records(records) + + assert [event.type for event in events] == [ + "interaction.requested", + "interaction.responded", + ] + assert [event.sequence for event in events] == [1, 2] + assert [event.watermark for event in events] == [2, 2] + assert events[0].entity_id == "interaction-1" + assert events[0].payload.interaction_id == "interaction-1" + assert events[0].payload.kind == "client_tool" + assert events[1].payload.kind == "user_approval" + + +def test_non_dict_payload_reads_as_absent_instead_of_raising(): + """A record whose `payload` attribute is not a dict must not poison the batch. + + `attributes` is an open dict filled from the ingest wire. Before this guard, a string or + list `payload` raised `AttributeError` outside the projection's `try`, so the whole batch + failed after its rows were committed and the same record returned on every redelivery. + """ + records = [ + _record( + sequence=1, + record_type="execution.started", + attributes={ + "payload": "not-a-dict", + "started_at": datetime.now(timezone.utc).isoformat(), + }, + ), + _record( + sequence=2, + record_type="execution.started", + attributes={ + "payload": ["also", "not", "a", "dict"], + "started_at": datetime.now(timezone.utc).isoformat(), + }, + ), + ] + + events = durable_events_from_records(records) + + assert [event.type for event in events] == [ + "execution.started", + "execution.started", + ] + assert [event.sequence for event in events] == [1, 2] diff --git a/api/oss/tests/pytest/unit/sessions/test_live_frame_ingest.py b/api/oss/tests/pytest/unit/sessions/test_live_frame_ingest.py new file mode 100644 index 00000000000..6e9cb628bf8 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_live_frame_ingest.py @@ -0,0 +1,377 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, HTTPException, Request +from pydantic import TypeAdapter, ValidationError +from oss.src.apis.fastapi.sessions.models import ( + SessionRecordIngestBody, + SessionRecordIngestRequest, +) +from oss.src.apis.fastapi.sessions.router import RecordsRouter +from oss.src.core.sessions.records.dtos import ( + MAX_LIVE_FRAME_BYTES, + MessageCompletedEvent, + SessionLiveFrame, + SessionRecordEvent, +) +from oss.src.core.sessions.records.streaming import ( + LIVE_FRAME_STREAM_NAME, + MAXLEN_STREAMS_RECORDS, + RECORD_STREAM_NAME, + publish_durable_event, + publish_live_frame, + publish_record, + trim_live_stream, +) +from oss.src.tasks.asyncio.sessions.records_worker import RecordsWorker +from oss.src.utils.env import env + + +def _request( + project_id, user_id, organization_id, *, content_length: int | None = None +) -> Request: + headers = [] + if content_length is not None: + headers.append((b"content-length", str(content_length).encode())) + request = Request( + { + "type": "http", + "method": "POST", + "path": "/sessions/records/ingest", + "headers": headers, + "app": FastAPI(), + } + ) + request.state.project_id = str(project_id) + request.state.user_id = str(user_id) + request.state.organization_id = str(organization_id) + return request + + +def _frame( + session_id: str = "session-1", + execution_id: str = "execution-1", + payload: dict | None = None, + frame_index: int = 0, +): + return SessionRecordIngestRequest( + version=1, + kind="frame", + session_id=session_id, + execution_id=execution_id, + frame_or_event_id=f"{execution_id}:{frame_index}", + frame_index=frame_index, + entity_id="message-1", + type="text-delta", + payload=payload or {"id": "message-1", "delta": "hello"}, + created_at=datetime.now(timezone.utc), + ) + + +async def test_frame_ingest_checks_current_execution_and_publishes(): + project_id = uuid4() + user_id = uuid4() + organization_id = uuid4() + router = RecordsRouter(records_service=AsyncMock()) + + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.get_running_owner", + new_callable=AsyncMock, + return_value="execution-1", + ) as current, + patch( + "oss.src.apis.fastapi.sessions.router.publish_live_frame", + new_callable=AsyncMock, + return_value=True, + ) as publish, + ): + result = await router.ingest_record_event( + request=_request(project_id, user_id, organization_id), + body=_frame(), + ) + + assert result == {"ok": True} + current.assert_awaited_once() + published = publish.await_args.kwargs["frame"] + assert published.execution_id == "execution-1" + assert published.type == "text-delta" + + +async def test_frame_ingest_accepts_a_batch_and_publishes_in_order(): + project_id = uuid4() + user_id = uuid4() + organization_id = uuid4() + router = RecordsRouter(records_service=AsyncMock()) + payload = [_frame(frame_index=index).model_dump(mode="json") for index in range(3)] + body = TypeAdapter(SessionRecordIngestBody).validate_python(payload) + + assert isinstance(body, list) + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.get_running_owner", + new_callable=AsyncMock, + return_value="execution-1", + ) as current, + patch( + "oss.src.apis.fastapi.sessions.router.publish_live_frame", + new_callable=AsyncMock, + return_value=True, + ) as publish, + ): + result = await router.ingest_record_event( + request=_request(project_id, user_id, organization_id), + body=body, + ) + + assert result == {"ok": True} + current.assert_awaited_once() + assert [call.kwargs["frame"].frame_index for call in publish.await_args_list] == [ + 0, + 1, + 2, + ] + + +async def test_frame_ingest_rejects_a_stale_execution(): + router = RecordsRouter(records_service=AsyncMock()) + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.get_running_owner", + new_callable=AsyncMock, + return_value="execution-new", + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_live_frame", + new_callable=AsyncMock, + ) as publish, + ): + with pytest.raises(HTTPException) as exc_info: + await router.ingest_record_event( + request=_request(uuid4(), uuid4(), uuid4()), + body=_frame(execution_id="execution-stale"), + ) + + assert exc_info.value.status_code == 403 + publish.assert_not_awaited() + + +def test_frame_request_rejects_oversized_serialized_payload(): + with pytest.raises(ValidationError, match="serialized live frame exceeds"): + _frame(payload={"delta": "x" * MAX_LIVE_FRAME_BYTES}) + + +async def test_frame_ingest_rejects_oversized_content_length(): + router = RecordsRouter(records_service=AsyncMock()) + owner = AsyncMock() + publish = AsyncMock() + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.get_running_owner", + owner, + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_live_frame", + publish, + ), + ): + with pytest.raises(HTTPException) as exc_info: + await router.ingest_record_event( + request=_request( + uuid4(), + uuid4(), + uuid4(), + content_length=MAX_LIVE_FRAME_BYTES + 1, + ), + body=_frame(), + ) + + assert exc_info.value.status_code == 413 + owner.assert_not_awaited() + publish.assert_not_awaited() + + +async def test_publish_frame_rejects_oversized_mutated_payload(): + redis = AsyncMock() + frame = SessionLiveFrame( + version=1, + kind="frame", + session_id="session-1", + execution_id="execution-1", + frame_or_event_id="execution-1:0", + frame_index=0, + entity_id="message-1", + type="text-delta", + payload={"delta": "hello"}, + created_at=datetime.now(timezone.utc), + ) + frame.payload = {"delta": "x" * MAX_LIVE_FRAME_BYTES} + + with patch( + "oss.src.core.sessions.records.streaming._get_redis", return_value=redis + ): + assert not await publish_live_frame(project_id=uuid4(), frame=frame) + + redis.xadd.assert_not_awaited() + + +async def test_publish_frame_uses_dedicated_bounded_stream(): + redis = AsyncMock() + frame = SessionLiveFrame( + version=1, + kind="frame", + session_id="session-1", + execution_id="execution-1", + frame_or_event_id="execution-1:0", + frame_index=0, + entity_id="message-1", + type="text-delta", + payload={"delta": "hello"}, + created_at=datetime.now(timezone.utc), + ) + with ( + patch("oss.src.core.sessions.records.streaming._get_redis", return_value=redis), + patch.object(env.sessions, "live_stream_maxlen", 4), + ): + assert await publish_live_frame(project_id=uuid4(), frame=frame) + + xadd = redis.xadd.await_args.kwargs + assert xadd["name"] == LIVE_FRAME_STREAM_NAME + assert isinstance(xadd["fields"]["data"], bytes) + assert xadd["maxlen"] == 4 + # The live stream carries disposable frames, so trimming is approximate on the hot path. + assert xadd["approximate"] is True + redis.xtrim.assert_awaited_once() + assert redis.xtrim.await_args.kwargs["approximate"] is True + + +async def test_publish_durable_event_uses_dedicated_bounded_stream(): + redis = AsyncMock() + event = MessageCompletedEvent.model_validate( + { + "session_id": "session-1", + "execution_id": "execution-1", + "frame_or_event_id": "event-1", + "entity_id": "message-1", + "sequence": 1, + "watermark": 1, + "type": "message.completed", + "payload": { + "message_id": "message-1", + "role": "assistant", + "content": "hello", + }, + "created_at": datetime.now(timezone.utc), + } + ) + with ( + patch("oss.src.core.sessions.records.streaming._get_redis", return_value=redis), + patch.object(env.sessions, "live_stream_maxlen", 4), + ): + assert await publish_durable_event(project_id=uuid4(), event=event) + + xadd = redis.xadd.await_args.kwargs + assert xadd["name"] == LIVE_FRAME_STREAM_NAME + assert xadd["name"] != RECORD_STREAM_NAME + assert xadd["maxlen"] == 4 + assert xadd["approximate"] is True + + +async def test_publish_record_preserves_flag_off_retention_bound(): + redis = AsyncMock() + project_id = uuid4() + record = SessionRecordEvent( + project_id=project_id, + session_id="session-1", + record_type="message", + attributes={"type": "text", "text": "hello"}, + ) + with ( + patch("oss.src.core.sessions.records.streaming._get_redis", return_value=redis), + patch.object(env.sessions, "shared_reader", False), + ): + assert await publish_record(project_id=project_id, record_event=record) + + xadd = redis.xadd.await_args.kwargs + assert xadd["name"] == RECORD_STREAM_NAME + assert xadd["maxlen"] == MAXLEN_STREAMS_RECORDS + assert xadd["approximate"] is True + + +async def test_records_worker_deletes_malformed_entries_after_ack(): + redis = AsyncMock() + worker = RecordsWorker( + service=AsyncMock(), + redis_client=redis, + stream_name=RECORD_STREAM_NAME, + consumer_group="worker-records", + ) + + appended, processed = await worker.process_batch( + [(b"1-0", {b"data": b"not-a-compressed-record"})] + ) + await worker.ack_and_delete(processed) + + assert appended == 0 + assert processed == [b"1-0"] + redis.xack.assert_awaited_once_with(RECORD_STREAM_NAME, "worker-records", b"1-0") + redis.xdel.assert_awaited_once_with(RECORD_STREAM_NAME, b"1-0") + + +async def test_live_frame_count_bound_does_not_touch_durable_records(): + fakeredis = pytest.importorskip("fakeredis") + redis = fakeredis.FakeAsyncRedis() + durable_id = await redis.xadd(RECORD_STREAM_NAME, {"data": b"durable"}) + + with ( + patch("oss.src.core.sessions.records.streaming._get_redis", return_value=redis), + patch.object(env.sessions, "live_stream_maxlen", 4), + ): + for index in range(5): + frame = SessionLiveFrame.model_validate( + { + **_frame().model_dump(), + "frame_or_event_id": f"execution-1:{index}", + "frame_index": index, + } + ) + assert await publish_live_frame(project_id=uuid4(), frame=frame) + + assert await redis.xlen(LIVE_FRAME_STREAM_NAME) == 4 + assert await redis.xrange(RECORD_STREAM_NAME, min=durable_id, max=durable_id) + + +async def test_age_trim_removes_expired_frames_from_live_stream(): + fakeredis = pytest.importorskip("fakeredis") + redis = fakeredis.FakeAsyncRedis() + expired_id = f"{int(datetime.now(timezone.utc).timestamp() * 1000) - 901_000}-0" + await redis.xadd(LIVE_FRAME_STREAM_NAME, {"data": b"expired-frame"}, id=expired_id) + + with patch.object(env.sessions, "live_frame_max_age_seconds", 900): + await trim_live_stream(redis) + + assert ( + await redis.xrange(LIVE_FRAME_STREAM_NAME, min=expired_id, max=expired_id) == [] + ) diff --git a/api/oss/tests/pytest/unit/sessions/test_live_relay.py b/api/oss/tests/pytest/unit/sessions/test_live_relay.py new file mode 100644 index 00000000000..d5b2387aef2 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_live_relay.py @@ -0,0 +1,411 @@ +import asyncio +import json +import zlib +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, HTTPException, Request +from orjson import dumps + +from oss.src.apis.fastapi.sessions.live_events import live_event_stream +from oss.src.apis.fastapi.sessions.router import SessionStreamsRouter +from oss.src.core.sessions.records.dtos import ( + MessageCompletedEvent, + SessionDurableEventsReplay, +) +from oss.src.core.sessions.records.streaming import LIVE_FRAME_STREAM_NAME +from oss.src.tasks.asyncio.sessions.live_relay_worker import LiveRelayWorker +from oss.src.utils.env import env + + +class FakePubSub: + def __init__(self, messages): + self.messages = list(messages) + self.subscribed = AsyncMock() + self.unsubscribed = AsyncMock() + self.closed = AsyncMock() + + async def subscribe(self, channel): + await self.subscribed(channel) + + async def get_message(self, **_kwargs): + await asyncio.sleep(0) + if self.messages: + return self.messages.pop(0) + await asyncio.sleep(0.01) + return None + + async def unsubscribe(self, channel): + await self.unsubscribed(channel) + + async def aclose(self): + await self.closed() + + +def _frame(index: int = 0): + return { + "version": 1, + "kind": "frame", + "session_id": "session-1", + "execution_id": "execution-1", + "frame_or_event_id": f"execution-1:{index}", + "frame_index": index, + "entity_id": "message-1", + "type": "text-delta", + "payload": {"id": "message-1", "delta": "hello"}, + "created_at": datetime.now(timezone.utc).isoformat(), + } + + +def _event(sequence: int = 1): + return { + "version": 1, + "kind": "event", + "session_id": "session-1", + "execution_id": "execution-1", + "frame_or_event_id": f"event-{sequence}", + "entity_id": "message-1", + "sequence": sequence, + "watermark": sequence, + "type": "message.completed", + "payload": { + "message_id": "message-1", + "role": "assistant", + "content": "hello", + }, + "created_at": datetime.now(timezone.utc).isoformat(), + } + + +async def test_rechecks_authorization_and_closes_revoked_reader(): + pubsub = FakePubSub([]) + checks = 0 + + async def authorize(): + nonlocal checks + checks += 1 + return False + + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=authorize, + authorization_recheck_seconds=0.001, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=4, + ) + + assert (await anext(stream)).startswith("retry: 5000") + assert (await anext(stream)).startswith("event: ready") + terminal = await anext(stream) + assert terminal.startswith("event: relay-close") + assert ( + json.loads(terminal.split("data: ", 1)[1])["reason"] == "authorization_revoked" + ) + assert checks == 1 + + +async def test_slow_reader_gets_terminal_close_frame(): + messages = [{"type": "message", "data": dumps(_frame(index))} for index in range(3)] + pubsub = FakePubSub(messages) + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=AsyncMock(return_value=True), + authorization_recheck_seconds=60, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=1, + ) + + assert (await anext(stream)).startswith("retry:") + assert (await anext(stream)).startswith("event: ready") + await asyncio.sleep(0.02) + terminal = await anext(stream) + assert terminal.startswith("event: relay-close") + assert json.loads(terminal.split("data: ", 1)[1])["reason"] == "slow_reader" + + +async def test_live_stream_forwards_durable_event_envelopes(): + pubsub = FakePubSub([{"type": "message", "data": dumps(_event())}]) + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=AsyncMock(return_value=True), + authorization_recheck_seconds=60, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=4, + ) + + assert (await anext(stream)).startswith("retry:") + assert (await anext(stream)).startswith("event: ready") + event = json.loads((await anext(stream)).split("data: ", 1)[1]) + assert event["kind"] == "event" + assert event["sequence"] == 1 + assert event["watermark"] == 1 + await stream.aclose() + + +async def test_live_stream_subscribes_before_replay_and_dedupes_notification(): + subscribed = False + event = MessageCompletedEvent.model_validate(_event(sequence=3)) + + class OrderingPubSub(FakePubSub): + async def subscribe(self, channel): + nonlocal subscribed + subscribed = True + await super().subscribe(channel) + + pubsub = OrderingPubSub([{"type": "message", "data": dumps(_event(sequence=3))}]) + replay_calls = [] + + async def replay(after): + assert subscribed is True + replay_calls.append(after) + return SessionDurableEventsReplay( + events=[event] if after < 3 else [], + watermark=3, + ) + + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=AsyncMock(return_value=True), + authorization_recheck_seconds=60, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=4, + after=2, + replay_query=replay, + ) + + assert (await anext(stream)).startswith("retry:") + replayed = json.loads((await anext(stream)).split("data: ", 1)[1]) + assert replayed["sequence"] == 3 + assert replayed["watermark"] == 3 + assert await anext(stream) == 'event: ready\ndata: {"watermark": 3}\n\n' + await asyncio.sleep(0.01) + assert replay_calls == [2, 3] + await stream.aclose() + + +async def test_replay_ready_reports_watermark_without_typed_events(): + pubsub = FakePubSub([]) + + async def replay(_after): + return SessionDurableEventsReplay(events=[], watermark=5) + + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=AsyncMock(return_value=True), + authorization_recheck_seconds=60, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=4, + after=2, + replay_query=replay, + ) + + assert (await anext(stream)).startswith("retry:") + assert await anext(stream) == 'event: ready\ndata: {"watermark": 5}\n\n' + await stream.aclose() + + +async def test_replay_larger_than_buffer_backpressures_without_closing_reader(): + events = [ + MessageCompletedEvent.model_validate(_event(sequence)) + for sequence in range(1, 6) + ] + pubsub = FakePubSub([]) + + async def replay(_after): + return SessionDurableEventsReplay(events=events, watermark=5) + + stream = live_event_stream( + channel="events:project-1:session:session-1", + pubsub_factory=lambda: pubsub, + authorization_check=AsyncMock(return_value=True), + authorization_recheck_seconds=60, + heartbeat_seconds=60, + retry_milliseconds=5000, + buffer_limit=2, + replay_query=replay, + ) + + assert (await anext(stream)).startswith("retry:") + replayed = [json.loads((await anext(stream)).split("data: ", 1)[1]) for _ in events] + assert [event["sequence"] for event in replayed] == [1, 2, 3, 4, 5] + assert await anext(stream) == 'event: ready\ndata: {"watermark": 5}\n\n' + await stream.aclose() + + +async def test_relay_worker_publishes_and_deletes_frames(): + project_id = uuid4() + redis = AsyncMock() + worker = LiveRelayWorker( + redis_client=redis, + stream_name=LIVE_FRAME_STREAM_NAME, + consumer_group="worker-session-live-relay", + ) + frame_message = { + "organization_id": None, + "project_id": str(project_id), + "kind": "frame", + "frame": _frame(), + } + batch = [ + (b"1-0", {b"data": zlib.compress(dumps(frame_message))}), + ] + + published, processed = await worker.process_batch(batch) + await worker.ack_and_delete(processed) + + assert published == 1 + assert processed == [b"1-0"] + redis.publish.assert_awaited_once() + redis.xack.assert_awaited_once_with( + LIVE_FRAME_STREAM_NAME, "worker-session-live-relay", b"1-0" + ) + redis.xdel.assert_awaited_once_with(LIVE_FRAME_STREAM_NAME, b"1-0") + + +async def test_relay_worker_discards_frames_older_than_900_seconds(): + project_id = uuid4() + redis = AsyncMock() + worker = LiveRelayWorker( + redis_client=redis, + stream_name=LIVE_FRAME_STREAM_NAME, + consumer_group="worker-session-live-relay", + ) + expired = _frame(0) + expired["created_at"] = ( + datetime.now(timezone.utc) - timedelta(seconds=901) + ).isoformat() + fresh = _frame(1) + batch = [ + ( + b"1-0", + { + b"data": zlib.compress( + dumps( + { + "organization_id": None, + "project_id": str(project_id), + "kind": "frame", + "frame": expired, + } + ) + ) + }, + ), + ( + b"2-0", + { + b"data": zlib.compress( + dumps( + { + "organization_id": None, + "project_id": str(project_id), + "kind": "frame", + "frame": fresh, + } + ) + ) + }, + ), + ] + + with patch.object(env.sessions, "live_frame_max_age_seconds", 900): + published, processed = await worker.process_batch(batch) + + assert published == 1 + assert processed == [b"1-0", b"2-0"] + redis.publish.assert_awaited_once() + + +async def test_relay_worker_publishes_durable_events(): + project_id = uuid4() + redis = AsyncMock() + worker = LiveRelayWorker( + redis_client=redis, + stream_name=LIVE_FRAME_STREAM_NAME, + consumer_group="worker-session-live-relay", + ) + event_message = { + "organization_id": None, + "project_id": str(project_id), + "kind": "event", + "event": _event(), + } + + published, processed = await worker.process_batch( + [(b"1-0", {b"data": zlib.compress(dumps(event_message))})] + ) + + assert published == 1 + assert processed == [b"1-0"] + relayed = json.loads(redis.publish.await_args.args[1]) + assert relayed["kind"] == "event" + assert relayed["sequence"] == 1 + assert relayed["watermark"] == 1 + + +async def test_events_route_is_hidden_when_shared_reader_is_off(): + router = SessionStreamsRouter( + service=AsyncMock(), + interactions_service=AsyncMock(), + ) + request = Request( + { + "type": "http", + "method": "GET", + "path": "/sessions/session-1/events", + "headers": [], + "app": FastAPI(), + } + ) + request.state.project_id = str(uuid4()) + request.state.user_id = str(uuid4()) + + with patch.object(env.sessions, "shared_reader", False): + with pytest.raises(HTTPException) as exc_info: + await router.session_events(request=request, session_id="session-1") + + assert exc_info.value.status_code == 404 + + +async def test_events_route_disables_authenticated_response_storage(): + router = SessionStreamsRouter( + service=AsyncMock(), + interactions_service=AsyncMock(), + records_service=AsyncMock(), + ) + request = Request( + { + "type": "http", + "method": "GET", + "path": "/sessions/session-1/events", + "headers": [], + "app": FastAPI(), + } + ) + request.state.project_id = str(uuid4()) + request.state.user_id = str(uuid4()) + + with ( + patch.object(env.sessions, "shared_reader", True), + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + ): + response = await router.session_events(request=request, session_id="session-1") + + assert response.headers["cache-control"] == "no-store" + await response.body_iterator.aclose() diff --git a/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py b/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py index 775d146fce1..d6d8b59c6ec 100644 --- a/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py +++ b/api/oss/tests/pytest/unit/sessions/test_records_mapping_upsert.py @@ -56,6 +56,7 @@ def test_turn_id_and_span_id_default_to_none(): dbe = map_record_event_to_dbe(event=_event()) assert dbe.turn_id is None assert dbe.span_id is None + assert dbe.sequence is None class _FakeResult: diff --git a/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py b/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py index bb11086f810..ab0e6db4534 100644 --- a/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py +++ b/api/oss/tests/pytest/unit/sessions/test_records_worker_batching.py @@ -9,7 +9,8 @@ still pass with the old one-append-per-event code. """ -from unittest.mock import AsyncMock +from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch from uuid import uuid4 import zlib @@ -117,3 +118,140 @@ async def test_process_batch_groups_by_project_one_append_many_per_project(): # never one per event (which would be 3). assert records_dao.append_many.await_count == 2 records_dao.append.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_failed_append_leaves_project_messages_unacknowledged(): + project_id = uuid4() + redis = AsyncMock() + records_dao = AsyncMock() + records_dao.append_many = AsyncMock(side_effect=RuntimeError("deadlock victim")) + worker = RecordsWorker( + service=RecordsService(records_dao=records_dao), + redis_client=redis, + stream_name="streams:records", + consumer_group="worker-records", + ) + batch = [ + ( + b"1-0", + {b"data": _payload(project_id=project_id, session_id="a", record_index=0)}, + ), + ( + b"2-0", + {b"data": _payload(project_id=project_id, session_id="b", record_index=0)}, + ), + ] + + total_appended, processed_ids = await worker.process_batch(batch) + await worker.ack_and_delete(processed_ids) + + assert total_appended == 0 + assert processed_ids == [] + redis.xack.assert_not_awaited() + redis.xdel.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_durable_event_is_published_only_after_record_commit_returns(): + project_id = uuid4() + committed = False + + class Service: + async def append_many(self, *, events): + nonlocal committed + committed = True + return [ + SessionRecord( + record_id=uuid4(), + session_id="sess-1", + project_id=project_id, + sequence=1, + turn_id="turn-1", + record_type="message", + record_source="agent", + attributes={"type": "message", "text": "done"}, + created_at=datetime.now(timezone.utc), + ) + ] + + async def publish(**kwargs): + assert committed is True + assert kwargs["event"].sequence == 1 + assert kwargs["event"].watermark == 1 + return True + + worker = RecordsWorker( + service=Service(), + redis_client=None, + stream_name="streams:records", + consumer_group="worker-records", + ) + + with patch( + "oss.src.tasks.asyncio.sessions.records_worker.publish_durable_event", + side_effect=publish, + ) as publisher: + await worker.process_batch( + [ + ( + b"1-0", + { + b"data": _payload( + project_id=project_id, session_id="sess-1", record_index=0 + ) + }, + ) + ] + ) + + publisher.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_quarantined_committed_record_is_not_published_as_a_durable_event(): + project_id = uuid4() + + class Service: + async def append_many(self, *, events): + return [ + SessionRecord( + record_id=uuid4(), + session_id="sess-1", + project_id=project_id, + sequence=1, + turn_id="turn-1", + record_type="message", + record_source="agent", + attributes={"type": "message", "text": "refused tail"}, + quarantined_at=datetime.now(timezone.utc), + created_at=datetime.now(timezone.utc), + ) + ] + + worker = RecordsWorker( + service=Service(), + redis_client=None, + stream_name="streams:records", + consumer_group="worker-records", + ) + + with patch( + "oss.src.tasks.asyncio.sessions.records_worker.publish_durable_event" + ) as publisher: + total_appended, processed_ids = await worker.process_batch( + [ + ( + b"1-0", + { + b"data": _payload( + project_id=project_id, session_id="sess-1", record_index=0 + ) + }, + ) + ] + ) + + assert total_appended == 1 + assert processed_ids == [b"1-0"] + publisher.assert_not_awaited() diff --git a/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py b/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py new file mode 100644 index 00000000000..3e10f1ee8cb --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py @@ -0,0 +1,110 @@ +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, Request + +from oss.src.apis.fastapi.sessions.router import SessionsRootRouter +from oss.src.core.sessions.records.dtos import SessionRecordsReadState +from oss.src.core.sessions.streams.dtos import SessionStream +from oss.src.utils.env import env + + +def _request(project_id, user_id) -> Request: + request = Request( + { + "type": "http", + "method": "GET", + "path": "/sessions/session-1", + "headers": [], + "app": FastAPI(), + } + ) + request.state.project_id = str(project_id) + request.state.user_id = str(user_id) + return request + + +@pytest.mark.asyncio +async def test_snapshot_groups_session_execution_pending_and_read_watermark(): + project_id = uuid4() + stream = SessionStream( + id=uuid4(), project_id=project_id, session_id="session-1", name="Session" + ) + streams = AsyncMock() + streams.fetch.return_value = stream + records = AsyncMock() + records.get_read_state.return_value = SessionRecordsReadState( + latest_sequence=7, + history_complete=True, + ) + interactions = AsyncMock() + interactions.query_interactions.return_value = [] + turns = AsyncMock() + turns.latest_turn.return_value = None + router = SessionsRootRouter( + sessions_service=AsyncMock(), + streams_service=streams, + records_service=records, + interactions_service=interactions, + turns_service=turns, + ) + + with ( + patch.object(env.sessions, "shared_reader", True), + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + ): + snapshot = await router.get_session_snapshot( + request=_request(project_id, uuid4()), + session_id="session-1", + ) + + assert snapshot.session.session_id == "session-1" + assert snapshot.execution is None + assert snapshot.pending.inputs == [] + assert snapshot.pending.interactions == [] + assert snapshot.read.latest_sequence == 7 + assert snapshot.read.history_complete is True + + +@pytest.mark.asyncio +async def test_snapshot_forces_incomplete_when_stream_marker_is_present(): + project_id = uuid4() + stream = SessionStream(id=uuid4(), project_id=project_id, session_id="session-1") + object.__setattr__(stream, "history_incomplete", True) + streams = AsyncMock() + streams.fetch.return_value = stream + records = AsyncMock() + records.get_read_state.return_value = SessionRecordsReadState( + latest_sequence=2, + history_complete=True, + ) + interactions = AsyncMock() + interactions.query_interactions.return_value = [] + turns = AsyncMock() + turns.latest_turn.return_value = None + router = SessionsRootRouter( + sessions_service=AsyncMock(), + streams_service=streams, + records_service=records, + interactions_service=interactions, + turns_service=turns, + ) + + with ( + patch.object(env.sessions, "shared_reader", True), + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + ): + snapshot = await router.get_session_snapshot( + request=_request(project_id, uuid4()), session_id="session-1" + ) + + assert snapshot.read.history_complete is False diff --git a/api/oss/tests/pytest/unit/sessions/test_worker_streams_startup.py b/api/oss/tests/pytest/unit/sessions/test_worker_streams_startup.py new file mode 100644 index 00000000000..63a7ace6223 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_worker_streams_startup.py @@ -0,0 +1,53 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from entrypoints import worker_streams + + +async def test_relay_initialization_failure_does_not_block_durable_consumers(): + records = SimpleNamespace( + stream_name="streams:records", + consumer_group="worker-records", + consumer_name="records-1", + create_consumer_group=AsyncMock(), + run=AsyncMock(), + ) + live_relay = SimpleNamespace( + stream_name="streams:session-live-frames", + consumer_group="worker-session-live-relay", + consumer_name="relay-1", + create_consumer_group=AsyncMock( + side_effect=RuntimeError("relay XGROUP failed") + ), + run=AsyncMock(), + ) + + with ( + patch.object(worker_streams, "_selected_streams", return_value=["records"]), + patch.object(worker_streams, "warn_deprecated_env_vars"), + patch.object(worker_streams, "validate_required_env_vars"), + patch.object(worker_streams, "is_ee", return_value=False), + patch.object(worker_streams.Redis, "from_url", return_value=AsyncMock()), + patch.object( + worker_streams, + "_build_records_worker", + new=AsyncMock(return_value=records), + ), + patch.object( + worker_streams, + "_build_live_relay_worker", + new=AsyncMock(return_value=live_relay), + ), + patch.object( + worker_streams, + "prune_idle_consumers", + new=AsyncMock(return_value=[]), + ), + patch.object(worker_streams.env.sessions, "shared_reader", True), + ): + assert await worker_streams.main_async() == 0 + + records.create_consumer_group.assert_awaited_once() + records.run.assert_awaited_once() + live_relay.create_consumer_group.assert_awaited_once() + live_relay.run.assert_not_awaited() diff --git a/docs/design/session-control-and-live-events/contracts/events.md b/docs/design/session-control-and-live-events/contracts/events.md new file mode 100644 index 00000000000..9a418c2425f --- /dev/null +++ b/docs/design/session-control-and-live-events/contracts/events.md @@ -0,0 +1,179 @@ +# Session event contracts + +> **AGENT-GENERATED, low weight.** + +This file describes the shipped session event contracts. Milestone 1 shipped the disposable +live-frame relay. Milestone 2 shipped the durable-event contract: replay with sequences and +watermarks over `GET /sessions/{session_id}/events`, and browser fan-out to a second reader. + +## Shipped live-frame contract + +### Client behavior + +The initiating browser continues to render the invoke response. A second browser subscribes to the +session event route only when the session advertises `shared_reader` and the run belongs to another +browser. The global environment switch controls both the route and the advertised capability. + +The event route replays durable events after the client's `after` cursor, then sends a `ready` +event carrying the replay watermark, then follows live frames and durable events as they are +published. Live frames are unnamed SSE data events. The existing watch SSE continues to send +low-frequency notices such as `records-changed`; clients use those notices to reload completed +records. + +Each execution must start at `frame_index: 0`, and each later frame must increment the index by one. +The client ignores duplicate and older indices. If the first index is above zero or a later index +skips a value, the client clears and suppresses the preview tail and refreshes durable records. A +reconnect also clears the disposable preview and refreshes durable records because Redis Pub/Sub has +no replay. + +### Live-frame envelope + +Frames use the existing records ingest HTTP endpoint. The API validates the frame and publishes it +to the dedicated live-frame Redis Stream. + +```text +version: 1 +kind: frame +session_id +execution_id +frame_or_event_id +frame_index +entity_id +type +payload +created_at +``` + +- `session_id` reuses the current `sessionId`. +- `execution_id` reuses the current `turnId`. +- `frame_or_event_id` combines the execution ID and frame index. +- `entity_id` reuses the message ID or tool-call ID. +- `frame_index` starts at zero and increases by one within an execution. +- `created_at` is the producer timestamp in UTC. It does not define order. + +### Live-frame payloads + +The envelope wraps the current invoke vocabulary. It does not rename the content protocol. + +| Family | Shipped types and fields | +|---|---| +| Text | `text-start`, `text-delta.delta`, `text-end`; all reuse `id` | +| Reasoning | `reasoning-start`, `reasoning-delta.delta`, `reasoning-end`; all reuse `id` | +| Tools | `tool-input-start`, `tool-input-available`, `tool-output-available`, `tool-output-error`, `tool-output-denied`; all reuse `toolCallId` and current input or output fields | + +Repeated tool input snapshots keep one `toolCallId`, so the reducer updates one preview. + +### Storage and retention + +The runner publishes frames asynchronously through a bounded 256-frame buffer. Publication errors +and buffer overflow do not block the run. Frames reach the records ingest HTTP route, where the API +appends frame relay envelopes to `streams:session-live-frames`. The stream has a 15-minute age limit +and an approximate 100,000-entry count bound by default. Redis may temporarily retain more entries +because both count and age trimming use `MAXLEN ~` and `MINID ~`. Concurrent sessions share the +count bound because relay messages are disposable. + +The relay worker reads only the live relay stream. It discards expired envelopes, publishes accepted +frames and durable events to the project-and-session Pub/Sub channel, then acknowledges and deletes +them. The measured long case reached 3,161 frames and 201,056 SSE bytes in one turn. At the highest +measured average rate, the default 100,000-entry trim threshold represents about 22 minutes for one +active run, but the 15-minute age limit caps effective relay retention at 15 minutes. + +Only durable records enter `streams:records`. Publication preserves the existing approximate +100,000-entry retention bound. After the records worker commits those records, it projects durable +events and appends their relay envelopes to `streams:session-live-frames`. It then acknowledges and +deletes the durable-record entries. The live relay never reads `streams:records`. + +### Authorization and reader limits + +Frame ingress verifies `RUN_SESSIONS` access and the caller's current owner claim for the supplied +session and execution. The shared runner token alone cannot authorize a foreign frame. The API also +enforces the serialized frame-size limit before publishing. + +The event route requires `VIEW_SESSIONS` access for the current project and revalidates access during +the connection. Each reader has one bounded output queue. The API sends `relay-close` and ends a +connection when the reader falls behind, authorization is revoked, or the relay fails. The response +uses `Cache-Control: no-store` and disables proxy buffering. + +Logs contain identifiers and reason codes only. They do not contain message content, tool payloads, +or tokens. + +## Durable-event contract + +### Sender on the shared path + +For `x-ag-session-response: shared`, invoke emits one transient `data-session-accepted` event with +`{sessionId, turnId, executionId}`, and emits it only after the runner admits the turn. The same ID +serves as the turn and the execution. The sender consumes invoke only for this acceptance, protocol +lifecycle, and errors. It renders text, reasoning, and tool progress from the session event route. + +### Durable event envelope + +Temporary frames and durable records reuse the records ingest HTTP endpoint. The API appends frame +relay envelopes directly, while the records worker projects durable events only after their records +commit. Both paths call `_append_live_relay_message` to append relay envelopes to +`streams:session-live-frames`, where `kind` distinguishes the two versioned shapes. Only durable +records use the separate `streams:records` path. + +```text +version +kind: frame | event +session_id +execution_id +frame_or_event_id +entity_id +type +payload +created_at + +when kind = frame: + frame_index + +when kind = event: + sequence + watermark +``` + +- `sequence` is the database-assigned per-session record cursor. It can skip values because every + record receives a sequence while the relay exposes only the typed events. +- `watermark` is the session's latest committed record sequence when the event is published or + replayed. On a live event it is the highest sequence committed in the publishing batch. On the + replay's final `ready` event it is the session cursor after replay. + +Clients apply durable events whose `sequence` is greater than the last event they applied, and +discard duplicate or older events. They do not wait for a contiguous durable sequence. After +applying an event, they advance the event-deduplication cursor to `sequence` and track the greater +of `sequence` and `watermark` separately as the reconnect cursor. A replay's final `ready` event can +advance both cursors after every event through its watermark has been applied. + +### Durable event types + +The contract defines these event types and payloads: + +| Type | Typed payload | +|---|---| +| `execution.started` | `{started_at}` | +| `execution.stopped` | `{stopped_at, reason, command_id}` | +| `execution.failed` | `{failed_at, error: {code, message, retryable, details?}}` | +| `execution.lost` | `{lost_at, reason, history_complete: false}` | +| `message.completed` | `{message_id, role, content, finish_reason?}` | +| `tool.completed` | `{tool_call_id, name, input, output?, error?, status}` | +| `interaction.requested` | `{interaction_id, kind?}` | +| `interaction.responded` | `{interaction_id, kind?}` | + +The envelope carries session, execution, entity, sequence, and creation fields, so payloads do not +repeat them. The reducer ignores an unknown event type and continues from the next sequence. +Interaction events carry no answer data. Readers use them to refresh records and the current +interaction state. + +### Replay and live handoff + +The event endpoint subscribes to the wake-up source before its first history query. It queries +Postgres after the supplied sequence, sends rows in order, and queries again when a notification +arrives. Notifications carry no durable truth. + +Each replay is bounded by the current database watermark. Replayed events carry that watermark. The +replay's final `ready` event also carries it, including when no typed event follows the supplied +sequence. + +If a reader falls behind, the API closes the connection. The reader then reloads the durable +snapshot and resumes from its durable sequence. diff --git a/docs/design/session-control-and-live-events/decisions.md b/docs/design/session-control-and-live-events/decisions.md index d2f1a57656e..75ecbed3ca3 100644 --- a/docs/design/session-control-and-live-events/decisions.md +++ b/docs/design/session-control-and-live-events/decisions.md @@ -41,7 +41,12 @@ system must deliver live frames to every connected reader. **Status:** Confirmed direction on 2026-09-02. Live text fragments can have bounded retention. Completed messages, lifecycle facts, tools, and -interactions require durable recovery. One raw ingress can feed both consumers. +interactions require durable recovery. The API accepts both through one HTTP ingest endpoint, then +publishes frames to `streams:session-live-frames` and durable records to `streams:records`. + +The live-frame stream applies a 15-minute age bound and a 100,000-frame count bound across the +deployment. Separate Redis Streams preserve the durable consumer's acknowledgement policy and +remove cross-consumer trim coordination. ### D-006: Investigate sandbox-agent cancellation before selecting Stop semantics @@ -172,13 +177,13 @@ durable recovery source after process or Redis failure. ## Proposed design decisions -### P-001: Use one raw runner event ingress +### P-001: Use one HTTP ingress and separate Redis Streams -**Status:** Proposed. Not approved. +**Status:** Settled on 2026-09-04. -The runner sends raw frames once. A shared Redis Stream can feed both the live relay and a durable -projector. The projector combines raw frames into durable events. The live relay forwards raw or -briefly batched frames without waiting for message completion. +The runner sends temporary frames and durable records through one records ingest HTTP endpoint. +The API routes frames to `streams:session-live-frames` and durable records to `streams:records`. +The live relay forwards frames without waiting for message completion. The current sender response and current persistence path can remain during migration. @@ -235,7 +240,11 @@ Do not add a sequence column to mutable upserts and call the result append-only. ### O-004: Raw live transport -Choose the Redis Stream layout, retention limit, redaction boundary, and browser fan-out model. +**Status:** Stream layout and retention settled on 2026-09-04. + +Temporary frames use a dedicated deployment-wide Redis Stream bounded to 15 minutes and 100,000 +frames. Trimming is approximate on both the publish and the sweep path, because the frames are +disposable. Browser fan-out shipped in milestone 2. Redaction remains open. ### O-005: Stable record-ID semantics spike diff --git a/docs/design/session-control-and-live-events/live-frame-envelope.md b/docs/design/session-control-and-live-events/live-frame-envelope.md new file mode 100644 index 00000000000..08c0e234239 --- /dev/null +++ b/docs/design/session-control-and-live-events/live-frame-envelope.md @@ -0,0 +1,89 @@ +# Live frame envelope + +> **AGENT-GENERATED, low weight.** + +## Measurement + +The sample used `agenta-ee-dev-session-integration` at `http://localhost:8580` on 3 September +2026. It ran Pi (`pi_core`) with `gpt-5.6-luna` and the local sandbox. Each case ran three times. +The Pi key came from `~/.agenta-qa-openai.env` under `OPENAI_API_KEY`. Its value was not recorded. + +A frame is one JSON `data:` SSE frame. Counts exclude the terminal `[DONE]` sentinel. Byte counts +include the `data:` prefix and frame delimiter. Run length starts before the invoke request and ends +when the response body closes. Raw bodies and the machine-readable results are in +`~/agenta-qa-evidence/2026-09-03-session-night/trackC/`. + +`base` below means `start:1`, `start-step:1`, `message-metadata:1`, `data-agent-status:2`, +`text-start:1`, `text-end:1`, `finish-step:1`, and `finish:1`. + +| Case | Run | Length | Frames | Frames/s | Bytes/frame min/median/max | Total bytes | Event counts | +|---|---:|---:|---:|---:|---:|---:|---| +| Short | 1 | 10.456 s | 104 | 9.947 | 30 / 63 / 190 | 6,830 | base; `text-delta:95` | +| Short | 2 | 9.406 s | 116 | 12.333 | 30 / 63 / 202 | 7,589 | base; `text-delta:107` | +| Short | 3 | 9.501 s | 105 | 11.052 | 30 / 64 / 202 | 6,938 | base; `text-delta:96` | +| Long | 1 | 39.210 s | 2,866 | 73.093 | 30 / 63 / 191 | 182,626 | base; `text-delta:2763`; reasoning start/delta/end `1/92/1` | +| Long | 2 | 41.795 s | 3,161 | 75.632 | 30 / 63 / 192 | 201,056 | base; `text-delta:3069`; reasoning start/delta/end `1/81/1` | +| Long | 3 | 46.149 s | 2,745 | 59.481 | 30 / 63 / 192 | 175,156 | base; `text-delta:2649`; reasoning start/delta/end `1/85/1` | +| Tool-heavy | 1 | 18.074 s | 674 | 37.291 | 30 / 62 / 514 | 58,983 | base; text `387`; reasoning `2/180/2`; tool start/input/output/error `7/80/2/5` | +| Tool-heavy | 2 | 17.956 s | 664 | 36.979 | 30 / 62 / 514 | 50,988 | base; text `361`; reasoning `3/240/3`; tool start/input/output/error `6/36/2/4` | +| Tool-heavy | 3 | 15.360 s | 566 | 36.850 | 30 / 61 / 514 | 39,685 | base; text `366`; reasoning `2/164/2`; tool start/input/output/error `6/11/2/4` | + +Tool input snapshots repeat under one `toolCallId`. The relay must keep that identity so the client +updates one tool preview instead of creating a tool for every snapshot. + +## Envelope + +Every temporary frame uses these fields: + +- `version` (`metadata`): Identifies the compatible envelope version. +- `kind` (`metadata`): Is `frame` for temporary output. Durable records use `event`. +- `session_id` (`identity`): Identifies the conversation. It reuses the current `sessionId` value. +- `execution_id` (`identity`): Identifies one admitted turn. It reuses the current `turnId` value. +- `frame_or_event_id` (`identity`): Identifies this frame for duplicate suppression. +- `frame_index` (`ordering`): Increases by one within an execution. It is not a durable replay cursor. +- `type` (`payload`): Reuses the current invoke event type without renaming it. +- `entity_id` (`identity`): Reuses `id`, `toolCallId`, or `messageId`. Execution-level frames use `execution_id`. +- `payload` (`payload`): Carries the current event-specific fields with their existing names. +- `created_at` (`metadata`): Records when the producer created the frame in UTC. + +The producer assigns `frame_index` before ingress. `frame_or_event_id` is stable for that index on +a retry. Redis Stream IDs order storage operations only. Clients order frames by +`(execution_id, frame_index)` and use `entity_id` to update previews. + +## Existing invoke vocabulary + +The envelope wraps the current invoke projection. It does not define a second content protocol. + +| Current event family | Existing names and fields to retain | +|---|---| +| Stream lifecycle | `start.messageId`, `start.messageMetadata.sessionId`, `start-step`, `finish-step`, `finish.finishReason`, `finish.messageMetadata.traceId`, `finish.messageMetadata.usage` | +| Execution correlation | `message-metadata.messageMetadata.turnId` | +| Text and reasoning | `text-start`, `text-delta.delta`, `text-end`, `reasoning-start`, `reasoning-delta.delta`, `reasoning-end`; all reuse `id` | +| Tools | `tool-input-start`, `tool-input-available`, `tool-output-available`, `tool-output-error`, and `tool-output-denied`; all reuse `toolCallId` and existing input or output fields | +| Other content | `data-*`, `file`, `error`, and the measured `data-agent-status` frames | + +The current `/sessions/streams/watch` SSE is not a content source. It sends `ready`, +`records-changed`, `lifecycle`, `interaction`, and heartbeat notifications. The new relay carries +the invoke frames above and can keep the existing watch notifications separate. + +## Redis transport and retention + +The records ingest HTTP endpoint accepts both temporary frames and durable records. It publishes +frames to the dedicated `streams:session-live-frames` Redis Stream and leaves durable records on +`streams:records`. Both keys use the same durable Redis deployment, but their acknowledgement and +retention policies are independent. + +The live-frame stream applies both limits across the deployment: + +- Maximum age: **15 minutes**. +- Maximum length: **100,000 frames**, trimmed exactly when a frame is appended. + +The highest observed run-average rate was 75.632 frames/s. Fifteen minutes at that rate is +`75.632 * 900 = 68,069` frames. A 100,000-frame cap leaves 47 percent headroom for one run and +represents 22.0 minutes at that rate. It also holds 31.6 times the largest measured run of 3,161 +frames. Concurrent sessions share this disposable capacity. If relay lag crosses either bound, +clients reload the durable snapshot and follow current frames. + +The largest measured run used 201,056 frame bytes. Scaling its 63.6-byte average to 100,000 frames +gives about 6.36 MB of SSE frame bytes. This excludes the envelope and Redis overhead. Each +serialized frame is limited to 64 KiB before it reaches Redis. diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index 855fb1360eb..e90fdd70593 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -518,6 +518,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === STORAGE ============================================== # volumes: - ../../../services/runner/src:/app/src diff --git a/hosting/docker-compose/ee/docker-compose.gh.local.yml b/hosting/docker-compose/ee/docker-compose.gh.local.yml index 52fa32fb570..b1092ce9ea1 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.local.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.local.yml @@ -346,6 +346,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === NETWORK ============================================== # networks: - agenta-ee-gh-network diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index b1d8086e097..7c3bc8bdcc8 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -355,6 +355,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === SUBSCRIPTION MOUNTS (opt-in) ========================= # # Use your own harness subscription for LOCAL runs instead of a managed API # key. Mount the login READ-WRITE: the harness runs directly out of the mount and diff --git a/hosting/docker-compose/ee/env.ee.dev.example b/hosting/docker-compose/ee/env.ee.dev.example index ce26143d5aa..6cb33aa2012 100644 --- a/hosting/docker-compose/ee/env.ee.dev.example +++ b/hosting/docker-compose/ee/env.ee.dev.example @@ -140,6 +140,16 @@ AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local AGENTA_SESSIONS_DURABLE_STOP=true # AGENTA_SESSIONS_LATE_OUTPUT=quarantine +# --- Live session relay (opt-in) --- +# The disposable live-frame stream keeps at most this many frames per deployment. +# AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 +# AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 +# Enable both switches to publish runner frames and advertise the shared reader. +# AGENTA_RUNNER_LIVE_FRAMES=false +# AGENTA_SESSIONS_SHARED_READER=false +# AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 +# AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 + # --- Attachment limits (files attached to an agent chat turn) --- # Per-file caps in bytes, by kind: 10 MB, except audio at 15 MB. Read by the api. # AGENTA_ATTACHMENTS_MAX_IMAGE_BYTES=10485760 diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index 7fd4ed414bd..94398f700c1 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -141,6 +141,16 @@ AGENTA_RUNNER_TOKEN=replace-me # cap (higher-fidelity reconstruction). Still opt-in, default off. # AGENTA_RECORDS_SMART_TRUNCATION=true +# --- Live session relay (opt-in) --- +# The disposable live-frame stream keeps at most this many frames per deployment. +# AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 +# AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 +# Enable both switches to publish runner frames and advertise the shared reader. +# AGENTA_RUNNER_LIVE_FRAMES=false +# AGENTA_SESSIONS_SHARED_READER=false +# AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 +# AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 + # --- Attachment limits (files attached to an agent chat turn) --- # Per-file caps in bytes, by kind: 10 MB, except audio at 15 MB. Read by the api. # AGENTA_ATTACHMENTS_MAX_IMAGE_BYTES=10485760 diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index cc9b9da708d..be93fd29a8c 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -483,6 +483,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === STORAGE ============================================== # volumes: - ../../../services/runner/src:/app/src diff --git a/hosting/docker-compose/oss/docker-compose.gh.local.yml b/hosting/docker-compose/oss/docker-compose.gh.local.yml index 9bea93b297b..f6ab7134c8f 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.local.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.local.yml @@ -342,6 +342,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === NETWORK ============================================== # networks: - agenta-oss-gh-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml index c07ddf88a43..ed34c52f944 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml @@ -368,6 +368,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === NETWORK ============================================== # networks: - agenta-gh-ssl-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index 897730d30d4..952d971bf1d 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -373,6 +373,7 @@ services: # only its last message loses the conversation. AGENTA_SESSIONS_RECONSTRUCT: ${AGENTA_SESSIONS_RECONSTRUCT:-} AGENTA_RECORDS_DURABLE: ${AGENTA_RECORDS_DURABLE:-} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-false} # === SUBSCRIPTION MOUNTS (opt-in) ========================= # # Use your own harness subscription for LOCAL runs instead of a managed API # key. Mount the login READ-WRITE: the harness runs directly out of the mount and diff --git a/hosting/docker-compose/oss/env.oss.dev.example b/hosting/docker-compose/oss/env.oss.dev.example index d863d07bcde..7a84c167870 100644 --- a/hosting/docker-compose/oss/env.oss.dev.example +++ b/hosting/docker-compose/oss/env.oss.dev.example @@ -146,6 +146,16 @@ NEXT_PUBLIC_AGENT_FILE_UPLOADS=true AGENTA_SESSIONS_DURABLE_STOP=true # AGENTA_SESSIONS_LATE_OUTPUT=quarantine +# --- Live session relay (opt-in) --- +# The disposable live-frame stream keeps at most this many frames per deployment. +# AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 +# AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 +# Enable both switches to publish runner frames and advertise the shared reader. +# AGENTA_RUNNER_LIVE_FRAMES=false +# AGENTA_SESSIONS_SHARED_READER=false +# AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 +# AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 + # --- Attachment limits (files attached to an agent chat turn) --- # Per-file caps in bytes, by kind: 10 MB, except audio at 15 MB. Read by the api. # AGENTA_ATTACHMENTS_MAX_IMAGE_BYTES=10485760 diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index e1ab99123e0..dacff6d5b24 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -146,6 +146,16 @@ AGENTA_RUNNER_TOKEN=replace-me # cap (higher-fidelity reconstruction). Still opt-in, default off. # AGENTA_RECORDS_SMART_TRUNCATION=true +# --- Live session relay (opt-in) --- +# The disposable live-frame stream keeps at most this many frames per deployment. +# AGENTA_SESSIONS_LIVE_STREAM_MAXLEN=100000 +# AGENTA_SESSIONS_LIVE_FRAME_MAX_AGE_SECONDS=900 +# Enable both switches to publish runner frames and advertise the shared reader. +# AGENTA_RUNNER_LIVE_FRAMES=false +# AGENTA_SESSIONS_SHARED_READER=false +# AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 +# AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 + # --- Attachment limits (files attached to an agent chat turn) --- # Per-file caps in bytes, by kind: 10 MB, except audio at 15 MB. Read by the api. # AGENTA_ATTACHMENTS_MAX_IMAGE_BYTES=10485760 diff --git a/hosting/kubernetes/helm/templates/runner-deployment.yaml b/hosting/kubernetes/helm/templates/runner-deployment.yaml index 49bc1008a3b..eb56eda8de1 100644 --- a/hosting/kubernetes/helm/templates/runner-deployment.yaml +++ b/hosting/kubernetes/helm/templates/runner-deployment.yaml @@ -86,6 +86,10 @@ spec: - name: AGENTA_RUNNER_LOG_LEVEL value: {{ $runner.logLevel | quote }} {{- end }} + {{- if and (hasKey $runner "liveFrames") (not (hasKey (default dict $runner.env) "AGENTA_RUNNER_LIVE_FRAMES")) }} + - name: AGENTA_RUNNER_LIVE_FRAMES + value: {{ $runner.liveFrames | quote }} + {{- end }} {{- if $daytona.apiUrl }} - name: AGENTA_RUNNER_DAYTONA_API_URL value: {{ $daytona.apiUrl | quote }} diff --git a/hosting/kubernetes/helm/values.schema.json b/hosting/kubernetes/helm/values.schema.json index 91ab2df83cb..4ed369b9773 100644 --- a/hosting/kubernetes/helm/values.schema.json +++ b/hosting/kubernetes/helm/values.schema.json @@ -307,6 +307,7 @@ "externalUrl": { "type": "string", "description": "AGENTA_RUNNER_INTERNAL_URL override pointing at an external runner." }, "piAgentDir": { "type": "string", "description": "PI_CODING_AGENT_DIR for local Pi runs (default /pi-agent); unset means no Agenta extension for the run (the runner logs a warning)." }, "logLevel": { "type": "string", "description": "AGENTA_RUNNER_LOG_LEVEL read by the runner service." }, + "liveFrames": { "type": "boolean", "description": "AGENTA_RUNNER_LIVE_FRAMES; opt-in temporary live-frame publication. Defaults to false." }, "providers": { "type": "object", "additionalProperties": false, diff --git a/hosting/kubernetes/helm/values.yaml b/hosting/kubernetes/helm/values.yaml index 55344705556..64f7e4791e3 100644 --- a/hosting/kubernetes/helm/values.yaml +++ b/hosting/kubernetes/helm/values.yaml @@ -138,6 +138,7 @@ redisDurable: # ================================================================== # # agentRunner: # enabled: true +# liveFrames: false # AGENTA_RUNNER_LIVE_FRAMES; opt in to temporary live-frame relay # providers: # enabled: [local] # AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS (rendered comma-joined) # default: local # AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER (must be one of enabled) diff --git a/hosting/railway/oss/scripts/configure.sh b/hosting/railway/oss/scripts/configure.sh index 4752b371520..8a1e2862604 100755 --- a/hosting/railway/oss/scripts/configure.sh +++ b/hosting/railway/oss/scripts/configure.sh @@ -513,7 +513,8 @@ main() { "AGENTA_RUNNER_DAYTONA_API_URL=${AGENTA_RUNNER_DAYTONA_API_URL:-}" \ "AGENTA_RUNNER_DAYTONA_TARGET=${AGENTA_RUNNER_DAYTONA_TARGET:-}" \ "AGENTA_RUNNER_DAYTONA_SNAPSHOT=${AGENTA_RUNNER_DAYTONA_SNAPSHOT:-}" \ - "AGENTA_RUNNER_DAYTONA_IMAGE=${AGENTA_RUNNER_DAYTONA_IMAGE:-}" + "AGENTA_RUNNER_DAYTONA_IMAGE=${AGENTA_RUNNER_DAYTONA_IMAGE:-}" \ + "AGENTA_RUNNER_LIVE_FRAMES=${AGENTA_RUNNER_LIVE_FRAMES:-}" # Do NOT list the runner's AGENTA_RUNNER_DAYTONA_* vars here: unset_vars always deletes, # which previously wiped a Daytona-configured runner's credentials right after setting them. diff --git a/hosting/railway/oss/template/template.json b/hosting/railway/oss/template/template.json index c3be8a90161..00c3b03de68 100644 --- a/hosting/railway/oss/template/template.json +++ b/hosting/railway/oss/template/template.json @@ -297,7 +297,8 @@ "AGENTA_RUNNER_DAYTONA_API_URL", "AGENTA_RUNNER_DAYTONA_TARGET", "AGENTA_RUNNER_DAYTONA_SNAPSHOT", - "AGENTA_RUNNER_DAYTONA_IMAGE" + "AGENTA_RUNNER_DAYTONA_IMAGE", + "AGENTA_RUNNER_LIVE_FRAMES" ] }, "worker-streams": { diff --git a/sdks/python/agenta/sdk/agents/adapters/local.py b/sdks/python/agenta/sdk/agents/adapters/local.py index 7b150c9232e..7417a93640a 100644 --- a/sdks/python/agenta/sdk/agents/adapters/local.py +++ b/sdks/python/agenta/sdk/agents/adapters/local.py @@ -48,6 +48,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> Session: diff --git a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py index a7f654bda52..f4429357f6d 100644 --- a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py +++ b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py @@ -68,6 +68,7 @@ def __init__( trace: Optional[TraceContext], run_context: Optional[RunContext], session_id: Optional[str], + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> None: @@ -78,6 +79,7 @@ def __init__( self._trace = trace self._run_context = run_context self._session_id = session_id + self._detached = detached self._effective_parameters = effective_parameters self._gateway_policy = gateway_policy @@ -95,6 +97,7 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: trace=self._trace, run_context=self._run_context, session_id=self._session_id, + detached=self._detached, effective_parameters=self._effective_parameters, gateway_policy=self._gateway_policy, ) @@ -168,6 +171,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> SandboxAgentSession: @@ -183,6 +187,7 @@ async def create_session( trace=trace, run_context=run_context, session_id=session_id, + detached=detached, effective_parameters=effective_parameters, gateway_policy=gateway_policy, ) diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index 950f83b4fb0..88e32459c4c 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -1168,6 +1168,8 @@ class SessionConfig(BaseModel): # wire when unset, so a run that needs no binding is byte-identical to before. run_context: Optional[RunContext] = None session_id: Optional[str] = None + # Explicit per-invoke ownership handoff. False preserves request-owned cancellation. + detached: bool = False # The post-hydration config this turn runs, carried verbatim so the runner can stamp it on # the interaction row of any HITL gate the turn parks (see # ``agents/utils/effective_config.py``). Wire-emitted only for a session run; never consumed diff --git a/sdks/python/agenta/sdk/agents/handler.py b/sdks/python/agenta/sdk/agents/handler.py index 82d8610c87d..ebd467640ce 100644 --- a/sdks/python/agenta/sdk/agents/handler.py +++ b/sdks/python/agenta/sdk/agents/handler.py @@ -311,6 +311,7 @@ async def _agent( trace=comp.trace_context(), run_context=rc, session_id=session_id, + detached=bool(flags.detached), # POST-hydration: the normalizer hands the handler `request.data.parameters` AFTER # the resolver has hydrated references (or kept the caller's inline config), so this # is the config the turn actually runs — the thing a HITL gate must be resumable diff --git a/sdks/python/agenta/sdk/agents/interfaces.py b/sdks/python/agenta/sdk/agents/interfaces.py index 3be298c5ce3..6e6ed92a83c 100644 --- a/sdks/python/agenta/sdk/agents/interfaces.py +++ b/sdks/python/agenta/sdk/agents/interfaces.py @@ -130,6 +130,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> Session: @@ -201,6 +202,7 @@ async def create_session( trace=session_config.trace, run_context=session_config.run_context, session_id=session_config.session_id, + detached=session_config.detached, effective_parameters=session_config.effective_parameters, gateway_policy=session_config.gateway_policy, ) diff --git a/sdks/python/agenta/sdk/agents/utils/ts_runner.py b/sdks/python/agenta/sdk/agents/utils/ts_runner.py index 292ebb99f96..7729cdd71a8 100644 --- a/sdks/python/agenta/sdk/agents/utils/ts_runner.py +++ b/sdks/python/agenta/sdk/agents/utils/ts_runner.py @@ -180,8 +180,9 @@ async def deliver_http_stream( ) -> AsyncIterator[Dict[str, Any]]: """POST ``/run`` asking for NDJSON and yield each parsed record as it arrives. - The ``async with`` closes the connection when the generator is closed or cancelled, which - the runner observes as a client disconnect and turns into run cancellation. + The ``async with`` closes the connection when the generator is closed or cancelled. The + runner turns that disconnect into cancellation for request-owned runs, while an explicitly + detached session run continues under session ownership. """ import httpx # local import: only the HTTP transport needs it diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index 77e495171b4..b1293a64ab4 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -93,6 +93,7 @@ def request_to_wire( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + detached: bool = False, turn_id: Optional[str] = None, project_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, @@ -172,6 +173,8 @@ def request_to_wire( payload["runContext"] = run_context_wire if turn_id is not None: payload["turnId"] = turn_id + if detached and session_id: + payload["detached"] = True if project_id is not None: payload["projectId"] = project_id if session_id: diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index a6acc809877..4a68139afb2 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -517,6 +517,7 @@ class WireRunRequest(_WireModel): harness: Optional[str] = None sandbox: Optional[str] = None session_id: Optional[str] = Field(default=None, alias="sessionId") + detached: Optional[bool] = None # Session-owned (detached) turn identity: the runner uses these to own the alive lock and # persist the transcript independently of any client connection. Omitted on ad-hoc runs. turn_id: Optional[str] = Field(default=None, alias="turnId") diff --git a/sdks/python/agenta/sdk/models/workflows.py b/sdks/python/agenta/sdk/models/workflows.py index c4841ab9c58..9fbb1772ec5 100644 --- a/sdks/python/agenta/sdk/models/workflows.py +++ b/sdks/python/agenta/sdk/models/workflows.py @@ -136,6 +136,8 @@ class WorkflowInvokeRequestFlags(BaseModel): trim: Optional[bool] = None force: Optional[bool] = None resolve: Optional[bool] = None + # A shared-event sender may close invoke after acceptance without owning the turn lifetime. + detached: Optional[bool] = None class WorkflowRevisionData(BaseModel): diff --git a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py index d83f5d69778..dcede9cf0cd 100644 --- a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py +++ b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py @@ -58,6 +58,7 @@ def __init__( trace: Optional[TraceContext], run_context: Optional[RunContext], session_id: Optional[str], + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> None: @@ -67,6 +68,7 @@ def __init__( self._trace = trace self._run_context = run_context self._session_id = session_id + self._detached = detached self._effective_parameters = effective_parameters self._gateway_policy = gateway_policy @@ -84,6 +86,7 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: trace=self._trace, run_context=self._run_context, session_id=self._session_id, + detached=self._detached, effective_parameters=self._effective_parameters, gateway_policy=self._gateway_policy, ) @@ -161,6 +164,7 @@ async def create_session( trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, + detached: bool = False, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> FakeRunnerSession: @@ -171,6 +175,7 @@ async def create_session( trace=trace, run_context=run_context, session_id=session_id, + detached=detached, effective_parameters=effective_parameters, gateway_policy=gateway_policy, ) diff --git a/sdks/python/oss/tests/pytest/unit/agents/conftest.py b/sdks/python/oss/tests/pytest/unit/agents/conftest.py index 22fb54a7e37..d2eab8fd4bf 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/conftest.py +++ b/sdks/python/oss/tests/pytest/unit/agents/conftest.py @@ -145,6 +145,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, effective_parameters=None, gateway_policy=None, ) -> FakeSession: @@ -157,6 +158,7 @@ async def create_session( "trace": trace, "run_context": run_context, "session_id": session_id, + "detached": detached, "effective_parameters": effective_parameters, "gateway_policy": gateway_policy, } diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py index d8171e326c0..5412f0d68ed 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py @@ -95,6 +95,7 @@ def __init__(self, *, output: str = "hi") -> None: self.created_run_contexts: List[Any] = [] self.created_effective_parameters: List[Any] = [] self.created_gateway_policies: List[Any] = [] + self.created_detached: List[bool] = [] # The per-harness config the adapter built. Capturing it alongside neutral backend # arguments checks both sides of the composition boundary rather than one hop. self.created_configs: List[Any] = [] @@ -112,12 +113,14 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: self.created_run_contexts.append(run_context) self.created_effective_parameters.append(effective_parameters) self.created_gateway_policies.append(gateway_policy) + self.created_detached.append(detached) self.created_configs.append(config) return _FakeSession(AgentResult(output=self._output, events=[], usage={})) @@ -143,6 +146,30 @@ def _params(harness="pi_core", *, model=None): return {"agent": template} +@pytest.mark.parametrize( + "flag, expected", [(True, True), (False, False), (None, False)] +) +async def test_invoke_detached_flag_reaches_the_backend_only_when_enabled( + flag, expected +): + backend = _FakeBackend() + handler = make_agent_handler( + AgentComposition( + select_backend=lambda template: backend, + resolve_connection=_no_connection, + ) + ) + flags = {} if flag is None else {"detached": flag} + + await handler( + request=WorkflowServiceRequest(flags=flags, session_id="session-1"), + messages=[{"role": "user", "content": "hi"}], + parameters=_params(), + ) + + assert backend.created_detached == [expected] + + # --------------------------------------------------------------------------- # # Drift 4: run_kind from `request.meta` must reach RunContext (not silently dropped) # --------------------------------------------------------------------------- # diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py b/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py index 1656fa71dd7..bd9978ab54e 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py @@ -97,6 +97,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, # Interface parity only; these tests assert on the redaction scope, not the wire. effective_parameters=None, gateway_policy=None, diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index 9c59aa0df85..0dedfcea60d 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py @@ -100,6 +100,7 @@ "sandboxPermission", "harnessFiles", "turnId", + "detached", "projectId", "effectiveParameters", } @@ -606,6 +607,27 @@ def test_request_to_wire_omits_turn_id_when_none(): assert "turnId" not in payload +def test_request_to_wire_carries_detached_only_for_a_session(): + detached = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(model="openai/gpt-5"), + messages=[], + session_id="sess-1", + detached=True, + ) + ad_hoc = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(model="openai/gpt-5"), + messages=[], + detached=True, + ) + + assert detached["detached"] is True + assert "detached" not in ad_hoc + + def test_request_to_wire_carries_project_id_when_set(): payload = request_to_wire( harness=HarnessKind.PI, diff --git a/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py b/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py index b5562e95a05..a5f7e22b4a3 100644 --- a/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py +++ b/sdks/python/oss/tests/pytest/unit/test_batch_fold_stream_contract_routing.py @@ -140,6 +140,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: diff --git a/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py b/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py index c177237ade8..edee6ae64ff 100644 --- a/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py +++ b/sdks/python/oss/tests/pytest/unit/test_invoke_real_handlers_negotiation_routing.py @@ -144,6 +144,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: diff --git a/sdks/python/oss/tests/pytest/unit/test_workflow_request_flags_running.py b/sdks/python/oss/tests/pytest/unit/test_workflow_request_flags_running.py index 5fe4a6c01f0..ddb3781db3a 100644 --- a/sdks/python/oss/tests/pytest/unit/test_workflow_request_flags_running.py +++ b/sdks/python/oss/tests/pytest/unit/test_workflow_request_flags_running.py @@ -51,7 +51,7 @@ def test_invoke_request_flags_dict_parses_via_accessor(): # `format` is HTTP-only, not a running-level flag (see test_workflow_format_routing.py) def test_format_is_not_a_request_flag(): - """format is http-only; the command flags are stream/trim/force/resolve.""" + """format is HTTP-only; detached is a running-level command flag.""" from agenta.sdk.models.workflows import WorkflowInvokeRequestFlags assert "format" not in WorkflowInvokeRequestFlags.model_fields @@ -60,4 +60,5 @@ def test_format_is_not_a_request_flag(): "trim", "force", "resolve", + "detached", } diff --git a/services/oss/tests/pytest/unit/agent/conftest.py b/services/oss/tests/pytest/unit/agent/conftest.py index 18309dd1366..bab173b7d82 100644 --- a/services/oss/tests/pytest/unit/agent/conftest.py +++ b/services/oss/tests/pytest/unit/agent/conftest.py @@ -101,6 +101,7 @@ def __init__( self.created_session_ids: list[Optional[str]] = [] self.created_secrets: list[Optional[Mapping[str, str]]] = [] self.created_run_contexts: list = [] + self.created_detached: list = [] async def setup(self) -> None: self.setup_calls += 1 @@ -121,6 +122,7 @@ async def create_session( trace=None, run_context=None, session_id=None, + detached=False, # Interface parity: the SDK passes this through on every session run. These tests # assert on the config and run context, not on the stamped parameters. effective_parameters=None, @@ -130,6 +132,7 @@ async def create_session( self.created_session_ids.append(session_id) self.created_secrets.append(secrets) self.created_run_contexts.append(run_context) + self.created_detached.append(detached) return _FakeSession(self._result) diff --git a/services/oss/tests/pytest/unit/agent/test_invoke_handler.py b/services/oss/tests/pytest/unit/agent/test_invoke_handler.py index bd9fafcbf7d..e7532a61405 100644 --- a/services/oss/tests/pytest/unit/agent/test_invoke_handler.py +++ b/services/oss/tests/pytest/unit/agent/test_invoke_handler.py @@ -44,15 +44,19 @@ def _first_allowed_provider(harness): return HARNESS_CONNECTION_CAPABILITIES[harness].providers[0] -def _request(*, stream=None, session_id=None): +def _request(*, stream=None, session_id=None, detached=None): """Build the request `_agent` reads stream/session_id off of. `_agent` now sources the stream decision from `request.flags.stream` and the session id from `request.session_id` (both set at the route/normalizer edge), instead of receiving them as handler params. """ - flags = {"stream": stream} if stream is not None else None - return WorkflowServiceRequest(flags=flags, session_id=session_id) + flags = {} + if stream is not None: + flags["stream"] = stream + if detached is not None: + flags["detached"] = detached + return WorkflowServiceRequest(flags=flags or None, session_id=session_id) def _patch_handler(monkeypatch, backend, *, tool_specs=(), tool_callback=None): @@ -247,6 +251,24 @@ async def test_messages_session_id_reaches_session_config(patched): ) assert backend.created_session_ids == ["sess_request"] + assert backend.created_detached == [False] + + +async def test_detached_flag_reaches_the_backend_session(patched): + """The shared-delivery flag rides the same edge as the session id. + + Nothing else in the service asserts it at this boundary, so a handler that stopped + forwarding `flags.detached` would still pass every other invoke test. + """ + backend, _ = patched + + await app._agent( + request=_request(session_id="sess_detached", detached=True), + messages=[{"role": "user", "content": "hi"}], + parameters={"agent": {"harness": {"kind": "pi_core"}}}, + ) + + assert backend.created_detached == [True] async def test_invoke_cross_harness_same_body_divergent_configs( diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index 53d4e78d04a..027a326041f 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -769,6 +769,8 @@ export interface AgentRunRequest { * non-session runs. A session sees a sequence of turnIds (send/steer each start a new one). */ turnId?: string; + /** True only when the shared event route, rather than this HTTP response, owns delivery. */ + detached?: boolean; /** * The Agenta project id for this run. Set alongside `turnId` on session-owned runs so * the runner can include it in heartbeat and record-ingest calls. Absent otherwise. diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 794548d600a..124eb73a034 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -438,7 +438,7 @@ function inBandAnswerTokens(request: AgentRunRequest): string[] | undefined { * exactly one terminal `{kind:"result"}` line (success or failure). Selected by the caller * with `Accept: application/x-ndjson`; the one-shot `/run` path is left untouched. * - * For session-owned runs (a sessionId is present; the turnId is runner-minted): + * For session-owned runs (including explicitly detached shared-sender runs): * - the run survives client disconnect (abort is NOT wired to the response close event); * - every event is persisted producer-side via the record ingest endpoint; * - an alive-lock watchdog heartbeats the coordination plane for the run's lifetime. @@ -473,6 +473,7 @@ async function runAndStreamWithApiBaseResolved( }); const sessionOwned = isSessionOwned(request); + const detached = sessionOwned && request.detached === true; const sessionId = request.sessionId!; const turnId = resolveTurnId(request); // Write the resolved id back: every downstream reader of `request.turnId` (the turns-ledger @@ -492,8 +493,8 @@ async function runAndStreamWithApiBaseResolved( `[sessions] stream sessionOwned=${sessionOwned} sessionId=${sessionId ?? "-"} turnId=${turnId ?? "-"} cred=${credentialState}\n`, ); - // Session-owned runs survive client disconnect — the runner owns the run. Non-session - // runs abort on disconnect (original behavior: caller drives, disconnect = cancel). + // Session-owned runs survive client disconnect; detached additionally selects shared response. + // Non-session runs remain request-owned: closing invoke aborts the turn. const controller = new AbortController(); let clientDisconnected = false; // Resolves when the platform tells us this turn is no longer current — a Stop, a takeover, @@ -503,7 +504,7 @@ async function runAndStreamWithApiBaseResolved( const interrupted = new Promise((resolve) => { markInterrupted = resolve; }); - if (!sessionOwned) { + if (!sessionOwned && !detached) { // Listen on the response, not the request: the request body is already fully read, so // its `close` can fire early on a keep-alive connection. `res` `close` fires when the // response connection ends — after a normal `res.end()` (harmless: the run is already @@ -525,6 +526,21 @@ async function runAndStreamWithApiBaseResolved( res.write(JSON.stringify(record) + "\n"); }; const liveEmit: EmitEvent = (event) => writeRecord({ kind: "event", event }); + // The invoke stream's sole positive payload in shared mode: correlation/acceptance. Live text + // and tools arrive through /sessions/{id}/events and are filtered from invoke client-side. + // + // Emitted only from the admission path below, never here: it switches the client to shared + // delivery, and a turn the runner is about to refuse (bad attachments, a competing turn already + // holding the session) must not move the client off its local stream first. + const emitSessionAccepted = () => { + if (!detached) return; + liveEmit({ + type: "data", + name: "session-accepted", + data: { sessionId, turnId, executionId: turnId }, + transient: true, + }); + }; const turn = currentUserTurn(request); const attachmentError = attachmentCountError(turn.attachments.length); if (attachmentError) { @@ -657,6 +673,10 @@ async function runAndStreamWithApiBaseResolved( // // Deliberately on `liveEmit`, not the persisting emitter that replaces it below: this is // transport correlation, not conversation, and it must never become a session record. + // + // Acceptance rides the same frame for the same reason, and lands here rather than at the + // top of the request so it can never precede the admission verdict. + emitSessionAccepted(); liveEmit({ type: "turn", turnId }); // A new turn supersedes any prior turn's unanswered gate: cancel stale pending diff --git a/services/runner/src/sessions/live-frames.ts b/services/runner/src/sessions/live-frames.ts new file mode 100644 index 00000000000..e2a29b5edc3 --- /dev/null +++ b/services/runner/src/sessions/live-frames.ts @@ -0,0 +1,346 @@ +import type { AgentEvent } from "../protocol.ts"; +import { apiBase } from "../apiBase.ts"; + +export const LIVE_FRAMES_ENV = "AGENTA_RUNNER_LIVE_FRAMES"; +export const LIVE_FRAME_BUFFER_CAPACITY = 256; +export const LIVE_FRAME_FLUSH_INTERVAL_MS = 150; +export const LIVE_FRAME_BATCH_CAPACITY = 50; +export const LIVE_FRAME_BATCH_MAX_BYTES = 64 * 1024; +// A stalled ingest POST would keep `pump()` pending, and `flush()` waits on `whenIdle()` — so an +// unbounded post delays turn completion. A timed-out batch counts as dropped, like any other +// send failure. +export const LIVE_FRAME_POST_TIMEOUT_MS = 5_000; + +export interface LiveFrameEnvelope { + version: 1; + kind: "frame"; + session_id: string; + execution_id: string; + frame_or_event_id: string; + frame_index: number; + entity_id: string; + type: string; + payload: Record; + created_at: string; +} + +interface ProjectedFrame { + entityId: string; + type: string; + payload: Record; +} + +interface QueuedFrame { + frame: LiveFrameEnvelope; + bytes: number; +} + +interface LiveFramePublisherOptions { + sessionId: string; + executionId: string; + auth: () => string; + enabled?: boolean; + capacity?: number; + flushIntervalMs?: number; + batchCapacity?: number; + maxBatchBytes?: number; + send?: (frames: LiveFrameEnvelope[]) => Promise; + postTimeoutMs?: number; + now?: () => string; + log?: (message: string) => void; +} + +function envEnabled(): boolean { + return ["1", "true", "yes", "on"].includes( + String(process.env[LIVE_FRAMES_ENV] ?? "") + .trim() + .toLowerCase(), + ); +} + +async function postFrames( + auth: () => string, + frames: LiveFrameEnvelope[], + timeoutMs: number = LIVE_FRAME_POST_TIMEOUT_MS, +): Promise { + const response = await fetch(`${apiBase()}/sessions/records/ingest`, { + method: "POST", + signal: AbortSignal.timeout(timeoutMs), + headers: { + "content-type": "application/json", + authorization: auth(), + }, + body: JSON.stringify(frames), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } +} + +function projectEvent( + event: AgentEvent, + seenToolCalls: Set, +): ProjectedFrame[] { + switch (event.type) { + case "message_start": + return [{ entityId: event.id, type: "text-start", payload: { id: event.id } }]; + case "message_delta": + return [ + { + entityId: event.id, + type: "text-delta", + payload: { id: event.id, delta: event.delta }, + }, + ]; + case "message_end": + return [{ entityId: event.id, type: "text-end", payload: { id: event.id } }]; + case "thought_start": + return [ + { entityId: event.id, type: "reasoning-start", payload: { id: event.id } }, + ]; + case "thought_delta": + return [ + { + entityId: event.id, + type: "reasoning-delta", + payload: { id: event.id, delta: event.delta }, + }, + ]; + case "thought_end": + return [ + { entityId: event.id, type: "reasoning-end", payload: { id: event.id } }, + ]; + case "tool_call": { + if (!event.id) return []; + const payload = { + toolCallId: event.id, + toolName: event.name, + input: event.input ?? {}, + }; + const input = { + entityId: event.id, + type: "tool-input-available", + payload, + }; + if (seenToolCalls.has(event.id)) return [input]; + seenToolCalls.add(event.id); + return [ + { + entityId: event.id, + type: "tool-input-start", + payload: { toolCallId: event.id, toolName: event.name }, + }, + input, + ]; + } + case "tool_result": { + if (!event.id || !seenToolCalls.has(event.id)) return []; + if (event.denied) { + return [ + { + entityId: event.id, + type: "tool-output-denied", + payload: { toolCallId: event.id }, + }, + ]; + } + if (event.isError) { + return [ + { + entityId: event.id, + type: "tool-output-error", + payload: { toolCallId: event.id, errorText: event.output ?? "" }, + }, + ]; + } + return [ + { + entityId: event.id, + type: "tool-output-available", + payload: { + toolCallId: event.id, + output: event.data ?? event.output, + }, + }, + ]; + } + default: + return []; + } +} + +export class LiveFramePublisher { + private readonly enabled: boolean; + private readonly capacity: number; + private readonly flushIntervalMs: number; + private readonly batchCapacity: number; + private readonly maxBatchBytes: number; + private readonly send: (frames: LiveFrameEnvelope[]) => Promise; + private readonly now: () => string; + private readonly log: (message: string) => void; + private readonly sessionId: string; + private readonly executionId: string; + private readonly queue: QueuedFrame[] = []; + private readonly seenToolCalls = new Set(); + private frameIndex = 0; + private dropped = 0; + private queuedPayloadBytes = 0; + private pumping = false; + private flushRequested = false; + private flushTimer: NodeJS.Timeout | null = null; + private idleWaiters: Array<() => void> = []; + + constructor(options: LiveFramePublisherOptions) { + this.enabled = options.enabled ?? envEnabled(); + this.capacity = Math.max(1, options.capacity ?? LIVE_FRAME_BUFFER_CAPACITY); + this.flushIntervalMs = Math.max( + 0, + options.flushIntervalMs ?? LIVE_FRAME_FLUSH_INTERVAL_MS, + ); + this.batchCapacity = Math.max( + 1, + options.batchCapacity ?? LIVE_FRAME_BATCH_CAPACITY, + ); + this.maxBatchBytes = Math.max( + 1, + options.maxBatchBytes ?? LIVE_FRAME_BATCH_MAX_BYTES, + ); + this.sessionId = options.sessionId; + this.executionId = options.executionId; + const postTimeoutMs = Math.max( + 1, + options.postTimeoutMs ?? LIVE_FRAME_POST_TIMEOUT_MS, + ); + this.send = + options.send ?? + ((frames) => postFrames(options.auth, frames, postTimeoutMs)); + this.now = options.now ?? (() => new Date().toISOString()); + this.log = + options.log ?? + ((message) => process.stderr.write(`[sessions/live-frames] ${message}\n`)); + } + + emit(event: AgentEvent): void { + if (!this.enabled) return; + for (const projected of projectEvent(event, this.seenToolCalls)) { + const index = this.frameIndex++; + const frame: LiveFrameEnvelope = { + version: 1, + kind: "frame", + session_id: this.sessionId, + execution_id: this.executionId, + frame_or_event_id: `${this.executionId}:${index}`, + frame_index: index, + entity_id: projected.entityId, + type: projected.type, + payload: projected.payload, + created_at: this.now(), + }; + if (this.queue.length >= this.capacity) { + this.dropped += 1; + continue; + } + const bytes = Buffer.byteLength(JSON.stringify(frame), "utf8"); + this.queue.push({ frame, bytes }); + this.queuedPayloadBytes += bytes; + } + if (this.shouldFlushImmediately()) { + this.startPump(false); + } else { + this.scheduleFlush(); + } + } + + reportDrops(): number { + const dropped = this.dropped; + if (dropped > 0) { + this.log( + `DROPPED session=${this.sessionId} execution=${this.executionId} count=${dropped}`, + ); + this.dropped = 0; + } + return dropped; + } + + async whenIdle(): Promise { + if (!this.pumping && this.queue.length === 0) return; + this.startPump(true); + await new Promise((resolve) => this.idleWaiters.push(resolve)); + } + + private serializedQueueBytes(): number { + if (this.queue.length === 0) return 2; + return this.queuedPayloadBytes + this.queue.length + 1; + } + + private shouldFlushImmediately(): boolean { + return ( + this.queue.length >= this.batchCapacity || + this.serializedQueueBytes() >= this.maxBatchBytes + ); + } + + private scheduleFlush(): void { + if (this.pumping || this.flushTimer || this.queue.length === 0) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + this.startPump(false); + }, this.flushIntervalMs); + this.flushTimer.unref?.(); + } + + private startPump(forceDrain: boolean): void { + if (forceDrain) this.flushRequested = true; + if (this.pumping || this.queue.length === 0) return; + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + this.pumping = true; + void this.pump(); + } + + private takeBatch(): LiveFrameEnvelope[] { + let count = 0; + let bytes = 2; + for (const queued of this.queue) { + if (count >= this.batchCapacity) break; + const additional = queued.bytes + (count > 0 ? 1 : 0); + if (count > 0 && bytes + additional > this.maxBatchBytes) break; + bytes += additional; + count += 1; + } + + const queued = this.queue.splice(0, count); + for (const item of queued) this.queuedPayloadBytes -= item.bytes; + return queued.map((item) => item.frame); + } + + private async pump(): Promise { + let sendFirstBatch = true; + while ( + this.queue.length > 0 && + (sendFirstBatch || this.flushRequested || this.shouldFlushImmediately()) + ) { + sendFirstBatch = false; + const frames = this.takeBatch(); + try { + await this.send(frames); + } catch { + this.dropped += frames.length; + } + } + this.pumping = false; + if (this.queue.length > 0) { + if (this.flushRequested) { + this.startPump(true); + } else { + this.scheduleFlush(); + } + return; + } + this.flushRequested = false; + const waiters = this.idleWaiters.splice(0); + for (const resolve of waiters) resolve(); + } +} diff --git a/services/runner/src/sessions/persist.ts b/services/runner/src/sessions/persist.ts index 56ac40dc246..885970c2c32 100644 --- a/services/runner/src/sessions/persist.ts +++ b/services/runner/src/sessions/persist.ts @@ -28,6 +28,7 @@ import { envInt, envTimerMs } from "../env.ts"; import type { AgentEvent } from "../protocol.ts"; import type { Redactor } from "../redaction.ts"; import { stableRecordId } from "./record-id.ts"; +import { LiveFramePublisher } from "./live-frames.ts"; const INGEST_MAX_RETRIES = 3; const INGEST_RETRY_BASE_MS = 100; @@ -262,6 +263,9 @@ export function buildPersistingEmitter( flush: () => Promise; } { let eventIndex = 0; + const liveFrames = turnId + ? new LiveFramePublisher({ sessionId, executionId: turnId, auth }) + : null; // Coalescing state: accumulate delta families into a single durable event. const coalescedMessages = new Map(); @@ -296,6 +300,7 @@ export function buildPersistingEmitter( const emit = (event: AgentEvent): void => { // Always forward to the live stream (if any). liveEmit?.(event); + liveFrames?.emit(event); // Transient data describes the current live turn. It must not become transcript history. if (event.type === "data" && event.transient) return; @@ -451,6 +456,8 @@ export function buildPersistingEmitter( `WARN session=${sessionId} durable log incomplete: ${dropped} record(s) dropped this turn; reconstruction may lack context`, ); } + await liveFrames?.whenIdle(); + liveFrames?.reportDrops(); }; return { emit, persist, flush }; diff --git a/services/runner/tests/unit/live-frames.test.ts b/services/runner/tests/unit/live-frames.test.ts new file mode 100644 index 00000000000..ac80600979b --- /dev/null +++ b/services/runner/tests/unit/live-frames.test.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it, vi } from "vitest"; + +import { + LiveFramePublisher, + type LiveFrameEnvelope, +} from "../../src/sessions/live-frames.ts"; + +describe("LiveFramePublisher", () => { + afterEach(() => { + vi.useRealTimers(); + delete process.env.AGENTA_RUNNER_LIVE_FRAMES; + }); + + it("assigns a monotonic frame index across projected progress", async () => { + const frames: LiveFrameEnvelope[] = []; + const publisher = new LiveFramePublisher({ + sessionId: "session-1", + executionId: "execution-1", + auth: () => "Secret test", + enabled: true, + now: () => "2026-09-04T00:00:00.000Z", + send: async (batch) => { + frames.push(...batch); + }, + }); + + publisher.emit({ type: "message_start", id: "message-1" }); + publisher.emit({ type: "message_delta", id: "message-1", delta: "hi" }); + publisher.emit({ type: "tool_call", id: "tool-1", name: "read", input: {} }); + publisher.emit({ + type: "tool_call", + id: "tool-1", + name: "read", + input: { path: "README.md" }, + }); + publisher.emit({ type: "tool_result", id: "tool-1", output: "ok" }); + await publisher.whenIdle(); + + assert.deepEqual( + frames.map((frame) => [frame.frame_index, frame.type, frame.entity_id]), + [ + [0, "text-start", "message-1"], + [1, "text-delta", "message-1"], + [2, "tool-input-start", "tool-1"], + [3, "tool-input-available", "tool-1"], + [4, "tool-input-available", "tool-1"], + [5, "tool-output-available", "tool-1"], + ], + ); + assert.deepEqual( + frames.map((frame) => frame.frame_or_event_id), + [ + "execution-1:0", + "execution-1:1", + "execution-1:2", + "execution-1:3", + "execution-1:4", + "execution-1:5", + ], + ); + }); + + it("drops beyond the bounded queue and logs identifiers only", async () => { + let releaseFirst: (() => void) | undefined; + const firstSend = new Promise((resolve) => { + releaseFirst = resolve; + }); + const logs: string[] = []; + let sends = 0; + const publisher = new LiveFramePublisher({ + sessionId: "session-drop", + executionId: "execution-drop", + auth: () => "Secret test", + enabled: true, + capacity: 1, + flushIntervalMs: 0, + batchCapacity: 1, + send: async () => { + sends += 1; + if (sends === 1) await firstSend; + }, + log: (message) => logs.push(message), + }); + + publisher.emit({ type: "message_delta", id: "message-1", delta: "secret-a" }); + publisher.emit({ type: "message_delta", id: "message-1", delta: "secret-b" }); + publisher.emit({ type: "message_delta", id: "message-1", delta: "secret-c" }); + releaseFirst?.(); + await publisher.whenIdle(); + + assert.equal(sends, 2); + assert.equal(publisher.reportDrops(), 1); + assert.deepEqual(logs, [ + "DROPPED session=session-drop execution=execution-drop count=1", + ]); + assert.ok(!logs[0].includes("secret")); + }); + + it("coalesces a 1,000-chunk stream into tens of ordered calls", async () => { + const calls: LiveFrameEnvelope[][] = []; + const publisher = new LiveFramePublisher({ + sessionId: "session-batch", + executionId: "execution-batch", + auth: () => "Secret test", + enabled: true, + send: async (batch) => { + calls.push(batch); + }, + }); + + for (let index = 0; index < 1_000; index += 1) { + publisher.emit({ + type: "message_delta", + id: "message-1", + delta: `chunk-${index}`, + }); + if ((index + 1) % 50 === 0) await Promise.resolve(); + } + await publisher.whenIdle(); + + const frames = calls.flat(); + assert.equal(calls.length, 20); + assert.equal(frames.length, 1_000); + assert.deepEqual( + frames.map((frame) => frame.frame_index), + Array.from({ length: 1_000 }, (_, index) => index), + ); + assert.equal(publisher.reportDrops(), 0); + }); + + it("flushes on the byte bound without reordering envelopes", async () => { + const calls: LiveFrameEnvelope[][] = []; + const publisher = new LiveFramePublisher({ + sessionId: "session-bytes", + executionId: "execution-bytes", + auth: () => "Secret test", + enabled: true, + batchCapacity: 50, + maxBatchBytes: 600, + send: async (batch) => { + calls.push(batch); + }, + }); + + publisher.emit({ + type: "message_delta", + id: "message-1", + delta: "a".repeat(100), + }); + publisher.emit({ + type: "message_delta", + id: "message-1", + delta: "b".repeat(100), + }); + await publisher.whenIdle(); + + assert.equal(calls.length, 2); + assert.deepEqual( + calls.flat().map((frame) => frame.frame_index), + [0, 1], + ); + }); + + it("sends no live frames when the feature flag is off", async () => { + process.env.AGENTA_RUNNER_LIVE_FRAMES = "false"; + let calls = 0; + const publisher = new LiveFramePublisher({ + sessionId: "session-off", + executionId: "execution-off", + auth: () => "Secret test", + send: async () => { + calls += 1; + }, + }); + + publisher.emit({ type: "message_start", id: "message-1" }); + publisher.emit({ type: "message_delta", id: "message-1", delta: "secret" }); + publisher.emit({ type: "message_end", id: "message-1" }); + await publisher.whenIdle(); + + assert.equal(calls, 0); + }); + it("drops a batch whose ingest POST never answers, instead of hanging the turn", async () => { + // `persist.ts` flush() awaits whenIdle(), so an ingest that stalls before response headers + // would hold turn completion open for as long as the socket stayed alive. + const realFetch = globalThis.fetch; + const signals: AbortSignal[] = []; + globalThis.fetch = ((_url: unknown, init?: { signal?: AbortSignal }) => { + const signal = init?.signal; + if (signal) signals.push(signal); + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason)); + }); + }) as typeof fetch; + const logs: string[] = []; + try { + const publisher = new LiveFramePublisher({ + sessionId: "session-stall", + executionId: "execution-stall", + auth: () => "Secret test", + enabled: true, + postTimeoutMs: 25, + log: (message) => logs.push(message), + }); + + publisher.emit({ type: "message_start", id: "message-1" }); + publisher.emit({ type: "message_delta", id: "message-1", delta: "hi" }); + await publisher.whenIdle(); + + assert.equal(signals.length, 1, "the ingest POST carries an abort signal"); + assert.equal(signals[0].aborted, true, "and the signal fired on the deadline"); + assert.equal( + publisher.reportDrops(), + 2, + "a timed-out batch counts as dropped, like any other send failure", + ); + assert.ok(logs.some((line) => line.startsWith("DROPPED "))); + } finally { + globalThis.fetch = realFetch; + } + }); +}); diff --git a/services/runner/tests/unit/server.test.ts b/services/runner/tests/unit/server.test.ts index 281cba9b69c..0437d7b096a 100644 --- a/services/runner/tests/unit/server.test.ts +++ b/services/runner/tests/unit/server.test.ts @@ -827,6 +827,106 @@ describe("createAgentServer", () => { await s.close(); } }); + for (const testCase of [ + { name: "plain", sessionOwned: false, detached: false, aborts: true }, + { name: "session-owned", sessionOwned: true, detached: false, aborts: false }, + { name: "detached", sessionOwned: true, detached: true, aborts: false }, + ]) { + it(`a dropped ${testCase.name} invoke ${testCase.aborts ? "cancels" : "does not cancel"} the turn`, async () => { + vi.stubEnv("AGENTA_API_INTERNAL_URL", "http://api:8000"); + let releaseRun: (() => void) | undefined; + let observedAbort = false; + let completed = false; + let markStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const run: RunAgent = async (_request, _emit, signal) => { + markStarted?.(); + return await new Promise((resolve) => { + const finish = () => { + if (completed) return; + completed = true; + resolve({ ok: true, output: "done", events: [] }); + }; + releaseRun = finish; + signal?.addEventListener( + "abort", + () => { + observedAbort = true; + finish(); + }, + { once: true }, + ); + }); + }; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-1" }, + is_current_turn: true, + }); + } + return Response.json({}); + }); + const s = await listen(run); + + try { + const request = http.request(`${s.url}/run`, { + method: "POST", + headers: { + ...AUTH, + accept: "application/x-ndjson", + "content-type": "application/json", + }, + }); + request.on("error", () => {}); + request.end( + JSON.stringify({ + harness: "pi_core", + ...(testCase.sessionOwned ? { sessionId: `session-${testCase.name}` } : {}), + ...(testCase.detached ? { detached: true } : {}), + telemetry: { + exporters: { + otlp: { + endpoint: "http://127.0.0.1:8000/otlp/v1/traces", + headers: { authorization: "ApiKey test" }, + }, + }, + }, + messages: [{ role: "user", content: "hello" }], + }), + ); + + await started; + request.destroy(); + await new Promise((resolve) => setTimeout(resolve, 25)); + + if (!testCase.aborts) { + assert.equal(observedAbort, false, "the dropped response must not own turn lifetime"); + assert.equal(completed, false, "the fake turn is still running after disconnect"); + releaseRun?.(); + } + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("run did not settle")), 1_000); + const poll = () => { + if (completed) { + clearTimeout(timeout); + resolve(); + } else setImmediate(poll); + }; + poll(); + }); + assert.equal(observedAbort, testCase.aborts); + } finally { + releaseRun?.(); + await s.close(); + fetchSpy.mockRestore(); + } + }); + } it("redacts this run's credentials from the stderr stack log when a run throws", async () => { // A per-run provider key rides ONLY the typed request (never process env). When the run diff --git a/services/runner/tests/unit/session-admission.test.ts b/services/runner/tests/unit/session-admission.test.ts index 6829f267fce..c1f7eb3ace5 100644 --- a/services/runner/tests/unit/session-admission.test.ts +++ b/services/runner/tests/unit/session-admission.test.ts @@ -137,7 +137,13 @@ function sessionRequest( interface StreamRecord { kind: string; - event?: { type: string; message?: string; code?: string; turnId?: string }; + event?: { + type: string; + message?: string; + code?: string; + turnId?: string; + name?: string; + }; result?: { ok: boolean; error?: string }; } @@ -564,4 +570,70 @@ describe("runner admission: the admitted turn id reaches the client", () => { await api.close(); } }); + it("emits NO session-accepted for a refused detached turn", async () => { + // `session-accepted` is what switches the client to shared delivery: live text stops coming + // from the invoke stream and starts coming from /sessions/{id}/events. A refused turn serves + // no frames on either channel, so a client that already switched renders nothing at all and + // waits out its acceptance deadline instead of showing the refusal. + const api = await startFakeApi(() => false); + process.env[INTERNAL_ENV] = api.url; + const runner = await startRunner(async () => ({ + ok: true, + output: "", + events: [], + })); + try { + const { records } = await postRun( + runner.url, + sessionRequest({ detached: true } as Partial), + ); + + assert.ok( + !records.some( + (r) => r.kind === "event" && r.event?.name === "session-accepted", + ), + "acceptance must never precede the admission verdict", + ); + const error = records.find( + (r) => r.kind === "event" && r.event?.type === "error", + ); + assert.ok(error, "the refusal still reaches the client as an error event"); + assert.equal(error!.event!.code, SESSION_TURN_IN_USE_CODE); + } finally { + await runner.close(); + await api.close(); + } + }); + + it("emits session-accepted for an admitted detached turn, before the turn event", async () => { + const api = await startFakeApi(() => true); + process.env[INTERNAL_ENV] = api.url; + const runner = await startRunner(async () => ({ + ok: true, + output: "answered", + events: [], + })); + try { + const { records } = await postRun( + runner.url, + sessionRequest({ detached: true } as Partial), + ); + + const accepted = records.findIndex( + (r) => r.kind === "event" && r.event?.name === "session-accepted", + ); + const turnEvent = records.findIndex( + (r) => r.kind === "event" && r.event?.type === "turn", + ); + assert.ok(accepted >= 0, "an admitted detached turn still announces itself"); + assert.ok(turnEvent >= 0, "and still hands out its execution id"); + assert.ok( + accepted < turnEvent, + "acceptance stays the first positive frame the shared client reads", + ); + } finally { + await runner.close(); + await api.close(); + } + }); }); diff --git a/services/runner/tests/unit/wire-contract.test.ts b/services/runner/tests/unit/wire-contract.test.ts index dfd9c5b4cc2..c8514beaa61 100644 --- a/services/runner/tests/unit/wire-contract.test.ts +++ b/services/runner/tests/unit/wire-contract.test.ts @@ -57,6 +57,7 @@ const KNOWN_REQUEST_KEYS = [ "sandboxPermission", "harnessFiles", "turnId", + "detached", "projectId", "effectiveParameters", ] as const; diff --git a/web/mobile/src/features/chat/ChatScreen.tsx b/web/mobile/src/features/chat/ChatScreen.tsx index a0dcc7ae0e4..39d1a661c01 100644 --- a/web/mobile/src/features/chat/ChatScreen.tsx +++ b/web/mobile/src/features/chat/ChatScreen.tsx @@ -77,8 +77,9 @@ export const ChatScreen = ({ // Only a FIRST load has nothing to hold — that is the one time a spinner is honest. const showLoading = resolving && !heldEntityId const liveness = useLivenessPoll(projectId) - const stream = liveness.data?.find((s) => s.session_id === sessionId) - const running = Boolean(stream?.flags?.is_running) + const liveStream = liveness.data?.find((s) => s.session_id === sessionId) + const running = Boolean(liveStream?.flags?.is_running) + const sharedReader = Boolean(liveStream?.capabilities?.shared_reader) // The conversation is ALWAYS mounted — the mode only decides what sits beside it (and, on a // narrow frame, which of the two is on screen). Unmounting it on a mode flip would drop a // streaming turn. @@ -99,8 +100,10 @@ export const ChatScreen = ({ workspaceId={workspaceId} running={running} stopStateLoading={liveness.isLoading} - sessionTurnId={stream?.turn_id} - stoppingTurnId={stream?.stopping_turn_id} + sessionTurnId={liveStream?.turn_id} + stoppingTurnId={liveStream?.stopping_turn_id} + sharedReader={sharedReader} + livenessUpdatedAt={liveness.dataUpdatedAt} agentId={resolvedAgentId} /> ) : ( diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index 74402ceded5..4da841efef2 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -11,8 +11,9 @@ import { } from "@agenta/chat/assets" import { ConnectionDock, - ElicitationDock, ConnectionFocusProvider, + ConnectionWarningStrip, + ElicitationDock, QueuedMessagesDock, RunningElsewhereStrip, } from "@agenta/chat/components" @@ -61,7 +62,7 @@ import {ChatLoading} from "./states/ChatStates" import {StopButton} from "./StopButton" import {cancelledStopAction} from "./stopHereState" import {TurnRow} from "./TurnRow" -import {showTrailingWorkingPulse} from "./turnStatus" +import {deriveMobileRemoteTurnPresentation, showTrailingWorkingPulse} from "./turnStatus" import {TurnStatusLine} from "./TurnStatusLine" import {useApprovalActions, type ApprovalActions} from "./useApprovalActions" import {useSessionWatch} from "./useSessionWatch" @@ -87,6 +88,8 @@ export const LiveConversation = ({ stopStateLoading, sessionTurnId, stoppingTurnId, + sharedReader, + livenessUpdatedAt, agentId, embedded = false, }: { @@ -100,12 +103,22 @@ export const LiveConversation = ({ stopStateLoading: boolean sessionTurnId?: string | null stoppingTurnId?: string | null + /** Backend-advertised ability to receive display-only live frames from another sender. */ + sharedReader: boolean + /** React Query timestamp used to reject the sender's stale post-settle liveness snapshot. */ + livenessUpdatedAt: number /** Scopes the session tab rail to this agent's sessions. */ agentId?: string | null /** Rendered inside a workspace pane — the shell and its rail belong to the parent. */ embedded?: boolean }) => { - const conversation = useAgentConversation({entityId, sessionId}) + const conversation = useAgentConversation({ + entityId, + sessionId, + sharedReaderAdvertised: sharedReader, + sharedReaderRunning: running, + sharedReaderLivenessUpdatedAt: livenessUpdatedAt, + }) // The connect-model gate — desktop parity. The engine deliberately leaves this to the skin // (`useAgentConversation` says so): a keyless project must be told to add a key BEFORE the @@ -207,6 +220,14 @@ export const LiveConversation = ({ ]) const streamingHere = conversation.status === "submitted" || conversation.status === "streaming" + const remoteTurn = deriveMobileRemoteTurnPresentation({ + livenessRunning: running, + snapshotRunning: conversation.runningFromSnapshot || conversation.acceptedRunPending, + sharedReaderAdvertised: sharedReader, + readerReady: conversation.readerReady, + ownedContinuation: conversation.acceptedRunPending, + }) + const showingTurnActivity = streamingHere || remoteTurn.showActivity const streamingHereRef = useRef(streamingHere) streamingHereRef.current = streamingHere const hitlPendingRef = useRef(conversation.hitlPending) @@ -247,6 +268,7 @@ export const LiveConversation = ({ sessionId, projectId, onRecordsChanged: revalidate, + sharedReaderAdvertised: sharedReader, }) // Poll slowly while a cross-device run cannot be watched live. useEffect(() => { @@ -562,7 +584,7 @@ export const LiveConversation = ({ far below the turn it described. It falls back to here for the one case that turn cannot cover: the request is submitted and no assistant turn exists yet. */} @@ -618,7 +640,7 @@ export const LiveConversation = ({ composer, as on the desktop — it used to be a top bar that also appeared for THIS device's own turns, duplicating the composer's Stop and shifting the transcript twice per run. */} - {running && !streamingHere ? ( + {remoteTurn.showStrip && !streamingHere ? ( ) : null} + {conversation.connectionWarning ? ( + + + + ) : null} {pendingApprovals.length > 0 ? ( - transcript !== null && +export const shouldAdoptTranscript = (transcript: unknown, rendered: RenderedTranscript): boolean => + isSessionTranscript(transcript) && shouldAdoptServerTranscript({ - serverRecordCount: transcript.recordCount, + serverRecordCount: transcript.sequenceCursor ?? transcript.recordCount, serverMessageCount: transcript.messages.length, localMessageCount: rendered.messageCount, - watermark: rendered.watermark, + watermark: + transcript.sequenceCursor === undefined + ? rendered.recordCount + : rendered.sequenceCursor, busy: false, }) + +/** Resolve one watch-triggered read without letting transport failure reach React. */ +export const adoptTranscriptRead = async ( + read: () => Promise, + adopt: (transcript: SessionTranscript) => boolean, +): Promise => { + try { + const transcript = await read() + return isSessionTranscript(transcript) ? adopt(transcript) : false + } catch { + return false + } +} diff --git a/web/mobile/src/features/chat/turnStatus.ts b/web/mobile/src/features/chat/turnStatus.ts index 0046fe0a1a8..018be97c168 100644 --- a/web/mobile/src/features/chat/turnStatus.ts +++ b/web/mobile/src/features/chat/turnStatus.ts @@ -1,3 +1,8 @@ +import {deriveRemoteTurnPresentation} from "@agenta/chat/model" + +/** Mobile presentation for a remote/shared-path run. */ +export const deriveMobileRemoteTurnPresentation = deriveRemoteTurnPresentation + /** * Should the trailing status line show the working pulse? * diff --git a/web/mobile/src/features/chat/useSessionTranscript.ts b/web/mobile/src/features/chat/useSessionTranscript.ts index 5e9ddb867e5..48b8d3a334b 100644 --- a/web/mobile/src/features/chat/useSessionTranscript.ts +++ b/web/mobile/src/features/chat/useSessionTranscript.ts @@ -5,7 +5,7 @@ import {revalidateSessionRecordsAtom} from "@agenta/entities/session" import type {UIMessage} from "ai" import {getDefaultStore} from "jotai" -import {shouldAdoptTranscript} from "./transcriptAdoption" +import {adoptTranscriptRead, shouldAdoptTranscript} from "./transcriptAdoption" /** * Read-only transcript for one session: server record replay via `loadSessionMessages` @@ -35,7 +35,8 @@ export const useSessionTranscript = (sessionId: string, pollMs = 0) => { const messagesRef = useRef([]) // Records the rendered transcript was built from; `undefined` until the first adoption. This // is in-memory only — mobile persists no transcript, so there is nothing to file it against. - const watermarkRef = useRef(undefined) + const recordCountRef = useRef(undefined) + const sequenceCursorRef = useRef(undefined) /** * Apply one delivery behind the shared adoption rule (`shouldAdoptTranscript`). Returns @@ -47,10 +48,13 @@ export const useSessionTranscript = (sessionId: string, pollMs = 0) => { if (sessionRef.current !== sessionId) return false const shouldAdopt = shouldAdoptTranscript(transcript, { messageCount: messagesRef.current.length, - watermark: watermarkRef.current, + recordCount: recordCountRef.current, + sequenceCursor: sequenceCursorRef.current, }) if (!shouldAdopt || !transcript) return false - watermarkRef.current = transcript.recordCount + recordCountRef.current = transcript.recordCount + if (transcript.sequenceCursor !== undefined) + sequenceCursorRef.current = transcript.sequenceCursor messagesRef.current = transcript.messages setMessages(transcript.messages) setState("ready") @@ -72,20 +76,25 @@ export const useSessionTranscript = (sessionId: string, pollMs = 0) => { setState("loading") setMessages([]) messagesRef.current = [] - watermarkRef.current = undefined + recordCountRef.current = undefined + sequenceCursorRef.current = undefined void loadSessionMessages(sessionId, (fresh) => { // Disk-restore revalidation re-delivery — fresh is non-empty by contract. if (cancelled) return if (adoptRef.current(fresh)) adopted = true - }).then((transcript) => { - if (cancelled) return - if (adoptRef.current(transcript)) { - adopted = true - return - } - // Nothing adopted from either delivery → no durable history for this session. - if (!adopted) setState("empty") }) + .then((transcript) => { + if (cancelled) return + if (adoptRef.current(transcript)) { + adopted = true + return + } + // Nothing adopted from either delivery → no durable history for this session. + if (!adopted) setState("empty") + }) + .catch(() => { + if (!cancelled && !adopted) setState("empty") + }) return () => { cancelled = true } @@ -100,20 +109,16 @@ export const useSessionTranscript = (sessionId: string, pollMs = 0) => { inFlightRef.current = true // Invalidate first so the shared-cache read refetches instead of serving staleTime. getDefaultStore().set(revalidateSessionRecordsAtom, sessionId) - void loadSessionMessages(sessionId) - .then((transcript) => { - adoptRef.current(transcript) - }) - // A failed poll keeps what is on screen and waits for the next tick; swallowing it - // here keeps a transient 5xx from surfacing as an unhandled rejection every 3s. - .catch(() => undefined) - .finally(() => { - inFlightRef.current = false - if (pendingRef.current) { - pendingRef.current = false - if (sessionRef.current === sessionId) refresh() - } - }) + void adoptTranscriptRead( + () => loadSessionMessages(sessionId), + (transcript) => adoptRef.current(transcript), + ).finally(() => { + inFlightRef.current = false + if (pendingRef.current) { + pendingRef.current = false + if (sessionRef.current === sessionId) refresh() + } + }) }, [sessionId]) useEffect(() => { diff --git a/web/mobile/src/features/chat/useSessionWatch.ts b/web/mobile/src/features/chat/useSessionWatch.ts index d5599d2c28d..d95cdde7e48 100644 --- a/web/mobile/src/features/chat/useSessionWatch.ts +++ b/web/mobile/src/features/chat/useSessionWatch.ts @@ -1,5 +1,6 @@ import {useEffect, useRef, useState} from "react" +import {shouldRefreshLegacyObserverLiveness} from "@agenta/chat/model" import {useQueryClient} from "@tanstack/react-query" import {tryRefreshSession} from "@/lib/auth" @@ -34,11 +35,13 @@ export const useSessionWatch = ({ projectId, onRecordsChanged, onInteractionChanged, + sharedReaderAdvertised = true, }: { sessionId: string projectId: string onRecordsChanged: () => void onInteractionChanged?: () => void + sharedReaderAdvertised?: boolean }): {connected: boolean} => { const [connected, setConnected] = useState(false) const queryClient = useQueryClient() @@ -56,6 +59,7 @@ export const useSessionWatch = ({ let disposed = false let attempt = 0 let lastNotifiedAt = 0 + let lastLivenessRefreshAt = 0 /** Reconnect coverage only — real `records-changed` events are never throttled. */ const notifyOnConnect = () => { @@ -65,8 +69,13 @@ export const useSessionWatch = ({ onRecordsChangedRef.current() } - const invalidateBadges = () => { + const invalidateLiveness = (trackLegacyRefresh = false) => { + if (trackLegacyRefresh) lastLivenessRefreshAt = Date.now() void queryClient.invalidateQueries({queryKey: livenessQueryKey(projectId)}) + } + + const invalidateBadges = (trackLegacyRefresh = false) => { + invalidateLiveness(trackLegacyRefresh) void queryClient.invalidateQueries({ queryKey: actionableInteractionsQueryKey(projectId), }) @@ -114,8 +123,20 @@ export const useSessionWatch = ({ notifyOnConnect() invalidateBadges() }) - es.addEventListener("records-changed", () => onRecordsChangedRef.current()) - es.addEventListener("lifecycle", invalidateBadges) + es.addEventListener("records-changed", () => { + onRecordsChangedRef.current() + const now = Date.now() + if ( + shouldRefreshLegacyObserverLiveness({ + sharedReaderAdvertised, + lastRefreshAt: lastLivenessRefreshAt, + now, + }) + ) { + invalidateLiveness(true) + } + }) + es.addEventListener("lifecycle", () => invalidateBadges(true)) es.addEventListener("interaction", () => { invalidateBadges() onInteractionChangedRef.current?.() @@ -143,7 +164,7 @@ export const useSessionWatch = ({ if (retryHandle !== undefined) window.clearTimeout(retryHandle) close() } - }, [sessionId, projectId, queryClient]) + }, [sessionId, projectId, queryClient, sharedReaderAdvertised]) return {connected} } diff --git a/web/mobile/tests/unit/transcriptAdoption.test.ts b/web/mobile/tests/unit/transcriptAdoption.test.ts index 30de438592a..a25f9562f53 100644 --- a/web/mobile/tests/unit/transcriptAdoption.test.ts +++ b/web/mobile/tests/unit/transcriptAdoption.test.ts @@ -1,56 +1,75 @@ import type {SessionTranscript} from "@agenta/chat/assets" import type {UIMessage} from "ai" -import {describe, expect, it} from "vitest" +import {describe, expect, it, vi} from "vitest" -import {shouldAdoptTranscript} from "../../src/features/chat/transcriptAdoption" +import { + adoptTranscriptRead, + shouldAdoptTranscript, +} from "../../src/features/chat/transcriptAdoption" -const transcript = (messageCount: number, recordCount: number): SessionTranscript => ({ +const transcript = ( + messageCount: number, + recordCount: number, + sequenceCursor?: number, +): SessionTranscript => ({ messages: Array.from( {length: messageCount}, (_, i) => ({id: `m${i}`, role: "assistant", parts: []}) as UIMessage, ), recordCount, + sequenceCursor, +}) + +const rendered = (messageCount: number, recordCount?: number, sequenceCursor?: number) => ({ + messageCount, + recordCount, + sequenceCursor, }) describe("shouldAdoptTranscript", () => { it("adopts the first delivery for a freshly opened session", () => { - expect( - shouldAdoptTranscript(transcript(3, 12), {messageCount: 0, watermark: undefined}), - ).toBe(true) + expect(shouldAdoptTranscript(transcript(3, 12), rendered(0))).toBe(true) }) it("ignores a failed / history-less load", () => { - expect(shouldAdoptTranscript(null, {messageCount: 0, watermark: undefined})).toBe(false) - expect( - shouldAdoptTranscript(transcript(0, 0), {messageCount: 0, watermark: undefined}), - ).toBe(false) + expect(shouldAdoptTranscript(null, rendered(0))).toBe(false) + expect(shouldAdoptTranscript(undefined, rendered(0))).toBe(false) + expect(shouldAdoptTranscript(transcript(0, 0), rendered(0))).toBe(false) }) it("ignores a re-read that brought no new records", () => { - expect(shouldAdoptTranscript(transcript(3, 12), {messageCount: 3, watermark: 12})).toBe( - false, - ) + expect(shouldAdoptTranscript(transcript(3, 12), rendered(3, 12))).toBe(false) }) // Issue #5530: a turn that grows in place (tool results landing, an approval round-trip // completing) keeps its message count, so only the record watermark sees the growth. it("adopts a grown log even when the message count is unchanged", () => { - expect(shouldAdoptTranscript(transcript(3, 40), {messageCount: 3, watermark: 12})).toBe( - true, - ) + expect(shouldAdoptTranscript(transcript(3, 40), rendered(3, 12))).toBe(true) }) it("never trades down to a snapshot shorter than what is on screen", () => { - expect(shouldAdoptTranscript(transcript(2, 40), {messageCount: 3, watermark: 12})).toBe( - false, - ) + expect(shouldAdoptTranscript(transcript(2, 40), rendered(3, 12))).toBe(false) }) // Mobile keeps no persisted transcript, so a session it has rendered before still opens with // an absent watermark — which reads as 0 and re-syncs from the durable log once. it("re-syncs when the watermark is absent", () => { - expect( - shouldAdoptTranscript(transcript(3, 12), {messageCount: 3, watermark: undefined}), - ).toBe(true) + expect(shouldAdoptTranscript(transcript(3, 12), rendered(3))).toBe(true) + }) + + it("adopts sequence growth when retention keeps the row count flat", () => { + expect(shouldAdoptTranscript(transcript(3, 20, 101), rendered(3, 20, 100))).toBe(true) + }) +}) + +describe("adoptTranscriptRead", () => { + it.each([ + ["rejected", () => Promise.reject(new Error("network changed"))], + ["undefined", () => Promise.resolve(undefined)], + ])("keeps the mobile transcript when a watch-triggered read is %s", async (_failure, read) => { + const adopt = vi.fn() + + await expect(adoptTranscriptRead(read, adopt)).resolves.toBe(false) + expect(adopt).not.toHaveBeenCalled() }) }) diff --git a/web/mobile/tests/unit/turnStatus.test.ts b/web/mobile/tests/unit/turnStatus.test.ts index b432656eb23..6b8ab043281 100644 --- a/web/mobile/tests/unit/turnStatus.test.ts +++ b/web/mobile/tests/unit/turnStatus.test.ts @@ -1,6 +1,9 @@ import {describe, expect, it} from "vitest" -import {showTrailingWorkingPulse} from "@/features/chat/turnStatus" +import { + deriveMobileRemoteTurnPresentation, + showTrailingWorkingPulse, +} from "@/features/chat/turnStatus" const userTurn = {isUser: true, isStreamingTurn: false} const streamingAssistant = {isUser: false, isStreamingTurn: true} @@ -25,3 +28,60 @@ describe("showTrailingWorkingPulse", () => { expect(showTrailingWorkingPulse(false, [])).toBe(false) }) }) + +describe("deriveMobileRemoteTurnPresentation", () => { + it.each([ + { + name: "renders activity and no strip for a ready reader", + input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: true}, + expected: {showActivity: true, showStrip: false}, + }, + { + name: "renders the strip while the reader is not ready", + input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: false}, + expected: {showActivity: false, showStrip: true}, + }, + { + name: "renders the strip when the feature is off", + input: {livenessRunning: true, sharedReaderAdvertised: false, readerReady: false}, + expected: {showActivity: false, showStrip: true}, + }, + { + name: "does not render the strip in the tab that owns a continuation", + input: { + livenessRunning: true, + sharedReaderAdvertised: true, + readerReady: false, + ownedContinuation: true, + }, + expected: {showActivity: false, showStrip: false}, + }, + ])("$name", ({input, expected}) => { + expect(deriveMobileRemoteTurnPresentation(input)).toEqual(expected) + }) + + it("shows the flag-off observer banner only while session-stream liveness is running", () => { + const input = { + snapshotRunning: true, + sharedReaderAdvertised: false, + readerReady: false, + } + + expect( + deriveMobileRemoteTurnPresentation({...input, livenessRunning: true}).showStrip, + ).toBe(true) + expect( + deriveMobileRemoteTurnPresentation({...input, livenessRunning: false}).showStrip, + ).toBe(false) + }) + + it("hides the banner when the advertised reader is ready", () => { + expect( + deriveMobileRemoteTurnPresentation({ + livenessRunning: true, + sharedReaderAdvertised: true, + readerReady: true, + }).showStrip, + ).toBe(false) + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 1a30c244aee..618e1c751da 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -13,6 +13,7 @@ import { stagedFilesToParts, useComposerAttachments, useAgentChatQueue, + useSessionLivePreview, type QueuedMessage, } from "@agenta/chat/hooks" import { @@ -29,6 +30,7 @@ import { isVisiblePart, } from "@agenta/chat/model" import {getInteractionAvailability, getLivePendingApprovals} from "@agenta/chat/model" +import {withoutSharedSenderAcceptanceMessages} from "@agenta/chat/model" import {hasSessionChat, sessionMessagesAtom, setSessionStatusAtom} from "@agenta/chat/state" import {clearSessionFresh} from "@agenta/chat/state" import { @@ -73,6 +75,7 @@ import {useScrollIntent} from "./hooks/useScrollIntent" import {useTranscriptScroll} from "./hooks/useTranscriptScroll" import {useTurnInspector} from "./hooks/useTurnInspector" import {useVirtuosoTranscript} from "./hooks/useVirtuosoTranscript" +import {deriveSessionRemoteTurnPresentation} from "./state/liveness" import {useChatScopeKey} from "./state/scope" import { activeSessionIdAtomFamily, @@ -112,7 +115,9 @@ const AgentConversation = ({ const artifactId = useAtomValue(workflowMolecule.selectors.workflowId(entityId)) const setSessionStatus = useSetAtom(setSessionStatusAtom) // Seed once from the persisted store (read imperatively so our own writes don't feed back). - const [initialMessages] = useState(() => store.get(sessionMessagesAtom)[sessionId] ?? []) + const [initialMessages] = useState(() => + withoutSharedSenderAcceptanceMessages(store.get(sessionMessagesAtom)[sessionId] ?? []), + ) const richInputRef = useRef(null) const composer = useComposerDraft({sessionId, richInputRef, revealPlayedRef}) @@ -130,6 +135,10 @@ const AgentConversation = ({ status, busy, error, + connectionWarning, + acceptedRunPending, + turnDeliverySource, + settleSharedTurn, sendMessage, regenerate, setMessages, @@ -146,8 +155,40 @@ const AgentConversation = ({ answerApproval, resumeOrphaned, isSeen, - runningElsewhere, + runningElsewhere: livenessRunningElsewhere, + sharedReaderAdvertised, + refreshFromRecords, + setSharedSenderReady, } = useAgentChatSession({entityId, sessionId, initialMessages, intent: scrollIntent}) + const { + messages: previewMessages, + runningFromSnapshot, + readerReady, + } = useSessionLivePreview({ + sessionId, + sharedReaderAdvertised, + runningElsewhere: livenessRunningElsewhere, + sender: true, + onReadyChange: setSharedSenderReady, + onExecutionSettled: settleSharedTurn, + onDisconnect: refreshFromRecords, + }) + const remoteTurn = deriveSessionRemoteTurnPresentation({ + livenessRunning: livenessRunningElsewhere, + snapshotRunning: runningFromSnapshot || acceptedRunPending, + sharedReaderAdvertised, + readerReady, + ownedContinuation: acceptedRunPending, + }) + const transcriptMessages = useMemo(() => { + const durableMessages = withoutSharedSenderAcceptanceMessages(messages) + if (turnDeliverySource === "legacy" || previewMessages.length === 0) return durableMessages + return [...durableMessages, ...previewMessages] + }, [messages, previewMessages, turnDeliverySource]) + const transcriptBusy = + busy || + remoteTurn.showActivity || + (turnDeliverySource !== "legacy" && previewMessages.length > 0) // Turn Inspector: open state, the focused turn, and the assistant → turn-number mapping. const { @@ -351,6 +392,7 @@ const AgentConversation = ({ } = useAgentChatQueue({ status, messages, + acceptedRunPending, stopped, resumeOrphaned, sendQueued, @@ -535,11 +577,16 @@ const AgentConversation = ({ // Exactly one scroll engine owns the transcript: Virtuoso when it's enabled in the playground // settings, the SC-1..4 DOM engine otherwise (each bails on the other's flag). Both act on the // shared `scrollIntent`, so producers never care which is live. - const virt = useVirtuosoTranscript({intent: scrollIntent, sessionId, messages, status}) + const virt = useVirtuosoTranscript({ + intent: scrollIntent, + sessionId, + messages: transcriptMessages, + status, + }) const useVirtuoso = virt.enabled const scroll = useTranscriptScroll({ intent: scrollIntent, - messages, + messages: transcriptMessages, status, useVirtuoso, }) @@ -661,10 +708,11 @@ const AgentConversation = ({ // fill. Keeping the fill on a STABLE element — not hopping it from the user bubble to the assistant // bubble when the answer arrives — avoids the mid-stream layout jump. const lastUserIndex = (() => { - for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return i + for (let i = transcriptMessages.length - 1; i >= 0; i--) + if (transcriptMessages[i].role === "user") return i return -1 })() - const activeStart = lastUserIndex >= 0 ? lastUserIndex : messages.length + const activeStart = lastUserIndex >= 0 ? lastUserIndex : transcriptMessages.length // The fill = min-h-full on the active turn whenever there's PRIOR conversation above it (so the // question can sit at the top). Derived from layout, NOT from `busy` — so it persists when the turn // settles instead of being yanked away (which clamped the scroll and jumped the view). @@ -677,6 +725,7 @@ const AgentConversation = ({ ) const handleResend = useCallback( (messageId: string) => { + if (busyRef.current) return const msgs = messagesRef.current const idx = msgs.findIndex((m) => m.id === messageId) // Same hazard as rewind (#6362 review): regenerating drops the failed assistant @@ -705,7 +754,7 @@ const AgentConversation = ({ ) const renderMessage = (message: UIMessage, index: number) => { - const isLast = index === messages.length - 1 + const isLast = index === transcriptMessages.length - 1 const isAssistantTurn = message.role === "assistant" const turn = turnNumbers.get(message.id) const isInspected = isAssistantTurn && inspectedTurn != null && turn === inspectedTurn @@ -718,13 +767,14 @@ const AgentConversation = ({ // never during render (unsafe under StrictMode's double invoke). enter={!isSeen(message.id)} isLast={isLast} - isStreaming={busy && isLast} - precededByEmptyAssistant={index > 0 && isEmptyAssistantTurn(messages[index - 1])} - // A user turn has no trace of its own; borrow the paired (next) assistant turn's - // trace so its timestamp dates from the run, not this browser's first-seen stamp. + isStreaming={transcriptBusy && isLast} + precededByEmptyAssistant={ + index > 0 && isEmptyAssistantTurn(transcriptMessages[index - 1]) + } + // A user turn borrows its paired assistant trace so the timestamp reflects the run. turnTraceId={ - message.role === "user" && messages[index + 1] - ? getMessageTraceId(messages[index + 1]) + message.role === "user" && transcriptMessages[index + 1] + ? getMessageTraceId(transcriptMessages[index + 1]) : undefined } inspected={isInspected} @@ -735,13 +785,15 @@ const AgentConversation = ({ turn={turn} onInspectTurn={handleInspectTurn} showWorking={ - isLast && busy && (!isAssistantTurn || message.parts.some(isVisiblePart)) + isLast && + transcriptBusy && + (!isAssistantTurn || message.parts.some(isVisiblePart)) } // Paused on the user (never concurrently with showWorking — hitlPending implies not // busy): keeps the turn from reading as finished while the queue holds sends. showWaiting={isLast && isAssistantTurn && !busy && hitlPending} showStopped={stopped && isLast && isAssistantTurn} - resendDisabled={busy} + resendDisabled={busy || acceptedRunPending} onResend={handleResend} onRewind={handleRewind} onClientToolOutput={handleClientToolOutput} @@ -803,7 +855,7 @@ const AgentConversation = ({ {/* Stream errors are surfaced inline on the failing turn (red error bubble with the real reason), stamped in the effect above — no separate top-level banner. */} {/* Sits with the other docked strips so a session running in another browser reads as busy instead of frozen (#5530). */} - {runningElsewhere && !chromeHidden ? ( + {showRunningElsewhere && !chromeHidden ? ( ) : null} + {connectionWarning && !chromeHidden ? ( + + ) : null} { expect(rendered).not.toContain("Add your key") }) + it("offers Try again for an offline send that failed before acceptance", () => { + const rendered = text( + undefined} + />, + ) + + expect(rendered).toContain("The agent run failed") + expect(rendered).toContain("Could not reach Agenta") + expect(rendered).toContain("Try again") + }) + it("hides Try again when no retry handler is wired (not the last turn, or busy)", () => { const rendered = text( ({ + acceptedRunBySession: new Map(), + turnDeliverySourceBySession: new Map(), capturedHooks: undefined as | { prepareRequest: (args: {messages: UIMessage[]; id?: string}) => Promise @@ -64,9 +66,11 @@ vi.mock("@agenta/chat/model", () => ({ if (event.type === "reset" && current.stopped) return {...current, stopped: false} return current }, + withoutSharedSenderAcceptanceMessages: (messages: UIMessage[]) => messages, })) vi.mock("@agenta/chat/state", () => ({ + acceptedRunBySession: state.acceptedRunBySession, clearSessionTurnId: (sessionId: string) => state.turnIds.delete(sessionId), clearTurnClockAtom: "clear-turn-clock", expandedKeysForMessages: () => [], @@ -80,6 +84,7 @@ vi.mock("@agenta/chat/state", () => ({ setSessionTurnId: (sessionId: string, turnId: string) => state.turnIds.set(sessionId, turnId), stampMessagesCreatedAtAtom: "stamp-created-at", startTurnClockAtom: "start-turn-clock", + turnDeliverySourceBySession: state.turnDeliverySourceBySession, })) vi.mock("@agenta/entities/session", () => ({ @@ -184,6 +189,8 @@ import {useAgentChatSession} from "./useAgentChatSession" describe("useAgentChatSession execution guard", () => { beforeEach(() => { + state.acceptedRunBySession.clear() + state.turnDeliverySourceBySession.clear() state.turnIds.clear() state.sendMessage.mockClear() state.regenerate.mockClear() diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index fd6594c0a0e..c133a6939e4 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useReducer, useRef} from "react" +import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from "react" import { buildRequestWithinDeadline, @@ -10,16 +10,21 @@ import { import type {ClientToolOutputHandler} from "@agenta/chat/clientTools" import {useSessionChat} from "@agenta/chat/hooks" import { + classifyAgentRunError, ignoreStreamRejection, createUserStoppedState, isSessionTurnStopping, - parseAgentRunError, reduceUserStoppedState, + type RunErrorMetadata, + withoutSharedSenderAcceptanceMessages, } from "@agenta/chat/model" import { + acceptedRunBySession, clearTurnClockAtom, stampMessagesCreatedAtAtom, startTurnClockAtom, + turnDeliverySourceBySession, + type TurnDeliverySource, } from "@agenta/chat/state" import {expandedKeysForMessages, pruneExpandedAtom} from "@agenta/chat/state" import { @@ -118,6 +123,8 @@ export const useAgentChatSession = ({ const recordWatermarkRef = useRef( store.get(sessionRecordCountsReadAtom)[sessionId], ) + // Durable sequence coverage is connection-local and must never be stored as a row count. + const sequenceWatermarkRef = useRef(undefined) // Whether the LAST assistant turn was user-stopped. You can only cancel the in-flight (last) turn, // so this is a single boolean gated on position at render time — independent of message ids (which // can be missing/duplicated in restore/error paths and would otherwise smear the tag onto every @@ -149,6 +156,24 @@ export const useAgentChatSession = ({ const mountedRef = useRef(false) const messagesRef = useRef(initialMessages) const setTurnStartupLabel = useSetAtom(startTurnClockAtom) + // Did the runner acknowledge THIS turn? Its acceptance frame is transient, so it reaches + // `onData` and never the transcript — this is the only place the answer survives. A stream that + // dies after it is a lost connection, not a lost turn; one that dies before it may be a send + // that never started, and that failure has to stay on screen and in the cache. + const turnAcceptedRef = useRef(acceptedRunBySession.has(sessionId)) + const acceptedExecutionIdRef = useRef( + acceptedRunBySession.get(sessionId) ?? null, + ) + const [acceptedRunPending, setAcceptedRunPending] = useState(() => + acceptedRunBySession.has(sessionId), + ) + const [turnDeliverySource, setTurnDeliverySource] = useState( + () => turnDeliverySourceBySession.get(sessionId) ?? null, + ) + const sharedSenderReadyRef = useRef(false) + const setSharedSenderReady = useCallback((ready: boolean) => { + sharedSenderReadyRef.current = ready + }, []) // Rebuilt every render and bound to the chat on every commit (below), so they always see the live // values — `entityId` included, which is why a run follows a revision switch or a self-commit @@ -156,12 +181,21 @@ export const useAgentChatSession = ({ const hooks: SessionChatHooks = { prepareRequest: async ({messages, id}) => { clearSessionTurnId(sessionId) + turnAcceptedRef.current = false + acceptedExecutionIdRef.current = null + acceptedRunBySession.delete(sessionId) + setAcceptedRunPending(false) + const sharedResponse = sharedSenderReadyRef.current + const deliverySource: TurnDeliverySource = sharedResponse ? "shared" : "legacy" + turnDeliverySourceBySession.set(sessionId, deliverySource) + setTurnDeliverySource(deliverySource) // Bounded: retries while the invocation URL is still loading and rejects if the build // hangs, so a failed send surfaces as an error bubble instead of an eternal spinner // (#6042). The helper owns the not-ready / timed-out errors. const req = await buildRequestWithinDeadline(() => buildAgentRequest(entityId, messages, { sessionId: id ?? sessionId, + sharedResponse, }), ) captureTurnRequest(buildTurnCapture(req, generateId(), Date.now())) @@ -169,6 +203,14 @@ export const useAgentChatSession = ({ }, // ── #6047 startup states: capture the runner's observed startup boundary as it streams ── onData: (part) => { + if (part.type === "data-session-accepted") { + turnAcceptedRef.current = true + const data = part.data as {executionId?: unknown} | undefined + acceptedExecutionIdRef.current = + typeof data?.executionId === "string" ? data.executionId : null + acceptedRunBySession.set(sessionId, acceptedExecutionIdRef.current) + setAcceptedRunPending(true) + } const label = startupLabelFromDataPart(part) if (label) setTurnStartupLabel(sessionId, label) }, @@ -242,6 +284,7 @@ export const useAgentChatSession = ({ addToolApprovalResponse, addToolOutput, error, + clearError, } = useChat({ chat, // Coalesce stream deltas to ~1 UI commit / 50ms so a fast token stream doesn't drive a @@ -263,13 +306,24 @@ export const useAgentChatSession = ({ }, [regenerateChatMessage, sessionId], ) + const lastMessage = messages[messages.length - 1] + const serverErrorProvenance = + lastMessage?.role === "assistant" && + lastMessage.parts.some((part) => part.type === "data-agent-error") + const errorBoundary = useMemo( + () => + error + ? classifyAgentRunError(error, turnAcceptedRef.current, serverErrorProvenance) + : {}, + [error, serverErrorProvenance], + ) const busy = isChatBusy(status) // `messages`/`busy` change every token; consumers that must stay referentially stable // (`handleRewind`, the hydration/SWR adoption guards) read them through refs instead. messagesRef.current = messages - const busyRef = useRef(busy) - busyRef.current = busy + const busyRef = useRef(busy || acceptedRunPending) + busyRef.current = busy || acceptedRunPending useEffect(() => { dispatchStopped({type: "transcript", messages}) @@ -289,6 +343,8 @@ export const useAgentChatSession = ({ stopStateLoading, sessionTurnId, stoppingTurnId, + sharedReaderAdvertised, + refreshFromRecords, } = useSessionHydration({ sessionId, initialMessages, @@ -297,9 +353,11 @@ export const useAgentChatSession = ({ seenIdsRef, restoredIdsRef, recordWatermarkRef, + sequenceWatermarkRef, busy, setMessages, persistMessages, + clearRunError: clearError, intent, pendingResumeRef: liveGateInteractionRef, }) @@ -398,7 +456,6 @@ export const useAgentChatSession = ({ // response, tool output, stream finish) — never on mount — so this resume can't fire and // must not hold the queue. Short-circuits cheap on the streaming hot path: any live send // makes the tail non-restored. - const lastMessage = messages[messages.length - 1] const resumeOrphaned = !liveGateInteractionRef.current && !!lastMessage && @@ -411,23 +468,20 @@ export const useAgentChatSession = ({ if (turnId) setSessionTurnId(sessionId, turnId) }, [messages, sessionId]) - // Surface a stream failure inline: stamp the parsed error onto the failing assistant turn so - // it renders as a red error bubble with the real reason (and persists with the session via the - // effect below), instead of a transient top banner + a generic "no response". FE-only — it - // uses the error useChat already has; the backend doesn't need to attach it to the trace. + // Run failures become conversation content; an accepted transport loss stays connection state. useEffect(() => { - if (!error) return - const parsed = parseAgentRunError(error) + const parsed = errorBoundary.runError + if (!parsed) return + const stamp: RunErrorMetadata = {runError: parsed} setMessages((prev) => { const last = prev.length > 0 ? prev[prev.length - 1] : undefined - const existing = (last?.metadata as {runError?: {message?: string}} | undefined) - ?.runError + const existing = (last?.metadata as RunErrorMetadata | undefined)?.runError if (last?.role === "assistant") { if (existing?.message === parsed.message) return prev // already stamped const next = [...prev] next[next.length - 1] = { ...last, - metadata: {...(last.metadata as object | undefined), runError: parsed}, + metadata: {...(last.metadata as object | undefined), ...stamp}, } return next } @@ -438,11 +492,11 @@ export const useAgentChatSession = ({ id: `run-error-${generateId()}`, role: "assistant", parts: [], - metadata: {runError: parsed}, + metadata: stamp, } as (typeof prev)[number], ] }) - }, [error, setMessages]) + }, [errorBoundary.runError, setMessages]) // A live turn makes the transcript no longer a copy of the server's, and we can't know how many // records the runner logged for it — so drop the watermark and let the next open re-sync from @@ -450,13 +504,20 @@ export const useAgentChatSession = ({ // flips to "submitted", effects run in declaration order, so clearing here is what stops the // persist below from filing a locally-extended transcript under a server watermark. useEffect(() => { - if (isChatBusy(status)) recordWatermarkRef.current = undefined + if (isChatBusy(status)) { + recordWatermarkRef.current = undefined + sequenceWatermarkRef.current = undefined + } }, [status]) // Persist the conversation whenever its stream settles (skip mid-stream). useEffect(() => { if (status === "streaming") return - persistMessages({id: sessionId, messages, recordCount: recordWatermarkRef.current}) + persistMessages({ + id: sessionId, + messages: withoutSharedSenderAcceptanceMessages(messages), + recordCount: recordWatermarkRef.current, + }) }, [messages, status, sessionId, persistMessages]) // ── #6047 startup states: one label per in-flight turn ── @@ -743,7 +804,19 @@ export const useAgentChatSession = ({ messages, status, busy, - error, + error: errorBoundary.runError, + connectionWarning: errorBoundary.connectionWarning, + acceptedRunPending, + turnDeliverySource, + settleSharedTurn: (executionId?: string) => { + const acceptedExecutionId = acceptedExecutionIdRef.current + if (executionId && acceptedExecutionId && acceptedExecutionId !== executionId) return + acceptedExecutionIdRef.current = null + acceptedRunBySession.delete(sessionId) + setAcceptedRunPending(false) + turnDeliverySourceBySession.delete(sessionId) + setTurnDeliverySource(null) + }, sendMessage: sendMessageWithFreshGuard, regenerate: regenerateWithFreshGuard, setMessages, @@ -753,6 +826,9 @@ export const useAgentChatSession = ({ isHydrating, hydratedEmpty, runningElsewhere, + sharedReaderAdvertised, + refreshFromRecords, + setSharedSenderReady, stopped, stopping, setStopped, diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx new file mode 100644 index 00000000000..9bd0fc77e4a --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx @@ -0,0 +1,242 @@ +import {act, createElement} from "react" + +import {useSessionLivePreview} from "@agenta/chat/hooks" +import type {SessionRecord} from "@agenta/entities/session" +import {projectIdAtom} from "@agenta/shared/state" +import type {UIMessage} from "ai" +import {createStore, Provider} from "jotai" +import {createRoot} from "react-dom/client" +import {beforeEach, describe, expect, it, vi} from "vitest" + +import {type ScrollIntent} from "./useScrollIntent" +import {useSessionHydration} from "./useSessionHydration" +;(globalThis as typeof globalThis & {IS_REACT_ACT_ENVIRONMENT: boolean}).IS_REACT_ACT_ENVIRONMENT = + true + +const mocks = vi.hoisted(() => ({ + fetchSessionSnapshot: vi.fn(), + querySessionTranscript: vi.fn(), + loadSessionMessages: vi.fn(), + openedUrls: [] as string[], +})) + +vi.mock("@agenta/chat/assets", async (importOriginal) => ({ + ...(await importOriginal()), + loadSessionMessages: mocks.loadSessionMessages, +})) + +vi.mock("@agenta/chat/state", async (importOriginal) => ({ + ...(await importOriginal()), + hasSessionChat: () => false, + isSessionFresh: () => true, +})) + +vi.mock("@agenta/entities/session", async (importOriginal) => { + const {atom} = await import("jotai") + const actual = await importOriginal() + return { + ...actual, + fetchSessionInteractionStatesAtom: atom(null, () => new Map()), + fetchSessionSnapshot: mocks.fetchSessionSnapshot, + querySessionTranscript: mocks.querySessionTranscript, + } +}) + +vi.mock("../state/liveness", async () => { + const {atom} = await import("jotai") + const liveness = atom({ + isLoading: false, + nest: {isRunning: false}, + sharedReader: true, + stoppingTurnId: null, + turnId: null, + }) + const runningElsewhere = atom(false) + return { + sessionLivenessAtomFamily: () => liveness, + sessionRunningElsewhereAtomFamily: () => runningElsewhere, + } +}) + +vi.mock("../state/scope", () => ({useChatScopeKey: () => "scope-1"})) + +vi.mock("../state/sessions", async () => { + const {atom} = await import("jotai") + const activeSessionId = atom("session-1") + return {activeSessionIdAtomFamily: () => activeSessionId} +}) + +vi.mock("./useSessionRecordsWatch", () => ({useSessionRecordsWatch: () => undefined})) + +const record = (id: string, sequence: number, payload: Record): SessionRecord => ({ + id, + session_id: "session-1", + project_id: "project-1", + sequence, + event_index: null, + sender: "agent", + session_update: String(payload.type), + payload, + created_at: null, +}) + +describe("desktop durable reconnect", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.openedUrls.length = 0 + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: false}}, + execution: null, + read: {latest_sequence: 10}, + }) + mocks.querySessionTranscript.mockResolvedValue([ + record("record-8", 8, {type: "message", text: "durable reply"}), + record("record-10", 10, {type: "done"}), + ]) + Object.defineProperty(document, "visibilityState", {configurable: true, value: "visible"}) + vi.stubGlobal( + "EventSource", + class { + onmessage = null + onerror = null + + constructor(url: string | URL) { + mocks.openedUrls.push(String(url)) + } + + addEventListener() {} + close() {} + }, + ) + }) + + it("opens SSE after the desktop adapter adopts the bounded snapshot", async () => { + const store = createStore() + store.set(projectIdAtom, "project-1") + const messagesRef = {current: [] as UIMessage[]} + const recordWatermarkRef = {current: undefined as number | undefined} + const sequenceWatermarkRef = {current: undefined as number | undefined} + const setMessages = vi.fn() + const busyRef = {current: false} + const seenIdsRef = {current: new Set()} + const restoredIdsRef = {current: new Set()} + const persistMessages = vi.fn() + const intent = { + armJump: vi.fn(), + stickRef: {current: false}, + } as unknown as ScrollIntent + const pendingResumeRef = {current: null} + const container = document.createElement("div") + const root = createRoot(container) + let hydration: ReturnType | undefined + + const Probe = () => { + hydration = useSessionHydration({ + sessionId: "session-1", + initialMessages: [], + messagesRef, + busyRef, + seenIdsRef, + restoredIdsRef, + recordWatermarkRef, + sequenceWatermarkRef, + busy: false, + setMessages, + persistMessages, + clearRunError: vi.fn(), + intent, + pendingResumeRef, + }) + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect: hydration.refreshFromRecords, + }) + return null + } + + await act(async () => { + root.render(createElement(Provider, {store}, createElement(Probe))) + }) + await vi.waitFor(() => expect(mocks.openedUrls).toHaveLength(1)) + expect(recordWatermarkRef.current).toBe(2) + expect(sequenceWatermarkRef.current).toBe(10) + expect(setMessages).toHaveBeenCalledOnce() + expect(mocks.openedUrls[0]).toContain("/sessions/session-1/events?after=10") + + messagesRef.current = setMessages.mock.calls[0][0] + const laterMessages = [ + { + id: "assistant-1", + role: "assistant", + parts: [{type: "text", text: "new retained tail"}], + } as UIMessage, + ] + await expect( + hydration!.refreshFromRecords({ + messages: laterMessages, + recordCount: 2, + sequenceCursor: 11, + }), + ).resolves.toBe(true) + expect(recordWatermarkRef.current).toBe(2) + expect(sequenceWatermarkRef.current).toBe(11) + expect(setMessages).toHaveBeenLastCalledWith(laterMessages) + act(() => root.unmount()) + }) + + it.each(["rejected", "undefined"] as const)( + "keeps the desktop transcript when a watch-triggered read is %s", + async (failure) => { + if (failure === "rejected") { + mocks.loadSessionMessages.mockRejectedValueOnce(new Error("network changed")) + } else { + mocks.loadSessionMessages.mockResolvedValueOnce(undefined) + } + const store = createStore() + store.set(projectIdAtom, "project-1") + const messagesRef = {current: [] as UIMessage[]} + const setMessages = vi.fn() + const container = document.createElement("div") + const root = createRoot(container) + let hydration: ReturnType | undefined + + const Probe = () => { + hydration = useSessionHydration({ + sessionId: "session-1", + initialMessages: [], + messagesRef, + busyRef: {current: false}, + seenIdsRef: {current: new Set()}, + restoredIdsRef: {current: new Set()}, + recordWatermarkRef: {current: undefined}, + sequenceWatermarkRef: {current: undefined}, + busy: false, + setMessages, + persistMessages: vi.fn(), + intent: { + armJump: vi.fn(), + stickRef: {current: false}, + } as unknown as ScrollIntent, + pendingResumeRef: {current: null}, + }) + return null + } + + await act(async () => { + root.render(createElement(Provider, {store}, createElement(Probe))) + }) + let adopted: boolean | undefined + await act(async () => { + adopted = await hydration!.refreshFromRecords( + new MessageEvent("records-changed") as never, + ) + }) + + expect(adopted).toBe(false) + expect(setMessages).not.toHaveBeenCalled() + act(() => root.unmount()) + }, + ) +}) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts index 99d7416e2ad..4d8de30efda 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts @@ -1,6 +1,7 @@ import {type MutableRefObject, useCallback, useEffect, useRef, useState} from "react" -import {loadSessionMessages, type SessionTranscript} from "@agenta/chat/assets" +import {isSessionTranscript, loadSessionMessages, type SessionTranscript} from "@agenta/chat/assets" +import {withoutSharedSenderAcceptanceMessages} from "@agenta/chat/model" import {hasSessionChat, isSessionFresh} from "@agenta/chat/state" import { fetchSessionRecordsAtom, @@ -106,9 +107,11 @@ export const useSessionHydration = ({ seenIdsRef, restoredIdsRef, recordWatermarkRef, + sequenceWatermarkRef, busy, setMessages, persistMessages, + clearRunError, intent, pendingResumeRef, }: { @@ -120,10 +123,14 @@ export const useSessionHydration = ({ restoredIdsRef: MutableRefObject> /** Records the rendered transcript was built from; `undefined` once a live turn supersedes it. */ recordWatermarkRef: MutableRefObject + /** Durable sequence coverage for sequenced reconnect snapshots; never a row count. */ + sequenceWatermarkRef: MutableRefObject /** THIS browser is streaming the turn — reactive, so the catch-up poll can start/stop on it. */ busy: boolean setMessages: (messages: UIMessage[]) => void persistMessages: (args: {id: string; messages: UIMessage[]; recordCount?: number}) => void + /** Drop the stream error `useChat` is holding. Adopting the log supersedes it. */ + clearRunError: () => void intent: ScrollIntent /** * Non-null while a client-tool settle (connect Not-now/Connect, an elicitation answer) has @@ -161,14 +168,18 @@ export const useSessionHydration = ({ * record log has grown past what we're rendering. Returns whether it adopted. */ const adoptServerTranscript = useCallback( - (transcript: SessionTranscript | null, {armJump = true} = {}): boolean => { - if (!transcript) return false - const {messages: serverMsgs, recordCount, interactionRows} = transcript + (transcript: unknown, {armJump = true} = {}): boolean => { + if (!isSessionTranscript(transcript)) return false + const {messages: serverMsgs, recordCount, sequenceCursor, interactionRows} = transcript const adopt = shouldAdoptServerTranscript({ - serverRecordCount: recordCount, + serverRecordCount: sequenceCursor ?? recordCount, serverMessageCount: serverMsgs.length, - localMessageCount: messagesRef.current.length, - watermark: recordWatermarkRef.current, + localMessageCount: withoutSharedSenderAcceptanceMessages(messagesRef.current) + .length, + watermark: + sequenceCursor === undefined + ? recordWatermarkRef.current + : sequenceWatermarkRef.current, busy: busyRef.current, // #5942: a card still parked on the user outranks the log — adopting over it // discards whatever they typed into its form. @@ -193,6 +204,11 @@ export const useSessionHydration = ({ // length, that keeps the guard order-independent and stops an older snapshot from // clobbering a newer one. recordWatermarkRef.current = recordCount + if (sequenceCursor !== undefined) sequenceWatermarkRef.current = sequenceCursor + // The log just superseded what this tab was rendering, a failed request of our own + // included. `useChat` holds that error until the next send and the session dot reads + // it, so without this the dot stays red beside a finished turn. + clearRunError() setMessages(serverMsgs) persistMessages({id: sessionId, messages: serverMsgs, recordCount}) return true @@ -208,8 +224,10 @@ export const useSessionHydration = ({ seenIdsRef, restoredIdsRef, recordWatermarkRef, + sequenceWatermarkRef, setMessages, persistMessages, + clearRunError, intent.armJump, intent.stickRef, ], @@ -319,11 +337,7 @@ export const useSessionHydration = ({ }, [sessionId, readLog]) // ── Follow a run happening somewhere else (#5530) ────────────────────────── - // There is no push channel to browsers: the runner publishes every event to Redis, but the only - // consumer is the ingest worker that writes them to the DB. So a session driven from another tab - // or device is followed by re-reading the durable log on a timer, and the adoption guard above - // decides whether anything actually changed. `isRunning` also covers OUR stream, so the atom - // excludes every case where this browser is the one driving (#5844). + // Live frames display immediately; durable polling converges events outside the frame subset. // // The settle stamp the derivation needs is written here rather than inside the package's // `setSessionStatusAtom`: this hook is mounted for the whole life of a session tab, which is @@ -332,6 +346,7 @@ export const useSessionHydration = ({ // `busy` stays as a second guard: it flips on the SEND commit, one commit before the status // atom the derivation reads, so it hides the strip a frame earlier when a local send takes over // a session that genuinely was running elsewhere. + const liveness = useAtomValue(sessionLivenessAtomFamily(sessionId)) const runningElsewhere = useAtomValue(sessionRunningElsewhereAtomFamily(sessionId)) && !busy useEffect(() => { @@ -398,7 +413,6 @@ export const useSessionHydration = ({ // CONCLUSIVE: `records: []` is a confirmed-empty log and stamps; `records: null` is a failed // fetch and never stamps — it retries a bounded burst, then re-arms so a later dependency // change can try again instead of latching the recovery out for the rest of the mount. - const liveness = useAtomValue(sessionLivenessAtomFamily(sessionId)) const strandedCheckRef = useRef<"idle" | "pending" | "done">("idle") useEffect(() => { if (strandedCheckRef.current !== "idle" || isHydrating || busy) return @@ -452,37 +466,66 @@ export const useSessionHydration = ({ const activeSessionId = useAtomValue(activeSessionIdAtomFamily(scopeKey)) const projectId = useAtomValue(projectIdAtom) const revalidateSessionRecords = useSetAtom(revalidateSessionRecordsAtom) - const refreshFromRecords = useCallback(() => { - // Entry check: skip while THIS tab streams (already the live truth, `onFinish` - // revalidates) OR a client-tool settle is already waiting on its resume dispatch — see - // `shouldSkipRecordsRefresh`. - if ( - shouldSkipRecordsRefresh({ - busy: busyRef.current, - pendingResume: !!pendingResumeRef.current, - }) - ) - return - // A tick usually lands inside the records query's stale window, so the shared cache would - // resolve unchanged; invalidate first, then adopt through the SAME guard as every other path. - revalidateSessionRecords(sessionId) - void readLog().then((transcript) => { + const refreshFromRecords = useCallback( + async (transcript?: SessionTranscript): Promise => { + const adoptOrConfirm = (candidate: unknown): boolean => { + if (!isSessionTranscript(candidate)) return false + const candidateWatermark = candidate.sequenceCursor ?? candidate.recordCount + const currentWatermark = + candidate.sequenceCursor === undefined + ? recordWatermarkRef.current + : sequenceWatermarkRef.current + return ( + adoptServerTranscriptRef.current(candidate, {armJump: false}) || + (currentWatermark ?? 0) >= candidateWatermark + ) + } + // Entry check: skip while THIS tab streams (already the live truth, `onFinish` + // revalidates) OR a client-tool settle is already waiting on its resume dispatch — see + // `shouldSkipRecordsRefresh`. + if ( + shouldSkipRecordsRefresh({ + busy: busyRef.current, + pendingResume: !!pendingResumeRef.current, + }) + ) + return false + if (isSessionTranscript(transcript)) { + return adoptOrConfirm(transcript) + } + // A tick usually lands inside the records query's stale window, so the shared cache would + // resolve unchanged; invalidate first, then adopt through the SAME guard as every other path. + revalidateSessionRecords(sessionId) + let refreshed: SessionTranscript | null + try { + refreshed = await readLog() + } catch { + return false + } // Adoption-point recheck: the entry check above only covers the window BEFORE this // fetch started. `loadSessionMessages` is a real network round trip, and a client-tool // settle can land while it's in flight — without re-checking here, that settle arrives - // busy=false/pendingResume=true, passes nothing, and this `.then` still clobbers it - // with the (now stale) transcript it fetched before the settle happened. + // busy=false/pendingResume=true, passes nothing, and this still clobbers it with stale data. if ( shouldSkipRecordsRefresh({ busy: busyRef.current, pendingResume: !!pendingResumeRef.current, }) ) - return + return false // A background catch-up must not yank a reader who scrolled up — as with the poll. - adoptServerTranscriptRef.current(transcript, {armJump: false}) - }) - }, [sessionId, busyRef, pendingResumeRef, revalidateSessionRecords, readLog]) + return adoptOrConfirm(refreshed) + }, + [ + sessionId, + busyRef, + pendingResumeRef, + recordWatermarkRef, + sequenceWatermarkRef, + revalidateSessionRecords, + readLog, + ], + ) // `ready` fires on every connect — each tab activation, each return to the foreground — so it // must not repeat a read the mount is already doing. A change that lands after the subscribe // arrives as `records-changed`, which is never skipped (#6296). @@ -506,7 +549,10 @@ export const useSessionHydration = ({ }, enabled: activeSessionId === sessionId, onReady: refreshOnReady, - onRecordsChanged: refreshFromRecords, + onRecordsChanged: () => { + void refreshFromRecords() + }, + sharedReaderAdvertised: liveness.sharedReader, }) return { @@ -516,5 +562,7 @@ export const useSessionHydration = ({ stopStateLoading: liveness.isLoading, sessionTurnId: liveness.turnId, stoppingTurnId: liveness.stoppingTurnId, + sharedReaderAdvertised: liveness.sharedReader, + refreshFromRecords, } } diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts index 891b6a3073c..bf1db11cbce 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts @@ -1,3 +1,6 @@ +import {useRef} from "react" + +import {shouldRefreshLegacyObserverLiveness} from "@agenta/chat/model" import {useWatchEventSource} from "@agenta/sessions/watch" import {useQueryClient} from "@tanstack/react-query" @@ -23,6 +26,7 @@ export const useSessionRecordsWatch = ({ onReady, onRecordsChanged, onInteractionChanged, + sharedReaderAdvertised, }: { sessionId: string projectId?: string | null @@ -32,25 +36,44 @@ export const useSessionRecordsWatch = ({ onReady: () => void onRecordsChanged: () => void onInteractionChanged: () => void + sharedReaderAdvertised: boolean }): void => { const queryClient = useQueryClient() + const lastLivenessRefreshAtRef = useRef(0) const url = sessionId && projectId ? sessionWatchUrl(sessionId, projectId) : null + const refreshLiveness = () => { + lastLivenessRefreshAtRef.current = Date.now() + void queryClient.invalidateQueries({queryKey: ["session-liveness"]}) + } + const refreshLegacyObserverLiveness = () => { + const now = Date.now() + if ( + !shouldRefreshLegacyObserverLiveness({ + sharedReaderAdvertised, + lastRefreshAt: lastLivenessRefreshAtRef.current, + now, + }) + ) + return + refreshLiveness() + } useWatchEventSource({ url, enabled, refreshSession, on: { ready: onReady, - "records-changed": onRecordsChanged, + "records-changed": () => { + onRecordsChanged() + refreshLegacyObserverLiveness() + }, interaction: onInteractionChanged, // A session that ends without this tab running it — a Stop from elsewhere, or the // execution watchdog settling a turn whose runner went silent. The records arrive // on their own event; this is the half that stops the session still LOOKING alive, // which otherwise waits out the 15s liveness poll. Mobile already does this // (web/mobile/src/features/chat/useSessionWatch.ts). - lifecycle: () => { - void queryClient.invalidateQueries({queryKey: ["session-liveness"]}) - }, + lifecycle: refreshLiveness, }, }) } diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts index d18a8962fd1..a10fe89f260 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts @@ -8,7 +8,7 @@ */ import {describe, expect, it} from "vitest" -import {isRunningElsewhere} from "./liveness" +import {deriveSessionRemoteTurnPresentation, isRunningElsewhere} from "./liveness" /** A session this browser has never run: no settle stamp, so the flag is trusted as-is. */ const neverRanHere = {localStatus: "idle", localSettledAt: undefined} as const @@ -83,3 +83,60 @@ describe("isRunningElsewhere", () => { ).toBe(true) }) }) + +describe("deriveSessionRemoteTurnPresentation", () => { + it.each([ + { + name: "renders activity and no strip for a ready reader", + input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: true}, + expected: {showActivity: true, showStrip: false}, + }, + { + name: "renders the strip while the reader is not ready", + input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: false}, + expected: {showActivity: false, showStrip: true}, + }, + { + name: "renders the strip when the feature is off", + input: {livenessRunning: true, sharedReaderAdvertised: false, readerReady: false}, + expected: {showActivity: false, showStrip: true}, + }, + { + name: "does not render the strip in the tab that owns a continuation", + input: { + livenessRunning: true, + sharedReaderAdvertised: true, + readerReady: false, + ownedContinuation: true, + }, + expected: {showActivity: false, showStrip: false}, + }, + ])("$name", ({input, expected}) => { + expect(deriveSessionRemoteTurnPresentation(input)).toEqual(expected) + }) + + it("shows the flag-off observer banner only while session-stream liveness is running", () => { + const input = { + snapshotRunning: true, + sharedReaderAdvertised: false, + readerReady: false, + } + + expect( + deriveSessionRemoteTurnPresentation({...input, livenessRunning: true}).showStrip, + ).toBe(true) + expect( + deriveSessionRemoteTurnPresentation({...input, livenessRunning: false}).showStrip, + ).toBe(false) + }) + + it("hides the banner when the advertised reader is ready", () => { + expect( + deriveSessionRemoteTurnPresentation({ + livenessRunning: true, + sharedReaderAdvertised: true, + readerReady: true, + }).showStrip, + ).toBe(false) + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.ts b/web/oss/src/components/AgentChatSlice/state/liveness.ts index c4c677bb4bc..fdfa0f5e2c8 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.ts @@ -1,4 +1,4 @@ -import {type SessionRunStatus} from "@agenta/chat/model" +import {deriveRemoteTurnPresentation, type SessionRunStatus} from "@agenta/chat/model" import {sessionLocalSettledAtAtomFamily, sessionStatusAtomFamily} from "@agenta/chat/state" import { deriveSessionLifecycle, @@ -51,6 +51,8 @@ export interface SessionLiveness { turnId: string | null stoppingTurnId: string | null isLoading: boolean + /** Server-advertised temporary frame relay for non-owning readers. */ + sharedReader: boolean } /** @@ -66,6 +68,7 @@ export const sessionLivenessAtomFamily = atomFamily((sessionId: string) => turnId: stream?.turn_id ?? null, stoppingTurnId: stream?.stopping_turn_id ?? null, isLoading: get(aliveStreamsQueryAtom).isLoading, + sharedReader: Boolean(stream?.capabilities?.shared_reader), } }), ) @@ -122,6 +125,9 @@ export const isRunningElsewhere = ({ return localSettledAt === undefined || livenessUpdatedAt > localSettledAt } +/** Desktop presentation for a remote/shared-path run. The strip is only the disconnected fallback. */ +export const deriveSessionRemoteTurnPresentation = deriveRemoteTurnPresentation + /** `isRunningElsewhere` bound to this session's local status and the shared liveness query. */ export const sessionRunningElsewhereAtomFamily = atomFamily((sessionId: string) => atom((get): boolean => diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts index c9c0b2ee1e3..6f796e6e95c 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.ts @@ -2614,4 +2614,70 @@ export class SessionsClient { "/sessions/{session_id}/cancel", ); } + + /** + * Fetch a consistent reconnect snapshot and its durable sequence watermark. + * + * @param {AgentaApi.GetSessionSnapshotRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + */ + public getSessionSnapshot( + request: AgentaApi.GetSessionSnapshotRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__getSessionSnapshot(request, requestOptions)); + } + + private async __getSessionSnapshot( + request: AgentaApi.GetSessionSnapshotRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId } = request; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)) ?? + environments.AgentaApiEnvironment.Default, + `sessions/${core.url.encodePathParam(sessionId)}`, + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 30) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + withCredentials: true, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as AgentaApi.SessionSnapshotResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 422: + throw new AgentaApi.UnprocessableEntityError( + _response.error.body as AgentaApi.HttpValidationError, + _response.rawResponse, + ); + default: + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/sessions/{session_id}"); + } } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/GetSessionSnapshotRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/GetSessionSnapshotRequest.ts new file mode 100644 index 00000000000..25ca5cedb26 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/GetSessionSnapshotRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * session_id: "session_id" + * } + */ +export interface GetSessionSnapshotRequest { + session_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordQueryRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordQueryRequest.ts index 4d26e502d8e..8f82e13f76c 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordQueryRequest.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionRecordQueryRequest.ts @@ -1,5 +1,7 @@ // This file was auto-generated by Fern from our API Definition. +import type * as AgentaApi from "../../../../index.js"; + /** * @example * { @@ -8,4 +10,5 @@ */ export interface SessionRecordQueryRequest { session_id: string; + windowing?: AgentaApi.SessionTranscriptWindowing | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts index 304c3ec79d2..c6f8ae0fe7c 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.ts @@ -10,6 +10,7 @@ export type { FetchInteractionRequest } from "./FetchInteractionRequest.js"; export type { FetchSessionMountsRequest } from "./FetchSessionMountsRequest.js"; export type { FetchSessionStreamRequest } from "./FetchSessionStreamRequest.js"; export type { FetchTurnRequest } from "./FetchTurnRequest.js"; +export type { GetSessionSnapshotRequest } from "./GetSessionSnapshotRequest.js"; export type { GetRecordEventRequest } from "./GetRecordEventRequest.js"; export type { SessionAttachmentReferenceRequest } from "./SessionAttachmentReferenceRequest.js"; export type { SessionDetachRequest } from "./SessionDetachRequest.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts index 8f868ae1761..d6394c0cd94 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionRecord.ts @@ -10,6 +10,7 @@ export interface SessionRecord { record_id: string; session_id: string; project_id: string; + sequence?: (number | null) | undefined; record_index?: (number | null) | undefined; timestamp?: (string | null) | undefined; record_type?: (string | null) | undefined; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsQueryResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsQueryResponse.ts index 306892fcec6..cead75fae9f 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsQueryResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsQueryResponse.ts @@ -5,4 +5,5 @@ import type * as AgentaApi from "../index.js"; export interface SessionRecordsQueryResponse { count: number; records: AgentaApi.SessionRecord[]; + windowing?: (AgentaApi.SessionTranscriptWindowing | null) | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsReadState.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsReadState.ts new file mode 100644 index 00000000000..f4c1e63501e --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionRecordsReadState.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionRecordsReadState { + latest_sequence: number; + history_complete: boolean; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts new file mode 100644 index 00000000000..9a107715202 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface SessionSnapshotPending { + inputs?: unknown[] | undefined; + interactions?: AgentaApi.SessionInteraction[] | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts new file mode 100644 index 00000000000..3253bdc3987 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface SessionSnapshotResponse { + session: AgentaApi.SessionStream; + execution?: (AgentaApi.SessionTurn | null) | undefined; + pending: AgentaApi.SessionSnapshotPending; + read: AgentaApi.SessionRecordsReadState; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionTranscriptWindowing.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionTranscriptWindowing.ts new file mode 100644 index 00000000000..7b2d2ba30cc --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionTranscriptWindowing.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionTranscriptWindowing { + offset?: number | undefined; + limit?: number | undefined; + through_sequence: number; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/index.ts b/web/packages/agenta-api-client/src/generated/api/types/index.ts index 6df14bd2fb2..cad4a931cad 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/index.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/index.ts @@ -404,6 +404,9 @@ export * from "./SessionPredicatesRequest.js"; export * from "./SessionRecord.js"; export * from "./SessionRecordResponse.js"; export * from "./SessionRecordsQueryResponse.js"; +export * from "./SessionRecordsReadState.js"; +export * from "./SessionSnapshotPending.js"; +export * from "./SessionSnapshotResponse.js"; export * from "./SessionReference.js"; export * from "./SessionResponse.js"; export * from "./SessionStream.js"; @@ -413,6 +416,7 @@ export * from "./SessionStreamHeaderEdit.js"; export * from "./SessionStreamQueryFlags.js"; export * from "./SessionStreamResponse.js"; export * from "./SessionStreamsResponse.js"; +export * from "./SessionTranscriptWindowing.js"; export * from "./SessionsResponse.js"; export * from "./SessionTrigger.js"; export * from "./SessionTriggerKind.js"; diff --git a/web/packages/agenta-chat/src/assets/loadSession.ts b/web/packages/agenta-chat/src/assets/loadSession.ts index 960fb0e21f5..d445f05f4b7 100644 --- a/web/packages/agenta-chat/src/assets/loadSession.ts +++ b/web/packages/agenta-chat/src/assets/loadSession.ts @@ -33,11 +33,16 @@ import {transcriptToMessages} from "./transcriptToMessages" export interface SessionTranscript { messages: UIMessage[] /** - * How many durable records this transcript was built from. The log is append-only and ordered, - * so this is an EXACT "has the server moved on?" watermark — unlike a message count, which - * `transcriptToMessages` deliberately holds flat while a turn grows (issue #5530). + * How many durable records this transcript was built from. This remains distinct from the + * sequence cursor because retention can hold the row count flat while the log moves forward. */ recordCount: number + /** + * Highest durable sequence covered by this transcript. Undefined for legacy, unsequenced logs. + * Snapshot hydration supplies its authoritative upper bound even when retention or filtered + * records make the visible sequence values sparse. + */ + sequenceCursor?: number /** * The interaction lifecycle rows this transcript was replayed against (#5942). Records never * carry a row's later lifecycle, so this is the only place the adoption guard can see whether @@ -47,6 +52,25 @@ export interface SessionTranscript { interactionRows?: SessionInteractionRowStates } +/** Runtime boundary for watch callbacks and best-effort transcript reads. */ +export const isSessionTranscript = (value: unknown): value is SessionTranscript => { + if (!value || typeof value !== "object") return false + const candidate = value as Partial + return ( + Array.isArray(candidate.messages) && + typeof candidate.recordCount === "number" && + Number.isFinite(candidate.recordCount) && + (candidate.sequenceCursor === undefined || + (typeof candidate.sequenceCursor === "number" && + Number.isFinite(candidate.sequenceCursor))) + ) +} + +const sequenceCursorForRecords = (records: {sequence?: number | null}[]): number | undefined => { + const cursor = records.reduce((latest, record) => Math.max(latest, record.sequence ?? 0), 0) + return cursor || undefined +} + export const loadSessionMessages = async ( sessionId: string, onRefreshed?: (transcript: SessionTranscript) => void, @@ -72,6 +96,7 @@ export const loadSessionMessages = async ( onRefreshed({ messages: freshMsgs, recordCount: fresh.length, + sequenceCursor: sequenceCursorForRecords(fresh), interactionRows: interactionRowStates, }) } @@ -86,7 +111,12 @@ export const loadSessionMessages = async ( if (!records || records.length === 0) return null const messages = transcriptToMessages(records, {interactionRowStates}) return messages - ? {messages, recordCount: records.length, interactionRows: interactionRowStates} + ? { + messages, + recordCount: records.length, + sequenceCursor: sequenceCursorForRecords(records), + interactionRows: interactionRowStates, + } : null } catch (err) { console.warn("[loadSessionMessages] hydration fetch failed:", err) diff --git a/web/packages/agenta-chat/src/components/ConnectionWarningStrip.tsx b/web/packages/agenta-chat/src/components/ConnectionWarningStrip.tsx new file mode 100644 index 00000000000..55e7fead9bd --- /dev/null +++ b/web/packages/agenta-chat/src/components/ConnectionWarningStrip.tsx @@ -0,0 +1,26 @@ +import {cn} from "@agenta/ui/ui" +import {WarningCircle} from "@phosphor-icons/react" + +/** Ephemeral sender-connection state; the accepted turn remains owned by the session. */ +export const ConnectionWarningStrip = ({ + message, + className, +}: { + message: string + className?: string +}) => ( +
+ + + Connection interrupted + {message} + +
+) diff --git a/web/packages/agenta-chat/src/components/RunningElsewhereStrip.tsx b/web/packages/agenta-chat/src/components/RunningElsewhereStrip.tsx index 827fb23b184..9d692eb2b6f 100644 --- a/web/packages/agenta-chat/src/components/RunningElsewhereStrip.tsx +++ b/web/packages/agenta-chat/src/components/RunningElsewhereStrip.tsx @@ -7,9 +7,8 @@ import {cn} from "@agenta/ui/ui" * session while THIS browser isn't the one streaming it (another tab, another device). * * Issue #5530: a second browser gave no sign at all that anything was happening, so a session that - * was mid-turn looked identical to an idle one. There is no push channel to browsers today, so the - * transcript catches up by polling the durable record log — this strip is what makes that - * legible instead of looking frozen. + * was mid-turn looked identical to an idle one. This is now the fallback while the shared reader + * is disabled or disconnected; a ready reader streams the transcript and shows turn activity. * * NOT shown while this browser is the one streaming: the composer's send button is already a Stop * and the transcript already shows the turn working, so a second "running" banner is noise — and @@ -47,8 +46,8 @@ export const RunningElsewhereStrip = ({ - This session is running somewhere else — the transcript updates as the turn progresses. - If it stays still, the run may have already ended. + This turn is still running — the transcript updates as it progresses. If it stays still, + the run may have already ended. {action ? {action} : null} diff --git a/web/packages/agenta-chat/src/components/SessionHistoryNotice.tsx b/web/packages/agenta-chat/src/components/SessionHistoryNotice.tsx new file mode 100644 index 00000000000..c2e857c621b --- /dev/null +++ b/web/packages/agenta-chat/src/components/SessionHistoryNotice.tsx @@ -0,0 +1,58 @@ +import {cn} from "@agenta/ui/ui" +import {CircleNotch, WarningCircle} from "@phosphor-icons/react" + +export type SessionHistoryNoticeState = "reconnecting" | "incomplete" + +const COPY: Record = { + reconnecting: { + title: "Reconnecting to this session", + detail: "Restoring the durable transcript before live updates resume.", + }, + incomplete: { + title: "Some earlier history is unavailable", + detail: "New activity will keep updating, but this transcript may be missing older events.", + }, +} + +/** User-visible reconnect/history integrity states shared by desktop and mobile chat surfaces. */ +export const SessionHistoryNotice = ({ + state, + className, +}: { + state: SessionHistoryNoticeState + className?: string +}) => { + const copy = COPY[state] + const reconnecting = state === "reconnecting" + + return ( +
+ {reconnecting ? ( + + ) : ( + + )} + + {copy.title} + {copy.detail} + +
+ ) +} diff --git a/web/packages/agenta-chat/src/components/index.ts b/web/packages/agenta-chat/src/components/index.ts index 521f2c77264..484450632a8 100644 --- a/web/packages/agenta-chat/src/components/index.ts +++ b/web/packages/agenta-chat/src/components/index.ts @@ -27,6 +27,8 @@ export {TurnFooter} from "./TurnFooter" export {TurnMetrics} from "./TurnMetrics" export {TurnTimestamp} from "./TurnTimestamp" export {RunningElsewhereStrip} from "./RunningElsewhereStrip" +export {ConnectionWarningStrip} from "./ConnectionWarningStrip" +export {SessionHistoryNotice, type SessionHistoryNoticeState} from "./SessionHistoryNotice" export {StartupActivity, WaitingForInput, WorkingDots} from "./TurnActivity" export {ConnectionDock, type ConnectionDockProps} from "./ConnectionDock" export {ElicitationDock, type ElicitationDockProps} from "./ElicitationDock" diff --git a/web/packages/agenta-chat/src/hooks/index.ts b/web/packages/agenta-chat/src/hooks/index.ts index 3a49e87ec92..936322bdd70 100644 --- a/web/packages/agenta-chat/src/hooks/index.ts +++ b/web/packages/agenta-chat/src/hooks/index.ts @@ -16,3 +16,4 @@ export * from "./useVoiceComposer" export * from "./useSessionChat" export * from "./useTypewriter" export * from "./useHardwareKeyboard" +export * from "./useSessionLivePreview" diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index f6572babe03..0321c86d72b 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -19,6 +19,9 @@ export interface QueuedMessage { interface UseAgentChatQueueArgs { status: string messages: UIMessage[] + /** The invoke stream disconnected after acceptance, but the shared session run still owns the + * turn. New messages stay queued until its durable terminal event arrives. */ + acceptedRunPending?: boolean /** The last turn was user-stopped (cancelled). A stop voids any pending approval / imminent * auto-resume, so the aborted turn's tool parts still reading as mid-HITL must NOT hold a new * send — a stopped-and-settled conversation is releasable. */ @@ -53,6 +56,7 @@ const queuedBySession = new Map() export const useAgentChatQueue = ({ status, messages, + acceptedRunPending = false, stopped, resumeOrphaned = false, sendQueued, @@ -74,7 +78,8 @@ export const useAgentChatQueue = ({ // Releasable now: the normal gate, OR a settled turn whose hold was voided — by a user stop, // or by an orphaned restored resume shape that nothing in this mount can ever fire. const canReleaseNow = - canReleaseQueuedMessage(status, messages) || ((stopped || resumeOrphaned) && settled) + !acceptedRunPending && + (canReleaseQueuedMessage(status, messages) || ((stopped || resumeOrphaned) && settled)) // A stop voids the gate for release (above), so it must void it for reporting too — else the // aborted turn's lingering `approval-requested` part still reads as "awaiting" while `submit` diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 9adb1793db3..fe3900e5b37 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -42,12 +42,17 @@ import {useSetAtom, useStore} from "jotai" import {latestTurnId} from "../assets/agentTurn" import {buildRequestWithinDeadline} from "../assets/boundedRequest" import {filesToParts} from "../assets/files" -import {loadSessionMessages, type SessionTranscript} from "../assets/loadSession" +import { + isSessionTranscript, + loadSessionMessages, + type SessionTranscript, +} from "../assets/loadSession" import {messageText, sideEffectingToolsInRange} from "../assets/rewind" import {startupLabelFromDataPart} from "../assets/startupPhases" import {getMessageTraceId} from "../assets/trace" import {isClientToolPart as defaultIsClientToolPart} from "../clientTools" -import {parseAgentRunError, type ParsedRunError} from "../model/error" +import {classifyAgentRunError, type ParsedRunError, type RunErrorMetadata} from "../model/error" +import {withoutSharedSenderAcceptanceMessages} from "../model/livePreview" import {deriveSessionRunStatus, type SessionRunStatus} from "../model/sessionStatus" import { buildTurnViewModels, @@ -65,11 +70,14 @@ import { type SessionChatHooks, } from "../state/sessionChats" import { + acceptedRunBySession, clearSessionFresh, clearSessionTurnId, composerDraftBySession, isSessionFresh, setSessionTurnId, + turnDeliverySourceBySession, + type TurnDeliverySource, } from "../state/sessionEphemera" import { persistSessionMessagesAtom, @@ -82,6 +90,7 @@ import {clearTurnClockAtom, startTurnClockAtom} from "../state/turnClock" import {useAgentChatQueue, type QueuedMessage} from "./useAgentChatQueue" import {useApprovalDock, type ApprovalDock} from "./useApprovalDock" import {useSessionChat} from "./useSessionChat" +import {useSessionLivePreview} from "./useSessionLivePreview" /** A stream error/abort is already surfaced via `useChat`'s `onError` + the stamped in-chat * error; swallow the floating `sendMessage`/`regenerate` rejection so it doesn't bubble to a @@ -119,6 +128,12 @@ export interface ToolOutputSettleInput { export interface UseAgentConversationArgs { entityId: string sessionId: string + /** Backend-advertised ability to read display-only live frames. */ + sharedReaderAdvertised?: boolean + /** Backend liveness says a run is active, potentially in another browser. */ + sharedReaderRunning?: boolean + /** Timestamp of the liveness snapshot behind `sharedReaderRunning`. */ + sharedReaderLivenessUpdatedAt?: number /** Override the client-tool predicate. Defaults to the package registry's, so a host does not * have to opt IN to elicitation and connect widgets — /m shipped without one for months and * silently folded every client tool into the plain "used N tools" group, leaving the run @@ -135,6 +150,8 @@ export interface AgentConversation { runStatus: SessionRunStatus /** Parsed reason of the current stream failure, when there is one. */ error?: ParsedRunError + /** Ephemeral warning when the sender connection drops after the session accepted the turn. */ + connectionWarning?: string /** Pre-grouped per-turn view models (render items, status, empty-collapse, active turn). */ turns: TurnViewModel[] /** Send a user message (routes through the queue: sends now, or holds while busy/paused). */ @@ -179,6 +196,12 @@ export interface AgentConversation { * revalidate-on-open (never mid-stream, only when strictly ahead). Wire push signals — a * session watch relay, a foreground event — to this. */ revalidate: () => void + /** Atomic snapshot says an unfinished backend execution is still running after refresh. */ + runningFromSnapshot: boolean + /** The shared live-event channel completed replay and is following new frames. */ + readerReady: boolean + /** This browser's accepted turn is still owned by the shared session path. */ + acceptedRunPending: boolean } /** @@ -192,6 +215,9 @@ export interface AgentConversation { export const useAgentConversation = ({ entityId, sessionId, + sharedReaderAdvertised = false, + sharedReaderRunning = false, + sharedReaderLivenessUpdatedAt = 0, isClientToolPart, }: UseAgentConversationArgs): AgentConversation => { const store = useStore() @@ -205,7 +231,9 @@ export const useAgentConversation = ({ const clearTurnClock = useSetAtom(clearTurnClockAtom) // Seed once from the persisted store (read imperatively so our own writes don't feed back). - const [initialMessages] = useState(() => store.get(sessionMessagesAtom)[sessionId] ?? []) + const [initialMessages] = useState(() => + withoutSharedSenderAcceptanceMessages(store.get(sessionMessagesAtom)[sessionId] ?? []), + ) // Only the last assistant turn can carry the current stopped state. const [userStoppedState, dispatchStopped] = useReducer( reduceUserStoppedState, @@ -230,6 +258,8 @@ export const useAgentConversation = ({ const recordWatermarkRef = useRef( store.get(sessionRecordCountsReadAtom)[sessionId], ) + // Durable sequence coverage is connection-local and must never be stored as a row count. + const sequenceWatermarkRef = useRef(undefined) // The registry owns the `Chat` and its transport for the life of the session, so the request // builder must read the CURRENT entity — capturing `entityId` by value would send every turn @@ -247,13 +277,39 @@ export const useAgentConversation = ({ const liveGateInteractionRef = useRef(null) const recordInteractionAnswer = useSetAtom(recordInteractionAnswerAtom) + // Did the runner acknowledge THIS turn? Its acceptance frame is transient, so it reaches + // `onData` and never the transcript — this is the only place the answer survives. A stream that + // dies after it is a lost connection, not a lost turn; one that dies before it may be a send + // that never started, and that failure has to stay on screen and in the cache. + const turnAcceptedRef = useRef(acceptedRunBySession.has(sessionId)) + const acceptedExecutionIdRef = useRef( + acceptedRunBySession.get(sessionId) ?? null, + ) + const [acceptedRunPending, setAcceptedRunPending] = useState(() => + acceptedRunBySession.has(sessionId), + ) + const [turnDeliverySource, setTurnDeliverySource] = useState( + () => turnDeliverySourceBySession.get(sessionId) ?? null, + ) // Tracks `busy` for callbacks that outlive a render (the preserve verdict at unmount). const busyRef = useRef(false) + // Only a stream THIS client renders. A shared-delivered turn renders from the live frames, + // so the durable snapshot behind them stays adoptable — see the adoption guard below. + const localRenderBusyRef = useRef(false) const messagesRef = useRef(initialMessages) + const sharedSenderReadyRef = useRef(false) const hooks: SessionChatHooks = { prepareRequest: async ({messages, id}) => { clearSessionTurnId(sessionId) + turnAcceptedRef.current = false + acceptedExecutionIdRef.current = null + acceptedRunBySession.delete(sessionId) + setAcceptedRunPending(false) + const sharedResponse = sharedSenderReadyRef.current + const deliverySource: TurnDeliverySource = sharedResponse ? "shared" : "legacy" + turnDeliverySourceBySession.set(sessionId, deliverySource) + setTurnDeliverySource(deliverySource) // Bounded, not instant. A null build means the workflow entity has not loaded its // invocation URL YET — the first send to a freshly created agent races that fetch, and // failing on the first null made a new user's first message fail (#6042 on the desktop; @@ -261,6 +317,7 @@ export const useAgentConversation = ({ const req = await buildRequestWithinDeadline(() => buildAgentRequest(entityIdRef.current, messages, { sessionId: id ?? sessionId, + sharedResponse, }), ) return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} @@ -282,6 +339,14 @@ export const useAgentConversation = ({ // #6047 startup states: the runner narrates what it is doing while the environment boots, // so a 15s cold start reads as progress instead of a stalled session. onData: (part) => { + if (part.type === "data-session-accepted") { + turnAcceptedRef.current = true + const data = part.data as {executionId?: unknown} | undefined + acceptedExecutionIdRef.current = + typeof data?.executionId === "string" ? data.executionId : null + acceptedRunBySession.set(sessionId, acceptedExecutionIdRef.current) + setAcceptedRunPending(true) + } const label = startupLabelFromDataPart(part) if (label) setTurnStartupLabel(sessionId, label) }, @@ -340,6 +405,7 @@ export const useAgentConversation = ({ addToolApprovalResponse, addToolOutput, error, + clearError, } = useChat({ chat, // Coalesce stream deltas to ~1 UI commit / 50ms so a fast token stream doesn't drive a @@ -348,10 +414,28 @@ export const useAgentConversation = ({ }) const busy = isChatBusy(status) + const lastMessage = messages[messages.length - 1] + const serverErrorProvenance = + lastMessage?.role === "assistant" && + lastMessage.parts.some((part) => part.type === "data-agent-error") + const errorBoundary = useMemo( + () => + error + ? classifyAgentRunError(error, turnAcceptedRef.current, serverErrorProvenance) + : {}, + [error, serverErrorProvenance], + ) + // Require liveness newer than the local settle before classifying a run as remote. + const previousBusyForReaderRef = useRef(busy) + const localReaderSettleAtRef = useRef(0) + if (previousBusyForReaderRef.current && !busy) localReaderSettleAtRef.current = Date.now() + previousBusyForReaderRef.current = busy + const remoteRunIsFresh = sharedReaderLivenessUpdatedAt > localReaderSettleAtRef.current // `messages`/`busy` change every commit; consumers that must stay referentially stable // (`rewind`, the hydration/revalidation adoption guards) read them through refs instead. messagesRef.current = messages - busyRef.current = busy + busyRef.current = busy || acceptedRunPending + localRenderBusyRef.current = busy && !acceptedRunPending useEffect(() => { dispatchStopped({type: "transcript", messages}) @@ -391,15 +475,23 @@ export const useAgentConversation = ({ * trigger, the message count only a floor. Returns whether it adopted. */ const adoptServerTranscript = useCallback( - (transcript: SessionTranscript | null): boolean => { - if (!transcript) return false - const {messages: serverMsgs, recordCount} = transcript + (transcript: unknown): boolean => { + if (!isSessionTranscript(transcript)) return false + const {messages: serverMsgs, recordCount, sequenceCursor} = transcript const adopt = shouldAdoptServerTranscript({ - serverRecordCount: recordCount, + serverRecordCount: sequenceCursor ?? recordCount, serverMessageCount: serverMsgs.length, - localMessageCount: messagesRef.current.length, - watermark: recordWatermarkRef.current, - busy: busyRef.current, + localMessageCount: withoutSharedSenderAcceptanceMessages(messagesRef.current) + .length, + watermark: + sequenceCursor === undefined + ? recordWatermarkRef.current + : sequenceWatermarkRef.current, + // NOT `busyRef`: that one also covers an accepted shared turn, whose content this + // client never streams. Blocking adoption there stalls `hydrateAndOpen`, which + // reconnects instead of opening the events stream — so a shared turn that drops + // mid-run can never come back until it settles. + busy: localRenderBusyRef.current, }) if (!adopt) return false serverMsgs.forEach((m) => restoredIdsRef.current.add(m.id)) @@ -411,11 +503,17 @@ export const useAgentConversation = ({ // refetch) can both see the pre-adoption transcript. It is this watermark, not the // on-screen length, that keeps the guard order-independent. recordWatermarkRef.current = recordCount + if (sequenceCursor !== undefined) sequenceWatermarkRef.current = sequenceCursor + // The durable log just superseded whatever this browser was rendering, including a + // failed request of our own. `useChat` holds that error until the next send, and the + // session dot reads it — so without this the transcript shows the finished turn while + // the dot stays red, with nothing on screen to explain it. + clearError() setMessages(serverMsgs) persistMessages({id: sessionId, messages: serverMsgs, recordCount}) return true }, - [persistMessages, sessionId, setMessages], + [clearError, persistMessages, sessionId, setMessages], ) useEffect(() => { @@ -508,7 +606,6 @@ export const useAgentConversation = ({ // mount never streamed it) shaped like "auto-resume imminent", and no gate was settled live // in this mount. The SDK only evaluates `sendAutomaticallyWhen` on live events — never on // mount — so this resume can't fire and must not hold the queue (AGE-3937). - const lastMessage = messages[messages.length - 1] const resumeOrphaned = !liveGateInteractionRef.current && !!lastMessage && @@ -529,6 +626,7 @@ export const useAgentConversation = ({ } = useAgentChatQueue({ status, messages, + acceptedRunPending, stopped, resumeOrphaned, sendQueued, @@ -623,7 +721,11 @@ export const useAgentConversation = ({ // Publish this session's run state (single source of truth for session-list status dots). // Precedence error > awaiting approval > running > idle. - const runStatus = deriveSessionRunStatus({error: !!error, hitlPending, busy}) + const runStatus = deriveSessionRunStatus({ + error: !!errorBoundary.runError, + hitlPending, + busy: busy || acceptedRunPending, + }) useEffect(() => { setSessionStatus({id: sessionId, status: runStatus}) }, [runStatus, sessionId, setSessionStatus]) @@ -637,22 +739,20 @@ export const useAgentConversation = ({ [sessionId, setSessionStatus], ) - // Surface a stream failure inline: stamp the parsed error onto the failing assistant turn so - // it renders as an error bubble with the real reason (and persists with the session via the - // effect below), instead of a transient banner + a generic "no response". + // Run failures become conversation content; an accepted transport loss stays connection state. useEffect(() => { - if (!error) return - const parsed = parseAgentRunError(error) + const parsed = errorBoundary.runError + if (!parsed) return + const stamp: RunErrorMetadata = {runError: parsed} setMessages((prev) => { const last = prev.length > 0 ? prev[prev.length - 1] : undefined - const existing = (last?.metadata as {runError?: {message?: string}} | undefined) - ?.runError + const existing = (last?.metadata as RunErrorMetadata | undefined)?.runError if (last?.role === "assistant") { if (existing?.message === parsed.message) return prev // already stamped const next = [...prev] next[next.length - 1] = { ...last, - metadata: {...(last.metadata as object | undefined), runError: parsed}, + metadata: {...(last.metadata as object | undefined), ...stamp}, } return next } @@ -663,11 +763,11 @@ export const useAgentConversation = ({ id: `run-error-${generateId()}`, role: "assistant", parts: [], - metadata: {runError: parsed}, + metadata: stamp, } as (typeof prev)[number], ] }) - }, [error, setMessages]) + }, [errorBoundary.runError, setMessages]) // A live turn makes the transcript no longer a copy of the server's, and we can't know how many // records the runner logged for it — so drop the watermark and let the next open re-sync from @@ -675,14 +775,21 @@ export const useAgentConversation = ({ // flips to "submitted", effects run in declaration order, so clearing here is what stops the // persist below from filing a locally-extended transcript under a server watermark. useEffect(() => { - if (status === "submitted" || status === "streaming") recordWatermarkRef.current = undefined + if (status === "submitted" || status === "streaming") { + recordWatermarkRef.current = undefined + sequenceWatermarkRef.current = undefined + } }, [status]) // Persist the conversation whenever its stream settles (skip mid-stream), under whatever // watermark the rendered transcript still stands on (undefined once a live turn extended it). useEffect(() => { if (status === "streaming") return - persistMessages({id: sessionId, messages, recordCount: recordWatermarkRef.current}) + persistMessages({ + id: sessionId, + messages: withoutSharedSenderAcceptanceMessages(messages), + recordCount: recordWatermarkRef.current, + }) }, [messages, status, sessionId, persistMessages]) // One startup label per in-flight turn. `submitted` opens a NEW turn, so a label the previous @@ -718,9 +825,68 @@ export const useAgentConversation = ({ // Push-signal revalidation: same guarded adoption as revalidate-on-open, callable at any // time (a watch relay tick, app foregrounding). Guards make it idempotent and stream-safe. - const revalidate = useCallback(() => { - void loadSessionMessages(sessionId, adoptServerTranscript).then(adoptServerTranscript) - }, [adoptServerTranscript, sessionId]) + const revalidate = useCallback( + async (transcript?: SessionTranscript): Promise => { + const adoptOrConfirm = (candidate: unknown): boolean => { + if (!isSessionTranscript(candidate)) return false + const candidateWatermark = candidate.sequenceCursor ?? candidate.recordCount + const currentWatermark = + candidate.sequenceCursor === undefined + ? recordWatermarkRef.current + : sequenceWatermarkRef.current + return ( + adoptServerTranscript(candidate) || + (currentWatermark ?? 0) >= candidateWatermark + ) + } + if (isSessionTranscript(transcript)) { + return adoptOrConfirm(transcript) + } + revalidateSessionRecords(sessionId) + let adopted = false + let refreshed: SessionTranscript | null + try { + refreshed = await loadSessionMessages(sessionId, (fresh) => { + if (adoptOrConfirm(fresh)) adopted = true + }) + } catch { + return false + } + return adoptOrConfirm(refreshed) || adopted + }, + [adoptServerTranscript, revalidateSessionRecords, sessionId], + ) + + const { + messages: previewMessages, + runningFromSnapshot, + readerReady, + } = useSessionLivePreview({ + sessionId, + sharedReaderAdvertised, + runningElsewhere: sharedReaderRunning && !busy && remoteRunIsFresh, + sender: true, + onReadyChange: (ready) => { + sharedSenderReadyRef.current = ready + }, + onExecutionSettled: (executionId?: string) => { + const acceptedExecutionId = acceptedExecutionIdRef.current + if (executionId && acceptedExecutionId && acceptedExecutionId !== executionId) return + acceptedExecutionIdRef.current = null + acceptedRunBySession.delete(sessionId) + setAcceptedRunPending(false) + turnDeliverySourceBySession.delete(sessionId) + setTurnDeliverySource(null) + }, + onDisconnect: revalidate, + }) + const includePreview = turnDeliverySource !== "legacy" + const displayMessages = useMemo(() => { + const transcriptMessages = withoutSharedSenderAcceptanceMessages(messages) + return includePreview && previewMessages.length + ? [...transcriptMessages, ...previewMessages] + : transcriptMessages + }, [includePreview, messages, previewMessages]) // Fence a delayed approval release before the host's durable cancel request settles. const voidPendingResume = useCallback(() => { @@ -777,6 +943,7 @@ export const useAgentConversation = ({ const regenerateTurn = useCallback( (id: string) => { clearSessionTurnId(sessionId) + if (busyRef.current) return setStopped(false) regenerate({messageId: id}).catch(ignoreStreamRejection) }, @@ -818,22 +985,29 @@ export const useAgentConversation = ({ const [executedFor] = useState(() => createExecutedToolIdentityCache()) const turns = useMemo( () => - buildTurnViewModels(messages, { - busy, + buildTurnViewModels(displayMessages, { + busy: busy || (includePreview && previewMessages.length > 0), executedFor, isClientToolPart: (part, ctx) => (isClientToolPart ?? defaultIsClientToolPart)(part, ctx, renderMap), }), - [messages, busy, executedFor, isClientToolPart, renderMap], + [ + displayMessages, + busy, + executedFor, + isClientToolPart, + includePreview, + previewMessages.length, + renderMap, + ], ) - const parsedError = useMemo(() => (error ? parseAgentRunError(error) : undefined), [error]) - return { - messages, + messages: displayMessages, status, runStatus, - error: parsedError, + error: errorBoundary.runError, + connectionWarning: errorBoundary.connectionWarning, turns, send, voidPendingResume, @@ -841,7 +1015,7 @@ export const useAgentConversation = ({ regenerate: regenerateTurn, rewind, isHydrating, - isEmpty: messages.length === 0, + isEmpty: displayMessages.length === 0, historyUnavailable, stopped, queued, @@ -854,5 +1028,8 @@ export const useAgentConversation = ({ approvals, sendToolOutput, revalidate, + runningFromSnapshot, + readerReady, + acceptedRunPending, } } diff --git a/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts b/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts new file mode 100644 index 00000000000..e2293ec8378 --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts @@ -0,0 +1,291 @@ +import {useEffect, useMemo, useRef, useState} from "react" + +import { + clearSessionLivePreviewAtom, + fetchSessionInteractionStatesAtom, + fetchSessionSnapshot, + querySessionTranscript, + revalidateSessionInteractionsAtom, + sessionLivePreviewAtomFamily, +} from "@agenta/entities/session" +import {projectIdAtom} from "@agenta/shared/state" +import type {UIMessage} from "ai" +import {useAtom, useAtomValue, useSetAtom} from "jotai" + +import type {SessionTranscript} from "../assets/loadSession" +import {transcriptToMessages} from "../assets/transcriptToMessages" +import { + completeSessionDurableEventReplay, + createSessionDurableEventState, + reduceSessionDurableEvent, + shouldRefetchSessionTranscript, +} from "../model/durableEvents" +import { + isSessionSnapshotRunning, + reduceSessionLivePreview, + sessionLivePreviewMessages, + shouldSubscribeToSessionLivePreview, +} from "../model/livePreview" +import { + connectSessionLiveEvents, + type SessionLiveEventsConnection, +} from "../transport/sessionLiveEvents" + +const RECONNECT_INITIAL_DELAY_MS = 5_000 +const RECONNECT_MAX_DELAY_MS = 30_000 + +export const useSessionLivePreview = ({ + sessionId, + sharedReaderAdvertised, + runningElsewhere, + sender, + onReadyChange, + onExecutionSettled, + onDisconnect, +}: { + sessionId: string + /** Capability copied from the current backend session snapshot or liveness response. */ + sharedReaderAdvertised: boolean + /** True only when this browser is not the sender of the running turn. */ + runningElsewhere: boolean + /** Subscribe before this browser sends its next turn. */ + sender?: boolean + /** Non-reactive request-pipeline signal: true only while the shared event route is ready. */ + onReadyChange?: (ready: boolean) => void + /** Reports the shared path's durable terminal verdict for the current execution. */ + onExecutionSettled?: (executionId?: string) => void + /** Adopts a bounded transcript or re-fetches after a later gap/disconnect. */ + onDisconnect: (transcript?: SessionTranscript) => boolean | Promise +}): {messages: UIMessage[]; runningFromSnapshot: boolean; readerReady: boolean} => { + const projectId = useAtomValue(projectIdAtom) + const [preview, setPreview] = useAtom(sessionLivePreviewAtomFamily(sessionId)) + const clearPreview = useSetAtom(clearSessionLivePreviewAtom) + const fetchInteractionStates = useSetAtom(fetchSessionInteractionStatesAtom) + const revalidateInteractionStates = useSetAtom(revalidateSessionInteractionsAtom) + const [runningFromSnapshot, setRunningFromSnapshot] = useState(false) + const [readerReady, setReaderReady] = useState(false) + const onDisconnectRef = useRef(onDisconnect) + onDisconnectRef.current = onDisconnect + const retryHydrationRef = useRef<() => void>(() => undefined) + const onReadyChangeRef = useRef(onReadyChange) + onReadyChangeRef.current = onReadyChange + const onExecutionSettledRef = useRef(onExecutionSettled) + onExecutionSettledRef.current = onExecutionSettled + const subscribed = shouldSubscribeToSessionLivePreview({ + sharedReaderAdvertised, + runningElsewhere, + sender, + }) + + useEffect(() => { + if (!runningElsewhere) setRunningFromSnapshot(false) + }, [runningElsewhere]) + + useEffect(() => { + clearPreview(sessionId) + setReaderReady(false) + onReadyChangeRef.current?.(false) + if (!sharedReaderAdvertised || !sessionId) return + setRunningFromSnapshot(false) + if (!subscribed) return + if (typeof window === "undefined" || typeof window.EventSource === "undefined") return + + let connection: SessionLiveEventsConnection | null = null + let disposed = false + let reconnectTimer: ReturnType | null = null + let reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS + let generation = 0 + let durable = createSessionDurableEventState() + + const close = () => { + connection?.close() + connection = null + setReaderReady(false) + onReadyChangeRef.current?.(false) + clearPreview(sessionId) + } + + const scheduleReconnect = () => { + if (disposed || reconnectTimer) return + const delay = reconnectDelayMs + reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_DELAY_MS) + reconnectTimer = setTimeout(() => { + reconnectTimer = null + close() + void hydrateAndOpen() + }, delay) + } + retryHydrationRef.current = scheduleReconnect + + const adoptTranscript = async (transcript?: SessionTranscript): Promise => { + try { + return Boolean(await onDisconnectRef.current(transcript)) + } catch { + return false + } + } + + const readBoundedTranscript = async ( + throughSequence: number, + ): Promise => { + if (!projectId) return null + const [records, interactionRowStates] = await Promise.all([ + querySessionTranscript({sessionId, projectId, throughSequence}), + fetchInteractionStates(sessionId), + ]) + if (!Array.isArray(records)) return null + return { + messages: transcriptToMessages(records, {interactionRowStates}) ?? [], + recordCount: records.length, + sequenceCursor: throughSequence, + interactionRows: interactionRowStates, + } + } + + const hydrateAndOpen = async () => { + if (disposed || connection || document.visibilityState !== "visible") return + const currentGeneration = ++generation + clearPreview(sessionId) + + let snapshot + try { + snapshot = projectId ? await fetchSessionSnapshot({sessionId, projectId}) : null + } catch { + scheduleReconnect() + return + } + if (disposed || currentGeneration !== generation) return + const snapshotRunning = isSessionSnapshotRunning(snapshot ?? undefined) + setRunningFromSnapshot(snapshotRunning) + if (snapshot && !snapshotRunning) onExecutionSettledRef.current?.() + + if (snapshot && projectId) { + try { + const transcript = await readBoundedTranscript(snapshot.read.latest_sequence) + if (!transcript) { + scheduleReconnect() + return + } + if (disposed || currentGeneration !== generation) return + const adopted = await adoptTranscript(transcript) + if (disposed || currentGeneration !== generation) return + if (!adopted) { + scheduleReconnect() + return + } + } catch { + scheduleReconnect() + return + } + } else { + const adopted = await adoptTranscript() + if (disposed || currentGeneration !== generation) return + if (!adopted) { + scheduleReconnect() + return + } + } + durable = createSessionDurableEventState( + snapshot?.read.latest_sequence ?? durable.latestSequence, + ) + connection = connectSessionLiveEvents({ + sessionId, + after: durable.latestSequence, + onFrame: (frame) => + setPreview((current) => reduceSessionLivePreview(current, frame)), + onEvent: (event) => { + const next = reduceSessionDurableEvent(durable, event) + if (!shouldRefetchSessionTranscript(durable, next, event)) { + durable = next + return + } + durable = next + if (event.type === "execution.started") setRunningFromSnapshot(true) + if ( + event.type === "execution.stopped" || + event.type === "execution.failed" || + event.type === "execution.lost" + ) { + setRunningFromSnapshot(false) + onExecutionSettledRef.current?.(event.execution_id) + } + const interactionChanged = + event.type === "interaction.requested" || + event.type === "interaction.responded" + if (interactionChanged) revalidateInteractionStates(sessionId) + // Completed durable rows replace temporary frames in the transcript source. + clearPreview(sessionId) + const refresh = + interactionChanged || event.type === "tool.completed" + ? readBoundedTranscript(next.latestSequence).then((transcript) => + transcript ? adoptTranscript(transcript) : false, + ) + : adoptTranscript() + void refresh.then( + (adopted) => { + if (!adopted && !disposed) scheduleReconnect() + }, + () => { + if (!disposed) scheduleReconnect() + }, + ) + }, + onReady: ({watermark}) => { + durable = completeSessionDurableEventReplay(durable, watermark) + reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS + setReaderReady(true) + onReadyChangeRef.current?.(true) + }, + onDisconnect: ({reconnect}) => { + close() + void adoptTranscript() + if (reconnect) scheduleReconnect() + }, + }) + } + + const open = () => void hydrateAndOpen() + const onVisibility = () => { + if (document.visibilityState === "visible") open() + else { + generation += 1 + close() + } + } + + document.addEventListener("visibilitychange", onVisibility) + open() + return () => { + disposed = true + retryHydrationRef.current = () => undefined + generation += 1 + onReadyChangeRef.current?.(false) + if (reconnectTimer) clearTimeout(reconnectTimer) + document.removeEventListener("visibilitychange", onVisibility) + close() + } + }, [ + clearPreview, + fetchInteractionStates, + projectId, + revalidateInteractionStates, + sessionId, + setPreview, + sharedReaderAdvertised, + subscribed, + ]) + + useEffect(() => { + if (!preview.gapDetected) return + const retryHydration = retryHydrationRef.current + void Promise.resolve(onDisconnectRef.current()).then((adopted) => { + if (!adopted) retryHydration() + }, retryHydration) + }, [preview.gapDetected]) + + return { + messages: useMemo(() => sessionLivePreviewMessages(preview), [preview]), + runningFromSnapshot: sharedReaderAdvertised && runningFromSnapshot, + readerReady: sharedReaderAdvertised && subscribed && readerReady, + } +} diff --git a/web/packages/agenta-chat/src/model/durableEvents.ts b/web/packages/agenta-chat/src/model/durableEvents.ts new file mode 100644 index 00000000000..56d22384779 --- /dev/null +++ b/web/packages/agenta-chat/src/model/durableEvents.ts @@ -0,0 +1,55 @@ +import {sessionDurableEventTypeSchema, type SessionDurableEvent} from "@agenta/entities/session" + +export interface SessionDurableEventState { + /** Latest committed record cursor reported by the snapshot or event stream. */ + latestSequence: number + /** Highest event sequence applied within this replay/live connection. */ + lastEventSequence: number + /** Number of known events accepted after the last snapshot. */ + acceptedKnownEventCount: number +} + +export const createSessionDurableEventState = (latestSequence = 0): SessionDurableEventState => ({ + latestSequence, + lastEventSequence: latestSequence, + acceptedKnownEventCount: 0, +}) + +export const completeSessionDurableEventReplay = ( + state: SessionDurableEventState, + watermark: number, +): SessionDurableEventState => { + const latestSequence = Math.max(state.latestSequence, watermark) + const lastEventSequence = Math.max(state.lastEventSequence, watermark) + if (latestSequence === state.latestSequence && lastEventSequence === state.lastEventSequence) + return state + return {...state, latestSequence, lastEventSequence} +} + +/** + * Apply one durable event exactly once. Record sequences can have gaps because only typed records + * become events. Unknown types still advance the event order and reconnect watermark. + */ +export const reduceSessionDurableEvent = ( + state: SessionDurableEventState, + event: SessionDurableEvent, +): SessionDurableEventState => { + const sequence = event.sequence + if (sequence == null || sequence <= state.lastEventSequence) return state + + return { + latestSequence: Math.max(state.latestSequence, sequence, event.watermark), + lastEventSequence: sequence, + acceptedKnownEventCount: + state.acceptedKnownEventCount + + Number(sessionDurableEventTypeSchema.safeParse(event.type).success), + } +} + +export const shouldRefetchSessionTranscript = ( + previous: SessionDurableEventState, + next: SessionDurableEventState, + event: SessionDurableEvent, +): boolean => + next.acceptedKnownEventCount !== previous.acceptedKnownEventCount || + (event.sequence != null && event.sequence > previous.lastEventSequence + 1) diff --git a/web/packages/agenta-chat/src/model/error.ts b/web/packages/agenta-chat/src/model/error.ts index 61d4bb6ee1a..dd026f6ee86 100644 --- a/web/packages/agenta-chat/src/model/error.ts +++ b/web/packages/agenta-chat/src/model/error.ts @@ -26,6 +26,9 @@ const ERROR_CLASS_PREFIX = /^[a-z]*error:\s*/ /** One sentence with something to do in it, in place of a browser's internal wording. */ export const TRANSPORT_ERROR_MESSAGE = "Could not reach Agenta. Check your connection and retry." +export const ACCEPTED_SENDER_DISCONNECT_MESSAGE = + "Connection interrupted. The turn was accepted and is still running." + /** Trailing periods and spaces, scanned rather than matched: `/[.\s]+$/` backtracks * quadratically on a long unmatched tail (CodeQL js/polynomial-redos). */ const withoutTrailingStop = (text: string): string => { @@ -62,7 +65,7 @@ export const isSessionBusyRefusal = (err: unknown): boolean => * envelope. An engine's own wording is translated — "Failed to fetch" under "The agent run * failed" read as a fault in the agent. */ -export const parseAgentRunError = (err: unknown): ParsedRunError => { +export const parseAgentRunError = (err: unknown, serverErrorProvenance = false): ParsedRunError => { const raw = err instanceof Error ? err.message : typeof err === "string" ? err : String(err ?? "") const fallback = raw.trim() || "The agent run failed." @@ -89,7 +92,8 @@ export const parseAgentRunError = (err: unknown): ParsedRunError => { return {message: fallback, code: SESSION_TURN_IN_USE_CODE} } // A server envelope outranks transport-phrase translation. - if (isTransportFailure(fallback)) return {message: TRANSPORT_ERROR_MESSAGE, transport: true} + if (!serverErrorProvenance && isTransportFailure(fallback)) + return {message: TRANSPORT_ERROR_MESSAGE, transport: true} return {message: fallback} } @@ -97,3 +101,24 @@ export const parseAgentRunError = (err: unknown): ParsedRunError => { * alert; swallow the floating `sendMessage`/`regenerate` rejection so it doesn't bubble to the * Next.js dev Runtime Error overlay (F-033). */ export const ignoreStreamRejection = () => {} + +export interface RunErrorMetadata { + runError?: ParsedRunError +} + +export interface AgentRunErrorBoundary { + runError?: ParsedRunError + connectionWarning?: string +} + +/** Keep an accepted sender disconnect out of conversation content; the shared run continues. */ +export const classifyAgentRunError = ( + error: unknown, + turnAccepted: boolean, + serverErrorProvenance = false, +): AgentRunErrorBoundary => { + const parsed = parseAgentRunError(error, serverErrorProvenance) + return turnAccepted && parsed.transport + ? {connectionWarning: ACCEPTED_SENDER_DISCONNECT_MESSAGE} + : {runError: parsed} +} diff --git a/web/packages/agenta-chat/src/model/index.ts b/web/packages/agenta-chat/src/model/index.ts index 0aaedd26353..d537ff0e1e0 100644 --- a/web/packages/agenta-chat/src/model/index.ts +++ b/web/packages/agenta-chat/src/model/index.ts @@ -13,3 +13,5 @@ export * from "./grouping" export * from "./sessionStatus" export * from "./turnViewModel" export * from "./userStop" +export * from "./livePreview" +export * from "./durableEvents" diff --git a/web/packages/agenta-chat/src/model/livePreview.ts b/web/packages/agenta-chat/src/model/livePreview.ts new file mode 100644 index 00000000000..0e552eb9756 --- /dev/null +++ b/web/packages/agenta-chat/src/model/livePreview.ts @@ -0,0 +1,218 @@ +import { + createSessionLivePreviewState, + type SessionSnapshot, + type SessionLiveFrame, + type SessionLivePreviewExecution, + type SessionLivePreviewState, +} from "@agenta/entities/session" +import type {UIMessage} from "ai" + +type PreviewPart = Record & {type: string} +const SHARED_SENDER_CONTROL_PARTS = new Set(["data-session-accepted", "step-start"]) +const LEGACY_LIVENESS_REFRESH_MS = 10_000 + +/** Throttle record-driven liveness refreshes used only by flag-off observers. */ +export const shouldRefreshLegacyObserverLiveness = ({ + sharedReaderAdvertised, + lastRefreshAt, + now, +}: { + sharedReaderAdvertised: boolean + lastRefreshAt: number + now: number +}): boolean => !sharedReaderAdvertised && now - lastRefreshAt >= LEGACY_LIVENESS_REFRESH_MS + +/** A sender subscribes eagerly; a secondary reader waits for a remote run. */ +export const shouldSubscribeToSessionLivePreview = ({ + sharedReaderAdvertised, + runningElsewhere, + sender = false, +}: { + sharedReaderAdvertised: boolean + runningElsewhere: boolean + sender?: boolean +}): boolean => sharedReaderAdvertised && (sender || runningElsewhere) + +/** Choose the live activity treatment only while the shared reader is actually connected. */ +export const deriveRemoteTurnPresentation = ({ + livenessRunning, + snapshotRunning = false, + sharedReaderAdvertised, + readerReady, + ownedContinuation = false, +}: { + /** Milestone-1 session-stream liveness; the only running source when the reader is disabled. */ + livenessRunning: boolean + /** Atomic shared-reader snapshot state. Ignored while the reader capability is disabled. */ + snapshotRunning?: boolean + sharedReaderAdvertised: boolean + readerReady: boolean + /** This tab answered the gate and owns the continuation even if its invoke stream detached. */ + ownedContinuation?: boolean +}): {showActivity: boolean; showStrip: boolean} => { + const running = livenessRunning || (sharedReaderAdvertised && snapshotRunning) + const showActivity = running && sharedReaderAdvertised && readerReady + return { + showActivity, + showStrip: running && !showActivity && !ownedContinuation, + } +} + +/** The shared sender still consumes the invoke response for acceptance ids and errors. The AI SDK + * creates a message carrier for that control-only stream; keep it out of transcript rendering and + * local persistence unless it also carries a run error. */ +export const withoutSharedSenderAcceptanceMessages = (messages: UIMessage[]): UIMessage[] => + messages.filter((message) => { + const metadata = message.metadata as + | {sharedSender?: boolean; runError?: unknown} + | undefined + if (!metadata?.sharedSender || metadata.runError) return true + return message.parts.some((part) => !SHARED_SENDER_CONTROL_PARTS.has(part.type)) + }) + +/** Atomic refresh verdict: the latest execution exists, is not complete, and the session still + * owns the running flag from the same snapshot read. */ +export const isSessionSnapshotRunning = (snapshot: SessionSnapshot | undefined): boolean => + snapshot?.session.flags?.is_running === true && + snapshot.execution != null && + snapshot.execution.end_time == null + +const stringValue = (value: unknown): string => + typeof value === "string" ? value : value == null ? "" : String(value) + +const applyFrame = ( + current: PreviewPart | undefined, + frame: SessionLiveFrame, +): PreviewPart | undefined => { + switch (frame.type) { + case "text-start": + return current ?? {type: "text", text: ""} + case "text-delta": + return { + type: "text", + text: + stringValue(current?.type === "text" ? current.text : "") + + stringValue(frame.payload.delta), + } + case "text-end": + return current + case "reasoning-start": + return current ?? {type: "reasoning", text: ""} + case "reasoning-delta": + return { + type: "reasoning", + text: + stringValue(current?.type === "reasoning" ? current.text : "") + + stringValue(frame.payload.delta), + } + case "reasoning-end": + return current + case "tool-input-start": + case "tool-input-available": { + const toolCallId = stringValue(frame.payload.toolCallId) || frame.entity_id + const toolName = stringValue(frame.payload.toolName) || "tool" + return { + ...(current ?? {}), + type: "dynamic-tool", + toolCallId, + toolName, + state: frame.type === "tool-input-start" ? "input-streaming" : "input-available", + input: frame.payload.input ?? current?.input, + } + } + case "tool-output-available": + case "tool-output-error": + case "tool-output-denied": { + const toolCallId = stringValue(frame.payload.toolCallId) || frame.entity_id + const base: PreviewPart = { + ...(current ?? {}), + type: "dynamic-tool", + toolCallId, + toolName: stringValue(current?.toolName) || "tool", + } + if (frame.type === "tool-output-available") { + return {...base, state: "output-available", output: frame.payload.output} + } else if (frame.type === "tool-output-error") { + return { + ...base, + state: "output-error", + errorText: stringValue(frame.payload.errorText), + } + } else { + return {...base, state: "output-denied"} + } + } + default: + // Forward-compatible clients ignore frame types they do not understand. + return current + } +} + +/** Collapse one ordered frame into bounded per-entity preview state. */ +export const reduceSessionLivePreview = ( + state: SessionLivePreviewState, + frame: SessionLiveFrame, +): SessionLivePreviewState => { + if (state.gapDetected) return state + + const current = state.byExecution[frame.execution_id] + if (current && frame.frame_index <= current.lastFrameIndex) return state + + const expectedFrameIndex = current ? current.lastFrameIndex + 1 : 0 + if (frame.frame_index !== expectedFrameIndex) { + return {...createSessionLivePreviewState(), gapDetected: true} + } + + const previousPart = current?.byEntity[frame.entity_id]?.part + const nextPart = applyFrame(previousPart, frame) + const execution: SessionLivePreviewExecution = current ?? { + entityOrder: [], + byEntity: {}, + lastFrameIndex: -1, + } + + return { + executionOrder: current + ? state.executionOrder + : [...state.executionOrder, frame.execution_id], + gapDetected: false, + byExecution: { + ...state.byExecution, + [frame.execution_id]: { + entityOrder: + nextPart && !previousPart + ? [...execution.entityOrder, frame.entity_id] + : execution.entityOrder, + byEntity: nextPart + ? { + ...execution.byEntity, + [frame.entity_id]: {part: nextPart}, + } + : execution.byEntity, + lastFrameIndex: frame.frame_index, + }, + }, + } +} + +/** Build disposable UI messages from the collapsed entity state. */ +export const sessionLivePreviewMessages = (state: SessionLivePreviewState): UIMessage[] => + state.executionOrder.flatMap((executionId) => { + const execution = state.byExecution[executionId] + if (!execution) return [] + const parts = execution.entityOrder.flatMap((entityId) => { + const entity = execution.byEntity[entityId] + return entity ? [entity.part] : [] + }) + if (parts.length === 0) return [] + return [ + { + id: `live-preview-${executionId}`, + role: "assistant", + parts, + metadata: {livePreview: true, executionId}, + } as unknown as UIMessage, + ] + }) + +export {createSessionLivePreviewState} diff --git a/web/packages/agenta-chat/src/state/sessionEphemera.ts b/web/packages/agenta-chat/src/state/sessionEphemera.ts index 3da821179ef..5e28240b3be 100644 --- a/web/packages/agenta-chat/src/state/sessionEphemera.ts +++ b/web/packages/agenta-chat/src/state/sessionEphemera.ts @@ -44,6 +44,15 @@ export const clearSessionTurnId = (sessionId: string) => { turnIdBySession.delete(sessionId) } +/** Accepted shared-path execution ids that still own their session turn after the invoke stream + * disconnects. A null id means the acceptance lacked a usable correlation id. */ +export const acceptedRunBySession = new Map() + +export type TurnDeliverySource = "legacy" | "shared" + +/** One rendering source per local turn, retained across a pane remount. */ +export const turnDeliverySourceBySession = new Map() + // The fresh-session registry moved to @agenta/entities/session — the drive needs the same // predicate, and this package sits ABOVE entity-ui so it cannot be imported from there. export {freshSessionIds} @@ -55,5 +64,7 @@ export const clearSessionEphemera = (sessionId: string) => { attachmentsBySession.delete(sessionId) turnIdBySession.delete(sessionId) supersededTurnIdsBySession.delete(sessionId) + acceptedRunBySession.delete(sessionId) + turnDeliverySourceBySession.delete(sessionId) freshSessionIds.delete(sessionId) } diff --git a/web/packages/agenta-chat/src/transport/AgentChatTransport.ts b/web/packages/agenta-chat/src/transport/AgentChatTransport.ts index 4ac98fd6037..38f7c763053 100644 --- a/web/packages/agenta-chat/src/transport/AgentChatTransport.ts +++ b/web/packages/agenta-chat/src/transport/AgentChatTransport.ts @@ -1,5 +1,9 @@ // Canonical since the desktop re-plumb: the OSS copy is deleted and both apps import this. -import {createNegotiatingFetch, type NegotiatingFetch} from "@agenta/playground/agent-chat" +import { + createNegotiatingFetch, + SHARED_SESSION_RESPONSE_HEADER, + type NegotiatingFetch, +} from "@agenta/playground/agent-chat" import {generateId} from "@agenta/shared/utils" import {DefaultChatTransport, type UIMessage, type UIMessageChunk} from "ai" @@ -22,6 +26,90 @@ import {installStreamTraceHelper, traceStreamChunks} from "./streamTrace" */ type AnyChunk = UIMessageChunk +export const SHARED_SENDER_ACCEPTANCE_TIMEOUT_MS = 15_000 + +type AgentChatTransportOptions = NonNullable< + ConstructorParameters>[0] +> & { + /** Test seam; production uses `SHARED_SENDER_ACCEPTANCE_TIMEOUT_MS`. */ + sharedAcceptanceTimeoutMs?: number +} + +interface SharedAcceptanceDeadline { + signal: AbortSignal + failure: Promise + accept: () => void + fail: (error: Error) => void + dispose: () => void + onFailure: (listener: (error: Error) => void) => () => void +} + +const sharedAcceptanceFailure = (): TypeError => new TypeError("Failed to fetch") + +const createSharedAcceptanceDeadline = ( + parentSignal: AbortSignal | null | undefined, + timeoutMs: number, +): SharedAcceptanceDeadline => { + const controller = new AbortController() + const listeners = new Set<(error: Error) => void>() + let state: "pending" | "accepted" | "disposed" = "pending" + let rejectFailure: (error: Error) => void = () => undefined + const failure = new Promise((_resolve, reject) => { + rejectFailure = reject + }) + const timer = setTimeout(() => fail(sharedAcceptanceFailure()), timeoutMs) + + const abortFromParent = () => { + const reason = + parentSignal?.reason instanceof Error + ? parentSignal.reason + : new DOMException("This operation was aborted", "AbortError") + if (!controller.signal.aborted) controller.abort(reason) + rejectFailure(reason) + for (const listener of listeners) listener(reason) + dispose() + } + + function accept() { + if (state !== "pending") return + state = "accepted" + clearTimeout(timer) + } + + function fail(error: Error) { + if (state !== "pending") return + state = "disposed" + clearTimeout(timer) + if (!controller.signal.aborted) controller.abort(error) + rejectFailure(error) + for (const listener of listeners) listener(error) + parentSignal?.removeEventListener("abort", abortFromParent) + } + + function dispose() { + if (state === "disposed") return + state = "disposed" + clearTimeout(timer) + listeners.clear() + parentSignal?.removeEventListener("abort", abortFromParent) + } + + if (parentSignal?.aborted) abortFromParent() + else parentSignal?.addEventListener("abort", abortFromParent, {once: true}) + + return { + signal: controller.signal, + failure, + accept, + fail, + dispose, + onFailure: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} + interface BatchPart { type?: string text?: string @@ -206,26 +294,154 @@ function batchJsonToUiMessageStream( }) } +const sharedAcceptanceChunk = (chunk: AnyChunk): AnyChunk | undefined => { + if (chunk.type === "start" || chunk.type === "finish") { + return { + ...chunk, + messageMetadata: { + ...((chunk as {messageMetadata?: Record}).messageMetadata ?? {}), + sharedSender: true, + }, + } as AnyChunk + } + if ( + chunk.type === "start-step" || + chunk.type === "finish-step" || + chunk.type === "error" || + chunk.type === "data-agent-error" || + chunk.type === "data-session-accepted" + ) + return chunk + return undefined +} + +/** Consume the invoke stream without letting its content become a second rendering source. */ +export const sharedAcceptanceStream = ( + stream: ReadableStream, + deadline?: SharedAcceptanceDeadline, +): ReadableStream => { + if (!deadline) { + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + const accepted = sharedAcceptanceChunk(chunk) + if (accepted) controller.enqueue(accepted) + }, + }), + ) + } + + const reader = stream.getReader() + let closed = false + let accepted = false + let unsubscribe: () => void = () => undefined + + return new ReadableStream({ + start(controller) { + unsubscribe = deadline.onFailure((error) => { + if (closed) return + closed = true + controller.error(error) + void reader.cancel(error).catch(() => undefined) + }) + }, + async pull(controller) { + try { + while (!closed) { + const next = await reader.read() + if (closed) return + if (next.done) { + if (!accepted) { + deadline.fail(sharedAcceptanceFailure()) + return + } + closed = true + unsubscribe() + deadline.dispose() + controller.close() + return + } + if (next.value.type === "data-session-accepted") { + accepted = true + deadline.accept() + } + const chunk = sharedAcceptanceChunk(next.value) + if (chunk) { + controller.enqueue(chunk) + return + } + } + } catch (error) { + if (closed) return + closed = true + unsubscribe() + deadline.dispose() + controller.error(error) + } + }, + cancel(reason) { + closed = true + unsubscribe() + deadline.dispose() + return reader.cancel(reason) + }, + }) +} + export class AgentChatTransport extends DefaultChatTransport { private readonly negotiator: NegotiatingFetch + private readonly sharedResponses = new WeakMap< + ReadableStream, + SharedAcceptanceDeadline + >() - constructor(options: ConstructorParameters>[0] = {}) { + constructor(options: AgentChatTransportOptions = {}) { + const { + sharedAcceptanceTimeoutMs = SHARED_SENDER_ACCEPTANCE_TIMEOUT_MS, + ...transportOptions + } = options // Own the transport's `fetch` so every request goes through stream→batch negotiation; // any caller-supplied fetch becomes the negotiator's base (tests inject one here). - super({...options, fetch: undefined}) - this.negotiator = createNegotiatingFetch(options.fetch) - this.fetch = this.negotiator.fetch + super({...transportOptions, fetch: undefined}) + this.negotiator = createNegotiatingFetch(transportOptions.fetch) + this.fetch = async (input, init) => { + const shared = + new Headers(init?.headers).get(SHARED_SESSION_RESPONSE_HEADER) === "shared" + if (!shared) return this.negotiator.fetch(input, init) + + const deadline = createSharedAcceptanceDeadline(init?.signal, sharedAcceptanceTimeoutMs) + try { + const response = await Promise.race([ + this.negotiator.fetch(input, {...init, signal: deadline.signal}), + deadline.failure, + ]) + if (!response.ok || !response.body) { + deadline.dispose() + return response + } + this.sharedResponses.set(response.body, deadline) + return response + } catch (error) { + deadline.dispose() + throw error + } + } } protected processResponseStream(stream: ReadableStream): ReadableStream { // Parse by the channel the request actually resolved to, not the requested one — a stream // request can come back as a batch via the 406 fallback. The mode is keyed off this exact // body stream (`resolvedMode(stream)`), so request and parse stay in lockstep. - if (this.negotiator.resolvedMode(stream) === "batch") - return batchJsonToUiMessageStream(stream) + const parsed = + this.negotiator.resolvedMode(stream) === "batch" + ? batchJsonToUiMessageStream(stream) + : super.processResponseStream(stream) + const sharedDeadline = this.sharedResponses.get(stream) + if (sharedDeadline) return sharedAcceptanceStream(parsed, sharedDeadline) + if (this.negotiator.resolvedMode(stream) === "batch") return parsed // Deltas pass through untouched: typing cadence is paced at paint by `useTypewriter`. // The trace only timestamps them — see `streamTrace.ts` for why the cadence is measured. installStreamTraceHelper() - return traceStreamChunks(super.processResponseStream(stream)) + return traceStreamChunks(parsed) } } diff --git a/web/packages/agenta-chat/src/transport/index.ts b/web/packages/agenta-chat/src/transport/index.ts index 8cc0f3d7736..38a974b9e9f 100644 --- a/web/packages/agenta-chat/src/transport/index.ts +++ b/web/packages/agenta-chat/src/transport/index.ts @@ -2,3 +2,4 @@ export * from "./AgentChatTransport" export * from "./agentResumeRequest" export * from "./resolveInvocationUrl" export * from "./streamTrace" +export * from "./sessionLiveEvents" diff --git a/web/packages/agenta-chat/src/transport/sessionLiveEvents.ts b/web/packages/agenta-chat/src/transport/sessionLiveEvents.ts new file mode 100644 index 00000000000..a8bd9a74541 --- /dev/null +++ b/web/packages/agenta-chat/src/transport/sessionLiveEvents.ts @@ -0,0 +1,91 @@ +import { + sessionDurableEventSchema, + sessionLiveFrameSchema, + type SessionDurableEvent, + type SessionLiveFrame, +} from "@agenta/entities/session" +import {safeParseWithLogging} from "@agenta/entities/shared" +import {getAgentaApiUrl} from "@agenta/shared/api" + +export interface SessionLiveDisconnect { + reason: string + reconnect: boolean +} + +export interface SessionLiveReady { + watermark: number +} + +export interface SessionLiveEventsConnection { + close: () => void +} + +export const sessionLiveEventsUrl = (sessionId: string, after = 0): string => + `${getAgentaApiUrl()}/sessions/${encodeURIComponent(sessionId)}/events?after=${Math.max(0, after)}` + +/** Native EventSource transport for uncoalesced live frames. */ +export const connectSessionLiveEvents = ({ + sessionId, + after, + onFrame, + onEvent, + onReady, + onDisconnect, +}: { + sessionId: string + after: number + onFrame: (frame: SessionLiveFrame) => void + onEvent: (event: SessionDurableEvent) => void + onReady: (event: SessionLiveReady) => void + onDisconnect: (event: SessionLiveDisconnect) => void +}): SessionLiveEventsConnection => { + const source = new EventSource(sessionLiveEventsUrl(sessionId, after), {withCredentials: true}) + + source.onmessage = (event) => { + try { + const parsed = safeParseWithLogging( + sessionLiveFrameSchema.or(sessionDurableEventSchema), + JSON.parse(event.data), + "[sessionLiveEvents]", + ) + if (parsed?.session_id === sessionId) { + if (parsed.kind === "frame") onFrame(parsed) + else onEvent(parsed) + } + } catch { + // Ignore malformed JSON because live relay envelopes are display-only. + } + } + source.addEventListener("ready", (event) => { + let watermark = after + try { + const data = JSON.parse((event as MessageEvent).data) as Record + if ( + typeof data.watermark === "number" && + Number.isInteger(data.watermark) && + data.watermark >= 0 + ) + watermark = data.watermark + } catch { + // A malformed readiness detail must not move the reconnect cursor forward. + } + onReady({watermark}) + }) + source.addEventListener("relay-close", (event) => { + let detail: SessionLiveDisconnect = {reason: "relay_closed", reconnect: true} + try { + const data = JSON.parse((event as MessageEvent).data) as Record + detail = { + reason: typeof data.reason === "string" ? data.reason : detail.reason, + reconnect: data.reconnect !== false, + } + } catch { + // The close itself is authoritative even if its optional detail is malformed. + } + onDisconnect(detail) + if (!detail.reconnect) source.close() + }) + source.onerror = () => onDisconnect({reason: "connection_lost", reconnect: true}) + + return {close: () => source.close()} +} diff --git a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts index cf3266d1097..efcc3dfa734 100644 --- a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts @@ -20,6 +20,7 @@ const record = (id: string, payload: Record, sender = "agent"): id, session_id: "session-1", project_id: "project-1", + sequence: null, event_index: null, sender, session_update: String(payload.type), @@ -67,6 +68,21 @@ describe("loadSessionMessages", () => { expect(transcript?.recordCount).toBe(3) }) + it("keeps a sparse sequence cursor distinct from the retained row count", async () => { + fetchResult = { + records: [ + {...record("r1", {type: "message", text: "hi"}), sequence: 3}, + {...record("r2", {type: "thought", text: "work"}), sequence: 6}, + {...record("r3", {type: "done"}), sequence: 9}, + ], + } + + const transcript = await loadSessionMessages("session-1") + + expect(transcript?.recordCount).toBe(3) + expect(transcript?.sequenceCursor).toBe(9) + }) + it("delivers a refreshed transcript via onRefreshed once the background revalidation resolves", async () => { const fresh = [record("r3", {type: "message", text: "fresh"}), record("r4", {type: "done"})] fetchResult = { diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts index 41b8aa11169..6557396d56e 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -36,6 +36,7 @@ interface HarnessProps { status: string messages: UIMessage[] stopped: boolean + acceptedRunPending?: boolean resumeOrphaned?: boolean sessionId?: string } @@ -77,6 +78,29 @@ describe("useAgentChatQueue", () => { expect(result.current.queued.map((m) => m.text)).toEqual(["first", "second"]) }) + it("holds an accepted turn after its sender stream errors until the shared path settles", () => { + const acceptedDisconnect: HarnessProps = { + status: "error", + messages: [userTurn("u1", "go")], + stopped: false, + acceptedRunPending: true, + } + const {result, rerender, sendQueued} = setup(acceptedDisconnect) + + act(() => result.current.submit({text: "hold behind the accepted turn"})) + + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued.map((message) => message.text)).toEqual([ + "hold behind the accepted turn", + ]) + + rerender({...acceptedDisconnect, acceptedRunPending: false}) + + expect(sendQueued).toHaveBeenCalledTimes(1) + expect(sendQueued.mock.calls[0][0]).toMatchObject({text: "hold behind the accepted turn"}) + expect(result.current.queued).toHaveLength(0) + }) + it("releases held messages one per settle, in FIFO order", () => { const streaming: HarnessProps = { status: "streaming", diff --git a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts index 16effd32333..0b7e808cce9 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -8,11 +8,18 @@ // persist-on-settle → run-status publish, plus error stamping and the rewind plan. import {createElement, type ReactNode} from "react" +import { + fetchSessionSnapshot, + querySessionTranscript, + sessionLivePreviewAtomFamily, + type SessionSnapshot, +} from "@agenta/entities/session" import {buildAgentRequest} from "@agenta/playground/agent-chat" +import {projectIdAtom} from "@agenta/shared/state" import {act, renderHook, waitFor} from "@testing-library/react" import type {UIMessage} from "ai" import {createStore, Provider} from "jotai" -import {beforeEach, describe, expect, it, vi} from "vitest" +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" const approvalRecord = vi.hoisted(() => ({ defer: false, @@ -51,6 +58,8 @@ vi.mock("@agenta/entities/session", async (importOriginal) => { // The hydration seam's records fetch: "no server history" for these tests. fetchSessionRecordsAtom: atom(null, () => ({records: null, refreshed: null})), fetchSessionInteractionStatesAtom: atom(null, () => new Map()), + fetchSessionSnapshot: vi.fn(), + querySessionTranscript: vi.fn(), } }) @@ -59,12 +68,14 @@ vi.mock("@agenta/entities/trace", () => ({ })) import {useAgentConversation} from "../../../src/hooks/useAgentConversation" +import {ACCEPTED_SENDER_DISCONNECT_MESSAGE, TRANSPORT_ERROR_MESSAGE} from "../../../src/model/error" import { getSessionTurnId, markSessionFresh, setSessionTurnId, } from "../../../src/state/sessionEphemera" import {sessionMessagesAtom, sessionStatusAtomFamily} from "../../../src/state/sessionMessages" +import {SHARED_SENDER_ACCEPTANCE_TIMEOUT_MS} from "../../../src/transport/AgentChatTransport" const sseBody = (text: string, finishReason?: string): string => { const chunks = [ @@ -107,6 +118,136 @@ const errorResponse = (): Response => headers: {"content-type": "application/json"}, }) +const sharedErrorResponse = (): Response => { + const chunks = [ + {type: "start", messageId: "shared-error"}, + {type: "start-step"}, + { + type: "data-session-accepted", + data: {sessionId: "session-1", turnId: "turn-1", executionId: "turn-1"}, + }, + {type: "error", errorText: "shared provider failed"}, + {type: "finish-step"}, + {type: "finish"}, + ] + const body = chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + return new Response(`${body}data: [DONE]\n\n`, { + status: 200, + headers: {"content-type": "text/event-stream"}, + }) +} + +const sharedBrowserPhraseServerErrorResponse = (): Response => { + const chunks = [ + {type: "start", messageId: "shared-browser-phrase-error"}, + {type: "start-step"}, + { + type: "data-session-accepted", + data: {sessionId: "session-1", turnId: "turn-1", executionId: "turn-1"}, + }, + { + type: "data-agent-error", + data: {code: "runner_error", errorText: "Failed to fetch"}, + }, + {type: "error", errorText: "Failed to fetch"}, + {type: "finish-step"}, + {type: "finish"}, + ] + const body = chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + return new Response(`${body}data: [DONE]\n\n`, { + status: 200, + headers: {"content-type": "text/event-stream"}, + }) +} + +/** The shared sender's invoke stream accepted the turn, then the connection died — what a + * backgrounded tab sees while the runner carries the turn on to completion. */ +const sharedDroppedStreamResponse = (): Response => { + const chunks = [ + {type: "start", messageId: "shared-dropped"}, + {type: "start-step"}, + { + // Transient, as the runner sends it: it reaches `onData` and never the transcript. + type: "data-session-accepted", + data: {sessionId: "session-1", turnId: "turn-1", executionId: "turn-1"}, + transient: true, + }, + ] + return new Response( + new ReadableStream({ + async start(controller) { + for (const chunk of chunks) { + controller.enqueue( + new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`), + ) + } + // Let the client read the acceptance first. `controller.error` resets the queue, so + // erroring in the same tick would throw away what was just enqueued. + await new Promise((resolve) => setTimeout(resolve, 20)) + controller.error(new TypeError("Failed to fetch")) + }, + }), + {status: 200, headers: {"content-type": "text/event-stream"}}, + ) +} + +class FakeEventSource { + static instances: FakeEventSource[] = [] + readonly listeners = new Map void>() + onmessage: ((event: MessageEvent) => void) | null = null + onerror: (() => void) | null = null + + constructor(readonly url: string) { + FakeEventSource.instances.push(this) + } + + addEventListener(type: string, listener: EventListenerOrEventListenerObject) { + this.listeners.set(type, listener as (event: Event) => void) + } + + ready(watermark = 0) { + this.listeners.get("ready")?.( + new MessageEvent("ready", {data: JSON.stringify({watermark})}), + ) + } + + message(data: unknown) { + this.onmessage?.(new MessageEvent("message", {data: JSON.stringify(data)})) + } + + close() {} +} + +const controlledLegacyResponse = () => { + const encoder = new TextEncoder() + let finish = () => {} + const response = new Response( + new ReadableStream({ + start(controller) { + for (const chunk of [ + {type: "start", messageId: "legacy-assistant"}, + {type: "start-step"}, + {type: "text-start", id: "legacy-text"}, + {type: "text-delta", id: "legacy-text", delta: "legacy answer"}, + ]) + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)) + finish = () => { + for (const chunk of [ + {type: "text-end", id: "legacy-text"}, + {type: "finish-step"}, + {type: "finish"}, + ]) + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)) + controller.enqueue(encoder.encode("data: [DONE]\n\n")) + controller.close() + } + }, + }), + {status: 200, headers: {"content-type": "text/event-stream"}}, + ) + return {response, finish: () => finish()} +} + const fetchMock = vi.fn() vi.stubGlobal("fetch", fetchMock) @@ -126,7 +267,23 @@ const mount = (store: ReturnType, entityId: string, sessionI beforeEach(() => { approvalRecord.defer = false approvalRecord.resolve = undefined + FakeEventSource.instances = [] + vi.stubGlobal("EventSource", FakeEventSource) fetchMock.mockReset() + vi.mocked(fetchSessionSnapshot).mockReset() + vi.mocked(fetchSessionSnapshot).mockResolvedValue({ + session: { + session_id: "session-1", + project_id: "project-1", + capabilities: {shared_reader: true}, + flags: {is_running: false}, + }, + execution: null, + pending: {inputs: [], interactions: []}, + read: {latest_sequence: 0, history_complete: true}, + } as SessionSnapshot) + vi.mocked(querySessionTranscript).mockReset() + vi.mocked(querySessionTranscript).mockResolvedValue([]) vi.mocked(buildAgentRequest).mockClear() // Restore the ready-workflow build: one test replaces it with a not-yet-loaded one, and // `mockClear` keeps the implementation. @@ -137,6 +294,8 @@ beforeEach(() => { })) }) +afterEach(() => vi.useRealTimers()) + describe("useAgentConversation", () => { it("runs a full turn: send → stream → settle → persist + status publish", async () => { fetchMock.mockResolvedValue(streamResponse("Hello back")) @@ -301,6 +460,83 @@ describe("useAgentConversation", () => { await waitFor(() => expect(result.current.runStatus).toBe("idle"), {timeout: 5000}) }) + it("keeps a pre-ready turn on the legacy delivery source when the shared reader opens mid-run", async () => { + const legacy = controlledLegacyResponse() + fetchMock.mockResolvedValue(legacy.response) + const store = createStore() + store.set(projectIdAtom, "project-1") + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = renderHook( + () => + useAgentConversation({ + entityId: "rev-1", + sessionId, + sharedReaderAdvertised: true, + }), + { + wrapper: ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children), + }, + ) + + await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)) + act(() => void result.current.send({text: "answer once"})) + await waitFor(() => + expect(vi.mocked(buildAgentRequest)).toHaveBeenLastCalledWith( + "rev-1", + expect.any(Array), + expect.objectContaining({sessionId, sharedResponse: false}), + ), + ) + await waitFor(() => + expect( + result.current.messages.some( + (message) => + message.role === "assistant" && + message.parts.some( + (part) => part.type === "text" && part.text === "legacy answer", + ), + ), + ).toBe(true), + ) + + act(() => { + FakeEventSource.instances[0].ready() + for (const [frameIndex, type, payload] of [ + [0, "text-start", {}], + [1, "text-delta", {delta: "shared duplicate"}], + ] as const) + FakeEventSource.instances[0].message({ + version: 1, + kind: "frame", + session_id: sessionId, + execution_id: "execution-1", + frame_or_event_id: `frame-${frameIndex}`, + frame_index: frameIndex, + entity_id: "text-1", + type, + payload, + created_at: "2026-09-05T12:00:00Z", + }) + }) + + await waitFor(() => + expect(store.get(sessionLivePreviewAtomFamily(sessionId)).executionOrder).toEqual([ + "execution-1", + ]), + ) + expect( + result.current.messages.filter((message) => message.role === "assistant"), + ).toHaveLength(1) + expect( + result.current.messages.some((message) => message.id.startsWith("live-preview-")), + ).toBe(false) + + act(() => legacy.finish()) + await waitFor(() => expect(result.current.status).toBe("ready")) + }) + it("rewinding a user message truncates the conversation and hands back its text", async () => { fetchMock.mockResolvedValue(streamResponse("answer")) const store = createStore() @@ -475,4 +711,287 @@ describe("useAgentConversation", () => { expect(result.current.error).toBeUndefined() expect(result.current.runStatus).toBe("idle") }) + + /** + * Increment 5, two tabs on one session: the sender's invoke stream carries acceptance and + * errors only, so a stream that dies while the tab is backgrounded says nothing about the turn + * — the runner finishes it and writes it to the session log. The stamp is live feedback and + * must not outlive the reload, or the next open paints "Could not reach Agenta" over a turn + * that completed server-side (browser evidence 2026-09-04, session 4d21415e). + */ + it("shows an accepted disconnect as ephemeral connection state", async () => { + vi.mocked(buildAgentRequest).mockImplementation(async (_entityId, _messages, opts) => ({ + invocationUrl: "https://agent.test/invoke", + headers: { + Accept: "text/event-stream", + "content-type": "application/json", + "x-ag-session-response": "shared", + }, + requestBody: {session_id: opts?.sessionId}, + })) + // Accepted, then the connection died — what a backgrounded tab's closed stream leaves. + fetchMock.mockResolvedValue(sharedDroppedStreamResponse()) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + + await act(async () => { + await result.current.send({text: "One more short line, please."}) + }) + await waitFor(() => { + expect(result.current.connectionWarning).toBe(ACCEPTED_SENDER_DISCONNECT_MESSAGE) + expect(result.current.error).toBeUndefined() + expect(result.current.acceptedRunPending).toBe(true) + expect(result.current.runStatus).toBe("running") + expect(result.current.turns.some((turn) => turn.status.isError)).toBe(false) + }) + + // Durable: only the user turn. Nothing here can repaint the failure after a reload, and + // the count the adoption guard compares stays equal to what the log holds. + await waitFor(() => { + const persisted = store.get(sessionMessagesAtom)[sessionId] + expect(persisted).toHaveLength(1) + expect(persisted[0].role).toBe("user") + expect(persisted.some((m) => (m.metadata as {runError?: unknown})?.runError)).toBe( + false, + ) + }) + }) + + /** + * An accepted shared turn is NOT a local stream. Its content arrives on the session events + * channel, so the durable snapshot behind those frames stays adoptable — and it has to be, + * because `hydrateAndOpen` only opens the events stream once `revalidate` adopts or confirms + * the bounded transcript. Treating `acceptedRunPending` as busy refused both, so a shared turn + * whose stream dropped mid-run reconnected forever and never came back. + */ + it("adopts the durable transcript while a shared turn is accepted but disconnected", async () => { + vi.mocked(buildAgentRequest).mockImplementation(async (_entityId, _messages, opts) => ({ + invocationUrl: "https://agent.test/invoke", + headers: { + Accept: "text/event-stream", + "content-type": "application/json", + "x-ag-session-response": "shared", + }, + requestBody: {session_id: opts?.sessionId}, + })) + fetchMock.mockResolvedValue(sharedDroppedStreamResponse()) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + + await act(async () => { + await result.current.send({text: "Draft the release note."}) + }) + await waitFor(() => { + expect(result.current.acceptedRunPending).toBe(true) + expect(result.current.status).not.toBe("streaming") + }) + + const serverMessages: UIMessage[] = [ + { + id: "srv-user", + role: "user", + parts: [{type: "text", text: "Draft the release note."}], + }, + {id: "srv-assistant", role: "assistant", parts: [{type: "text", text: "Here it is."}]}, + ] + const revalidate = result.current.revalidate as unknown as ( + transcript: unknown, + ) => Promise + let adopted = false + await act(async () => { + adopted = await revalidate({ + messages: serverMessages, + recordCount: 2, + sequenceCursor: 2, + }) + }) + + expect(adopted).toBe(true) + await waitFor(() => { + const persisted = store.get(sessionMessagesAtom)[sessionId] + expect(persisted.map((message) => message.id)).toEqual(["srv-user", "srv-assistant"]) + }) + }) + + /** + * The other half of the rule. A stream that dies BEFORE the acceptance may describe a turn that + * never started, so that card is the only signal the user gets and it has to survive the + * reload. + */ + it("turns an offline-before-send hang into a retryable failure card", async () => { + vi.useFakeTimers() + vi.mocked(buildAgentRequest).mockImplementation(async (_entityId, _messages, opts) => ({ + invocationUrl: "https://agent.test/invoke", + headers: { + Accept: "text/event-stream", + "content-type": "application/json", + "x-ag-session-response": "shared", + }, + requestBody: {session_id: opts?.sessionId}, + })) + // Chromium can leave an offline fetch pending instead of rejecting it. + fetchMock.mockImplementation(() => new Promise(() => undefined)) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + + act(() => void result.current.send({text: "this one never left"})) + await act(() => vi.advanceTimersByTimeAsync(SHARED_SENDER_ACCEPTANCE_TIMEOUT_MS)) + vi.useRealTimers() + await waitFor(() => expect(result.current.runStatus).toBe("error"), {timeout: 5000}) + expect(result.current.connectionWarning).toBeUndefined() + const failedTurn = result.current.turns.at(-1) + expect(failedTurn?.message.role).toBe("assistant") + expect(failedTurn?.status).toMatchObject({ + showError: true, + errorText: TRANSPORT_ERROR_MESSAGE, + }) + expect(result.current.rewind(failedTurn!.message)).not.toBeNull() + + await waitFor(() => { + const persisted = store.get(sessionMessagesAtom)[sessionId] + expect(persisted).toHaveLength(2) + expect(persisted[0]).toMatchObject({ + role: "user", + parts: [{type: "text", text: "this one never left"}], + }) + const stamped = persisted[1].metadata as {runError?: {message?: string}} + expect(stamped.runError?.message).toBe(TRANSPORT_ERROR_MESSAGE) + }) + }) + + it("resets acceptance before an immediate next-request failure", async () => { + vi.mocked(buildAgentRequest) + .mockImplementationOnce(async (_entityId, _messages, opts) => ({ + invocationUrl: "https://agent.test/invoke", + headers: { + Accept: "text/event-stream", + "content-type": "application/json", + "x-ag-session-response": "shared", + }, + requestBody: {session_id: opts?.sessionId}, + })) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + fetchMock.mockImplementationOnce(async () => sharedDroppedStreamResponse()) + const store = createStore() + store.set(projectIdAtom, "project-1") + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = renderHook( + () => + useAgentConversation({ + entityId: "rev-1", + sessionId, + sharedReaderAdvertised: true, + }), + { + wrapper: ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children), + }, + ) + + await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)) + act(() => void result.current.send({text: "accepted first"})) + await waitFor(() => { + expect(result.current.connectionWarning).toBe(ACCEPTED_SENDER_DISCONNECT_MESSAGE) + expect(result.current.acceptedRunPending).toBe(true) + }) + + act(() => + FakeEventSource.instances[0].message({ + version: 1, + kind: "event", + session_id: sessionId, + execution_id: "turn-1", + frame_or_event_id: "lost-1", + sequence: 1, + watermark: 1, + type: "execution.lost", + payload: {}, + created_at: "2026-09-05T12:00:00Z", + }), + ) + await waitFor(() => expect(result.current.acceptedRunPending).toBe(false)) + + act(() => void result.current.send({text: "fails before acceptance"})) + await waitFor(() => { + expect(result.current.connectionWarning).toBeUndefined() + expect(result.current.error).toEqual({ + message: TRANSPORT_ERROR_MESSAGE, + transport: true, + }) + }) + }) + + it("renders an invoke error that shares the acceptance carrier", async () => { + vi.mocked(buildAgentRequest).mockImplementation(async (_entityId, _messages, opts) => ({ + invocationUrl: "https://agent.test/invoke", + headers: { + Accept: "text/event-stream", + "content-type": "application/json", + "x-ag-session-response": "shared", + }, + requestBody: {session_id: opts?.sessionId}, + })) + fetchMock.mockResolvedValue(sharedErrorResponse()) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + + await act(async () => { + await result.current.send({text: "explode on the shared path"}) + }) + await waitFor(() => expect(result.current.runStatus).toBe("error"), {timeout: 5000}) + expect(result.current.connectionWarning).toBeUndefined() + + await waitFor(() => { + const sharedCarriers = result.current.messages.filter( + (message) => + (message.metadata as {sharedSender?: boolean} | undefined)?.sharedSender, + ) + expect(sharedCarriers).toHaveLength(1) + expect( + (sharedCarriers[0].metadata as {runError?: {message?: string}}).runError?.message, + ).toBe("shared provider failed") + }) + const last = result.current.turns[result.current.turns.length - 1] + expect(last.status.errorText).toBe("shared provider failed") + expect(last.status.isError).toBe(true) + }) + + it("keeps a runner failure whose text matches a browser disconnect phrase as a run error", async () => { + vi.mocked(buildAgentRequest).mockImplementation(async (_entityId, _messages, opts) => ({ + invocationUrl: "https://agent.test/invoke", + headers: { + Accept: "text/event-stream", + "content-type": "application/json", + "x-ag-session-response": "shared", + }, + requestBody: {session_id: opts?.sessionId}, + })) + fetchMock.mockResolvedValue(sharedBrowserPhraseServerErrorResponse()) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + + await act(async () => { + await result.current.send({text: "surface the runner failure"}) + }) + await waitFor(() => expect(result.current.runStatus).toBe("error"), {timeout: 5000}) + + expect(result.current.connectionWarning).toBeUndefined() + expect(result.current.error).toEqual({message: "Failed to fetch"}) + await waitFor(() => { + const last = result.current.turns[result.current.turns.length - 1] + expect(last.status.errorText).toBe("Failed to fetch") + expect(last.status.isError).toBe(true) + }) + }) }) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.ts new file mode 100644 index 00000000000..7bfeadc84e4 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.ts @@ -0,0 +1,223 @@ +// @vitest-environment jsdom +import {createElement, type ReactNode} from "react" + +import { + fetchSessionSnapshot, + querySessionTranscript, + type SessionSnapshot, +} from "@agenta/entities/session" +import {projectIdAtom} from "@agenta/shared/state" +import {act, renderHook, waitFor} from "@testing-library/react" +import {createStore, Provider} from "jotai" +import {beforeEach, describe, expect, it, vi} from "vitest" + +vi.mock("@agenta/entities/session", async (importOriginal) => { + const {atom} = await import("jotai") + return { + ...(await importOriginal()), + fetchSessionInteractionStatesAtom: atom(null, () => new Map()), + fetchSessionSnapshot: vi.fn(), + querySessionTranscript: vi.fn(), + } +}) + +import {useSessionLivePreview} from "../../../src/hooks/useSessionLivePreview" + +class FakeEventSource { + static instances: FakeEventSource[] = [] + readonly listeners = new Map void>() + onmessage: ((event: MessageEvent) => void) | null = null + onerror: (() => void) | null = null + closed = false + + constructor( + readonly url: string, + readonly options?: EventSourceInit, + ) { + FakeEventSource.instances.push(this) + } + + addEventListener(type: string, listener: EventListenerOrEventListenerObject) { + this.listeners.set(type, listener as (event: Event) => void) + } + + emit(type: string, data?: unknown) { + this.listeners.get(type)?.( + data === undefined + ? new Event(type) + : new MessageEvent(type, {data: JSON.stringify(data)}), + ) + } + + message(data: unknown) { + this.onmessage?.(new MessageEvent("message", {data: JSON.stringify(data)})) + } + + close() { + this.closed = true + } +} + +const snapshot = (sharedReader: boolean): SessionSnapshot => + ({ + session: { + session_id: "session-1", + project_id: "project-1", + capabilities: {shared_reader: sharedReader}, + flags: {is_running: true}, + }, + execution: {turn_id: "turn-1", end_time: null}, + pending: {inputs: [], interactions: []}, + read: {latest_sequence: 42, history_complete: true}, + }) as SessionSnapshot + +const wrapper = (store: ReturnType) => + function Wrapper({children}: {children: ReactNode}) { + return createElement(Provider, {store}, children) + } + +describe("useSessionLivePreview sender subscription", () => { + beforeEach(() => { + FakeEventSource.instances = [] + vi.stubGlobal("EventSource", FakeEventSource) + vi.mocked(fetchSessionSnapshot).mockReset() + vi.mocked(querySessionTranscript).mockReset() + vi.mocked(querySessionTranscript).mockResolvedValue([]) + }) + + it("subscribes an advertised sender and follows after the snapshot watermark", async () => { + vi.mocked(fetchSessionSnapshot).mockResolvedValue(snapshot(true)) + const store = createStore() + store.set(projectIdAtom, "project-1") + const onReadyChange = vi.fn() + + const {result} = renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: false, + sender: true, + onReadyChange, + onDisconnect: vi.fn().mockResolvedValue(true), + }), + {wrapper: wrapper(store)}, + ) + + await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)) + expect(FakeEventSource.instances[0].url).toMatch(/\?after=42$/) + expect(result.current.runningFromSnapshot).toBe(true) + act(() => FakeEventSource.instances[0].emit("ready")) + expect(onReadyChange).toHaveBeenLastCalledWith(true) + }) + + it("makes no snapshot request or running claim when the capability is off", async () => { + const store = createStore() + store.set(projectIdAtom, "project-1") + + const {result} = renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: false, + runningElsewhere: false, + sender: true, + onDisconnect: vi.fn().mockResolvedValue(true), + }), + {wrapper: wrapper(store)}, + ) + + await act(async () => {}) + expect(fetchSessionSnapshot).not.toHaveBeenCalled() + expect(FakeEventSource.instances).toHaveLength(0) + expect(result.current.runningFromSnapshot).toBe(false) + }) + + it("keeps the sender connection open across remote-running state changes", async () => { + vi.mocked(fetchSessionSnapshot).mockResolvedValue(snapshot(true)) + const store = createStore() + store.set(projectIdAtom, "project-1") + + const {rerender} = renderHook( + ({runningElsewhere}: {runningElsewhere: boolean}) => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere, + sender: true, + onDisconnect: vi.fn().mockResolvedValue(true), + }), + {initialProps: {runningElsewhere: false}, wrapper: wrapper(store)}, + ) + + await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)) + rerender({runningElsewhere: true}) + + expect(fetchSessionSnapshot).toHaveBeenCalledOnce() + expect(FakeEventSource.instances).toHaveLength(1) + expect(FakeEventSource.instances[0].closed).toBe(false) + }) + + it("clears the reload snapshot when current liveness reports the turn stopped", async () => { + vi.mocked(fetchSessionSnapshot).mockResolvedValue(snapshot(true)) + const store = createStore() + store.set(projectIdAtom, "project-1") + + const {result, rerender} = renderHook( + ({runningElsewhere}: {runningElsewhere: boolean}) => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere, + sender: true, + onDisconnect: vi.fn().mockResolvedValue(true), + }), + {initialProps: {runningElsewhere: true}, wrapper: wrapper(store)}, + ) + + await waitFor(() => expect(result.current.runningFromSnapshot).toBe(true)) + rerender({runningElsewhere: false}) + + await waitFor(() => expect(result.current.runningFromSnapshot).toBe(false)) + expect(FakeEventSource.instances).toHaveLength(1) + expect(FakeEventSource.instances[0].closed).toBe(false) + }) + + it("reports the terminal durable event that releases an accepted sender turn", async () => { + vi.mocked(fetchSessionSnapshot).mockResolvedValue(snapshot(true)) + const store = createStore() + store.set(projectIdAtom, "project-1") + const onExecutionSettled = vi.fn() + + renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: false, + sender: true, + onExecutionSettled, + onDisconnect: vi.fn().mockResolvedValue(true), + }), + {wrapper: wrapper(store)}, + ) + + await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)) + act(() => + FakeEventSource.instances[0].message({ + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "execution-1", + frame_or_event_id: "event-43", + sequence: 43, + watermark: 43, + type: "execution.stopped", + payload: {}, + created_at: "2026-09-05T12:00:00Z", + }), + ) + + expect(onExecutionSettled).toHaveBeenCalledWith("execution-1") + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx new file mode 100644 index 00000000000..0da1e6c84b7 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx @@ -0,0 +1,436 @@ +import {createElement, type ReactNode} from "react" + +import type {SessionInteractionRowStates, SessionRecord} from "@agenta/entities/session" +import {projectIdAtom} from "@agenta/shared/state" +import {act, renderHook, waitFor} from "@testing-library/react" +import {createStore, Provider} from "jotai" +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" + +const mocks = vi.hoisted(() => ({ + fetchSessionSnapshot: vi.fn(), + querySessionTranscript: vi.fn(), + connectSessionLiveEvents: vi.fn(() => ({close: vi.fn()})), + interactionRowStates: new Map() as SessionInteractionRowStates, + revalidateInteractionStates: vi.fn(), +})) + +vi.mock("@agenta/entities/session", async (importOriginal) => { + const {atom} = await import("jotai") + const actual = await importOriginal() + return { + ...actual, + fetchSessionInteractionStatesAtom: atom(null, () => mocks.interactionRowStates), + revalidateSessionInteractionsAtom: atom(null, () => mocks.revalidateInteractionStates()), + fetchSessionSnapshot: mocks.fetchSessionSnapshot, + querySessionTranscript: mocks.querySessionTranscript, + } +}) + +vi.mock("../../../src/transport/sessionLiveEvents", () => ({ + connectSessionLiveEvents: mocks.connectSessionLiveEvents, +})) + +import {useSessionLivePreview} from "../../../src/hooks/useSessionLivePreview" + +const deferred = () => { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return {promise, resolve} +} + +const record = (id: string, payload: Record): SessionRecord => ({ + id, + session_id: "session-1", + project_id: "project-1", + sequence: null, + event_index: null, + sender: "agent", + session_update: String(payload.type), + payload, + created_at: null, +}) + +describe("useSessionLivePreview", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.interactionRowStates = new Map() + Object.defineProperty(document, "visibilityState", {configurable: true, value: "visible"}) + vi.stubGlobal("EventSource", class {}) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it("keeps the flag-off path snapshot-free", async () => { + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + + const {result} = renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: false, + runningElsewhere: true, + onDisconnect: vi.fn(), + }), + {wrapper}, + ) + + await act(async () => Promise.resolve()) + expect(mocks.fetchSessionSnapshot).not.toHaveBeenCalled() + expect(mocks.connectSessionLiveEvents).not.toHaveBeenCalled() + expect(result.current.runningFromSnapshot).toBe(false) + expect(result.current.readerReady).toBe(false) + }) + + it("loads and adopts the transcript through the snapshot before following its cursor", async () => { + const records = deferred<[]>() + const adopted = deferred() + const onDisconnect = vi.fn(() => adopted.promise) + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: false}}, + execution: null, + read: {latest_sequence: 7}, + }) + mocks.querySessionTranscript.mockReturnValue(records.promise) + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + + renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect, + }), + {wrapper}, + ) + + await waitFor(() => + expect(mocks.querySessionTranscript).toHaveBeenCalledWith({ + sessionId: "session-1", + projectId: "project-1", + throughSequence: 7, + }), + ) + expect(mocks.connectSessionLiveEvents).not.toHaveBeenCalled() + + await act(async () => records.resolve([])) + await waitFor(() => + expect(onDisconnect).toHaveBeenCalledWith({ + messages: [], + recordCount: 0, + sequenceCursor: 7, + interactionRows: mocks.interactionRowStates, + }), + ) + expect(mocks.connectSessionLiveEvents).not.toHaveBeenCalled() + + await act(async () => adopted.resolve(true)) + await waitFor(() => + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledWith( + expect.objectContaining({sessionId: "session-1", after: 7}), + ), + ) + }) + + it("does not follow a snapshot cursor the host refused to adopt", async () => { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: false}}, + execution: null, + read: {latest_sequence: 7}, + }) + mocks.querySessionTranscript.mockResolvedValue([]) + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + + renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect: vi.fn().mockResolvedValue(false), + }), + {wrapper}, + ) + + await waitFor(() => expect(mocks.querySessionTranscript).toHaveBeenCalledOnce()) + expect(mocks.connectSessionLiveEvents).not.toHaveBeenCalled() + }) + + it.each(["rejected", "undefined"] as const)( + "backs off when the bounded transcript read is %s", + async (failure) => { + vi.useFakeTimers() + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: false}}, + execution: null, + read: {latest_sequence: 7}, + }) + if (failure === "rejected") { + mocks.querySessionTranscript + .mockRejectedValueOnce(new Error("network changed")) + .mockResolvedValueOnce([]) + } else { + mocks.querySessionTranscript + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([]) + } + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + + renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect: vi.fn().mockResolvedValue(true), + }), + {wrapper}, + ) + + await act(async () => { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + expect(mocks.querySessionTranscript).toHaveBeenCalledTimes(1) + expect(mocks.connectSessionLiveEvents).not.toHaveBeenCalled() + + await act(async () => vi.advanceTimersByTimeAsync(4_999)) + expect(mocks.querySessionTranscript).toHaveBeenCalledTimes(1) + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(mocks.querySessionTranscript).toHaveBeenCalledTimes(2) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(1) + }, + ) + + it.each(["responded", "resolved", "cancelled"] as const)( + "joins %s interaction lifecycle before adopting the bounded transcript", + async (status) => { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: false}}, + execution: null, + read: {latest_sequence: 3}, + }) + mocks.querySessionTranscript.mockResolvedValue([ + record("r-call", { + type: "tool_call", + id: "tool-1", + name: "request_input", + input: {question: "Continue?"}, + }), + record("r-request", { + type: "interaction_request", + id: "interaction-1", + kind: "client_tool", + payload: {toolCallId: "tool-1", toolName: "request_input"}, + }), + record("r-done", {type: "done", stopReason: "paused"}), + ]) + mocks.interactionRowStates = new Map([ + [ + "interaction-1", + { + token: "interaction-1", + toolCallId: "tool-1", + kind: "client_tool", + status, + }, + ], + ]) as SessionInteractionRowStates + const onDisconnect = vi.fn().mockResolvedValue(true) + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + + renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect, + }), + {wrapper}, + ) + + await waitFor(() => expect(onDisconnect).toHaveBeenCalledOnce()) + const transcript = onDisconnect.mock.calls[0][0] + expect(transcript.interactionRows).toBe(mocks.interactionRowStates) + expect(transcript.messages[0].parts).toContainEqual( + expect.objectContaining({toolCallId: "tool-1", state: "output-available"}), + ) + }, + ) + + it("shows and settles a watched interaction from durable record events", async () => { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + execution: {turn_id: "turn-1", end_time: null}, + read: {latest_sequence: 1}, + }) + mocks.querySessionTranscript.mockResolvedValueOnce([]) + const onDisconnect = vi.fn().mockResolvedValue(true) + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + + renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect, + }), + {wrapper}, + ) + + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce()) + const connection = mocks.connectSessionLiveEvents.mock.calls[0][0] + const requestRecords = [ + record("r-call", { + type: "tool_call", + id: "tool-1", + name: "request_connection", + input: {integration: "github"}, + }), + record("r-request", { + type: "interaction_request", + id: "interaction-1", + kind: "client_tool", + payload: {toolCallId: "tool-1", toolName: "request_connection"}, + }), + ] + mocks.querySessionTranscript.mockResolvedValueOnce(requestRecords) + + act(() => + connection.onEvent({ + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "r-request", + sequence: 2, + watermark: 2, + type: "interaction.requested", + payload: {interaction_id: "interaction-1", kind: "client_tool"}, + created_at: "2026-09-05T12:00:00Z", + }), + ) + + await waitFor(() => expect(onDisconnect).toHaveBeenCalledTimes(2)) + expect(onDisconnect.mock.calls[1][0].messages[0].parts).toContainEqual( + expect.objectContaining({toolCallId: "tool-1", state: "input-available"}), + ) + + mocks.querySessionTranscript.mockResolvedValueOnce([ + ...requestRecords, + record("r-result", { + type: "tool_result", + id: "tool-1", + data: {connected: true}, + }), + ]) + act(() => + connection.onEvent({ + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "r-response", + sequence: 3, + watermark: 3, + type: "tool.completed", + payload: { + tool_call_id: "tool-1", + name: "request_connection", + input: {integration: "github"}, + output: {connected: true}, + status: "completed", + }, + created_at: "2026-09-05T12:00:01Z", + }), + ) + + await waitFor(() => expect(onDisconnect).toHaveBeenCalledTimes(3)) + expect(onDisconnect.mock.calls[2][0].messages[0].parts).toContainEqual( + expect.objectContaining({toolCallId: "tool-1", state: "output-available"}), + ) + expect(mocks.revalidateInteractionStates).toHaveBeenCalledOnce() + }) + + it("backs reconnects off and resets the delay only after ready", async () => { + vi.useFakeTimers() + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: false}}, + execution: null, + read: {latest_sequence: 7}, + }) + mocks.querySessionTranscript.mockResolvedValue([]) + const store = createStore() + store.set(projectIdAtom, "project-1") + const wrapper = ({children}: {children: ReactNode}) => + createElement(Provider, {store}, children) + + const {result} = renderHook( + () => + useSessionLivePreview({ + sessionId: "session-1", + sharedReaderAdvertised: true, + runningElsewhere: true, + onDisconnect: vi.fn().mockResolvedValue(true), + }), + {wrapper}, + ) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + expect(result.current.readerReady).toBe(false) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(1) + + const first = mocks.connectSessionLiveEvents.mock.calls[0][0] + act(() => first.onDisconnect({reason: "connection_lost", reconnect: true})) + await act(async () => vi.advanceTimersByTimeAsync(4_999)) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(1) + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(2) + + const second = mocks.connectSessionLiveEvents.mock.calls[1][0] + act(() => second.onDisconnect({reason: "connection_lost", reconnect: true})) + await act(async () => vi.advanceTimersByTimeAsync(9_999)) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(2) + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(3) + + const third = mocks.connectSessionLiveEvents.mock.calls[2][0] + act(() => third.onReady({watermark: 7})) + expect(result.current.readerReady).toBe(true) + act(() => third.onDisconnect({reason: "connection_lost", reconnect: true})) + expect(result.current.readerReady).toBe(false) + await act(async () => vi.advanceTimersByTimeAsync(4_999)) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(3) + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(4) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/durableEvents.test.ts b/web/packages/agenta-chat/tests/unit/model/durableEvents.test.ts new file mode 100644 index 00000000000..910b218d91e --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/durableEvents.test.ts @@ -0,0 +1,121 @@ +import {describe, expect, it} from "vitest" + +import type {SessionDurableEvent} from "@agenta/entities/session" + +import { + completeSessionDurableEventReplay, + createSessionDurableEventState, + reduceSessionDurableEvent, + shouldRefetchSessionTranscript, +} from "../../../src/model" + +const durableEvent = ( + sequence: number, + type = "message.completed", + watermark = sequence, +): SessionDurableEvent => ({ + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "execution-1", + frame_or_event_id: `event-${sequence}`, + sequence, + watermark, + type, + payload: {}, + created_at: "2026-09-04T00:00:00Z", +}) + +describe("reduceSessionDurableEvent", () => { + it("deduplicates retry deliveries by durable sequence", () => { + const initial = createSessionDurableEventState(4) + const once = reduceSessionDurableEvent(initial, durableEvent(5)) + const twice = reduceSessionDurableEvent(once, durableEvent(5)) + + expect(twice.latestSequence).toBe(5) + expect(twice.acceptedKnownEventCount).toBe(1) + expect(twice).toBe(once) + }) + + it("applies the non-contiguous event sequence seen on the live relay", () => { + const state = [3, 6, 7, 9].reduce( + (current, sequence) => reduceSessionDurableEvent(current, durableEvent(sequence)), + createSessionDurableEventState(1), + ) + + expect(state.latestSequence).toBe(9) + expect(state.lastEventSequence).toBe(9) + expect(state.acceptedKnownEventCount).toBe(4) + }) + + it("drops a duplicate and an out-of-order older event", () => { + const initial = [3, 6, 7, 9].reduce( + (current, sequence) => reduceSessionDurableEvent(current, durableEvent(sequence)), + createSessionDurableEventState(1), + ) + + expect(reduceSessionDurableEvent(initial, durableEvent(9))).toBe(initial) + expect(reduceSessionDurableEvent(initial, durableEvent(6))).toBe(initial) + }) + + it("keeps event order separate from an event watermark ahead of it", () => { + const first = reduceSessionDurableEvent( + createSessionDurableEventState(1), + durableEvent(3, "message.completed", 9), + ) + const second = reduceSessionDurableEvent(first, durableEvent(6, "tool.completed", 9)) + + expect(second.latestSequence).toBe(9) + expect(second.lastEventSequence).toBe(6) + expect(second.acceptedKnownEventCount).toBe(2) + }) + + it("learns the replay watermark when no typed event was returned", () => { + const state = completeSessionDurableEventReplay(createSessionDurableEventState(1), 5) + + expect(state.latestSequence).toBe(5) + expect(state.lastEventSequence).toBe(5) + expect(state.acceptedKnownEventCount).toBe(0) + }) + + it("ignores unknown event payloads while advancing the reconnect cursor", () => { + const state = reduceSessionDurableEvent( + createSessionDurableEventState(8), + durableEvent(9, "future.completed"), + ) + + expect(state.latestSequence).toBe(9) + expect(state.acceptedKnownEventCount).toBe(0) + }) + + it("does not replay cursorless legacy envelopes into the live tail", () => { + const state = createSessionDurableEventState(0) + expect(reduceSessionDurableEvent(state, {...durableEvent(1), sequence: null})).toBe(state) + }) + + it("refetches for each newly accepted known event", () => { + const initial = createSessionDurableEventState(4) + const accepted = reduceSessionDurableEvent(initial, durableEvent(5)) + const duplicate = reduceSessionDurableEvent(accepted, durableEvent(5)) + + expect(shouldRefetchSessionTranscript(initial, accepted, durableEvent(5))).toBe(true) + expect(shouldRefetchSessionTranscript(accepted, duplicate, durableEvent(5))).toBe(false) + }) + + it("refetches once for a sequence gap, not for watermark-only advances", () => { + let state = createSessionDurableEventState(4) + let refetches = 0 + const feed = (event: SessionDurableEvent) => { + const next = reduceSessionDurableEvent(state, event) + if (shouldRefetchSessionTranscript(state, next, event)) refetches += 1 + state = next + } + + feed(durableEvent(5, "future.completed", 8)) + feed(durableEvent(6, "future.completed", 9)) + expect(refetches).toBe(0) + + feed(durableEvent(8, "future.completed", 10)) + expect(refetches).toBe(1) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/error.test.ts b/web/packages/agenta-chat/tests/unit/model/error.test.ts index d1df74f894b..c66ac1d27af 100644 --- a/web/packages/agenta-chat/tests/unit/model/error.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/error.test.ts @@ -1,6 +1,8 @@ import {describe, expect, it} from "vitest" import { + ACCEPTED_SENDER_DISCONNECT_MESSAGE, + classifyAgentRunError, isTransportFailure, isSessionBusyRefusal, parseAgentRunError, @@ -108,3 +110,30 @@ describe("single-turn admission refusal", () => { expect(SESSION_TURN_IN_USE_MESSAGE).not.toContain("\n") }) }) + +describe("classifyAgentRunError", () => { + it("turns an accepted transport loss into connection state", () => { + expect(classifyAgentRunError(new TypeError("Failed to fetch"), true)).toEqual({ + connectionWarning: ACCEPTED_SENDER_DISCONNECT_MESSAGE, + }) + }) + + it("keeps an unaccepted transport loss as a run failure", () => { + expect(classifyAgentRunError(new TypeError("Failed to fetch"), false)).toEqual({ + runError: {message: TRANSPORT_ERROR_MESSAGE, transport: true}, + }) + }) + + it("keeps an accepted server verdict as a run failure", () => { + const verdict = JSON.stringify({status: {code: 422, message: "no usable credential"}}) + expect(classifyAgentRunError(verdict, true)).toEqual({ + runError: {message: "no usable credential", code: 422}, + }) + }) + + it("lets server-error provenance override a browser transport phrase", () => { + expect(classifyAgentRunError(new TypeError("Failed to fetch"), true, true)).toEqual({ + runError: {message: "Failed to fetch"}, + }) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts new file mode 100644 index 00000000000..420c50ff6d6 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts @@ -0,0 +1,318 @@ +import type {SessionLiveFrame, SessionSnapshot} from "@agenta/entities/session" +import {describe, expect, it} from "vitest" + +import { + createSessionLivePreviewState, + deriveRemoteTurnPresentation, + isSessionSnapshotRunning, + reduceSessionLivePreview, + sessionLivePreviewMessages, + shouldRefreshLegacyObserverLiveness, + shouldSubscribeToSessionLivePreview, + withoutSharedSenderAcceptanceMessages, +} from "../../../src/model/livePreview" + +const frame = ( + frameIndex: number, + type: string, + payload: Record, + entityId = "text-1", +): SessionLiveFrame => ({ + version: 1, + kind: "frame", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: `turn-1:${frameIndex}`, + frame_index: frameIndex, + entity_id: entityId, + type, + payload, + created_at: "2026-08-06T12:00:00Z", +}) + +describe("session live preview reducer", () => { + it("removes the control-only invoke message but preserves an invoke error", () => { + const accepted = { + id: "accepted", + role: "assistant", + parts: [{type: "data-session-accepted", data: {turnId: "turn-1"}}], + metadata: {sharedSender: true}, + } + const failed = { + ...accepted, + id: "failed", + metadata: {sharedSender: true, runError: {message: "failed"}}, + } + const ordinary = {id: "ordinary", role: "assistant", parts: [{type: "text", text: "ok"}]} + + expect( + withoutSharedSenderAcceptanceMessages([accepted, failed, ordinary] as never[]).map( + (message) => message.id, + ), + ).toEqual(["failed", "ordinary"]) + }) + + it("drops the control row and keeps a real invoke error", () => { + const user = {id: "u1", role: "user", parts: [{type: "text", text: "hi"}]} + const carrier = { + id: "accepted", + role: "assistant", + parts: [{type: "step-start"}], + metadata: {sharedSender: true}, + } + const invokeError = { + ...carrier, + metadata: {sharedSender: true, runError: {message: "no usable credential", code: 422}}, + } + + expect( + withoutSharedSenderAcceptanceMessages([user, invokeError] as never[]).map((m) => m.id), + ).toEqual(["u1", "accepted"]) + expect( + withoutSharedSenderAcceptanceMessages([user, carrier] as never[]).map((m) => m.id), + ).toEqual(["u1"]) + }) + + it("recognizes a running execution from the atomic reconnect snapshot", () => { + const snapshot = { + session: {flags: {is_running: true}}, + execution: {turn_id: "turn-1", end_time: null}, + } as SessionSnapshot + + expect(isSessionSnapshotRunning(snapshot)).toBe(true) + expect( + isSessionSnapshotRunning({ + ...snapshot, + execution: {...snapshot.execution, end_time: "2026-08-06T12:01:00Z"}, + } as SessionSnapshot), + ).toBe(false) + expect( + isSessionSnapshotRunning({ + ...snapshot, + session: {...snapshot.session, flags: {is_running: false}}, + } as SessionSnapshot), + ).toBe(false) + }) + + it("collapses ordered frames into their current entity state", () => { + let state = createSessionLivePreviewState() + state = reduceSessionLivePreview(state, frame(0, "text-start", {})) + state = reduceSessionLivePreview(state, frame(1, "text-delta", {delta: "hello "})) + state = reduceSessionLivePreview(state, frame(2, "text-delta", {delta: "world"})) + + expect(state.executionOrder).toEqual(["turn-1"]) + expect(state.byExecution["turn-1"].lastFrameIndex).toBe(2) + expect(state.byExecution["turn-1"].entityOrder).toEqual(["text-1"]) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "hello world"}, + ]) + }) + + it("deduplicates repeated frame ids", () => { + const initial = createSessionLivePreviewState() + const once = reduceSessionLivePreview(initial, frame(0, "text-delta", {delta: "once"})) + const twice = reduceSessionLivePreview(once, frame(0, "text-delta", {delta: "once"})) + + expect(twice).toBe(once) + expect(sessionLivePreviewMessages(twice)[0].parts).toEqual([{type: "text", text: "once"}]) + }) + + it("ignores a stale frame index without retaining a dedupe history", () => { + const first = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "text-delta", {delta: "new"}), + ) + const current = reduceSessionLivePreview(first, frame(1, "text-delta", {delta: "er"})) + const stale = reduceSessionLivePreview(current, frame(0, "text-delta", {delta: "old"})) + + expect(stale).toBe(current) + expect(sessionLivePreviewMessages(stale)[0].parts).toEqual([{type: "text", text: "newer"}]) + }) + + it("suppresses a late join whose first frame index is above zero", () => { + const gapped = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(2, "text-delta", {delta: "tail"}), + ) + const later = reduceSessionLivePreview(gapped, frame(3, "text-delta", {delta: "later"})) + + expect(gapped.gapDetected).toBe(true) + expect(gapped.executionOrder).toEqual([]) + expect(sessionLivePreviewMessages(gapped)).toEqual([]) + expect(later).toBe(gapped) + }) + + it("clears and suppresses a preview after an internal frame gap", () => { + const first = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "text-delta", {delta: "hello"}), + ) + const gapped = reduceSessionLivePreview(first, frame(2, "text-delta", {delta: " tail"})) + const missing = reduceSessionLivePreview(gapped, frame(1, "text-delta", {delta: " world"})) + + expect(gapped.gapDetected).toBe(true) + expect(gapped.executionOrder).toEqual([]) + expect(sessionLivePreviewMessages(gapped)).toEqual([]) + expect(missing).toBe(gapped) + }) + + it("updates one tool part by entity id through input and output", () => { + let state = createSessionLivePreviewState() + state = reduceSessionLivePreview( + state, + frame(0, "tool-input-start", {toolCallId: "call-1", toolName: "write_file"}, "call-1"), + ) + state = reduceSessionLivePreview( + state, + frame( + 1, + "tool-input-available", + {toolCallId: "call-1", toolName: "write_file", input: {path: "note.md"}}, + "call-1", + ), + ) + state = reduceSessionLivePreview( + state, + frame( + 2, + "tool-output-available", + {toolCallId: "call-1", output: {written: true}}, + "call-1", + ), + ) + + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + { + type: "dynamic-tool", + toolCallId: "call-1", + toolName: "write_file", + state: "output-available", + input: {path: "note.md"}, + output: {written: true}, + }, + ]) + }) + + it("keeps 5,000 deltas bounded to one entity with the same final text", () => { + let state = createSessionLivePreviewState() + for (let index = 0; index < 5_000; index += 1) { + state = reduceSessionLivePreview(state, frame(index, "text-delta", {delta: "x"})) + } + + const execution = state.byExecution["turn-1"] + expect(execution.lastFrameIndex).toBe(4_999) + expect(execution.entityOrder).toEqual(["text-1"]) + expect(Object.keys(execution.byEntity)).toEqual(["text-1"]) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "x".repeat(5_000)}, + ]) + }) +}) + +describe("session live preview subscription", () => { + it.each([ + {sharedReaderAdvertised: false, runningElsewhere: false, sender: false, expected: false}, + {sharedReaderAdvertised: false, runningElsewhere: true, sender: false, expected: false}, + {sharedReaderAdvertised: false, runningElsewhere: false, sender: true, expected: false}, + {sharedReaderAdvertised: true, runningElsewhere: false, sender: false, expected: false}, + {sharedReaderAdvertised: true, runningElsewhere: true, sender: false, expected: true}, + {sharedReaderAdvertised: true, runningElsewhere: false, sender: true, expected: true}, + ])( + "returns $expected when advertised=$sharedReaderAdvertised, remote=$runningElsewhere, sender=$sender", + ({sharedReaderAdvertised, runningElsewhere, sender, expected}) => { + expect( + shouldSubscribeToSessionLivePreview({ + sharedReaderAdvertised, + runningElsewhere, + sender, + }), + ).toBe(expected) + }, + ) +}) + +describe("legacy observer liveness refresh", () => { + it("refreshes from record notifications only when the reader is off and the throttle is due", () => { + expect( + shouldRefreshLegacyObserverLiveness({ + sharedReaderAdvertised: false, + lastRefreshAt: 1_000, + now: 11_000, + }), + ).toBe(true) + expect( + shouldRefreshLegacyObserverLiveness({ + sharedReaderAdvertised: false, + lastRefreshAt: 1_000, + now: 10_999, + }), + ).toBe(false) + expect( + shouldRefreshLegacyObserverLiveness({ + sharedReaderAdvertised: true, + lastRefreshAt: 1_000, + now: 11_000, + }), + ).toBe(false) + }) +}) + +describe("remote turn presentation", () => { + it.each([ + { + name: "uses turn activity once the advertised reader is ready", + input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: true}, + expected: {showActivity: true, showStrip: false}, + }, + { + name: "uses the fallback strip before the reader is ready", + input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: false}, + expected: {showActivity: false, showStrip: true}, + }, + { + name: "uses the fallback strip when the feature is off", + input: {livenessRunning: true, sharedReaderAdvertised: false, readerReady: false}, + expected: {showActivity: false, showStrip: true}, + }, + { + name: "never gives an owned continuation the fallback strip", + input: { + livenessRunning: true, + sharedReaderAdvertised: true, + readerReady: false, + ownedContinuation: true, + }, + expected: {showActivity: false, showStrip: false}, + }, + ])("$name", ({input, expected}) => { + expect(deriveRemoteTurnPresentation(input)).toEqual(expected) + }) + + it("uses session-stream liveness for the flag-off observer and clears at turn end", () => { + const base = { + snapshotRunning: true, + sharedReaderAdvertised: false, + readerReady: false, + } + + expect(deriveRemoteTurnPresentation({...base, livenessRunning: true})).toEqual({ + showActivity: false, + showStrip: true, + }) + expect(deriveRemoteTurnPresentation({...base, livenessRunning: false})).toEqual({ + showActivity: false, + showStrip: false, + }) + }) + + it("uses activity instead of the banner when the shared reader is ready", () => { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: false, + snapshotRunning: true, + sharedReaderAdvertised: true, + readerReady: true, + }), + ).toEqual({showActivity: true, showStrip: false}) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts b/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts index dffa34b9e7e..2f01f4fb4a6 100644 --- a/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts +++ b/web/packages/agenta-chat/tests/unit/transport/AgentChatTransport.test.ts @@ -1,7 +1,10 @@ import type {UIMessage, UIMessageChunk} from "ai" import {describe, expect, it, vi} from "vitest" -import {AgentChatTransport} from "../../../src/transport/AgentChatTransport" +import { + AgentChatTransport, + SHARED_SENDER_ACCEPTANCE_TIMEOUT_MS, +} from "../../../src/transport/AgentChatTransport" const readAll = async (stream: ReadableStream): Promise => { const reader = stream.getReader() @@ -17,6 +20,22 @@ const readAll = async (stream: ReadableStream): Promise ({id: "m1", role: "user", parts: [{type: "text", text}]}) as unknown as UIMessage +const streamResponse = (text: string): Response => { + const chunks = [ + {type: "start", messageId: "assistant-1"}, + {type: "start-step"}, + {type: "text-start", id: "text-1"}, + {type: "text-delta", id: "text-1", delta: text}, + {type: "text-end", id: "text-1"}, + {type: "finish-step"}, + {type: "finish"}, + ] + const body = chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + return new Response(`${body}data: [DONE]\n\n`, { + headers: {"content-type": "text/event-stream"}, + }) +} + describe("AgentChatTransport", () => { it("constructs and owns its own fetch (a caller-supplied fetch becomes the negotiator's base)", () => { const baseFetch = vi.fn() @@ -176,4 +195,241 @@ describe("AgentChatTransport", () => { expect(chunks[chunks.length - 1]).toMatchObject({type: "finish"}) expect(chunks.filter((c) => c.type === "text-end")).toHaveLength(1) }) + + it("consumes a shared sender invoke as acceptance and errors, never rendered content", async () => { + const sseBody = + [ + {type: "start", messageId: "acceptance-1", messageMetadata: {sessionId: "s1"}}, + {type: "start-step"}, + { + type: "data-session-accepted", + data: {turnId: "turn-1", executionId: "turn-1"}, + }, + { + type: "data-agent-error", + data: {code: "runner_error", errorText: "provider failed"}, + }, + {type: "text-start", id: "t1"}, + {type: "text-delta", id: "t1", delta: "must render from the event route"}, + {type: "text-end", id: "t1"}, + {type: "error", errorText: "provider failed"}, + {type: "finish-step"}, + {type: "finish", messageMetadata: {traceId: "trace-1"}}, + ] + .map((c) => `data: ${JSON.stringify(c)}\n\n`) + .join("") + "data: [DONE]\n\n" + const transport = new AgentChatTransport({ + api: "/api/agent/invoke", + headers: { + Accept: "text/event-stream", + "x-ag-session-response": "shared", + }, + fetch: vi.fn( + async () => + new Response(sseBody, { + status: 200, + headers: {"content-type": "text/event-stream"}, + }), + ) as unknown as typeof fetch, + }) + + const chunks = await readAll( + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [userMessage("hi")], + }), + ) + + expect(chunks.map((chunk) => chunk.type)).toEqual([ + "start", + "start-step", + "data-session-accepted", + "data-agent-error", + "error", + "finish-step", + "finish", + ]) + expect(chunks[0]).toMatchObject({messageMetadata: {sessionId: "s1", sharedSender: true}}) + expect(chunks[3]).toMatchObject({ + data: {code: "runner_error", errorText: "provider failed"}, + }) + expect(chunks.at(-1)).toMatchObject({ + messageMetadata: {traceId: "trace-1", sharedSender: true}, + }) + }) + + it("rejects a shared sender request when no acceptance arrives before the deadline", async () => { + vi.useFakeTimers() + try { + let requestSignal: AbortSignal | null | undefined + const transport = new AgentChatTransport({ + api: "/api/agent/invoke", + headers: { + Accept: "text/event-stream", + "x-ag-session-response": "shared", + }, + fetch: vi.fn((_input, init) => { + requestSignal = init?.signal + return new Promise(() => undefined) + }) as unknown as typeof fetch, + }) + + const pending = transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [userMessage("offline before send")], + }) + const rejection = expect(pending).rejects.toThrow("Failed to fetch") + await vi.advanceTimersByTimeAsync(SHARED_SENDER_ACCEPTANCE_TIMEOUT_MS) + + await rejection + expect(requestSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("rejects a shared sender response body that opens but never emits acceptance", async () => { + vi.useFakeTimers() + try { + let requestSignal: AbortSignal | null | undefined + const encoder = new TextEncoder() + const transport = new AgentChatTransport({ + api: "/api/agent/invoke", + headers: { + Accept: "text/event-stream", + "x-ag-session-response": "shared", + }, + fetch: vi.fn((_input, init) => { + requestSignal = init?.signal + return Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + for (const chunk of [ + {type: "start", messageId: "waiting-1"}, + {type: "start-step"}, + ]) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`), + ) + } + }, + }), + {headers: {"content-type": "text/event-stream"}}, + ), + ) + }) as unknown as typeof fetch, + }) + const pending = readAll( + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [userMessage("response opened but no acceptance")], + }), + ) + const rejection = expect(pending).rejects.toThrow("Failed to fetch") + + await vi.advanceTimersByTimeAsync(SHARED_SENDER_ACCEPTANCE_TIMEOUT_MS) + + await rejection + expect(requestSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("disarms the deadline after acceptance so a later disconnect stays post-acceptance", async () => { + vi.useFakeTimers() + try { + let source: ReadableStreamDefaultController | undefined + let requestSignal: AbortSignal | null | undefined + const encoder = new TextEncoder() + const transport = new AgentChatTransport({ + api: "/api/agent/invoke", + headers: { + Accept: "text/event-stream", + "x-ag-session-response": "shared", + }, + fetch: vi.fn((_input, init) => { + requestSignal = init?.signal + return Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + source = controller + for (const chunk of [ + {type: "start", messageId: "accepted-1"}, + {type: "start-step"}, + { + type: "data-session-accepted", + data: {executionId: "turn-1"}, + }, + ]) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`), + ) + } + }, + }), + {headers: {"content-type": "text/event-stream"}}, + ), + ) + }) as unknown as typeof fetch, + }) + const reader = ( + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [userMessage("accepted first")], + }) + ).getReader() + + expect((await reader.read()).value?.type).toBe("start") + expect((await reader.read()).value?.type).toBe("start-step") + expect((await reader.read()).value?.type).toBe("data-session-accepted") + await vi.advanceTimersByTimeAsync(SHARED_SENDER_ACCEPTANCE_TIMEOUT_MS * 2) + expect(requestSignal?.aborted).toBe(false) + + source?.error(new TypeError("Failed to fetch")) + await expect(reader.read()).rejects.toThrow("Failed to fetch") + } finally { + vi.useRealTimers() + } + }) + + it("does not apply the acceptance deadline when the shared sender flag is off", async () => { + vi.useFakeTimers() + try { + const baseFetch = vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => resolve(streamResponse("legacy response")), 20_000) + }), + ) + const transport = new AgentChatTransport({ + api: "/api/agent/invoke", + headers: {Accept: "text/event-stream"}, + fetch: baseFetch as unknown as typeof fetch, + }) + + const pending = transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [userMessage("legacy path")], + }) + await vi.advanceTimersByTimeAsync(20_000) + const chunks = await readAll(await pending) + + expect(chunks.some((chunk) => chunk.type === "text-delta")).toBe(true) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/web/packages/agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts b/web/packages/agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts new file mode 100644 index 00000000000..11c8f0bd6ef --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts @@ -0,0 +1,76 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" + +vi.mock("@agenta/shared/api", () => ({ + getAgentaApiUrl: () => "https://api.example.test", +})) + +import { + connectSessionLiveEvents, + sessionLiveEventsUrl, +} from "../../../src/transport/sessionLiveEvents" + +class FakeEventSource { + static latest: FakeEventSource | undefined + + onmessage: ((event: MessageEvent) => void) | null = null + onerror: (() => void) | null = null + readonly listeners = new Map() + + constructor( + readonly url: string, + readonly options: EventSourceInit, + ) { + FakeEventSource.latest = this + } + + addEventListener(type: string, listener: EventListener): void { + this.listeners.set(type, listener) + } + + close(): void {} +} + +describe("connectSessionLiveEvents", () => { + beforeEach(() => { + vi.stubGlobal("EventSource", FakeEventSource) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + FakeEventSource.latest = undefined + }) + + it("logs schema-invalid frames and does not deliver them", () => { + const onFrame = vi.fn() + const error = vi.spyOn(console, "error").mockImplementation(() => undefined) + connectSessionLiveEvents({ + sessionId: "session-1", + onFrame, + onReady: vi.fn(), + onDisconnect: vi.fn(), + }) + + FakeEventSource.latest?.onmessage?.( + new MessageEvent("message", {data: JSON.stringify({kind: "frame"})}), + ) + + expect(onFrame).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith( + "[sessionLiveEvents] Validation failed:", + expect.any(Object), + ) + }) +}) + +describe("sessionLiveEventsUrl", () => { + it("reconnects after the snapshot's durable sequence watermark", () => { + expect(sessionLiveEventsUrl("session/one", 37)).toMatch( + /\/sessions\/session%2Fone\/events\?after=37$/, + ) + }) + + it("never sends a negative replay cursor", () => { + expect(sessionLiveEventsUrl("session-1", -5)).toMatch(/\?after=0$/) + }) +}) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 1085d2abbcd..6d2fd8dd08a 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -18,6 +18,7 @@ import { sessionInteractionsResponseSchema, sessionRecordsQueryResponseSchema, sessionCancelExecutionResponseSchema, + sessionSnapshotSchema, sessionsQueryResponseSchema, sessionStreamCommandResponseSchema, sessionStreamSchema, @@ -30,6 +31,7 @@ import { type SessionInteractionKind, type SessionInteractionStatusCode, type SessionRecord, + type SessionSnapshot, type SessionExpansion, type SessionOrigin, type SessionStream, @@ -89,6 +91,60 @@ export async function querySessionRecords({ return validated?.records ?? null } +export interface QuerySessionTranscriptParams extends QueryRecordsParams { + /** Snapshot watermark. Rows committed later are replayed over SSE, never mixed into paging. */ + throughSequence: number + pageSize?: number +} + +/** Load the transcript fixed at a snapshot watermark, following bounded backend pages. */ +export async function querySessionTranscript({ + sessionId, + projectId, + appId, + abortSignal, + lowPriority, + throughSequence, + pageSize = 100, +}: QuerySessionTranscriptParams): Promise { + if (!projectId || !sessionId) return null + + const client = lowPriority ? getLowPrioritySessionsClient() : getSessionsClient() + const records: SessionRecord[] = [] + const visitedOffsets = new Set() + let offset = 0 + + while (!visitedOffsets.has(offset)) { + visitedOffsets.add(offset) + const data = await callFern("[querySessionTranscript]", () => + client.queryRecords( + { + session_id: sessionId, + windowing: { + offset, + limit: Math.max(1, Math.min(200, pageSize)), + through_sequence: throughSequence, + }, + }, + projectScopedRequest(projectId, appId, abortSignal), + ), + ) + if (!data) return null + + const validated = safeParseWithLogging( + sessionRecordsQueryResponseSchema, + data, + "[querySessionTranscript]", + ) + if (!validated) return null + records.push(...validated.records) + if (!validated.windowing) return records + offset = validated.windowing.offset + } + + return null +} + export interface SessionScopedParams { sessionId: string projectId: string @@ -96,6 +152,26 @@ export interface SessionScopedParams { abortSignal?: AbortSignal } +/** Fetch the lifecycle/pending state and durable sequence watermark used to reconnect safely. */ +export async function fetchSessionSnapshot({ + sessionId, + projectId, + appId, + abortSignal, +}: SessionScopedParams): Promise { + if (!projectId || !sessionId) return null + + const data = await callFern("[fetchSessionSnapshot]", () => + getSessionsClient().getSessionSnapshot( + {session_id: sessionId}, + projectScopedRequest(projectId, appId, abortSignal), + ), + ) + if (!data) return null + + return safeParseWithLogging(sessionSnapshotSchema, data, "[fetchSessionSnapshot]") +} + export interface QueryInteractionsParams extends Omit { /** Omit for a PROJECT-WIDE query — the backend treats `session_id` as optional, so one call * returns every matching interaction across the project (the pending-approvals badge diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index f15855544c7..b7e55b2f34b 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -21,6 +21,7 @@ export const sessionRecordSchema = z record_id: z.string(), session_id: z.string(), project_id: z.string(), + sequence: z.number().int().positive().nullish(), record_index: z.number().nullish(), record_source: z.string().nullish(), record_type: z.string().nullish(), @@ -32,6 +33,7 @@ export const sessionRecordSchema = z id: r.record_id, session_id: r.session_id, project_id: r.project_id, + sequence: r.sequence ?? null, event_index: r.record_index ?? null, sender: r.record_source ?? null, session_update: r.record_type ?? null, @@ -42,6 +44,13 @@ export const sessionRecordSchema = z export const sessionRecordsQueryResponseSchema = z.object({ count: z.number(), records: z.array(sessionRecordSchema), + windowing: z + .object({ + offset: z.number().int().nonnegative(), + limit: z.number().int().positive(), + through_sequence: z.number().int().nonnegative(), + }) + .nullish(), }) export type SessionRecord = z.infer @@ -163,6 +172,11 @@ export const sessionStreamSchema = z.object({ is_attached: z.boolean().nullish(), }) .nullish(), + capabilities: z + .object({ + shared_reader: z.boolean().nullish(), + }) + .nullish(), created_at: z.string().nullish(), updated_at: z.string().nullish(), deleted_at: z.string().nullish(), @@ -182,6 +196,66 @@ export const sessionStreamSchema = z.object({ last_message: sessionMessagePreviewSchema.nullish(), }) +/** Temporary live-frame envelope. Frames are display-only and never become durable records. */ +export const sessionLiveFrameSchema = z.object({ + version: z.literal(1), + kind: z.literal("frame"), + session_id: z.string(), + execution_id: z.string(), + frame_or_event_id: z.string(), + frame_index: z.number().int().nonnegative(), + entity_id: z.string(), + type: z.string(), + payload: z.record(z.string(), z.unknown()), + created_at: z.string(), +}) + +/** Durable relay envelope. `watermark` is a non-negative integer: on a live event it is the + * publishing records-worker batch's highest committed sequence for the session; on an SSE ready + * frame the same field name is the authoritative session sequence cursor after replay. When a + * ready frame omits it, the client keeps its requested `after` cursor. The open `type` is + * intentional: reconnect cursors must advance past future event types even when this client does + * not know how to render them yet. */ +export const sessionDurableEventSchema = z.object({ + version: z.literal(1), + kind: z.literal("event"), + session_id: z.string(), + execution_id: z.string(), + frame_or_event_id: z.string(), + sequence: z.number().int().positive().nullable(), + watermark: z.number().int().nonnegative(), + type: z.string(), + payload: z.record(z.string(), z.unknown()), + created_at: z.string(), +}) + +export const sessionDurableEventTypeSchema = z.enum([ + "execution.started", + "execution.stopped", + "execution.failed", + "execution.lost", + "message.completed", + "tool.completed", + "interaction.requested", + "interaction.responded", +]) + +export const sessionRecordsReadStateSchema = z.object({ + latest_sequence: z.number().int().nonnegative(), + history_complete: z.boolean(), +}) + +/** Atomic reconnect read: durable watermark plus lifecycle and pending-work context. */ +export const sessionSnapshotSchema = z.object({ + session: sessionStreamSchema, + execution: z.record(z.string(), z.unknown()).nullable().optional(), + pending: z.object({ + inputs: z.array(z.unknown()).default([]), + interactions: z.array(sessionInteractionSchema).default([]), + }), + read: sessionRecordsReadStateSchema, +}) + export const sessionStreamsResponseSchema = z.object({ count: z.number(), streams: z.array(sessionStreamSchema), @@ -222,6 +296,11 @@ export const sessionCancelExecutionResponseSchema = z.union([ ]) export type SessionStream = z.infer +export type SessionLiveFrame = z.infer +export type SessionDurableEvent = z.infer +export type SessionDurableEventType = z.infer +export type SessionRecordsReadState = z.infer +export type SessionSnapshot = z.infer export type SessionReference = z.infer export type SessionOrigin = z.infer export type SessionTriggerKind = z.infer diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index 823aa229466..1ade6569aca 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -7,6 +7,8 @@ */ export { querySessionRecords, + querySessionTranscript, + fetchSessionSnapshot, queryInteractions, fetchInteraction, respondInteraction, @@ -34,6 +36,7 @@ export { type MountFilesPage, type LatestMountFilesParams, type QueryRecordsParams, + type QuerySessionTranscriptParams, type QuerySessionsPageParams, type QuerySessionsParams, type SessionScopedParams, @@ -57,6 +60,11 @@ export { sessionRecordsQueryResponseSchema, sessionInteractionSchema, sessionStreamSchema, + sessionLiveFrameSchema, + sessionDurableEventSchema, + sessionDurableEventTypeSchema, + sessionRecordsReadStateSchema, + sessionSnapshotSchema, sessionsQueryResponseSchema, type SessionRecord, type SessionRecordsQueryResponse, @@ -64,6 +72,11 @@ export { type SessionInteractionKind, type SessionInteractionStatusCode, type SessionStream, + type SessionLiveFrame, + type SessionDurableEvent, + type SessionDurableEventType, + type SessionRecordsReadState, + type SessionSnapshot, type SessionsQueryResponse, type SessionReference, type SessionReferenceKey, @@ -113,6 +126,14 @@ export { sessionRecordsQueryKey, type SessionRecordsFetchResult, } from "./state/records" +export { + sessionLivePreviewAtomFamily, + clearSessionLivePreviewAtom, + createSessionLivePreviewState, + type SessionLivePreviewExecution, + type SessionLivePreviewEntityState, + type SessionLivePreviewState, +} from "./state/livePreview" export { fetchSessionInteractionStatesAtom, hasWaitingInteraction, diff --git a/web/packages/agenta-entities/src/session/state/livePreview.ts b/web/packages/agenta-entities/src/session/state/livePreview.ts new file mode 100644 index 00000000000..b8360c34efd --- /dev/null +++ b/web/packages/agenta-entities/src/session/state/livePreview.ts @@ -0,0 +1,37 @@ +import {atom} from "jotai" +import {atomFamily} from "jotai-family" + +export interface SessionLivePreviewEntityState { + part: Record & {type: string} +} + +export interface SessionLivePreviewExecution { + entityOrder: string[] + byEntity: Record + lastFrameIndex: number +} + +/** + * Display-only frame state for a session. `@agenta/chat` owns reduction semantics; the entity + * layer owns the per-session lifetime so desktop and mobile share one source without touching the + * sender's durable transcript or `useChat` state. + */ +export interface SessionLivePreviewState { + executionOrder: string[] + byExecution: Record + gapDetected: boolean +} + +export const createSessionLivePreviewState = (): SessionLivePreviewState => ({ + executionOrder: [], + byExecution: {}, + gapDetected: false, +}) + +export const sessionLivePreviewAtomFamily = atomFamily((_sessionId: string) => + atom(createSessionLivePreviewState()), +) + +export const clearSessionLivePreviewAtom = atom(null, (_get, set, sessionId: string) => { + set(sessionLivePreviewAtomFamily(sessionId), createSessionLivePreviewState()) +}) diff --git a/web/packages/agenta-playground/src/agentChat.ts b/web/packages/agenta-playground/src/agentChat.ts index 00e4c9c90fc..d7de7e781f9 100644 --- a/web/packages/agenta-playground/src/agentChat.ts +++ b/web/packages/agenta-playground/src/agentChat.ts @@ -8,6 +8,7 @@ export { buildAgentRequest, applyBuildKitOverlay, + SHARED_SESSION_RESPONSE_HEADER, type AgentRequest, } from "./state/execution/agentRequest" export { diff --git a/web/packages/agenta-playground/src/state/execution/agentRequest.ts b/web/packages/agenta-playground/src/state/execution/agentRequest.ts index 07481be7700..d9f1786d787 100644 --- a/web/packages/agenta-playground/src/state/execution/agentRequest.ts +++ b/web/packages/agenta-playground/src/state/execution/agentRequest.ts @@ -49,6 +49,11 @@ export interface AgentRequest { headers: Record } +/** Client-only transport marker: the invoke stream carries acceptance/errors while session + * frames provide the rendered response. The API forwards it harmlessly; AgentChatTransport + * consumes it before parsing the response. */ +export const SHARED_SESSION_RESPONSE_HEADER = "x-ag-session-response" + /** Minimal store surface — the default Jotai store, or a test store. */ type StoreLike = Pick, "get"> @@ -302,7 +307,7 @@ const withQuery = (url: string, params: Record): str export async function buildAgentRequest( entityId: string, messages: unknown[], - opts: {sessionId: string; store?: StoreLike}, + opts: {sessionId: string; store?: StoreLike; sharedResponse?: boolean}, ): Promise { const store = opts.store ?? getDefaultStore() @@ -388,8 +393,14 @@ export async function buildAgentRequest( // the UIMessage request body (`data.inputs.messages`) and the response projection. const channelMode = store.get(agentChannelModeAtomFamily(opts.sessionId)) const headers: Record = { - Accept: channelMode === "batch" ? "application/json" : "text/event-stream", + // The shared sender still consumes invoke acceptance/errors as SSE; its response content + // is deliberately not the render source, regardless of the local batch preference. + Accept: + opts.sharedResponse || channelMode !== "batch" + ? "text/event-stream" + : "application/json", "x-ag-messages-format": "vercel", + ...(opts.sharedResponse ? {[SHARED_SESSION_RESPONSE_HEADER]: "shared"} : {}), ...(headersFactory ? await headersFactory() : {}), } @@ -422,6 +433,7 @@ export async function buildAgentRequest( headers, requestBody: { session_id: opts.sessionId, + ...(opts.sharedResponse ? {flags: {detached: true}} : {}), references, data: {inputs: {messages: outboundMessages}, parameters}, }, diff --git a/web/packages/agenta-playground/src/state/execution/index.ts b/web/packages/agenta-playground/src/state/execution/index.ts index 7e74d820c98..ed9c1a48d44 100644 --- a/web/packages/agenta-playground/src/state/execution/index.ts +++ b/web/packages/agenta-playground/src/state/execution/index.ts @@ -356,6 +356,7 @@ export { applyBuildKitOverlay, buildAgentRequest, buildAgentReferences, + SHARED_SESSION_RESPONSE_HEADER, type AgentRequest, } from "./agentRequest" // Stream vs batch response channel for the agent lane (read by buildAgentRequest's Accept header). diff --git a/web/packages/agenta-playground/tests/unit/agentRequest.test.ts b/web/packages/agenta-playground/tests/unit/agentRequest.test.ts index b5968009fb2..76b946a13fc 100644 --- a/web/packages/agenta-playground/tests/unit/agentRequest.test.ts +++ b/web/packages/agenta-playground/tests/unit/agentRequest.test.ts @@ -697,6 +697,27 @@ describe("buildAgentRequest", () => { store.set(agentChannelModeAtomFamily("s1"), "stream") }) + it("marks a shared sender request and keeps its acceptance channel streaming", async () => { + store.set(agentChannelModeAtomFamily("s1"), "batch") + seed(store, "e", {}) + const req = await buildAgentRequest("e", [], { + sessionId: "s1", + sharedResponse: true, + store, + }) + expect(req!.headers.Accept).toBe("text/event-stream") + expect(req!.headers["x-ag-session-response"]).toBe("shared") + expect(req!.requestBody).toMatchObject({flags: {detached: true}}) + store.set(agentChannelModeAtomFamily("s1"), "stream") + }) + + it("keeps the legacy invoke body unchanged when the shared sender is not ready", async () => { + seed(store, "e", {}) + const req = await buildAgentRequest("e", [], {sessionId: "s1", store}) + expect(req!.requestBody).not.toHaveProperty("flags") + expect(req!.headers).not.toHaveProperty("x-ag-session-response") + }) + it("declares the Vercel message format via x-ag-messages-format", async () => { seed(store, "e", {}) const req = await buildAgentRequest("e", [], {sessionId: "s1", store}) diff --git a/web/storybook/stories/domain/SessionHistoryNotice.stories.tsx b/web/storybook/stories/domain/SessionHistoryNotice.stories.tsx new file mode 100644 index 00000000000..cc3f8c3fc09 --- /dev/null +++ b/web/storybook/stories/domain/SessionHistoryNotice.stories.tsx @@ -0,0 +1,34 @@ +import {SessionHistoryNotice} from "@agenta/chat/components" +import type {Meta, StoryObj} from "@storybook/nextjs" + +const meta = { + title: "@agenta/chat/Domain/SessionHistoryNotice", + component: SessionHistoryNotice, + parameters: { + layout: "centered", + docs: { + description: { + component: + "Transient reconnect feedback and the persistent warning shown when a session's durable history is known to be incomplete.", + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Reconnecting: Story = { + args: {state: "reconnecting"}, +} + +export const IncompleteHistory: Story = { + args: {state: "incomplete"}, +}