diff --git a/STATUS-round7.md b/STATUS-round7.md new file mode 100644 index 00000000000..9295311f0b5 --- /dev/null +++ b/STATUS-round7.md @@ -0,0 +1,48 @@ +# Round 7 status + +Branch: `feat/session-durable-approvals` + +Implemented as seven buildable commits after `5700408966`: + +1. Cross-reader interaction events bypass the general refetch throttle, then refetch and reconcile + settled rows into mounted transcripts. +2. Held messages remain visible during gates; recoverable Sends retry the durable continuation. +3. A terminal server transcript clears the desktop approval dock even if the row cache is stale. +4. Approval response and recovery state is scoped to the interaction that produced it. +5. Initial and background transcript hydration replay against fresh interaction rows. +6. Dispatcher and inline router fallback share bounded reference resolution (latest turn, then stream). +7. Replayed terminal records release the held queue after a completed continuation. + +## Browser re-check + +- Open one pending approval in two desktop tabs. Answer in tab B. Tab A must leave the actionable + "Needs your approval" state within one second without a second click, then clear after the + continuation's terminal record. +- Repeat with mobile answering and desktop observing. The desktop result must match the two-desktop + case. +- While a gate is open, send a message. A visible `1 queued message · waits for your answer` card + must appear immediately on desktop and mobile. +- Force a recoverable approval response, then Send. The Send must redeliver the saved continuation, + keep the typed message visible in the held queue, and must not start a competing fresh turn or + create a `continuation_resumed` failure bubble. +- After that continuation writes its terminal record, the approval dock must close and the held + message must leave the queue and run exactly once as the next turn. +- After any recoverable interaction, start a new approval whose continuation succeeds. The new card + must show ordinary pending/answered copy, never inherited "retry needed" copy. +- Reload a session whose interaction row is `responded` or `resolved`. No actionable approval card + may reappear. +- Exercise a legacy/reference-less gate through the inline fallback composition. The continuation + must resolve the newest turn's workflow reference (or the stream fallback) and invoke normally. + +## Automated verification + +- `@agenta/chat`: 648 passed. +- `@agenta/oss`: 429 passed, 1 skipped. +- `@agenta/entities`: 1,480 unit tests passed; 31 integration tests skipped because the required + API/auth environment was not configured. +- `@agenta/mobile`: 147 passed. +- `@agenta/sessions`: 71 passed. +- Chat, OSS, entities, and mobile typechecks passed. +- Monorepo frontend lint passed (four pre-existing mobile hook warnings remain). +- API sessions: 706 passed. +- Ruff 0.15.12 format check: 1,491 files formatted; Ruff check passed. diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index 3683c9a9978..2646fc86099 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -1,6 +1,7 @@ from contextlib import asynccontextmanager import asyncio import time +from uuid import UUID import agenta as ag from fastapi import FastAPI @@ -95,6 +96,7 @@ from oss.src.core.folders.service import FoldersService from oss.src.core.workflows.service import WorkflowsService from oss.src.core.workflows.service import SimpleWorkflowsService +from oss.src.core.workflows.dtos import WorkflowServiceRequest from oss.src.core.workflows.static_catalog import StaticWorkflowCatalog from oss.src.core.evaluators.service import EvaluatorsService from oss.src.core.evaluators.service import SimpleEvaluatorsService @@ -185,6 +187,9 @@ from oss.src.dbs.postgres.sessions.commands.dbes import SessionCommandDBE # noqa: F401 from oss.src.dbs.postgres.sessions.commands.dao import SessionCommandsDAO from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO +from oss.src.dbs.postgres.sessions.inputs.dbes import SessionInputDBE # noqa: F401 +from oss.src.dbs.postgres.sessions.inputs.dao import SessionInputsDAO +from oss.src.core.sessions.inputs.service import SessionInputsService from oss.src.core.sessions.commands.service import SessionCommandsService from oss.src.dbs.http.sessions.control_delivery_direct import DirectControlDelivery from oss.src.tasks.asyncio.sessions.orphan_sweep import orphan_sweep_loop @@ -602,6 +607,7 @@ async def lifespan(*args, **kwargs): session_turns_dao = SessionTurnsDAO(engine=_transactions_engine) session_commands_dao = SessionCommandsDAO(engine=_transactions_engine) session_executions_dao = SessionExecutionsDAO(engine=_transactions_engine) +session_inputs_dao = SessionInputsDAO(engine=_transactions_engine) connections_dao = ConnectionsDAO(engine=_transactions_engine) mounts_dao = MountsDAO(engine=_transactions_engine) @@ -868,11 +874,12 @@ async def lifespan(*args, **kwargs): # Detached workflow start: hand the run to the runner and return on the started handshake # (no awaiting the run). Shared by both detached consumers (triggers + interactions respond). -async def _dispatch_detached_run(*, project_id, user_id, request) -> str: +async def _dispatch_detached_run(*, project_id, user_id, request, run_id=None) -> str: result = await workflows_service.invoke_workflow_detached( project_id=project_id, user_id=user_id, request=request, + run_id=run_id, ) return result.run_id @@ -891,6 +898,10 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: workflows_service=workflows_service, interactions_service=interactions_service, records_service=records_service, + # Read-only: the resume's reference fallback, for a gate row whose own `data.references` is + # empty. Without it the invoke has nothing to resolve a service URL from. + turns_service=session_turns_service, + streams_service=session_streams_service, dispatch_fn=_dispatch_detached_run, ) @@ -1148,8 +1159,37 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: streams_service=session_streams_service, interactions_service=interactions_service, lock_engine=_lock_engine, - delivery=DirectControlDelivery(), + delivery=DirectControlDelivery( + continue_interaction=lambda command: _interactions_dispatcher.respond_many( + project_id=command.project_id, + user_id=command.created_by_id, + interaction_answers=[ + (UUID(item["interaction_id"]), item["answer"]) + for item in command.data["answers"] + ], + control_command_id=command.id, + continuation_execution_id=command.target_turn_id, + ), + continue_input=lambda command: workflows_service.invoke_workflow_detached( + project_id=command.project_id, + user_id=command.created_by_id, + request=WorkflowServiceRequest.model_validate(command.data["request"]), + run_id=command.target_turn_id, + control_command_id=command.id, + ), + ), + executions_dao=session_executions_dao, + inputs_dao=session_inputs_dao, +) +session_inputs_service = SessionInputsService( + inputs_dao=session_inputs_dao, + interactions_dao=interactions_dao, + streams_service=session_streams_service, executions_dao=session_executions_dao, + continuation_resumer=session_commands_service.resume_recoverable_continuation, +) +workflows_service.set_session_continuation_resumer( + session_commands_service.resume_recoverable_continuation ) sessions = SessionsRouter( @@ -1163,6 +1203,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: turns_service=session_turns_service, sessions_service=sessions_service, commands_service=session_commands_service, + inputs_service=session_inputs_service, respond_task=_interactions_worker.respond_interaction, interactions_dispatcher=_interactions_dispatcher, ) diff --git a/api/entrypoints/worker_streams.py b/api/entrypoints/worker_streams.py index e9125c1eb00..2aad6cf942a 100644 --- a/api/entrypoints/worker_streams.py +++ b/api/entrypoints/worker_streams.py @@ -35,6 +35,7 @@ from oss.src.dbs.postgres.events.dao import EventsDAO from oss.src.dbs.postgres.secrets.dao import SecretsDAO from oss.src.dbs.postgres.sessions.interactions.dao import SessionInteractionsDAO +from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO from oss.src.dbs.postgres.sessions.records.dao import RecordsDAO from oss.src.dbs.postgres.tracing.dao import TracingDAO from oss.src.dbs.postgres.webhooks.dao import WebhooksDAO @@ -88,7 +89,10 @@ async def _build_spans_worker(redis_client: Redis) -> StreamConsumer: async def _build_records_worker(redis_client: Redis) -> StreamConsumer: watch_publisher = SessionsWatchPublisher(redis_client=redis_client) return RecordsWorker( - service=RecordsService(records_dao=RecordsDAO()), + service=RecordsService( + records_dao=RecordsDAO(), + executions_dao=SessionExecutionsDAO(), + ), redis_client=redis_client, stream_name=RECORD_STREAM_NAME, consumer_group="worker-records", diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000027_add_durable_interaction_continuations.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000027_add_durable_interaction_continuations.py new file mode 100644 index 00000000000..daef355161b --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000027_add_durable_interaction_continuations.py @@ -0,0 +1,76 @@ +"""add durable interaction continuation executions + +Revision ID: oss000000027 +Revises: oss000000026 +Create Date: 2026-09-04 12:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "oss000000027" +down_revision: Union[str, None] = "oss000000026" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_constraint("ck_session_commands_kind", "session_commands", type_="check") + op.create_check_constraint( + "ck_session_commands_kind", + "session_commands", + "kind IN ('cancel', 'continue_interaction')", + ) + + op.alter_column("session_executions", "terminal_outcome", nullable=True) + op.alter_column("session_executions", "settled_by", nullable=True) + op.alter_column("session_executions", "settled_at", nullable=True) + op.add_column( + "session_executions", + sa.Column("state", sa.String(), server_default="terminal", nullable=False), + ) + op.add_column( + "session_executions", + sa.Column("parent_execution_id", sa.String(), nullable=True), + ) + op.add_column( + "session_executions", + sa.Column("source_interaction_id", sa.UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "session_executions", + sa.Column("error", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + op.alter_column( + "session_executions", "state", server_default="active", nullable=False + ) + op.create_index( + "uq_session_executions_source_interaction", + "session_executions", + ["project_id", "source_interaction_id"], + unique=True, + postgresql_where=sa.text("source_interaction_id IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index( + "uq_session_executions_source_interaction", table_name="session_executions" + ) + op.drop_column("session_executions", "error") + op.drop_column("session_executions", "source_interaction_id") + op.drop_column("session_executions", "parent_execution_id") + op.drop_column("session_executions", "state") + op.execute("DELETE FROM session_executions WHERE terminal_outcome IS NULL") + op.alter_column("session_executions", "settled_at", nullable=False) + op.alter_column("session_executions", "settled_by", nullable=False) + op.alter_column("session_executions", "terminal_outcome", nullable=False) + + op.drop_constraint("ck_session_commands_kind", "session_commands", type_="check") + op.create_check_constraint( + "ck_session_commands_kind", "session_commands", "kind IN ('cancel')" + ) diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py new file mode 100644 index 00000000000..437cb7d173b --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000028_add_session_pending_inputs.py @@ -0,0 +1,93 @@ +"""add durable session pending inputs + +Revision ID: oss000000028 +Revises: oss000000027 +Create Date: 2026-09-04 15:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "oss000000028" +down_revision: Union[str, None] = "oss000000027" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_constraint("ck_session_commands_kind", "session_commands", type_="check") + op.create_check_constraint( + "ck_session_commands_kind", + "session_commands", + "kind IN ('cancel', 'continue_interaction', 'continue_input')", + ) + op.create_table( + "session_inputs", + sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("content", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("position", sa.BigInteger(), nullable=False), + sa.Column("state", sa.String(), server_default="pending", nullable=False), + sa.Column("policy", sa.String(), nullable=False), + sa.Column("idempotency_key", sa.String(), nullable=False), + sa.Column("request_fingerprint", sa.String(length=64), nullable=False), + sa.Column("promoted_execution_id", sa.String(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.func.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(as_uuid=True), nullable=True), + sa.Column("updated_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("deleted_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.CheckConstraint( + "state IN ('pending', 'promoted', 'removed')", + name="ck_session_inputs_state", + ), + sa.CheckConstraint( + "policy IN ('queue', 'steer')", name="ck_session_inputs_policy" + ), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("project_id", "id"), + ) + op.create_index("uq_session_inputs_id", "session_inputs", ["id"], unique=True) + op.create_index( + "uq_session_inputs_idempotency", + "session_inputs", + ["project_id", "session_id", "idempotency_key"], + unique=True, + ) + op.create_index( + "uq_session_inputs_position", + "session_inputs", + ["project_id", "session_id", "position"], + unique=True, + ) + op.create_index( + "ix_session_inputs_pending", + "session_inputs", + ["project_id", "session_id", "position"], + postgresql_where=sa.text("state = 'pending'"), + ) + + +def downgrade() -> None: + op.drop_index("ix_session_inputs_pending", table_name="session_inputs") + op.drop_index("uq_session_inputs_position", table_name="session_inputs") + op.drop_index("uq_session_inputs_idempotency", table_name="session_inputs") + op.drop_index("uq_session_inputs_id", table_name="session_inputs") + op.drop_table("session_inputs") + op.drop_constraint("ck_session_commands_kind", "session_commands", type_="check") + op.create_check_constraint( + "ck_session_commands_kind", + "session_commands", + "kind IN ('cancel', 'continue_interaction')", + ) diff --git a/api/oss/src/apis/fastapi/sessions/models.py b/api/oss/src/apis/fastapi/sessions/models.py index 2b577657656..b6280d3896a 100644 --- a/api/oss/src/apis/fastapi/sessions/models.py +++ b/api/oss/src/apis/fastapi/sessions/models.py @@ -29,6 +29,7 @@ from oss.src.core.sessions.mounts.dtos import SessionMount, SessionMountQuery from oss.src.core.sessions.turns.dtos import HarnessKind, SessionTurn, SessionTurnQuery from oss.src.core.sessions.types import SessionReference +from oss.src.core.sessions.inputs.dtos import PendingInputUpdate, PendingInput from oss.src.core.shared.dtos import OTelSpanId, Windowing from oss.src.dbs.postgres.sessions.streams.dao import MAX_SESSION_QUERY_LIMIT @@ -122,6 +123,35 @@ class SessionResponse(BaseModel): session: Optional[SessionStream] = None +class SessionCapabilities(BaseModel): + durable_approvals: bool = False + queue: bool = False + steer: bool = False + + +class SessionExecutionSnapshot(BaseModel): + id: Optional[str] = None + state: Literal["idle", "running", "stopping"] = "idle" + + +class PendingInputResponse(BaseModel): + input: PendingInput + + +class PendingInputAdmissionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + session_id: SessionId + content: Dict[str, Any] + on_busy: Literal["reject", "queue", "steer"] = "reject" + + +class PendingInputAdmissionResponse(BaseModel): + action: Literal["execute", "pending"] + input: Optional[PendingInput] = None + execution_id: Optional[str] = None + + # --------------------------------------------------------------------------- # Streams request/response models # --------------------------------------------------------------------------- @@ -140,6 +170,7 @@ class SessionStreamQueryRequest(BaseModel): class SessionStreamResponse(BaseModel): stream: Optional[SessionStream] = None + capabilities: SessionCapabilities = Field(default_factory=SessionCapabilities) class SessionStreamsResponse(BaseModel): @@ -178,15 +209,33 @@ class SessionRecordsQueryResponse(BaseModel): class SessionSnapshotPending(BaseModel): - inputs: List[Any] = Field(default_factory=list) + inputs: List[PendingInput] = Field(default_factory=list) interactions: List[SessionInteraction] = Field(default_factory=list) class SessionSnapshotResponse(BaseModel): - session: SessionStream + """One snapshot for every reader of an open session. + + `session`, `execution` and `read` are the nullable reconnect half: the stream row, the + latest turn (whose `end_time` says whether that turn is still live), and the durable + sequence watermark a reader replays from. They are absent when the shared reader is off or + before a fresh session has a stream row. + + `execution_state` and `pending.inputs` are the queue half. `execution_state` is the + session's CURRENT lifecycle derived from the stream row, which is a different question from + `execution`: that names the last turn, this says whether anything is running right now. + `capabilities` reports the same flags the streams endpoint reports, from the same helper, so + a client never sees the two disagree. + """ + + session: Optional[SessionStream] = None execution: Optional[SessionTurn] = None + execution_state: SessionExecutionSnapshot = Field( + default_factory=SessionExecutionSnapshot + ) pending: SessionSnapshotPending - read: SessionRecordsReadState + read: Optional[SessionRecordsReadState] = None + capabilities: SessionCapabilities = Field(default_factory=SessionCapabilities) class SessionRecordResponse(BaseModel): @@ -261,11 +310,28 @@ class SessionInteractionsResponse(BaseModel): interactions: List[SessionInteraction] = Field(default_factory=list) +class SessionInteractionAnswerRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + interaction_id: UUID + answer: Dict[str, Any] + + class SessionInteractionRespondRequest(BaseModel): # For a user_approval interaction the answer is {approved: bool, tool_call_id?: str, # message?: str} — the dispatcher composes the full resume conversation server-side # (interactions_dispatcher.compose_approval_messages). Other kinds pass through as-is. answer: Optional[Dict[str, Any]] = None + answers: Optional[List[SessionInteractionAnswerRequest]] = Field( + default=None, min_length=1, max_length=100 + ) + expected_execution_id: Optional[str] = None + + @model_validator(mode="after") + def validate_answer_shape(self) -> "SessionInteractionRespondRequest": + if self.answer is not None and self.answers is not None: + raise ValueError("answer and answers cannot be combined") + return self # --------------------------------------------------------------------------- @@ -469,6 +535,23 @@ class SessionExecutionRef(BaseModel): state: Literal["stopping", "idle"] +class SessionInteractionContinuationExecution(BaseModel): + id: str + state: Literal[ + "awaiting_interactions", + "pending_delivery", + "recoverable", + "running", + "terminal", + ] + + +class SessionInteractionContinuationResponse(BaseModel): + interaction: SessionInteraction + command: Optional[SessionCommandRef] = None + execution: SessionInteractionContinuationExecution + + class SessionCancelResponse(BaseModel): command: SessionCommandRef execution: SessionExecutionRef @@ -482,7 +565,13 @@ class SessionExecutionOutcome(BaseModel): # stopped: cancelled as asked. not_running: no such execution on this runner. # superseded_by_newer_turn: the held execution started after the command arrived. # failed: the cancel itself failed. - state: Literal["stopped", "failed", "not_running", "superseded_by_newer_turn"] + state: Literal[ + "stopped", + "failed", + "not_running", + "superseded_by_newer_turn", + "started", + ] # Short and human-readable, present only when `state` is "failed". error: Optional[str] = Field(default=None, max_length=2000) @@ -501,10 +590,24 @@ class SessionCommandSettlement(BaseModel): id: UUID state: Literal["applied", "obsolete"] outcome: Literal[ - "stopped", "not_running", "superseded_by_newer_turn", "failed", "lost" + "stopped", + "not_running", + "superseded_by_newer_turn", + "failed", + "lost", + "started", ] settled_at: Optional[datetime] = None class SessionControlOutcomeResponse(BaseModel): command: SessionCommandSettlement + admitted: bool = False + + +class SessionContinuationResumeResponse(BaseModel): + resumed: bool + + +class PendingInputUpdateRequest(PendingInputUpdate): + pass diff --git a/api/oss/src/apis/fastapi/sessions/router.py b/api/oss/src/apis/fastapi/sessions/router.py index 96281be9e4f..96f486e81f3 100644 --- a/api/oss/src/apis/fastapi/sessions/router.py +++ b/api/oss/src/apis/fastapi/sessions/router.py @@ -82,6 +82,7 @@ from oss.src.core.sessions.commands.types import ( ExecutionExpectationFailed, SessionCommandIdempotencyConflict, + InteractionResponseConflict, SessionCommandNotClaimable, SessionCommandNotFound, ) @@ -90,6 +91,7 @@ MAX_LIVE_FRAME_BYTES, SessionLiveFrame, SessionRecordEvent, + TERMINAL_RECORD_TYPE, ) from oss.src.core.sessions.records.streaming import publish_live_frame, publish_record from oss.src.core.sessions.interactions.dtos import ( @@ -100,7 +102,19 @@ SessionInteractionTransition, ) from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.interactions.references import resolve_interaction_references from oss.src.core.sessions.interactions.types import InteractionNotFound +from oss.src.core.sessions.inputs.service import SessionInputsService +from oss.src.core.sessions.inputs.types import ( + SessionInputBusy, + SessionInputIdempotencyConflict, + SessionInputNotFound, + SessionInputNotRemovable, + SessionInputNotEditable, + SessionInputContentInvalid, + SessionInputRemoved, +) +from oss.src.core.sessions.inputs.dtos import PendingInputState from oss.src.core.sessions.attachments.dtos import Attachment from oss.src.core.sessions.attachments.service import SessionAttachmentsService from oss.src.core.sessions.attachments.types import ( @@ -147,6 +161,7 @@ SessionCommandSettlement, SessionControlOutcomeRequest, SessionControlOutcomeResponse, + SessionContinuationResumeResponse, SessionExecutionRef, # streams SessionDetachRequest, @@ -166,6 +181,8 @@ SessionInteractionCreateRequest, SessionInteractionQueryRequest, SessionInteractionRespondRequest, + SessionInteractionContinuationExecution, + SessionInteractionContinuationResponse, SessionInteractionResolution, SessionInteractionResponse, SessionInteractionsResponse, @@ -188,6 +205,12 @@ SessionQueryRequest, SessionResponse, SessionsResponse, + PendingInputResponse, + PendingInputUpdateRequest, + PendingInputAdmissionRequest, + PendingInputAdmissionResponse, + SessionCapabilities, + SessionExecutionSnapshot, ) from oss.src.apis.fastapi.sessions.utils import ( compute_session_response_windowing, @@ -203,6 +226,27 @@ _SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,128}$") +def _session_capabilities() -> SessionCapabilities: + return SessionCapabilities( + durable_approvals=env.agenta.sessions.durable_approvals, + queue=env.agenta.sessions.queue, + steer=env.agenta.sessions.queue and env.agenta.sessions.steer, + ) + + +def _idempotency_key_too_long_response() -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "code": "validation_error", + "message": "Idempotency-Key is too long.", + "retryable": False, + "details": {"field": "Idempotency-Key", "reason": "too_long"}, + "next_step": "Use an Idempotency-Key of at most 255 characters.", + }, + ) + + def _validate_session_id_http(session_id: str) -> None: if not _SESSION_ID_RE.match(session_id): raise HTTPException( @@ -498,7 +542,10 @@ async def fetch_session_stream( project_id=UUID(str(project_id)), session_id=session_id, ) - return SessionStreamResponse(stream=sanitize_session_stream(stream)) + return SessionStreamResponse( + stream=sanitize_session_stream(stream), + capabilities=_session_capabilities(), + ) @intercept_exceptions() @_handle_session_exceptions() @@ -810,8 +857,13 @@ async def watch_project( class RecordsRouter: """Records sub-router — /sessions/records/*""" - def __init__(self, records_service: RecordsService): + def __init__( + self, + records_service: RecordsService, + commands_service: Optional[SessionCommandsService] = None, + ): self.records_service = records_service + self.commands_service = commands_service self.router = APIRouter() self.router.add_api_route( @@ -992,7 +1044,25 @@ async def ingest_record_event( return {"ok": True} assert not isinstance(body, list) - await publish_record( + # For a finished continuation, commit the core execution outcome before accepting its + # terminal record into the asynchronous tracing stream. This closes the cross-database + # window where the watchdog could see no `done`, expose recovery, and replay work that + # had already finished while the records worker was still settling core state. + if ( + (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue) + and self.commands_service is not None + and body.record_type == TERMINAL_RECORD_TYPE + and body.turn_id + and (body.attributes or {}).get("stopReason") + not in ("paused", "cancelled", "error") + ): + await self.commands_service.settle_execution_completed( + project_id=UUID(project_id), + session_id=body.session_id, + execution_id=body.turn_id, + ) + + published = await publish_record( organization_id=UUID(request.state.organization_id), project_id=UUID(project_id), record_event=SessionRecordEvent( @@ -1008,6 +1078,11 @@ async def ingest_record_event( span_id=body.span_id, ), ) + if not published: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Record ingestion is temporarily unavailable.", + ) return {"ok": True} @@ -1024,11 +1099,17 @@ def __init__( # import the tasks layer). When present, the no-worker respond fallback goes through # it so both paths share ONE answer-composition implementation. interactions_dispatcher: Optional[Any] = None, + commands_service: Optional[SessionCommandsService] = None, + turns_service: Optional[SessionTurnsService] = None, + streams_service: Optional[SessionStreamsService] = None, ) -> None: self.interactions_service = interactions_service self.workflows_service = workflows_service self.respond_task = respond_task self.interactions_dispatcher = interactions_dispatcher + self.commands_service = commands_service + self.turns_service = turns_service + self.streams_service = streams_service self.router = APIRouter() @@ -1259,7 +1340,7 @@ async def respond_interaction( request: Request, interaction_id: UUID, body: SessionInteractionRespondRequest, - ) -> SessionInteractionResponse: + ) -> Any: project_id: UUID = request.state.project_id user_id: UUID = request.state.user_id @@ -1271,6 +1352,116 @@ async def respond_interaction( if not authorized: raise FORBIDDEN_EXCEPTION + if body.answers is not None and interaction_id not in { + item.interaction_id for item in body.answers + }: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "code": "validation_error", + "message": "The path interaction must be included in answers.", + "retryable": False, + "details": {"field": "answers", "reason": "anchor_missing"}, + }, + ) + + if env.agenta.sessions.durable_approvals and self.commands_service is not None: + idempotency_key = (request.headers.get("Idempotency-Key") or "").strip() + if not idempotency_key: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "code": "validation_error", + "message": "Idempotency-Key is required for a durable response.", + "retryable": False, + "details": {"field": "Idempotency-Key", "reason": "required"}, + "next_step": "Retry with a stable Idempotency-Key header.", + }, + ) + if len(idempotency_key) > _MAX_IDEMPOTENCY_KEY_CHARACTERS: + return _idempotency_key_too_long_response() + if body.answer is None and body.answers is None: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "code": "validation_error", + "message": "answer is required for a durable response.", + "retryable": False, + "details": {"field": "answer", "reason": "required"}, + }, + ) + interaction_answers = ( + [(item.interaction_id, item.answer) for item in body.answers] + if body.answers is not None + else [(interaction_id, body.answer)] + ) + try: + if body.answers is not None: + admission = await self.commands_service.respond_interactions( + project_id=UUID(str(project_id)), + user_id=UUID(str(user_id)), + interaction_answers=interaction_answers, + expected_execution_id=body.expected_execution_id, + idempotency_key=idempotency_key, + ) + else: + admission = await self.commands_service.respond_interaction( + project_id=UUID(str(project_id)), + user_id=UUID(str(user_id)), + interaction_id=interaction_id, + answer=body.answer, + expected_execution_id=body.expected_execution_id, + idempotency_key=idempotency_key, + ) + except InteractionResponseConflict as error: + return JSONResponse( + status_code=( + status.HTTP_422_UNPROCESSABLE_ENTITY + if error.code == "validation_error" + else status.HTTP_409_CONFLICT + ), + content={ + "code": error.code, + "message": error.message, + "retryable": False, + **({"details": error.details} if error.details else {}), + }, + ) + + response = SessionInteractionContinuationResponse( + interaction=admission.interaction, + command=( + SessionCommandRef( + id=admission.command.id, + state=admission.command.state.value, + ) + if admission.command is not None + else None + ), + execution=SessionInteractionContinuationExecution( + id=admission.execution_id, + state=( + "awaiting_interactions" + if getattr(admission, "waiting_for_interactions", False) + else admission.execution_state.value + ), + ), + ) + return JSONResponse( + status_code=status.HTTP_202_ACCEPTED, + content=response.model_dump(mode="json"), + ) + + if body.answers is not None: + responses = {} + for item in body.answers: + responses[item.interaction_id] = await self.respond_interaction( + request=request, + interaction_id=item.interaction_id, + body=SessionInteractionRespondRequest(answer=item.answer), + ) + return responses[interaction_id] + try: interaction = await self.interactions_service.fetch_interaction( project_id=project_id, @@ -1328,13 +1519,11 @@ async def respond_interaction( answer=answer, ) else: - references = ( - { - k: v.model_dump(mode="json") - for k, v in interaction.data.references.items() - } - if interaction.data and interaction.data.references - else None + references = await resolve_interaction_references( + project_id=UUID(str(project_id)), + interaction=interaction, + turns_service=self.turns_service, + streams_service=self.streams_service, ) selector = ( interaction.data.selector.model_dump(mode="json") @@ -1913,12 +2102,14 @@ def __init__( records_service: Optional[RecordsService] = None, interactions_service: Optional[SessionInteractionsService] = None, turns_service: Optional[SessionTurnsService] = None, + inputs_service: Optional[SessionInputsService] = 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.inputs_service = inputs_service self.router = APIRouter() self.router.add_api_route( @@ -1939,6 +2130,25 @@ def __init__( status_code=status.HTTP_200_OK, tags=["Sessions"], ) + if inputs_service is not None: + # The snapshot itself is `get_session_snapshot`, registered below: one route serves + # both the reconnect watermark and the durable queue. + self.router.add_api_route( + "/sessions/{session_id}/inputs/{input_id}", + self.update_pending_input, + methods=["PATCH"], + operation_id="update_pending_session_input", + response_model=PendingInputResponse, + tags=["Sessions"], + ) + self.router.add_api_route( + "/sessions/{session_id}/inputs/{input_id}", + self.remove_pending_input, + methods=["DELETE"], + operation_id="remove_pending_session_input", + response_model=PendingInputResponse, + tags=["Sessions"], + ) self.router.add_api_route( "/sessions/archive", self.archive_session, @@ -1977,8 +2187,6 @@ async def get_session_snapshot( 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), @@ -1986,33 +2194,33 @@ async def get_session_snapshot( permission=Permission.VIEW_SESSIONS, ): raise FORBIDDEN_EXCEPTION - if not all( - ( - self.streams_service, - self.records_service, - self.interactions_service, - self.turns_service, - ) + if self.streams_service is None or self.interactions_service is None: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) + if env.sessions.shared_reader and ( + self.records_service is None or self.turns_service is None ): 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( + stream = await self.streams_service.fetch( project_id=project_id, session_id=session_id, ) + session = None + execution = None + read = None + if env.sessions.shared_reader and stream is not None: + session = sanitize_session_stream(stream) + read = await self.records_service.get_read_state( + project_id=project_id, + session_id=session_id, + ) + if getattr(stream, "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( @@ -2020,11 +2228,34 @@ async def get_session_snapshot( status=SessionInteractionStatus.pending, ), ) + # The durable queue half. It is optional: a deployment without the inputs service still + # gets the reconnect half, and reports an empty queue rather than failing the snapshot. + inputs = ( + await self.inputs_service.list_pending( + project_id=project_id, + session_id=session_id, + ) + if self.inputs_service is not None + else [] + ) + # The stream remains the lifecycle source even when its reconnect representation is hidden. + execution_state = SessionExecutionSnapshot( + id=(stream.stopping_turn_id or stream.turn_id) if stream else None, + state=( + "stopping" + if stream and stream.stopping_turn_id + else "running" + if stream and stream.flags.is_running + else "idle" + ), + ) return SessionSnapshotResponse( - session=sanitize_session_stream(session), + session=session, execution=execution, - pending=SessionSnapshotPending(interactions=interactions), + execution_state=execution_state, + pending=SessionSnapshotPending(inputs=inputs, interactions=interactions), read=read, + capabilities=_session_capabilities(), ) @intercept_exceptions() @@ -2083,6 +2314,105 @@ async def query_sessions( windowing=response_windowing, ) + @intercept_exceptions() + async def update_pending_input( + self, + request: Request, + session_id: str, + input_id: UUID, + payload: PendingInputUpdateRequest, + ) -> PendingInputResponse: + _validate_session_id_http(session_id) + project_id = UUID(str(request.state.project_id)) + user_id = request.state.user_id + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + try: + item = await self.inputs_service.update( + project_id=project_id, + user_id=UUID(str(user_id)) if user_id else None, + session_id=session_id, + input_id=input_id, + update=payload, + ) + except SessionInputNotFound as error: + raise HTTPException( + status_code=404, + detail={ + "code": "pending_input_not_found", + "message": str(error), + "retryable": False, + "details": {"input_id": str(input_id)}, + }, + ) from error + except SessionInputNotEditable as error: + raise HTTPException( + status_code=409, + detail={ + "code": "pending_input_not_editable", + "message": str(error), + "retryable": False, + "details": {"input_id": str(input_id)}, + }, + ) from error + except SessionInputContentInvalid as error: + raise HTTPException( + status_code=422, + detail={ + "code": "pending_input_content_invalid", + "message": str(error), + "retryable": False, + "details": {"input_id": str(input_id)}, + }, + ) from error + return PendingInputResponse(input=item) + + @intercept_exceptions() + async def remove_pending_input( + self, request: Request, session_id: str, input_id: UUID + ) -> PendingInputResponse: + _validate_session_id_http(session_id) + project_id = UUID(str(request.state.project_id)) + user_id = request.state.user_id + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + try: + item = await self.inputs_service.remove( + project_id=project_id, + user_id=UUID(str(user_id)) if user_id else None, + session_id=session_id, + input_id=input_id, + ) + except SessionInputNotFound as error: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "pending_input_not_found", + "message": str(error), + "retryable": False, + "details": {"input_id": error.input_id}, + }, + ) from error + except SessionInputNotRemovable as error: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "pending_input_promoted", + "message": str(error), + "retryable": False, + "details": {"input_id": error.input_id}, + }, + ) from error + return PendingInputResponse(input=item) + @intercept_exceptions() async def delete_session( self, @@ -2208,6 +2538,58 @@ async def wrapper(*args, **kwargs): status_code=status.HTTP_409_CONFLICT, detail={"message": e.message, "state": e.state}, ) from e + except InteractionResponseConflict as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": e.code, + "message": e.message, + "retryable": False, + **({"details": e.details} if e.details else {}), + }, + ) from e + + return wrapper + + return decorator + + +def _handle_input_exceptions(): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except SessionInputBusy as error: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "session_busy", + "message": str(error), + "retryable": True, + "next_step": "Retry after the current execution settles.", + "details": {"current_execution_id": error.current_execution_id}, + }, + ) from error + except SessionInputIdempotencyConflict as error: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "idempotency_key_reused", + "message": str(error), + "retryable": False, + "next_step": "Reuse the original body or send a new key.", + }, + ) from error + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": "validation_error", + "message": str(error), + "retryable": False, + }, + ) from error return wrapper @@ -2232,8 +2614,10 @@ def __init__( self, *, commands_service: SessionCommandsService, + inputs_service: Optional[SessionInputsService] = None, ) -> None: self._service = commands_service + self._inputs_service = inputs_service self.router = APIRouter() self.router.add_api_route( @@ -2243,6 +2627,13 @@ def __init__( operation_id="cancel_session_execution", tags=["Sessions"], ) + self.router.add_api_route( + "/sessions/{session_id}/continuations/resume", + self.resume_session_continuation, + methods=["POST"], + operation_id="resume_session_continuation", + tags=["Sessions"], + ) self.router.add_api_route( "/sessions/control/commands/{command_id}/outcome", self.report_command_outcome, @@ -2251,6 +2642,126 @@ def __init__( tags=["Sessions"], include_in_schema=False, ) + if inputs_service is not None: + self.router.add_api_route( + "/sessions/{session_id}/inputs/{input_id}/send-now", + self.send_pending_input_now, + methods=["POST"], + operation_id="send_pending_session_input_now", + response_model=PendingInputAdmissionResponse, + tags=["Sessions"], + ) + self.router.add_api_route( + "/sessions/control/inputs/admit", + self.admit_session_input, + methods=["POST"], + operation_id="admit_session_input", + include_in_schema=False, + tags=["Sessions"], + ) + + @intercept_exceptions() + @_handle_input_exceptions() + @_handle_command_exceptions() + async def send_pending_input_now( + self, request: Request, session_id: str, input_id: UUID + ) -> JSONResponse: + _validate_session_id_http(session_id) + project_id = UUID(str(request.state.project_id)) + user_id = request.state.user_id + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + try: + admission = await self._service.send_pending_input_now( + project_id=project_id, + user_id=UUID(str(user_id)) if user_id else None, + session_id=session_id, + input_id=input_id, + ) + except (SessionInputNotFound, SessionInputRemoved) as error: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND + if isinstance(error, SessionInputNotFound) + else status.HTTP_409_CONFLICT, + detail={ + "code": "pending_input_not_found" + if isinstance(error, SessionInputNotFound) + else "pending_input_removed", + "message": str(error), + "retryable": False, + "details": {"input_id": error.input_id}, + }, + ) from error + return JSONResponse( + status_code=status.HTTP_202_ACCEPTED, + content=PendingInputAdmissionResponse(**admission.model_dump()).model_dump( + mode="json", exclude_none=True + ), + ) + + @intercept_exceptions() + @_handle_input_exceptions() + async def admit_session_input( + self, request: Request, payload: PendingInputAdmissionRequest + ) -> JSONResponse: + project_id = UUID(str(request.state.project_id)) + user_id = request.state.user_id + if not await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ): + raise FORBIDDEN_EXCEPTION + idempotency_key = request.headers.get("Idempotency-Key") + if idempotency_key is not None: + idempotency_key = ( + idempotency_key.strip()[:_MAX_IDEMPOTENCY_KEY_CHARACTERS] or None + ) + admission = await self._inputs_service.admit( + project_id=project_id, + user_id=UUID(str(user_id)) if user_id else None, + session_id=payload.session_id, + content=payload.content, + policy=payload.on_busy, + idempotency_key=idempotency_key, + ) + if ( + payload.on_busy == "steer" + and admission.action == "pending" + and admission.input is not None + and admission.input.state == PendingInputState.pending + ): + # The input is durable before Stop is requested. A refused or unreachable Stop + # therefore never loses the user's message; it stays visible and removable. + try: + await self._service.request_cancel( + project_id=project_id, + user_id=UUID(str(user_id)) if user_id else None, + session_id=payload.session_id, + expected_execution_id=admission.execution_id, + idempotency_key=f"steer:{admission.input.id}", + steer_input_id=admission.input.id, + ) + except Exception as error: # noqa: BLE001 - the input is already durable + log.warning( + "steer stop request failed input=%s session=%s: %s", + admission.input.id, + payload.session_id, + error, + ) + response = PendingInputAdmissionResponse(**admission.model_dump()) + return JSONResponse( + status_code=( + status.HTTP_202_ACCEPTED + if admission.action == "pending" + else status.HTTP_200_OK + ), + content=response.model_dump(mode="json", exclude_none=True), + ) @intercept_exceptions() @_handle_command_exceptions() @@ -2287,9 +2798,10 @@ async def cancel_session_execution( idempotency_key = request.headers.get("Idempotency-Key") if idempotency_key is not None: - idempotency_key = ( - idempotency_key.strip()[:_MAX_IDEMPOTENCY_KEY_CHARACTERS] or None - ) + idempotency_key = idempotency_key.strip() + if len(idempotency_key) > _MAX_IDEMPOTENCY_KEY_CHARACTERS: + return _idempotency_key_too_long_response() + idempotency_key = idempotency_key or None admission = await self._service.request_cancel( project_id=UUID(str(project_id)), @@ -2318,6 +2830,34 @@ async def cancel_session_execution( content=body.model_dump(mode="json"), ) + @intercept_exceptions() + @_handle_command_exceptions() + async def resume_session_continuation( + self, + request: Request, + session_id: str, + ) -> SessionContinuationResumeResponse: + project_id = request.state.project_id + user_id = request.state.user_id + + has_permission = await check_action_access( + user_uid=str(user_id), + project_id=str(project_id), + permission=Permission.RUN_SESSIONS, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + resumed = False + if env.agenta.sessions.durable_approvals: + resumed = bool( + await self._service.resume_recoverable_continuation( + project_id=UUID(str(project_id)), + session_id=session_id, + ) + ) + return SessionContinuationResumeResponse(resumed=resumed) + @intercept_exceptions() @_handle_command_exceptions() async def report_command_outcome( @@ -2328,7 +2868,7 @@ async def report_command_outcome( ) -> SessionControlOutcomeResponse: _assert_runner_token(request) - settled = await self._service.report_outcome( + report = await self._service.report_outcome( command_id=command_id, replica_id=payload.replica_id, result=payload.result, @@ -2338,11 +2878,14 @@ async def report_command_outcome( ) return SessionControlOutcomeResponse( command=SessionCommandSettlement( - id=settled.id, - state=settled.state.value, - outcome=settled.outcome.value if settled.outcome else "failed", - settled_at=settled.settled_at, - ) + id=report.command.id, + state=report.command.state.value, + outcome=( + report.command.outcome.value if report.command.outcome else "failed" + ), + settled_at=report.command.settled_at, + ), + admitted=report.admitted, ) @@ -2406,6 +2949,7 @@ def __init__( turns_service: SessionTurnsService, sessions_service: SessionsService, commands_service: SessionCommandsService, + inputs_service: Optional[SessionInputsService] = None, respond_task: Optional[Any] = None, interactions_dispatcher: Optional[Any] = None, ) -> None: @@ -2414,12 +2958,18 @@ def __init__( interactions_service=interactions_service, records_service=records_service, ) - self.records = RecordsRouter(records_service=records_service) + self.records = RecordsRouter( + records_service=records_service, + commands_service=commands_service, + ) self.interactions = InteractionsRouter( interactions_service=interactions_service, workflows_service=workflows_service, respond_task=respond_task, interactions_dispatcher=interactions_dispatcher, + commands_service=commands_service, + turns_service=turns_service, + streams_service=streams_service, ) self.attachments = SessionAttachmentsRouter( attachments_service=attachments_service, @@ -2435,5 +2985,8 @@ def __init__( records_service=records_service, interactions_service=interactions_service, turns_service=turns_service, + inputs_service=inputs_service, + ) + self.control = SessionControlRouter( + commands_service=commands_service, inputs_service=inputs_service ) - self.control = SessionControlRouter(commands_service=commands_service) diff --git a/api/oss/src/apis/fastapi/sessions/watch.py b/api/oss/src/apis/fastapi/sessions/watch.py index 5c6c0613339..9684cafb2f7 100644 --- a/api/oss/src/apis/fastapi/sessions/watch.py +++ b/api/oss/src/apis/fastapi/sessions/watch.py @@ -1,10 +1,10 @@ """SSE frame generator for ``GET /sessions/streams/watch`` (M3 live relay). Bridges one Redis pub/sub subscription (durable plane, one per SSE connection) -into `text/event-stream` frames. Events carry TYPE + minimal metadata only — -clients revalidate through their existing query paths; no record payloads ride -the wire. Idle periods emit ``: heartbeat`` comment frames so proxies and -clients never see a silent connection. +into `text/event-stream` frames. Most events carry type plus minimal metadata; +interaction events may carry committed row state so readers can retire a gate +without a query round trip. Idle periods emit ``: heartbeat`` comment frames so +proxies and clients never see a silent connection. """ import json diff --git a/api/oss/src/core/sessions/commands/dtos.py b/api/oss/src/core/sessions/commands/dtos.py index 27b2f9c2ad2..4ecc4d8d669 100644 --- a/api/oss/src/core/sessions/commands/dtos.py +++ b/api/oss/src/core/sessions/commands/dtos.py @@ -24,6 +24,8 @@ class SessionCommandKind(str, Enum): cancel = "cancel" + continue_interaction = "continue_interaction" + continue_input = "continue_input" class SessionCommandState(str, Enum): @@ -45,6 +47,7 @@ class SessionCommandOutcome(str, Enum): ) failed = "failed" # the cancel itself failed lost = "lost" # nobody ever reported; the sweep settled it + started = "started" # a continuation was admitted by the runner class SessionCommand(Identifier, Lifecycle): diff --git a/api/oss/src/core/sessions/commands/interfaces.py b/api/oss/src/core/sessions/commands/interfaces.py index 9f87cd73106..89bb9ce7afd 100644 --- a/api/oss/src/core/sessions/commands/interfaces.py +++ b/api/oss/src/core/sessions/commands/interfaces.py @@ -42,9 +42,12 @@ class DeliveryReceipt(BaseModel): settlement sweep recovers it. * `not_held` — a reachable runner said it does not hold that session, which lets the service settle at once instead of waiting for the deadline. + * `exhausted` — the bounded delivery budget is spent, so the transport was never called. + Nothing retries the command on its own after this, which is why it is not `unreachable`: + the caller must tell the user the truth rather than promise a redelivery. """ - status: str # "accepted" | "unreachable" | "not_held" + status: str # "accepted" | "unreachable" | "not_held" | "exhausted" detail: Optional[str] = None # Which runner process took it, when the transport learned that. The service uses it as the # claim owner, so the outcome route's guard reads the same way on every transport. @@ -80,10 +83,22 @@ async def create_command( user_id: Optional[UUID], command: SessionCommandCreate, stopping_turn_id: Optional[str] = None, + transaction: Optional[Any] = None, ) -> SessionCommand: """Insert one command and, in the SAME transaction, stamp the session row's `stopping_turn_id`. Idempotent on `(project_id, session_id, idempotency_key)`.""" + @abstractmethod + async def fetch_by_idempotency_key( + self, + *, + project_id: UUID, + session_id: str, + idempotency_key: str, + transaction: Optional[Any] = None, + ) -> Optional[SessionCommand]: + """The command previously created for this session-scoped retry key.""" + @abstractmethod async def create_command_with_status( self, @@ -95,26 +110,49 @@ async def create_command_with_status( """Create a command and report whether this call inserted it.""" @abstractmethod - async def fetch_by_idempotency_key( + async def fetch_open_command( self, *, project_id: UUID, session_id: str, - idempotency_key: str, + kind: SessionCommandKind, + target_turn_id: Optional[str], + transaction: Optional[Any] = None, ) -> Optional[SessionCommand]: - """The command previously created for this session-scoped retry key.""" + """The open (`pending` or `claimed`) command for this exact target, if one exists. + This is what collapses two Stops in a row onto one command.""" - @abstractmethod - async def fetch_open_command( + async def bind_steer_input( + self, + *, + project_id: UUID, + command_id: UUID, + input_id: UUID, + transaction: Optional[Any] = None, + ) -> Optional[SessionCommand]: + """Bind the first Steer input to an open Stop command.""" + raise NotImplementedError + + async def fetch_resumable_continuation( self, *, project_id: UUID, session_id: str, - kind: SessionCommandKind, - target_turn_id: Optional[str], ) -> Optional[SessionCommand]: - """The open (`pending` or `claimed`) command for this exact target, if one exists. - This is what collapses two Stops in a row onto one command.""" + """The continuation whose open/recoverable execution owns the next turn.""" + raise NotImplementedError + + async def reopen_continuation( + self, + *, + project_id: UUID, + command_id: UUID, + target_turn_id: str, + replacement_turn_id: str, + transaction: Optional[Any] = None, + ) -> Optional[SessionCommand]: + """Atomically retarget an exhausted continuation to a fresh execution attempt.""" + raise NotImplementedError @abstractmethod async def fetch_command( @@ -122,6 +160,7 @@ async def fetch_command( *, command_id: UUID, project_id: Optional[UUID] = None, + transaction: Optional[Any] = None, ) -> Optional[SessionCommand]: """One command by id. `project_id` is optional because the runner reports an outcome with the command id alone and holds no project credential.""" diff --git a/api/oss/src/core/sessions/commands/service.py b/api/oss/src/core/sessions/commands/service.py index 3382652cdbb..ce0854fbc51 100644 --- a/api/oss/src/core/sessions/commands/service.py +++ b/api/oss/src/core/sessions/commands/service.py @@ -33,7 +33,7 @@ from datetime import datetime, timedelta, timezone from typing import Any, List, Optional, Tuple -from uuid import UUID +from uuid import UUID, uuid4 from oss.src.core.sessions.commands.dtos import ( SessionCommand, @@ -46,16 +46,32 @@ from oss.src.core.sessions.commands.interfaces import ( CommandCreateResult, ControlDeliveryPort, + DeliveryReceipt, SessionCommandsDAOInterface, ) from oss.src.core.sessions.commands.types import ( ExecutionExpectationFailed, SessionCommandIdempotencyConflict, + IdempotencyKeyReused, + InteractionResponseConflict, SessionCommandNotClaimable, SessionCommandNotFound, ) +from oss.src.core.sessions.executions.dtos import SessionExecutionState from oss.src.core.sessions.executions.interfaces import SessionExecutionsDAOInterface +from oss.src.core.sessions.interactions.dtos import ( + SessionInteraction, + SessionInteractionStatus, + SessionInteractionTransition, +) from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.inputs.interfaces import SessionInputsDAOInterface +from oss.src.core.sessions.inputs.dtos import PendingInputAdmission, PendingInputState +from oss.src.core.sessions.inputs.types import ( + SessionInputBusy, + SessionInputNotFound, + SessionInputRemoved, +) from oss.src.core.sessions.streams.dtos import ( SessionStreamCommandRequest, SessionStreamCommandResponse, @@ -79,6 +95,10 @@ log = get_module_logger(__name__) +class _ContinuationReopenLost(Exception): + """The durable command changed while a fresh recovery attempt was being created.""" + + class CancelAdmission: """What admission decided, in the shape the route answers with.""" @@ -97,6 +117,44 @@ def __init__( self.accepted = accepted +class InteractionContinuationAdmission: + def __init__( + self, + *, + interaction: SessionInteraction, + command: Optional[SessionCommand], + execution_id: str, + execution_state: SessionExecutionState = SessionExecutionState.pending_delivery, + interactions: Optional[List[SessionInteraction]] = None, + waiting_for_interactions: bool = False, + ) -> None: + self.interaction = interaction + self.interactions = interactions or [interaction] + self.command = command + self.execution_id = execution_id + self.execution_state = execution_state + self.waiting_for_interactions = waiting_for_interactions + + +class InputContinuationAdmission: + def __init__( + self, + *, + command: SessionCommand, + execution_id: str, + execution_state: SessionExecutionState = SessionExecutionState.pending_delivery, + ) -> None: + self.command = command + self.execution_id = execution_id + self.execution_state = execution_state + + +class CommandOutcomeReport: + def __init__(self, *, command: SessionCommand, admitted: bool) -> None: + self.command = command + self.admitted = admitted + + class _SettlementRejected(Exception): pass @@ -111,6 +169,7 @@ def __init__( lock_engine: LockEngine, delivery: ControlDeliveryPort, executions_dao: Optional[SessionExecutionsDAOInterface] = None, + inputs_dao: Optional[SessionInputsDAOInterface] = None, ) -> None: self._dao = commands_dao self._streams = streams_service @@ -118,9 +177,167 @@ def __init__( self._lock = lock_engine self._delivery = delivery self._executions = executions_dao + self._inputs = inputs_dao # -- admission ---------------------------------------------------------- # + async def send_pending_input_now( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + input_id: UUID, + ) -> PendingInputAdmission: + if not validate_session_id(session_id): + raise SessionIdInvalid(session_id) + if not (env.agenta.sessions.queue and env.agenta.sessions.steer): + raise SessionInputBusy() + if self._inputs is None or self._executions is None: + raise SessionInputBusy() + received_at = datetime.now(timezone.utc) + target_id, _ = await self._resolve_target( + project_id=project_id, session_id=session_id, expected_turn_id=None + ) + if target_id is None: + stream = await self._streams.fetch_header( + project_id=project_id, session_id=session_id + ) + target_id = stream.turn_id if stream else None + if target_id is None: + raise SessionInputBusy() + + continuation = None + cancelled_interactions = 0 + async with self._dao.transaction() as transaction: + execution = await self._executions.lock_for_control( + project_id=project_id, + session_id=session_id, + execution_id=target_id, + transaction=transaction, + ) + if execution.terminal_outcome is not None: + successor = await self._executions.lock_active_continuation( + project_id=project_id, + session_id=session_id, + transaction=transaction, + ) + if successor is not None: + execution = successor + target_id = successor.execution_id + item = await self._inputs.prioritize_pending( + project_id=project_id, + session_id=session_id, + input_id=input_id, + user_id=user_id, + transaction=transaction, + ) + if item is None: + raise SessionInputNotFound(str(input_id)) + if item.state == PendingInputState.removed: + raise SessionInputRemoved(str(input_id)) + if item.state == PendingInputState.promoted: + return PendingInputAdmission( + action="pending", + input=item, + execution_id=item.promoted_execution_id, + ) + + if execution.terminal_outcome is not None: + cancelled_interactions = ( + await self._interactions.cancel_session_pending( + project_id=project_id, + session_id=session_id, + only_turn_id=target_id, + transaction=transaction, + publish=False, + ) + ) + continuation = await self._promote_next_input( + project_id=project_id, + session_id=session_id, + parent_execution_id=target_id, + input_id=input_id, + transaction=transaction, + ) + assert continuation is not None, "Locked pending input was not promoted" + command = continuation.command + item = item.model_copy( + update={ + "state": PendingInputState.promoted, + "promoted_execution_id": continuation.execution_id, + } + ) + else: + command = await self._dao.fetch_open_command( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.cancel, + target_turn_id=target_id, + transaction=transaction, + ) + if command is None: + await self._executions.set_state( + project_id=project_id, + session_id=session_id, + execution_id=target_id, + state=SessionExecutionState.stopping, + transaction=transaction, + ) + command = await self._dao.create_command( + user_id=user_id, + command=SessionCommandCreate( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.cancel, + target_turn_id=target_id, + expected_turn_id=target_id, + created_at=received_at, + idempotency_key=f"send-now:{input_id}", + data={"steer_input_id": str(input_id)}, + ), + stopping_turn_id=target_id, + transaction=transaction, + ) + command = await self._dao.bind_steer_input( + project_id=project_id, + command_id=command.id, + input_id=input_id, + transaction=transaction, + ) + if command is None: + raise SessionInputBusy(current_execution_id=target_id) + cancelled_interactions = ( + await self._interactions.cancel_session_pending( + project_id=project_id, + session_id=session_id, + only_turn_id=target_id, + transaction=transaction, + publish=False, + ) + ) + if cancelled_interactions: + await self._interactions.publish_session_pending_cancelled( + project_id=project_id, + session_id=session_id, + ) + if continuation is not None: + await self._reconcile_stopped_redis( + project_id=project_id, + session_id=session_id, + execution_id=target_id, + ) + receipt = await self._deliver(command) + if receipt is None or receipt.status != "accepted": + await self._mark_continuation_recoverable(continuation, receipt) + elif command.state == SessionCommandState.pending: + await self._deliver(command) + return PendingInputAdmission( + action="pending", + input=item, + execution_id=continuation.execution_id if continuation else target_id, + ) + async def request_cancel_legacy( self, *, @@ -153,6 +370,7 @@ async def request_cancel( session_id: str, expected_execution_id: Optional[str] = None, idempotency_key: Optional[str] = None, + steer_input_id: Optional[UUID] = None, ) -> CancelAdmission: if not validate_session_id(session_id): raise SessionIdInvalid(session_id) @@ -209,6 +427,11 @@ async def request_cancel( target_turn_id=None, expected_turn_id=expected_execution_id, idempotency_key=idempotency_key, + data=( + {"steer_input_id": str(steer_input_id)} + if steer_input_id is not None + else None + ), state=SessionCommandState.obsolete, outcome=SessionCommandOutcome.not_running, ) @@ -233,6 +456,11 @@ async def request_cancel( target_turn_id=None, expected_turn_id=None, idempotency_key=idempotency_key, + data=( + {"steer_input_id": str(steer_input_id)} + if steer_input_id is not None + else None + ), state=SessionCommandState.obsolete, outcome=SessionCommandOutcome.superseded_by_newer_turn, ) @@ -241,45 +469,693 @@ async def request_cancel( command = created.command return CancelAdmission(command=command, execution_id=None, accepted=False) - # Two Stops in a row are one intent. Collapse onto the open command for the same target - # BEFORE inserting, so this holds even when the caller sends a different idempotency key. - open_command = await self._dao.fetch_open_command( + if self._executions is None or not hasattr( + self._executions, "lock_for_control" + ): + open_command = await self._dao.fetch_open_command( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.cancel, + target_turn_id=target_turn_id, + ) + if open_command is not None: + if steer_input_id is not None: + command = await self._dao.bind_steer_input( + project_id=project_id, + command_id=open_command.id, + input_id=steer_input_id, + ) + if command is None: + raise SessionCommandNotClaimable( + command_id=str(open_command.id), + state="closed or already bound", + ) + else: + command = open_command + else: + created = await self._insert( + project_id=project_id, + user_id=user_id, + session_id=session_id, + received_at=received_at, + target_turn_id=target_turn_id, + expected_turn_id=expected_execution_id, + idempotency_key=idempotency_key, + data=( + {"steer_input_id": str(steer_input_id)} + if steer_input_id is not None + else None + ), + state=SessionCommandState.pending, + outcome=None, + stopping_turn_id=target_turn_id, + ) + if not created.inserted: + return self._admission_for_existing(created.command) + command = created.command + else: + cancelled_interactions = 0 + async with self._dao.transaction() as transaction: + execution = await self._executions.lock_for_control( + project_id=project_id, + session_id=session_id, + execution_id=target_turn_id, + transaction=transaction, + ) + if execution.terminal_outcome is not None: + raise ExecutionExpectationFailed( + expected=expected_execution_id or target_turn_id, + current=None, + ) + open_command = await self._dao.fetch_open_command( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.cancel, + target_turn_id=target_turn_id, + transaction=transaction, + ) + if open_command is not None: + if steer_input_id is not None: + command = await self._dao.bind_steer_input( + project_id=project_id, + command_id=open_command.id, + input_id=steer_input_id, + transaction=transaction, + ) + if command is None: + raise SessionCommandNotClaimable( + command_id=str(open_command.id), + state="closed or already bound", + ) + else: + command = open_command + else: + await self._executions.set_state( + project_id=project_id, + session_id=session_id, + execution_id=target_turn_id, + state=SessionExecutionState.stopping, + transaction=transaction, + ) + created = await self._insert( + project_id=project_id, + user_id=user_id, + session_id=session_id, + received_at=received_at, + target_turn_id=target_turn_id, + expected_turn_id=expected_execution_id, + idempotency_key=idempotency_key, + data=( + {"steer_input_id": str(steer_input_id)} + if steer_input_id is not None + else None + ), + state=SessionCommandState.pending, + outcome=None, + stopping_turn_id=target_turn_id, + transaction=transaction, + ) + command = created.command + if steer_input_id is not None: + command = await self._dao.bind_steer_input( + project_id=project_id, + command_id=command.id, + input_id=steer_input_id, + transaction=transaction, + ) + if command is None: + raise SessionCommandNotClaimable( + command_id=str(created.command.id), + state="closed or already bound", + ) + cancelled_interactions = ( + await self._interactions.cancel_session_pending( + project_id=project_id, + session_id=session_id, + only_turn_id=target_turn_id, + transaction=transaction, + publish=False, + ) + ) + if cancelled_interactions: + await self._interactions.publish_session_pending_cancelled( + project_id=project_id, session_id=session_id + ) + # The row is committed. Everything from here is promptness, not correctness. + if command.state == SessionCommandState.pending: + await self._deliver(command) + return CancelAdmission( + command=command, execution_id=target_turn_id, accepted=True + ) + + async def respond_interaction( + self, + *, + project_id: UUID, + user_id: UUID, + interaction_id: UUID, + answer: dict[str, Any], + expected_execution_id: Optional[str], + idempotency_key: str, + ) -> InteractionContinuationAdmission: + return await self.respond_interactions( project_id=project_id, - session_id=session_id, - kind=SessionCommandKind.cancel, - target_turn_id=target_turn_id, + user_id=user_id, + interaction_answers=[(interaction_id, answer)], + expected_execution_id=expected_execution_id, + idempotency_key=idempotency_key, ) - if open_command is not None: - if open_command.state == SessionCommandState.pending: - # Nobody has taken it. The first delivery may have failed, so try again; the - # runner deduplicates by command id, so a duplicate arrival aborts nothing twice. - await self._deliver(open_command) - return CancelAdmission( - command=open_command, - execution_id=target_turn_id, - accepted=True, + + async def respond_interactions( + self, + *, + project_id: UUID, + user_id: UUID, + interaction_answers: List[Tuple[UUID, dict[str, Any]]], + expected_execution_id: Optional[str], + idempotency_key: str, + ) -> InteractionContinuationAdmission: + if self._executions is None: + raise RuntimeError( + "durable interaction responses require executions storage" + ) + requested = dict(interaction_answers) + if not requested or len(requested) != len(interaction_answers): + raise InteractionResponseConflict( + code="validation_error", + message="Each response must answer at least one distinct interaction.", ) - created = await self._insert( + anchor_id = interaction_answers[0][0] + anchor = await self._interactions.fetch_interaction( + project_id=project_id, + interaction_id=anchor_id, + ) + source_execution_id = anchor.turn_id + if source_execution_id is None: + raise InteractionResponseConflict( + code="validation_error", + message="The interaction is not linked to an execution.", + ) + if ( + expected_execution_id is not None + and expected_execution_id != source_execution_id + ): + raise InteractionResponseConflict( + code="execution_mismatch", + message="The interaction belongs to a different execution.", + details={"current_execution_id": source_execution_id}, + ) + + async with self._dao.transaction() as transaction: + source = await self._executions.lock_for_control( + project_id=project_id, + session_id=anchor.session_id, + execution_id=source_execution_id, + transaction=transaction, + ) + turn_interactions = await self._interactions.fetch_turn_interactions( + project_id=project_id, + session_id=anchor.session_id, + turn_id=source_execution_id, + transaction=transaction, + for_update=True, + ) + by_id = {interaction.id: interaction for interaction in turn_interactions} + if not requested.keys() <= by_id.keys(): + raise InteractionResponseConflict( + code="execution_mismatch", + message="Every interaction must belong to the same execution.", + details={"current_execution_id": source_execution_id}, + ) + existing = await self._dao.fetch_by_idempotency_key( + project_id=project_id, + session_id=anchor.session_id, + idempotency_key=idempotency_key, + transaction=transaction, + ) + if existing is not None: + existing_ids = (existing.data or {}).get("interaction_ids") + if not isinstance(existing_ids, list): + existing_id = (existing.data or {}).get("interaction_id") + existing_ids = [existing_id] if isinstance(existing_id, str) else [] + same_request = ( + existing.kind == SessionCommandKind.continue_interaction + and existing.expected_turn_id == source_execution_id + and set(existing_ids) == {str(item) for item in requested} + and all( + by_id[item].data is not None + and by_id[item].data.resolution == answer + for item, answer in requested.items() + ) + ) + if not same_request: + raise IdempotencyKeyReused() + execution_id = existing.target_turn_id or str( + existing.data["continuation_execution_id"] + ) + admission = InteractionContinuationAdmission( + interaction=by_id[anchor_id], + command=existing, + execution_id=execution_id, + interactions=[by_id[item] for item in requested], + ) + else: + if source.terminal_outcome is not None or source.state in ( + SessionExecutionState.stopping, + SessionExecutionState.terminal, + ): + for interaction_id, answer in interaction_answers: + interaction = by_id[interaction_id] + if ( + interaction.status != SessionInteractionStatus.responded + or interaction.data is None + or interaction.data.resolution != answer + ): + raise InteractionResponseConflict( + code="execution_terminal", + message="The source execution can no longer be continued.", + details={"execution_state": source.state.value}, + ) + return InteractionContinuationAdmission( + interaction=by_id[anchor_id], + command=None, + execution_id=source_execution_id, + execution_state=source.state, + interactions=[by_id[item] for item in requested], + ) + + transitioned: List[SessionInteraction] = [] + for interaction_id, answer in interaction_answers: + interaction = by_id[interaction_id] + if interaction.status == SessionInteractionStatus.responded: + if ( + interaction.data is None + or interaction.data.resolution != answer + ): + raise InteractionResponseConflict( + code="execution_terminal", + message="The interaction was already answered differently.", + details={"interaction_status": "responded"}, + ) + transitioned.append(interaction) + continue + if interaction.status != SessionInteractionStatus.pending: + raise InteractionResponseConflict( + code="execution_terminal", + message="The interaction is no longer pending.", + details={ + "interaction_status": ( + interaction.status.value + if interaction.status is not None + else None + ) + }, + ) + updated = await self._interactions.transition_interaction( + transition=SessionInteractionTransition( + project_id=project_id, + session_id=interaction.session_id, + token=interaction.token, + status=SessionInteractionStatus.responded, + resolution=answer, + ), + transaction=transaction, + publish=False, + ) + by_id[interaction_id] = updated + transitioned.append(updated) + + if any( + interaction.status == SessionInteractionStatus.pending + for interaction in by_id.values() + ): + admission = InteractionContinuationAdmission( + interaction=by_id[anchor_id], + command=None, + execution_id=source_execution_id, + execution_state=source.state, + interactions=transitioned, + waiting_for_interactions=True, + ) + else: + answered = [ + interaction + for interaction in by_id.values() + if interaction.status == SessionInteractionStatus.responded + and interaction.data is not None + and interaction.data.resolution is not None + ] + result = await self._executions.settle( + project_id=project_id, + session_id=anchor.session_id, + execution_id=source_execution_id, + terminal_outcome="continued", + settled_by="interaction_response", + transaction=transaction, + ) + if not result.won: + raise InteractionResponseConflict( + code="execution_terminal", + message="The source execution can no longer be continued.", + details={ + "terminal_outcome": result.settlement.terminal_outcome + }, + ) + + execution_id = str(uuid4()) + await self._executions.create_continuation( + project_id=project_id, + session_id=anchor.session_id, + execution_id=execution_id, + parent_execution_id=source_execution_id, + source_interaction_id=anchor_id, + transaction=transaction, + ) + interaction_ids = [str(interaction.id) for interaction in answered] + command = await self._dao.create_command( + user_id=user_id, + command=SessionCommandCreate( + project_id=project_id, + session_id=anchor.session_id, + kind=SessionCommandKind.continue_interaction, + target_turn_id=execution_id, + expected_turn_id=source_execution_id, + data={ + "interaction_id": str(anchor_id), + "interaction_ids": interaction_ids, + "continuation_execution_id": execution_id, + }, + idempotency_key=idempotency_key, + ), + transaction=transaction, + ) + if ( + command.kind != SessionCommandKind.continue_interaction + or command.target_turn_id != execution_id + or command.data is None + or set(command.data.get("interaction_ids") or []) + != set(interaction_ids) + ): + raise IdempotencyKeyReused() + admission = InteractionContinuationAdmission( + interaction=by_id[anchor_id], + command=command, + execution_id=execution_id, + interactions=answered, + ) + + try: + await self._interactions.publish_interaction_responded( + project_id=project_id, + session_id=admission.interaction.session_id, + interactions=admission.interactions, + ) + except Exception as error: # noqa: BLE001 - the durable transaction already committed + log.warning( + "interaction response watch publish failed interaction=%s: %s", + anchor_id, + error, + ) + if ( + admission.command is not None + and admission.command.state == SessionCommandState.pending + ): + try: + receipt = await self._deliver(admission.command) + except Exception as error: # noqa: BLE001 - admission remains accepted + log.warning( + "continuation post-commit delivery failed command=%s: %s", + admission.command.id, + error, + ) + receipt = None + if receipt is None or receipt.status != "accepted": + if await self._mark_continuation_recoverable(admission, receipt): + admission.execution_state = SessionExecutionState.recoverable + return admission + + async def _mark_continuation_recoverable( + self, + admission: InteractionContinuationAdmission, + receipt: Optional[DeliveryReceipt] = None, + ) -> bool: + """Project a failed delivery onto the execution the card reads. + + Two guards, both learned from a browser pass where a delivered continuation was reported + `unreachable` anyway. + + `expected_states` is the important one. A transport that fails AFTER the runner reported + its outcome would otherwise demote a `running` execution back to `recoverable`, telling + the user to retry a turn that is running underneath the card. Only an execution still + waiting for a runner may be turned recoverable. + + The message is the other. It is what the card renders, so it must not promise a + redelivery that cannot happen: once the delivery budget is spent, nothing redelivers this + command on its own and only the user's next Send does (`resume_recoverable_continuation` + reopens the budget). + + False means the DAO REFUSED, which happens only when the execution has moved on, so the + caller must not report `recoverable`. A projection that raises returns True: the write is + best effort, but the transport failure that brought us here is real and the user still + owns the retry. + """ + if self._executions is None or admission.command is None: + return False + exhausted = receipt is not None and receipt.status == "exhausted" + try: + applied = await self._executions.set_state( + project_id=admission.command.project_id, + session_id=admission.command.session_id, + execution_id=admission.execution_id, + state=SessionExecutionState.recoverable, + error={ + "code": ( + "continuation_delivery_exhausted" + if exhausted + else "continuation_delivery_failed" + ), + "retryable": True, + "message": ( + "The continuation could not be delivered. Send your next message to " + "retry it." + if exhausted + else "The continuation is durable and awaiting redelivery." + ), + }, + expected_states=[ + SessionExecutionState.pending_delivery, + SessionExecutionState.recoverable, + ], + ) + except Exception as error: # noqa: BLE001 - recovery projection is best effort + log.error( + "continuation recoverable projection failed command=%s execution=%s: %s", + admission.command.id, + admission.execution_id, + error, + ) + return True + return applied is not None + + async def _execution_is_parked_on_a_gate( + self, *, project_id: UUID, session_id: str, execution_id: str + ) -> bool: + """Has this execution raised its own gate and stopped to wait on the user? + + Read from the interaction rows, not from the Redis `running` lock. The lock says the + right thing about a healthy runner and the wrong thing about a partitioned one: it is + absent both when a turn parks AND when the runner goes quiet mid-tool-call, and those + two must not be answered the same way (see + `test_stale_heartbeat_never_replays_an_admitted_continuation`). A pending row is a + durable fact that only the park writes, so the unknown case falls to "executing", + which is the safe side. + """ + rows = await self._interactions.fetch_turn_interactions( project_id=project_id, - user_id=user_id, session_id=session_id, - received_at=received_at, - target_turn_id=target_turn_id, - expected_turn_id=expected_execution_id, - idempotency_key=idempotency_key, - state=SessionCommandState.pending, - outcome=None, - stopping_turn_id=target_turn_id, + turn_id=execution_id, ) - if not created.inserted: - return self._admission_for_existing(created.command) - command = created.command - # The row is committed. Everything from here is promptness, not correctness. - await self._deliver(command) - return CancelAdmission( - command=command, execution_id=target_turn_id, accepted=True + return any(row.status == SessionInteractionStatus.pending for row in rows) + + async def resume_recoverable_continuation( + self, *, project_id: UUID, session_id: str + ) -> Optional[str]: + if not (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue): + return None + command = await self._dao.fetch_resumable_continuation( + project_id=project_id, + session_id=session_id, ) + if command is None: + return None + if ( + command.kind == SessionCommandKind.continue_interaction + and not env.agenta.sessions.durable_approvals + ) or ( + command.kind == SessionCommandKind.continue_input + and not env.agenta.sessions.queue + ): + return None + execution_id = command.target_turn_id + if execution_id is None or self._executions is None: + return execution_id + execution = await self._executions.fetch_execution( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + if execution is None: + return execution_id + if execution.state == SessionExecutionState.running: + # A stale heartbeat is not a fencing token: a partitioned runner can still be + # executing the approved side effect. Only the watchdog may turn `running` into + # `recoverable`, after it has collapsed and tombstoned the old ownership. Until + # then this durable continuation still owns Send, but it is never redelivered. + # + # `running` covers two live shapes, and only one of them owns Send: + # + # * EXECUTING — the continuation is inside a tool call. A Send here starts a + # second turn for the session, and the runner resolves that by superseding: + # it destroys the warm sandbox mid-call and the tool the user had just + # approved returns aborted. Both turns are lost. Refuse. + # * PARKED on its own approval — the continuation raised a new gate and stopped + # to wait on the user. Nothing is in flight to destroy, so a Send is a steer + # and stays allowed. Allow. + return ( + None + if await self._execution_is_parked_on_a_gate( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + else execution_id + ) + if ( + command.state + in ( + SessionCommandState.pending, + SessionCommandState.claimed, + ) + and command.claim_count >= env.agenta.sessions.commands.max_deliveries + ): + # The budget bounds the AUTOMATIC retry loop, not the user. A command that spent it + # is undeliverable until the sweep settles it exhausted, so a Send arriving inside + # that window would deliver nothing and re-render a card asking for another Send. + # Settle it here instead. Redelivering it as it stands is not an option: the budget + # is spent precisely because this execution id keeps being refused, so the ending has + # to be recorded before the reopen below can retarget a fresh one. + if await self._settle_exhausted_continuation(command): + refreshed = await self._dao.fetch_command(command_id=command.id) + if refreshed is not None: + command = refreshed + execution = ( + await self._executions.fetch_execution( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + or execution + ) + if command.state not in ( + SessionCommandState.pending, + SessionCommandState.claimed, + ): + command = await self._reopen_continuation_attempt( + project_id=project_id, + session_id=session_id, + command=command, + execution=execution, + ) + if command is None: + return execution_id + execution_id = command.target_turn_id or execution_id + if command.kind == SessionCommandKind.continue_input: + admission: Any = InputContinuationAdmission( + command=command, + execution_id=execution_id, + execution_state=SessionExecutionState.recoverable, + ) + else: + admission = InteractionContinuationAdmission( + interaction=await self._interaction_for_command(command), + command=command, + execution_id=execution_id, + execution_state=SessionExecutionState.recoverable, + ) + try: + receipt = await self._deliver(command) + except Exception as error: # noqa: BLE001 - keep ownership with the durable continuation + log.warning( + "continuation resume delivery failed command=%s: %s", command.id, error + ) + receipt = None + if receipt is None or receipt.status != "accepted": + await self._mark_continuation_recoverable(admission, receipt) + return execution_id + + async def _reopen_continuation_attempt( + self, + *, + project_id: UUID, + session_id: str, + command: SessionCommand, + execution: Any, + ) -> Optional[SessionCommand]: + """Fence a tombstoned attempt and retarget its command in one transaction.""" + if ( + self._executions is None + or execution.state != SessionExecutionState.recoverable + ): + return None + data = command.data or {} + root_execution_id = data.get("continuation_execution_id") + if not isinstance(root_execution_id, str) or not root_execution_id: + return None + replacement_execution_id = str(uuid4()) + try: + async with self._dao.transaction() as transaction: + stored = await self._executions.lock_for_control( + project_id=project_id, + session_id=session_id, + execution_id=execution.execution_id, + transaction=transaction, + ) + if ( + stored.state != SessionExecutionState.recoverable + or stored.terminal_outcome is not None + ): + return None + settled = await self._executions.settle( + project_id=project_id, + session_id=session_id, + execution_id=stored.execution_id, + terminal_outcome=SessionCommandOutcome.lost.value, + settled_by="watchdog", + transaction=transaction, + ) + if not settled.won: + return None + await self._executions.create_continuation( + project_id=project_id, + session_id=session_id, + execution_id=replacement_execution_id, + parent_execution_id=root_execution_id, + source_interaction_id=None, + transaction=transaction, + ) + reopened = await self._dao.reopen_continuation( + project_id=project_id, + command_id=command.id, + target_turn_id=stored.execution_id, + replacement_turn_id=replacement_execution_id, + transaction=transaction, + ) + if reopened is None: + raise _ContinuationReopenLost + return reopened + except _ContinuationReopenLost: + return None async def _resolve_target( self, @@ -323,10 +1199,31 @@ async def _insert( target_turn_id: Optional[str], expected_turn_id: Optional[str], idempotency_key: Optional[str], + data: Optional[dict[str, Any]], state: SessionCommandState, outcome: Optional[SessionCommandOutcome], stopping_turn_id: Optional[str] = None, + transaction: Optional[Any] = None, ) -> CommandCreateResult: + if transaction is not None: + command = await self._dao.create_command( + user_id=user_id, + command=SessionCommandCreate( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.cancel, + target_turn_id=target_turn_id, + expected_turn_id=expected_turn_id, + state=state, + outcome=outcome, + settled_at=received_at if outcome is not None else None, + idempotency_key=idempotency_key, + created_at=received_at, + ), + stopping_turn_id=stopping_turn_id, + transaction=transaction, + ) + return CommandCreateResult(command=command, inserted=True) return await self._dao.create_command_with_status( user_id=user_id, command=SessionCommandCreate( @@ -335,6 +1232,7 @@ async def _insert( kind=SessionCommandKind.cancel, target_turn_id=target_turn_id, expected_turn_id=expected_turn_id, + data=data, state=state, outcome=outcome, settled_at=received_at if outcome is not None else None, @@ -355,19 +1253,49 @@ def _admission_for_existing(command: SessionCommand) -> CancelAdmission: # -- delivery ----------------------------------------------------------- # - async def _deliver(self, command: SessionCommand) -> None: + async def _deliver(self, command: SessionCommand) -> Optional[DeliveryReceipt]: """Hand the command to the transport, then record what the transport learned. Never raises. The user's request has already succeeded by the time this runs. + + An `exhausted` receipt means the bounded delivery budget is spent. Nothing redelivers the + command after that, so the caller must say the true thing on the card rather than promise + a redelivery: only the user's next Send reopens the budget. """ - command = await self._dao.record_delivery_attempt( - project_id=command.project_id, - command_id=command.id, - now=datetime.now(timezone.utc), - max_deliveries=env.agenta.sessions.commands.max_deliveries, - ) + maximum = env.agenta.sessions.commands.max_deliveries + requested = command + try: + command = await self._dao.record_delivery_attempt( + project_id=requested.project_id, + command_id=requested.id, + now=datetime.now(timezone.utc), + max_deliveries=maximum, + ) + except Exception as error: # noqa: BLE001 - delivery bookkeeping is post-commit + log.warning("control delivery reservation failed: %s", error) + return None if command is None: - return + if requested.claim_count >= maximum: + log.warning( + "control delivery budget exhausted for command=%s session=%s after %s " + "attempts", + requested.id, + requested.session_id, + requested.claim_count, + ) + return DeliveryReceipt( + status="exhausted", + detail=f"delivery budget of {maximum} attempts is spent", + ) + return None + + try: + command = await self._command_for_delivery(command) + except Exception as error: # noqa: BLE001 - a later sweep or Send can retry + log.warning( + "control delivery hydration failed command=%s: %s", command.id, error + ) + return DeliveryReceipt(status="unreachable", detail=str(error)) try: receipt = await self._delivery.deliver(command=command) @@ -378,22 +1306,34 @@ async def _deliver(self, command: SessionCommand) -> None: command.session_id, e, ) - return + return DeliveryReceipt(status="unreachable", detail=str(e)) if receipt.status == "accepted": # Take the claim on the runner's behalf, so the outcome route's guard reads the same # way on every transport: only the holder of the claim writes the outcome. - await self._dao.claim_for_delivery( - project_id=command.project_id, - command_id=command.id, - replica_id=receipt.replica_id or "direct", - lease_seconds=env.agenta.sessions.commands.lease_seconds, - ) - return + try: + await self._dao.claim_for_delivery( + project_id=command.project_id, + command_id=command.id, + replica_id=receipt.replica_id or "direct", + lease_seconds=env.agenta.sessions.commands.lease_seconds, + ) + except Exception as error: # noqa: BLE001 - runner outcome still owns settlement + log.warning( + "control delivery claim projection failed command=%s: %s", + command.id, + error, + ) + return receipt if receipt.status == "not_held": + if command.kind in ( + SessionCommandKind.continue_interaction, + SessionCommandKind.continue_input, + ): + return receipt await self._settle_not_held(command) - return + return receipt log.warning( "control delivery unreachable for command=%s session=%s: %s", @@ -401,6 +1341,59 @@ async def _deliver(self, command: SessionCommand) -> None: command.session_id, receipt.detail or "no detail", ) + return receipt + + async def _interactions_for_command( + self, command: SessionCommand + ) -> List[SessionInteraction]: + interaction_ids = (command.data or {}).get("interaction_ids") + if not isinstance(interaction_ids, list): + interaction_id = (command.data or {}).get("interaction_id") + interaction_ids = ( + [interaction_id] if isinstance(interaction_id, str) else [] + ) + if not interaction_ids or not all( + isinstance(interaction_id, str) for interaction_id in interaction_ids + ): + raise ValueError("continuation command has no interaction ids") + return [ + await self._interactions.fetch_interaction( + project_id=command.project_id, + interaction_id=UUID(interaction_id), + ) + for interaction_id in interaction_ids + ] + + async def _interaction_for_command( + self, command: SessionCommand + ) -> SessionInteraction: + return (await self._interactions_for_command(command))[0] + + async def _command_for_delivery(self, command: SessionCommand) -> SessionCommand: + if command.kind != SessionCommandKind.continue_interaction: + return command + interactions = await self._interactions_for_command(command) + if any( + interaction.data is None or interaction.data.resolution is None + for interaction in interactions + ): + raise ValueError("continuation interaction has no durable resolution") + answers = [ + { + "interaction_id": str(interaction.id), + "answer": interaction.data.resolution, + } + for interaction in interactions + ] + return command.model_copy( + update={ + "data": { + **(command.data or {}), + "answers": answers, + **({"answer": answers[0]["answer"]} if len(answers) == 1 else {}), + } + } + ) async def _settle_not_held(self, command: SessionCommand) -> None: """A reachable runner said it does not hold this session. Two different things look @@ -483,6 +1476,24 @@ async def settle_abandoned_commands(self, *, now: datetime) -> int: ) settled = 0 for command in abandoned: + if command.kind in ( + SessionCommandKind.continue_interaction, + SessionCommandKind.continue_input, + ): + capability_enabled = ( + env.agenta.sessions.durable_approvals + if command.kind == SessionCommandKind.continue_interaction + else env.agenta.sessions.queue + ) + if not capability_enabled: + continue + if command.claim_count < max_deliveries: + await self._deliver(command) + continue + result = await self._settle_exhausted_continuation(command) + if result: + settled += 1 + continue beating = await self._session_is_beating( project_id=command.project_id, session_id=command.session_id, @@ -507,8 +1518,97 @@ async def settle_abandoned_commands(self, *, now: datetime) -> int: settled += 1 return settled + async def _settle_exhausted_continuation(self, command: SessionCommand) -> bool: + transition = SessionCommandSettle( + project_id=command.project_id, + command_id=command.id, + state=SessionCommandState.obsolete, + outcome=SessionCommandOutcome.lost, + expected_states=[SessionCommandState.pending, SessionCommandState.claimed], + ) + async with self._dao.transaction() as transaction: + settled = await self._dao.settle_command( + settle=transition, transaction=transaction + ) + if settled is None: + return False + if self._executions is not None and command.target_turn_id is not None: + await self._executions.set_state( + project_id=command.project_id, + session_id=command.session_id, + execution_id=command.target_turn_id, + state=SessionExecutionState.recoverable, + error={ + "code": "continuation_delivery_exhausted", + "message": "Continuation delivery exhausted its automatic retry budget.", + "retryable": True, + }, + transaction=transaction, + ) + return True + # -- settlement --------------------------------------------------------- # + async def _promote_next_input( + self, + *, + project_id: UUID, + session_id: str, + parent_execution_id: str, + transaction: Any, + input_id: Optional[UUID] = None, + only_policy: Optional[str] = None, + ) -> Optional[InputContinuationAdmission]: + """Promote one durable input and create its continuation in the same commit.""" + if self._inputs is None or self._executions is None: + return None + + execution_id = str(uuid4()) + pending_input = await self._inputs.promote_next( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + input_id=input_id, + only_policy=only_policy, + transaction=transaction, + ) + if pending_input is None: + return None + + await self._executions.create_continuation( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + parent_execution_id=parent_execution_id, + source_interaction_id=None, + transaction=transaction, + ) + request = dict(pending_input.content) + request_meta = dict(request.get("meta") or {}) + request_meta["promoted_input_id"] = str(pending_input.id) + request["meta"] = request_meta + command = await self._dao.create_command( + user_id=pending_input.created_by_id, + command=SessionCommandCreate( + project_id=project_id, + session_id=session_id, + kind=SessionCommandKind.continue_input, + target_turn_id=execution_id, + expected_turn_id=parent_execution_id, + data={ + "input_id": str(pending_input.id), + "continuation_execution_id": execution_id, + "request": request, + }, + idempotency_key=f"input:{pending_input.id}", + ), + transaction=transaction, + ) + return InputContinuationAdmission( + command=command, + execution_id=execution_id, + ) + async def settle_execution_lost( self, *, @@ -520,6 +1620,41 @@ async def settle_execution_lost( ) -> bool: if self._executions is None: return True + execution = await self._executions.fetch_execution( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + if ( + execution is not None + and (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue) + and ( + execution.source_interaction_id is not None + or execution.parent_execution_id is not None + ) + and execution.terminal_outcome is None + ): + recovered = await self._executions.set_state( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + state=SessionExecutionState.recoverable, + error={ + "code": "continuation_execution_lost", + "message": "The continuation runner disappeared before completion.", + "retryable": True, + }, + expected_states=[ + SessionExecutionState.pending_delivery, + SessionExecutionState.running, + ], + ) + if recovered is not None: + return False + # A recoverable continuation deliberately receives no watchdog terminal record. + # Its next delivery resumes the same logical execution. A concurrent terminal + # winner likewise already owns the ending, so neither race permits a lost record. + return False result = await self._executions.settle( project_id=project_id, session_id=session_id, @@ -535,6 +1670,49 @@ async def settle_execution_lost( and winner.settled_by == "watchdog" ) + async def settle_execution_completed( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + ) -> bool: + """Reconcile a persisted runner ending before stale ownership is collapsed.""" + if self._executions is None: + return True + admission: Optional[InputContinuationAdmission] = None + async with self._dao.transaction() as transaction: + result = await self._executions.settle( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + terminal_outcome="completed", + settled_by="runner", + transaction=transaction, + ) + if result.won and env.agenta.sessions.queue: + admission = await self._promote_next_input( + project_id=project_id, + session_id=session_id, + parent_execution_id=execution_id, + transaction=transaction, + ) + + if admission is not None: + # The terminal record can arrive before the runner's final `is_running=false` beat. + # Release and fence that completed generation first, or the promoted continuation's + # first heartbeat sees the old `running` owner and rejects immediate delivery. + await self._reconcile_stopped_redis( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + receipt = await self._deliver(admission.command) + if receipt is None or receipt.status != "accepted": + await self._mark_continuation_recoverable(admission, receipt) + admission.execution_state = SessionExecutionState.recoverable + return result.won or result.settlement.terminal_outcome is not None + async def repair_terminal_redis(self) -> int: if self._executions is None: return 0 @@ -578,13 +1756,26 @@ async def report_outcome( execution_id: Optional[str], execution_state: str, error: Optional[str] = None, - ) -> SessionCommand: + ) -> CommandOutcomeReport: """The runner reporting what happened to the execution. Both adapters land here, so settlement has one path on every transport.""" command = await self._dao.fetch_command(command_id=command_id) if command is None: raise SessionCommandNotFound(command_id=str(command_id)) + if command.kind in ( + SessionCommandKind.continue_interaction, + SessionCommandKind.continue_input, + ): + return await self._report_continuation_outcome( + command=command, + replica_id=replica_id, + result=result, + execution_id=execution_id, + execution_state=execution_state, + error=error, + ) + outcome = _OUTCOME_BY_EXECUTION_STATE.get(execution_state) if outcome is None: outcome = SessionCommandOutcome.failed @@ -625,7 +1816,135 @@ async def report_outcome( command_id=str(command_id), state=stored.state.value if stored else "unknown", ) - return settled + return CommandOutcomeReport(command=settled, admitted=True) + + async def _report_continuation_outcome( + self, + *, + command: SessionCommand, + replica_id: str, + result: str, + execution_id: Optional[str], + execution_state: str, + error: Optional[str], + ) -> CommandOutcomeReport: + target = execution_id or command.target_turn_id + if target is None or target != command.target_turn_id: + raise SessionCommandNotClaimable( + command_id=str(command.id), state="execution_mismatch" + ) + if ( + command.state == SessionCommandState.applied + and command.outcome == SessionCommandOutcome.started + ): + return await self._readmit_recoverable_continuation( + command=command, + replica_id=replica_id, + execution_id=target, + ) + started = result == "applied" and execution_state == "started" + settle = SessionCommandSettle( + project_id=command.project_id, + command_id=command.id, + state=( + SessionCommandState.applied if started else SessionCommandState.obsolete + ), + outcome=( + SessionCommandOutcome.started + if started + else SessionCommandOutcome.failed + ), + expected_states=[SessionCommandState.pending, SessionCommandState.claimed], + replica_id=replica_id, + ) + execution_blocked = False + async with self._dao.transaction() as transaction: + execution = await self._executions.lock_for_control( + project_id=command.project_id, + session_id=command.session_id, + execution_id=target, + transaction=transaction, + ) + execution_blocked = ( + execution.terminal_outcome is not None + or execution.state + not in ( + SessionExecutionState.pending_delivery, + SessionExecutionState.recoverable, + ) + ) + stored = None + if not execution_blocked: + stored = await self._dao.settle_command( + settle=settle, transaction=transaction + ) + if stored is not None: + transitioned = await self._executions.set_state( + project_id=command.project_id, + session_id=command.session_id, + execution_id=target, + state=( + SessionExecutionState.running + if started + else SessionExecutionState.recoverable + ), + error=( + None + if started + else { + "code": "continuation_start_failed", + "message": error or "The runner rejected the continuation.", + "retryable": True, + } + ), + expected_states=[execution.state], + transaction=transaction, + ) + if transitioned is None: + raise RuntimeError( + "continuation execution changed while its control lock was held" + ) + if execution_blocked: + return CommandOutcomeReport(command=command, admitted=False) + if stored is None: + latest = await self._dao.fetch_command(command_id=command.id) + if ( + latest is not None + and latest.state == SessionCommandState.applied + and latest.outcome == SessionCommandOutcome.started + ): + return await self._readmit_recoverable_continuation( + command=latest, + replica_id=replica_id, + execution_id=target, + ) + raise SessionCommandNotClaimable( + command_id=str(command.id), + state=latest.state.value if latest else command.state.value, + ) + return CommandOutcomeReport(command=stored, admitted=True) + + async def _readmit_recoverable_continuation( + self, + *, + command: SessionCommand, + replica_id: str, + execution_id: str, + ) -> CommandOutcomeReport: + if command.claimed_by != replica_id or self._executions is None: + return CommandOutcomeReport(command=command, admitted=False) + transitioned = await self._executions.set_state( + project_id=command.project_id, + session_id=command.session_id, + execution_id=execution_id, + state=SessionExecutionState.running, + error=None, + expected_states=[SessionExecutionState.recoverable], + ) + return CommandOutcomeReport( + command=command, + admitted=transitioned is not None, + ) async def settle( self, @@ -653,12 +1972,14 @@ async def settle( ) atomic_core_settlement = self._executions is not None cancelled_interactions = 0 + input_admission: Optional[InputContinuationAdmission] = None if atomic_core_settlement: stored_command = await self._dao.fetch_command(command_id=command_id) if stored_command is None: return None terminal = outcome in ( SessionCommandOutcome.stopped, + SessionCommandOutcome.not_running, SessionCommandOutcome.lost, ) settled_by = ( @@ -670,13 +1991,7 @@ async def settle( ) try: async with self._dao.transaction() as transaction: - settled = await self._dao.settle_command( - settle=transition, - transaction=transaction, - ) - if settled is None: - raise _SettlementRejected - + result = None if execution_id and terminal and settled_by: result = await self._executions.settle( project_id=project_id, @@ -686,12 +2001,48 @@ async def settle( settled_by=settled_by, transaction=transaction, ) + stored_command = await self._dao.fetch_command( + command_id=command_id, + project_id=project_id, + transaction=transaction, + ) + if stored_command is None: + raise _SettlementRejected + settled = await self._dao.settle_command( + settle=transition, + transaction=transaction, + ) + if settled is None: + raise _SettlementRejected + + if execution_id and terminal and settled_by: + assert result is not None winner = result.settlement if not result.won and ( winner.terminal_outcome != outcome.value or winner.settled_by != settled_by ): raise _SettlementRejected + steer_input_id = (settled.data or {}).get("steer_input_id") + if ( + result.won + and outcome + in ( + SessionCommandOutcome.stopped, + SessionCommandOutcome.not_running, + ) + and env.agenta.sessions.queue + and env.agenta.sessions.steer + and isinstance(steer_input_id, str) + ): + input_admission = await self._promote_next_input( + project_id=project_id, + session_id=stored_command.session_id, + parent_execution_id=execution_id, + input_id=UUID(steer_input_id), + only_policy="steer", + transaction=transaction, + ) await self._streams.settle_command( project_id=project_id, @@ -780,6 +2131,10 @@ async def settle( project_id=project_id, session_id=session_id, ) + if input_admission is not None: + receipt = await self._deliver(input_admission.command) + if receipt is None or receipt.status != "accepted": + await self._mark_continuation_recoverable(input_admission, receipt) return settled @@ -793,5 +2148,6 @@ async def settle( __all__ = [ "CancelAdmission", + "InteractionContinuationAdmission", "SessionCommandsService", ] diff --git a/api/oss/src/core/sessions/commands/types.py b/api/oss/src/core/sessions/commands/types.py index 47092a44c01..2923c658bfb 100644 --- a/api/oss/src/core/sessions/commands/types.py +++ b/api/oss/src/core/sessions/commands/types.py @@ -49,3 +49,21 @@ def __init__(self, *, command_id: str, state: str) -> None: self.state = state self.message = f"session command '{command_id}' is '{state}' and cannot be settled by this caller" super().__init__(self.message) + + +class InteractionResponseConflict(SessionCommandError): + def __init__( + self, *, code: str, message: str, details: Optional[dict] = None + ) -> None: + self.code = code + self.message = message + self.details = details or {} + super().__init__(message) + + +class IdempotencyKeyReused(InteractionResponseConflict): + def __init__(self) -> None: + super().__init__( + code="idempotency_key_reused", + message="This idempotency key was already used for a different response.", + ) diff --git a/api/oss/src/core/sessions/executions/dtos.py b/api/oss/src/core/sessions/executions/dtos.py index 84c3887161e..a266eb8f699 100644 --- a/api/oss/src/core/sessions/executions/dtos.py +++ b/api/oss/src/core/sessions/executions/dtos.py @@ -1,17 +1,31 @@ from datetime import datetime -from typing import Optional +from enum import Enum +from typing import Any, Dict, Optional from uuid import UUID from pydantic import BaseModel +class SessionExecutionState(str, Enum): + active = "active" + stopping = "stopping" + pending_delivery = "pending_delivery" + recoverable = "recoverable" + running = "running" + terminal = "terminal" + + class SessionExecutionSettlement(BaseModel): project_id: UUID session_id: str execution_id: str - terminal_outcome: str - settled_by: str - settled_at: datetime + state: SessionExecutionState = SessionExecutionState.terminal + parent_execution_id: Optional[str] = None + source_interaction_id: Optional[UUID] = None + error: Optional[Dict[str, Any]] = None + terminal_outcome: Optional[str] = None + settled_by: Optional[str] = None + settled_at: Optional[datetime] = None ending_written_at: Optional[datetime] = None redis_reconciled_at: Optional[datetime] = None diff --git a/api/oss/src/core/sessions/executions/interfaces.py b/api/oss/src/core/sessions/executions/interfaces.py index 92e87e927c2..73f8ddb381e 100644 --- a/api/oss/src/core/sessions/executions/interfaces.py +++ b/api/oss/src/core/sessions/executions/interfaces.py @@ -4,12 +4,70 @@ from uuid import UUID from oss.src.core.sessions.executions.dtos import ( + SessionExecutionState, SessionExecutionSettlement, SessionExecutionSettlementResult, ) class SessionExecutionsDAOInterface(ABC): + async def fetch_execution( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + transaction: Optional[Any] = None, + ) -> Optional[SessionExecutionSettlement]: + """Fetch one execution without creating or locking it.""" + raise NotImplementedError + + async def lock_for_control( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + transaction: Any, + ) -> SessionExecutionSettlement: + """Ensure and row-lock the source execution for Stop/answer arbitration.""" + raise NotImplementedError + + async def lock_active_continuation( + self, + *, + project_id: UUID, + session_id: str, + transaction: Any, + ) -> Optional[SessionExecutionSettlement]: + """Lock the unsettled continuation that still owns this session.""" + raise NotImplementedError + + async def create_continuation( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + parent_execution_id: str, + source_interaction_id: Optional[UUID], + transaction: Any, + ) -> SessionExecutionSettlement: + raise NotImplementedError + + async def set_state( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + state: SessionExecutionState, + error: Optional[dict] = None, + expected_states: Optional[Sequence[SessionExecutionState]] = None, + transaction: Optional[Any] = None, + ) -> Optional[SessionExecutionSettlement]: + raise NotImplementedError + @abstractmethod async def settle( self, diff --git a/api/oss/src/core/sessions/inputs/__init__.py b/api/oss/src/core/sessions/inputs/__init__.py new file mode 100644 index 00000000000..f12ad9fd8b2 --- /dev/null +++ b/api/oss/src/core/sessions/inputs/__init__.py @@ -0,0 +1,15 @@ +from oss.src.core.sessions.inputs.dtos import ( + PendingInput, + PendingInputAdmission, + PendingInputCreate, + PendingInputState, +) +from oss.src.core.sessions.inputs.service import SessionInputsService + +__all__ = [ + "PendingInput", + "PendingInputAdmission", + "PendingInputCreate", + "PendingInputState", + "SessionInputsService", +] diff --git a/api/oss/src/core/sessions/inputs/dtos.py b/api/oss/src/core/sessions/inputs/dtos.py new file mode 100644 index 00000000000..f86d2507d6c --- /dev/null +++ b/api/oss/src/core/sessions/inputs/dtos.py @@ -0,0 +1,61 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Literal, Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from oss.src.core.shared.dtos import Identifier, Lifecycle + + +class PendingInputState(str, Enum): + pending = "pending" + promoted = "promoted" + removed = "removed" + + +class PendingInput(Identifier, Lifecycle): + project_id: UUID + session_id: str + content: Dict[str, Any] + position: int + state: PendingInputState + policy: Literal["queue", "steer"] + idempotency_key: str + request_fingerprint: str + promoted_execution_id: Optional[str] = None + + +class PendingInputCreate(BaseModel): + project_id: UUID + session_id: str + content: Dict[str, Any] + policy: Literal["queue", "steer"] + idempotency_key: str + request_fingerprint: str + + +class PendingInputAdmission(BaseModel): + action: Literal["execute", "pending"] + input: Optional[PendingInput] = None + execution_id: Optional[str] = None + + +class PendingInputPromotion(BaseModel): + input: PendingInput + execution_id: str + created_at: datetime + + +class PendingInputAttachment(BaseModel): + model_config = ConfigDict(extra="forbid") + uri: str = Field(min_length=1) + mime_type: str = Field(min_length=1) + filename: Optional[str] = None + attachment_id: Optional[str] = Field(default=None, min_length=1) + + +class PendingInputUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + text: str + attachments: List[PendingInputAttachment] = Field(default_factory=list) diff --git a/api/oss/src/core/sessions/inputs/interfaces.py b/api/oss/src/core/sessions/inputs/interfaces.py new file mode 100644 index 00000000000..575bac48ac7 --- /dev/null +++ b/api/oss/src/core/sessions/inputs/interfaces.py @@ -0,0 +1,114 @@ +from abc import ABC, abstractmethod +from typing import Any, AsyncContextManager, Dict, List, Optional +from uuid import UUID + +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputCreate + + +class SessionInputsDAOInterface(ABC): + @abstractmethod + def transaction(self) -> AsyncContextManager[Any]: + pass + + @abstractmethod + async def create_input( + self, + *, + user_id: Optional[UUID], + pending_input: PendingInputCreate, + prioritize: bool = False, + transaction: Optional[Any] = None, + ) -> PendingInput: + pass + + @abstractmethod + async def fetch_by_idempotency_key( + self, + *, + project_id: UUID, + session_id: str, + idempotency_key: str, + transaction: Optional[Any] = None, + ) -> Optional[PendingInput]: + pass + + @abstractmethod + async def list_pending( + self, + *, + project_id: UUID, + session_id: str, + transaction: Optional[Any] = None, + ) -> List[PendingInput]: + pass + + @abstractmethod + async def fetch_active_successor( + self, + *, + project_id: UUID, + session_id: str, + transaction: Any, + ) -> Optional[PendingInput]: + pass + + @abstractmethod + async def fetch_input( + self, *, project_id: UUID, session_id: str, input_id: UUID + ) -> Optional[PendingInput]: + pass + + @abstractmethod + async def remove_pending( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + user_id: Optional[UUID], + ) -> Optional[PendingInput]: + pass + + @abstractmethod + async def prioritize_pending( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + user_id: Optional[UUID], + transaction: Any, + ) -> Optional[PendingInput]: + pass + + @abstractmethod + async def promote_next( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + input_id: Optional[UUID] = None, + only_policy: Optional[str] = None, + transaction: Optional[Any] = None, + ) -> Optional[PendingInput]: + pass + + @abstractmethod + async def lock_pending_for_edit( + self, *, project_id: UUID, session_id: str, input_id: UUID, transaction: Any + ) -> Optional[PendingInput]: + pass + + @abstractmethod + async def update_content( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + content: Dict[str, Any], + user_id: Optional[UUID], + transaction: Any, + ) -> PendingInput: + pass diff --git a/api/oss/src/core/sessions/inputs/service.py b/api/oss/src/core/sessions/inputs/service.py new file mode 100644 index 00000000000..93ff2a12a99 --- /dev/null +++ b/api/oss/src/core/sessions/inputs/service.py @@ -0,0 +1,423 @@ +from copy import deepcopy + +import hashlib +import json +from typing import Any, Awaitable, Callable, Dict, List, Optional +from uuid import UUID + +from oss.src.core.sessions.inputs.dtos import ( + PendingInput, + PendingInputAdmission, + PendingInputCreate, + PendingInputState, + PendingInputUpdate, +) +from oss.src.core.sessions.inputs.interfaces import SessionInputsDAOInterface +from oss.src.core.sessions.inputs.types import ( + SessionInputBusy, + SessionInputIdempotencyConflict, + SessionInputNotFound, + SessionInputNotRemovable, + SessionInputContentInvalid, +) +from oss.src.core.sessions.interactions.dtos import SessionInteractionStatus +from oss.src.core.sessions.interactions.interfaces import ( + SessionInteractionsDAOInterface, +) +from oss.src.core.sessions.executions.interfaces import SessionExecutionsDAOInterface +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.utils.env import env + + +def input_fingerprint(*, content: Dict[str, Any], policy: str) -> str: + canonical = json.dumps( + {"content": content, "on_busy": policy}, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + return hashlib.sha256(canonical).hexdigest() + + +def edit_pending_input_content( + content: Dict[str, Any], update: PendingInputUpdate +) -> Dict[str, Any]: + edited = deepcopy(content) + data = edited.get("data") + inputs = data.get("inputs") if isinstance(data, dict) else None + messages = inputs.get("messages") if isinstance(inputs, dict) else None + if not isinstance(messages, list): + raise SessionInputContentInvalid( + "The queued input has no editable user message." + ) + message = next( + ( + item + for item in reversed(messages) + if isinstance(item, dict) and item.get("role") == "user" + ), + None, + ) + if message is None: + raise SessionInputContentInvalid( + "The queued input has no editable user message." + ) + original = message.get("content") + field = "content" + if isinstance(original, str): + if not update.attachments: + message[field] = update.text + return edited + blocks = [{"type": "text", "text": original}] + elif isinstance(original, list): + blocks = original + elif isinstance(message.get("parts"), list): + field = "parts" + blocks = message[field] + else: + raise SessionInputContentInvalid( + "The queued user message uses an unsupported content format." + ) + kept = [] + wrote_text = False + for block in blocks: + if isinstance(block, dict) and block.get("type") == "text": + if not wrote_text: + kept.append({**block, "text": update.text}) + wrote_text = True + else: + kept.append(block) + if not wrote_text and update.text: + kept.insert(0, {"type": "text", "text": update.text}) + uris = { + block.get("uri", block.get("url")) + for block in kept + if isinstance(block, dict) + and isinstance(block.get("uri", block.get("url")), str) + } + attachment_ids = set() + for block in kept: + if not isinstance(block, dict): + continue + attachment_id = block.get("attachmentId", block.get("attachment_id")) + provider_metadata = block.get("providerMetadata") + agenta_metadata = ( + provider_metadata.get("agenta") + if isinstance(provider_metadata, dict) + else None + ) + if not attachment_id and isinstance(agenta_metadata, dict): + attachment_id = agenta_metadata.get("attachmentId") + if isinstance(attachment_id, str) and attachment_id: + attachment_ids.add(attachment_id) + for attachment in update.attachments: + if attachment.uri in uris or ( + attachment.attachment_id and attachment.attachment_id in attachment_ids + ): + continue + if field == "parts": + block = { + "type": "file", + "url": attachment.uri, + "mediaType": attachment.mime_type, + } + if attachment.attachment_id is not None: + block["providerMetadata"] = { + "agenta": {"attachmentId": attachment.attachment_id} + } + if attachment.filename is not None: + block["filename"] = attachment.filename + elif attachment.attachment_id is not None: + block = { + "type": "attachment", + "attachmentId": attachment.attachment_id, + "mimeType": attachment.mime_type, + } + if attachment.filename is not None: + block["filename"] = attachment.filename + else: + block = { + "type": "image" + if attachment.mime_type.startswith("image/") + else "resource", + "uri": attachment.uri, + "mimeType": attachment.mime_type, + } + if attachment.filename is not None: + block["filename"] = attachment.filename + kept.append(block) + uris.add(attachment.uri) + if attachment.attachment_id: + attachment_ids.add(attachment.attachment_id) + message[field] = kept + return edited + + +class SessionInputsService: + def __init__( + self, + *, + inputs_dao: SessionInputsDAOInterface, + streams_service: SessionStreamsService, + executions_dao: Optional[SessionExecutionsDAOInterface] = None, + interactions_dao: Optional[SessionInteractionsDAOInterface] = None, + continuation_resumer: Optional[Callable[..., Awaitable[Optional[str]]]] = None, + ) -> None: + self._dao = inputs_dao + self._streams = streams_service + self._executions = executions_dao + self._interactions = interactions_dao + self._continuation_resumer = continuation_resumer + + async def admit( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + content: Dict[str, Any], + policy: str, + idempotency_key: Optional[str], + ) -> PendingInputAdmission: + fingerprint = input_fingerprint(content=content, policy=policy) + if idempotency_key: + existing = await self._dao.fetch_by_idempotency_key( + project_id=project_id, + session_id=session_id, + idempotency_key=idempotency_key, + ) + if existing is not None: + if existing.request_fingerprint != fingerprint: + raise SessionInputIdempotencyConflict() + return PendingInputAdmission( + action="pending", + input=existing, + execution_id=existing.promoted_execution_id, + ) + + stream = await self._streams.fetch_header( + project_id=project_id, session_id=session_id + ) + busy = bool(stream and stream.flags and stream.flags.is_running) + # A parked approval has no running heartbeat but still owns Queue. + queued_behind_interaction = bool( + policy == "queue" + and env.agenta.sessions.queue + and stream + and stream.turn_id + and await self._has_pending_interaction( + project_id=project_id, + session_id=session_id, + execution_id=stream.turn_id, + ) + ) + busy = busy or queued_behind_interaction + resumed_execution_id: Optional[str] = None + if ( + not busy + and (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue) + and self._continuation_resumer is not None + ): + resumed_execution_id = await self._continuation_resumer( + project_id=project_id, + session_id=session_id, + ) + busy = resumed_execution_id is not None + if not busy: + return PendingInputAdmission(action="execute") + + current_execution_id = resumed_execution_id or ( + stream.turn_id if stream else None + ) + queue_enabled = env.agenta.sessions.queue + steer_enabled = queue_enabled and env.agenta.sessions.steer + if policy == "steer" and not steer_enabled: + raise SessionInputBusy(current_execution_id=current_execution_id) + if policy not in ("queue", "steer") or not queue_enabled: + raise SessionInputBusy(current_execution_id=current_execution_id) + if not idempotency_key: + raise ValueError("Idempotency-Key is required when queueing input.") + + retry_after_interaction = False + async with self._dao.transaction() as transaction: + source_execution = None + successor_execution_id = None + if self._executions is not None and current_execution_id is not None: + source_execution = await self._executions.lock_for_control( + project_id=project_id, + session_id=session_id, + execution_id=current_execution_id, + transaction=transaction, + ) + existing = await self._dao.fetch_by_idempotency_key( + project_id=project_id, + session_id=session_id, + idempotency_key=idempotency_key, + transaction=transaction, + ) + if existing is not None: + if existing.request_fingerprint != fingerprint: + raise SessionInputIdempotencyConflict() + return PendingInputAdmission( + action="pending", + input=existing, + execution_id=current_execution_id, + ) + if ( + source_execution is not None + and source_execution.terminal_outcome is not None + and not ( + queued_behind_interaction + and await self._has_pending_interaction( + project_id=project_id, + session_id=session_id, + execution_id=current_execution_id, + transaction=transaction, + ) + ) + ): + if queued_behind_interaction: + # An approval can win while Queue waits for the execution lock. + # Re-enter admission outside this transaction so its continuation, + # rather than a fresh run, owns the queued message. + retry_after_interaction = True + else: + successor = await self._dao.fetch_active_successor( + project_id=project_id, + session_id=session_id, + transaction=transaction, + ) + successor_execution_id = ( + successor.promoted_execution_id + if successor is not None + else None + ) + if successor_execution_id is None and self._executions is not None: + # Redis can announce a resumed turn before the durable header moves off + # its terminal parent. Approval children have no promoted input row. + continuation = await self._executions.lock_active_continuation( + project_id=project_id, + session_id=session_id, + transaction=transaction, + ) + successor_execution_id = ( + continuation.execution_id + if continuation is not None + else None + ) + if successor_execution_id is None: + return PendingInputAdmission(action="execute") + if not retry_after_interaction: + item = await self._dao.create_input( + user_id=user_id, + pending_input=PendingInputCreate( + project_id=project_id, + session_id=session_id, + content=content, + policy=policy, + idempotency_key=idempotency_key, + request_fingerprint=fingerprint, + ), + prioritize=policy == "steer", + transaction=transaction, + ) + # `create_input` rechecks under the session transaction lock, so a concurrent + # admission can return the row that won after our optimistic read above. + if item.request_fingerprint != fingerprint: + raise SessionInputIdempotencyConflict() + if retry_after_interaction: + return await self.admit( + project_id=project_id, + user_id=user_id, + session_id=session_id, + content=content, + policy=policy, + idempotency_key=idempotency_key, + ) + return PendingInputAdmission( + action="pending", + input=item, + execution_id=successor_execution_id or current_execution_id, + ) + + async def _has_pending_interaction( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + transaction: Optional[Any] = None, + ) -> bool: + if self._interactions is None: + return False + interactions = await self._interactions.fetch_turn_interactions( + project_id=project_id, + session_id=session_id, + turn_id=execution_id, + transaction=transaction, + for_update=transaction is not None, + ) + return any( + interaction.status == SessionInteractionStatus.pending + for interaction in interactions + ) + + async def list_pending( + self, *, project_id: UUID, session_id: str + ) -> List[PendingInput]: + if not env.agenta.sessions.queue: + return [] + return await self._dao.list_pending( + project_id=project_id, session_id=session_id + ) + + async def remove( + self, + *, + project_id: UUID, + user_id: Optional[UUID], + session_id: str, + input_id: UUID, + ) -> PendingInput: + item = await self._dao.remove_pending( + project_id=project_id, + session_id=session_id, + input_id=input_id, + user_id=user_id, + ) + if item is not None: + return item + existing = await self._dao.fetch_input( + project_id=project_id, session_id=session_id, input_id=input_id + ) + if existing is not None and existing.state != PendingInputState.pending: + raise SessionInputNotRemovable(str(input_id)) + raise SessionInputNotFound(str(input_id)) + + async def update( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + user_id: Optional[UUID], + update: PendingInputUpdate, + ) -> PendingInput: + async with self._dao.transaction() as transaction: + item = await self._dao.lock_pending_for_edit( + project_id=project_id, + session_id=session_id, + input_id=input_id, + transaction=transaction, + ) + if item is None: + raise SessionInputNotFound(str(input_id)) + content = edit_pending_input_content(item.content, update) + return await self._dao.update_content( + project_id=project_id, + session_id=session_id, + input_id=input_id, + content=content, + user_id=user_id, + transaction=transaction, + ) diff --git a/api/oss/src/core/sessions/inputs/types.py b/api/oss/src/core/sessions/inputs/types.py new file mode 100644 index 00000000000..0a351606e52 --- /dev/null +++ b/api/oss/src/core/sessions/inputs/types.py @@ -0,0 +1,46 @@ +from typing import Optional + + +class SessionInputError(Exception): + pass + + +class SessionInputBusy(SessionInputError): + def __init__(self, current_execution_id: Optional[str] = None): + self.current_execution_id = current_execution_id + super().__init__("The session is already running an execution.") + + +class SessionInputNotFound(SessionInputError): + def __init__(self, input_id: str): + self.input_id = input_id + super().__init__("The pending input was not found.") + + +class SessionInputNotRemovable(SessionInputError): + def __init__(self, input_id: str): + self.input_id = input_id + super().__init__("The input can no longer be removed because it was promoted.") + + +class SessionInputIdempotencyConflict(SessionInputError): + def __init__(self): + super().__init__("This idempotency key was already used for a different input.") + + +class SessionInputRemoved(SessionInputError): + def __init__(self, input_id: str): + self.input_id = input_id + super().__init__("The queued input was removed and cannot be sent.") + + +class SessionInputNotEditable(SessionInputError): + def __init__(self, input_id: str): + self.input_id = input_id + super().__init__( + "The queued input is no longer editable because it was removed, promoted, or selected to run next." + ) + + +class SessionInputContentInvalid(SessionInputError): + pass diff --git a/api/oss/src/core/sessions/interactions/interfaces.py b/api/oss/src/core/sessions/interactions/interfaces.py index 7a11646a6b4..b36493561d5 100644 --- a/api/oss/src/core/sessions/interactions/interfaces.py +++ b/api/oss/src/core/sessions/interactions/interfaces.py @@ -29,13 +29,27 @@ async def fetch_interaction( project_id: UUID, # interaction_id: UUID, + transaction: Optional[Any] = None, + for_update: bool = False, ) -> Optional[SessionInteraction]: ... + @abstractmethod + async def fetch_turn_interactions( + self, + *, + project_id: UUID, + session_id: str, + turn_id: str, + transaction: Optional[Any] = None, + for_update: bool = False, + ) -> List[SessionInteraction]: ... + @abstractmethod async def transition_interaction( self, *, transition: SessionInteractionTransition, + transaction: Optional[Any] = None, ) -> Optional[SessionInteraction]: ... @abstractmethod diff --git a/api/oss/src/core/sessions/interactions/references.py b/api/oss/src/core/sessions/interactions/references.py new file mode 100644 index 00000000000..b08fcd2bc86 --- /dev/null +++ b/api/oss/src/core/sessions/interactions/references.py @@ -0,0 +1,95 @@ +from typing import Any, Dict, List, Optional +from uuid import UUID + +from oss.src.core.sessions.interactions.dtos import SessionInteraction +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.core.sessions.turns.dtos import SessionTurnQuery +from oss.src.core.sessions.turns.service import SessionTurnsService +from oss.src.core.sessions.types import SessionReference +from oss.src.core.shared.dtos import Windowing +from oss.src.utils.logging import get_module_logger + + +log = get_module_logger(__name__) + + +_EXECUTION_REFERENCE_KEYS = frozenset( + { + "workflow", + "workflow_variant", + "workflow_revision", + "application", + "application_variant", + "application_revision", + "evaluator", + "evaluator_variant", + "evaluator_revision", + } +) + + +def keyed_references( + elements: Optional[List[SessionReference]], +) -> Optional[Dict[str, Any]]: + if not elements: + return None + keyed: Dict[str, Any] = {} + for element in elements: + key = getattr(element, "key", None) + if key not in _EXECUTION_REFERENCE_KEYS or key in keyed: + continue + reference = element.model_dump(mode="json", exclude_none=True) + reference.pop("key", None) + if reference: + keyed[key] = reference + return keyed or None + + +async def resolve_interaction_references( + *, + project_id: UUID, + interaction: SessionInteraction, + turns_service: Optional[SessionTurnsService] = None, + streams_service: Optional[SessionStreamsService] = None, +) -> Optional[Dict[str, Any]]: + data = interaction.data + if data and data.references: + return { + key: reference.model_dump(mode="json") + for key, reference in data.references.items() + } + + if turns_service is not None: + try: + turns = await turns_service.query_turns( + project_id=project_id, + query=SessionTurnQuery(session_id=interaction.session_id), + windowing=Windowing(limit=1), + ) + except Exception as error: # noqa: BLE001 - fallback reads are best effort + log.warning( + f"[interactions] turn references unavailable for " + f"session={interaction.session_id}: {error}" + ) + turns = [] + if turns: + references = keyed_references(turns[0].references) + if references: + return references + + if streams_service is not None: + try: + stream = await streams_service.fetch_header( + project_id=project_id, + session_id=interaction.session_id, + ) + except Exception as error: # noqa: BLE001 - fallback reads are best effort + log.warning( + f"[interactions] stream references unavailable for " + f"session={interaction.session_id}: {error}" + ) + stream = None + if stream is not None: + return keyed_references(stream.references) + + return None diff --git a/api/oss/src/core/sessions/interactions/service.py b/api/oss/src/core/sessions/interactions/service.py index 14c5187174a..df187cf4ba5 100644 --- a/api/oss/src/core/sessions/interactions/service.py +++ b/api/oss/src/core/sessions/interactions/service.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from uuid import NAMESPACE_DNS, UUID, uuid5 from oss.src.core.sessions.interactions.dtos import ( @@ -26,6 +26,27 @@ log = get_module_logger(__name__) +def _watch_interaction_state(interaction: SessionInteraction) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if interaction.data is not None: + if ( + interaction.data.request is not None + and interaction.data.request.tool_call_id is not None + ): + data["request"] = {"tool_call_id": interaction.data.request.tool_call_id} + if interaction.data.resolution is not None: + data["resolution"] = interaction.data.resolution + return { + "id": str(interaction.id) if interaction.id is not None else None, + "session_id": interaction.session_id, + "turn_id": interaction.turn_id, + "token": interaction.token, + "kind": interaction.kind.value, + "status": interaction.status.value if interaction.status is not None else None, + "data": data or None, + } + + class SessionInteractionsService: def __init__( self, @@ -39,7 +60,12 @@ def __init__( self._records = records_service async def _publish_interaction( - self, *, project_id: UUID, session_id: str, status: str + self, + *, + project_id: UUID, + session_id: str, + status: str, + interactions: Optional[List[SessionInteraction]] = None, ) -> None: # Fire-and-forget relay notification; the publisher never raises. if self._watch is not None: @@ -47,6 +73,14 @@ async def _publish_interaction( project_id=str(project_id), session_id=session_id, status=status, + interactions=( + [ + _watch_interaction_state(interaction) + for interaction in interactions + ] + if interactions is not None + else None + ), ) async def create_interaction( @@ -66,6 +100,7 @@ async def create_interaction( project_id=project_id, session_id=interaction.session_id, status=WATCH_INTERACTION_PENDING, + interactions=[created], ) return created @@ -75,32 +110,58 @@ async def fetch_interaction( project_id: UUID, # interaction_id: UUID, + transaction: Optional[Any] = None, + for_update: bool = False, ) -> SessionInteraction: result = await self.interactions_dao.fetch_interaction( project_id=project_id, interaction_id=interaction_id, + transaction=transaction, + for_update=for_update, ) if result is None: raise InteractionNotFound(f"Interaction {interaction_id} not found") return result + async def fetch_turn_interactions( + self, + *, + project_id: UUID, + session_id: str, + turn_id: str, + transaction: Optional[Any] = None, + for_update: bool = False, + ) -> List[SessionInteraction]: + return await self.interactions_dao.fetch_turn_interactions( + project_id=project_id, + session_id=session_id, + turn_id=turn_id, + transaction=transaction, + for_update=for_update, + ) + async def transition_interaction( self, *, transition: SessionInteractionTransition, + transaction: Optional[Any] = None, + publish: bool = True, ) -> Optional[SessionInteraction]: result = await self.interactions_dao.transition_interaction( transition=transition, + transaction=transaction, ) if result is None: raise InteractionNotFound( f"Interaction with token {transition.token!r} not found or already terminal" ) - await self._publish_interaction( - project_id=transition.project_id, - session_id=transition.session_id, - status=WATCH_INTERACTION_RESOLVED, - ) + if publish: + await self._publish_interaction( + project_id=transition.project_id, + session_id=transition.session_id, + status=WATCH_INTERACTION_RESOLVED, + interactions=[result], + ) return result async def cancel_session_pending( @@ -174,6 +235,20 @@ async def publish_session_pending_cancelled( status=WATCH_INTERACTION_RESOLVED, ) + async def publish_interaction_responded( + self, + *, + project_id: UUID, + session_id: str, + interactions: List[SessionInteraction], + ) -> None: + await self._publish_interaction( + project_id=project_id, + session_id=session_id, + status=WATCH_INTERACTION_RESOLVED, + interactions=interactions, + ) + async def query_interactions( self, *, diff --git a/api/oss/src/core/sessions/records/events.py b/api/oss/src/core/sessions/records/events.py index 1a105e5d5bb..560bfd56f30 100644 --- a/api/oss/src/core/sessions/records/events.py +++ b/api/oss/src/core/sessions/records/events.py @@ -4,18 +4,25 @@ from oss.src.core.sessions.records.dtos import ( SESSION_DURABLE_EVENT_TYPES, - InteractionRequestedEvent, - InteractionRespondedEvent, - MessageCompletedEvent, SessionDurableEvent, SessionRecord, - ToolCompletedEvent, ) _EVENT_ADAPTER = TypeAdapter(SessionDurableEvent) +def _validated_event( + *, base: Dict[str, Any], event_type: str, payload: Dict[str, Any] +) -> Optional[SessionDurableEvent]: + try: + return _EVENT_ADAPTER.validate_python( + {**base, "type": event_type, "payload": payload} + ) + except ValidationError: + return None + + def _event_base( record: SessionRecord, *, @@ -72,12 +79,7 @@ def _direct_event( ) if base is None: return None - try: - return _EVENT_ADAPTER.validate_python( - {**base, "type": record.record_type, "payload": payload} - ) - except ValidationError: - return None + return _validated_event(base=base, event_type=record.record_type, payload=payload) def durable_events_from_records( @@ -124,47 +126,57 @@ def durable_events_from_records( if base is None: continue + # Runner completion is persisted as `done`, including paused and cancelled turns. + if record.record_type == "done": + stop_reason = attributes.get("stopReason") + event = _validated_event( + base=base, + event_type="execution.stopped", + payload={ + "stopped_at": base["created_at"], + "reason": stop_reason + if isinstance(stop_reason, str) and stop_reason + else "completed", + }, + ) + if event is not None: + events.append(event) + 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, - ) - ) + event = _validated_event( + base=base, + event_type=( + "interaction.requested" + if record.record_type == "interaction_request" + else "interaction.responded" + ), + payload=payload, + ) + if event is not None: + events.append(event) 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"), - }, - ) + event = _validated_event( + base=base, + event_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"), + }, ) + if event is not None: + events.append(event) continue tool_key = (str(record.turn_id), entity_id) @@ -177,21 +189,19 @@ def durable_events_from_records( 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", - }, - ) + event = _validated_event( + base=base, + event_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", + }, ) + if event is not None: + events.append(event) return events diff --git a/api/oss/src/core/sessions/records/interfaces.py b/api/oss/src/core/sessions/records/interfaces.py index a6e516a4005..a80490907d6 100644 --- a/api/oss/src/core/sessions/records/interfaces.py +++ b/api/oss/src/core/sessions/records/interfaces.py @@ -92,3 +92,12 @@ async def settled_turns( """ raise NotImplementedError + + async def runner_completed_turns( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + ) -> Set[Tuple[str, str]]: + """Turns with an effective, successful runner terminal record.""" + raise NotImplementedError diff --git a/api/oss/src/core/sessions/records/service.py b/api/oss/src/core/sessions/records/service.py index cdbac8957d9..75d315e1367 100644 --- a/api/oss/src/core/sessions/records/service.py +++ b/api/oss/src/core/sessions/records/service.py @@ -79,9 +79,10 @@ async def append_many( return [] guarded = await self._handle_late_events(events=events) - appended = await self.records_dao.append_many(events=guarded) + records = await self.records_dao.append_many(events=guarded) + await self._settle_completed_continuations(records=records) await self._mark_endings_written(events=guarded) - return appended + return records async def _mark_endings_written( self, @@ -116,6 +117,69 @@ async def _mark_endings_written( exc_info=True, ) + async def _settle_completed_continuations( + self, + *, + records: List[SessionRecord], + ) -> None: + """Make a runner's durable ending the continuation's durable outcome. + + The runner flushes its terminal record before releasing its heartbeat. Without this + bridge, an admitted continuation remained ``running`` forever; once its heartbeat went + stale, recovery could replay work that had already completed. Only an effective runner + ending qualifies: paused turns remain continuable, watchdog endings are not successful + completion, and quarantined late endings already lost the Stop/watchdog race. + + Record persistence is the source of truth and happened immediately above. Settlement is + best effort here because records and executions use different database engines; the + watchdog repeats the reconciliation before it collapses stale ownership. + """ + if self.executions_dao is None or not env.agenta.sessions.durable_approvals: + return + + candidates = { + (record.project_id, record.session_id, record.turn_id) + for record in records + if record.record_type == TERMINAL_RECORD_TYPE + and record.turn_id + and record.quarantined_at is None + and (record.attributes or {}).get(RECORD_SETTLED_BY_ATTRIBUTE) + != SETTLED_BY_WATCHDOG + and (record.attributes or {}).get("stopReason") + not in ("paused", "cancelled", "error") + } + for project_id, session_id, execution_id in candidates: + try: + execution = await self.executions_dao.fetch_execution( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + ) + if ( + execution is None + or ( + execution.source_interaction_id is None + and execution.parent_execution_id is None + ) + or execution.terminal_outcome is not None + ): + continue + await self.executions_dao.settle( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + terminal_outcome="completed", + settled_by="runner", + ) + except Exception: + log.warning( + "[RECORDS] Continuation completion settlement failed", + project_id=str(project_id), + session_id=session_id, + turn_id=execution_id, + exc_info=True, + ) + async def _handle_late_events( self, *, @@ -384,3 +448,14 @@ async def settled_turns( keys=keys, settled_by=settled_by, ) + + async def runner_completed_turns( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + ) -> Set[Tuple[str, str]]: + return await self.records_dao.runner_completed_turns( + project_id=project_id, + keys=keys, + ) diff --git a/api/oss/src/core/sessions/streams/service.py b/api/oss/src/core/sessions/streams/service.py index 9d0df4d577e..6eff4a7b0b2 100644 --- a/api/oss/src/core/sessions/streams/service.py +++ b/api/oss/src/core/sessions/streams/service.py @@ -28,6 +28,7 @@ ) from oss.src.core.sessions.watch.interfaces import SessionsWatchPublisherInterface from oss.src.dbs.redis.sessions.locks import ( + SessionHeartbeatGuardLost, acquire_alive_with_start, acquire_running, claim_owner, @@ -35,6 +36,7 @@ clear_owner, displace_turns, release_running, + session_heartbeat_guard, force_clear_owner, get_alive_owner, get_owner, @@ -551,6 +553,30 @@ async def heartbeat( *, project_id: UUID, request: SessionHeartbeatRequest, + ) -> SessionHeartbeatResult: + _validate_session_id(request.session_id) + async with session_heartbeat_guard( + self._lock, + project_id=str(project_id), + session_id=request.session_id, + ) as guard: + result = await self._heartbeat_locked( + project_id=project_id, request=request + ) + try: + guard.ensure_held() + except SessionHeartbeatGuardLost: + log.warning( + "sessions: heartbeat guard lease lost after heartbeat committed", + session_id=request.session_id, + ) + return result + + async def _heartbeat_locked( + self, + *, + project_id: UUID, + request: SessionHeartbeatRequest, ) -> SessionHeartbeatResult: """Refresh the nest, mirror it onto the row, and fill what the row still lacks. diff --git a/api/oss/src/core/sessions/watch/interfaces.py b/api/oss/src/core/sessions/watch/interfaces.py index dd5b77b7c1a..b86773dd462 100644 --- a/api/oss/src/core/sessions/watch/interfaces.py +++ b/api/oss/src/core/sessions/watch/interfaces.py @@ -1,4 +1,4 @@ -from typing import Protocol, runtime_checkable +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable @runtime_checkable @@ -22,9 +22,14 @@ async def lifecycle(self, *, project_id: str, session_id: str, state: str) -> No ... async def interaction( - self, *, project_id: str, session_id: str, status: str + self, + *, + project_id: str, + session_id: str, + status: str, + interactions: Optional[List[Dict[str, Any]]] = None, ) -> None: - """A gate became actionable or was answered (`pending` | `resolved`).""" + """A gate changed, optionally carrying the committed row state.""" ... async def changed(self, *, project_id: str, entity: str, id: str) -> None: diff --git a/api/oss/src/core/workflows/service.py b/api/oss/src/core/workflows/service.py index 5b83462740b..327574d14a9 100644 --- a/api/oss/src/core/workflows/service.py +++ b/api/oss/src/core/workflows/service.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, Optional, List, Union, TYPE_CHECKING +from typing import Any, Awaitable, Callable, Dict, Optional, List, Union, TYPE_CHECKING from uuid import UUID, uuid4 import httpx @@ -277,6 +277,33 @@ def __init__( self.embeds_service = embeds_service self.static_catalog = static_catalog self._watch = watch_publisher + self._session_continuation_resumer: Optional[ + Callable[..., Awaitable[Optional[str]]] + ] = None + + def set_session_continuation_resumer( + self, callback: Callable[..., Awaitable[Optional[str]]] + ) -> None: + self._session_continuation_resumer = callback + + async def _resume_pending_session_continuation( + self, *, project_id: UUID, request: WorkflowServiceRequest + ) -> bool: + session_id = request.session_id + meta = request.meta or {} + if ( + not env.agenta.sessions.durable_approvals + or not session_id + or meta.get("control_command_id") + or self._session_continuation_resumer is None + ): + return False + return bool( + await self._session_continuation_resumer( + project_id=project_id, + session_id=session_id, + ) + ) @staticmethod def _artifact_cache_key(artifact_id: UUID) -> str: @@ -727,15 +754,21 @@ async def _stream_service_started( credentials: str, payload: dict, run_id: str, + strict_first_record: bool = False, ) -> WorkflowServiceDetachedResponse: """Stream the service ``/invoke`` and return on the FIRST record (the started handshake). - The runner emits NDJSON ``{"kind": "event"|"result", ...}`` records the moment each is - built; the first one means the run is accepted and owned (the alive-held handshake). We - return then and close the connection — the runner owns the run (alive watchdog) and - persists independently (producer-driven ingest), so draining to completion is unnecessary. - The read timeout is generous (sandbox cold-start can take seconds); we are NOT awaiting - the whole run, so it is not the batch 60s-whole-run budget. + The deployed workflow service emits one NDJSON record the moment each is built; the first + one means the run is accepted and owned (the alive-held handshake). We return then and + close the connection — the runner owns the run (alive watchdog) and persists independently + (producer-driven ingest), so draining to completion is unnecessary. The read timeout is + generous (sandbox cold-start can take seconds); we are NOT awaiting the whole run, so it + is not the batch 60s-whole-run budget. + + ``strict_first_record`` (a durable control command) surfaces an explicit failure frame + instead of reporting it as a start. It does NOT require a particular record shape: see + ``_detached_start_failure`` for why the two producers on this stream disagree about the + vocabulary, and why anything unrecognised is a start. """ headers = inject( { @@ -777,8 +810,22 @@ async def _stream_service_started( # exiting the context closes the connection (run keeps going on the runner). try: record = json.loads(line) - except json.JSONDecodeError: + except json.JSONDecodeError as error: + if strict_first_record: + raise WorkflowDetachedStartFailed( + "Workflow service emitted malformed NDJSON before detached start." + ) from error record = None + if strict_first_record and not isinstance(record, dict): + raise WorkflowDetachedStartFailed( + "Workflow service emitted a non-object record before detached start." + ) + if strict_first_record and isinstance(record, dict): + failure = WorkflowsService._detached_start_failure(record) + if failure is not None: + raise WorkflowDetachedStartFailed( + f"Workflow service rejected detached start: {failure}" + ) record_run_id = ( record.get("run_id") if isinstance(record, dict) else None ) @@ -794,6 +841,48 @@ async def _stream_service_started( "Workflow service closed the stream before emitting a started record." ) + @staticmethod + def _detached_start_failure(record: dict) -> Optional[str]: + """Read an explicit failure out of the FIRST record, in either wire vocabulary. + + Two producers can answer this stream and they do not share a vocabulary. + + * The deployed workflow SERVICE is the ordinary case. It streams agenta event frames, + ``{"type": ..., "data": {...}}``, and its failure frame is ``{"type": "error"}``. There + is no ``kind`` anywhere on that wire. + * The agent RUNNER's own NDJSON, ``{"kind": "event"|"result"}``, reaches this parser only + where a deployment forwards the runner stream verbatim. Its failure is a terminal + ``{"kind": "result", "result": {"ok": false}}``. + + Everything else is the started handshake. Rejecting an unrecognised record instead is what + made EVERY durable continuation report `unreachable` while the runner was in fact already + running the turn: the service's first frame carries ``type``, never ``kind``. + + A runner that refuses a continuation outright never reaches here at all. The SDK turns its + ``ok: false`` result into an exception inside the already-committed ASGI response, so the + service closes the stream having written nothing, and the caller raises the + "closed the stream" failure above. + """ + if record.get("kind") == "result": + result = record.get("result") + if isinstance(result, dict) and result.get("ok") is True: + return None + detail = ( + result.get("error") + if isinstance(result, dict) + else "malformed result record" + ) + return str(detail or "the runner rejected the run") + + if record.get("type") == "error": + data = record.get("data") + message = data.get("message") if isinstance(data, dict) else None + code = data.get("code") if isinstance(data, dict) else None + detail = str(message or "the service reported an error") + return f"{detail} ({code})" if code else detail + + return None + @staticmethod def _coerce_invoke_response( *, @@ -2875,6 +2964,19 @@ async def invoke_workflow( WorkflowServiceBatchResponse, WorkflowServiceStreamResponse, ]: + if await self._resume_pending_session_continuation( + project_id=project_id, request=request + ): + return WorkflowServiceBatchResponse( + status=WorkflowServiceStatus( + type="https://agenta.ai/docs/errors#continuation-resumed", + code=409, + message=( + "A durable approval continuation already owns this session; " + "it was redelivered instead of starting a competing turn." + ), + ) + ) credentials, service_url = await self._prepare_invoke( project_id=project_id, user_id=user_id, @@ -2913,6 +3015,7 @@ async def invoke_workflow_detached( request: WorkflowServiceRequest, # run_id: Optional[str] = None, + control_command_id: Optional[UUID] = None, ) -> WorkflowServiceDetachedResponse: """Fire-and-forget invoke: stream the service and return on the started handshake. @@ -2930,6 +3033,8 @@ async def invoke_workflow_detached( meta = dict(request.meta or {}) meta["run_id"] = run_id meta["project_id"] = str(project_id) + if control_command_id is not None: + meta["control_command_id"] = str(control_command_id) request.meta = meta credentials, service_url = await self._prepare_invoke( @@ -2949,6 +3054,7 @@ async def invoke_workflow_detached( exclude_none=True, ), run_id=run_id, + strict_first_record=bool(meta.get("control_command_id")), ) async def inspect_workflow( diff --git a/api/oss/src/dbs/http/sessions/control_delivery_direct.py b/api/oss/src/dbs/http/sessions/control_delivery_direct.py index dd470dcfb4d..98c67cf562b 100644 --- a/api/oss/src/dbs/http/sessions/control_delivery_direct.py +++ b/api/oss/src/dbs/http/sessions/control_delivery_direct.py @@ -32,10 +32,10 @@ broke Stop for the whole window after every deploy, which is worse than the failure it guarded. """ -from typing import Optional +from typing import Awaitable, Callable, Optional from uuid import UUID -from oss.src.core.sessions.commands.dtos import SessionCommand +from oss.src.core.sessions.commands.dtos import SessionCommand, SessionCommandKind from oss.src.core.sessions.commands.interfaces import ( ControlDeliveryPort, DeliveryReceipt, @@ -51,14 +51,48 @@ class DirectControlDelivery(ControlDeliveryPort): - def __init__(self, *, timeout_seconds: Optional[float] = None) -> None: + def __init__( + self, + *, + timeout_seconds: Optional[float] = None, + continue_interaction: Optional[ + Callable[[SessionCommand], Awaitable[None]] + ] = None, + continue_input: Optional[Callable[[SessionCommand], Awaitable[None]]] = None, + ) -> None: self._timeout = ( timeout_seconds if timeout_seconds is not None else env.agenta.sessions.commands.delivery_timeout_seconds ) + self._continue_interaction = continue_interaction + self._continue_input = continue_input async def deliver(self, *, command: SessionCommand) -> DeliveryReceipt: + if command.kind == SessionCommandKind.continue_interaction: + if self._continue_interaction is None: + return DeliveryReceipt( + status="unreachable", + detail="continuation delivery is not configured", + ) + try: + await self._continue_interaction(command) + except Exception as error: # noqa: BLE001 - transport maps failures to receipts + return DeliveryReceipt(status="unreachable", detail=str(error)) + return DeliveryReceipt(status="accepted", replica_id="direct") + + if command.kind == SessionCommandKind.continue_input: + if self._continue_input is None: + return DeliveryReceipt( + status="unreachable", + detail="pending input delivery is not configured", + ) + try: + await self._continue_input(command) + except Exception as error: # noqa: BLE001 - transport maps failures to receipts + return DeliveryReceipt(status="unreachable", detail=str(error)) + return DeliveryReceipt(status="accepted", replica_id="direct") + answer = await cancel_runner_execution( command_id=str(command.id), project_id=str(command.project_id), diff --git a/api/oss/src/dbs/postgres/sessions/commands/dao.py b/api/oss/src/dbs/postgres/sessions/commands/dao.py index 482e33d0e7f..c54a865644c 100644 --- a/api/oss/src/dbs/postgres/sessions/commands/dao.py +++ b/api/oss/src/dbs/postgres/sessions/commands/dao.py @@ -10,7 +10,8 @@ from typing import Any, Dict, List, Optional from uuid import UUID -from sqlalchemy import and_, func, or_, select, update as sa_update +from sqlalchemy import and_, cast, func, or_, select, update as sa_update +from sqlalchemy.dialects.postgresql import JSON, JSONB from sqlalchemy.exc import IntegrityError from oss.src.utils.logging import get_module_logger @@ -32,6 +33,7 @@ map_command_dbe_to_dto, map_command_dto_to_dbe_create, ) +from oss.src.dbs.postgres.sessions.executions.dbes import SessionExecutionDBE from oss.src.dbs.postgres.sessions.streams.dbes import SessionStreamDBE from oss.src.dbs.postgres.shared.engine import ( TransactionsEngine, @@ -95,13 +97,56 @@ async def create_command( user_id: Optional[UUID], command: SessionCommandCreate, stopping_turn_id: Optional[str] = None, + transaction: Optional[Any] = None, ) -> SessionCommand: - result = await self.create_command_with_status( - user_id=user_id, - command=command, - stopping_turn_id=stopping_turn_id, - ) - return result.command + if transaction is None: + result = await self.create_command_with_status( + user_id=user_id, + command=command, + stopping_turn_id=stopping_turn_id, + ) + return result.command + + dbe = map_command_dto_to_dbe_create(user_id=user_id, command=command) + + async def execute(session: Any) -> SessionCommand: + session.add(dbe) + if stopping_turn_id is not None: + await session.execute( + sa_update(SessionStreamDBE) + .where( + SessionStreamDBE.project_id == command.project_id, + SessionStreamDBE.session_id == command.session_id, + SessionStreamDBE.deleted_at.is_(None), + ) + .values(stopping_turn_id=stopping_turn_id) + ) + await session.flush() + return map_command_dbe_to_dto(dbe) + + try: + async with transaction.begin_nested(): + return await execute(transaction) + except IntegrityError: + if command.idempotency_key is not None: + existing = await self.fetch_by_idempotency_key( + project_id=command.project_id, + session_id=command.session_id, + idempotency_key=command.idempotency_key, + transaction=transaction, + ) + if existing is not None: + return existing + open_command = await self.fetch_open_command( + project_id=command.project_id, + session_id=command.session_id, + kind=command.kind, + target_turn_id=command.target_turn_id, + transaction=transaction, + ) + if open_command is None: + raise + return open_command async def create_command_with_status( self, @@ -110,15 +155,7 @@ async def create_command_with_status( command: SessionCommandCreate, stopping_turn_id: Optional[str] = None, ) -> CommandCreateResult: - """Insert the command and stamp the session row's `stopping_turn_id` together. - - One transaction, on purpose. A user whose Stop was recorded but whose session row never - learned it is waiting has a session that renders as plainly running while a command - exists to stop it, and nothing later reconciles the two. - - `session_streams` is written from here rather than through the streams DAO because - sharing one transaction is the whole requirement, and the streams DAO opens its own. - """ + """Insert the command and stamp the session row's status atomically.""" dbe = map_command_dto_to_dbe_create(user_id=user_id, command=command) try: @@ -140,15 +177,6 @@ async def create_command_with_status( command=map_command_dbe_to_dto(dbe), inserted=True ) except IntegrityError: - # One of two unique constraints refused this insert, and both mean the same thing: - # a command for this intent already exists. Return it rather than a second command. - # - # uq_session_commands_idempotency — the caller retried with the same key. - # uq_session_commands_open_target — another request is already stopping this - # execution, which is what makes two Stops in - # the SAME INSTANT one command. Admission's own - # read cannot see a row that has not committed - # yet, so the database is the decider. if command.idempotency_key is not None: existing = await self.fetch_by_idempotency_key( project_id=command.project_id, @@ -173,8 +201,9 @@ async def fetch_by_idempotency_key( project_id: UUID, session_id: str, idempotency_key: str, + transaction: Optional[Any] = None, ) -> Optional[SessionCommand]: - async with self.engine.session() as session: + async def execute(session: Any) -> Optional[SessionCommand]: stmt = select(SessionCommandDBE).where( SessionCommandDBE.project_id == project_id, SessionCommandDBE.session_id == session_id, @@ -182,7 +211,12 @@ async def fetch_by_idempotency_key( ) result = await session.execute(stmt) dbe = result.scalar_one_or_none() - return map_command_dbe_to_dto(dbe) if dbe is not None else None + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) async def fetch_open_command( self, @@ -191,8 +225,9 @@ async def fetch_open_command( session_id: str, kind: SessionCommandKind, target_turn_id: Optional[str], + transaction: Optional[Any] = None, ) -> Optional[SessionCommand]: - async with self.engine.session() as session: + async def execute(session: Any) -> Optional[SessionCommand]: stmt = ( select(SessionCommandDBE) .where( @@ -212,15 +247,188 @@ async def fetch_open_command( ) result = await session.execute(stmt) dbe = result.scalar_one_or_none() + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def bind_steer_input( + self, + *, + project_id: UUID, + command_id: UUID, + input_id: UUID, + transaction: Optional[Any] = None, + ) -> Optional[SessionCommand]: + async def execute(session: Any) -> Optional[SessionCommand]: + row = ( + await session.execute( + sa_update(SessionCommandDBE) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.id == command_id, + SessionCommandDBE.state.in_(_OPEN_STATES), + or_( + SessionCommandDBE.data["steer_input_id"].astext.is_(None), + SessionCommandDBE.data["steer_input_id"].astext + == str(input_id), + ), + ) + .values( + data=cast( + func.coalesce( + cast(SessionCommandDBE.data, JSONB), cast({}, JSONB) + ).op("||")(cast({"steer_input_id": str(input_id)}, JSONB)), + JSON, + ) + ) + .returning(SessionCommandDBE) + ) + ).scalar_one_or_none() + return map_command_dbe_to_dto(row) if row is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def fetch_resumable_continuation( + self, + *, + project_id: UUID, + session_id: str, + ) -> Optional[SessionCommand]: + async with self.engine.session() as session: + stmt = ( + select(SessionCommandDBE) + .join( + SessionExecutionDBE, + and_( + SessionExecutionDBE.project_id == SessionCommandDBE.project_id, + SessionExecutionDBE.session_id == SessionCommandDBE.session_id, + SessionExecutionDBE.execution_id + == SessionCommandDBE.target_turn_id, + ), + ) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.session_id == session_id, + SessionCommandDBE.kind.in_( + ( + SessionCommandKind.continue_interaction.value, + SessionCommandKind.continue_input.value, + ) + ), + or_( + and_( + SessionCommandDBE.state.in_(_OPEN_STATES), + SessionExecutionDBE.state.in_( + ("pending_delivery", "recoverable") + ), + ), + and_( + SessionCommandDBE.state + == SessionCommandState.obsolete.value, + SessionCommandDBE.outcome.in_(("lost", "failed")), + SessionExecutionDBE.state == "recoverable", + ), + and_( + SessionCommandDBE.state + == SessionCommandState.applied.value, + SessionCommandDBE.outcome == "started", + # `running` belongs here beside `recoverable`. A delivered + # continuation that is still executing OWNS the session's next turn, + # and `resume_recoverable_continuation` already says exactly that: + # its `state == running` branch returns True without redelivering. + # That branch was unreachable while this filter dropped `running`, so + # the Send preflight answered "nobody owns this" and the browser + # invoked the runner directly — which supersedes the continuation, + # tears down its warm sandbox mid-call and returns the tool call the + # user had just approved as aborted. + SessionExecutionDBE.state.in_(("recoverable", "running")), + ), + ), + SessionCommandDBE.deleted_at.is_(None), + ) + .order_by(SessionCommandDBE.created_at) + .limit(1) + ) + dbe = (await session.execute(stmt)).scalar_one_or_none() return map_command_dbe_to_dto(dbe) if dbe is not None else None + async def reopen_continuation( + self, + *, + project_id: UUID, + command_id: UUID, + target_turn_id: str, + replacement_turn_id: str, + transaction: Optional[Any] = None, + ) -> Optional[SessionCommand]: + async def execute(session: Any) -> Optional[SessionCommand]: + stmt = ( + sa_update(SessionCommandDBE) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.id == command_id, + SessionCommandDBE.target_turn_id == target_turn_id, + SessionCommandDBE.kind.in_( + ( + SessionCommandKind.continue_interaction.value, + SessionCommandKind.continue_input.value, + ) + ), + or_( + and_( + SessionCommandDBE.state + == SessionCommandState.obsolete.value, + SessionCommandDBE.outcome.in_(("lost", "failed")), + ), + and_( + SessionCommandDBE.state + == SessionCommandState.applied.value, + SessionCommandDBE.outcome == "started", + ), + ), + select(SessionExecutionDBE.execution_id) + .where( + SessionExecutionDBE.project_id == SessionCommandDBE.project_id, + SessionExecutionDBE.session_id == SessionCommandDBE.session_id, + SessionExecutionDBE.execution_id == replacement_turn_id, + SessionExecutionDBE.state == "pending_delivery", + ) + .exists(), + ) + .values( + target_turn_id=replacement_turn_id, + state=SessionCommandState.pending.value, + outcome=None, + settled_at=None, + claimed_by=None, + claim_expires_at=None, + claim_count=0, + updated_at=datetime.now(timezone.utc), + ) + .returning(SessionCommandDBE) + ) + dbe = (await session.execute(stmt)).scalar_one_or_none() + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + async def fetch_command( self, *, command_id: UUID, project_id: Optional[UUID] = None, + transaction: Optional[Any] = None, ) -> Optional[SessionCommand]: - async with self.engine.session() as session: + async def execute(session: Any) -> Optional[SessionCommand]: stmt = select(SessionCommandDBE).where( SessionCommandDBE.id == command_id, ) @@ -228,7 +436,12 @@ async def fetch_command( stmt = stmt.where(SessionCommandDBE.project_id == project_id) result = await session.execute(stmt) dbe = result.scalars().first() - return map_command_dbe_to_dto(dbe) if dbe is not None else None + return map_command_dbe_to_dto(dbe) if dbe is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) async def claim_commands( self, @@ -411,12 +624,18 @@ async def execute(session: Any) -> Optional[SessionCommand]: SessionCommandDBE.claimed_by == settle.replica_id, ) ) - stmt = stmt.values( + values = dict( state=settle.state.value, outcome=settle.outcome.value, settled_at=now, updated_at=now, - ).returning(SessionCommandDBE) + ) + if settle.replica_id is not None: + # A pending continuation can report before the API records its delivery claim. + # Persisting that reporter makes a lost HTTP response retryable by the same + # runner, while a different replica still receives admitted=false. + values["claimed_by"] = settle.replica_id + stmt = stmt.values(**values).returning(SessionCommandDBE) result = await session.execute(stmt) dbe = result.scalar_one_or_none() return map_command_dbe_to_dto(dbe) if dbe is not None else None diff --git a/api/oss/src/dbs/postgres/sessions/commands/dbes.py b/api/oss/src/dbs/postgres/sessions/commands/dbes.py index f4a755aba9a..428305a60c0 100644 --- a/api/oss/src/dbs/postgres/sessions/commands/dbes.py +++ b/api/oss/src/dbs/postgres/sessions/commands/dbes.py @@ -25,7 +25,10 @@ class SessionCommandDBE(Base, SessionCommandDBA): "idempotency_key", name="uq_session_commands_idempotency", ), - CheckConstraint("kind IN ('cancel')", name="ck_session_commands_kind"), + CheckConstraint( + "kind IN ('cancel', 'continue_interaction', 'continue_input')", + name="ck_session_commands_kind", + ), CheckConstraint( "state IN ('pending', 'claimed', 'applied', 'obsolete')", name="ck_session_commands_state", diff --git a/api/oss/src/dbs/postgres/sessions/executions/dao.py b/api/oss/src/dbs/postgres/sessions/executions/dao.py index 69c646835a5..abbdffad22c 100644 --- a/api/oss/src/dbs/postgres/sessions/executions/dao.py +++ b/api/oss/src/dbs/postgres/sessions/executions/dao.py @@ -2,10 +2,11 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple from uuid import UUID -from sqlalchemy import and_, literal_column, or_, select, tuple_, update as sa_update +from sqlalchemy import and_, or_, select, tuple_, update as sa_update from sqlalchemy.dialects.postgresql import insert from oss.src.core.sessions.executions.dtos import ( + SessionExecutionState, SessionExecutionSettlement, SessionExecutionSettlementResult, ) @@ -22,6 +23,10 @@ def _to_dto(row: SessionExecutionDBE) -> SessionExecutionSettlement: project_id=row.project_id, session_id=row.session_id, execution_id=row.execution_id, + state=SessionExecutionState(row.state), + parent_execution_id=row.parent_execution_id, + source_interaction_id=row.source_interaction_id, + error=row.error, terminal_outcome=row.terminal_outcome, settled_by=row.settled_by, settled_at=row.settled_at, @@ -34,41 +39,187 @@ class SessionExecutionsDAO(SessionExecutionsDAOInterface): def __init__(self, engine: Optional[TransactionsEngine] = None): self.engine = engine or get_transactions_engine() - async def settle( + async def fetch_execution( self, *, project_id: UUID, session_id: str, execution_id: str, - terminal_outcome: str, - settled_by: str, - settled_at: Optional[datetime] = None, transaction: Optional[Any] = None, - ) -> SessionExecutionSettlementResult: - settled_at = settled_at or datetime.now(timezone.utc) - stmt = ( + ) -> Optional[SessionExecutionSettlement]: + async def execute(session: Any) -> Optional[SessionExecutionSettlement]: + row = ( + await session.execute( + select(SessionExecutionDBE).where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.execution_id == execution_id, + ) + ) + ).scalar_one_or_none() + return _to_dto(row) if row is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def lock_for_control( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + transaction: Any, + ) -> SessionExecutionSettlement: + await transaction.execute( insert(SessionExecutionDBE) .values( project_id=project_id, session_id=session_id, execution_id=execution_id, - terminal_outcome=terminal_outcome, - settled_by=settled_by, - settled_at=settled_at, + state=SessionExecutionState.active.value, ) - .on_conflict_do_update( - index_elements=["project_id", "session_id", "execution_id"], - set_={"terminal_outcome": SessionExecutionDBE.terminal_outcome}, + .on_conflict_do_nothing( + index_elements=["project_id", "session_id", "execution_id"] ) - .returning( - SessionExecutionDBE, - literal_column("xmax = 0").label("won"), + ) + row = ( + await transaction.execute( + select(SessionExecutionDBE) + .where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.execution_id == execution_id, + ) + .with_for_update() ) + ).scalar_one() + return _to_dto(row) + + async def lock_active_continuation( + self, + *, + project_id: UUID, + session_id: str, + transaction: Any, + ) -> Optional[SessionExecutionSettlement]: + row = ( + await transaction.execute( + select(SessionExecutionDBE) + .where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.parent_execution_id.is_not(None), + SessionExecutionDBE.terminal_outcome.is_(None), + ) + .order_by(SessionExecutionDBE.execution_id) + .limit(1) + .with_for_update() + ) + ).scalar_one_or_none() + return _to_dto(row) if row is not None else None + + async def create_continuation( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + parent_execution_id: str, + source_interaction_id: Optional[UUID], + transaction: Any, + ) -> SessionExecutionSettlement: + row = SessionExecutionDBE( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + state=SessionExecutionState.pending_delivery.value, + parent_execution_id=parent_execution_id, + source_interaction_id=source_interaction_id, ) + transaction.add(row) + await transaction.flush() + return _to_dto(row) + + async def set_state( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + state: SessionExecutionState, + error: Optional[dict] = None, + expected_states: Optional[Sequence[SessionExecutionState]] = None, + transaction: Optional[Any] = None, + ) -> Optional[SessionExecutionSettlement]: + async def execute(session: Any) -> Optional[SessionExecutionSettlement]: + stmt = sa_update(SessionExecutionDBE).where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.execution_id == execution_id, + SessionExecutionDBE.terminal_outcome.is_(None), + ) + if expected_states is not None: + stmt = stmt.where( + SessionExecutionDBE.state.in_( + [expected.value for expected in expected_states] + ) + ) + row = ( + await session.execute( + stmt.values(state=state.value, error=error).returning( + SessionExecutionDBE + ) + ) + ).scalar_one_or_none() + return _to_dto(row) if row is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def settle( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + terminal_outcome: str, + settled_by: str, + settled_at: Optional[datetime] = None, + transaction: Optional[Any] = None, + ) -> SessionExecutionSettlementResult: + settled_at = settled_at or datetime.now(timezone.utc) async def execute(session: Any) -> SessionExecutionSettlementResult: - stored, won = (await session.execute(stmt)).one() - return SessionExecutionSettlementResult(settlement=_to_dto(stored), won=won) + stored = await self.lock_for_control( + project_id=project_id, + session_id=session_id, + execution_id=execution_id, + transaction=session, + ) + if stored.terminal_outcome is not None: + return SessionExecutionSettlementResult(settlement=stored, won=False) + row = ( + await session.execute( + sa_update(SessionExecutionDBE) + .where( + SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.session_id == session_id, + SessionExecutionDBE.execution_id == execution_id, + ) + .values( + state=SessionExecutionState.terminal.value, + terminal_outcome=terminal_outcome, + settled_by=settled_by, + settled_at=settled_at, + ) + .returning(SessionExecutionDBE) + ) + ).scalar_one() + return SessionExecutionSettlementResult(settlement=_to_dto(row), won=True) if transaction is not None: return await execute(transaction) @@ -97,6 +248,7 @@ async def query_settled( await session.execute( select(SessionExecutionDBE).where( SessionExecutionDBE.project_id == project_id, + SessionExecutionDBE.terminal_outcome.is_not(None), key_filter, ) ) diff --git a/api/oss/src/dbs/postgres/sessions/executions/dbes.py b/api/oss/src/dbs/postgres/sessions/executions/dbes.py index 2a13846bb91..d00e1255868 100644 --- a/api/oss/src/dbs/postgres/sessions/executions/dbes.py +++ b/api/oss/src/dbs/postgres/sessions/executions/dbes.py @@ -6,6 +6,7 @@ String, text, ) +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy import TIMESTAMP from sqlalchemy.dialects.postgresql import UUID @@ -18,15 +19,26 @@ class SessionExecutionDBE(Base): project_id = Column(UUID(as_uuid=True), nullable=False) session_id = Column(String, nullable=False) execution_id = Column(String, nullable=False) - terminal_outcome = Column(String, nullable=False) - settled_by = Column(String, nullable=False) - settled_at = Column(TIMESTAMP(timezone=True), nullable=False) + state = Column(String, nullable=False, default="active", server_default="active") + parent_execution_id = Column(String, nullable=True) + source_interaction_id = Column(UUID(as_uuid=True), nullable=True) + error = Column(JSONB(none_as_null=True), nullable=True) + terminal_outcome = Column(String, nullable=True) + settled_by = Column(String, nullable=True) + settled_at = Column(TIMESTAMP(timezone=True), nullable=True) ending_written_at = Column(TIMESTAMP(timezone=True), nullable=True) redis_reconciled_at = Column(TIMESTAMP(timezone=True), nullable=True) __table_args__ = ( ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), PrimaryKeyConstraint("project_id", "session_id", "execution_id"), + Index( + "uq_session_executions_source_interaction", + "project_id", + "source_interaction_id", + unique=True, + postgresql_where=text("source_interaction_id IS NOT NULL"), + ), Index( "ix_session_executions_project_session", "project_id", diff --git a/api/oss/src/dbs/postgres/sessions/inputs/__init__.py b/api/oss/src/dbs/postgres/sessions/inputs/__init__.py new file mode 100644 index 00000000000..575f08594ad --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/inputs/__init__.py @@ -0,0 +1,3 @@ +from oss.src.dbs.postgres.sessions.inputs.dao import SessionInputsDAO + +__all__ = ["SessionInputsDAO"] diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dao.py b/api/oss/src/dbs/postgres/sessions/inputs/dao.py new file mode 100644 index 00000000000..81096574842 --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/inputs/dao.py @@ -0,0 +1,403 @@ +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional +from uuid import UUID + +from sqlalchemy import and_, func, or_, select, text, update as sa_update + +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputCreate +from oss.src.core.sessions.inputs.interfaces import SessionInputsDAOInterface +from oss.src.core.sessions.inputs.types import ( + SessionInputNotRemovable, + SessionInputNotEditable, +) +from oss.src.dbs.postgres.sessions.commands.dbes import SessionCommandDBE +from oss.src.dbs.postgres.sessions.inputs.dbes import SessionInputDBE +from oss.src.dbs.postgres.sessions.inputs.mappings import ( + new_input_row, + to_pending_input, +) +from oss.src.dbs.postgres.sessions.executions.dbes import SessionExecutionDBE +from oss.src.dbs.postgres.shared.engine import ( + TransactionsEngine, + get_transactions_engine, +) + + +class SessionInputsDAO(SessionInputsDAOInterface): + def __init__(self, engine: Optional[TransactionsEngine] = None): + self.engine = engine or get_transactions_engine() + + def transaction(self): + return self.engine.session() + + async def _lock_session( + self, session: Any, project_id: UUID, session_id: str + ) -> None: + await session.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:scope))"), + {"scope": f"{project_id}:{session_id}:inputs"}, + ) + + async def create_input( + self, + *, + user_id: Optional[UUID], + pending_input: PendingInputCreate, + prioritize: bool = False, + transaction: Optional[Any] = None, + ) -> PendingInput: + async def execute(session: Any) -> PendingInput: + await self._lock_session( + session, pending_input.project_id, pending_input.session_id + ) + # Admission reads before it writes. Two requests carrying the same key can both + # observe "missing" before either commits, so repeat that lookup after taking the + # session-scoped transaction lock. Returning the winner lets the service apply the + # fingerprint rule without leaking an IntegrityError to either caller. + existing = ( + await session.execute( + select(SessionInputDBE).where( + SessionInputDBE.project_id == pending_input.project_id, + SessionInputDBE.session_id == pending_input.session_id, + SessionInputDBE.idempotency_key + == pending_input.idempotency_key, + ) + ) + ).scalar_one_or_none() + if existing is not None: + return to_pending_input(existing) + aggregate = func.min if prioritize else func.max + current = ( + await session.execute( + select(aggregate(SessionInputDBE.position)).where( + SessionInputDBE.project_id == pending_input.project_id, + SessionInputDBE.session_id == pending_input.session_id, + ) + ) + ).scalar_one() + position = ( + (current - 1) + if prioritize and current is not None + else (current or 0) + 1 + ) + row = new_input_row( + user_id=user_id, + position=position, + values=pending_input.model_dump(mode="python"), + ) + session.add(row) + await session.flush() + return to_pending_input(row) + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def fetch_by_idempotency_key( + self, + *, + project_id: UUID, + session_id: str, + idempotency_key: str, + transaction: Optional[Any] = None, + ) -> Optional[PendingInput]: + async def execute(session: Any) -> Optional[PendingInput]: + row = ( + await session.execute( + select(SessionInputDBE).where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.idempotency_key == idempotency_key, + ) + ) + ).scalar_one_or_none() + return to_pending_input(row) if row else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def list_pending( + self, + *, + project_id: UUID, + session_id: str, + transaction: Optional[Any] = None, + ) -> List[PendingInput]: + async def execute(session: Any) -> List[PendingInput]: + rows = ( + await session.execute( + select(SessionInputDBE) + .outerjoin( + SessionExecutionDBE, + and_( + SessionExecutionDBE.project_id + == SessionInputDBE.project_id, + SessionExecutionDBE.session_id + == SessionInputDBE.session_id, + SessionExecutionDBE.execution_id + == SessionInputDBE.promoted_execution_id, + ), + ) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + or_( + SessionInputDBE.state == "pending", + and_( + SessionInputDBE.state == "promoted", + SessionExecutionDBE.state.in_( + ("pending_delivery", "recoverable") + ), + ), + ), + ) + .order_by(SessionInputDBE.position, SessionInputDBE.created_at) + ) + ).scalars() + return [to_pending_input(row) for row in rows] + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def fetch_active_successor( + self, + *, + project_id: UUID, + session_id: str, + transaction: Any, + ) -> Optional[PendingInput]: + row = ( + await transaction.execute( + select(SessionInputDBE) + .join( + SessionExecutionDBE, + and_( + SessionExecutionDBE.project_id == SessionInputDBE.project_id, + SessionExecutionDBE.session_id == SessionInputDBE.session_id, + SessionExecutionDBE.execution_id + == SessionInputDBE.promoted_execution_id, + ), + ) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.state == "promoted", + SessionExecutionDBE.terminal_outcome.is_(None), + ) + .order_by(SessionInputDBE.position, SessionInputDBE.created_at) + .limit(1) + .with_for_update(of=SessionExecutionDBE) + ) + ).scalar_one_or_none() + return to_pending_input(row) if row else None + + async def fetch_input( + self, *, project_id: UUID, session_id: str, input_id: UUID + ) -> Optional[PendingInput]: + async with self.engine.session() as session: + row = ( + await session.execute( + select(SessionInputDBE).where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.id == input_id, + ) + ) + ).scalar_one_or_none() + return to_pending_input(row) if row else None + + async def remove_pending( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + user_id: Optional[UUID], + ) -> Optional[PendingInput]: + async with self.engine.session() as session: + # Serialize with admission before checking its committed command reservation. + await self._lock_session(session, project_id, session_id) + reserved = ( + await session.execute( + select(SessionCommandDBE.id) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.session_id == session_id, + SessionCommandDBE.kind == "cancel", + SessionCommandDBE.state.in_(("pending", "claimed")), + SessionCommandDBE.deleted_at.is_(None), + SessionCommandDBE.data["steer_input_id"].astext + == str(input_id), + ) + .limit(1) + ) + ).scalar_one_or_none() + if reserved is not None: + raise SessionInputNotRemovable(str(input_id)) + row = ( + await session.execute( + sa_update(SessionInputDBE) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.id == input_id, + SessionInputDBE.state == "pending", + ) + .values( + state="removed", + updated_at=datetime.now(timezone.utc), + updated_by_id=user_id, + ) + .returning(SessionInputDBE) + ) + ).scalar_one_or_none() + return to_pending_input(row) if row else None + + async def prioritize_pending( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + user_id: Optional[UUID], + transaction: Any, + ) -> Optional[PendingInput]: + await self._lock_session(transaction, project_id, session_id) + row = ( + await transaction.execute( + select(SessionInputDBE) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.id == input_id, + ) + .with_for_update() + ) + ).scalar_one_or_none() + if row is None: + return None + if row.state == "pending" and row.policy != "steer": + minimum = ( + await transaction.execute( + select(func.min(SessionInputDBE.position)).where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + ) + ) + ).scalar_one() + row.position = minimum - 1 + row.policy = "steer" + row.updated_at = datetime.now(timezone.utc) + row.updated_by_id = user_id + await transaction.flush() + return to_pending_input(row) + + async def promote_next( + self, + *, + project_id: UUID, + session_id: str, + execution_id: str, + input_id: Optional[UUID] = None, + only_policy: Optional[str] = None, + transaction: Optional[Any] = None, + ) -> Optional[PendingInput]: + async def execute(session: Any) -> Optional[PendingInput]: + await self._lock_session(session, project_id, session_id) + stmt = select(SessionInputDBE).where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.state == "pending", + ) + if only_policy is not None: + stmt = stmt.where(SessionInputDBE.policy == only_policy) + if input_id is not None: + stmt = stmt.where(SessionInputDBE.id == input_id) + row = ( + await session.execute( + stmt.order_by(SessionInputDBE.position, SessionInputDBE.created_at) + .limit(1) + .with_for_update(skip_locked=True) + ) + ).scalar_one_or_none() + if row is None: + return None + row.state = "promoted" + row.promoted_execution_id = execution_id + row.updated_at = datetime.now(timezone.utc) + await session.flush() + return to_pending_input(row) + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def lock_pending_for_edit( + self, *, project_id: UUID, session_id: str, input_id: UUID, transaction: Any + ) -> Optional[PendingInput]: + await self._lock_session(transaction, project_id, session_id) + row = ( + await transaction.execute( + select(SessionInputDBE) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.id == input_id, + ) + .with_for_update() + ) + ).scalar_one_or_none() + if row is None: + return None + reserved = ( + await transaction.execute( + select(SessionCommandDBE.id) + .where( + SessionCommandDBE.project_id == project_id, + SessionCommandDBE.session_id == session_id, + SessionCommandDBE.kind == "cancel", + SessionCommandDBE.state.in_(("pending", "claimed")), + SessionCommandDBE.deleted_at.is_(None), + SessionCommandDBE.data["steer_input_id"].astext == str(input_id), + ) + .limit(1) + ) + ).scalar_one_or_none() + if row.state != "pending" or reserved is not None: + raise SessionInputNotEditable(str(input_id)) + return to_pending_input(row) + + async def update_content( + self, + *, + project_id: UUID, + session_id: str, + input_id: UUID, + content: Dict[str, Any], + user_id: Optional[UUID], + transaction: Any, + ) -> PendingInput: + row = ( + await transaction.execute( + sa_update(SessionInputDBE) + .where( + SessionInputDBE.project_id == project_id, + SessionInputDBE.session_id == session_id, + SessionInputDBE.id == input_id, + SessionInputDBE.state == "pending", + ) + .values( + content=content, + updated_at=datetime.now(timezone.utc), + updated_by_id=user_id, + ) + .returning(SessionInputDBE) + ) + ).scalar_one() + return to_pending_input(row) diff --git a/api/oss/src/dbs/postgres/sessions/inputs/dbes.py b/api/oss/src/dbs/postgres/sessions/inputs/dbes.py new file mode 100644 index 00000000000..c6231acae4c --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/inputs/dbes.py @@ -0,0 +1,65 @@ +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Column, + ForeignKeyConstraint, + Index, + PrimaryKeyConstraint, + String, + text, +) +from sqlalchemy.dialects.postgresql import JSONB + +from oss.src.dbs.postgres.shared.base import Base +from oss.src.dbs.postgres.shared.dbas import ( + IdentifierDBA, + LifecycleDBA, + ProjectScopeDBA, +) + + +class SessionInputDBE(Base, ProjectScopeDBA, LifecycleDBA, IdentifierDBA): + __tablename__ = "session_inputs" + + session_id = Column(String, nullable=False) + content = Column(JSONB(none_as_null=True), nullable=False) + position = Column(BigInteger, nullable=False) + state = Column(String, nullable=False, default="pending", server_default="pending") + policy = Column(String, nullable=False) + idempotency_key = Column(String, nullable=False) + request_fingerprint = Column(String(64), nullable=False) + promoted_execution_id = Column(String, nullable=True) + + __table_args__ = ( + ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + PrimaryKeyConstraint("project_id", "id"), + CheckConstraint( + "state IN ('pending', 'promoted', 'removed')", + name="ck_session_inputs_state", + ), + CheckConstraint( + "policy IN ('queue', 'steer')", name="ck_session_inputs_policy" + ), + Index("uq_session_inputs_id", "id", unique=True), + Index( + "uq_session_inputs_idempotency", + "project_id", + "session_id", + "idempotency_key", + unique=True, + ), + Index( + "uq_session_inputs_position", + "project_id", + "session_id", + "position", + unique=True, + ), + Index( + "ix_session_inputs_pending", + "project_id", + "session_id", + "position", + postgresql_where=text("state = 'pending'"), + ), + ) diff --git a/api/oss/src/dbs/postgres/sessions/inputs/mappings.py b/api/oss/src/dbs/postgres/sessions/inputs/mappings.py new file mode 100644 index 00000000000..19add41b349 --- /dev/null +++ b/api/oss/src/dbs/postgres/sessions/inputs/mappings.py @@ -0,0 +1,36 @@ +from typing import Optional +from uuid import UUID + +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputState +from oss.src.dbs.postgres.sessions.inputs.dbes import SessionInputDBE + + +def to_pending_input(row: SessionInputDBE) -> PendingInput: + return PendingInput( + id=row.id, + created_at=row.created_at, + updated_at=row.updated_at, + deleted_at=row.deleted_at, + created_by_id=row.created_by_id, + updated_by_id=row.updated_by_id, + deleted_by_id=row.deleted_by_id, + project_id=row.project_id, + session_id=row.session_id, + content=row.content, + position=row.position, + state=PendingInputState(row.state), + policy=row.policy, + idempotency_key=row.idempotency_key, + request_fingerprint=row.request_fingerprint, + promoted_execution_id=row.promoted_execution_id, + ) + + +def new_input_row( + *, user_id: Optional[UUID], values: dict, position: int +) -> SessionInputDBE: + return SessionInputDBE( + **values, + position=position, + created_by_id=user_id, + ) diff --git a/api/oss/src/dbs/postgres/sessions/interactions/dao.py b/api/oss/src/dbs/postgres/sessions/interactions/dao.py index ef46fcbd0a8..da60407439c 100644 --- a/api/oss/src/dbs/postgres/sessions/interactions/dao.py +++ b/api/oss/src/dbs/postgres/sessions/interactions/dao.py @@ -77,24 +77,61 @@ async def fetch_interaction( project_id: UUID, # interaction_id: UUID, + transaction: Optional[Any] = None, + for_update: bool = False, ) -> Optional[SessionInteraction]: - async with self.engine.session() as session: + async def execute(session: Any) -> Optional[SessionInteraction]: stmt = select(SessionInteractionDBE).where( SessionInteractionDBE.project_id == project_id, SessionInteractionDBE.id == interaction_id, ) + if for_update: + stmt = stmt.with_for_update() result = await session.execute(stmt) dbe = result.scalar_one_or_none() - if dbe is None: - return None - return map_interaction_dbe_to_dto(dbe) + return map_interaction_dbe_to_dto(dbe) if dbe is not None else None + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + + async def fetch_turn_interactions( + self, + *, + project_id: UUID, + session_id: str, + turn_id: str, + transaction: Optional[Any] = None, + for_update: bool = False, + ) -> List[SessionInteraction]: + async def execute(session: Any) -> List[SessionInteraction]: + stmt = ( + select(SessionInteractionDBE) + .where( + SessionInteractionDBE.project_id == project_id, + SessionInteractionDBE.session_id == session_id, + SessionInteractionDBE.turn_id == turn_id, + ) + .order_by(SessionInteractionDBE.id) + ) + if for_update: + stmt = stmt.with_for_update() + rows = (await session.execute(stmt)).scalars().all() + return [map_interaction_dbe_to_dto(row) for row in rows] + + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) async def transition_interaction( self, *, transition: SessionInteractionTransition, + transaction: Optional[Any] = None, ) -> Optional[SessionInteraction]: - async with self.engine.session() as session: + async def execute(session: Any) -> Optional[SessionInteraction]: # Only non-terminal interactions transition: pending (responded|resolved| # cancelled) and responded (resolved, when the runner consumes an API-plane # answer). resolved/cancelled are terminal. @@ -129,11 +166,15 @@ async def transition_interaction( ) result = await session.execute(stmt) dbe = result.scalar_one_or_none() - await session.commit() if dbe is None: return None return map_interaction_dbe_to_dto(dbe) + if transaction is not None: + return await execute(transaction) + async with self.engine.session() as session: + return await execute(session) + async def cancel_session_pending( self, *, diff --git a/api/oss/src/dbs/postgres/sessions/records/dao.py b/api/oss/src/dbs/postgres/sessions/records/dao.py index 019deb5ceac..5c8792773d7 100644 --- a/api/oss/src/dbs/postgres/sessions/records/dao.py +++ b/api/oss/src/dbs/postgres/sessions/records/dao.py @@ -7,6 +7,7 @@ from oss.src.core.sessions.records.dtos import ( RECORD_SETTLED_BY_ATTRIBUTE, + SETTLED_BY_WATCHDOG, SESSION_MESSAGE_PREVIEW_TEXT_LIMIT, TERMINAL_RECORD_TYPE, SessionMessagePreview, @@ -522,6 +523,37 @@ async def settled_turns( return {(row.session_id, row.turn_id) for row in rows} + async def runner_completed_turns( + self, + *, + project_id: UUID, + keys: Sequence[Tuple[str, str]], + ) -> Set[Tuple[str, str]]: + if not keys: + return set() + async with self.engine.session() as session: + stmt = ( + select(RecordDBE.session_id, RecordDBE.turn_id) + .where( + RecordDBE.project_id == project_id, + RecordDBE.record_type == TERMINAL_RECORD_TYPE, + RecordDBE.deleted_at.is_(None), + RecordDBE.quarantined_at.is_(None), + func.coalesce( + RecordDBE.attributes[RECORD_SETTLED_BY_ATTRIBUTE].astext, + "", + ) + != SETTLED_BY_WATCHDOG, + func.coalesce(RecordDBE.attributes["stopReason"].astext, "").notin_( + ("paused", "cancelled", "error") + ), + tuple_(RecordDBE.session_id, RecordDBE.turn_id).in_(keys), + ) + .distinct() + ) + rows = (await session.execute(stmt)).all() + return {(row.session_id, row.turn_id) for row in rows} + async def get_event( self, *, diff --git a/api/oss/src/dbs/postgres/sessions/streams/dao.py b/api/oss/src/dbs/postgres/sessions/streams/dao.py index 399db927ad3..1a8983a6a59 100644 --- a/api/oss/src/dbs/postgres/sessions/streams/dao.py +++ b/api/oss/src/dbs/postgres/sessions/streams/dao.py @@ -538,6 +538,7 @@ async def update( SessionExecutionDBE.project_id == project_id, SessionExecutionDBE.session_id == session_id, SessionExecutionDBE.execution_id == stream.expected_turn_id, + SessionExecutionDBE.terminal_outcome.is_not(None), ) .exists() ) @@ -561,7 +562,11 @@ async def update( SessionStreamDBE.flags.contains( {"is_alive": True, "is_running": True} ), - ~terminal_execution_exists, + # Final idle beats must persist after settlement; active beats + # must not revive an execution that has already ended. + (~terminal_execution_exists) + if stream.flags is None or stream.flags.is_running + else True, ) .values(**values) .returning(SessionStreamDBE) diff --git a/api/oss/src/dbs/redis/sessions/contract.py b/api/oss/src/dbs/redis/sessions/contract.py index ce308a292ca..ac25129fe59 100644 --- a/api/oss/src/dbs/redis/sessions/contract.py +++ b/api/oss/src/dbs/redis/sessions/contract.py @@ -29,6 +29,8 @@ The nest: alive ⊇ running ⊇ attached. attached ⟹ running ⟹ alive. """ +from typing import Any, Dict, List, Optional + from oss.src.utils.env import env # --------------------------------------------------------------------------- @@ -130,7 +132,8 @@ def make_displacement_payload(*, by: str) -> dict: # Payload shapes: # {"type": "records-changed", "session_id": s} # {"type": "lifecycle", "session_id": s, "state": "running"|"ended"} -# {"type": "interaction", "session_id": s, "status": "pending"|"resolved"} +# {"type": "interaction", "session_id": s, "status": "pending"|"resolved", +# "interactions": [...]?} # {"type": "-changed", "entity": entity, "id": id} # --------------------------------------------------------------------------- @@ -169,8 +172,17 @@ def make_watch_lifecycle_payload(*, session_id: str, state: str) -> dict: return {"type": WATCH_EVENT_LIFECYCLE, "session_id": session_id, "state": state} -def make_watch_interaction_payload(*, session_id: str, status: str) -> dict: - return {"type": WATCH_EVENT_INTERACTION, "session_id": session_id, "status": status} +def make_watch_interaction_payload( + *, session_id: str, status: str, interactions: Optional[List[Dict[str, Any]]] = None +) -> dict: + payload = { + "type": WATCH_EVENT_INTERACTION, + "session_id": session_id, + "status": status, + } + if interactions is not None: + payload["interactions"] = interactions + return payload def make_watch_entity_changed_payload(*, entity: str, id: str) -> dict: diff --git a/api/oss/src/dbs/redis/sessions/locks.py b/api/oss/src/dbs/redis/sessions/locks.py index a3bc900d26b..e6cc347bda6 100644 --- a/api/oss/src/dbs/redis/sessions/locks.py +++ b/api/oss/src/dbs/redis/sessions/locks.py @@ -5,9 +5,14 @@ every key name, TTL, and wire shape. """ +import asyncio import json -from typing import List, Optional, Tuple +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import AsyncIterator, List, Optional, Tuple +from uuid import uuid4 +from oss.src.utils.logging import get_module_logger from oss.src.dbs.redis.shared.engine import LockEngine from oss.src.dbs.redis.sessions.contract import ( ALIVE_TTL_SECONDS, @@ -35,12 +40,103 @@ validate_session_id, # noqa: F401 — re-exported for callers that import from locks ) +log = get_module_logger(__name__) + +_RENEW_IF_OWNER_LUA = """ +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('expire', KEYS[1], ARGV[2]) +end +return 0 +""" + + +class SessionHeartbeatGuardLost(RuntimeError): + pass + + +@dataclass +class SessionHeartbeatGuardLease: + session_id: str + lost: bool = False + + def ensure_held(self) -> None: + if self.lost: + raise SessionHeartbeatGuardLost( + f"heartbeat guard lease was lost for session {self.session_id}" + ) + # --------------------------------------------------------------------------- # Alive lock — global run lock (at most one in-flight run per session) # --------------------------------------------------------------------------- +@asynccontextmanager +async def session_heartbeat_guard( + engine: LockEngine, + *, + project_id: str, + session_id: str, + lease_seconds: int = 30, + renewal_seconds: float = 10.0, + wait_seconds: float = 5.0, +) -> AsyncIterator[SessionHeartbeatGuardLease]: + """Serialize heartbeat ownership changes with watchdog fencing for one session.""" + key = f"heartbeat-guard:{project_id}:session:{session_id}" + token = str(uuid4()).encode() + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_seconds + while await engine.set(key, token, nx=True, ex=lease_seconds) is None: + if asyncio.get_running_loop().time() >= deadline: + raise TimeoutError(f"heartbeat guard timed out for session {session_id}") + await asyncio.sleep(0.01) + + lease = SessionHeartbeatGuardLease(session_id=session_id) + renewed_at = loop.time() + + async def renew() -> None: + nonlocal renewed_at + while True: + await asyncio.sleep(renewal_seconds) + try: + renewed = await engine.eval( + _RENEW_IF_OWNER_LUA, + 1, + key.encode(), + token, + str(lease_seconds).encode(), + ) + except Exception: + log.warning( + "heartbeat guard renewal failed; retrying before lease expiry", + session_id=session_id, + exc_info=True, + ) + if loop.time() - renewed_at >= lease_seconds: + lease.lost = True + return + continue + if renewed != 1: + lease.lost = True + return + renewed_at = loop.time() + + renewal = asyncio.create_task(renew()) + try: + yield lease + finally: + renewal.cancel() + await asyncio.gather(renewal, return_exceptions=True) + try: + await engine.eval(RELEASE_IF_OWNER_LUA, 1, key.encode(), token) + except Exception: + log.warning( + "heartbeat guard release failed; lease will expire", + session_id=session_id, + exc_info=True, + ) + + async def acquire_alive( engine: LockEngine, *, diff --git a/api/oss/src/dbs/redis/sessions/watch.py b/api/oss/src/dbs/redis/sessions/watch.py index c978e57f312..bf695fc589b 100644 --- a/api/oss/src/dbs/redis/sessions/watch.py +++ b/api/oss/src/dbs/redis/sessions/watch.py @@ -11,7 +11,7 @@ import asyncio import json -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional from oss.src.dbs.redis.sessions.contract import ( make_watch_entity_changed_payload, @@ -96,12 +96,15 @@ async def interaction( project_id: str, session_id: str, status: str, + interactions: Optional[List[Dict[str, Any]]] = None, ) -> None: await self._publish( channel=watch_channel(project_id, session_id), project_id=project_id, payload=make_watch_interaction_payload( - session_id=session_id, status=status + session_id=session_id, + status=status, + interactions=interactions, ), ) diff --git a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py index 30e5f1b1bcb..97c9acd6e3a 100644 --- a/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py +++ b/api/oss/src/tasks/asyncio/sessions/interactions_dispatcher.py @@ -9,14 +9,22 @@ ``session-identity.ts`` ``approvalDecisionForToolCall``). The client payload stays minimal: ``{approved: bool, tool_call_id?, message?}``. -Every other interaction kind keeps the original passthrough contract -(``data.inputs = answer``). +Agent ``client_tool`` answers replay the same conversation with their structured output or +error as the tool result. Other interaction kinds retain ``data.inputs = answer``. The resume also carries the gated turn's own config when the runner stamped one on the row (``data.parameters``): sending it inline suppresses reference hydration in the SDK resolver, so the run continues under the config the gate was raised against rather than the referenced variant's HEAD revision. A row written before that field existed has none, and the body is byte-identical to the references-only one this dispatcher has always sent. + +``references`` are not decoration on this request: they are how the invoke finds a service to +call at all (``WorkflowsService._ensure_request_revision`` resolves them into +``data.revision``, and ``_get_service_url`` reads the URL off it). A gate row whose +``data.references`` is empty therefore produces an invoke with no service URL, which fails +``Workflow revision has no runnable service URL.`` on every redelivery. The same identity is +also recorded on the session's turn and stream rows, so this dispatcher falls back to those +before giving up. """ from typing import Any, Callable, Dict, List, Optional @@ -27,9 +35,15 @@ SessionInteractionData, SessionInteractionKind, ) +from oss.src.core.sessions.interactions.references import ( + keyed_references as keyed_references, + resolve_interaction_references, +) from oss.src.core.sessions.records.dtos import SessionRecord from oss.src.core.sessions.records.service import RecordsService from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.streams.service import SessionStreamsService +from oss.src.core.sessions.turns.service import SessionTurnsService from oss.src.core.workflows.dtos import ( WorkflowServiceRequest, WorkflowServiceRequestData, @@ -219,10 +233,29 @@ def _gated_call_shape( return {"name": request.tool, "args": request.args} +def _is_agent_interaction_answer(interaction: SessionInteraction, answer: Any) -> bool: + if not isinstance(answer, dict): + return False + return ( + interaction.kind == SessionInteractionKind.user_approval + and isinstance(answer.get("approved"), bool) + ) or ( + interaction.kind == SessionInteractionKind.client_tool + and answer.get("outcome") in ("completed", "error") + ) + + def compose_approval_messages( records: List[SessionRecord], interaction: SessionInteraction, answer: Dict[str, Any], +) -> List[Dict[str, Any]]: + return compose_approval_messages_many(records, [(interaction, answer)]) + + +def compose_approval_messages_many( + records: List[SessionRecord], + interaction_answers: List[tuple[SessionInteraction, Dict[str, Any]]], ) -> List[Dict[str, Any]]: """The full resume conversation: replayed history + the approval envelope. @@ -242,58 +275,61 @@ def compose_approval_messages( denial (#5444). The note is still persisted as a user record either way. """ messages = build_wire_messages(records) - gated_id = resolve_gated_tool_call_id(records, interaction, answer) - - gated_call = next( - ( - block - for message in messages - if isinstance(message.get("content"), list) - for block in message["content"] - if block.get("type") == "tool_call" and block.get("toolCallId") == gated_id - ), - None, - ) - has_gated_call = gated_call is not None - shape = _gated_call_shape(records, interaction) - if not has_gated_call: - # No durable tool_call record (e.g. records unavailable): synthesize the anchor the - # runner's call-shape index needs to bind the envelope to name+args. - block = {"type": "tool_call", "toolCallId": gated_id} - if shape.get("name"): - block["toolName"] = shape["name"] - if shape.get("args") is not None: - block["input"] = shape["args"] - messages.append({"role": "assistant", "content": [block]}) - - envelope = { - "type": "tool_result", - "toolCallId": gated_id, - "output": { - "approved": bool(answer.get("approved")), - "interactionToken": interaction.token, - }, - } - # The runner renders the resume nudge as "Call again with the same arguments" and - # matches stale-vs-live approvals by name. An unnamed envelope renders the literal word - # "tool", which names nothing the model can call — it then narrates a fabricated execution - # instead of re-issuing the call. - gated_name = (gated_call or {}).get("toolName") or shape.get("name") - if gated_name: - envelope["toolName"] = gated_name - tail = messages[-1] if messages else None - if ( - tail is not None - and tail.get("role") == "assistant" - and isinstance(tail.get("content"), list) - ): - tail["content"].append(envelope) - else: - messages.append({"role": "assistant", "content": [envelope]}) - - note = answer.get("message") - if isinstance(note, str) and note.strip(): - messages.append({"role": "user", "content": note}) + notes: List[str] = [] + for interaction, answer in interaction_answers: + gated_id = resolve_gated_tool_call_id(records, interaction, answer) + gated_call = next( + ( + block + for message in messages + if isinstance(message.get("content"), list) + for block in message["content"] + if block.get("type") == "tool_call" + and block.get("toolCallId") == gated_id + ), + None, + ) + shape = _gated_call_shape(records, interaction) + if gated_call is None: + # No durable tool_call record (e.g. records unavailable): synthesize the anchor the + # runner's call-shape index needs to bind the envelope to name+args. + gated_call = {"type": "tool_call", "toolCallId": gated_id} + if shape.get("name"): + gated_call["toolName"] = shape["name"] + if shape.get("args") is not None: + gated_call["input"] = shape["args"] + messages.append({"role": "assistant", "content": [gated_call]}) + + envelope: Dict[str, Any] = {"type": "tool_result", "toolCallId": gated_id} + if interaction.kind == SessionInteractionKind.client_tool: + is_error = answer.get("outcome") == "error" + envelope["output"] = ( + answer.get("error") if is_error else answer.get("output", {}) + ) + envelope["isError"] = is_error + else: + envelope["output"] = { + "approved": bool(answer.get("approved")), + "interactionToken": interaction.token, + } + gated_name = gated_call.get("toolName") or shape.get("name") + if gated_name: + envelope["toolName"] = gated_name + tail = messages[-1] if messages else None + if ( + tail is not None + and tail.get("role") == "assistant" + and isinstance(tail.get("content"), list) + ): + tail["content"].append(envelope) + else: + messages.append({"role": "assistant", "content": [envelope]}) + + note = answer.get("message") + if isinstance(note, str) and note.strip(): + notes.append(note) + + messages.extend({"role": "user", "content": note} for note in notes) return messages @@ -307,11 +343,17 @@ def __init__( workflows_service: WorkflowsService, interactions_service: SessionInteractionsService, records_service: Optional[RecordsService] = None, + turns_service: Optional[SessionTurnsService] = None, + streams_service: Optional[SessionStreamsService] = None, dispatch_fn: Optional[Callable] = None, ) -> None: self.workflows_service = workflows_service self.interactions_service = interactions_service self.records_service = records_service + # Read-only, for the resume's reference fallback: the identity a session recorded on its + # turn and stream rows when the gate row carries none. + self.turns_service = turns_service + self.streams_service = streams_service self._dispatch_fn = dispatch_fn async def _compose_inputs( @@ -321,11 +363,7 @@ async def _compose_inputs( interaction: SessionInteraction, answer: Any, ) -> Dict[str, Any]: - if ( - interaction.kind == SessionInteractionKind.user_approval - and isinstance(answer, dict) - and isinstance(answer.get("approved"), bool) - ): + if _is_agent_interaction_answer(interaction, answer): records: List[SessionRecord] = [] if self.records_service is not None: try: @@ -350,26 +388,68 @@ async def respond( # interaction_id: UUID, answer: Any, + control_command_id: Optional[UUID] = None, + continuation_execution_id: Optional[str] = None, ) -> None: - interaction = await self.interactions_service.fetch_interaction( + await self.respond_many( project_id=project_id, - interaction_id=interaction_id, + user_id=user_id, + interaction_answers=[(interaction_id, answer)], + control_command_id=control_command_id, + continuation_execution_id=continuation_execution_id, ) + async def respond_many( + self, + *, + project_id: UUID, + user_id: UUID, + interaction_answers: List[tuple[UUID, Any]], + control_command_id: Optional[UUID] = None, + continuation_execution_id: Optional[str] = None, + ) -> None: + resolved = [ + ( + await self.interactions_service.fetch_interaction( + project_id=project_id, + interaction_id=interaction_id, + ), + answer, + ) + for interaction_id, answer in interaction_answers + ] + interaction, first_answer = resolved[0] + data: Optional[SessionInteractionData] = interaction.data - references = ( - {k: v.model_dump(mode="json") for k, v in data.references.items()} - if data and data.references - else None + references = await resolve_interaction_references( + project_id=project_id, + interaction=interaction, + turns_service=self.turns_service, + streams_service=self.streams_service, ) selector = ( data.selector.model_dump(mode="json") if data and data.selector else None ) - inputs = await self._compose_inputs( - project_id=project_id, - interaction=interaction, - answer=answer, - ) + if all(_is_agent_interaction_answer(item, answer) for item, answer in resolved): + records: List[SessionRecord] = [] + if self.records_service is not None: + try: + records = await self.records_service.get_records( + project_id=project_id, + session_id=interaction.session_id, + ) + except Exception as e: # degrade to synthesized-anchor replay + log.warning( + "[interactions] records replay unavailable for " + f"session={interaction.session_id}: {e}" + ) + inputs = {"messages": compose_approval_messages_many(records, resolved)} + else: + inputs = await self._compose_inputs( + project_id=project_id, + interaction=interaction, + answer=first_answer, + ) # The effective config the gated turn ran under, when the runner stamped one. Sending it # INLINE is what makes the resume correct: the resolver decides hydration purely from # what the caller sent (`_caller_supplied_configuration`), so inline parameters suppress @@ -384,14 +464,22 @@ async def respond( data=WorkflowServiceRequestData(inputs=inputs, parameters=parameters), session_id=interaction.session_id, ) + if control_command_id is not None: + invoke_request.meta = { + **(invoke_request.meta or {}), + "control_command_id": str(control_command_id), + } if self._dispatch_fn is not None: # Detached path: hand off to the runner, return immediately. - await self._dispatch_fn( - project_id=project_id, - user_id=user_id, - request=invoke_request, - ) + kwargs = { + "project_id": project_id, + "user_id": user_id, + "request": invoke_request, + } + if continuation_execution_id is not None: + kwargs["run_id"] = continuation_execution_id + await self._dispatch_fn(**kwargs) return await self.workflows_service.invoke_workflow( diff --git a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py index 72d2e531516..e80a7cbb6ed 100644 --- a/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py +++ b/api/oss/src/tasks/asyncio/sessions/orphan_sweep.py @@ -309,6 +309,68 @@ async def _mark_endings_written( ) +async def _reconcile_completed_executions( + *, + commands_service: Optional[Any], + candidates: Sequence[Tuple[UUID, str, str]], +) -> Set[Tuple[UUID, str, str]]: + """Return persisted endings whose execution could not be terminalized yet.""" + if commands_service is None: + return set(candidates) + failed: Set[Tuple[UUID, str, str]] = set() + for project_id, session_id, turn_id in candidates: + try: + reconciled = await commands_service.settle_execution_completed( + project_id=project_id, + session_id=session_id, + execution_id=turn_id, + ) + except Exception: + reconciled = False + log.warning( + "watchdog: failed to settle a completed continuation execution", + project_id=str(project_id), + session_id=session_id, + turn_id=turn_id, + exc_info=True, + ) + if not reconciled: + failed.add((project_id, session_id, turn_id)) + return failed + + +async def _runner_completed_executions( + *, + records_service: RecordsService, + candidates: Sequence[Tuple[UUID, str, str]], +) -> Tuple[Set[Tuple[UUID, str, str]], Set[Tuple[UUID, str, str]]]: + by_project: Dict[UUID, List[Tuple[str, str]]] = {} + for project_id, session_id, turn_id in candidates: + by_project.setdefault(project_id, []).append((session_id, turn_id)) + completed: Set[Tuple[UUID, str, str]] = set() + failed: Set[Tuple[UUID, str, str]] = set() + for project_id, keys in by_project.items(): + try: + matches = await records_service.runner_completed_turns( + project_id=project_id, + keys=keys, + ) + except Exception: + log.warning( + "watchdog: runner-completion lookup failed", + project_id=str(project_id), + exc_info=True, + ) + failed.update( + (project_id, session_id, turn_id) for session_id, turn_id in keys + ) + continue + completed.update( + (project_id, session_id, turn_id) for session_id, turn_id in matches + ) + return completed, failed + + async def _settle_abandoned_commands( commands_service: Optional[Any], now: datetime, @@ -472,6 +534,32 @@ async def run_orphan_sweep( written_at=now_utc, ) + # A runner can persist `done` and die before the post-append execution settlement. + # Reconcile that durable proof before clearing its stale heartbeat; otherwise the next + # Send would see a recoverable continuation and replay already-completed work. + completion_failures: Set[Tuple[UUID, str, str]] = set() + if ( + records_service is not None + and commands_service is not None + and (env.agenta.sessions.durable_approvals or env.agenta.sessions.queue) + ): + runner_completed, completion_failures = await _runner_completed_executions( + records_service=records_service, + candidates=claimed, + ) + completion_failures.update( + await _reconcile_completed_executions( + commands_service=commands_service, + candidates=sorted(runner_completed, key=lambda t: t[1]), + ) + ) + if completion_failures: + orphan_rows = [ + row + for row in orphan_rows + if (row[1], row[2], str(row[3])) not in completion_failures + ] + if not orphan_rows and not unsettled: # No stale row and nothing owed an ending, but a command can still be abandoned: # its execution may have ended normally between the claim and the report. @@ -531,12 +619,12 @@ async def run_orphan_sweep( if turn_id is not None else SessionStreamDBE.turn_id.is_(None) ), - ( - SessionStreamDBE.updated_at == observed_updated_at - if observed_updated_at is not None - else SessionStreamDBE.updated_at.is_(None) - ), ] + conditions.append( + SessionStreamDBE.updated_at == observed_updated_at + if observed_updated_at is not None + else SessionStreamDBE.updated_at.is_(None) + ) result = await session.execute( sa_update(SessionStreamDBE) .where(*conditions) diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 7006b9ca525..a812f962ba2 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -703,6 +703,11 @@ class SessionsConfig(BaseModel): """Agenta sessions sub-namespace.""" durable_stop: bool = _sessions_durable_stop_enabled() + durable_approvals: bool = ( + os.getenv("AGENTA_SESSIONS_DURABLE_APPROVALS") or "true" + ).lower() in _TRUTHY + queue: bool = (os.getenv("AGENTA_SESSIONS_QUEUE") or "true").lower() in _TRUTHY + steer: bool = (os.getenv("AGENTA_SESSIONS_STEER") or "true").lower() in _TRUTHY late_output: Literal["quarantine", "reject"] = _parse_sessions_late_output() attachments: SessionAttachmentsConfig = SessionAttachmentsConfig() commands: SessionsCommandsConfig = SessionsCommandsConfig() @@ -1558,7 +1563,7 @@ class SessionsRedisConfig(BaseModel): """ sequence_writes: bool = ( - os.getenv("AGENTA_SESSIONS_SEQUENCE_WRITES") or "false" + os.getenv("AGENTA_SESSIONS_SEQUENCE_WRITES") or "true" ).lower() in _TRUTHY alive_ttl_seconds: int = ( _parse_optional_positive_int_env("AGENTA_SESSIONS_REDIS_ALIVE_TTL_SECONDS") @@ -1601,7 +1606,7 @@ class SessionsRedisConfig(BaseModel): or 900 ) shared_reader: bool = ( - os.getenv("AGENTA_SESSIONS_SHARED_READER") or "false" + os.getenv("AGENTA_SESSIONS_SHARED_READER") or "true" ).lower() in _TRUTHY live_auth_recheck_seconds: int = ( _parse_optional_positive_int_env("AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS") diff --git a/api/oss/tests/pytest/unit/sessions/test_command_claim_unknown_kind.py b/api/oss/tests/pytest/unit/sessions/test_command_claim_unknown_kind.py index 849d0237290..f8abed29453 100644 --- a/api/oss/tests/pytest/unit/sessions/test_command_claim_unknown_kind.py +++ b/api/oss/tests/pytest/unit/sessions/test_command_claim_unknown_kind.py @@ -62,7 +62,7 @@ def test_a_claimable_stop_survives_an_unknown_kind_in_the_batch(monkeypatch): monkeypatch.setattr(commands_dao, "log", recorder) stop = _row(SessionCommandKind.cancel.value) - unknown = _row("continue_interaction") + unknown = _row("future_command") mapped = commands_dao._map_commands_skipping_unmappable( [stop, unknown], context="claimed" @@ -78,7 +78,7 @@ def test_the_claim_warning_names_the_claimed_context_and_the_kind(monkeypatch): monkeypatch.setattr(commands_dao, "log", recorder) commands_dao._map_commands_skipping_unmappable( - [_row(SessionCommandKind.cancel.value), _row("continue_interaction")], + [_row(SessionCommandKind.cancel.value), _row("future_command")], context="claimed", ) @@ -86,4 +86,4 @@ def test_the_claim_warning_names_the_claimed_context_and_the_kind(monkeypatch): args = recorder.warnings[0][0] assert args[1] == 1 # one unmappable row assert args[2] == "claimed" # the batch context - assert "continue_interaction=1" in args[3] + assert "future_command=1" in args[3] diff --git a/api/oss/tests/pytest/unit/sessions/test_command_settle_unknown_kind.py b/api/oss/tests/pytest/unit/sessions/test_command_settle_unknown_kind.py index 3bc171e3134..ac1135098ef 100644 --- a/api/oss/tests/pytest/unit/sessions/test_command_settle_unknown_kind.py +++ b/api/oss/tests/pytest/unit/sessions/test_command_settle_unknown_kind.py @@ -1,12 +1,9 @@ """The watchdog must settle the commands it understands past a row it cannot map. A newer API replica can write a command `kind` (or state, or outcome) an older replica's -enums do not know. On the integration stack a `continue_interaction` row (increment 6, not on -this head) sat in the claimed table next to an abandoned Stop. The abandoned-command sweep -mapped the whole batch to DTOs before it settled any of it, and `map_command_dbe_to_dto` -raised `ValueError: 'continue_interaction' is not a valid SessionCommandKind` on that one row. -The ValueError escaped the batch, so NO command was settled and the Stop stayed pending pass -after pass. +enums do not know. The abandoned-command sweep used to map the whole batch to DTOs before it +settled any of it, so a `ValueError` on one future command kind escaped the batch. No command +was settled and a known Stop could stay pending pass after pass. `_map_commands_skipping_unmappable` now skips the rows this API cannot map, warns once with the kinds and count, and returns the rest. These tests hold that contract: the known Stop survives as a @@ -66,7 +63,7 @@ def test_a_known_stop_survives_and_an_unknown_kind_is_left_alone(monkeypatch): monkeypatch.setattr(commands_dao, "log", recorder) stop = _row(SessionCommandKind.cancel.value) - unknown = _row("continue_interaction") + unknown = _row("future_command") mapped = commands_dao._map_commands_skipping_unmappable( [stop, unknown], context="abandoned" @@ -85,8 +82,8 @@ def test_the_unknown_kind_is_warned_once_with_its_kind_and_count(monkeypatch): rows = [ _row(SessionCommandKind.cancel.value), - _row("continue_interaction"), - _row("continue_interaction"), + _row("future_command"), + _row("future_command"), ] commands_dao._map_commands_skipping_unmappable(rows, context="abandoned") @@ -96,7 +93,7 @@ def test_the_unknown_kind_is_warned_once_with_its_kind_and_count(monkeypatch): # The message and its args name the count, the batch context, and the offending kind. assert args[1] == 2 # two unmappable rows assert args[2] == "abandoned" # the batch context - assert "continue_interaction=2" in args[3] + assert "future_command=2" in args[3] def test_an_all_mappable_batch_logs_nothing(monkeypatch): diff --git a/api/oss/tests/pytest/unit/sessions/test_durable_events.py b/api/oss/tests/pytest/unit/sessions/test_durable_events.py index ee638e58b8d..5c486cabffd 100644 --- a/api/oss/tests/pytest/unit/sessions/test_durable_events.py +++ b/api/oss/tests/pytest/unit/sessions/test_durable_events.py @@ -113,6 +113,31 @@ def test_maps_interaction_records_to_durable_lifecycle_events(): assert events[1].payload.kind == "user_approval" +def test_invalid_open_wire_strings_do_not_poison_durable_event_projection(): + records = [ + _record( + sequence=1, + record_type="interaction_request", + attributes={"id": "interaction-1", "kind": {"invalid": True}}, + ), + _record( + sequence=2, + record_type="message", + attributes={"id": "message-1", "text": "bad", "finish_reason": 42}, + ), + _record( + sequence=3, + record_type="message", + attributes={"id": "message-2", "text": "kept", "finish_reason": "stop"}, + ), + ] + + events = durable_events_from_records(records) + + assert [event.entity_id for event in events] == ["message-2"] + assert events[0].payload.finish_reason == "stop" + + 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. @@ -146,3 +171,20 @@ def test_non_dict_payload_reads_as_absent_instead_of_raising(): "execution.started", ] assert [event.sequence for event in events] == [1, 2] + + +def test_maps_runner_done_records_to_terminal_events(): + for reason in (None, "paused", "cancelled"): + attributes = {"type": "done"} + if reason is not None: + attributes["stopReason"] = reason + record = _record(sequence=7, record_type="done", attributes=attributes) + events = durable_events_from_records([record]) + assert len(events) == 1 + event = events[0] + assert event.type == "execution.stopped" + assert event.execution_id == record.turn_id + assert event.sequence == 7 + assert event.watermark == 7 + assert event.payload.stopped_at == record.created_at + assert event.payload.reason == (reason or "completed") diff --git a/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py b/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py index 7fd1bca0521..d45b5bbdeaa 100644 --- a/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py +++ b/api/oss/tests/pytest/unit/sessions/test_heartbeat_lock_races.py @@ -7,6 +7,7 @@ than by a comment. """ +from contextlib import asynccontextmanager from typing import Optional from unittest.mock import AsyncMock, patch from uuid import uuid4 @@ -20,12 +21,15 @@ ) from oss.src.core.sessions.streams.service import SessionStreamsService from oss.src.dbs.redis.sessions.locks import ( + SessionHeartbeatGuardLost, force_clear_owner, get_alive_owner, get_owner, get_running_owner, is_turn_superseded, + session_heartbeat_guard, ) +from oss.src.dbs.redis.sessions.contract import RELEASE_IF_OWNER_LUA from unit.sessions.test_heartbeat_parked_zombie import _FakeStreamsDAO from unit.sessions.test_project_scoped_locks import _FakeRedis @@ -90,6 +94,66 @@ async def _superseded(lock_engine, turn: str) -> bool: # --------------------------------------------------------------------------- # +@pytest.mark.asyncio +async def test_heartbeat_guard_release_failure_does_not_mask_the_body(lock_engine): + redis = lock_engine._client() + original_eval = redis.eval + + async def fail_release(script, numkeys, *keys_and_args): + if script == RELEASE_IF_OWNER_LUA: + raise ConnectionError("redis unavailable") + return await original_eval(script, numkeys, *keys_and_args) + + with ( + patch.object(redis, "eval", new=fail_release), + patch("oss.src.dbs.redis.sessions.locks.log.warning") as warning, + ): + async with session_heartbeat_guard( + lock_engine, + project_id=str(_PROJECT), + session_id=_SESSION, + ): + result = "committed" + + assert result == "committed" + warning.assert_called_once_with( + "heartbeat guard release failed; lease will expire", + session_id=_SESSION, + exc_info=True, + ) + + +@pytest.mark.asyncio +async def test_heartbeat_returns_committed_result_when_guard_lease_is_lost(lock_engine): + dao = _FakeStreamsDAO() + svc = _service(lock_engine, dao) + + class _LostGuard: + def ensure_held(self): + raise SessionHeartbeatGuardLost("lease expired") + + @asynccontextmanager + async def _lost_guard(*_args, **_kwargs): + yield _LostGuard() + + with ( + patch( + "oss.src.core.sessions.streams.service.session_heartbeat_guard", + new=_lost_guard, + ), + patch("oss.src.core.sessions.streams.service.log.warning") as warning, + ): + result = await svc.heartbeat(project_id=_PROJECT, request=_beat("turn-a")) + + assert result.is_current_turn is True + assert dao.row is result.stream + assert dao.row is not None and dao.row.turn_id == "turn-a" + warning.assert_called_once_with( + "sessions: heartbeat guard lease lost after heartbeat committed", + session_id=_SESSION, + ) + + @pytest.mark.asyncio async def test_a_dead_turns_beat_does_not_reclaim_replica_affinity(lock_engine): """`claim_owner` never steals, so an owner key that keeps getting renewed locks the diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_cancel_records.py b/api/oss/tests/pytest/unit/sessions/test_interaction_cancel_records.py index 743d734d4b5..c26bc866aba 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interaction_cancel_records.py +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_cancel_records.py @@ -19,9 +19,10 @@ def __init__(self, journal): self.journal = journal self.calls = [] - async def interaction(self, *, project_id, session_id, status): + async def interaction(self, *, project_id, session_id, status, interactions=None): self.journal.append("publish") self.calls.append((project_id, session_id, status)) + self.pushed = interactions class _RecordingRecordsService: diff --git a/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py new file mode 100644 index 00000000000..da43b2a83d1 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_interaction_continuation_admission.py @@ -0,0 +1,1497 @@ +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from time import monotonic +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from oss.src.core.sessions.commands.dtos import ( + SessionCommand, + SessionCommandKind, + SessionCommandOutcome, + SessionCommandState, +) +from oss.src.core.sessions.commands.interfaces import DeliveryReceipt +from oss.src.core.sessions.commands.service import SessionCommandsService +from oss.src.core.sessions.commands.types import IdempotencyKeyReused +from oss.src.utils.env import env +from oss.src.core.sessions.executions.dtos import ( + SessionExecutionSettlement, + SessionExecutionSettlementResult, + SessionExecutionState, +) +from oss.src.core.sessions.interactions.dtos import ( + SessionInteraction, + SessionInteractionData, + SessionInteractionKind, + SessionInteractionStatus, +) +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputState + + +class _Commands: + def __init__(self): + self.command = None + self.abandoned = [] + self.resumable = True + + @asynccontextmanager + async def transaction(self): + yield object() + + async def fetch_by_idempotency_key(self, **kwargs): + if self.command and self.command.idempotency_key == kwargs["idempotency_key"]: + return self.command + return None + + async def fetch_command(self, **kwargs): + return self.command + + async def create_command(self, *, user_id, command, transaction=None, **kwargs): + self.command = SessionCommand( + id=uuid4(), + project_id=command.project_id, + session_id=command.session_id, + kind=command.kind, + target_turn_id=command.target_turn_id, + expected_turn_id=command.expected_turn_id, + data=command.data, + state=command.state, + idempotency_key=command.idempotency_key, + created_at=datetime.now(timezone.utc), + ) + return self.command + + async def record_delivery_attempt(self, **kwargs): + return self.command + + async def claim_for_delivery(self, **kwargs): + return self.command + + async def fetch_resumable_continuation(self, **kwargs): + if ( + self.resumable + and self.command + and ( + self.command.state + in ( + SessionCommandState.pending, + SessionCommandState.claimed, + ) + or ( + self.command.state == SessionCommandState.obsolete + and self.command.outcome + in (SessionCommandOutcome.lost, SessionCommandOutcome.failed) + ) + or ( + self.command.state == SessionCommandState.applied + and self.command.outcome == SessionCommandOutcome.started + ) + ) + ): + return self.command + return None + + async def reopen_continuation(self, **kwargs): + self.command = self.command.model_copy( + update={ + "target_turn_id": kwargs["replacement_turn_id"], + "state": SessionCommandState.pending, + "outcome": None, + "claimed_by": None, + "settled_at": None, + "claim_count": 0, + } + ) + return self.command + + async def expire_claims(self, **kwargs): + return self.abandoned + + async def settle_command(self, *, settle, **kwargs): + if self.command is None or self.command.state not in settle.expected_states: + return None + self.command = self.command.model_copy( + update={ + "state": settle.state, + "outcome": settle.outcome, + **({"claimed_by": settle.replica_id} if settle.replica_id else {}), + } + ) + return self.command + + +@pytest.fixture(autouse=True) +def _durable_approvals_enabled(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + + +class _Interactions: + def __init__(self, interaction): + self.interactions = [interaction] + self.published = [] + + @property + def interaction(self): + return self.interactions[0] + + @interaction.setter + def interaction(self, value): + self.interactions[0] = value + + async def fetch_interaction(self, *, interaction_id, **kwargs): + return next(item for item in self.interactions if item.id == interaction_id) + + async def fetch_turn_interactions(self, **kwargs): + return self.interactions + + async def transition_interaction(self, *, transition, **kwargs): + index = next( + index + for index, item in enumerate(self.interactions) + if item.token == transition.token + ) + interaction = self.interactions[index] + data = interaction.data or SessionInteractionData() + self.interactions[index] = interaction.model_copy( + update={ + "status": transition.status, + "data": data.model_copy(update={"resolution": transition.resolution}), + } + ) + return self.interactions[index] + + async def publish_interaction_responded(self, **kwargs): + self.published.append(kwargs) + + +class _Executions: + def __init__(self, *, project_id, session_id, source_id): + self.source = SessionExecutionSettlement( + project_id=project_id, + session_id=session_id, + execution_id=source_id, + state=SessionExecutionState.active, + ) + self.continuation = SessionExecutionSettlement( + project_id=project_id, + session_id=session_id, + execution_id="continuation-1", + state=SessionExecutionState.recoverable, + source_interaction_id=uuid4(), + ) + self.states = [] + + async def fetch_execution(self, **kwargs): + if kwargs["execution_id"] == self.source.execution_id: + return self.source + if kwargs["execution_id"] == self.continuation.execution_id: + return self.continuation + return None + + async def lock_for_control(self, **kwargs): + execution = await self.fetch_execution(**kwargs) + assert execution is not None + return execution + + async def settle(self, **kwargs): + current = ( + self.source + if kwargs["execution_id"] == self.source.execution_id + else self.continuation + ) + if current.terminal_outcome is not None: + return SessionExecutionSettlementResult(settlement=current, won=False) + settled = current.model_copy( + update={ + "state": SessionExecutionState.terminal, + "terminal_outcome": kwargs["terminal_outcome"], + "settled_by": kwargs["settled_by"], + "settled_at": datetime.now(timezone.utc), + } + ) + if current is self.source: + self.source = settled + else: + self.continuation = settled + return SessionExecutionSettlementResult(settlement=settled, won=True) + + async def create_continuation(self, **kwargs): + self.continuation = SessionExecutionSettlement( + project_id=kwargs["project_id"], + session_id=kwargs["session_id"], + execution_id=kwargs["execution_id"], + state=SessionExecutionState.pending_delivery, + parent_execution_id=kwargs["parent_execution_id"], + source_interaction_id=kwargs["source_interaction_id"], + ) + return self.continuation + + async def set_state(self, **kwargs): + self.states.append((kwargs["execution_id"], kwargs["state"], kwargs["error"])) + current = ( + self.source + if kwargs["execution_id"] == self.source.execution_id + else self.continuation + ) + expected = kwargs.get("expected_states") + if expected is not None and current.state not in expected: + return None + updated = current.model_copy( + update={"state": kwargs["state"], "error": kwargs["error"]} + ) + if current is self.source: + self.source = updated + else: + self.continuation = updated + return updated + + +class _Unreachable: + def __init__(self): + self.delivered = [] + + async def deliver(self, **kwargs): + self.delivered.append(kwargs["command"]) + return DeliveryReceipt(status="unreachable", detail="runner unavailable") + + async def acknowledge(self, **kwargs): + return None + + +class _Inputs: + def __init__(self, items): + self.items = items + + async def promote_next( + self, *, execution_id, input_id=None, only_policy=None, **kwargs + ): + item = next( + ( + item + for item in self.items + if item.state == PendingInputState.pending + and (input_id is None or item.id == input_id) + and (only_policy is None or item.policy == only_policy) + ), + None, + ) + if item is None: + return None + promoted = item.model_copy( + update={ + "state": PendingInputState.promoted, + "promoted_execution_id": execution_id, + } + ) + self.items[self.items.index(item)] = promoted + return promoted + + +@pytest.mark.asyncio +async def test_delivery_failure_keeps_answer_and_continuation_recoverable(): + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + commands = _Commands() + interactions = _Interactions(interaction) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=interactions, + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + admission = await service.respond_interaction( + project_id=project_id, + user_id=user_id, + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="source-1", + idempotency_key="response-1", + ) + + assert admission.interaction.status == SessionInteractionStatus.responded + assert admission.execution_state == SessionExecutionState.recoverable + assert commands.command.data == { + "interaction_id": str(interaction_id), + "interaction_ids": [str(interaction_id)], + "continuation_execution_id": admission.execution_id, + } + assert delivery.delivered[0].data["answer"] == {"approved": True} + assert executions.source.terminal_outcome == "continued" + assert executions.states[-1][1] == SessionExecutionState.recoverable + assert interactions.published[0]["interactions"][0].data.resolution == { + "approved": True + } + + commands.command = commands.command.model_copy( + update={"target_turn_id": "continuation-retry"} + ) + retry = await service.respond_interaction( + project_id=project_id, + user_id=user_id, + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="source-1", + idempotency_key="response-1", + ) + assert retry.command.id == admission.command.id + assert retry.execution_id == "continuation-retry" + + with pytest.raises(IdempotencyKeyReused): + await service.respond_interaction( + project_id=project_id, + user_id=user_id, + interaction_id=interaction_id, + answer={"approved": False}, + expected_execution_id="source-1", + idempotency_key="response-1", + ) + + +@pytest.mark.asyncio +async def test_parallel_answers_wait_then_share_one_continuation(): + project_id = uuid4() + first_id = uuid4() + second_id = uuid4() + interactions = _Interactions( + SessionInteraction( + id=first_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + ) + interactions.interactions.append( + SessionInteraction( + id=second_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-2", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + ) + commands = _Commands() + delivery = _Unreachable() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=interactions, + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + first = await service.respond_interaction( + project_id=project_id, + user_id=uuid4(), + interaction_id=first_id, + answer={"approved": True}, + expected_execution_id="source-1", + idempotency_key="response-1", + ) + + assert first.command is None + assert first.waiting_for_interactions is True + assert interactions.interactions[0].status == SessionInteractionStatus.responded + assert interactions.interactions[1].status == SessionInteractionStatus.pending + assert executions.source.terminal_outcome is None + assert delivery.delivered == [] + + second = await service.respond_interaction( + project_id=project_id, + user_id=uuid4(), + interaction_id=second_id, + answer={"approved": False}, + expected_execution_id="source-1", + idempotency_key="response-2", + ) + + assert second.command is not None + assert executions.source.terminal_outcome == "continued" + assert len(delivery.delivered) == 1 + assert delivery.delivered[0].data["answers"] == [ + {"interaction_id": str(first_id), "answer": {"approved": True}}, + {"interaction_id": str(second_id), "answer": {"approved": False}}, + ] + + retry = await service.respond_interaction( + project_id=project_id, + user_id=uuid4(), + interaction_id=first_id, + answer={"approved": True}, + expected_execution_id="source-1", + idempotency_key="response-1-retry", + ) + + assert retry.interaction.id == first_id + assert retry.command is None + assert retry.execution_state == SessionExecutionState.terminal + assert len(delivery.delivered) == 1 + + +@pytest.mark.asyncio +async def test_approve_all_commits_one_continuation_for_the_batch(): + project_id = uuid4() + first_id = uuid4() + second_id = uuid4() + interactions = _Interactions( + SessionInteraction( + id=first_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + ) + interactions.interactions.append( + SessionInteraction( + id=second_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-2", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + ) + commands = _Commands() + delivery = _Unreachable() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=interactions, + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + admission = await service.respond_interactions( + project_id=project_id, + user_id=uuid4(), + interaction_answers=[ + (first_id, {"approved": True}), + (second_id, {"approved": True}), + ], + expected_execution_id="source-1", + idempotency_key="approve-all", + ) + + assert admission.command is not None + assert len(delivery.delivered) == 1 + assert { + item["interaction_id"] for item in delivery.delivered[0].data["answers"] + } == { + str(first_id), + str(second_id), + } + + +@pytest.mark.asyncio +async def test_post_commit_failures_do_not_reject_an_accepted_answer(): + project_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + commands = _Commands() + interactions = _Interactions(interaction) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + interactions.publish_interaction_responded = AsyncMock( + side_effect=RuntimeError("watch unavailable") + ) + commands.record_delivery_attempt = AsyncMock( + side_effect=RuntimeError("attempt write unavailable") + ) + executions.set_state = AsyncMock( + side_effect=RuntimeError("recoverable projection unavailable") + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=interactions, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + admission = await service.respond_interaction( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="source-1", + idempotency_key="response-1", + ) + + assert admission.interaction.status == SessionInteractionStatus.responded + assert admission.execution_state == SessionExecutionState.recoverable + + +def _continuation_command(project_id, interaction_id, *, claim_count=1): + return SessionCommand( + id=uuid4(), + project_id=project_id, + session_id="session-1", + kind=SessionCommandKind.continue_interaction, + target_turn_id="continuation-1", + expected_turn_id="source-1", + data={ + "interaction_id": str(interaction_id), + "continuation_execution_id": "continuation-1", + }, + state=SessionCommandState.pending, + claim_count=claim_count, + created_at=datetime.now(timezone.utc), + ) + + +@pytest.mark.asyncio +async def test_sweep_redelivers_continuation_without_a_heartbeat(): + project_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + commands.abandoned = [commands.command] + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions(interaction), + lock_engine=None, + delivery=delivery, + executions_dao=_Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ), + ) + + settled = await service.settle_abandoned_commands(now=datetime.now(timezone.utc)) + + assert settled == 0 + assert [item.id for item in delivery.delivered] == [commands.command.id] + + +@pytest.mark.asyncio +async def test_next_send_resumes_the_same_open_continuation(): + project_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions(interaction), + lock_engine=None, + delivery=delivery, + executions_dao=_Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ), + ) + + resumed = await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + + assert resumed == commands.command.target_turn_id + assert delivery.delivered[0].id == commands.command.id + assert delivery.delivered[0].target_turn_id == "continuation-1" + + +@pytest.mark.asyncio +async def test_exhausted_continuation_stays_recoverable(monkeypatch): + maximum = 2 + monkeypatch.setattr(env.agenta.sessions.commands, "max_deliveries", maximum) + project_id = uuid4() + interaction_id = uuid4() + command = _continuation_command(project_id, interaction_id, claim_count=maximum) + commands = _Commands() + commands.command = command + commands.abandoned = [command] + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + settled = await service.settle_abandoned_commands(now=datetime.now(timezone.utc)) + + assert settled == 1 + assert commands.command.state == SessionCommandState.obsolete + assert executions.states[-1][1] == SessionExecutionState.recoverable + + resumed = await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + assert resumed == commands.command.target_turn_id + assert commands.command.state == SessionCommandState.pending + assert delivery.delivered[-1].id == command.id + assert delivery.delivered[-1].target_turn_id != "continuation-1" + assert executions.continuation.execution_id == delivery.delivered[-1].target_turn_id + assert executions.continuation.parent_execution_id == "continuation-1" + assert executions.continuation.source_interaction_id is None + + +@pytest.mark.asyncio +async def test_only_the_winning_started_outcome_is_admitted(): + project_id = uuid4() + interaction_id = uuid4() + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + first = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-1", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + duplicate = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-2", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + + assert first.admitted is True + assert first.command.outcome == SessionCommandOutcome.started + assert duplicate.admitted is False + assert duplicate.command.id == first.command.id + + same_replica_retry = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-1", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + assert same_replica_retry.admitted is False + + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.recoverable} + ) + recovered = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-1", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + concurrent_retry = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-1", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + assert recovered.admitted is True + assert concurrent_retry.admitted is False + + +@pytest.mark.asyncio +async def test_parked_running_continuation_does_not_own_send_preflight(): + project_id = uuid4() + interaction_id = uuid4() + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id).model_copy( + update={ + "state": SessionCommandState.applied, + "outcome": SessionCommandOutcome.started, + "claimed_by": "runner-1", + } + ) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.running} + ) + commands.resumable = False + delivery = _Unreachable() + streams = SimpleNamespace( + fetch_header=AsyncMock( + return_value=SimpleNamespace( + turn_id="continuation-1", + updated_at=datetime.now(timezone.utc), + flags=SimpleNamespace(is_alive=True), + ) + ) + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=streams, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + assert not await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + assert delivery.delivered == [] + assert commands.command.state == SessionCommandState.applied + + +@pytest.mark.asyncio +async def test_stale_heartbeat_never_replays_an_admitted_continuation(): + project_id = uuid4() + interaction_id = uuid4() + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id).model_copy( + update={ + "state": SessionCommandState.applied, + "outcome": SessionCommandOutcome.started, + "claimed_by": "runner-1", + } + ) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.running} + ) + delivery = _Unreachable() + streams = SimpleNamespace( + fetch_header=AsyncMock( + return_value=SimpleNamespace( + turn_id="continuation-1", + updated_at=datetime.now(timezone.utc) - timedelta(minutes=5), + flags=SimpleNamespace(is_alive=True), + ) + ) + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=streams, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + assert await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + assert commands.command.state == SessionCommandState.applied + assert commands.command.claimed_by == "runner-1" + assert executions.continuation.state == SessionExecutionState.running + assert delivery.delivered == [] + + +@pytest.mark.asyncio +async def test_stop_winner_blocks_continuation_admission(): + project_id = uuid4() + interaction_id = uuid4() + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.stopping} + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + report = await service.report_outcome( + command_id=commands.command.id, + replica_id="runner-1", + result="applied", + execution_id="continuation-1", + execution_state="started", + ) + + assert report.admitted is False + assert commands.command.state == SessionCommandState.pending + assert executions.continuation.state == SessionExecutionState.stopping + + +@pytest.mark.asyncio +async def test_watchdog_keeps_lost_continuation_recoverable(): + project_id = uuid4() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.running} + ) + service = SessionCommandsService( + commands_dao=_Commands(), + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + assert not await service.settle_execution_lost( + project_id=project_id, + session_id="session-1", + execution_id="continuation-1", + settled_at=datetime.now(timezone.utc), + ) + assert executions.continuation.state == SessionExecutionState.recoverable + assert executions.continuation.terminal_outcome is None + + +@pytest.mark.asyncio +async def test_watchdog_does_not_recover_continuation_when_approvals_are_disabled( + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) + project_id = uuid4() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.running} + ) + service = SessionCommandsService( + commands_dao=_Commands(), + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + assert await service.settle_execution_lost( + project_id=project_id, + session_id="session-1", + execution_id="continuation-1", + settled_at=datetime.now(timezone.utc), + ) + assert executions.continuation.state == SessionExecutionState.terminal + assert executions.continuation.terminal_outcome == SessionCommandOutcome.lost.value + + +@pytest.mark.asyncio +async def test_persisted_completion_terminalizes_continuation_before_recovery(): + project_id = uuid4() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + executions.continuation = executions.continuation.model_copy( + update={"state": SessionExecutionState.running} + ) + service = SessionCommandsService( + commands_dao=_Commands(), + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + ) + + assert await service.settle_execution_completed( + project_id=project_id, + session_id="session-1", + execution_id="continuation-1", + ) + assert executions.continuation.state == SessionExecutionState.terminal + assert executions.continuation.terminal_outcome == "completed" + assert executions.continuation.settled_by == "runner" + + +@pytest.mark.asyncio +async def test_completion_promotes_exactly_one_pending_input_once(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + user_id = uuid4() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + items = _Inputs( + [ + PendingInput( + id=uuid4(), + project_id=project_id, + session_id="session-1", + content={"session_id": "session-1", "data": {"messages": ["one"]}}, + position=1, + state=PendingInputState.pending, + policy="queue", + idempotency_key="queue-1", + request_fingerprint="a" * 64, + created_by_id=user_id, + ), + PendingInput( + id=uuid4(), + project_id=project_id, + session_id="session-1", + content={"session_id": "session-1", "data": {"messages": ["two"]}}, + position=2, + state=PendingInputState.pending, + policy="queue", + idempotency_key="queue-2", + request_fingerprint="b" * 64, + created_by_id=user_id, + ), + ] + ) + commands = _Commands() + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=delivery, + executions_dao=executions, + inputs_dao=items, + ) + service._reconcile_stopped_redis = AsyncMock() + + assert await service.settle_execution_completed( + project_id=project_id, + session_id="session-1", + execution_id="source-1", + ) + assert items.items[0].state == PendingInputState.promoted + assert items.items[1].state == PendingInputState.pending + assert commands.command.kind == SessionCommandKind.continue_input + assert commands.command.data["request"]["meta"]["promoted_input_id"] == str( + items.items[0].id + ) + assert len(delivery.delivered) == 1 + + assert await service.settle_execution_completed( + project_id=project_id, + session_id="session-1", + execution_id="source-1", + ) + assert items.items[1].state == PendingInputState.pending + assert len(delivery.delivered) == 1 + + +@pytest.mark.asyncio +async def test_done_record_admits_promoted_input_within_one_second(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + source_reconciled = False + admitted_at = None + + class _ReleaseAwareDelivery: + async def deliver(self, **kwargs): + nonlocal admitted_at + if not source_reconciled: + return DeliveryReceipt( + status="unreachable", detail="source execution still owns running" + ) + admitted_at = monotonic() + return DeliveryReceipt(status="accepted", replica_id="runner-1") + + async def acknowledge(self, **kwargs): + return None + + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + items = _Inputs([_pending_input(project_id, policy="queue", position=1)]) + service = SessionCommandsService( + commands_dao=_Commands(), + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=_ReleaseAwareDelivery(), + executions_dao=executions, + inputs_dao=items, + ) + + async def reconcile_source(**kwargs): + nonlocal source_reconciled + source_reconciled = True + + service._reconcile_stopped_redis = AsyncMock(side_effect=reconcile_source) + done_record_at = monotonic() + + assert await service.settle_execution_completed( + project_id=project_id, + session_id="session-1", + execution_id="source-1", + ) + + assert items.items[0].state == PendingInputState.promoted + service._reconcile_stopped_redis.assert_awaited_once_with( + project_id=project_id, + session_id="session-1", + execution_id="source-1", + ) + assert admitted_at is not None + assert admitted_at - done_record_at < 1 + + +@pytest.mark.asyncio +async def test_completion_handles_a_lost_input_delivery_reservation(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + items = _Inputs([_pending_input(project_id, policy="queue", position=1)]) + commands = _Commands() + commands.record_delivery_attempt = AsyncMock(return_value=None) + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=None, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=executions, + inputs_dao=items, + ) + service._reconcile_stopped_redis = AsyncMock() + + assert await service.settle_execution_completed( + project_id=project_id, + session_id="session-1", + execution_id="source-1", + ) + assert items.items[0].state == PendingInputState.promoted + assert executions.continuation.state == SessionExecutionState.recoverable + + +@pytest.mark.asyncio +async def test_recovery_hooks_are_disabled_with_durable_approvals(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) + project_id = uuid4() + interaction_id = uuid4() + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + commands.abandoned = [commands.command] + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions( + SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + ), + lock_engine=None, + delivery=delivery, + executions_dao=_Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ), + ) + + assert ( + await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + is None + ) + assert await service.settle_abandoned_commands(now=datetime.now(timezone.utc)) == 0 + assert delivery.delivered == [] + + +class _StartedThenUnreachable: + """The runner admits the continuation and reports `started`, then the transport fails. + + The real shape of it (browser pass, 2026-09-04 17:28Z-17:35Z): the runner posted its + outcome, the API turned the execution `running`, and only afterwards did the detached-start + parser reject the stream. Both executions ran to completion carrying + `error.code = continuation_delivery_failed`, so the card offered a retry for work that was + already done. + """ + + def __init__(self, executions, execution_id): + self._executions = executions + self._execution_id = execution_id + self.delivered = [] + + async def deliver(self, **kwargs): + command = kwargs["command"] + self.delivered.append(command) + await self._executions.set_state( + project_id=command.project_id, + session_id=command.session_id, + execution_id=self._execution_id, + state=SessionExecutionState.running, + error=None, + ) + return DeliveryReceipt( + status="unreachable", detail="parser rejected the stream" + ) + + async def acknowledge(self, **kwargs): + return None + + +@pytest.mark.asyncio +async def test_a_late_delivery_failure_does_not_demote_a_running_continuation(): + project_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + commands = _Commands() + commands.command = _continuation_command(project_id, interaction_id) + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + delivery = _StartedThenUnreachable(executions, "continuation-1") + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions(interaction), + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + resumed = await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + + assert resumed == "continuation-1" + assert delivery.delivered + # The turn the runner is already running keeps `running`. The recoverable projection is + # refused, so the card never asks the user to retry work that is under way. + assert executions.continuation.state == SessionExecutionState.running + assert executions.continuation.error is None + + +@pytest.mark.asyncio +async def test_a_send_after_the_budget_is_spent_reopens_the_continuation(monkeypatch): + """Command 01a06d7a of the same pass: three refusals, then a command nothing redelivers. + + The budget bounds the automatic loop only. A Send arriving before the sweep settles the + command must not be swallowed: settle it exhausted, retarget a fresh execution, deliver. + """ + maximum = 3 + monkeypatch.setattr(env.agenta.sessions.commands, "max_deliveries", maximum) + project_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + data=SessionInteractionData(resolution={"approved": True}), + ) + commands = _Commands() + commands.command = _continuation_command( + project_id, interaction_id, claim_count=maximum + ) + spent = commands.command + executions = _Executions( + project_id=project_id, session_id="session-1", source_id="source-1" + ) + delivery = _Unreachable() + service = SessionCommandsService( + commands_dao=commands, + streams_service=None, + interactions_service=_Interactions(interaction), + lock_engine=None, + delivery=delivery, + executions_dao=executions, + ) + + resumed = await service.resume_recoverable_continuation( + project_id=project_id, session_id="session-1" + ) + + assert resumed == commands.command.target_turn_id + # The exhausted attempt is recorded as ended and the command now targets a NEW execution: + # redelivering the old id is what spent the budget in the first place. + assert commands.command.target_turn_id != spent.target_turn_id + assert commands.command.claim_count == 0 + assert delivery.delivered + assert delivery.delivered[0].target_turn_id == commands.command.target_turn_id + + +def _pending_input(project_id, *, policy, position): + return PendingInput( + id=uuid4(), + project_id=project_id, + session_id="session-1", + content={"session_id": "session-1", "data": {"messages": [policy]}}, + position=position, + state=PendingInputState.pending, + policy=policy, + idempotency_key=f"{policy}-{position}", + request_fingerprint=str(position) * 64, + created_by_id=uuid4(), + ) + + +def _stop_service(*, project_id, command_data, inputs): + commands = _Commands() + commands.command = SessionCommand( + id=uuid4(), + project_id=project_id, + session_id="session-1", + kind=SessionCommandKind.cancel, + target_turn_id="source-1", + data=command_data, + state=SessionCommandState.pending, + created_at=datetime.now(timezone.utc), + ) + streams = SimpleNamespace( + settle_command=AsyncMock(), + publish_session_ended=AsyncMock(), + ) + interactions = SimpleNamespace( + cancel_session_pending=AsyncMock(return_value=0), + publish_session_pending_cancelled=AsyncMock(), + ) + service = SessionCommandsService( + commands_dao=commands, + streams_service=streams, + interactions_service=interactions, + lock_engine=None, + delivery=_Unreachable(), + executions_dao=_Executions( + project_id=project_id, + session_id="session-1", + source_id="source-1", + ), + inputs_dao=inputs, + ) + service._reconcile_stopped_redis = AsyncMock() + return service, commands + + +@pytest.mark.asyncio +async def test_manual_stop_pauses_pending_steer(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + project_id = uuid4() + steered = _pending_input(project_id, policy="steer", position=0) + inputs = _Inputs([steered]) + service, commands = _stop_service( + project_id=project_id, + command_data=None, + inputs=inputs, + ) + + settled = await service.settle( + command_id=commands.command.id, + project_id=project_id, + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-1", + ) + + assert settled is not None + assert inputs.items[0].state == PendingInputState.pending + + +@pytest.mark.asyncio +async def test_steer_stop_promotes_its_saved_input_before_queue(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + project_id = uuid4() + queued = _pending_input(project_id, policy="queue", position=1) + steered = _pending_input(project_id, policy="steer", position=0) + inputs = _Inputs([queued, steered]) + service, commands = _stop_service( + project_id=project_id, + command_data={"steer_input_id": str(steered.id)}, + inputs=inputs, + ) + + settled = await service.settle( + command_id=commands.command.id, + project_id=project_id, + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-1", + ) + + assert settled is not None + assert inputs.items[0].state == PendingInputState.pending + assert inputs.items[1].state == PendingInputState.promoted + assert commands.command.kind == SessionCommandKind.continue_input + assert commands.command.data["input_id"] == str(steered.id) + + +@pytest.mark.asyncio +async def test_steer_handles_a_lost_input_delivery_reservation(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + project_id = uuid4() + steered = _pending_input(project_id, policy="steer", position=0) + inputs = _Inputs([steered]) + service, commands = _stop_service( + project_id=project_id, + command_data={"steer_input_id": str(steered.id)}, + inputs=inputs, + ) + commands.record_delivery_attempt = AsyncMock(return_value=None) + + settled = await service.settle( + command_id=commands.command.id, + project_id=project_id, + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-1", + ) + + assert settled is not None + assert inputs.items[0].state == PendingInputState.promoted + assert service._executions.continuation.state == SessionExecutionState.recoverable diff --git a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py index 5cd89e69b41..bde88224897 100644 --- a/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py +++ b/api/oss/tests/pytest/unit/sessions/test_interactions_dispatcher.py @@ -6,6 +6,8 @@ from unittest.mock import AsyncMock, MagicMock +import pytest + from oss.src.apis.fastapi.sessions.models import SessionInteractionCreateRequest from oss.src.core.sessions.interactions.dtos import SessionInteractionKind from oss.src.core.sessions.records.dtos import SessionRecord @@ -13,6 +15,7 @@ from oss.src.tasks.asyncio.sessions.interactions_dispatcher import ( InteractionsDispatcher, build_wire_messages, + compose_approval_messages_many, ) @@ -226,6 +229,42 @@ async def test_respond_detached_calls_dispatch_fn_not_invoke(): # --------------------------------------------------------------------------- +def test_parallel_approval_answers_share_one_resume_conversation(): + project_id = uuid4() + first = _make_interaction( + kind=SessionInteractionKind.user_approval, + request={"tool": "bash", "tool_call_id": "tc-1"}, + ) + second = _make_interaction( + kind=SessionInteractionKind.user_approval, + request={"tool": "write_file", "tool_call_id": "tc-2"}, + ) + second = second.model_copy(update={"token": "tok-def"}) + records = [ + *_approval_records(project_id), + *_approval_records(project_id, token="tok-def", tool_call_id="tc-2")[1:], + ] + + messages = compose_approval_messages_many( + records, + [(first, {"approved": True}), (second, {"approved": False})], + ) + + results = [ + block + for message in messages + if isinstance(message.get("content"), list) + for block in message["content"] + if block.get("type") == "tool_result" + and isinstance(block.get("output"), dict) + and block["output"].get("interactionToken") in {"tok-abc", "tok-def"} + ] + assert [(item["toolCallId"], item["output"]["approved"]) for item in results] == [ + ("tc-1", True), + ("tc-2", False), + ] + + async def test_approval_respond_composes_resume_messages_from_records(): """The dispatched inputs must be a replayable conversation ending in the {approved, interactionToken} tool_result the runner's decision map reads, @@ -726,3 +765,296 @@ def test_a_user_record_with_neither_text_nor_attachments_is_skipped(): ] assert build_wire_messages(records) == [] + + +# --------------------------------------------------------------------------- +# The resume's reference fallback. +# +# A resume is a server-side invoke, and the invoke resolves its service URL from the request's +# references. A gate row with no `data.references` produced a request with none, so +# `WorkflowsService._prepare_invoke` returned no URL and every redelivery failed with +# "Workflow revision has no runnable service URL." The same identity is recorded on the +# session's turn and stream rows, so the resume reads it from there. +# --------------------------------------------------------------------------- + + +def _session_turn(project_id, *, references, session_id="sess-test-1"): + from agenta.sdk.agents.dtos import HarnessKind + from oss.src.core.sessions.turns.dtos import SessionTurn + + return SessionTurn( + id=uuid4(), + project_id=project_id, + session_id=session_id, + turn_id=uuid4(), + stream_id=uuid4(), + turn_index=0, + harness_kind=HarnessKind.PI, + references=references, + ) + + +def _session_stream(project_id, *, references, session_id="sess-test-1"): + from oss.src.core.sessions.streams.dtos import SessionStream + + return SessionStream( + id=uuid4(), + project_id=project_id, + session_id=session_id, + references=references, + ) + + +def _turns_service(turns): + service = MagicMock() + service.query_turns = AsyncMock(return_value=turns) + return service + + +def _streams_service(stream): + service = MagicMock() + service.fetch_header = AsyncMock(return_value=stream) + return service + + +async def test_resume_falls_back_to_the_turn_references_when_the_gate_row_has_none(): + from oss.src.core.sessions.types import SessionReference + + interaction = _make_interaction(with_refs=False) + project_id = uuid4() + + interactions_service = MagicMock() + interactions_service.fetch_interaction = AsyncMock(return_value=interaction) + + workflows_service = MagicMock() + workflows_service.invoke_workflow = AsyncMock(return_value=SimpleNamespace()) + + turns_service = _turns_service( + [ + _session_turn( + project_id, + references=[ + SessionReference(key="workflow", id=uuid4(), slug="wf-1"), + SessionReference( + key="workflow_variant", id=uuid4(), slug="wf-1.default" + ), + ], + ), + ] + ) + + worker = InteractionsDispatcher( + workflows_service=workflows_service, + interactions_service=interactions_service, + turns_service=turns_service, + ) + + await worker.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + invoke_request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert set(invoke_request.references or {}) == {"workflow", "workflow_variant"} + assert invoke_request.references["workflow"].slug == "wf-1" + # `key` names the family in the stored list; it is not a field of a wire Reference. + assert not hasattr(invoke_request.references["workflow"], "key") + turn_query = turns_service.query_turns.await_args.kwargs + assert turn_query["query"].session_id == interaction.session_id + assert turn_query["windowing"].limit == 1 + + +async def test_resume_falls_back_to_the_stream_references_when_no_turn_carries_any(): + from oss.src.core.sessions.types import SessionReference + + interaction = _make_interaction(with_refs=False) + project_id = uuid4() + + interactions_service = MagicMock() + interactions_service.fetch_interaction = AsyncMock(return_value=interaction) + + workflows_service = MagicMock() + workflows_service.invoke_workflow = AsyncMock(return_value=SimpleNamespace()) + + worker = InteractionsDispatcher( + workflows_service=workflows_service, + interactions_service=interactions_service, + turns_service=_turns_service([_session_turn(project_id, references=None)]), + streams_service=_streams_service( + _session_stream( + project_id, + references=[SessionReference(key="workflow", id=uuid4(), slug="wf-2")], + ) + ), + ) + + await worker.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + invoke_request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert set(invoke_request.references) == {"workflow"} + assert invoke_request.references["workflow"].slug == "wf-2" + + +async def test_the_gate_rows_own_references_win_over_the_session_fallback(): + from oss.src.core.sessions.types import SessionReference + + interaction = _make_interaction(with_refs=True) + project_id = uuid4() + + interactions_service = MagicMock() + interactions_service.fetch_interaction = AsyncMock(return_value=interaction) + + workflows_service = MagicMock() + workflows_service.invoke_workflow = AsyncMock(return_value=SimpleNamespace()) + + turns_service = _turns_service( + [ + _session_turn( + project_id, + references=[SessionReference(key="workflow", id=uuid4(), slug="other")], + ) + ] + ) + + worker = InteractionsDispatcher( + workflows_service=workflows_service, + interactions_service=interactions_service, + turns_service=turns_service, + ) + + await worker.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + invoke_request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert set(invoke_request.references) == {"workflow"} + assert invoke_request.references["workflow"].slug == "wf-1" + turns_service.query_turns.assert_not_awaited() + + +async def test_a_session_with_no_recorded_identity_still_sends_a_reference_less_request(): + interaction = _make_interaction(with_refs=False) + project_id = uuid4() + + interactions_service = MagicMock() + interactions_service.fetch_interaction = AsyncMock(return_value=interaction) + + workflows_service = MagicMock() + workflows_service.invoke_workflow = AsyncMock(return_value=SimpleNamespace()) + + worker = InteractionsDispatcher( + workflows_service=workflows_service, + interactions_service=interactions_service, + turns_service=_turns_service([]), + streams_service=_streams_service(None), + ) + + await worker.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer={"approved": True}, + ) + + invoke_request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert invoke_request.references is None + + +def test_keyed_references_drops_families_the_invoke_does_not_accept(): + from oss.src.core.sessions.types import SessionReference + from oss.src.tasks.asyncio.sessions.interactions_dispatcher import keyed_references + + assert ( + keyed_references( + [ + SessionReference(key="testset", slug="ts-1"), + SessionReference(key=None, slug="untyped"), + ] + ) + is None + ) + assert keyed_references([SessionReference(key="application", slug="app-1")]) == { + "application": {"slug": "app-1"} + } + + +@pytest.mark.parametrize("outcome", ["completed", "error"]) +async def test_client_tool_answer_replays_questionnaire_result(outcome): + project_id = uuid4() + interaction = _make_interaction( + kind=SessionInteractionKind.client_tool, + request={ + "tool": "__ag__request_input", + "tool_call_id": "form-call", + "args": {"title": "Choose"}, + }, + ) + records = [ + _record( + project_id, + source="user", + rtype="message", + attributes={"text": "ask a questionnaire"}, + ), + _record( + project_id, + rtype="tool_call", + attributes={ + "id": "form-call", + "name": "__ag__request_input", + "input": {"title": "Choose"}, + }, + index=1, + ), + ] + answer = { + "tool_call_id": "form-call", + "tool_name": "__ag__request_input", + "outcome": outcome, + } + result = {"action": "accept", "content": {"selected": "blue", "default": "UTC"}} + if outcome == "error": + answer["error"] = "Questionnaire could not be rendered" + else: + answer["output"] = result + dispatch_fn = AsyncMock() + dispatcher = _dispatcher_with(interaction, records, dispatch_fn) + execution_id = str(uuid4()) + await dispatcher.respond( + project_id=project_id, + user_id=uuid4(), + interaction_id=interaction.id, + answer=answer, + continuation_execution_id=execution_id, + ) + request = dispatch_fn.await_args.kwargs["request"] + assert dispatch_fn.await_args.kwargs["run_id"] == execution_id + messages = request.data.inputs["messages"] + assert messages[0] == {"role": "user", "content": "ask a questionnaire"} + assert messages[-1]["role"] == "assistant" + assert messages[-1]["content"][-1] == { + "type": "tool_result", + "toolCallId": "form-call", + "toolName": "__ag__request_input", + "output": answer["error"] if outcome == "error" else result, + "isError": outcome == "error", + } + assert ( + sum( + block.get("type") == "tool_call" + for m in messages + if isinstance(m.get("content"), list) + for block in m["content"] + ) + == 1 + ) diff --git a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py index b163fd60c6b..79dbc34f2b1 100644 --- a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py +++ b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine.py @@ -125,6 +125,18 @@ async def settle( ): key = (session_id, execution_id) if key in self.rows: + if self.rows[key].terminal_outcome is None: + self.rows[key] = self.rows[key].model_copy( + update={ + "state": "terminal", + "terminal_outcome": terminal_outcome, + "settled_by": settled_by, + "settled_at": settled_at or datetime.now(timezone.utc), + } + ) + return SessionExecutionSettlementResult( + settlement=self.rows[key], won=True + ) return SessionExecutionSettlementResult( settlement=self.rows[key], won=False ) @@ -139,6 +151,11 @@ async def settle( self.rows[key] = row return SessionExecutionSettlementResult(settlement=row, won=True) + async def fetch_execution(self, *, project_id, session_id, execution_id): + if self.raises: + raise RuntimeError("core database is unreachable") + return self.rows.get((session_id, execution_id)) + async def query_settled(self, *, project_id, keys): if self.raises: raise RuntimeError("core database is unreachable") @@ -338,16 +355,92 @@ async def test_execution_lookup_failure_appends_the_batch_unguarded(monkeypatch) assert _quarantined(dao) == [] -async def test_ingest_does_not_write_a_terminal_execution(monkeypatch): +async def test_runner_done_terminalizes_a_continuation_execution(monkeypatch): monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) executions = _ExecutionSettlements() + executions.rows[(_SESSION, _TURN)] = SessionExecutionSettlement( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + state="running", + source_interaction_id=uuid4(), + ) service = RecordsService(records_dao=_StubDAO(), executions_dao=executions) + await service.append_many(events=[_event("done")]) + + execution = executions.rows[(_SESSION, _TURN)] + assert execution.terminal_outcome == "completed" + assert execution.settled_by == "runner" + + +async def test_paused_or_quarantined_done_does_not_complete_a_continuation(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + executions = _ExecutionSettlements() + executions.rows[(_SESSION, _TURN)] = SessionExecutionSettlement( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + state="running", + source_interaction_id=uuid4(), + ) + service = RecordsService( + records_dao=_StubDAO(watchdog_settled={(_SESSION, _TURN)}), + executions_dao=executions, + ) + await service.append_many( - events=[_event("done", attributes={"type": "done", "stopReason": "cancelled"})] + events=[_event("done", attributes={"type": "done", "stopReason": "paused"})] + ) + assert executions.rows[(_SESSION, _TURN)].terminal_outcome is None + + await executions.settle( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + terminal_outcome="lost", + settled_by="watchdog", + ) + await service.append_many(events=[_event("done")]) + + assert executions.rows[(_SESSION, _TURN)].terminal_outcome == "lost" + assert _quarantined(service.records_dao)[-1].record_type == "done" + + +@pytest.mark.parametrize("stop_reason", ["cancelled", "error"]) +async def test_non_completing_done_does_not_claim_completion(monkeypatch, stop_reason): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + executions = _ExecutionSettlements() + executions.rows[(_SESSION, _TURN)] = SessionExecutionSettlement( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + state="running", + source_interaction_id=uuid4(), + ) + service = RecordsService(records_dao=_StubDAO(), executions_dao=executions) + + await service.append_many( + events=[_event("done", attributes={"type": "done", "stopReason": stop_reason})] + ) + assert executions.rows[(_SESSION, _TURN)].terminal_outcome is None + + if stop_reason == "error": + return + + result = await executions.settle( + project_id=_PROJECT, + session_id=_SESSION, + execution_id=_TURN, + terminal_outcome="stopped", + settled_by="runner", ) - assert executions.rows == {} + assert result.won is True + assert executions.rows[(_SESSION, _TURN)].terminal_outcome == "stopped" async def test_ingest_marks_the_runners_terminal_record_written(monkeypatch): diff --git a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py index 2d90c28626d..9b8a43aeefd 100644 --- a/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_late_record_quarantine_dao.py @@ -158,6 +158,32 @@ async def test_settled_by_narrows_the_answer_to_one_writer(): ) == {(session_id, watchdog_turn)} +@pytest.mark.parametrize("stop_reason", ["paused", "cancelled", "error"]) +async def test_runner_completion_excludes_non_success_terminal_reasons(stop_reason): + project_id, session_id = _ids() + turn_id = f"turn-{uuid.uuid4().hex[:8]}" + dao = RecordsDAO(engine=get_analytics_engine()) + + await dao.append_many( + events=[ + _event( + project_id, + session_id, + turn_id, + "done", + attributes={"type": "done", "stopReason": stop_reason}, + ) + ] + ) + + assert ( + await dao.runner_completed_turns( + project_id=project_id, keys=[(session_id, turn_id)] + ) + == set() + ) + + async def test_a_redelivery_keeps_the_first_quarantine_instant(): project_id, session_id = _ids() turn_id = f"turn-{uuid.uuid4().hex[:8]}" diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py index 7e7d62db863..e5970c42f9b 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_clears_redis.py @@ -85,6 +85,7 @@ async def execute(self, stmt): for row in self._rows: if row.id in ids: row.flags = dict(flags_val) + row.updated_at = datetime.now(timezone.utc) matched += 1 return _FakeResult([], rowcount=matched) return _FakeResult([]) diff --git a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py index 3d9d8558b20..a7824c83086 100644 --- a/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py +++ b/api/oss/tests/pytest/unit/sessions/test_orphan_sweep_thresholds.py @@ -16,6 +16,7 @@ from contextlib import asynccontextmanager from datetime import datetime, timezone, timedelta from typing import Optional +from uuid import UUID import pytest from sqlalchemy.sql import operators @@ -37,6 +38,7 @@ ORPHAN_THRESHOLD_SECONDS, run_orphan_sweep, ) +from oss.src.utils.env import env _PROJECT_ID = "proj-sweep-1" @@ -138,6 +140,9 @@ def __init__( self.flags = flags self.created_at = datetime.now(timezone.utc) - timedelta(days=1) self.updated_at = datetime.now(timezone.utc) - timedelta(seconds=age_seconds) + self.terminal_outcome = None + self.ending_written_at = None + self.settled_at = None class _FakeScalars: @@ -158,9 +163,10 @@ def scalars(self): class _FakePgSession: - def __init__(self, rows, before_update=None): + def __init__(self, rows, before_update=None, on_commit=None): self._rows = rows self._before_update = before_update + self._on_commit = on_commit async def execute(self, stmt): if isinstance(stmt, Update): @@ -181,17 +187,22 @@ async def execute(self, stmt): return _FakeResult(matched) async def commit(self): - pass + if self._on_commit is not None: + self._on_commit() class _FakeTransactionsEngine: def __init__(self, rows, before_update=None): self._rows = rows self._before_update = before_update + self.committed = False + + def _mark_committed(self): + self.committed = True @asynccontextmanager async def session(self): - yield _FakePgSession(self._rows, self._before_update) + yield _FakePgSession(self._rows, self._before_update, self._mark_committed) class _FakeRedis: @@ -245,6 +256,20 @@ def decode(value): return [released_alive, released_running, released_owner] +class _CommitObservingRedis(_FakeRedis): + def __init__(self, engine: _FakeTransactionsEngine): + super().__init__() + self.engine = engine + + async def eval(self, script, numkeys, key, expected, *args): + normalized = key.decode() if isinstance(key, bytes) else key + if normalized.startswith(("alive:", "running:", "owner:")): + assert self.engine.committed, ( + "watchdog released Redis before the row commit" + ) + return await super().eval(script, numkeys, key, expected, *args) + + def _swept(row: _FakeRow) -> bool: return row.flags == {"is_alive": False, "is_running": False, "is_attached": False} @@ -267,6 +292,37 @@ async def repair_terminal_redis(self): return 0 +class _CompletedRecords: + async def settled_turns(self, *, project_id, keys): + return set(keys) + + async def runner_completed_turns(self, *, project_id, keys): + return set(keys) + + +class _NoCompletedRecords(_CompletedRecords): + async def settled_turns(self, *, project_id, keys): + return set() + + async def runner_completed_turns(self, *, project_id, keys): + return set() + + +class _CompletionLookupFailure(_CompletedRecords): + async def runner_completed_turns(self, *, project_id, keys): + raise RuntimeError("records database unavailable") + + +class _CompletionCommands(_OrderedCommandsService): + def __init__(self, *, succeeds: bool) -> None: + super().__init__() + self.succeeds = succeeds + + async def settle_execution_completed(self, **kwargs): + self.calls.append(("completed", kwargs["execution_id"])) + return self.succeeds + + @pytest.mark.anyio async def test_redis_repair_runs_after_the_sweeps_main_work(anyio_backend): commands = _OrderedCommandsService() @@ -295,6 +351,157 @@ async def test_running_row_is_swept_at_the_short_threshold(anyio_backend): ) +@pytest.mark.anyio +async def test_heartbeat_during_lost_settlement_prevents_collapse( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + row = _FakeRow( + session_id="sess-settled-during-sweep", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="turn-lost", + ) + row.project_id = UUID("00000000-0000-4000-8000-000000000001") + + def advance_timestamp_only(): + row.updated_at = datetime.now(timezone.utc) + + async def publish(**_kwargs): + return False + + await run_orphan_sweep( + _FakeTransactionsEngine([row], before_update=advance_timestamp_only), + _FakeRedis(), + records_service=_NoCompletedRecords(), + publish=publish, + ) + + assert not _swept(row) + + +@pytest.mark.anyio +async def test_redis_release_happens_only_after_stream_collapse_commits( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + row = _FakeRow( + session_id="sess-commit-before-redis", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="turn-old", + ) + engine = _FakeTransactionsEngine([row]) + redis = _CommitObservingRedis(engine) + for prefix, value in ( + ("alive", b"turn-old"), + ("running", b"turn-old"), + ("owner", b"replica-old"), + ): + await redis.set(f"{prefix}:{_PROJECT_ID}:session:{row.session_id}", value) + + await run_orphan_sweep(engine, redis) + + assert engine.committed is True + assert _swept(row) + + +@pytest.mark.anyio +async def test_durable_sweep_clears_dead_affinity_when_alive_already_expired( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + row = _FakeRow( + session_id="sess-dead-affinity", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="turn-dead", + ) + redis = _FakeRedis() + owner_key = f"owner:{_PROJECT_ID}:session:{row.session_id}" + await redis.set(owner_key, b"replica-dead") + + await run_orphan_sweep(_FakeTransactionsEngine([row]), redis) + + assert _swept(row) + assert await redis.get(owner_key) is None + + +@pytest.mark.anyio +async def test_persisted_done_is_terminalized_before_stale_ownership_is_cleared( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr(env.agenta.sessions, "durable_stop", False) + row = _FakeRow( + session_id="sess-completed-continuation", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="continuation-1", + ) + commands = _CompletionCommands(succeeds=True) + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=_CompletedRecords(), + commands_service=commands, + ) + + assert ("completed", "continuation-1") in commands.calls + assert _swept(row) + + +@pytest.mark.anyio +async def test_completion_settlement_failure_keeps_ownership_blocking_replay( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + row = _FakeRow( + session_id="sess-completion-race", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="continuation-1", + ) + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=_CompletedRecords(), + commands_service=_CompletionCommands(succeeds=False), + ) + + assert not _swept(row) + + +@pytest.mark.anyio +async def test_completion_lookup_failure_keeps_ownership_blocking_replay( + anyio_backend, + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + row = _FakeRow( + session_id="sess-completion-lookup-race", + flags={"is_alive": True, "is_running": True, "is_attached": False}, + age_seconds=360, + turn_id="continuation-1", + ) + + await run_orphan_sweep( + _FakeTransactionsEngine([row]), + _FakeRedis(), + records_service=_CompletionLookupFailure(), + commands_service=_CompletionCommands(succeeds=True), + ) + + assert not _swept(row) + + @pytest.mark.anyio async def test_idle_row_survives_the_short_threshold(anyio_backend): """The regression: a turn parked awaiting approval stops beating but stays resumable.""" diff --git a/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py new file mode 100644 index 00000000000..45ea9dbfb21 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_pending_inputs_service.py @@ -0,0 +1,502 @@ +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from oss.src.core.sessions.interactions.dtos import SessionInteractionStatus +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputState +from oss.src.core.sessions.inputs.service import SessionInputsService +from oss.src.core.sessions.inputs.types import ( + SessionInputBusy, + SessionInputIdempotencyConflict, +) +from oss.src.utils.env import env + + +class MemoryInputsDAO: + def __init__(self): + self.items = [] + + @asynccontextmanager + async def transaction(self): + yield self + + async def fetch_by_idempotency_key(self, **kwargs): + return next( + ( + item + for item in self.items + if item.project_id == kwargs["project_id"] + and item.session_id == kwargs["session_id"] + and item.idempotency_key == kwargs["idempotency_key"] + ), + None, + ) + + async def create_input( + self, *, user_id, pending_input, prioritize=False, **_kwargs + ): + item = PendingInput( + id=uuid4(), + created_at=datetime.now(timezone.utc), + created_by_id=user_id, + position=(-1 if prioritize else len(self.items) + 1), + state=PendingInputState.pending, + **pending_input.model_dump(), + ) + self.items.append(item) + return item + + async def list_pending(self, *, project_id, session_id): + return sorted( + [ + item + for item in self.items + if item.project_id == project_id + and item.session_id == session_id + and item.state == PendingInputState.pending + ], + key=lambda item: item.position, + ) + + async def fetch_input(self, *, project_id, session_id, input_id): + return next( + ( + item + for item in self.items + if item.project_id == project_id + and item.session_id == session_id + and item.id == input_id + ), + None, + ) + + async def remove_pending(self, *, project_id, session_id, input_id, **_kwargs): + item = await self.fetch_input( + project_id=project_id, session_id=session_id, input_id=input_id + ) + if item is None or item.state != PendingInputState.pending: + return None + item.state = PendingInputState.removed + return item + + +class Streams: + def __init__(self, *, running=True): + self.running = running + + async def fetch_header(self, **_kwargs): + return SimpleNamespace( + flags=SimpleNamespace(is_running=self.running), turn_id="execution-1" + ) + + +@pytest.mark.asyncio +async def test_busy_queue_is_rejected_when_switch_is_off(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", False) + service = SessionInputsService( + inputs_dao=MemoryInputsDAO(), streams_service=Streams() + ) + + with pytest.raises(SessionInputBusy): + await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "later"}, + policy="queue", + idempotency_key="key-1", + ) + + +@pytest.mark.asyncio +async def test_busy_queue_is_durable_and_removable(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + user_id = uuid4() + dao = MemoryInputsDAO() + service = SessionInputsService(inputs_dao=dao, streams_service=Streams()) + + admitted = await service.admit( + project_id=project_id, + user_id=user_id, + session_id="session-1", + content={"message": "later"}, + policy="queue", + idempotency_key="key-1", + ) + + assert admitted.action == "pending" + assert admitted.input is not None + assert await service.list_pending( + project_id=project_id, session_id="session-1" + ) == [admitted.input] + removed = await service.remove( + project_id=project_id, + user_id=user_id, + session_id="session-1", + input_id=admitted.input.id, + ) + assert removed.state == PendingInputState.removed + assert ( + await service.list_pending(project_id=project_id, session_id="session-1") == [] + ) + + +@pytest.mark.asyncio +async def test_idle_input_executes_without_being_queued(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, streams_service=Streams(running=False) + ) + + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "now"}, + policy="queue", + idempotency_key="key-1", + ) + + assert admitted.action == "execute" + assert dao.items == [] + + +@pytest.mark.asyncio +async def test_detached_executing_continuation_keeps_input_in_the_queue(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + continuation_resumer = AsyncMock(return_value="continuation-1") + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, + streams_service=Streams(running=False), + continuation_resumer=continuation_resumer, + ) + + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "after the continuation"}, + policy="queue", + idempotency_key="key-1", + ) + + assert admitted.action == "pending" + assert admitted.input is not None + continuation_resumer.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_no_recoverable_continuation_keeps_idle_input_executable(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + continuation_resumer = AsyncMock(return_value=None) + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, + streams_service=Streams(running=False), + continuation_resumer=continuation_resumer, + ) + + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "normal idle input"}, + policy="queue", + idempotency_key="key-1", + ) + + assert admitted.action == "execute" + assert dao.items == [] + + +@pytest.mark.asyncio +async def test_queue_flag_off_keeps_the_idle_path_without_a_continuation_probe( + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "queue", False) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) + continuation_resumer = AsyncMock(return_value="continuation-1") + service = SessionInputsService( + inputs_dao=MemoryInputsDAO(), + streams_service=Streams(running=False), + continuation_resumer=continuation_resumer, + ) + + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "old path"}, + policy="queue", + idempotency_key="key-1", + ) + + assert admitted.action == "execute" + continuation_resumer.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_steer_targets_the_execution_reopened_by_continuation_resume(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + continuation_resumer = AsyncMock(return_value="continuation-2") + executions = SimpleNamespace( + lock_for_control=AsyncMock(return_value=SimpleNamespace(terminal_outcome=None)) + ) + service = SessionInputsService( + inputs_dao=MemoryInputsDAO(), + streams_service=Streams(running=False), + executions_dao=executions, + continuation_resumer=continuation_resumer, + ) + + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "steer the resumed continuation"}, + policy="steer", + idempotency_key="key-1", + ) + + assert admitted.execution_id == "continuation-2" + assert ( + executions.lock_for_control.await_args.kwargs["execution_id"] + == "continuation-2" + ) + + +@pytest.mark.asyncio +async def test_queue_idempotency_returns_same_input_and_rejects_conflicting_reuse( + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + service = SessionInputsService( + inputs_dao=MemoryInputsDAO(), streams_service=Streams() + ) + kwargs = { + "project_id": project_id, + "user_id": uuid4(), + "session_id": "session-1", + "content": {"message": "later"}, + "policy": "queue", + "idempotency_key": "key-1", + } + + first = await service.admit(**kwargs) + retry = await service.admit(**kwargs) + assert retry.input.id == first.input.id + + with pytest.raises(SessionInputIdempotencyConflict): + await service.admit(**{**kwargs, "content": {"message": "different"}}) + + +@pytest.mark.asyncio +async def test_idle_retry_returns_the_existing_promoted_input(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + project_id = uuid4() + dao = MemoryInputsDAO() + streams = Streams() + service = SessionInputsService(inputs_dao=dao, streams_service=streams) + kwargs = { + "project_id": project_id, + "user_id": uuid4(), + "session_id": "session-1", + "content": {"message": "once"}, + "policy": "queue", + "idempotency_key": "key-1", + } + + first = await service.admit(**kwargs) + first.input.state = PendingInputState.promoted + first.input.promoted_execution_id = "execution-2" + streams.running = False + + retry = await service.admit(**kwargs) + + assert retry.action == "pending" + assert retry.input.id == first.input.id + assert retry.execution_id == "execution-2" + with pytest.raises(SessionInputIdempotencyConflict): + await service.admit(**{**kwargs, "content": {"message": "different"}}) + + +@pytest.mark.asyncio +async def test_steer_is_saved_ahead_of_queued_input(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + project_id = uuid4() + dao = MemoryInputsDAO() + service = SessionInputsService(inputs_dao=dao, streams_service=Streams()) + + queued = await service.admit( + project_id=project_id, + user_id=uuid4(), + session_id="session-1", + content={"message": "later"}, + policy="queue", + idempotency_key="queue-1", + ) + steered = await service.admit( + project_id=project_id, + user_id=uuid4(), + session_id="session-1", + content={"message": "now"}, + policy="steer", + idempotency_key="steer-1", + ) + + pending = await service.list_pending(project_id=project_id, session_id="session-1") + assert [item.id for item in pending] == [steered.input.id, queued.input.id] + assert steered.execution_id == "execution-1" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("terminal_outcome", [None, "completed"]) +async def test_queue_waits_for_unanswered_approval_even_after_execution_settles( + monkeypatch, terminal_outcome +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + pending = SimpleNamespace(status=SessionInteractionStatus.pending) + interactions = SimpleNamespace( + fetch_turn_interactions=AsyncMock(return_value=[pending]) + ) + executions = SimpleNamespace( + lock_for_control=AsyncMock( + return_value=SimpleNamespace(terminal_outcome=terminal_outcome) + ) + ) + resumer = AsyncMock(return_value=None) + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, + streams_service=Streams(running=False), + executions_dao=executions, + interactions_dao=interactions, + continuation_resumer=resumer, + ) + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "after approval"}, + policy="queue", + idempotency_key="queued-1", + ) + assert admitted.action == "pending" + assert admitted.input == dao.items[0] + assert admitted.execution_id == "execution-1" + assert pending.status == SessionInteractionStatus.pending + resumer.assert_not_awaited() + if terminal_outcome: + assert ( + interactions.fetch_turn_interactions.await_args.kwargs["for_update"] is True + ) + + +@pytest.mark.asyncio +async def test_steer_can_replace_unanswered_approval(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + interactions = SimpleNamespace(fetch_turn_interactions=AsyncMock()) + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, + streams_service=Streams(running=False), + interactions_dao=interactions, + continuation_resumer=AsyncMock(return_value=None), + ) + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "replace this"}, + policy="steer", + idempotency_key="steer-1", + ) + assert admitted.action == "execute" + assert dao.items == [] + interactions.fetch_turn_interactions.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status", [SessionInteractionStatus.resolved, SessionInteractionStatus.cancelled] +) +async def test_idle_queue_does_not_wait_on_old_answered_approval(monkeypatch, status): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + interactions = SimpleNamespace( + fetch_turn_interactions=AsyncMock(return_value=[SimpleNamespace(status=status)]) + ) + service = SessionInputsService( + inputs_dao=MemoryInputsDAO(), + streams_service=Streams(running=False), + interactions_dao=interactions, + continuation_resumer=AsyncMock(return_value=None), + ) + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "now"}, + policy="queue", + idempotency_key="idle-1", + ) + assert admitted.action == "execute" + + +@pytest.mark.asyncio +async def test_approval_winning_queue_lock_keeps_input_behind_its_continuation( + monkeypatch, +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + interactions = SimpleNamespace( + fetch_turn_interactions=AsyncMock( + side_effect=[ + [SimpleNamespace(status=SessionInteractionStatus.pending)], + [SimpleNamespace(status=SessionInteractionStatus.responded)], + [SimpleNamespace(status=SessionInteractionStatus.responded)], + ] + ) + ) + executions = SimpleNamespace( + lock_for_control=AsyncMock( + side_effect=[ + SimpleNamespace(terminal_outcome="completed"), + SimpleNamespace(terminal_outcome=None), + ] + ) + ) + resumer = AsyncMock(return_value="approved-continuation") + dao = MemoryInputsDAO() + service = SessionInputsService( + inputs_dao=dao, + streams_service=Streams(running=False), + interactions_dao=interactions, + executions_dao=executions, + continuation_resumer=resumer, + ) + admitted = await service.admit( + project_id=uuid4(), + user_id=uuid4(), + session_id="session-1", + content={"message": "after the approved tool"}, + policy="queue", + idempotency_key="queue-approval-race", + ) + assert admitted.action == "pending" + assert admitted.execution_id == "approved-continuation" + assert len(dao.items) == 1 + resumer.assert_awaited_once() + assert ( + executions.lock_for_control.await_args.kwargs["execution_id"] + == "approved-continuation" + ) diff --git a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py index 2ee452d4b44..9e1ce473a84 100644 --- a/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py +++ b/api/oss/tests/pytest/unit/sessions/test_record_ingest_endpoint.py @@ -145,6 +145,135 @@ async def test_record_ingest_threads_turn_id_and_span_id(): assert event.span_id == span_id +async def test_terminal_continuation_settles_core_before_stream_acceptance(monkeypatch): + monkeypatch.setattr( + "oss.src.apis.fastapi.sessions.router.env.agenta.sessions.durable_approvals", + True, + ) + records_service = AsyncMock() + commands_service = AsyncMock() + router = RecordsRouter( + records_service=records_service, + commands_service=commands_service, + ) + project_id = uuid4() + request = _make_authed_request(FastAPI(), project_id, uuid4(), uuid4()) + body = SessionRecordIngestRequest( + session_id="session-1", + record_type="done", + record_source="agent", + turn_id="continuation-1", + attributes={"stopReason": "end_turn"}, + ) + order = [] + commands_service.settle_execution_completed.side_effect = lambda **_: order.append( + "settled" + ) + + async def publish(**kwargs): + order.append("published") + return True + + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_record", side_effect=publish + ), + ): + await router.ingest_record_event(request=request, body=body) + + assert order == ["settled", "published"] + commands_service.settle_execution_completed.assert_awaited_once_with( + project_id=project_id, + session_id="session-1", + execution_id="continuation-1", + ) + + +@pytest.mark.parametrize("stop_reason", ["cancelled", "error"]) +async def test_non_completing_terminal_does_not_settle_continuation_as_completed( + monkeypatch, stop_reason +): + monkeypatch.setattr( + "oss.src.apis.fastapi.sessions.router.env.agenta.sessions.durable_approvals", + True, + ) + commands_service = AsyncMock() + router = RecordsRouter( + records_service=AsyncMock(), + commands_service=commands_service, + ) + project_id = uuid4() + request = _make_authed_request(FastAPI(), project_id, uuid4(), uuid4()) + body = SessionRecordIngestRequest( + session_id="session-1", + record_type="done", + record_source="agent", + turn_id="continuation-1", + attributes={"stopReason": stop_reason}, + ) + + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_record", + new_callable=AsyncMock, + return_value=True, + ), + ): + await router.ingest_record_event(request=request, body=body) + + commands_service.settle_execution_completed.assert_not_awaited() + + +async def test_terminal_publish_failure_is_retryable_after_core_settlement(monkeypatch): + from fastapi import HTTPException + + monkeypatch.setattr( + "oss.src.apis.fastapi.sessions.router.env.agenta.sessions.durable_approvals", + True, + ) + commands_service = AsyncMock() + router = RecordsRouter( + records_service=AsyncMock(), + commands_service=commands_service, + ) + project_id = uuid4() + request = _make_authed_request(FastAPI(), project_id, uuid4(), uuid4()) + body = SessionRecordIngestRequest( + session_id="session-1", + record_type="done", + record_source="agent", + turn_id="continuation-1", + ) + + with ( + patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "oss.src.apis.fastapi.sessions.router.publish_record", + new_callable=AsyncMock, + return_value=False, + ), + pytest.raises(HTTPException) as exc_info, + ): + await router.ingest_record_event(request=request, body=body) + + assert exc_info.value.status_code == 503 + commands_service.settle_execution_completed.assert_awaited_once() + + async def test_record_ingest_defaults_turn_id_and_span_id_to_none(): records_service = AsyncMock() router = RecordsRouter(records_service=records_service) 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 ab0e6db4534..24087d16fbd 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 @@ -153,7 +153,13 @@ async def test_failed_append_leaves_project_messages_unacknowledged(): @pytest.mark.asyncio -async def test_durable_event_is_published_only_after_record_commit_returns(): +@pytest.mark.parametrize( + "record_type,event_type", + [("message", "message.completed"), ("done", "execution.stopped")], +) +async def test_durable_event_is_published_only_after_record_commit_returns( + record_type, event_type +): project_id = uuid4() committed = False @@ -168,15 +174,16 @@ async def append_many(self, *, events): project_id=project_id, sequence=1, turn_id="turn-1", - record_type="message", + record_type=record_type, record_source="agent", - attributes={"type": "message", "text": "done"}, + attributes={"type": record_type, "text": "done"}, created_at=datetime.now(timezone.utc), ) ] async def publish(**kwargs): assert committed is True + assert kwargs["event"].type == event_type assert kwargs["event"].sequence == 1 assert kwargs["event"].watermark == 1 return True diff --git a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py new file mode 100644 index 00000000000..f890e329f3e --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_durable.py @@ -0,0 +1,397 @@ +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from oss.src.apis.fastapi.sessions import router as router_module +from oss.src.apis.fastapi.sessions.models import SessionInteractionRespondRequest +from oss.src.apis.fastapi.sessions.router import InteractionsRouter +from oss.src.apis.fastapi.sessions.router import SessionControlRouter +from oss.src.apis.fastapi.sessions.router import SessionStreamsRouter +from oss.src.core.sessions.commands.dtos import SessionCommandState +from oss.src.core.sessions.commands.types import IdempotencyKeyReused +from oss.src.core.sessions.executions.dtos import SessionExecutionState +from oss.src.core.sessions.interactions.dtos import ( + SessionInteraction, + SessionInteractionKind, + SessionInteractionStatus, +) +from oss.src.utils.env import env + + +async def test_durable_response_returns_202_and_stable_refs(monkeypatch): + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + command_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="turn-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + ) + admission = SimpleNamespace( + interaction=interaction, + command=SimpleNamespace(id=command_id, state=SessionCommandState.pending), + execution_id="turn-2", + execution_state=SessionExecutionState.pending_delivery, + ) + commands = SimpleNamespace(respond_interaction=AsyncMock(return_value=admission)) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "answer-1"}, + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=request, + interaction_id=interaction_id, + body=SessionInteractionRespondRequest( + answer={"approved": True}, expected_execution_id="turn-1" + ), + ) + + assert response.status_code == 202 + assert json.loads(response.body) == { + "interaction": interaction.model_dump(mode="json"), + "command": {"id": str(command_id), "state": "pending"}, + "execution": {"id": "turn-2", "state": "pending_delivery"}, + } + commands.respond_interaction.assert_awaited_once_with( + project_id=project_id, + user_id=user_id, + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="turn-1", + idempotency_key="answer-1", + ) + + +async def test_matching_partial_answer_retry_returns_terminal_source(monkeypatch): + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="session-1", + turn_id="source-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + ) + admission = SimpleNamespace( + interaction=interaction, + command=None, + execution_id="source-1", + execution_state=SessionExecutionState.terminal, + waiting_for_interactions=False, + ) + commands = SimpleNamespace(respond_interaction=AsyncMock(return_value=admission)) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "partial-answer-retry"}, + ), + interaction_id=interaction_id, + body=SessionInteractionRespondRequest( + answer={"approved": True}, expected_execution_id="source-1" + ), + ) + + assert response.status_code == 202 + assert json.loads(response.body) == { + "interaction": interaction.model_dump(mode="json"), + "command": None, + "execution": {"id": "source-1", "state": "terminal"}, + } + + +async def test_durable_batch_returns_202_with_one_continuation(monkeypatch): + project_id = uuid4() + user_id = uuid4() + first_id = uuid4() + second_id = uuid4() + interaction = SessionInteraction( + id=first_id, + project_id=project_id, + session_id="session-1", + turn_id="turn-1", + token="approval-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.responded, + ) + admission = SimpleNamespace( + interaction=interaction, + command=SimpleNamespace(id=uuid4(), state=SessionCommandState.pending), + execution_id="turn-2", + execution_state=SessionExecutionState.pending_delivery, + waiting_for_interactions=False, + ) + commands = SimpleNamespace(respond_interactions=AsyncMock(return_value=admission)) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "approve-all"}, + ), + interaction_id=first_id, + body=SessionInteractionRespondRequest( + answers=[ + {"interaction_id": first_id, "answer": {"approved": True}}, + {"interaction_id": second_id, "answer": {"approved": True}}, + ], + expected_execution_id="turn-1", + ), + ) + + assert response.status_code == 202 + commands.respond_interactions.assert_awaited_once_with( + project_id=project_id, + user_id=user_id, + interaction_answers=[ + (first_id, {"approved": True}), + (second_id, {"approved": True}), + ], + expected_execution_id="turn-1", + idempotency_key="approve-all", + ) + + +async def test_durable_response_returns_the_conflict_envelope(monkeypatch): + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + commands = SimpleNamespace( + respond_interaction=AsyncMock(side_effect=IdempotencyKeyReused()) + ) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "answer-1"}, + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=request, + interaction_id=interaction_id, + body=SessionInteractionRespondRequest(answer={"approved": False}), + ) + + assert response.status_code == 409 + assert json.loads(response.body) == { + "code": "idempotency_key_reused", + "message": "This idempotency key was already used for a different response.", + "retryable": False, + } + + +async def test_durable_validation_error_returns_422(monkeypatch): + project_id = uuid4() + user_id = uuid4() + commands = SimpleNamespace( + respond_interaction=AsyncMock( + side_effect=router_module.InteractionResponseConflict( + code="validation_error", + message="The interaction is not linked to an execution.", + ) + ) + ) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "answer-1"}, + ), + interaction_id=uuid4(), + body=SessionInteractionRespondRequest(answer={"approved": True}), + ) + + assert response.status_code == 422 + assert json.loads(response.body)["code"] == "validation_error" + + +async def test_durable_response_rejects_an_overlength_idempotency_key(monkeypatch): + project_id = uuid4() + user_id = uuid4() + commands = SimpleNamespace(respond_interaction=AsyncMock()) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = InteractionsRouter( + interactions_service=AsyncMock(), + workflows_service=AsyncMock(), + commands_service=commands, + ) + + response = await router.respond_interaction( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "x" * 256}, + ), + interaction_id=uuid4(), + body=SessionInteractionRespondRequest(answer={"approved": True}), + ) + + assert response.status_code == 422 + assert json.loads(response.body) == { + "code": "validation_error", + "message": "Idempotency-Key is too long.", + "retryable": False, + "details": {"field": "Idempotency-Key", "reason": "too_long"}, + "next_step": "Use an Idempotency-Key of at most 255 characters.", + } + commands.respond_interaction.assert_not_awaited() + + +async def test_continuation_resume_endpoint_is_feature_gated(monkeypatch): + project_id = uuid4() + user_id = uuid4() + commands = SimpleNamespace( + resume_recoverable_continuation=AsyncMock(return_value=True) + ) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = SessionControlRouter(commands_service=commands) + request = SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id) + ) + + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) + disabled = await router.resume_session_continuation( + request=request, session_id="session-1" + ) + assert disabled.resumed is False + commands.resume_recoverable_continuation.assert_not_awaited() + + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + enabled = await router.resume_session_continuation( + request=request, session_id="session-1" + ) + assert enabled.resumed is True + commands.resume_recoverable_continuation.assert_awaited_once_with( + project_id=project_id, session_id="session-1" + ) + + +async def test_feature_off_batch_without_path_anchor_returns_422(monkeypatch): + project_id = uuid4() + anchor_id = uuid4() + other_id = uuid4() + interactions = SimpleNamespace(fetch_interaction=AsyncMock()) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", False) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = InteractionsRouter( + interactions_service=interactions, + workflows_service=AsyncMock(), + commands_service=AsyncMock(), + ) + + response = await router.respond_interaction( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=uuid4()), headers={} + ), + interaction_id=anchor_id, + body=SessionInteractionRespondRequest( + answers=[{"interaction_id": other_id, "answer": {"approved": True}}] + ), + ) + + assert response.status_code == 422 + assert json.loads(response.body)["details"] == { + "field": "answers", + "reason": "anchor_missing", + } + interactions.fetch_interaction.assert_not_awaited() + + +@pytest.mark.parametrize( + ("queue_enabled", "steer_enabled", "expected_queue", "expected_steer"), + [ + (True, True, True, True), + (True, False, True, False), + (False, True, False, False), + ], +) +async def test_session_stream_response_advertises_capabilities( + monkeypatch, + queue_enabled, + steer_enabled, + expected_queue, + expected_steer, +): + project_id = uuid4() + service = SimpleNamespace(fetch=AsyncMock(return_value=None)) + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + monkeypatch.setattr(env.agenta.sessions, "queue", queue_enabled) + monkeypatch.setattr(env.agenta.sessions, "steer", steer_enabled) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = SessionStreamsRouter( + service=service, + interactions_service=AsyncMock(), + ) + + response = await router.fetch_session_stream( + request=SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=uuid4()) + ), + session_id="session-1", + ) + + assert response.capabilities.durable_approvals is True + assert response.capabilities.queue is expected_queue + assert response.capabilities.steer is expected_steer diff --git a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py index a063615ffcc..95d8c6a8962 100644 --- a/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py +++ b/api/oss/tests/pytest/unit/sessions/test_respond_interaction_enqueue.py @@ -9,6 +9,7 @@ import asyncio from uuid import uuid4 +from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest @@ -214,3 +215,49 @@ async def test_no_worker_fallback_routes_through_the_dispatcher(): answer={"approved": True}, ) workflows_service.invoke_workflow.assert_not_awaited() + + +async def test_inline_last_resort_uses_the_shared_session_reference_resolver(): + from oss.src.core.sessions.types import SessionReference + + project_id = uuid4() + user_id = uuid4() + interaction_id = uuid4() + interaction = SessionInteraction( + id=interaction_id, + project_id=project_id, + session_id="sess-1", + token="tok-1", + kind=SessionInteractionKind.user_approval, + status=SessionInteractionStatus.pending, + ) + service = _RacyInteractionsService(interaction=interaction) + workflows_service = AsyncMock() + turns_service = AsyncMock() + turns_service.query_turns.return_value = [ + SimpleNamespace( + references=[SessionReference(key="workflow", slug="agent-from-turn")] + ) + ] + router = InteractionsRouter( + interactions_service=service, + workflows_service=workflows_service, + turns_service=turns_service, + ) + + app = FastAPI() + request = _make_authed_request(app, project_id, user_id) + with patch( + "oss.src.apis.fastapi.sessions.router.check_action_access", + new_callable=AsyncMock, + return_value=True, + ): + await router.respond_interaction( + request=request, + interaction_id=interaction_id, + body=SessionInteractionRespondRequest(answer={"approved": True}), + ) + + invoke_request = workflows_service.invoke_workflow.await_args.kwargs["request"] + assert invoke_request.references["workflow"].slug == "agent-from-turn" + assert turns_service.query_turns.await_args.kwargs["windowing"].limit == 1 diff --git a/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py index 35c66fbb1f5..64f50cba46f 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_cancel_admission.py @@ -134,7 +134,9 @@ async def fetch_by_idempotency_key( return row return None - async def fetch_open_command(self, *, project_id, session_id, kind, target_turn_id): + async def fetch_open_command( + self, *, project_id, session_id, kind, target_turn_id, transaction=None + ): for row in reversed(self.rows): if ( row.project_id == project_id @@ -147,7 +149,20 @@ async def fetch_open_command(self, *, project_id, session_id, kind, target_turn_ return row return None - async def fetch_command(self, *, command_id, project_id=None): + async def bind_steer_input( + self, *, project_id, command_id, input_id, transaction=None + ): + for index, row in enumerate(self.rows): + if row.project_id != project_id or row.id != command_id: + continue + data = dict(row.data or {}) + data.setdefault("steer_input_id", str(input_id)) + bound = row.model_copy(update={"data": data}) + self.rows[index] = bound + return bound + raise AssertionError("command to bind was not found") + + async def fetch_command(self, *, command_id, project_id=None, transaction=None): for row in self.rows: if row.id == command_id: return row @@ -341,6 +356,9 @@ def __init__(self) -> None: self.commands = None self.interactions = None + async def fetch_execution(self, *, project_id, session_id, execution_id): + return self.rows.get((session_id, execution_id)) + async def settle( self, *, @@ -887,6 +905,57 @@ async def test_reused_idempotency_key_rejects_a_different_expected_execution( assert len(delivery.delivered) == 1 +@pytest.mark.asyncio +async def test_steer_binds_to_an_already_open_stop(lock_engine): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + ) + first = await svc.request_cancel( + project_id=_PROJECT, user_id=_USER, session_id=_SESSION + ) + steer_input_id = uuid4() + + steered = await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + steer_input_id=steer_input_id, + ) + + assert steered.command.id == first.command.id + assert steered.command.data == {"steer_input_id": str(steer_input_id)} + assert len(dao.rows) == 1 + + +@pytest.mark.asyncio +async def test_steer_rejects_an_open_stop_that_closes_before_binding(lock_engine): + await _run_turn(lock_engine, "turn-A") + dao = _FakeCommandsDAO() + svc = _service( + lock_engine, + dao=dao, + streams=_FakeStreamsService( + _stream("turn-A", datetime.now(timezone.utc) - timedelta(seconds=30)) + ), + ) + await svc.request_cancel(project_id=_PROJECT, user_id=_USER, session_id=_SESSION) + dao.bind_steer_input = AsyncMock(return_value=None) + + with pytest.raises(SessionCommandNotClaimable): + await svc.request_cancel( + project_id=_PROJECT, + user_id=_USER, + session_id=_SESSION, + steer_input_id=uuid4(), + ) + + @pytest.mark.asyncio async def test_a_reachable_runner_that_does_not_hold_the_session_settles_at_once( lock_engine, diff --git a/api/oss/tests/pytest/unit/sessions/test_session_cancel_feature_flag.py b/api/oss/tests/pytest/unit/sessions/test_session_cancel_feature_flag.py index 163794b204a..532de238f63 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_cancel_feature_flag.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_cancel_feature_flag.py @@ -134,3 +134,27 @@ def test_runner_token_rejects_non_ascii_credentials_as_unauthorized(monkeypatch) router_module._assert_runner_token(request) assert exc_info.value.status_code == 401 + + +async def test_cancel_rejects_an_overlength_idempotency_key(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_stop", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + service = SimpleNamespace(request_cancel=AsyncMock()) + request = _request() + request.headers = {"Idempotency-Key": "x" * 256} + + response = await SessionControlRouter( + commands_service=service + ).cancel_session_execution(request, "session-1") + + assert response.status_code == 422 + assert json.loads(response.body) == { + "code": "validation_error", + "message": "Idempotency-Key is too long.", + "retryable": False, + "details": {"field": "Idempotency-Key", "reason": "too_long"}, + "next_step": "Use an Idempotency-Key of at most 255 characters.", + } + service.request_cancel.assert_not_awaited() diff --git a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py index acca6be0006..202eee28cf7 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_commands_dao.py @@ -11,6 +11,7 @@ import asyncio import uuid from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock import pytest from sqlalchemy import text @@ -23,16 +24,28 @@ SessionCommandState, ) from oss.src.core.sessions.commands.interfaces import SessionScope +from oss.src.core.sessions.commands.interfaces import DeliveryReceipt from oss.src.core.sessions.commands.service import SessionCommandsService -from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.commands.types import ( + ExecutionExpectationFailed, + IdempotencyKeyReused, + InteractionResponseConflict, +) from oss.src.core.sessions.streams.service import SessionStreamsService from oss.src.dbs.postgres.sessions.commands.dao import SessionCommandsDAO from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO +from oss.src.core.sessions.executions.dtos import SessionExecutionState +from oss.src.core.sessions.interactions.dtos import ( + SessionInteractionStatus, + SessionInteractionTransition, +) +from oss.src.core.sessions.interactions.service import SessionInteractionsService from oss.src.dbs.postgres.sessions.interactions.dao import SessionInteractionsDAO from oss.src.dbs.postgres.sessions.streams.dao import SessionStreamsDAO import oss.src.dbs.postgres.shared.engine as engine_module from oss.src.dbs.postgres.shared.engine import get_transactions_engine import oss.src.models.db_models # noqa: F401 +from oss.src.utils.env import env pytestmark = pytest.mark.integration @@ -191,6 +204,33 @@ async def test_a_repeated_idempotency_key_returns_the_first_row(command_scope): ) +async def test_concurrent_shared_transactions_replay_one_idempotent_command( + command_scope, +): + dao = SessionCommandsDAO(engine=command_scope["engine"]) + + async def insert(): + async with command_scope["engine"].session() as transaction: + return await dao.create_command( + user_id=command_scope["user_id"], + command=_create(command_scope, idempotency_key="shared-retry"), + transaction=transaction, + ) + + first, second = await asyncio.wait_for( + asyncio.gather(insert(), insert()), timeout=5 + ) + + assert first.id == second.id + assert ( + await dao.count_open( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + == 1 + ) + + async def test_two_open_commands_for_one_execution_collapse_to_one(command_scope): # Two Stops for the same execution are one intent, even with no idempotency key and even # when admission's own read cannot see the other because it has not committed yet. The @@ -563,7 +603,13 @@ async def test_old_pending_commands_are_returned_for_redelivery(command_scope): now = datetime.now(timezone.utc) command = await dao.create_command( user_id=command_scope["user_id"], - command=_create(command_scope, created_at=now - timedelta(minutes=5)), + # The integration database is intentionally reused between runs. Put this row ahead of + # any accumulated abandoned-command backlog so the DAO's production batch limit does not + # make the assertion depend on how many earlier test runs used the same database. + command=_create( + command_scope, + created_at=datetime(1970, 1, 1, tzinfo=timezone.utc), + ), ) rows = await dao.expire_claims( @@ -680,6 +726,692 @@ async def test_execution_ending_marker_is_one_way(command_scope): ) +async def _insert_pending_interaction(scope, *, token: str): + interaction_id = uuid.uuid4() + async with scope["engine"].session() as session: + await session.execute( + text( + "INSERT INTO session_interactions " + "(project_id, id, session_id, turn_id, token, kind, status) " + "VALUES (:project_id, :id, :session_id, 'turn-A', :token, " + "'user_approval', 'pending')" + ), + { + "project_id": scope["project_id"], + "id": interaction_id, + "session_id": scope["session_id"], + "token": token, + }, + ) + return interaction_id + + +class _UnreachableDelivery: + async def deliver(self, **kwargs): + return DeliveryReceipt(status="unreachable") + + async def acknowledge(self, **kwargs): + return None + + +def _commands_service(scope, *, executions=None): + interactions = SessionInteractionsDAO(engine=scope["engine"]) + service = SessionCommandsService( + commands_dao=SessionCommandsDAO(engine=scope["engine"]), + streams_service=None, + interactions_service=SessionInteractionsService(interactions_dao=interactions), + lock_engine=None, + delivery=_UnreachableDelivery(), + executions_dao=executions or SessionExecutionsDAO(engine=scope["engine"]), + ) + service._resolve_target = AsyncMock(return_value=("turn-A", None)) + return service + + +async def test_full_service_stop_and_answer_have_one_postgres_winner(command_scope): + interaction_id = await _insert_pending_interaction( + command_scope, token="service-race" + ) + service = _commands_service(command_scope) + + results = await asyncio.gather( + service.request_cancel( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + session_id=command_scope["session_id"], + expected_execution_id="turn-A", + idempotency_key="stop-race", + ), + service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="answer-race", + ), + return_exceptions=True, + ) + + assert sum(not isinstance(result, Exception) for result in results) == 1 + loser = next(result for result in results if isinstance(result, Exception)) + assert isinstance(loser, (ExecutionExpectationFailed, InteractionResponseConflict)) + + interaction = await SessionInteractionsDAO( + engine=command_scope["engine"] + ).fetch_interaction( + project_id=command_scope["project_id"], interaction_id=interaction_id + ) + assert interaction.status in ( + SessionInteractionStatus.cancelled, + SessionInteractionStatus.responded, + ) + + +async def test_full_service_stop_between_parallel_answers_cancels_the_remainder( + command_scope, +): + first_id = await _insert_pending_interaction(command_scope, token="parallel-first") + second_id = await _insert_pending_interaction( + command_scope, token="parallel-second" + ) + service = _commands_service(command_scope) + + first = await service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=first_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="parallel-answer-first", + ) + assert first.command is None + assert first.waiting_for_interactions is True + + stopped = await service.request_cancel( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + session_id=command_scope["session_id"], + expected_execution_id="turn-A", + idempotency_key="parallel-stop", + ) + assert stopped.accepted is True + + with pytest.raises(InteractionResponseConflict): + await service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=second_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="parallel-answer-second", + ) + + interactions = SessionInteractionsDAO(engine=command_scope["engine"]) + first_row = await interactions.fetch_interaction( + project_id=command_scope["project_id"], interaction_id=first_id + ) + second_row = await interactions.fetch_interaction( + project_id=command_scope["project_id"], interaction_id=second_id + ) + assert first_row.status == SessionInteractionStatus.responded + assert second_row.status == SessionInteractionStatus.cancelled + async with command_scope["engine"].session() as session: + continuation_count = await session.scalar( + text( + "SELECT count(*) FROM session_executions " + "WHERE project_id = :project_id AND session_id = :session_id " + "AND parent_execution_id = 'turn-A'" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + assert continuation_count == 0 + + +async def test_full_service_parallel_answers_create_one_terminal_continuation( + command_scope, +): + first_id = await _insert_pending_interaction( + command_scope, token="parallel-continue-first" + ) + second_id = await _insert_pending_interaction( + command_scope, token="parallel-continue-second" + ) + service = _commands_service(command_scope) + + first = await service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=first_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="parallel-continue-answer-first", + ) + assert first.command is None + + second = await service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=second_id, + answer={"approved": False}, + expected_execution_id="turn-A", + idempotency_key="parallel-continue-answer-second", + ) + assert second.command is not None + + async with command_scope["engine"].session() as session: + before = ( + await session.execute( + text( + "SELECT execution_id, terminal_outcome FROM session_executions " + "WHERE project_id = :project_id AND session_id = :session_id " + "AND parent_execution_id = 'turn-A'" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + ).all() + assert before == [(second.execution_id, None)] + + assert await service.settle_execution_completed( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id=second.execution_id, + ) + async with command_scope["engine"].session() as session: + outcomes = ( + ( + await session.execute( + text( + "SELECT terminal_outcome FROM session_executions " + "WHERE project_id = :project_id AND session_id = :session_id " + "AND parent_execution_id = 'turn-A'" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + ) + .scalars() + .all() + ) + assert outcomes == ["completed"] + + +async def test_full_service_failure_rolls_back_answer_execution_and_command( + command_scope, +): + interaction_id = await _insert_pending_interaction( + command_scope, token="service-rollback" + ) + async with command_scope["engine"].session() as session: + await session.execute( + text( + "INSERT INTO session_executions " + "(project_id, session_id, execution_id, state) " + "VALUES (:project_id, :session_id, 'turn-A', 'active')" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + service = _commands_service(command_scope) + create_command = service._dao.create_command + + async def fail_after_command_insert(**kwargs): + await create_command(**kwargs) + raise RuntimeError("abort transaction") + + service._dao.create_command = fail_after_command_insert + + with pytest.raises(RuntimeError, match="abort transaction"): + await service.respond_interaction( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="answer-rollback", + ) + + interaction = await SessionInteractionsDAO( + engine=command_scope["engine"] + ).fetch_interaction( + project_id=command_scope["project_id"], interaction_id=interaction_id + ) + assert interaction.status == SessionInteractionStatus.pending + async with command_scope["engine"].session() as session: + execution = ( + await session.execute( + text( + "SELECT state, terminal_outcome FROM session_executions " + "WHERE project_id = :project_id AND session_id = :session_id " + "AND execution_id = 'turn-A'" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + ).one() + continuation_count = await session.scalar( + text( + "SELECT count(*) FROM session_executions " + "WHERE project_id = :project_id AND session_id = :session_id " + "AND parent_execution_id = 'turn-A'" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + commands = await session.scalar( + text( + "SELECT count(*) FROM session_commands " + "WHERE project_id = :project_id AND session_id = :session_id" + ), + { + "project_id": command_scope["project_id"], + "session_id": command_scope["session_id"], + }, + ) + assert execution == ("active", None) + assert continuation_count == 0 + assert commands == 0 + + +async def test_full_service_concurrent_same_key_same_answer_replays_ids(command_scope): + interaction_id = await _insert_pending_interaction( + command_scope, token="service-same" + ) + service = _commands_service(command_scope) + request = dict( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=interaction_id, + answer={"approved": True}, + expected_execution_id="turn-A", + idempotency_key="answer-same", + ) + + first, second = await asyncio.gather( + service.respond_interaction(**request), + service.respond_interaction(**request), + ) + + assert first.command.id == second.command.id + assert first.execution_id == second.execution_id + + +async def test_full_service_concurrent_same_key_conflicting_answer_is_409_domain( + command_scope, +): + interaction_id = await _insert_pending_interaction( + command_scope, token="service-conflict" + ) + service = _commands_service(command_scope) + common = dict( + project_id=command_scope["project_id"], + user_id=command_scope["user_id"], + interaction_id=interaction_id, + expected_execution_id="turn-A", + idempotency_key="answer-conflict", + ) + + results = await asyncio.gather( + service.respond_interaction(answer={"approved": True}, **common), + service.respond_interaction(answer={"approved": False}, **common), + return_exceptions=True, + ) + + assert sum(not isinstance(result, Exception) for result in results) == 1 + conflict = next(result for result in results if isinstance(result, Exception)) + assert isinstance(conflict, IdempotencyKeyReused) + + +async def test_live_continuation_is_a_send_candidate_and_reopens_after_recovery( + command_scope, +): + commands = SessionCommandsDAO(engine=command_scope["engine"]) + executions = SessionExecutionsDAO(engine=command_scope["engine"]) + interaction_id = await _insert_pending_interaction( + command_scope, token="running-blocker" + ) + async with commands.transaction() as transaction: + await executions.create_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-running", + parent_execution_id="turn-A", + source_interaction_id=interaction_id, + transaction=transaction, + ) + command = await commands.create_command( + user_id=command_scope["user_id"], + command=_create( + command_scope, + kind=SessionCommandKind.continue_interaction, + target_turn_id="continuation-running", + expected_turn_id="turn-A", + data={ + "interaction_id": str(interaction_id), + "continuation_execution_id": "continuation-running", + }, + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.started, + settled_at=datetime.now(timezone.utc), + ), + transaction=transaction, + ) + await executions.set_state( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-running", + state=SessionExecutionState.running, + transaction=transaction, + ) + + # A `running` continuation row now REACHES the service, which decides between the two live + # shapes `running` covers. The DAO deliberately does not: the discriminator is the Redis + # `running` lock, which only the service reads. + # + # * PARKED on its own approval — no Redis `running` lock. A Send is a steer and is + # allowed. This is review finding N2 and it stays. + # * EXECUTING inside a tool call — the lock names this execution. A Send starts a second + # turn, the runner supersedes, the warm sandbox is destroyed mid-call and the tool the + # user had just approved returns "Command aborted" (increment-6 browser pass, round 8, + # session 9d40cfcc-6485-4250-8d2e-17f1f12f55f4). It is refused. + # + # Both are covered by the two `resume_recoverable_continuation` tests below. + blocker = await commands.fetch_resumable_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + assert blocker is not None and blocker.id == command.id + # Being a Send candidate is not the same as being retargetable: only a recovered execution + # reopens. + assert ( + await commands.reopen_continuation( + project_id=command_scope["project_id"], + command_id=command.id, + target_turn_id="continuation-running", + replacement_turn_id="continuation-retry", + ) + is None + ) + + await executions.set_state( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-running", + state=SessionExecutionState.recoverable, + expected_states=[SessionExecutionState.running], + ) + blocker = await commands.fetch_resumable_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + assert blocker is not None and blocker.id == command.id + async with commands.transaction() as transaction: + await executions.create_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-retry", + parent_execution_id="continuation-running", + source_interaction_id=None, + transaction=transaction, + ) + reopened = await commands.reopen_continuation( + project_id=command_scope["project_id"], + command_id=command.id, + target_turn_id="continuation-running", + replacement_turn_id="continuation-retry", + ) + assert reopened is not None + assert reopened.state == SessionCommandState.pending + assert reopened.claimed_by is None + assert reopened.target_turn_id == "continuation-retry" + + +async def _park_continuation_on_its_own_gate(command_scope, *, token: str) -> None: + """The shape a continuation leaves when it raises its OWN approval and stops on the user.""" + async with command_scope["engine"].session() as session: + await session.execute( + text( + "INSERT INTO session_interactions " + "(project_id, id, session_id, turn_id, token, kind, status) " + "VALUES (:project_id, :id, :session_id, 'continuation-live', :token, " + "'user_approval', 'pending')" + ), + { + "project_id": command_scope["project_id"], + "id": uuid.uuid4(), + "session_id": command_scope["session_id"], + "token": token, + }, + ) + + +async def _seed_live_continuation(command_scope, *, token: str) -> None: + commands = SessionCommandsDAO(engine=command_scope["engine"]) + executions = SessionExecutionsDAO(engine=command_scope["engine"]) + interaction_id = await _insert_pending_interaction(command_scope, token=token) + async with commands.transaction() as transaction: + await executions.create_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-live", + parent_execution_id="turn-A", + source_interaction_id=interaction_id, + transaction=transaction, + ) + await commands.create_command( + user_id=command_scope["user_id"], + command=_create( + command_scope, + kind=SessionCommandKind.continue_interaction, + target_turn_id="continuation-live", + expected_turn_id="turn-A", + data={ + "interaction_id": str(interaction_id), + "continuation_execution_id": "continuation-live", + }, + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.started, + settled_at=datetime.now(timezone.utc), + ), + transaction=transaction, + ) + await executions.set_state( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-live", + state=SessionExecutionState.running, + transaction=transaction, + ) + + +async def test_executing_continuation_refuses_a_competing_send( + command_scope, monkeypatch +): + """The continuation is inside a tool call: Send must be refused, not superseded. + + Its execution holds no pending gate of its own, which is exactly the state the runner was + in when a released message tore down the warm sandbox and turned the approved call into + "Command aborted". + """ + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + await _seed_live_continuation(command_scope, token="live-executing") + service = _commands_service(command_scope) + + assert ( + await service.resume_recoverable_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + == "continuation-live" + ) + + +async def test_parked_continuation_still_accepts_a_send(command_scope, monkeypatch): + """The continuation raised its own approval gate: a Send is a steer and stays allowed. + + The park writes a pending interaction row against the continuation's own execution, so the + same `running` row in Postgres must not be read as ownership. This is review finding N2. + """ + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + await _seed_live_continuation(command_scope, token="live-parked") + await _park_continuation_on_its_own_gate(command_scope, token="live-parked-gate") + service = _commands_service(command_scope) + + assert ( + await service.resume_recoverable_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + ) + is None + ) + + +async def test_stop_and_answer_have_one_postgres_serialized_winner(command_scope): + commands = SessionCommandsDAO(engine=command_scope["engine"]) + executions = SessionExecutionsDAO(engine=command_scope["engine"]) + interactions = SessionInteractionsDAO(engine=command_scope["engine"]) + interaction_id = await _insert_pending_interaction(command_scope, token="race") + + async def stop(): + async with commands.transaction() as transaction: + source = await executions.lock_for_control( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + transaction=transaction, + ) + if source.terminal_outcome is not None: + return False + await executions.set_state( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + state=SessionExecutionState.stopping, + transaction=transaction, + ) + await interactions.cancel_session_pending( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + only_turn_id="turn-A", + transaction=transaction, + ) + return True + + async def answer(): + async with commands.transaction() as transaction: + source = await executions.lock_for_control( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + transaction=transaction, + ) + interaction = await interactions.fetch_interaction( + project_id=command_scope["project_id"], + interaction_id=interaction_id, + transaction=transaction, + for_update=True, + ) + if ( + source.terminal_outcome is not None + or source.state == SessionExecutionState.stopping + or interaction.status != SessionInteractionStatus.pending + ): + return False + await interactions.transition_interaction( + transition=SessionInteractionTransition( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + token="race", + status=SessionInteractionStatus.responded, + resolution={"approved": True}, + ), + transaction=transaction, + ) + await executions.settle( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + terminal_outcome="continued", + settled_by="interaction_response", + transaction=transaction, + ) + return True + + winners = await asyncio.gather(stop(), answer()) + assert sum(winners) == 1 + + interaction = await interactions.fetch_interaction( + project_id=command_scope["project_id"], interaction_id=interaction_id + ) + assert interaction.status in ( + SessionInteractionStatus.cancelled, + SessionInteractionStatus.responded, + ) + + +async def test_continuation_transaction_rolls_back_the_answer_on_failure(command_scope): + commands = SessionCommandsDAO(engine=command_scope["engine"]) + executions = SessionExecutionsDAO(engine=command_scope["engine"]) + interactions = SessionInteractionsDAO(engine=command_scope["engine"]) + interaction_id = await _insert_pending_interaction(command_scope, token="rollback") + + with pytest.raises(RuntimeError, match="abort transaction"): + async with commands.transaction() as transaction: + await executions.lock_for_control( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="turn-A", + transaction=transaction, + ) + await interactions.transition_interaction( + transition=SessionInteractionTransition( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + token="rollback", + status=SessionInteractionStatus.responded, + resolution={"approved": True}, + ), + transaction=transaction, + ) + await executions.create_continuation( + project_id=command_scope["project_id"], + session_id=command_scope["session_id"], + execution_id="continuation-rollback", + parent_execution_id="turn-A", + source_interaction_id=interaction_id, + transaction=transaction, + ) + raise RuntimeError("abort transaction") + + interaction = await interactions.fetch_interaction( + project_id=command_scope["project_id"], interaction_id=interaction_id + ) + assert interaction.status == SessionInteractionStatus.pending + async with command_scope["engine"].session() as session: + count = await session.scalar( + text( + "SELECT count(*) FROM session_executions " + "WHERE project_id = :project_id AND execution_id = 'continuation-rollback'" + ), + {"project_id": command_scope["project_id"]}, + ) + assert count == 0 + + async def test_terminal_core_facts_commit_in_one_transaction(command_scope): commands = SessionCommandsDAO(engine=command_scope["engine"]) executions = SessionExecutionsDAO(engine=command_scope["engine"]) diff --git a/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py new file mode 100644 index 00000000000..5c2a8fc32f1 --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_session_inputs_dao.py @@ -0,0 +1,1712 @@ +"""Postgres transaction guarantees for durable session input admission and promotion.""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import ANY, AsyncMock +import uuid + +from fastapi import HTTPException +import pytest +from sqlalchemy import text + +from oss.src.apis.fastapi.sessions.models import PendingInputAdmissionRequest +from oss.src.apis.fastapi.sessions.router import SessionControlRouter +import oss.src.apis.fastapi.sessions.router as router_module +from oss.src.core.sessions.commands.dtos import ( + SessionCommandCreate, + SessionCommandKind, + SessionCommandOutcome, + SessionCommandState, +) +from oss.src.core.sessions.commands.interfaces import DeliveryReceipt +from oss.src.core.sessions.commands import service as commands_service_module +from oss.src.core.sessions.commands.service import SessionCommandsService +from oss.src.core.sessions.commands.types import ExecutionExpectationFailed +from oss.src.core.sessions.executions.dtos import SessionExecutionState +from oss.src.core.sessions.inputs.dtos import ( + PendingInputCreate, + PendingInputState, + PendingInputUpdate, + PendingInputAttachment, +) +from oss.src.core.sessions.inputs.service import ( + SessionInputsService, + input_fingerprint, + edit_pending_input_content, +) +from oss.src.core.sessions.inputs.types import ( + SessionInputBusy, + SessionInputNotEditable, + SessionInputContentInvalid, + SessionInputNotFound, + SessionInputNotRemovable, + SessionInputRemoved, +) +from oss.src.dbs.postgres.sessions.commands.dao import SessionCommandsDAO +from oss.src.dbs.postgres.sessions.executions.dao import SessionExecutionsDAO +from oss.src.dbs.postgres.sessions.inputs.dao import SessionInputsDAO +import oss.src.dbs.postgres.shared.engine as engine_module +from oss.src.dbs.postgres.shared.engine import get_transactions_engine +import oss.src.models.db_models # noqa: F401 +from oss.src.utils.env import env + + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +async def _fresh_engine_per_test(): + if engine_module._transactions_engine is not None: + await engine_module._transactions_engine.close() + engine_module._transactions_engine = None + yield + if engine_module._transactions_engine is not None: + await engine_module._transactions_engine.close() + engine_module._transactions_engine = None + + +@pytest.fixture +async def input_scope(): + engine = get_transactions_engine() + user_id = uuid.uuid4() + organization_id = uuid.uuid4() + workspace_id = uuid.uuid4() + project_id = uuid.uuid4() + session_id = f"input-dao-{project_id.hex[:12]}" + + async with engine.session() as session: + await session.execute( + text( + "INSERT INTO users (id, uid, username, email) " + "VALUES (:id, :uid, :username, :email)" + ), + { + "id": user_id, + "uid": str(user_id), + "username": "input-dao-test", + "email": f"input-dao-{user_id.hex[:8]}@example.com", + }, + ) + await session.execute( + text( + "INSERT INTO organizations (id, name, owner_id) " + "VALUES (:id, :name, :owner_id)" + ), + { + "id": organization_id, + "name": "input-dao-test-org", + "owner_id": user_id, + }, + ) + await session.execute( + text( + "INSERT INTO workspaces (id, name, organization_id) " + "VALUES (:id, :name, :organization_id)" + ), + { + "id": workspace_id, + "name": "input-dao-test-workspace", + "organization_id": organization_id, + }, + ) + await session.execute( + text( + "INSERT INTO projects " + "(id, project_name, workspace_id, organization_id) " + "VALUES (:id, :project_name, :workspace_id, :organization_id)" + ), + { + "id": project_id, + "project_name": "input-dao-test-project", + "workspace_id": workspace_id, + "organization_id": organization_id, + }, + ) + + yield { + "engine": engine, + "project_id": project_id, + "user_id": user_id, + "session_id": session_id, + } + + async with engine.session() as session: + await session.execute( + text("DELETE FROM session_inputs WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM session_commands WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM session_executions WHERE project_id = :project_id"), + {"project_id": project_id}, + ) + await session.execute( + text("DELETE FROM projects WHERE id = :id"), {"id": project_id} + ) + await session.execute( + text("DELETE FROM workspaces WHERE id = :id"), {"id": workspace_id} + ) + await session.execute( + text("DELETE FROM organizations WHERE id = :id"), + {"id": organization_id}, + ) + await session.execute(text("DELETE FROM users WHERE id = :id"), {"id": user_id}) + + +def _input(scope, *, key: str, message: str, policy: str = "queue"): + content = { + "session_id": scope["session_id"], + "data": {"messages": [message]}, + } + return PendingInputCreate( + project_id=scope["project_id"], + session_id=scope["session_id"], + content=content, + policy=policy, + idempotency_key=key, + request_fingerprint=input_fingerprint(content=content, policy=policy), + ) + + +class _BusyStreams: + async def fetch_header(self, **_kwargs): + return SimpleNamespace( + flags=SimpleNamespace(is_running=True), + turn_id="source-turn", + turn_started_at=None, + ) + + +class _SettlementRaceStreams(_BusyStreams): + def __init__(self): + self.observed_busy = asyncio.Event() + self.allow_admission = asyncio.Event() + + async def fetch_header(self, **_kwargs): + self.observed_busy.set() + await self.allow_admission.wait() + return await super().fetch_header() + + +class _UnreachableDelivery: + async def deliver(self, **_kwargs): + return DeliveryReceipt(status="unreachable") + + async def acknowledge(self, **_kwargs): + return None + + +def _settlement_service(scope, inputs, *, commands=None, executions=None): + streams = SimpleNamespace( + settle_command=AsyncMock(), + publish_session_ended=AsyncMock(), + ) + interactions = SimpleNamespace( + cancel_session_pending=AsyncMock(return_value=0), + publish_session_pending_cancelled=AsyncMock(), + ) + service = SessionCommandsService( + commands_dao=commands or SessionCommandsDAO(engine=scope["engine"]), + streams_service=streams, + interactions_service=interactions, + lock_engine=None, + delivery=_UnreachableDelivery(), + executions_dao=executions or SessionExecutionsDAO(engine=scope["engine"]), + inputs_dao=inputs, + ) + service._reconcile_stopped_redis = AsyncMock() + return service + + +def _cancel_service(scope, inputs, *, executions): + return SessionCommandsService( + commands_dao=SessionCommandsDAO(engine=scope["engine"]), + streams_service=_BusyStreams(), + interactions_service=SimpleNamespace( + cancel_session_pending=AsyncMock(return_value=0), + publish_session_pending_cancelled=AsyncMock(), + ), + lock_engine=None, + delivery=_UnreachableDelivery(), + executions_dao=executions, + inputs_dao=inputs, + ) + + +class _PausingExecutionsDAO(SessionExecutionsDAO): + def __init__(self, *, engine): + super().__init__(engine=engine) + self.locked = asyncio.Event() + self.release = asyncio.Event() + + async def lock_for_control(self, **kwargs): + execution = await super().lock_for_control(**kwargs) + if not self.locked.is_set(): + self.locked.set() + await self.release.wait() + return execution + + +class _ObservedExecutionsDAO(SessionExecutionsDAO): + def __init__(self, *, engine): + super().__init__(engine=engine) + self.lock_attempted = asyncio.Event() + + async def lock_for_control(self, **kwargs): + self.lock_attempted.set() + return await super().lock_for_control(**kwargs) + + +class _ObservedCommandsDAO(SessionCommandsDAO): + def __init__(self, *, engine): + super().__init__(engine=engine) + self.command_observed = asyncio.Event() + + async def fetch_command(self, **kwargs): + command = await super().fetch_command(**kwargs) + if kwargs.get("transaction") is None: + self.command_observed.set() + return command + + +async def _pending_command(scope, *, data=None): + return await SessionCommandsDAO(engine=scope["engine"]).create_command( + user_id=scope["user_id"], + command=SessionCommandCreate( + project_id=scope["project_id"], + session_id=scope["session_id"], + kind=SessionCommandKind.cancel, + target_turn_id="source-turn", + state=SessionCommandState.pending, + data=data, + ), + ) + + +async def test_completion_promotes_one_fifo_input_in_the_settlement_transaction( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + first = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="queue-1", message="first"), + ) + second = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="queue-2", message="second"), + ) + service = _settlement_service(input_scope, inputs) + + assert await service.settle_execution_completed( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + ) + assert await service.settle_execution_completed( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + ) + + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=first.id, + ) + ).state == PendingInputState.promoted + assert [ + item.id + for item in await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + ] == [first.id, second.id] + + await SessionExecutionsDAO(engine=input_scope["engine"]).set_state( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id=( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=first.id, + ) + ).promoted_execution_id, + state=SessionExecutionState.running, + expected_states=[SessionExecutionState.recoverable], + ) + assert [ + item.id + for item in await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + ] == [second.id] + + +async def test_admission_rechecks_settlement_under_the_execution_lock( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + streams = _SettlementRaceStreams() + admission_service = SessionInputsService( + inputs_dao=inputs, + streams_service=streams, + executions_dao=executions, + ) + settlement_service = _settlement_service(input_scope, inputs) + async with input_scope["engine"].session() as transaction: + await executions.lock_for_control( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + transaction=transaction, + ) + + admission_task = asyncio.create_task( + admission_service.admit( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + content={"message": "arrived during settlement"}, + policy="queue", + idempotency_key="settlement-race", + ) + ) + await streams.observed_busy.wait() + + assert await settlement_service.settle_execution_completed( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + ) + streams.allow_admission.set() + admission = await admission_task + + assert admission.action == "execute" + assert ( + await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + == [] + ) + + +async def test_admission_queues_behind_running_input_promoted_by_settlement( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + older = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="older", message="older"), + ) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + streams = _SettlementRaceStreams() + admission_service = SessionInputsService( + inputs_dao=inputs, + streams_service=streams, + executions_dao=executions, + ) + settlement_service = _settlement_service(input_scope, inputs) + async with input_scope["engine"].session() as transaction: + await executions.lock_for_control( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + transaction=transaction, + ) + + admission_task = asyncio.create_task( + admission_service.admit( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + content={"message": "arrived during settlement"}, + policy="queue", + idempotency_key="settlement-race", + ) + ) + await streams.observed_busy.wait() + + assert await settlement_service.settle_execution_completed( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + ) + promoted = await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=older.id, + ) + assert promoted.state == PendingInputState.promoted + running = await executions.set_state( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id=promoted.promoted_execution_id, + state=SessionExecutionState.running, + expected_states=[SessionExecutionState.recoverable], + ) + assert running is not None + assert running.state == SessionExecutionState.running + assert ( + await inputs.list_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + ) + == [] + ) + + streams.allow_admission.set() + admission = await admission_task + + assert admission.action == "pending" + assert admission.execution_id == promoted.promoted_execution_id + assert [ + item.id + for item in await inputs.list_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + ) + ] == [admission.input.id] + + +async def test_manual_stop_commits_without_promoting_pending_input( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + pending = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="queue-paused", message="later"), + ) + command = await _pending_command(input_scope) + service = _settlement_service(input_scope, inputs) + + settled = await service.settle( + command_id=command.id, + project_id=input_scope["project_id"], + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-turn", + ) + + assert settled is not None + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=pending.id, + ) + ).state == PendingInputState.pending + + +async def test_concurrent_idempotent_admission_returns_one_postgres_row( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + service = SessionInputsService( + inputs_dao=SessionInputsDAO(engine=input_scope["engine"]), + streams_service=_BusyStreams(), + ) + kwargs = { + "project_id": input_scope["project_id"], + "user_id": input_scope["user_id"], + "session_id": input_scope["session_id"], + "content": {"message": "same"}, + "policy": "queue", + "idempotency_key": "same-key", + } + + first, retry = await asyncio.wait_for( + asyncio.gather(service.admit(**kwargs), service.admit(**kwargs)), timeout=5 + ) + + assert first.input.id == retry.input.id + assert ( + len( + await service.list_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + ) + ) + == 1 + ) + + +async def test_conflicting_key_returns_the_409_envelope(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + inputs = SessionInputsService( + inputs_dao=SessionInputsDAO(engine=input_scope["engine"]), + streams_service=_BusyStreams(), + ) + router = SessionControlRouter( + commands_service=SimpleNamespace(), inputs_service=inputs + ) + request = SimpleNamespace( + state=SimpleNamespace( + project_id=input_scope["project_id"], user_id=input_scope["user_id"] + ), + headers={"Idempotency-Key": "conflicting-key"}, + ) + await router.admit_session_input( + request, + PendingInputAdmissionRequest( + session_id=input_scope["session_id"], + content={"message": "first"}, + on_busy="queue", + ), + ) + + with pytest.raises(HTTPException) as raised: + await router.admit_session_input( + request, + PendingInputAdmissionRequest( + session_id=input_scope["session_id"], + content={"message": "different"}, + on_busy="queue", + ), + ) + + assert raised.value.status_code == 409 + assert raised.value.detail["code"] == "idempotency_key_reused" + assert raised.value.detail["retryable"] is False + + +async def test_promoted_input_cannot_be_removed(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + item = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="promoted", message="go"), + ) + await inputs.promote_next( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="next-turn", + ) + service = SessionInputsService(inputs_dao=inputs, streams_service=_BusyStreams()) + + with pytest.raises(SessionInputNotRemovable): + await service.remove( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=item.id, + ) + + +async def test_steer_is_committed_before_failed_stop_and_stays_first( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + dao = SessionInputsDAO(engine=input_scope["engine"]) + await dao.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="older", message="older"), + ) + inputs = SessionInputsService(inputs_dao=dao, streams_service=_BusyStreams()) + events = [] + + class FailedStop: + async def request_cancel(self, **kwargs): + pending = await dao.list_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + ) + assert pending[0].id == kwargs["steer_input_id"] + events.append("stop-after-commit") + raise RuntimeError("runner unavailable") + + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + router = SessionControlRouter(commands_service=FailedStop(), inputs_service=inputs) + response = await router.admit_session_input( + SimpleNamespace( + state=SimpleNamespace( + project_id=input_scope["project_id"], user_id=input_scope["user_id"] + ), + headers={"Idempotency-Key": "steer"}, + ), + PendingInputAdmissionRequest( + session_id=input_scope["session_id"], + content={"message": "steer now"}, + on_busy="steer", + ), + ) + + pending = await dao.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + assert response.status_code == 202 + assert json.loads(response.body)["input"]["id"] == str(pending[0].id) + assert [item.idempotency_key for item in pending] == ["steer", "older"] + assert events == ["stop-after-commit"] + + +async def test_steer_stop_promotes_only_the_bound_input(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + older = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="older", message="older"), + ) + steer = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input( + input_scope, key="steer", message="steer now", policy="steer" + ), + prioritize=True, + ) + command = await _pending_command(input_scope) + command = await SessionCommandsDAO(engine=input_scope["engine"]).bind_steer_input( + project_id=input_scope["project_id"], + command_id=command.id, + input_id=steer.id, + ) + assert command is not None + rebound = await SessionCommandsDAO(engine=input_scope["engine"]).bind_steer_input( + project_id=input_scope["project_id"], + command_id=command.id, + input_id=steer.id, + ) + rejected = await SessionCommandsDAO(engine=input_scope["engine"]).bind_steer_input( + project_id=input_scope["project_id"], + command_id=command.id, + input_id=older.id, + ) + assert command.data == {"steer_input_id": str(steer.id)} + assert rebound is not None and rebound.data == command.data + assert rejected is None + service = _settlement_service(input_scope, inputs) + + settled = await service.settle( + command_id=command.id, + project_id=input_scope["project_id"], + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-turn", + ) + + assert settled is not None + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=steer.id, + ) + ).state == PendingInputState.promoted + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=older.id, + ) + ).state == PendingInputState.pending + continuation = await SessionExecutionsDAO( + engine=input_scope["engine"] + ).fetch_execution( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id=( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=steer.id, + ) + ).promoted_execution_id, + ) + assert continuation.state == SessionExecutionState.recoverable + + +async def test_steer_bind_wins_before_stop_settlement(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + monkeypatch.setattr( + commands_service_module, + "get_running_owner", + AsyncMock(return_value="source-turn"), + ) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + steer = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input( + input_scope, key="steer-first", message="steer first", policy="steer" + ), + prioritize=True, + ) + command = await _pending_command(input_scope) + bind_executions = _PausingExecutionsDAO(engine=input_scope["engine"]) + bind_service = _cancel_service(input_scope, inputs, executions=bind_executions) + settlement_commands = _ObservedCommandsDAO(engine=input_scope["engine"]) + settlement_service = _settlement_service( + input_scope, + inputs, + commands=settlement_commands, + executions=SessionExecutionsDAO(engine=input_scope["engine"]), + ) + + bind_task = asyncio.create_task( + bind_service.request_cancel( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + expected_execution_id="source-turn", + steer_input_id=steer.id, + ) + ) + await asyncio.wait_for(bind_executions.locked.wait(), timeout=5) + settlement_task = asyncio.create_task( + settlement_service.settle( + command_id=command.id, + project_id=input_scope["project_id"], + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-turn", + ) + ) + await asyncio.wait_for(settlement_commands.command_observed.wait(), timeout=5) + bind_executions.release.set() + admission, settled = await asyncio.wait_for( + asyncio.gather(bind_task, settlement_task), timeout=5 + ) + + assert admission.command.data == {"steer_input_id": str(steer.id)} + assert settled is not None + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=steer.id, + ) + ).state == PendingInputState.promoted + + +async def test_stop_settlement_wins_before_steer_bind(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + monkeypatch.setattr( + commands_service_module, + "get_running_owner", + AsyncMock(return_value="source-turn"), + ) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + steer = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input( + input_scope, + key="settlement-first", + message="settlement first", + policy="steer", + ), + prioritize=True, + ) + command = await _pending_command(input_scope) + settlement_executions = _PausingExecutionsDAO(engine=input_scope["engine"]) + settlement_service = _settlement_service( + input_scope, inputs, executions=settlement_executions + ) + bind_executions = _ObservedExecutionsDAO(engine=input_scope["engine"]) + bind_service = _cancel_service(input_scope, inputs, executions=bind_executions) + + settlement_task = asyncio.create_task( + settlement_service.settle( + command_id=command.id, + project_id=input_scope["project_id"], + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-turn", + ) + ) + await asyncio.wait_for(settlement_executions.locked.wait(), timeout=5) + bind_task = asyncio.create_task( + bind_service.request_cancel( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + expected_execution_id="source-turn", + steer_input_id=steer.id, + ) + ) + await asyncio.wait_for(bind_executions.lock_attempted.wait(), timeout=5) + settlement_executions.release.set() + + assert await asyncio.wait_for(settlement_task, timeout=5) is not None + with pytest.raises(ExecutionExpectationFailed): + await asyncio.wait_for(bind_task, timeout=5) + assert ( + await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=steer.id, + ) + ).state == PendingInputState.pending + settled_command = await SessionCommandsDAO( + engine=input_scope["engine"] + ).fetch_command(command_id=command.id) + assert settled_command.data is None + + +@pytest.mark.parametrize("policy", ["queue", "steer"]) +@pytest.mark.parametrize( + "state", ["pending_delivery", "running", "recoverable", "terminal"] +) +async def test_admission_follows_approval_continuation_before_stream_header_catches_up( + input_scope, monkeypatch, policy, state +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + scope = { + "project_id": input_scope["project_id"], + "session_id": input_scope["session_id"], + } + async with input_scope["engine"].session() as transaction: + await executions.settle( + **scope, + execution_id="source-turn", + terminal_outcome="continued", + settled_by="interaction_response", + transaction=transaction, + ) + await executions.create_continuation( + **scope, + execution_id="approved-child", + parent_execution_id="source-turn", + source_interaction_id=None, + transaction=transaction, + ) + if state == "terminal": + await executions.settle( + **scope, + execution_id="approved-child", + terminal_outcome="completed", + settled_by="runner", + transaction=transaction, + ) + else: + await executions.set_state( + **scope, + execution_id="approved-child", + state=SessionExecutionState(state), + transaction=transaction, + ) + service = SessionInputsService( + inputs_dao=inputs, + streams_service=_BusyStreams(), + executions_dao=executions, + ) + admission = await service.admit( + **scope, + user_id=input_scope["user_id"], + content={"message": "after approved work"}, + policy=policy, + idempotency_key="approval-start-gap", + ) + if state == "terminal": + assert admission.action == "execute" + assert await inputs.list_pending(**scope) == [] + else: + assert admission.action == "pending" + assert admission.execution_id == "approved-child" + assert len(await inputs.list_pending(**scope)) == 1 + + +@pytest.mark.parametrize("idle", [False, True]) +async def test_send_now_preserves_selected_row_and_remaining_order( + input_scope, monkeypatch, idle +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + service = _cancel_service(input_scope, inputs, executions=executions) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + service._reconcile_stopped_redis = AsyncMock() + rows = [] + for index in range(3): + values = _input(input_scope, key=f"send-now-{index}", message=str(index)) + values.content["attachments"] = [{"file_id": f"file-{index}"}] + rows.append( + await inputs.create_input( + user_id=input_scope["user_id"], pending_input=values + ) + ) + if idle: + await executions.settle( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="source-turn", + terminal_outcome="completed", + settled_by="runner", + ) + args = dict( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=rows[2].id, + ) + first, second = await asyncio.gather( + service.send_pending_input_now(**args), service.send_pending_input_now(**args) + ) + assert first.input.id == second.input.id == rows[2].id + stored = await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=rows[2].id, + ) + assert stored.content == rows[2].content + assert stored.idempotency_key == rows[2].idempotency_key + assert stored.request_fingerprint == rows[2].request_fingerprint + remaining = await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + assert [item.id for item in remaining if item.id != rows[2].id] == [ + rows[0].id, + rows[1].id, + ] + assert [item.position for item in remaining if item.id != rows[2].id] == [ + rows[0].position, + rows[1].position, + ] + async with input_scope["engine"].session() as transaction: + commands = ( + ( + await transaction.execute( + text( + "SELECT kind, data FROM session_commands WHERE project_id=:project_id" + ), + {"project_id": input_scope["project_id"]}, + ) + ) + .mappings() + .all() + ) + assert len(commands) == 1 + assert commands[0]["kind"] == ("continue_input" if idle else "cancel") + if idle: + assert stored.state == PendingInputState.promoted + assert commands[0]["data"]["input_id"] == str(rows[2].id) + assert ( + commands[0]["data"]["request"]["attachments"] + == rows[2].content["attachments"] + ) + else: + assert stored.state == PendingInputState.pending + assert remaining[0].id == rows[2].id + assert commands[0]["data"]["steer_input_id"] == str(rows[2].id) + + +async def test_send_now_does_not_resurrect_removed_input(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + row = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="removed-send-now", message="removed"), + ) + await inputs.remove_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + ) + service = _cancel_service( + input_scope, + inputs, + executions=SessionExecutionsDAO(engine=input_scope["engine"]), + ) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + with pytest.raises(SessionInputRemoved, match="removed"): + await service.send_pending_input_now( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=row.id, + ) + async with input_scope["engine"].session() as transaction: + count = ( + await transaction.execute( + text( + "SELECT count(*) FROM session_commands WHERE project_id=:project_id" + ), + {"project_id": input_scope["project_id"]}, + ) + ).scalar_one() + assert count == 0 + + +async def test_send_now_stop_promotes_selected_once_and_holds_other_rows( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + service = _cancel_service(input_scope, inputs, executions=executions) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + rows = [ + await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key=f"selected-{i}", message=str(i)), + ) + for i in range(3) + ] + args = dict( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=rows[1].id, + ) + await service.send_pending_input_now(**args) + commands = SessionCommandsDAO(engine=input_scope["engine"]) + cancel = await commands.fetch_by_idempotency_key( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + idempotency_key=f"send-now:{rows[1].id}", + ) + settlement = _settlement_service(input_scope, inputs) + await settlement.settle( + command_id=cancel.id, + project_id=input_scope["project_id"], + replica_id=None, + expected_states=[SessionCommandState.pending], + state=SessionCommandState.applied, + outcome=SessionCommandOutcome.stopped, + execution_id="source-turn", + ) + retry = await service.send_pending_input_now(**args) + assert retry.input.state == PendingInputState.promoted + async with input_scope["engine"].session() as transaction: + continuations = ( + ( + await transaction.execute( + text( + "SELECT data FROM session_commands WHERE project_id=:project_id AND kind='continue_input'" + ), + {"project_id": input_scope["project_id"]}, + ) + ) + .scalars() + .all() + ) + assert len(continuations) == 1 + assert continuations[0]["input_id"] == str(rows[1].id) + pending = await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + assert [row.id for row in pending if row.state == PendingInputState.pending] == [ + rows[0].id, + rows[2].id, + ] + + +async def test_competing_send_now_keeps_losing_row_unchanged(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + service = _cancel_service( + input_scope, + inputs, + executions=SessionExecutionsDAO(engine=input_scope["engine"]), + ) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + rows = [ + await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key=f"compete-{i}", message=str(i)), + ) + for i in range(2) + ] + outcomes = await asyncio.gather( + *( + service.send_pending_input_now( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=row.id, + ) + for row in rows + ), + return_exceptions=True, + ) + assert sum(isinstance(outcome, SessionInputBusy) for outcome in outcomes) == 1 + loser = rows[ + next( + i + for i, outcome in enumerate(outcomes) + if isinstance(outcome, SessionInputBusy) + ) + ] + stored = await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=loser.id, + ) + assert stored.policy == "queue" + assert stored.position == loser.position + assert stored.content == loser.content + + +async def test_send_now_route_rejects_cross_session_and_removed_rows( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + monkeypatch.setattr( + router_module, "check_action_access", AsyncMock(return_value=True) + ) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + service = _cancel_service( + input_scope, + inputs, + executions=SessionExecutionsDAO(engine=input_scope["engine"]), + ) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + row = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="route-selected", message="original"), + ) + router = SessionControlRouter( + commands_service=service, inputs_service=SimpleNamespace() + ) + request = SimpleNamespace( + state=SimpleNamespace( + project_id=input_scope["project_id"], user_id=input_scope["user_id"] + ) + ) + with pytest.raises(HTTPException) as missing: + await router.send_pending_input_now(request, "another-session", row.id) + assert missing.value.status_code == 404 + await inputs.remove_pending( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + ) + with pytest.raises(HTTPException) as removed: + await router.send_pending_input_now(request, input_scope["session_id"], row.id) + assert removed.value.status_code == 409 + assert removed.value.detail["code"] == "pending_input_removed" + + +async def test_send_now_parked_input_continuation_advances_when_runner_not_held( + input_scope, monkeypatch +): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + monkeypatch.setattr( + commands_service_module, "get_running_owner", AsyncMock(return_value=None) + ) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + async with input_scope["engine"].session() as transaction: + await executions.create_continuation( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="parked-input-child", + parent_execution_id="source-turn", + source_interaction_id=None, + transaction=transaction, + ) + await executions.set_state( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="parked-input-child", + state=SessionExecutionState.running, + transaction=transaction, + ) + first = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="held-first", message="later"), + ) + selected = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="held-selected", message="now"), + ) + service = _settlement_service(input_scope, inputs, executions=executions) + service._resolve_target = AsyncMock(return_value=(None, None)) + service._streams.fetch_header = AsyncMock( + return_value=SimpleNamespace( + turn_id="parked-input-child", flags=SimpleNamespace(is_running=False) + ) + ) + service._interactions.cancel_session_pending.return_value = 1 + + class ParkedDelivery(_UnreachableDelivery): + async def deliver(self, *, command): + return DeliveryReceipt( + status="not_held" + if command.kind == SessionCommandKind.cancel + else "unreachable" + ) + + service._delivery = ParkedDelivery() + await service.send_pending_input_now( + project_id=input_scope["project_id"], + user_id=input_scope["user_id"], + session_id=input_scope["session_id"], + input_id=selected.id, + ) + stored = await inputs.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=selected.id, + ) + assert stored.state == PendingInputState.promoted + pending = await inputs.list_pending( + project_id=input_scope["project_id"], session_id=input_scope["session_id"] + ) + assert [row.id for row in pending if row.state == PendingInputState.pending] == [ + first.id + ] + service._interactions.cancel_session_pending.assert_any_await( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + only_turn_id="parked-input-child", + transaction=ANY, + publish=False, + ) + async with input_scope["engine"].session() as transaction: + count = ( + await transaction.execute( + text( + "SELECT count(*) FROM session_commands WHERE project_id=:project_id AND kind='continue_input'" + ), + {"project_id": input_scope["project_id"]}, + ) + ).scalar_one() + assert count == 1 + + +async def test_send_now_reservation_blocks_concurrent_removal(input_scope, monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "queue", True) + monkeypatch.setattr(env.agenta.sessions, "steer", True) + inputs = SessionInputsDAO(engine=input_scope["engine"]) + executions = SessionExecutionsDAO(engine=input_scope["engine"]) + service = _cancel_service(input_scope, inputs, executions=executions) + service._resolve_target = AsyncMock(return_value=("source-turn", None)) + row = await inputs.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="reserved-send-now", message="selected"), + ) + reserved = asyncio.Event() + release = asyncio.Event() + original_prioritize = inputs.prioritize_pending + + async def pause_reserved(**kwargs): + selected = await original_prioritize(**kwargs) + reserved.set() + await release.wait() + return selected + + monkeypatch.setattr(inputs, "prioritize_pending", pause_reserved) + args = dict( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + ) + sending = asyncio.create_task(service.send_pending_input_now(**args)) + await asyncio.wait_for(reserved.wait(), timeout=5) + removing = asyncio.create_task(inputs.remove_pending(**args)) + await asyncio.sleep(0) + release.set() + admission = await sending + with pytest.raises(SessionInputNotRemovable): + await removing + assert admission.input.id == row.id + stored = await inputs.fetch_input( + project_id=args["project_id"], session_id=args["session_id"], input_id=row.id + ) + assert stored.state == PendingInputState.pending + assert stored.content == row.content + + async with input_scope["engine"].session() as transaction: + await transaction.execute( + text( + "UPDATE session_commands SET state='claimed' WHERE project_id=:project_id" + ), + {"project_id": args["project_id"]}, + ) + with pytest.raises(SessionInputNotRemovable): + await inputs.remove_pending(**args) + async with input_scope["engine"].session() as transaction: + await transaction.execute( + text( + "UPDATE session_commands SET state='obsolete', outcome='lost' WHERE project_id=:project_id" + ), + {"project_id": args["project_id"]}, + ) + removed = await inputs.remove_pending(**args) + assert removed.state == PendingInputState.removed + + +@pytest.mark.asyncio +async def test_edit_pending_preserves_payload_identity_and_retry_attachments( + input_scope, +): + dao = SessionInputsDAO(engine=input_scope["engine"]) + service = SessionInputsService(inputs_dao=dao, streams_service=_BusyStreams()) + values = _input(input_scope, key="edit-existing", message="unused") + values.content = { + "data": { + "inputs": { + "messages": [ + {"role": "system", "content": "history"}, + { + "id": "user-id", + "role": "user", + "parts": [ + {"type": "text", "text": "before"}, + { + "type": "file", + "url": "agenta://old", + "mediaType": "text/plain", + "opaque": True, + }, + ], + }, + ] + }, + "parameters": {"agent": {"instructions": "keep-config"}}, + }, + "references": {"revision": {"id": "keep-revision"}}, + } + values.request_fingerprint = input_fingerprint( + content=values.content, policy=values.policy + ) + row = await dao.create_input(user_id=input_scope["user_id"], pending_input=values) + update = PendingInputUpdate( + text="after", + attachments=[ + PendingInputAttachment( + uri="agenta://new", mime_type="text/plain", filename="new.txt" + ) + ], + ) + for _ in range(2): + edited = await service.update( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + update=update, + ) + original_retry = await service.admit( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + user_id=input_scope["user_id"], + content=values.content, + policy=values.policy, + idempotency_key=values.idempotency_key, + ) + assert original_retry.input.id == row.id + assert original_retry.input.content == edited.content + assert edited.id == row.id and edited.position == row.position + assert ( + edited.request_fingerprint == row.request_fingerprint + and edited.idempotency_key == row.idempotency_key + ) + assert edited.content["references"] == values.content["references"] + assert edited.content["data"]["parameters"] == values.content["data"]["parameters"] + messages = edited.content["data"]["inputs"]["messages"] + assert messages[0] == values.content["data"]["inputs"]["messages"][0] + assert messages[1]["id"] == "user-id" + assert messages[1]["parts"] == [ + {"type": "text", "text": "after"}, + { + "type": "file", + "url": "agenta://old", + "mediaType": "text/plain", + "opaque": True, + }, + { + "type": "file", + "url": "agenta://new", + "mediaType": "text/plain", + "filename": "new.txt", + }, + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("row_state", ["pending", "claimed", "promoted", "removed"]) +async def test_edit_pending_rejects_promoted_and_reserved_rows(input_scope, row_state): + dao = SessionInputsDAO(engine=input_scope["engine"]) + service = SessionInputsService(inputs_dao=dao, streams_service=_BusyStreams()) + row = await dao.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="edit-reserved", message="original"), + ) + if row_state in ("pending", "claimed"): + await _pending_command(input_scope, data={"steer_input_id": str(row.id)}) + if row_state == "claimed": + async with input_scope["engine"].session() as tx: + await tx.execute( + text( + "UPDATE session_commands SET state='claimed' WHERE project_id=:project" + ), + {"project": input_scope["project_id"]}, + ) + else: + async with input_scope["engine"].session() as tx: + await tx.execute( + text("UPDATE session_inputs SET state=:state WHERE id=:id"), + {"state": row_state, "id": row.id}, + ) + with pytest.raises(SessionInputNotEditable): + await service.update( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + update=PendingInputUpdate(text="changed"), + ) + stored = await dao.fetch_input( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + ) + assert stored.content == row.content + + +@pytest.mark.asyncio +async def test_promotion_waits_for_edited_head_instead_of_skipping_it(input_scope): + dao = SessionInputsDAO(engine=input_scope["engine"]) + first = await dao.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="edit-first", message="first"), + ) + await dao.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="edit-second", message="second"), + ) + async with dao.transaction() as tx: + await dao.lock_pending_for_edit( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=first.id, + transaction=tx, + ) + promotion = asyncio.create_task( + dao.promote_next( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + execution_id="next", + ) + ) + await asyncio.sleep(0.05) + assert not promotion.done() + await dao.update_content( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=first.id, + content={"edited": "head"}, + user_id=input_scope["user_id"], + transaction=tx, + ) + promoted = await asyncio.wait_for(promotion, 2) + assert promoted.id == first.id + assert promoted.content == {"edited": "head"} + + +@pytest.mark.asyncio +async def test_edit_pending_scope_and_invalid_content_leave_row_unchanged(input_scope): + dao = SessionInputsDAO(engine=input_scope["engine"]) + service = SessionInputsService(inputs_dao=dao, streams_service=_BusyStreams()) + row = await dao.create_input( + user_id=input_scope["user_id"], + pending_input=_input(input_scope, key="invalid-edit", message="opaque"), + ) + args = dict( + project_id=input_scope["project_id"], + session_id=input_scope["session_id"], + input_id=row.id, + user_id=input_scope["user_id"], + update=PendingInputUpdate(text="new"), + ) + with pytest.raises(SessionInputNotFound): + await service.update(**{**args, "project_id": uuid.uuid4()}) + with pytest.raises(SessionInputContentInvalid): + await service.update(**args) + stored = await dao.fetch_input( + project_id=args["project_id"], session_id=args["session_id"], input_id=row.id + ) + assert stored.content == row.content + + +@pytest.mark.parametrize( + "original", + [ + "before", + [ + {"type": "text", "text": "before"}, + {"type": "attachment", "uri": "agenta://old", "opaque": True}, + ], + ], +) +def test_edit_pending_canonical_content_keeps_attachments(original): + with pytest.raises(ValueError): + PendingInputAttachment( + uri="agenta://invalid", + mime_type="text/plain", + attachment_id="", + ) + content = { + "data": {"inputs": {"messages": [{"role": "user", "content": original}]}} + } + update = PendingInputUpdate( + text="after", + attachments=[ + PendingInputAttachment(uri="agenta://new", mime_type="text/plain") + ], + ) + edited = edit_pending_input_content(content, update) + assert edit_pending_input_content(edited, update) == edited + blocks = edited["data"]["inputs"]["messages"][0]["content"] + assert blocks[0] == {"type": "text", "text": "after"} + assert blocks[-1] == { + "type": "resource", + "uri": "agenta://new", + "mimeType": "text/plain", + } + if isinstance(original, list): + assert blocks[1] == original[1] + assert content["data"]["inputs"]["messages"][0]["content"] == original + + +def test_edit_pending_canonical_content_preserves_durable_attachment_identity(): + old_attachment_id = "01995d1a-2f83-7c4d-8a6b-123456789abc" + new_attachment_id = "01996b6c-7b6b-7000-8000-000000000001" + original_attachment = { + "type": "attachment", + "attachmentId": old_attachment_id, + "mimeType": "application/pdf", + "filename": "old.pdf", + } + content = { + "data": { + "inputs": { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "before"}, + original_attachment, + ], + } + ] + } + } + } + update = PendingInputUpdate( + text="after", + attachments=[ + PendingInputAttachment( + uri="https://files.test/old.pdf", + mime_type="application/pdf", + filename="old.pdf", + attachment_id=old_attachment_id, + ), + PendingInputAttachment( + uri="https://files.test/new.png", + mime_type="image/png", + filename="new.png", + attachment_id=new_attachment_id, + ), + ], + ) + + edited = edit_pending_input_content(content, update) + assert edit_pending_input_content(edited, update) == edited + assert edited["data"]["inputs"]["messages"][0]["content"] == [ + {"type": "text", "text": "after"}, + original_attachment, + { + "type": "attachment", + "attachmentId": new_attachment_id, + "mimeType": "image/png", + "filename": "new.png", + }, + ] + + +def test_edit_pending_ui_parts_preserves_durable_attachment_identity(): + old_attachment_id = "01995d1a-2f83-7c4d-8a6b-123456789abc" + new_attachment_id = "01996b6c-7b6b-7000-8000-000000000001" + original_file = { + "type": "file", + "url": "https://files.test/old.pdf", + "mediaType": "application/pdf", + "filename": "old.pdf", + "providerMetadata": {"agenta": {"attachmentId": old_attachment_id}}, + } + content = { + "data": { + "inputs": { + "messages": [ + { + "role": "user", + "parts": [ + {"type": "text", "text": "before"}, + original_file, + ], + } + ] + } + } + } + update = PendingInputUpdate( + text="after", + attachments=[ + PendingInputAttachment( + uri="https://other-host.test/old.pdf", + mime_type="application/pdf", + filename="old.pdf", + attachment_id=old_attachment_id, + ), + PendingInputAttachment( + uri="https://files.test/new.png", + mime_type="image/png", + filename="new.png", + attachment_id=new_attachment_id, + ), + ], + ) + + edited = edit_pending_input_content(content, update) + assert edit_pending_input_content(edited, update) == edited + assert edited["data"]["inputs"]["messages"][0]["parts"] == [ + {"type": "text", "text": "after"}, + original_file, + { + "type": "file", + "url": "https://files.test/new.png", + "mediaType": "image/png", + "filename": "new.png", + "providerMetadata": {"agenta": {"attachmentId": new_attachment_id}}, + }, + ] diff --git a/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py b/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py index 3e10f1ee8cb..863ba3156da 100644 --- a/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py +++ b/api/oss/tests/pytest/unit/sessions/test_session_snapshot.py @@ -5,8 +5,9 @@ from fastapi import FastAPI, Request from oss.src.apis.fastapi.sessions.router import SessionsRootRouter +from oss.src.core.sessions.inputs.dtos import PendingInput, PendingInputState from oss.src.core.sessions.records.dtos import SessionRecordsReadState -from oss.src.core.sessions.streams.dtos import SessionStream +from oss.src.core.sessions.streams.dtos import SessionStream, SessionStreamFlags from oss.src.utils.env import env @@ -25,6 +26,104 @@ def _request(project_id, user_id) -> Request: return request +@pytest.mark.asyncio +async def test_snapshot_keeps_queue_capabilities_when_shared_reader_is_off(): + project_id = uuid4() + stream = SessionStream( + id=uuid4(), + project_id=project_id, + session_id="session-1", + turn_id="turn-live", + flags=SessionStreamFlags(is_alive=True, is_running=True, is_attached=False), + ) + streams = AsyncMock() + streams.fetch.return_value = stream + records = AsyncMock() + interactions = AsyncMock() + interactions.query_interactions.return_value = [] + turns = AsyncMock() + inputs = AsyncMock() + inputs.list_pending.return_value = [] + router = SessionsRootRouter( + sessions_service=AsyncMock(), + streams_service=streams, + records_service=records, + interactions_service=interactions, + turns_service=turns, + inputs_service=inputs, + ) + + with ( + patch.object(env.sessions, "shared_reader", False), + patch.object(env.agenta.sessions, "queue", True), + patch.object(env.agenta.sessions, "steer", 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 is None + assert snapshot.execution is None + assert snapshot.read is None + assert snapshot.execution_state.state == "running" + assert snapshot.execution_state.id == "turn-live" + assert snapshot.capabilities.queue is True + assert snapshot.capabilities.steer is True + records.get_read_state.assert_not_awaited() + turns.latest_turn.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_snapshot_is_idle_before_a_fresh_session_has_a_stream_row(): + project_id = uuid4() + streams = AsyncMock() + streams.fetch.return_value = None + records = AsyncMock() + interactions = AsyncMock() + interactions.query_interactions.return_value = [] + turns = AsyncMock() + inputs = AsyncMock() + inputs.list_pending.return_value = [] + router = SessionsRootRouter( + sessions_service=AsyncMock(), + streams_service=streams, + records_service=records, + interactions_service=interactions, + turns_service=turns, + inputs_service=inputs, + ) + + with ( + patch.object(env.sessions, "shared_reader", True), + patch.object(env.agenta.sessions, "queue", True), + patch.object(env.agenta.sessions, "steer", 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 is None + assert snapshot.execution is None + assert snapshot.read is None + assert snapshot.execution_state.state == "idle" + assert snapshot.execution_state.id is None + assert snapshot.pending.inputs == [] + assert snapshot.capabilities.queue is True + assert snapshot.capabilities.steer is True + records.get_read_state.assert_not_awaited() + turns.latest_turn.assert_not_awaited() + + @pytest.mark.asyncio async def test_snapshot_groups_session_execution_pending_and_read_watermark(): project_id = uuid4() @@ -108,3 +207,118 @@ async def test_snapshot_forces_incomplete_when_stream_marker_is_present(): ) assert snapshot.read.history_complete is False + + +@pytest.mark.asyncio +async def test_snapshot_carries_the_queue_half_when_the_inputs_service_is_wired(): + """One route serves both readers. + + Milestone 2's live preview reads `session`, `execution` and `read`; the durable queue reads + `execution_state`, `pending.inputs` and `capabilities`. Both halves come from this one call, + so a client can never see a snapshot and a capability report that disagree. + """ + project_id = uuid4() + stream = SessionStream( + id=uuid4(), + project_id=project_id, + session_id="session-1", + turn_id="turn-live", + flags=SessionStreamFlags(is_alive=True, is_running=True, is_attached=False), + ) + streams = AsyncMock() + streams.fetch.return_value = stream + records = AsyncMock() + records.get_read_state.return_value = SessionRecordsReadState( + latest_sequence=9, + history_complete=True, + ) + interactions = AsyncMock() + interactions.query_interactions.return_value = [] + turns = AsyncMock() + turns.latest_turn.return_value = None + pending = PendingInput( + id=uuid4(), + project_id=project_id, + session_id="session-1", + content={"data": {"inputs": {"messages": []}}}, + position=1, + state=PendingInputState.pending, + policy="queue", + idempotency_key="key-1", + request_fingerprint="fingerprint-1", + ) + inputs = AsyncMock() + inputs.list_pending.return_value = [pending] + router = SessionsRootRouter( + sessions_service=AsyncMock(), + streams_service=streams, + records_service=records, + interactions_service=interactions, + turns_service=turns, + inputs_service=inputs, + ) + + with ( + patch.object(env.sessions, "shared_reader", True), + patch.object(env.agenta.sessions, "durable_approvals", True), + patch.object(env.agenta.sessions, "queue", True), + patch.object(env.agenta.sessions, "steer", 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" + ) + + # The reconnect half is unchanged. + assert snapshot.session.session_id == "session-1" + assert snapshot.read.latest_sequence == 9 + # The queue half rides along. + assert snapshot.execution_state.state == "running" + assert snapshot.execution_state.id == "turn-live" + assert [item.id for item in snapshot.pending.inputs] == [pending.id] + assert snapshot.capabilities.durable_approvals is True + assert snapshot.capabilities.queue is True + assert snapshot.capabilities.steer is True + + +@pytest.mark.asyncio +async def test_snapshot_reports_an_empty_queue_without_the_inputs_service(): + project_id = uuid4() + stream = SessionStream(id=uuid4(), project_id=project_id, session_id="session-1") + streams = AsyncMock() + streams.fetch.return_value = stream + records = AsyncMock() + records.get_read_state.return_value = SessionRecordsReadState( + latest_sequence=0, + 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.pending.inputs == [] + assert snapshot.execution_state.state == "idle" diff --git a/api/oss/tests/pytest/unit/sessions/test_session_steer_admission.py b/api/oss/tests/pytest/unit/sessions/test_session_steer_admission.py new file mode 100644 index 00000000000..bef8684e2cb --- /dev/null +++ b/api/oss/tests/pytest/unit/sessions/test_session_steer_admission.py @@ -0,0 +1,88 @@ +import json +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from oss.src.apis.fastapi.sessions.models import PendingInputAdmissionRequest +from oss.src.apis.fastapi.sessions.router import SessionControlRouter +from oss.src.core.sessions.inputs.dtos import ( + PendingInput, + PendingInputAdmission, + PendingInputState, +) + + +class _Inputs: + def __init__(self, *, project_id, events): + self.events = events + self.item = PendingInput( + id=uuid4(), + project_id=project_id, + session_id="session-1", + content={"message": "steer now"}, + position=0, + state=PendingInputState.pending, + policy="steer", + idempotency_key="steer-1", + request_fingerprint="a" * 64, + created_by_id=uuid4(), + ) + + async def admit(self, **_kwargs): + self.events.append("saved") + return PendingInputAdmission( + action="pending", + input=self.item, + execution_id="execution-1", + ) + + +class _FailedStop: + def __init__(self, events): + self.events = events + + async def request_cancel(self, **kwargs): + self.events.append("stop") + assert kwargs["steer_input_id"] is not None + raise RuntimeError("runner unavailable") + + +@pytest.mark.asyncio +async def test_steer_is_saved_before_stop_and_stays_visible_when_stop_fails( + monkeypatch, +): + project_id = uuid4() + user_id = uuid4() + events = [] + inputs = _Inputs(project_id=project_id, events=events) + monkeypatch.setattr( + "oss.src.apis.fastapi.sessions.router.check_action_access", + lambda **_kwargs: _allowed(), + ) + router = SessionControlRouter( + commands_service=_FailedStop(events), + inputs_service=inputs, + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=project_id, user_id=user_id), + headers={"Idempotency-Key": "steer-1"}, + ) + + response = await router.admit_session_input( + request, + PendingInputAdmissionRequest( + session_id="session-1", + content={"message": "steer now"}, + on_busy="steer", + ), + ) + + assert events == ["saved", "stop"] + assert response.status_code == 202 + assert json.loads(response.body)["input"]["id"] == str(inputs.item.id) + assert inputs.item.state == PendingInputState.pending + + +async def _allowed(): + return True diff --git a/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py b/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py index 790190c38a1..4c1174c57a4 100644 --- a/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py +++ b/api/oss/tests/pytest/unit/sessions/test_watch_interactions_publish.py @@ -15,11 +15,15 @@ from oss.src.core.sessions.interactions.dtos import ( SessionInteraction, SessionInteractionCreate, + SessionInteractionData, SessionInteractionKind, SessionInteractionStatus, SessionInteractionTransition, ) -from oss.src.core.sessions.interactions.service import SessionInteractionsService +from oss.src.core.sessions.interactions.service import ( + SessionInteractionsService, + _watch_interaction_state, +) _PROJECT = uuid4() @@ -27,12 +31,17 @@ class _RecordingPublisher: def __init__(self): - self.interaction_calls: list[tuple[str, str, str]] = [] - - async def interaction( - self, *, project_id: str, session_id: str, status: str - ) -> None: - self.interaction_calls.append((project_id, session_id, status)) + self.interaction_calls: list[tuple[str, str, str, list[dict] | None]] = [] + + async def interaction(self, **kwargs) -> None: + self.interaction_calls.append( + ( + kwargs["project_id"], + kwargs["session_id"], + kwargs["status"], + kwargs.get("interactions"), + ) + ) def _interaction(session_id: str) -> SessionInteraction: @@ -70,13 +79,28 @@ async def test_create_publishes_pending(): ), ) - assert publisher.interaction_calls == [(str(_PROJECT), "sess-1", "pending")] + assert publisher.interaction_calls == [ + ( + str(_PROJECT), + "sess-1", + "pending", + [_watch_interaction_state(dao.create_interaction.return_value)], + ) + ] @pytest.mark.asyncio async def test_transition_publishes_resolved(): dao = AsyncMock() - dao.transition_interaction = AsyncMock(return_value=_interaction("sess-1")) + resolved = _interaction("sess-1").model_copy( + update={ + "status": SessionInteractionStatus.responded, + "data": SessionInteractionData( + resolution={"verdict": "approved", "tool_call_id": "tool-1"} + ), + } + ) + dao.transition_interaction = AsyncMock(return_value=resolved) svc, publisher = _service(dao) await svc.transition_interaction( @@ -88,7 +112,14 @@ async def test_transition_publishes_resolved(): ), ) - assert publisher.interaction_calls == [(str(_PROJECT), "sess-1", "resolved")] + assert publisher.interaction_calls == [ + ( + str(_PROJECT), + "sess-1", + "resolved", + [_watch_interaction_state(resolved)], + ) + ] @pytest.mark.asyncio @@ -127,7 +158,7 @@ async def test_cancel_sweep_publishes_resolved_only_when_it_cancelled(): project_id=_PROJECT, session_id="sess-1" ) assert cancelled == 2 - assert publisher.interaction_calls == [(str(_PROJECT), "sess-1", "resolved")] + assert publisher.interaction_calls == [(str(_PROJECT), "sess-1", "resolved", None)] # No-op sweep: nothing was pending, nothing changed, nothing to notify. dao.cancel_session_pending = AsyncMock(return_value=[]) diff --git a/api/oss/tests/pytest/unit/sessions/test_watch_publish.py b/api/oss/tests/pytest/unit/sessions/test_watch_publish.py index 31717408ae0..0c5c52b4ba0 100644 --- a/api/oss/tests/pytest/unit/sessions/test_watch_publish.py +++ b/api/oss/tests/pytest/unit/sessions/test_watch_publish.py @@ -182,6 +182,44 @@ async def test_publisher_publishes_on_watch_channel(): } +@pytest.mark.asyncio +async def test_publisher_carries_committed_interaction_resolution(): + import fakeredis + + redis = fakeredis.FakeAsyncRedis() + pubsub = redis.pubsub() + project_id = str(uuid4()) + channel = watch_channel(project_id, "sess-1") + await pubsub.subscribe(channel) + await pubsub.get_message(timeout=1) + + publisher = SessionsWatchPublisher(redis_client=redis) + interaction = { + "id": str(uuid4()), + "session_id": "sess-1", + "turn_id": "turn-1", + "token": "approval-1", + "kind": "user_approval", + "status": "responded", + "data": {"resolution": {"verdict": "approved"}}, + } + await publisher.interaction( + project_id=project_id, + session_id="sess-1", + status="resolved", + interactions=[interaction], + ) + + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1) + assert message is not None + assert json.loads(message["data"]) == { + "type": "interaction", + "session_id": "sess-1", + "status": "resolved", + "interactions": [interaction], + } + + @pytest.mark.asyncio async def test_publisher_swallows_redis_failure(): broken = AsyncMock() diff --git a/api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py b/api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py index ea48e847019..22659e6cbdd 100644 --- a/api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py +++ b/api/oss/tests/pytest/unit/sessions/test_watchdog_collapse_persistence.py @@ -586,3 +586,78 @@ async def heartbeat_is_blocked_on_the_sweep(): "is_running": False, "is_attached": False, } + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "settled,is_running,collapsed,replaced,accepted", + [ + (False, True, False, False, True), + (True, False, False, False, True), + (True, True, False, False, False), + (True, False, True, False, False), + (True, False, False, True, False), + ], +) +async def test_heartbeat_mirror_distinguishes_active_and_settled_execution( + anyio_backend, wd_engine, settled, is_running, collapsed, replaced, accepted +): + session_id = "wd-" + uuid.uuid4().hex[:12] + turn_id = str(uuid.uuid4()) + project_id = await _seed_scenario(wd_engine, session_id=session_id, turn_id=turn_id) + async with wd_engine.session() as transaction: + # Use the actual M3 admission writer: active execution rows exist before completion. + await SessionExecutionsDAO(wd_engine).create_continuation( + project_id=project_id, + session_id=session_id, + execution_id=turn_id, + parent_execution_id="previous-turn", + source_interaction_id=None, + transaction=transaction, + ) + if settled: + await transaction.execute( + text( + "UPDATE session_executions SET state='terminal', " + "terminal_outcome='stopped', settled_by='runner', settled_at=NOW() " + "WHERE project_id=:p AND session_id=:s" + ), + {"p": project_id, "s": session_id}, + ) + if collapsed: + await transaction.execute( + text( + "UPDATE session_streams SET flags=" + '\'{"is_alive": false,"is_running": false,"is_attached": false}\'::jsonb ' + "WHERE project_id=:p AND session_id=:s" + ), + {"p": project_id, "s": session_id}, + ) + if replaced: + await transaction.execute( + text( + "UPDATE session_streams SET turn_id='new-turn' WHERE project_id=:p AND session_id=:s" + ), + {"p": project_id, "s": session_id}, + ) + await transaction.commit() + + mirrored = await SessionStreamsDAO(wd_engine).update( + project_id=project_id, + user_id=None, + session_id=session_id, + stream=SessionStreamEdit( + flags=SessionStreamFlags( + is_alive=True, is_running=is_running, is_attached=False + ), + turn_id=turn_id, + expected_turn_id=turn_id, + ), + ) + assert (mirrored is not None) is accepted + persisted = await SessionStreamsDAO(wd_engine).get_by_session_id( + project_id=project_id, session_id=session_id + ) + assert persisted.flags.is_running is (is_running if accepted else not collapsed) + assert persisted.flags.is_alive is (not collapsed) + assert persisted.turn_id == ("new-turn" if replaced else turn_id) diff --git a/api/oss/tests/pytest/unit/utils/test_env_runner_config.py b/api/oss/tests/pytest/unit/utils/test_env_runner_config.py index caf102f5b4a..37c1ca2ecbb 100644 --- a/api/oss/tests/pytest/unit/utils/test_env_runner_config.py +++ b/api/oss/tests/pytest/unit/utils/test_env_runner_config.py @@ -112,3 +112,34 @@ def test_sandbox_runner_honors_explicit_restricted(monkeypatch): finally: monkeypatch.delenv("AGENTA_SERVICES_CODE_SANDBOX_RUNNER", raising=False) importlib.reload(env) + + +@pytest.mark.parametrize( + "configured, expected", [(None, True), ("", True), ("true", True), ("false", False)] +) +def test_session_features_default_on_and_honor_overrides( + monkeypatch, configured, expected +): + try: + with monkeypatch.context() as context: + for name in ( + "AGENTA_SESSIONS_DURABLE_APPROVALS", + "AGENTA_SESSIONS_QUEUE", + "AGENTA_SESSIONS_STEER", + "AGENTA_SESSIONS_SHARED_READER", + "AGENTA_SESSIONS_SEQUENCE_WRITES", + ): + if configured is None: + context.delenv(name, raising=False) + else: + context.setenv(name, configured) + importlib.reload(env) + sessions_config = env.SessionsConfig() + redis_config = env.SessionsRedisConfig() + assert sessions_config.durable_approvals is expected + assert sessions_config.queue is expected + assert sessions_config.steer is expected + assert redis_config.shared_reader is expected + assert redis_config.sequence_writes is expected + finally: + importlib.reload(env) diff --git a/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py b/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py index 59782cfd83c..e277e01438e 100644 --- a/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py +++ b/api/oss/tests/pytest/unit/workflows/test_invoke_detached.py @@ -14,6 +14,7 @@ from oss.src.core.workflows.service import WorkflowsService from oss.src.core.workflows.types import WorkflowDetachedStartFailed +from oss.src.utils.env import env class _FakeStreamResponse: @@ -126,6 +127,133 @@ async def test_stream_service_started_raises_on_http_error(): ) +@pytest.mark.parametrize( + "line", + [ + "not-json", + "[]", + '{"kind": "result", "result": {"ok": false, "error": "rejected"}}', + '{"kind": "result"}', + # The service wire's own failure frame (an agenta `error` event). + '{"type": "error", "data": {"type": "error", "message": "no key", "code": "auth"}}', + ], +) +async def test_stream_service_started_rejects_failure_or_malformed_first_record(line): + response = _FakeStreamResponse(lines=[line]) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + with pytest.raises(WorkflowDetachedStartFailed): + await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + strict_first_record=True, + ) + + +async def test_stream_service_started_keeps_legacy_best_effort_for_ordinary_trigger(): + response = _FakeStreamResponse(lines=["not-json"]) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + result = await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + ) + + assert result.accepted is True + assert result.run_id == "run-x" + + +@pytest.mark.parametrize( + "line", + [ + # The record sequence a durable continuation really produced (browser pass, + # 2026-09-04 17:35Z, session d99f32ae / command 01a06d7d): the runner admitted the + # continuation and its first event was a `tool_call`. The DEPLOYED SERVICE re-frames + # every runner record as an agenta event, `{"type", "data"}` — there is no `kind` on + # that wire, and reading the first frame as a runner record called every one of those + # deliveries unreachable while the turn ran to completion underneath the card. + '{"type": "tool_call", "data": {"type": "tool_call", "id": "t1", "name": "Bash"}}', + '{"type": "interaction_response", "data": {"type": "interaction_response"}}', + '{"type": "message", "data": {"type": "message", "text": "ok"}}', + # An unrecognised record is a start, not a failure: only an explicit failure frame is. + '{"kind": "unknown"}', + '{"type": "error_recovered", "data": {}}', + ], +) +async def test_stream_service_started_accepts_a_service_event_frame_as_the_start(line): + response = _FakeStreamResponse(lines=[line, '{"type": "done", "data": {}}']) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + result = await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + strict_first_record=True, + ) + assert result.accepted is True + assert result.run_id == "run-x" + assert response.consumed == 1 + + +async def test_stream_service_started_reports_a_runner_refusal_verbatim(): + """Case (b) of the same browser pass, command 01a06d7a. + + The runner refuses a continuation it cannot prove it owns and writes + ``{"kind": "result", ok: false}``. Where a deployment forwards that record verbatim the + caller must surface the reason, not report a start. + """ + refusal = ( + '{"kind": "result", "result": {"ok": false, "error": ' + '"Continuation could not establish alive ownership; retry delivery."}}' + ) + response = _FakeStreamResponse(lines=[refusal]) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + with pytest.raises(WorkflowDetachedStartFailed) as failure: + await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + strict_first_record=True, + ) + assert "alive ownership" in str(failure.value) + + +async def test_stream_service_started_reports_an_empty_stream_as_a_failed_start(): + """The same refusal as it actually reaches the API through the SDK service. + + The SDK turns the runner's ``ok: false`` result into an exception inside an ASGI response + whose 200 is already committed, so the service closes the stream having written nothing. + """ + response = _FakeStreamResponse(lines=[]) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + with pytest.raises(WorkflowDetachedStartFailed) as failure: + await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + strict_first_record=True, + ) + assert "closed the stream" in str(failure.value) + + +async def test_stream_service_started_accepts_success_result_record(): + response = _FakeStreamResponse( + lines=['{"kind": "result", "result": {"ok": true}}'], + ) + with patch("httpx.AsyncClient", return_value=_FakeAsyncClient(response)): + result = await _service()._stream_service_started( + url="http://svc/invoke", + credentials="Secret tok", + payload={}, + run_id="run-x", + ) + assert result.accepted is True + + async def test_invoke_workflow_detached_returns_run_id_and_threads_meta(): svc = _service() project_id = uuid4() @@ -136,10 +264,11 @@ async def test_invoke_workflow_detached_returns_run_id_and_threads_meta(): captured = {} - async def _fake_stream(*, url, credentials, payload, run_id): + async def _fake_stream(*, url, credentials, payload, run_id, strict_first_record): captured["url"] = url captured["payload"] = payload captured["run_id"] = run_id + captured["strict_first_record"] = strict_first_record from oss.src.core.workflows.dtos import WorkflowServiceDetachedResponse return WorkflowServiceDetachedResponse(run_id=run_id, accepted=True) @@ -162,6 +291,32 @@ async def _fake_stream(*, url, credentials, payload, run_id): # The coordination ids are threaded onto the request meta (Foundation B handoff). assert captured["payload"]["meta"]["run_id"] == "run-fixed" assert captured["payload"]["meta"]["project_id"] == str(project_id) + assert captured["strict_first_record"] is False + + +async def test_invoke_workflow_detached_enables_strict_handshake_for_control_command(): + svc = _service() + svc._prepare_invoke = AsyncMock(return_value=("Secret tok", "http://svc")) + captured = {} + + async def _fake_stream(*, url, credentials, payload, run_id, strict_first_record): + captured["strict_first_record"] = strict_first_record + from oss.src.core.workflows.dtos import WorkflowServiceDetachedResponse + + return WorkflowServiceDetachedResponse(run_id=run_id, accepted=True) + + svc._stream_service_started = _fake_stream + + from agenta.sdk.decorators.running import WorkflowServiceRequest + + await svc.invoke_workflow_detached( + project_id=uuid4(), + user_id=uuid4(), + request=WorkflowServiceRequest(meta={"control_command_id": str(uuid4())}), + run_id="run-control", + ) + + assert captured["strict_first_record"] is True async def test_invoke_workflow_detached_raises_when_no_service_url(): @@ -194,6 +349,48 @@ async def test_invoke_workflow_batch_still_returns_400_when_no_service_url(): assert result.status.code == 400 +async def test_ordinary_session_invoke_redelivers_recoverable_continuation(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + svc = _service() + resume = AsyncMock(return_value=True) + svc.set_session_continuation_resumer(resume) + svc._prepare_invoke = AsyncMock() + + from agenta.sdk.decorators.running import WorkflowServiceRequest + + project_id = uuid4() + result = await svc.invoke_workflow( + project_id=project_id, + user_id=uuid4(), + request=WorkflowServiceRequest(session_id="session-1"), + ) + + assert result.status.code == 409 + resume.assert_awaited_once_with(project_id=project_id, session_id="session-1") + svc._prepare_invoke.assert_not_awaited() + + +async def test_control_continuation_bypasses_ordinary_send_recovery_hook(monkeypatch): + monkeypatch.setattr(env.agenta.sessions, "durable_approvals", True) + svc = _service() + resume = AsyncMock(return_value=True) + svc.set_session_continuation_resumer(resume) + svc._prepare_invoke = AsyncMock(return_value=("Secret tok", None)) + + from agenta.sdk.decorators.running import WorkflowServiceRequest + + result = await svc.invoke_workflow( + project_id=uuid4(), + user_id=uuid4(), + request=WorkflowServiceRequest( + session_id="session-1", meta={"control_command_id": "command-1"} + ), + ) + + assert result.status.code == 400 + resume.assert_not_awaited() + + def test_dispatch_fn_injected_into_both_consumers(): """The entrypoint wires a real dispatch_fn into both detached consumers.""" from oss.src.tasks.asyncio.sessions.interactions_dispatcher import ( diff --git a/api/pyproject.toml b/api/pyproject.toml index acb318e7cac..d80951009d0 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "api" -version = "0.115.1" +version = "0.115.2" description = "Agenta API" requires-python = ">=3.11,<3.14" authors = [ diff --git a/api/uv.lock b/api/uv.lock index d38067daff9..337db3d3055 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agenta" -version = "0.115.1" +version = "0.115.2" source = { editable = "../sdks/python" } dependencies = [ { name = "agenta-client" }, @@ -72,7 +72,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.115.1" +version = "0.115.2" source = { editable = "../clients/python" } dependencies = [ { name = "httpx" }, @@ -276,7 +276,7 @@ wheels = [ [[package]] name = "api" -version = "0.115.1" +version = "0.115.2" source = { virtual = "." } dependencies = [ { name = "agenta" }, diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index 6bd3db608f9..dd101668e58 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agenta-client" -version = "0.115.1" +version = "0.115.2" description = "Fern-generated Python client for the Agenta API." requires-python = ">=3.11,<3.14" authors = [ diff --git a/clients/python/uv.lock b/clients/python/uv.lock index 05acbe65210..16ac5c18eb3 100644 --- a/clients/python/uv.lock +++ b/clients/python/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11, <3.14" [[package]] name = "agenta-client" -version = "0.115.1" +version = "0.115.2" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/docs/design/session-control-and-live-events/contracts/commands.md b/docs/design/session-control-and-live-events/contracts/commands.md new file mode 100644 index 00000000000..7d31f529cc2 --- /dev/null +++ b/docs/design/session-control-and-live-events/contracts/commands.md @@ -0,0 +1,92 @@ +# Private command contract + +> **AGENT-GENERATED, low weight.** + +## Purpose + +Commands preserve execution-changing intent independently from delivery. Public routes accept +operations, while a private command store and adapter deliver them to the runner. + +## Command shape + +```text +command_id +session_id +type +expected_execution_id +payload: typed by command type +status: pending | claimed | applied | obsolete | lost +claimed_by +claim_expires_at +attempt_count +next_attempt_at +created_at +applied_at +result +``` + +`expected_execution_id` has the same name and meaning at the public and private boundaries. Each +command type defines its payload. A free-form payload is not part of the contract. + +The command ID remains stable across retries. The runner applies one command ID at most once. + +## Delivery port + +The commands domain owns this transport port: + +```text +deliver(command) -> receipt +``` + +The receipt reports whether the runner accepted, duplicated, or refused delivery. It does not +settle the command. The command service owns settlement, retry scheduling, and recovery. + +## State transitions + +```text +pending -> claimed -> applied + -> obsolete + -> lost +``` + +`pending` and `claimed` describe private delivery. Public clients follow execution state and +durable terminal events. + +## Continuation admission + +A continuation command owns the next Send while its execution is `pending_delivery` or +`recoverable`. Once it is `applied/started`, Send admission depends on the continuation phase: + +- **EXECUTING:** the client holds a Send and delivers it after the continuation ends. If a client + sends it anyway, the server refuses it so it cannot supersede the executing continuation. +- **PARKED:** the server accepts a Send as a steer, just as it does for an initial execution parked + for human input. + +If the watchdog moves the execution to `recoverable`, preflight may reopen and redeliver it before +accepting a new message. The client hold is an ordering guarantee; the server refusal is the race +backstop for stale or non-conforming clients. + +## Recovery rules + +- A delivery failure leaves the command `pending`. +- A delivery timeout has an unknown result, so recovery reuses the same command ID. +- A `pending` command whose session still beats is redelivered with bounded attempts. +- A `pending` command whose runner is gone settles `lost`. +- Duplicate delivery returns the existing receipt and applies no second effect. +- Normal shutdown releases claims. Forced shutdown relies on lease expiry and the sweep. +- Each sweep pass has a time bound. A timeout is logged and does not stop later passes. + +## Settlement rules + +The execution row chooses one terminal winner through a compare-and-set. The runner and watchdog +call the same settlement service. Only the winner writes the effective terminal event. + +Where the data shares a database, one transaction settles the command, clears the stopping marker, +updates the session mirror, and cancels pending interactions for the target execution. Redis +liveness changes after commit through an idempotent write. A sweep repairs a missed Redis write. + +## Stop and interaction races + +Both transactions lock the execution row first and the interaction row second. Each update checks +the exact expected state. A winning Stop cancels only interactions owned by its target execution. +A winning response creates one continuation execution and command. diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index e90fdd70593..a3290bcc1e6 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -518,7 +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} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === 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 b1092ce9ea1..12dd2752a3b 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.local.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.local.yml @@ -346,7 +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} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === 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 7c3bc8bdcc8..7aaaff28291 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -355,7 +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} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === 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 6cb33aa2012..82f3bb75f42 100644 --- a/hosting/docker-compose/ee/env.ee.dev.example +++ b/hosting/docker-compose/ee/env.ee.dev.example @@ -140,13 +140,14 @@ AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local AGENTA_SESSIONS_DURABLE_STOP=true # AGENTA_SESSIONS_LATE_OUTPUT=quarantine -# --- Live session relay (opt-in) --- +# --- Live session relay (enabled by default) --- # 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 +# Set these switches to false to disable their respective session features. +# AGENTA_RUNNER_LIVE_FRAMES=true +# AGENTA_SESSIONS_SHARED_READER=true +# AGENTA_SESSIONS_SEQUENCE_WRITES=true # AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 # AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index 94398f700c1..85b044150c0 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -141,13 +141,14 @@ AGENTA_RUNNER_TOKEN=replace-me # cap (higher-fidelity reconstruction). Still opt-in, default off. # AGENTA_RECORDS_SMART_TRUNCATION=true -# --- Live session relay (opt-in) --- +# --- Live session relay (enabled by default) --- # 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 +# Set these switches to false to disable their respective session features. +# AGENTA_RUNNER_LIVE_FRAMES=true +# AGENTA_SESSIONS_SHARED_READER=true +# AGENTA_SESSIONS_SEQUENCE_WRITES=true # AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 # AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index be93fd29a8c..1ecc1ff673f 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -483,7 +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} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === 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 f6ab7134c8f..d8befb3f562 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.local.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.local.yml @@ -342,7 +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} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === 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 ed34c52f944..51b8b734daa 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml @@ -368,7 +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} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === 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 952d971bf1d..f579f32db5d 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -373,7 +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} + AGENTA_RUNNER_LIVE_FRAMES: ${AGENTA_RUNNER_LIVE_FRAMES:-true} # === 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 7a84c167870..d4d65187be3 100644 --- a/hosting/docker-compose/oss/env.oss.dev.example +++ b/hosting/docker-compose/oss/env.oss.dev.example @@ -146,13 +146,14 @@ NEXT_PUBLIC_AGENT_FILE_UPLOADS=true AGENTA_SESSIONS_DURABLE_STOP=true # AGENTA_SESSIONS_LATE_OUTPUT=quarantine -# --- Live session relay (opt-in) --- +# --- Live session relay (enabled by default) --- # 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 +# Set these switches to false to disable their respective session features. +# AGENTA_RUNNER_LIVE_FRAMES=true +# AGENTA_SESSIONS_SHARED_READER=true +# AGENTA_SESSIONS_SEQUENCE_WRITES=true # AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 # AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index dacff6d5b24..6d6f8fa5423 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -146,13 +146,14 @@ AGENTA_RUNNER_TOKEN=replace-me # cap (higher-fidelity reconstruction). Still opt-in, default off. # AGENTA_RECORDS_SMART_TRUNCATION=true -# --- Live session relay (opt-in) --- +# --- Live session relay (enabled by default) --- # 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 +# Set these switches to false to disable their respective session features. +# AGENTA_RUNNER_LIVE_FRAMES=true +# AGENTA_SESSIONS_SHARED_READER=true +# AGENTA_SESSIONS_SEQUENCE_WRITES=true # AGENTA_SESSIONS_LIVE_AUTH_RECHECK_SECONDS=60 # AGENTA_SESSIONS_LIVE_READER_BUFFER_LIMIT=256 diff --git a/hosting/kubernetes/helm/Chart.yaml b/hosting/kubernetes/helm/Chart.yaml index 18586ecf5c0..c61577f0e41 100644 --- a/hosting/kubernetes/helm/Chart.yaml +++ b/hosting/kubernetes/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: agenta description: A Helm chart for deploying Agenta (OSS or EE) on Kubernetes type: application -version: 0.115.1 -appVersion: "v0.115.1" +version: 0.115.2 +appVersion: "v0.115.2" keywords: - agenta - llm diff --git a/hosting/kubernetes/helm/values.schema.json b/hosting/kubernetes/helm/values.schema.json index 4ed369b9773..e5f1683d6fd 100644 --- a/hosting/kubernetes/helm/values.schema.json +++ b/hosting/kubernetes/helm/values.schema.json @@ -307,7 +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." }, + "liveFrames": { "type": "boolean", "description": "AGENTA_RUNNER_LIVE_FRAMES; temporary live-frame publication. Defaults to true." }, "providers": { "type": "object", "additionalProperties": false, diff --git a/hosting/kubernetes/helm/values.yaml b/hosting/kubernetes/helm/values.yaml index 64f7e4791e3..d6ba68a9b20 100644 --- a/hosting/kubernetes/helm/values.yaml +++ b/hosting/kubernetes/helm/values.yaml @@ -138,7 +138,7 @@ redisDurable: # ================================================================== # # agentRunner: # enabled: true -# liveFrames: false # AGENTA_RUNNER_LIVE_FRAMES; opt in to temporary live-frame relay +# liveFrames: true # AGENTA_RUNNER_LIVE_FRAMES; set false to disable 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/sdks/python/agenta/sdk/agents/adapters/local.py b/sdks/python/agenta/sdk/agents/adapters/local.py index 7417a93640a..b67342faca8 100644 --- a/sdks/python/agenta/sdk/agents/adapters/local.py +++ b/sdks/python/agenta/sdk/agents/adapters/local.py @@ -49,6 +49,9 @@ async def create_session( run_context: Optional[RunContext] = None, session_id: Optional[str] = None, detached: bool = False, + turn_id: Optional[str] = None, + project_id: Optional[str] = None, + control_command_id: Optional[str] = None, 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 f4429357f6d..ca978875be7 100644 --- a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py +++ b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py @@ -69,6 +69,9 @@ def __init__( run_context: Optional[RunContext], session_id: Optional[str], detached: bool = False, + turn_id: Optional[str], + project_id: Optional[str], + control_command_id: Optional[str], effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> None: @@ -80,6 +83,9 @@ def __init__( self._run_context = run_context self._session_id = session_id self._detached = detached + self._turn_id = turn_id + self._project_id = project_id + self._control_command_id = control_command_id self._effective_parameters = effective_parameters self._gateway_policy = gateway_policy @@ -98,6 +104,9 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: run_context=self._run_context, session_id=self._session_id, detached=self._detached, + turn_id=self._turn_id, + project_id=self._project_id, + control_command_id=self._control_command_id, effective_parameters=self._effective_parameters, gateway_policy=self._gateway_policy, ) @@ -172,6 +181,9 @@ async def create_session( run_context: Optional[RunContext] = None, session_id: Optional[str] = None, detached: bool = False, + turn_id: Optional[str] = None, + project_id: Optional[str] = None, + control_command_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> SandboxAgentSession: @@ -188,6 +200,9 @@ async def create_session( run_context=run_context, session_id=session_id, detached=detached, + turn_id=turn_id, + project_id=project_id, + control_command_id=control_command_id, 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 88e32459c4c..f725c6e3774 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -1170,6 +1170,12 @@ class SessionConfig(BaseModel): session_id: Optional[str] = None # Explicit per-invoke ownership handoff. False preserves request-owned cancellation. detached: bool = False + # Coordination identities supplied by the workflow service in request.meta. They remain + # per-turn transport metadata: the harness never consumes them, but the runner uses turn_id as + # the fresh execution guard and control_command_id to deduplicate durable continuation delivery. + turn_id: Optional[str] = None + project_id: Optional[str] = None + control_command_id: Optional[str] = None # 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 ebd467640ce..ce8c0bf56a9 100644 --- a/sdks/python/agenta/sdk/agents/handler.py +++ b/sdks/python/agenta/sdk/agents/handler.py @@ -304,6 +304,16 @@ async def _agent( base = rc or RunContext() rc = base.model_copy(update={"run": RunContextRun(kind=run_kind)}) + # Detached session coordination rides the generic workflow request metadata. Keep these + # values out of harness configuration: they identify this delivery/execution only. In + # particular, a durable approval retry repeats control_command_id while run_id names the + # one fresh continuation execution the runner must admit at most once. + request_meta = request.meta or {} + + def _meta_string(name: str) -> Optional[str]: + value = request_meta.get(name) + return value.strip() if isinstance(value, str) and value.strip() else None + session_config = SessionConfig( agent=agent_template, resolved_connection=resolved_connection, @@ -312,6 +322,9 @@ async def _agent( run_context=rc, session_id=session_id, detached=bool(flags.detached), + turn_id=_meta_string("run_id"), + project_id=_meta_string("project_id"), + control_command_id=_meta_string("control_command_id"), # 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 6e6ed92a83c..8c78b46a4bb 100644 --- a/sdks/python/agenta/sdk/agents/interfaces.py +++ b/sdks/python/agenta/sdk/agents/interfaces.py @@ -131,6 +131,9 @@ async def create_session( run_context: Optional[RunContext] = None, session_id: Optional[str] = None, detached: bool = False, + turn_id: Optional[str] = None, + project_id: Optional[str] = None, + control_command_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> Session: @@ -203,6 +206,9 @@ async def create_session( run_context=session_config.run_context, session_id=session_config.session_id, detached=session_config.detached, + turn_id=session_config.turn_id, + project_id=session_config.project_id, + control_command_id=session_config.control_command_id, effective_parameters=session_config.effective_parameters, gateway_policy=session_config.gateway_policy, ) diff --git a/sdks/python/agenta/sdk/agents/utils/effective_config.py b/sdks/python/agenta/sdk/agents/utils/effective_config.py index 3911bd4069f..57ec9590ea1 100644 --- a/sdks/python/agenta/sdk/agents/utils/effective_config.py +++ b/sdks/python/agenta/sdk/agents/utils/effective_config.py @@ -19,8 +19,8 @@ replayed run re-resolves the same credentials from the project vault. The cost is deliberate: an author who inlines a static header into an MCP connection loses that header on a replayed resume rather than having it persisted in a second place. -- **Size cap.** Measured over the dev corpus (n=326 revisions with parameters): avg 761 B, - p90 1.4 KB, max 20 KB — the large ones are entirely tool JSON-Schema. Anything over +- **Size cap.** The playground builder config includes its tool catalog and measured + 147 KB in live QA, larger than saved agent revisions. Anything over :data:`MAX_STAMPED_BYTES` is dropped WHOLE with a warning (a truncated blob would be invalid JSON, and a silently truncated config is worse than none); the resume then degrades to today's references-only hydration. @@ -35,8 +35,8 @@ log = get_module_logger(__name__) -# 3x the largest config measured in the dev corpus. Over this the blob is not stamped at all. -MAX_STAMPED_BYTES = 64 * 1024 +# Keep normal builder turns (147 KB observed) resumable while bounding durable config size. +MAX_STAMPED_BYTES = 256 * 1024 # Keys that can hold a raw credential VALUE on a tool/MCP entry or its connection descriptor. # `credentials` is deliberately NOT here: it holds vault key names, which the replay needs. diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index b1293a64ab4..07b35fb2ab2 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -96,6 +96,7 @@ def request_to_wire( detached: bool = False, turn_id: Optional[str] = None, project_id: Optional[str] = None, + control_command_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> Dict[str, Any]: @@ -177,6 +178,8 @@ def request_to_wire( payload["detached"] = True if project_id is not None: payload["projectId"] = project_id + if control_command_id is not None: + payload["controlCommandId"] = control_command_id if session_id: stamped = stamp_effective_parameters(effective_parameters) if stamped: diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index 4a68139afb2..8cf142af770 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -522,6 +522,9 @@ class WireRunRequest(_WireModel): # persist the transcript independently of any client connection. Omitted on ad-hoc runs. turn_id: Optional[str] = Field(default=None, alias="turnId") project_id: Optional[str] = Field(default=None, alias="projectId") + # Stable id of the durable continuation command that caused this run. The runner admits one + # execution per id even when delivery is retried. + control_command_id: Optional[str] = Field(default=None, alias="controlCommandId") agents_md: Optional[str] = Field(default=None, alias="agentsMd") # Model id stays scalar. The author's connection CHOICE and what that choice RESOLVED to are # two separate fields: `connection` is non-secret routing config the runner reads directly, diff --git a/sdks/python/agenta/sdk/decorators/routing.py b/sdks/python/agenta/sdk/decorators/routing.py index 28348cefb4c..b4578c7d37a 100644 --- a/sdks/python/agenta/sdk/decorators/routing.py +++ b/sdks/python/agenta/sdk/decorators/routing.py @@ -1,6 +1,7 @@ # /agenta/sdk/decorators/routing.py import warnings +import httpx from typing import Any, Callable, Optional, AsyncGenerator, Union from json import dumps from uuid import UUID @@ -43,6 +44,7 @@ from agenta.sdk.contexts.tracing import TracingContext, tracing_context_manager from agenta.sdk.decorators.running import auto_workflow, inspect_workflow, Workflow from agenta.sdk.engines.running.errors import ErrorStatus +from agenta.sdk.agents.platform.connection import PlatformConnection # --------------------------------------------------------------------------- @@ -235,6 +237,79 @@ def apply_invoke_prelude(req: Request, request: WorkflowInvokeRequest) -> None: } +async def admit_session_input( + req: Request, + request: WorkflowInvokeRequest, + credentials: Optional[str], +) -> Optional[Response]: + if request.on_busy is None or request.session_id is None: + return None + if isinstance(request.meta, dict) and request.meta.get("promoted_input_id"): + return None + + connection = PlatformConnection(authorization=credentials) + api_base = connection.base_url() + if not api_base: + return JSONResponse( + status_code=503, + content={ + "code": "service_unavailable", + "message": "Session admission is unavailable.", + "retryable": True, + "next_step": "Retry with the same idempotency key.", + }, + ) + headers = connection.headers(authorization=credentials) + idempotency_key = req.headers.get("Idempotency-Key") + if idempotency_key: + headers["Idempotency-Key"] = idempotency_key + try: + async with httpx.AsyncClient(timeout=connection.timeout) as client: + response = await client.post( + f"{api_base}/sessions/control/inputs/admit", + headers=headers, + json={ + "session_id": request.session_id, + "content": request.model_dump(mode="json", exclude_none=True), + "on_busy": request.on_busy, + }, + ) + except httpx.HTTPError: + return JSONResponse( + status_code=503, + content={ + "code": "service_unavailable", + "message": "Session admission is unavailable.", + "retryable": True, + "next_step": "Retry with the same idempotency key.", + }, + ) + + if response.status_code == 200: + body = response.json() + execution_id = body.get("execution_id") + if isinstance(execution_id, str) and execution_id: + request.meta = {**(request.meta or {}), "run_id": execution_id} + return None + + try: + body = response.json() + except ValueError: + body = { + "code": "internal_error", + "message": "Session admission returned an invalid response.", + "retryable": True, + "next_step": "Retry with the same idempotency key.", + } + if ( + isinstance(body, dict) + and set(body) == {"detail"} + and isinstance(body["detail"], dict) + ): + body = body["detail"] + return JSONResponse(status_code=response.status_code, content=body) + + def _get_request_tracing_context(req: Request) -> TracingContext: context = TracingContext.get().model_copy(deep=True) otel = getattr(req.state, "otel", None) or {} @@ -632,6 +707,11 @@ async def invoke_endpoint(req: Request, request: WorkflowInvokeRequest): apply_invoke_prelude(req, request) try: + admission_response = await admit_session_input( + req, request, credentials + ) + if admission_response is not None: + return admission_response with tracing_context_manager(_get_request_tracing_context(req)): response = await wf.invoke( request=request, diff --git a/sdks/python/agenta/sdk/models/workflows.py b/sdks/python/agenta/sdk/models/workflows.py index 9fbb1772ec5..165c50b200c 100644 --- a/sdks/python/agenta/sdk/models/workflows.py +++ b/sdks/python/agenta/sdk/models/workflows.py @@ -286,6 +286,7 @@ def _coerce_nested_models(cls, values: Dict[str, Any]) -> Dict[str, Any]: class WorkflowInvokeRequest(WorkflowBaseRequest): data: Optional[WorkflowRequestData] = None + on_busy: Optional[Literal["reject", "queue", "steer"]] = None # back-compat alias 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 dcede9cf0cd..718cec45a8b 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 @@ -59,6 +59,9 @@ def __init__( run_context: Optional[RunContext], session_id: Optional[str], detached: bool = False, + turn_id: Optional[str] = None, + project_id: Optional[str] = None, + control_command_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> None: @@ -69,6 +72,9 @@ def __init__( self._run_context = run_context self._session_id = session_id self._detached = detached + self._turn_id = turn_id + self._project_id = project_id + self._control_command_id = control_command_id self._effective_parameters = effective_parameters self._gateway_policy = gateway_policy @@ -87,6 +93,9 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: run_context=self._run_context, session_id=self._session_id, detached=self._detached, + turn_id=self._turn_id, + project_id=self._project_id, + control_command_id=self._control_command_id, effective_parameters=self._effective_parameters, gateway_policy=self._gateway_policy, ) @@ -165,6 +174,9 @@ async def create_session( run_context: Optional[RunContext] = None, session_id: Optional[str] = None, detached: bool = False, + turn_id: Optional[str] = None, + project_id: Optional[str] = None, + control_command_id: Optional[str] = None, effective_parameters: Optional[Dict[str, Any]] = None, gateway_policy: Optional[ResolvedGatewayPolicy] = None, ) -> FakeRunnerSession: @@ -176,6 +188,9 @@ async def create_session( run_context=run_context, session_id=session_id, detached=detached, + turn_id=turn_id, + project_id=project_id, + control_command_id=control_command_id, 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 d2eab8fd4bf..58b88197d36 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/conftest.py +++ b/sdks/python/oss/tests/pytest/unit/agents/conftest.py @@ -146,6 +146,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, effective_parameters=None, gateway_policy=None, ) -> FakeSession: @@ -159,6 +162,9 @@ async def create_session( "run_context": run_context, "session_id": session_id, "detached": detached, + "turn_id": turn_id, + "project_id": project_id, + "control_command_id": control_command_id, "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 5412f0d68ed..756fa8dcf50 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 @@ -96,6 +96,7 @@ def __init__(self, *, output: str = "hi") -> None: self.created_effective_parameters: List[Any] = [] self.created_gateway_policies: List[Any] = [] self.created_detached: List[bool] = [] + self.created_coordination: List[Any] = [] # 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] = [] @@ -114,6 +115,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: @@ -121,6 +125,9 @@ async def create_session( self.created_effective_parameters.append(effective_parameters) self.created_gateway_policies.append(gateway_policy) self.created_detached.append(detached) + self.created_coordination.append( + (session_id, turn_id, project_id, control_command_id) + ) self.created_configs.append(config) return _FakeSession(AgentResult(output=self._output, events=[], usage={})) @@ -215,6 +222,33 @@ async def test_absent_run_kind_leaves_composition_run_context_untouched(): assert ctx.to_wire() == {"trace": {"trace_id": "trace-1"}} +async def test_detached_coordination_meta_reaches_the_backend_session(): + backend = _FakeBackend() + handler = make_agent_handler( + AgentComposition( + select_backend=lambda template: backend, + resolve_connection=_no_connection, + ) + ) + + await handler( + request=WorkflowServiceRequest( + session_id="session-1", + meta={ + "run_id": "turn-continuation-1", + "project_id": "project-1", + "control_command_id": "command-1", + }, + ), + messages=[{"role": "user", "content": "approved"}], + parameters=_params(), + ) + + assert backend.created_coordination == [ + ("session-1", "turn-continuation-1", "project-1", "command-1") + ] + + async def test_handler_carries_the_effective_config_onto_the_session(): """The config the handler RAN with reaches the session, verbatim. 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 bd9978ab54e..32f46fca91a 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 @@ -98,6 +98,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, # 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 0dedfcea60d..cf68c820641 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 @@ -102,6 +102,7 @@ "turnId", "detached", "projectId", + "controlCommandId", "effectiveParameters", } @@ -774,6 +775,36 @@ def test_effective_parameters_preserve_tool_input_schema_properties(): assert payload["effectiveParameters"]["agent"]["tools"][0] == tool +def test_effective_parameters_preserve_builder_sized_configuration(): + # The normal playground builder measured 147,209 bytes with 18 tools. Dropping + # that config silently resumes against the saved agent, which has no builder tools. + parameters = { + "agent": { + "instructions": {"agents_md": "Build and update the current agent."}, + "tools": [ + { + "name": "commit_revision" + if index == 0 + else f"builder_tool_{index}", + "description": "Builder operation schema documentation. " * 210, + "inputSchema": {"type": "object", "properties": {}}, + } + for index in range(18) + ], + } + } + assert 147_209 <= len(json.dumps(parameters).encode("utf-8")) <= 160_000 + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(), + messages=[Message(role="user", content="Configure this agent")], + session_id="sess-builder", + effective_parameters=parameters, + ) + assert payload["effectiveParameters"] == parameters + + def test_effective_parameters_over_the_cap_are_dropped_whole(): # A truncated config is invalid JSON and a silently-truncated one is worse than none, so an # oversize blob is not stamped at all (the resume degrades to reference hydration). @@ -1047,6 +1078,28 @@ def test_known_request_keys_match_the_wire_schema(): assert declared == KNOWN_REQUEST_KEYS +def test_request_to_wire_carries_durable_continuation_coordination_ids(): + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(model="openai/gpt-5.5"), + messages=[Message(role="user", content="approved")], + session_id="session-1", + turn_id="turn-continuation-1", + project_id="project-1", + control_command_id="command-1", + ) + + assert payload["sessionId"] == "session-1" + assert payload["turnId"] == "turn-continuation-1" + assert payload["projectId"] == "project-1" + assert payload["controlCommandId"] == "command-1" + assert set(payload) <= KNOWN_REQUEST_KEYS + + parsed = WireRunRequest.model_validate(payload) + assert parsed.control_command_id == "command-1" + + def test_named_connection_choice_is_a_declared_schema_field(): """A named Agenta connection reaches the runner as a first-class field, not as an extra. 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 a5f7e22b4a3..e36b2aee898 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 @@ -141,6 +141,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: diff --git a/sdks/python/oss/tests/pytest/unit/test_invoke_dispatch_parity_routing.py b/sdks/python/oss/tests/pytest/unit/test_invoke_dispatch_parity_routing.py index e78e6b3b95d..d9a32570ae5 100644 --- a/sdks/python/oss/tests/pytest/unit/test_invoke_dispatch_parity_routing.py +++ b/sdks/python/oss/tests/pytest/unit/test_invoke_dispatch_parity_routing.py @@ -24,6 +24,7 @@ from agenta.sdk.decorators.routing import ( route, apply_invoke_prelude, + admit_session_input, handle_invoke_success, handle_invoke_failure, ) @@ -74,6 +75,9 @@ async def dispatch_invoke(req: Request, request: WorkflowInvokeRequest): credentials = req.state.auth.get("credentials") apply_invoke_prelude(req, request) try: + admission_response = await admit_session_input(req, request, credentials) + if admission_response is not None: + return admission_response response = await invoke_workflow(request=request, credentials=credentials) return await handle_invoke_success(req, response) except Exception as exception: 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 edee6ae64ff..815e39c1512 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 @@ -145,6 +145,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, effective_parameters=None, gateway_policy=None, ) -> _FakeSession: diff --git a/sdks/python/oss/tests/pytest/unit/test_session_input_admission_routing.py b/sdks/python/oss/tests/pytest/unit/test_session_input_admission_routing.py new file mode 100644 index 00000000000..cb76bc968b4 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/test_session_input_admission_routing.py @@ -0,0 +1,160 @@ +"""Durable Queue/Steer admission at the two shared invoke entrypoints.""" + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from starlette.requests import Request + +from agenta.sdk.decorators.routing import admit_session_input +from agenta.sdk.models.workflows import WorkflowInvokeRequest + + +def _request(*, idempotency_key: str = "input-1") -> Request: + return Request( + { + "type": "http", + "method": "POST", + "path": "/invoke", + "headers": [(b"idempotency-key", idempotency_key.encode())], + "query_string": b"", + } + ) + + +class _Client: + def __init__(self, response, captured): + self.response = response + self.captured = captured + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + async def post(self, url, **kwargs): + self.captured.update({"url": url, **kwargs}) + if isinstance(self.response, Exception): + raise self.response + return self.response + + +def _platform(): + connection = MagicMock() + connection.base_url.return_value = "https://platform.example/api" + connection.headers.return_value = {"Authorization": "opaque-test-value"} + connection.timeout = 3 + return connection + + +@pytest.mark.asyncio +async def test_queue_admission_forwards_the_stable_key_and_returns_202(): + captured = {} + upstream = SimpleNamespace( + status_code=202, + json=lambda: { + "action": "pending", + "input": {"id": "00000000-0000-0000-0000-000000000001"}, + }, + ) + request = WorkflowInvokeRequest( + session_id="session-1", + on_busy="queue", + data={"inputs": {"messages": [{"role": "user", "content": "later"}]}}, + ) + + with ( + patch( + "agenta.sdk.decorators.routing.PlatformConnection", + return_value=_platform(), + ), + patch( + "agenta.sdk.decorators.routing.httpx.AsyncClient", + return_value=_Client(upstream, captured), + ), + ): + response = await admit_session_input(_request(), request, "ApiKey caller") + + assert response.status_code == 202 + assert json.loads(response.body)["action"] == "pending" + assert ( + captured["url"] == "https://platform.example/api/sessions/control/inputs/admit" + ) + assert captured["headers"]["Idempotency-Key"] == "input-1" + assert captured["json"]["on_busy"] == "queue" + assert captured["json"]["content"]["session_id"] == "session-1" + + +@pytest.mark.asyncio +async def test_idle_admission_continues_invoke_with_the_server_execution_id(): + captured = {} + upstream = SimpleNamespace( + status_code=200, + json=lambda: {"action": "execute", "execution_id": "execution-2"}, + ) + request = WorkflowInvokeRequest( + session_id="session-1", + on_busy="queue", + data={"inputs": {"value": "now"}}, + ) + + with ( + patch( + "agenta.sdk.decorators.routing.PlatformConnection", + return_value=_platform(), + ), + patch( + "agenta.sdk.decorators.routing.httpx.AsyncClient", + return_value=_Client(upstream, captured), + ), + ): + response = await admit_session_input(_request(), request, "ApiKey caller") + + assert response is None + assert request.meta["run_id"] == "execution-2" + + +@pytest.mark.asyncio +async def test_promoted_input_skips_admission_to_avoid_recursive_queueing(): + request = WorkflowInvokeRequest( + session_id="session-1", + on_busy="queue", + meta={"promoted_input_id": "00000000-0000-0000-0000-000000000001"}, + data={"inputs": {"value": "promoted"}}, + ) + + with patch("agenta.sdk.decorators.routing.httpx.AsyncClient") as client: + response = await admit_session_input(_request(), request, "ApiKey caller") + + assert response is None + client.assert_not_called() + + +@pytest.mark.asyncio +async def test_precommit_transport_failure_returns_a_retryable_503(): + request = WorkflowInvokeRequest( + session_id="session-1", + on_busy="steer", + data={"inputs": {"value": "urgent"}}, + ) + transport_error = httpx.ConnectError("unreachable") + + with ( + patch( + "agenta.sdk.decorators.routing.PlatformConnection", + return_value=_platform(), + ), + patch( + "agenta.sdk.decorators.routing.httpx.AsyncClient", + return_value=_Client(transport_error, {}), + ), + ): + response = await admit_session_input(_request(), request, "ApiKey caller") + + body = json.loads(response.body) + assert response.status_code == 503 + assert body["retryable"] is True + assert body["next_step"] == "Retry with the same idempotency key." diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 9ad4713f066..fabf0b9ad77 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agenta" -version = "0.115.1" +version = "0.115.2" description = "Agenta is the open-source workspace for your agents. Build agents through chat, improve them with feedback, and share them with your team." readme = "README.md" requires-python = ">=3.11,<3.14" diff --git a/sdks/python/uv.lock b/sdks/python/uv.lock index 1c39c691c98..941abc8930c 100644 --- a/sdks/python/uv.lock +++ b/sdks/python/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11, <3.14" [[package]] name = "agenta" -version = "0.115.1" +version = "0.115.2" source = { editable = "." } dependencies = [ { name = "agenta-client" }, @@ -85,7 +85,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.115.1" +version = "0.115.2" source = { editable = "../../clients/python" } dependencies = [ { name = "httpx" }, diff --git a/services/entrypoints/main.py b/services/entrypoints/main.py index a2c49ef5985..1eef7cf5657 100644 --- a/services/entrypoints/main.py +++ b/services/entrypoints/main.py @@ -10,6 +10,7 @@ from agenta.sdk.decorators.routing import ( create_app, apply_invoke_prelude, + admit_session_input, handle_invoke_success, handle_invoke_failure, handle_inspect_success, @@ -87,6 +88,9 @@ async def services_invoke(req: Request, request: WorkflowInvokeRequest): credentials = req.state.auth.get("credentials") apply_invoke_prelude(req, request) try: + admission_response = await admit_session_input(req, request, credentials) + if admission_response is not None: + return admission_response response = await invoke_workflow(request=request, credentials=credentials) return await handle_invoke_success(req, response) except Exception as exception: diff --git a/services/oss/tests/pytest/unit/agent/conftest.py b/services/oss/tests/pytest/unit/agent/conftest.py index bab173b7d82..0ffd199f090 100644 --- a/services/oss/tests/pytest/unit/agent/conftest.py +++ b/services/oss/tests/pytest/unit/agent/conftest.py @@ -123,6 +123,9 @@ async def create_session( run_context=None, session_id=None, detached=False, + turn_id=None, + project_id=None, + control_command_id=None, # 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, diff --git a/services/pyproject.toml b/services/pyproject.toml index c077b94cebf..eb32eae89b3 100644 --- a/services/pyproject.toml +++ b/services/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "services" -version = "0.115.1" +version = "0.115.2" description = "Agenta Services (Chat & Completion)" requires-python = ">=3.11,<3.14" authors = [ diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index 027a326041f..8ffdca54623 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -776,6 +776,11 @@ export interface AgentRunRequest { * the runner can include it in heartbeat and record-ingest calls. Absent otherwise. */ projectId?: string; + /** + * Stable id of the durable continuation command that admitted this request. Repeated delivery + * carries the same id; the runner starts at most one execution for it. Omitted for ordinary runs. + */ + controlCommandId?: string; /** * The post-hydration config this turn runs, produced by the SDK (`agents/utils/wire.py`) and * OPAQUE here: the runner never reads inside it and never derives behavior from it. It is diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 124eb73a034..c71ffbf3e84 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -98,10 +98,13 @@ import { import { applyCommand, holdsSession, + reportContinuationAdmission, type ControlCommand, type ParkedSessionControl, } from "./sessions/control-channel.ts"; +import { claimContinuationAdmission } from "./sessions/continuation-admission.ts"; import { + findExecution, noteExecutionProject, registerExecution, unregisterExecution, @@ -475,11 +478,120 @@ async function runAndStreamWithApiBaseResolved( const sessionOwned = isSessionOwned(request); const detached = sessionOwned && request.detached === true; const sessionId = request.sessionId!; + const requestedTurnId = request.turnId?.trim(); const turnId = resolveTurnId(request); // Write the resolved id back: every downstream reader of `request.turnId` (the turns-ledger // append, interaction rows) must see the SAME execution id the alive-lock and records use. request.turnId = turnId; + const writeRecord = (record: StreamRecord): void => { + if (res.writableEnded) return; + res.write(JSON.stringify(record) + "\n"); + }; + const liveEmit: EmitEvent = (event) => writeRecord({ kind: "event", event }); + const turn = currentUserTurn(request); + const attachmentError = attachmentCountError(turn.attachments.length); + if (attachmentError) { + writeRecord({ + kind: "result", + result: { ok: false, error: attachmentError, events: [] }, + }); + res.end(); + return; + } + + const rawControlCommandId = request.controlCommandId; + const controlCommandId = + typeof rawControlCommandId === "string" + ? rawControlCommandId.trim() + : undefined; + if ( + rawControlCommandId !== undefined && + (!controlCommandId || !sessionOwned || !requestedTurnId) + ) { + writeRecord({ + kind: "result", + result: { + ok: false, + error: "A continuation command requires explicit sessionId and turnId.", + events: [], + }, + }); + res.end(); + return; + } + + // This is the continuation's exactly-once admission boundary. Everything above it is pure + // request validation and remains retryable. A duplicate never creates a controller, never + // replaces the live-execution registry entry, and never calls the engine. + let continuationAdmission = controlCommandId + ? claimContinuationAdmission(controlCommandId, turnId) + : undefined; + let continuationAlreadyAdmitted = false; + if (continuationAdmission?.role === "duplicate") { + const priorGenerationAdmitted = await continuationAdmission.admitted; + if (!priorGenerationAdmitted) { + writeRecord({ + kind: "result", + result: { + ok: false, + error: + "Continuation admission failed before execution started; retry delivery.", + events: [], + }, + }); + res.end(); + return; + } + try { + const admitted = await reportContinuationAdmission({ + commandId: controlCommandId!, + sessionId, + executionId: turnId, + }); + if (!admitted) { + const live = findExecution( + projectScopeFor(request, undefined)?.id ?? "", + sessionId, + ); + if (!live || live.turnId !== turnId) { + continuationAdmission.forget(); + } + writeRecord({ + kind: "result", + result: { + ok: true, + output: "", + stopReason: "control_command_duplicate", + events: [], + sessionId, + }, + }); + res.end(); + return; + } + const promoted = continuationAdmission.promote(); + if (!promoted) { + throw new Error( + "Continuation admission changed while recovering; retry delivery.", + ); + } + continuationAdmission = promoted; + continuationAlreadyAdmitted = true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write( + `[control] duplicate continuation report failed command=${controlCommandId}: ${message}\n`, + ); + writeRecord({ + kind: "result", + result: { ok: false, error: message, events: [] }, + }); + res.end(); + return; + } + } + // Diagnostic: surface whether the session-owned persist/alive path is entered and whether the // invoke credential arrived. Empty cred => heartbeat/persist would 401. The two empty cases have // different fixes, so name them apart: ABSENT means the caller sent no credential, DROPPED means @@ -504,6 +616,16 @@ async function runAndStreamWithApiBaseResolved( const interrupted = new Promise((resolve) => { markInterrupted = resolve; }); + let aliveWatchdog: + | { + release: () => Promise; + abandon: () => void; + credential: () => string; + streamId: () => string | undefined; + firstBeatOwned: boolean; + admitted: boolean; + } + | undefined; 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 @@ -521,11 +643,6 @@ async function runAndStreamWithApiBaseResolved( }); } - const writeRecord = (record: StreamRecord): void => { - if (res.writableEnded) return; - 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. // @@ -541,15 +658,115 @@ async function runAndStreamWithApiBaseResolved( transient: true, }); }; - const turn = currentUserTurn(request); - const attachmentError = attachmentCountError(turn.attachments.length); - if (attachmentError) { - writeRecord({ - kind: "result", - result: { ok: false, error: attachmentError, events: [] }, - }); - res.end(); - return; + + // The Stop handle is registered only AFTER admission succeeds, further down. A contender that + // the coordination plane refuses must never replace the admitted turn's handle, or a Stop for + // the live turn reaches the refused one and the live turn keeps running. + // + // A run with no project scope is not registered. `poolKeyFor` forms no key for it either, so + // it can never park, and Stop falls back to the heartbeat path exactly as it did before. + + if (sessionOwned) { + try { + // Await ownership before a durable continuation reports `started`. An ordinary run keeps + // its historical fail-open heartbeat behavior; only a continuation requires the first beat + // to affirm that this exact turn owns the coordination row. + aliveWatchdog = await startAliveWatchdog( + sessionId, + turnId, + platformCredentialForRequest(request), + () => { + markInterrupted?.( + "the platform reported this turn is no longer current (stopped, taken over, or " + + "declared lost)", + ); + controller.abort(USER_STOP_ABORT_REASON); + }, + { + name: proposeSessionName(request), + references: buildWorkflowReferenceList(request.runContext?.workflow), + }, + ); + request.streamId = aliveWatchdog.streamId(); + } catch (error) { + if (continuationAdmission?.role !== "leader") throw error; + continuationAdmission.release(); + unregisterExecution(sessionId, turnId); + const message = error instanceof Error ? error.message : String(error); + writeRecord({ + kind: "result", + result: { ok: false, error: message, events: [] }, + }); + res.end(); + return; + } + + if ( + continuationAdmission?.role === "leader" && + !aliveWatchdog.firstBeatOwned + ) { + continuationAdmission.release(); + aliveWatchdog.abandon(); + unregisterExecution(sessionId, turnId); + writeRecord({ + kind: "result", + result: { + ok: false, + error: + "Continuation could not establish alive ownership; retry delivery.", + events: [], + }, + }); + res.end(); + return; + } + } + + if (continuationAdmission?.role === "leader") { + try { + // The API settles the durable command and marks this execution running before the harness + // can observe the approval. If the callback fails, release the process-local claim and live + // registry entry: no engine work started, so redelivery is safe. + const admitted = continuationAlreadyAdmitted + ? true + : await reportContinuationAdmission({ + commandId: controlCommandId!, + sessionId, + executionId: turnId, + }); + if (!admitted) { + continuationAdmission.release(); + aliveWatchdog?.abandon(); + unregisterExecution(sessionId, turnId); + writeRecord({ + kind: "result", + result: { + ok: true, + output: "", + stopReason: "control_command_duplicate", + events: [], + sessionId, + }, + }); + res.end(); + return; + } + continuationAdmission.admit(); + } catch (error) { + continuationAdmission.release(); + aliveWatchdog?.abandon(); + unregisterExecution(sessionId, turnId); + const message = error instanceof Error ? error.message : String(error); + process.stderr.write( + `[control] continuation admission failed command=${controlCommandId}: ${message}\n`, + ); + writeRecord({ + kind: "result", + result: { ok: false, error: message, events: [] }, + }); + res.end(); + return; + } } // For session-owned runs: wrap the live emitter so every event is also persisted @@ -569,50 +786,27 @@ async function runAndStreamWithApiBaseResolved( | undefined; let persistTerminal: ((stopReason?: string) => void) | undefined; let terminalRecordEmitted = false; - let aliveWatchdog: - | { - release: () => Promise; - credential: () => string; - } - | undefined; try { if (sessionOwned) { - // The request's api base (if any) is already scoped for this call via - // runWithRequestApiBase in the outer runAndStream — apiBase() below sees it. - // The runner authenticates session calls AS the invoke caller (the run credential), - // refreshing it for the turn's lifetime — never the admin key. Project scope is - // resolved server-side from the credential, so no project_id rides the request. - // - // onInterrupted (W7.4): a cancel/steer/kill against this session (via - // `POST /sessions/streams/` or the runner's own `/kill`) drops this turn's alive lock. - // The next heartbeat surfaces that as `is_current_turn: false`; wiring it to - // `controller.abort()` is what makes the control-plane signal actually reach this - // in-flight run — before this, a session-owned run's controller was never aborted. - // Awaited (WP3) so the first heartbeat's stream_id is ready before the turn starts. - // - // The beat also proposes the two things a headless session otherwise never gets: a name - // (no browser ever renders it, and the browser is the only other title writer) and the - // run's workflow references (they ride only a fire-and-forget turn append today, so a - // dropped append leaves a row the UI cannot open). Both are fill-once server-side. - const watchdog = await startAliveWatchdog( - sessionId, - turnId, - platformCredentialForRequest(request), - () => { - markInterrupted?.( - "the platform reported this turn is no longer current (stopped, taken over, or " + - "declared lost)", - ); - // LABELLED, not a bare abort: `shouldPark` parks only an abort it can prove was a - // cooperative Stop. See `sessions/stop-signal.ts`. - controller.abort(USER_STOP_ABORT_REASON); - }, - { - name: proposeSessionName(request), - references: buildWorkflowReferenceList(request.runContext?.workflow), - }, - ); + // The request's api base (if any) is already scoped for this call via + // runWithRequestApiBase in the outer runAndStream — apiBase() below sees it. + // The runner authenticates session calls AS the invoke caller (the run credential), + // refreshing it for the turn's lifetime — never the admin key. Project scope is + // resolved server-side from the credential, so no project_id rides the request. + // + // onInterrupted (W7.4): a cancel/steer/kill against this session (via + // `POST /sessions/streams/` or the runner's own `/kill`) drops this turn's alive lock. + // The next heartbeat surfaces that as `is_current_turn: false`; wiring it to + // `controller.abort()` is what makes the control-plane signal actually reach this + // in-flight run — before this, a session-owned run's controller was never aborted. + // Awaited (WP3) so the first heartbeat's stream_id is ready before the turn starts. + // + // The beat also proposes the two things a headless session otherwise never gets: a name + // (no browser ever renders it, and the browser is the only other title writer) and the + // run's workflow references (they ride only a fire-and-forget turn append today, so a + // dropped append leaves a row the UI cannot open). Both are fill-once server-side. + const watchdog = aliveWatchdog!; aliveWatchdog = watchdog; // The heartbeat response already carries the session_streams row id — free, no extra // round-trip. Thread it onto the request so the engine's turn-append write has it. @@ -749,6 +943,7 @@ async function runAndStreamWithApiBaseResolved( } let result: AgentRunResult; + let teardownCompleted = true; try { // Not a bare `await run(...)`: an await inside the run that never settles would keep this // function parked forever, and with it the terminal record below AND the alive watchdog's @@ -791,6 +986,7 @@ async function runAndStreamWithApiBaseResolved( // The run is still pending and may never settle. Give the turn the ending the runner // owes it, and let the abandoned run keep its own teardown if it ever unwinds. turnClosed = true; + teardownCompleted = false; const message = `${ABANDONED_TURN_MARKER}: ${outcome.reason}`; process.stderr.write( `[sessions] ABANDONED session=${sessionId ?? "-"} turn=${turnId ?? "-"}: ${outcome.reason}\n`, @@ -837,7 +1033,7 @@ async function runAndStreamWithApiBaseResolved( // Same `finally` as the watchdog release, so a run that threw still leaves the registry // clean. Scoped to this turn id, so a turn that finishes after its successor registered // cannot unregister the successor. - if (sessionOwned) unregisterExecution(sessionId, turnId); + if (sessionOwned) unregisterExecution(sessionId, turnId, teardownCompleted); } // Streaming delivered the events live, so don't echo them in the terminal record. @@ -953,11 +1149,7 @@ function parkedSessionControl( keepaliveConfigs[provider].ttlMs, ), teardown: () => - pool.evictIfCurrent( - live, - "stop-approval-failed", - "failed-turn", - ), + pool.evictIfCurrent(live, "stop-approval-failed", "failed-turn"), }); }, }; @@ -1152,11 +1344,7 @@ export function createRequestListener( : "", }; if ( - !holdsSession( - cancelProjectId, - cancelSessionId, - parkedSessionControl, - ) + !holdsSession(cancelProjectId, cancelSessionId, parkedSessionControl) ) { // 404 is ambiguous on purpose and the API disambiguates it: a `not_held` for a // session whose row is alive and beating means the call reached the wrong replica. diff --git a/services/runner/src/sessions/alive.ts b/services/runner/src/sessions/alive.ts index eeeef93baf3..84906c194bc 100644 --- a/services/runner/src/sessions/alive.ts +++ b/services/runner/src/sessions/alive.ts @@ -132,12 +132,12 @@ export function ownedSessionCount(now: number = Date.now()): number { * Authenticates AS the invoke caller (the run credential) — project scope is resolved server-side * from that credential, so no `project_id` rides the request. * - * Returns both signals the one response body carries: `streamId` (the `session_streams` row + * Returns the signals the one response body carries: `streamId` (the `session_streams` row * uuid — the free gift of a call the runner already makes every turn, no new round-trip) and * `interrupted: true` when the API reports `is_current_turn: false` (a cancel/steer/kill took * this turn's alive/running lock since the last beat — W7.4, the control-signal path). A - * network/HTTP failure yields `confirmed: false`; callers use that to fail closed for initial - * admission while later watchdog beats remain best effort for a turn already admitted. + * network/HTTP failure is unconfirmed and unowned. Initial admission fails closed, and a durable + * continuation additionally requires an explicit ownership response before reporting `started`. */ async function sendHeartbeat( sessionId: string, @@ -149,6 +149,7 @@ async function sendHeartbeat( streamId: string | undefined; interrupted: boolean; confirmed: boolean; + owned: boolean; }> { try { const url = `${apiBase()}/sessions/streams/heartbeat`; @@ -172,7 +173,12 @@ async function sendHeartbeat( }); if (!res.ok) { log(`heartbeat HTTP ${res.status} session=${sessionId} turn=${turnId}`); - return { streamId: undefined, interrupted: false, confirmed: false }; + return { + streamId: undefined, + interrupted: false, + confirmed: false, + owned: false, + }; } const body = (await res.json()) as { stream?: { id?: unknown } | null; @@ -194,12 +200,18 @@ async function sendHeartbeat( log( `heartbeat OK session=${sessionId} turn=${turnId} running=${isRunning}${interrupted ? " INTERRUPTED" : ""}`, ); - return { streamId, interrupted, confirmed: true }; + const owned = body.is_current_turn === true; + return { streamId, interrupted, confirmed: true, owned }; } catch (err) { log( `heartbeat failed session=${sessionId} turn=${turnId}: ${String(err instanceof Error ? err.message : err).slice(0, 120)}`, ); - return { streamId: undefined, interrupted: false, confirmed: false }; + return { + streamId: undefined, + interrupted: false, + confirmed: false, + owned: false, + }; } } @@ -283,10 +295,14 @@ export async function startAliveWatchdog( proposal?: SessionProposal, ): Promise<{ release: () => Promise; + /** Stop heartbeating without publishing turn-end; used before durable admission. */ + abandon: () => void; credential: () => string; streamId: () => string | undefined; /** False when the FIRST beat reported `is_current_turn: false` — another turn owns the session. */ admitted: boolean; + /** True only when the awaited first heartbeat confirmed this turn owns the session. */ + firstBeatOwned: boolean; }> { // Session coordination and standalone turns share this lease. The watchdog owns it here so // heartbeat, persistence, and trace export all observe the same current credential. @@ -300,6 +316,7 @@ export async function startAliveWatchdog( const handleBeat = (result: { streamId: string | undefined; interrupted: boolean; + owned: boolean; }): void => { if (result.streamId) streamId = result.streamId; if (result.interrupted && !interruptedFired) { @@ -365,8 +382,13 @@ export async function startAliveWatchdog( proposal, ); }, + abandon() { + clearInterval(interval); + credentialLease.release(); + }, credential: credentialLease.credential, streamId: () => streamId, + firstBeatOwned: first.owned, }; } diff --git a/services/runner/src/sessions/applied-commands.ts b/services/runner/src/sessions/applied-commands.ts index 71e1206b149..6088863b857 100644 --- a/services/runner/src/sessions/applied-commands.ts +++ b/services/runner/src/sessions/applied-commands.ts @@ -23,6 +23,8 @@ export interface AppliedCommand { executionId: string | null; result: "applied" | "obsolete"; appliedAt: number; + /** Duplicate deliveries must respect the original execution's teardown boundary. */ + settled?: Promise; } /** diff --git a/services/runner/src/sessions/continuation-admission.ts b/services/runner/src/sessions/continuation-admission.ts new file mode 100644 index 00000000000..8fa92b92f17 --- /dev/null +++ b/services/runner/src/sessions/continuation-admission.ts @@ -0,0 +1,141 @@ +/** + * Process-local admission barrier for durable continuation commands. + * + * The API may deliver one committed command more than once. Every delivery carries the same + * `controlCommandId`; only its leader may cross the boundary into execution registration. A + * concurrent duplicate waits for that leader to finish the durable outcome callback, then + * acknowledges the same admission without starting an engine run. + * + * This is deliberately an admission cache, not the durable source of truth. The API command row is + * durable. A leader that cannot report admission releases its cache entry, so a later delivery may + * retry. Once the API accepts the `started` outcome, duplicates remain no-ops for the cache TTL. + */ + +const ADMISSION_TTL_MS = 30 * 60 * 1000; + +interface PendingAdmission { + phase: "pending"; + executionId: string; + insertedAt: number; + settled: Promise; + settle: (admitted: boolean) => void; +} + +interface AppliedAdmission { + phase: "applied"; + executionId: string; + insertedAt: number; +} + +type Admission = PendingAdmission | AppliedAdmission; + +export type ContinuationAdmissionLeader = { + role: "leader"; + executionId: string; + admit: () => void; + release: () => void; +}; + +export type ContinuationAdmissionClaim = + | ContinuationAdmissionLeader + | { + role: "duplicate"; + /** The first delivery's execution id is authoritative for duplicate outcome reports. */ + executionId: string; + /** False means the leader failed before durable admission and this delivery may be retried. */ + admitted: Promise; + /** Replace a stale applied cache entry after the API grants a recoverable generation. */ + promote: () => ContinuationAdmissionLeader | undefined; + /** Evict only the cache generation this duplicate observed. */ + forget: () => void; + }; + +const admissions = new Map(); + +function createLeader( + commandId: string, + executionId: string, + now: number, +): ContinuationAdmissionLeader { + let settle!: (admitted: boolean) => void; + const settled = new Promise((resolve) => { + settle = resolve; + }); + const pending: PendingAdmission = { + phase: "pending", + executionId, + insertedAt: now, + settled, + settle, + }; + admissions.set(commandId, pending); + + return { + role: "leader", + executionId, + admit: () => { + if (admissions.get(commandId) !== pending) return; + admissions.set(commandId, { + phase: "applied", + executionId, + insertedAt: now, + }); + pending.settle(true); + }, + release: () => { + if (admissions.get(commandId) !== pending) return; + admissions.delete(commandId); + pending.settle(false); + }, + }; +} + +/** Claim a command immediately before creating/registering its fresh execution guard. */ +export function claimContinuationAdmission( + commandId: string, + executionId: string, + now = Date.now(), +): ContinuationAdmissionClaim { + prune(now); + const existing = admissions.get(commandId); + if (existing) { + return { + role: "duplicate", + executionId: existing.executionId, + admitted: + existing.phase === "applied" ? Promise.resolve(true) : existing.settled, + promote: () => { + const current = admissions.get(commandId); + if (current && current !== existing) return undefined; + // A losing concurrent probe may evict the stale generation before this API-winning + // response returns. The durable API CAS is authoritative: its sole winner may recreate + // the local barrier when the old cache entry is still present or gone. It must never + // overwrite a newer pending generation: doing that strands every duplicate awaiting + // the newer generation's promise. + return createLeader(commandId, executionId, Date.now()); + }, + forget: () => { + if (admissions.get(commandId) === existing) + admissions.delete(commandId); + }, + }; + } + return createLeader(commandId, executionId, now); +} + +function prune(now: number): void { + for (const [commandId, admission] of admissions) { + if (now - admission.insertedAt >= ADMISSION_TTL_MS) { + admissions.delete(commandId); + if (admission.phase === "pending") admission.settle(false); + } + } +} + +/** Test seam: admission state belongs to the process and must not leak between cases. */ +export function resetContinuationAdmissionsForTest(): void { + for (const admission of admissions.values()) { + if (admission.phase === "pending") admission.settle(false); + } + admissions.clear(); +} diff --git a/services/runner/src/sessions/control-channel.ts b/services/runner/src/sessions/control-channel.ts index bc45e57f1ca..d780a6265db 100644 --- a/services/runner/src/sessions/control-channel.ts +++ b/services/runner/src/sessions/control-channel.ts @@ -25,6 +25,7 @@ */ import { apiBase } from "../apiBase.ts"; +import { envTimerMs } from "../env.ts"; import { REPLICA_ID } from "./alive.ts"; import { recallCommand, @@ -77,7 +78,10 @@ export interface ParkedLookup { export interface ApplyCommandDeps { /** Overridden in tests. Defaults to the module-level execution registry. */ - findLive?: (projectId: string, sessionId: string) => LiveExecution | undefined; + findLive?: ( + projectId: string, + sessionId: string, + ) => LiveExecution | undefined; /** Whether the keep-alive pool holds this session parked awaiting an approval. */ isParked?: ParkedLookup; /** Overridden in tests. Defaults to the HTTP report below. */ @@ -112,6 +116,7 @@ export async function applyCommand( if (seen) { // A no-op that STILL acknowledges. Aborting a second time could kill a newer turn; not // acknowledging would leave the command open until the settlement sweep gave up on it. + await seen.settled; const outcome: ControlOutcome = { result: seen.result, execution: { @@ -135,12 +140,17 @@ export async function applyCommand( // Remember BEFORE aborting. A duplicate that arrives while the first abort is still settling // must find the command already taken, not start a second one. + let settleCommand!: () => void; + const settled = new Promise((resolve) => { + settleCommand = resolve; + }); rememberCommand( { commandId: command.id, executionId: outcome.execution.id, executionState: outcome.execution.state, result: outcome.result, + settled, }, now(), ); @@ -152,6 +162,11 @@ export async function applyCommand( // ACP `session/cancel` to the harness and lets the environment be PARKED rather than // deleted (see `cancel-turn.ts` and `shouldPark`). Stop keeps the session warm. live.abort(); + if ((await live.released) === false) { + throw new Error( + "Stopped execution did not finish releasing its environment.", + ); + } log( `aborted command=${command.id} session=${command.sessionId} turn=${live.turnId}`, ); @@ -167,19 +182,25 @@ export async function applyCommand( } } catch (error) { const message = - error instanceof Error ? error.message : String(error ?? "abort failed"); + error instanceof Error + ? error.message + : String(error ?? "abort failed"); outcome.result = "applied"; outcome.execution.state = "failed"; outcome.execution.error = message.slice(0, 2000); - updateCommandOutcome(command.id, { result: "applied", executionState: "failed" }); - log(`abort FAILED command=${command.id} session=${command.sessionId}: ${message}`); + updateCommandOutcome(command.id, { + result: "applied", + executionState: "failed", + }); + log( + `abort FAILED command=${command.id} session=${command.sessionId}: ${message}`, + ); } } - // Reported as soon as the abort is issued, not after the harness settles. The command's job - // is to deliver the Stop; the turn's own teardown then writes its transcript and parks the - // sandbox on its own clock, which can take seconds. Waiting for it would make a Stop that - // worked look stuck. + settleCommand(); + // The transport already acknowledged Stop. A stopped outcome may promote Steer, so it + // must follow teardown rather than merely issuing the abort. await report(command, outcome).catch((error) => { log( `outcome report failed command=${command.id}: ${ @@ -241,7 +262,10 @@ function decideOutcome( // execution that can still be stopped. return { result: "obsolete", - execution: { id: command.target.turnId ?? live.turnId, state: "not_running" }, + execution: { + id: command.target.turnId ?? live.turnId, + state: "not_running", + }, }; } @@ -297,3 +321,55 @@ export async function reportOutcome( `outcome reported command=${command.id} session=${command.sessionId} state=${outcome.execution.state}`, ); } + +/** + * Confirm that a durable continuation command crossed the runner's admission barrier. + * + * Unlike Stop's best-effort terminal report, this acknowledgement is a prerequisite for starting + * the continuation engine: without it the API could redeliver after a transport failure and run the + * approved side effect twice. The API returns `admitted: true` only to the report that wins the + * pending/claimed-to-applied transition. An already-applied duplicate returns `false`, which is a + * successful acknowledgement but never permission to start an engine. A 409 is a real + * command/execution mismatch and must not start the engine. + */ +export async function reportContinuationAdmission(input: { + commandId: string; + sessionId: string; + executionId: string; +}): Promise { + const token = process.env.AGENTA_RUNNER_TOKEN; + if (!token) { + throw new Error("AGENTA_RUNNER_TOKEN is not set"); + } + const url = `${apiBase()}/sessions/control/commands/${encodeURIComponent(input.commandId)}/outcome`; + const res = await fetch(url, { + method: "POST", + signal: AbortSignal.timeout( + envTimerMs("AGENTA_RUNNER_CONTROL_OUTCOME_TIMEOUT_MS", 5_000), + ), + headers: { + "content-type": "application/json", + "x-agenta-runner-token": token, + }, + body: JSON.stringify({ + replica_id: REPLICA_ID, + result: "applied", + execution: { + id: input.executionId, + state: "started", + }, + }), + }); + if (!res.ok) { + throw new Error(`continuation admission outcome HTTP ${res.status}`); + } + const response = (await res.json()) as { admitted?: unknown }; + if (typeof response.admitted !== "boolean") { + throw new Error("continuation admission outcome omitted boolean admitted"); + } + log( + `continuation outcome command=${input.commandId} session=${input.sessionId} ` + + `turn=${input.executionId} admitted=${response.admitted}`, + ); + return response.admitted; +} diff --git a/services/runner/src/sessions/execution-registry.ts b/services/runner/src/sessions/execution-registry.ts index 0d83a2f3820..34059ea2bd2 100644 --- a/services/runner/src/sessions/execution-registry.ts +++ b/services/runner/src/sessions/execution-registry.ts @@ -53,11 +53,19 @@ export interface LiveExecution { * environment that was about to be parked. So the applier reads this flag and does nothing. */ settled?: boolean; + /** Resolves after teardown and the final ownership release, not merely prompt settlement. */ + released?: Promise; /** Stop the run. Aborting is what makes the turn end `cancelled`. */ abort: () => void; } const executions = new Map(); +const releases = new Map< + string, + { promise: Promise; resolve: (safeToContinue: boolean) => void } +>(); +const releaseKey = (sessionId: string, turnId: string) => + JSON.stringify([sessionId, turnId]); /** * Register a run as live. A second registration for the same session REPLACES the first, @@ -65,6 +73,17 @@ const executions = new Map(); * time a replacement turn starts. */ export function registerExecution(execution: LiveExecution): void { + const key = releaseKey(execution.sessionId, execution.turnId); + let completion = releases.get(key); + if (!completion) { + let resolve!: (safeToContinue: boolean) => void; + const promise = new Promise((done) => { + resolve = done; + }); + completion = { promise, resolve }; + releases.set(key, completion); + } + execution.released = completion.promise; executions.set(execution.sessionId, execution); } @@ -98,7 +117,14 @@ export function noteExecutionSettled(sessionId: string, turnId: string): void { * Remove a run, but only if it is still the one registered. A turn that finishes after its * successor registered must not unregister the successor. */ -export function unregisterExecution(sessionId: string, turnId: string): void { +export function unregisterExecution( + sessionId: string, + turnId: string, + safeToContinue = true, +): void { + const key = releaseKey(sessionId, turnId); + releases.get(key)?.resolve(safeToContinue); + releases.delete(key); const current = executions.get(sessionId); if (current && current.turnId === turnId) executions.delete(sessionId); } @@ -129,5 +155,7 @@ export function liveExecutions(): LiveExecution[] { /** Test seam: drop everything. Never called by the server. */ export function resetExecutionsForTest(): void { + for (const completion of releases.values()) completion.resolve(false); + releases.clear(); executions.clear(); } diff --git a/services/runner/src/sessions/live-frames.ts b/services/runner/src/sessions/live-frames.ts index e2a29b5edc3..ba4c6425d6c 100644 --- a/services/runner/src/sessions/live-frames.ts +++ b/services/runner/src/sessions/live-frames.ts @@ -52,7 +52,7 @@ interface LiveFramePublisherOptions { function envEnabled(): boolean { return ["1", "true", "yes", "on"].includes( - String(process.env[LIVE_FRAMES_ENV] ?? "") + String(process.env[LIVE_FRAMES_ENV] || "true") .trim() .toLowerCase(), ); diff --git a/services/runner/src/sessions/persist.ts b/services/runner/src/sessions/persist.ts index 885970c2c32..e642aa0b139 100644 --- a/services/runner/src/sessions/persist.ts +++ b/services/runner/src/sessions/persist.ts @@ -154,7 +154,7 @@ async function postEvent( export function persistEvent( sessionId: string, auth: () => string, - event: AgentEvent, + event: AgentEvent & { message_id?: string }, eventIndex: number, sender: string = "agent", recordId?: string, @@ -348,7 +348,7 @@ export function buildPersistingEmitter( persistEvent( sessionId, auth, - { type: "message", text: acc.text }, + { type: "message", text: acc.text, message_id: acc.id }, eventIndex++, "agent", undefined, @@ -378,7 +378,7 @@ export function buildPersistingEmitter( persistEvent( sessionId, auth, - { type: "thought", text: acc.text }, + { type: "thought", text: acc.text, message_id: acc.id }, eventIndex++, "agent", undefined, diff --git a/services/runner/src/tracing/otel.ts b/services/runner/src/tracing/otel.ts index b9ea3f01174..52bf0b9c317 100644 --- a/services/runner/src/tracing/otel.ts +++ b/services/runner/src/tracing/otel.ts @@ -2085,14 +2085,13 @@ export function createSandboxAgentOtel( // Mark a non-completing turn's terminal record so a cold reload can tell it from a real turn // boundary (the FE adoption heuristic and hydration read this). A completed turn omits it. // - // `cancelled` rides here for the same reason `paused` does, and closes a real gap: without - // it a stopped turn is indistinguishable from a finished one in Postgres, so neither the - // frontend nor the release gate can tell a Stop from a completion. Kept as an explicit - // allowlist rather than passing `stopReason` through, so a harness-reported value such as - // `end_turn` or `max_tokens` cannot start appearing on the terminal record by accident. + // These non-completing outcomes must remain distinguishable from a normal finish in Postgres. + // Keep an explicit allowlist so arbitrary harness reasons cannot leak into the record contract. record({ type: "done", - ...(stopReason === "paused" || stopReason === "cancelled" + ...(stopReason === "paused" || + stopReason === "cancelled" || + stopReason === "error" ? { stopReason } : {}), ...(runTraceId ? { traceId: runTraceId } : {}), diff --git a/services/runner/tests/unit/continuation-admission.test.ts b/services/runner/tests/unit/continuation-admission.test.ts new file mode 100644 index 00000000000..805112a0faa --- /dev/null +++ b/services/runner/tests/unit/continuation-admission.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "vitest"; + +import { + claimContinuationAdmission, + resetContinuationAdmissionsForTest, +} from "../../src/sessions/continuation-admission.ts"; + +afterEach(resetContinuationAdmissionsForTest); + +describe("durable continuation admission", () => { + it("allows one leader and makes a concurrent duplicate wait for its durable outcome", async () => { + const leader = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(leader.role, "leader"); + + const duplicate = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(duplicate.role, "duplicate"); + let duplicateSettled = false; + void duplicate.admitted.then(() => { + duplicateSettled = true; + }); + await Promise.resolve(); + assert.equal(duplicateSettled, false); + + leader.admit(); + assert.equal(await duplicate.admitted, true); + }); + + it("makes a failure before durable admission retryable", async () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + const waiting = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(waiting.role, "duplicate"); + + first.release(); + assert.equal(await waiting.admitted, false); + + const retry = claimContinuationAdmission("command-1", "turn-1", 3); + assert.equal(retry.role, "leader"); + }); + + it("pins duplicate reports to the first delivery's execution id", async () => { + const leader = claimContinuationAdmission("command-1", "turn-original", 1); + assert.equal(leader.role, "leader"); + leader.admit(); + + const duplicate = claimContinuationAdmission( + "command-1", + "turn-conflicting", + 2, + ); + assert.equal(duplicate.role, "duplicate"); + assert.equal(duplicate.executionId, "turn-original"); + assert.equal(await duplicate.admitted, true); + }); + + it("requires a fresh API admission decision after the applied cache expires", () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + first.admit(); + + const cached = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(cached.role, "duplicate"); + + const afterTtl = claimContinuationAdmission( + "command-1", + "turn-1", + 30 * 60 * 1000 + 1, + ); + assert.equal(afterTtl.role, "leader"); + }); + + it("evicts a stale applied generation after the API finds no live execution", async () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + first.admit(); + const stale = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(stale.role, "duplicate"); + stale.forget(); + assert.equal( + claimContinuationAdmission("command-1", "turn-1", 3).role, + "leader", + ); + }); + + it("promotes only the API-winning duplicate into a fresh generation", () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + first.admit(); + const winner = claimContinuationAdmission("command-1", "turn-1", 2); + const loser = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(winner.role, "duplicate"); + assert.equal(loser.role, "duplicate"); + const promoted = winner.promote(); + assert.equal(promoted?.role, "leader"); + loser.forget(); + assert.equal( + claimContinuationAdmission("command-1", "turn-1", 3).role, + "duplicate", + "the prior loser cannot evict the recovered generation", + ); + }); + + it("promotes a recovered command with its fresh execution generation", () => { + const first = claimContinuationAdmission("command-1", "turn-old", 1); + assert.equal(first.role, "leader"); + first.admit(); + + const recovered = claimContinuationAdmission("command-1", "turn-fresh", 2); + assert.equal(recovered.role, "duplicate"); + assert.equal(recovered.executionId, "turn-old"); + + const promoted = recovered.promote(); + assert.equal(promoted?.executionId, "turn-fresh"); + const waiter = claimContinuationAdmission("command-1", "turn-fresh", 3); + assert.equal(waiter.role, "duplicate"); + assert.equal(waiter.executionId, "turn-fresh"); + }); + + it("lets the API winner recover after a losing probe evicts the stale cache", () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + first.admit(); + const winner = claimContinuationAdmission("command-1", "turn-1", 2); + const loser = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(winner.role, "duplicate"); + assert.equal(loser.role, "duplicate"); + loser.forget(); + assert.equal(winner.promote()?.role, "leader"); + }); + + it("never overwrites a newer pending generation during promotion", async () => { + const first = claimContinuationAdmission("command-1", "turn-1", 1); + assert.equal(first.role, "leader"); + first.admit(); + const staleWinner = claimContinuationAdmission("command-1", "turn-1", 2); + const staleLoser = claimContinuationAdmission("command-1", "turn-1", 2); + assert.equal(staleWinner.role, "duplicate"); + assert.equal(staleLoser.role, "duplicate"); + + staleLoser.forget(); + const freshLeader = claimContinuationAdmission("command-1", "turn-1", 3); + const freshWaiter = claimContinuationAdmission("command-1", "turn-1", 4); + assert.equal(freshLeader.role, "leader"); + assert.equal(freshWaiter.role, "duplicate"); + + assert.equal(staleWinner.promote(), undefined); + freshLeader.release(); + assert.equal(await freshWaiter.admitted, false); + }); +}); diff --git a/services/runner/tests/unit/control-command-apply.test.ts b/services/runner/tests/unit/control-command-apply.test.ts index 825d0bc3bfa..2ff7be2c015 100644 --- a/services/runner/tests/unit/control-command-apply.test.ts +++ b/services/runner/tests/unit/control-command-apply.test.ts @@ -643,6 +643,80 @@ describe("applyCommand", () => { }); }); +describe("Stop teardown before outcome", () => { + it("aborts once immediately but holds original and duplicate outcomes until release", async () => { + const { execution, aborts } = liveRun(); + registerExecution(execution); + const { reported, report } = collector(); + const first = applyCommand(command(), { report }); + const duplicate = applyCommand(command(), { report }); + await Promise.resolve(); + assert.equal(aborts.length, 1); + assert.equal( + reported.length, + 0, + "Steer cannot promote into the still-busy environment", + ); + noteExecutionSettled(SESSION, TURN); + await Promise.resolve(); + assert.equal(reported.length, 0, "prompt settlement precedes teardown"); + unregisterExecution(SESSION, TURN); + await Promise.all([first, duplicate]); + assert.equal(reported.length, 2); + assert.ok( + reported.every((outcome) => outcome.execution.state === "stopped"), + ); + }); + + it("reports failure for an abandoned turn, including an already-waiting duplicate", async () => { + registerExecution(liveRun().execution); + const { reported, report } = collector(); + const first = applyCommand(command(), { report }); + const duplicate = applyCommand(command(), { report }); + unregisterExecution(SESSION, TURN, false); + await Promise.all([first, duplicate]); + assert.equal(reported.length, 2); + assert.ok( + reported.every((outcome) => outcome.execution.state === "failed"), + ); + }); + + it("does not strand a duplicate when abort throws before teardown", async () => { + registerExecution( + liveRun({ + abort: () => { + throw new Error("abort failed"); + }, + }).execution, + ); + const { reported, report } = collector(); + await Promise.all([ + applyCommand(command(), { report }), + applyCommand(command(), { report }), + ]); + assert.equal(reported.length, 2); + assert.ok( + reported.every((outcome) => outcome.execution.state === "failed"), + ); + unregisterExecution(SESSION, TURN); + }); + + it("does not let unregistering an older execution release its successor", async () => { + const older = liveRun({ turnId: "older" }).execution; + const current = liveRun().execution; + registerExecution(older); + registerExecution(current); + const { reported, report } = collector(); + const pending = applyCommand(command(), { report }); + unregisterExecution(SESSION, "older"); + await Promise.resolve(); + assert.equal(reported.length, 0); + unregisterExecution(SESSION, TURN); + await pending; + assert.equal(reported.length, 1); + }); +}); + describe("the execution registry", () => { it("refuses a lookup from another project once the scope is known", () => { const { execution } = liveRun(); diff --git a/services/runner/tests/unit/harness-cancel-park.test.ts b/services/runner/tests/unit/harness-cancel-park.test.ts index 8ccc1d8066b..69fd6b3bb9d 100644 --- a/services/runner/tests/unit/harness-cancel-park.test.ts +++ b/services/runner/tests/unit/harness-cancel-park.test.ts @@ -328,8 +328,12 @@ describe("the terminal done record", () => { assert.equal(doneRecordFor("paused").stopReason, "paused"); }); + it("carries an error so a failed turn cannot settle as a normal completion", () => { + assert.equal(doneRecordFor("error").stopReason, "error"); + }); + it("omits the field for a completed turn and for every harness-reported reason", () => { - // An explicit two-value allowlist, so `end_turn` / `max_tokens` / a future harness string + // An explicit allowlist, so `end_turn` / `max_tokens` / a future harness string // cannot start appearing on the terminal record by accident. assert.equal(doneRecordFor("end_turn").stopReason, undefined); assert.equal(doneRecordFor("max_tokens").stopReason, undefined); diff --git a/services/runner/tests/unit/live-frames.test.ts b/services/runner/tests/unit/live-frames.test.ts index ac80600979b..b8352596d22 100644 --- a/services/runner/tests/unit/live-frames.test.ts +++ b/services/runner/tests/unit/live-frames.test.ts @@ -162,6 +162,28 @@ describe("LiveFramePublisher", () => { ); }); + it.each([undefined, "", "true"])( + "publishes live frames with default or enabled configuration %s", + async (configured) => { + if (configured === undefined) + delete process.env.AGENTA_RUNNER_LIVE_FRAMES; + else process.env.AGENTA_RUNNER_LIVE_FRAMES = configured; + const frames: LiveFrameEnvelope[] = []; + const publisher = new LiveFramePublisher({ + sessionId: "session-default", + executionId: "execution-default", + auth: () => "Secret test", + send: async (batch) => { + frames.push(...batch); + }, + }); + publisher.emit({ type: "message_start", id: "message-1" }); + await publisher.whenIdle(); + assert.equal(frames.length, 1); + assert.equal(frames[0].type, "text-start"); + }, + ); + it("sends no live frames when the feature flag is off", async () => { process.env.AGENTA_RUNNER_LIVE_FRAMES = "false"; let calls = 0; diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index 75245aceb74..a1c9b2e71d3 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -57,6 +57,7 @@ import { } from "../utils/sandbox-agent-harness.ts"; import { findExecution, + unregisterExecution, registerExecution, resetExecutionsForTest, } from "../../src/sessions/execution-registry.ts"; @@ -2698,7 +2699,7 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { ); await pauseTeardownStarted; - const outcome = await applyCommand( + const stopped = applyCommand( { id: "command-stop-during-pause-teardown", projectId, @@ -2709,8 +2710,7 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { }, { report: async () => {} }, ); - assert.equal(outcome.execution.state, "stopped"); - + assert.equal(controller.signal.aborted, true); releasePauseTeardown(); const result = await turn; @@ -2719,6 +2719,8 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { assert.equal(result.stopReason, "cancelled"); assert.equal(result.cancelSettled, true); assert.equal(findExecution(projectId, sessionId)?.settled, true); + unregisterExecution(sessionId, turnId); + assert.equal((await stopped).execution.state, "stopped"); }); it("effective ask with no decision pauses the tool, no harness reply (F-024)", async () => { diff --git a/services/runner/tests/unit/server.test.ts b/services/runner/tests/unit/server.test.ts index 0437d7b096a..8e3fecc992b 100644 --- a/services/runner/tests/unit/server.test.ts +++ b/services/runner/tests/unit/server.test.ts @@ -30,6 +30,7 @@ import { liveExecutions, resetExecutionsForTest, } from "../../src/sessions/execution-registry.ts"; +import { resetContinuationAdmissionsForTest } from "../../src/sessions/continuation-admission.ts"; const TOKEN_ENV = "AGENTA_RUNNER_TOKEN"; const previousToken = process.env[TOKEN_ENV]; @@ -39,6 +40,7 @@ const previousLimit = process.env[LIMIT_ENV]; afterEach(() => { resetExecutionsForTest(); + resetContinuationAdmissionsForTest(); vi.restoreAllMocks(); vi.unstubAllEnvs(); if (previousToken === undefined) delete process.env[TOKEN_ENV]; @@ -928,6 +930,398 @@ describe("createAgentServer", () => { }); } + it("admits one execution for duplicate durable continuation delivery and re-reports started", async () => { + vi.stubEnv("AGENTA_API_URL", "https://api.example.test/api"); + let runCalls = 0; + const signals: AbortSignal[] = []; + const s = await listen(async (_request, _emit, signal) => { + runCalls += 1; + assert.ok(signal); + signals.push(signal); + return { ok: true, output: "continued", events: [] }; + }); + const realFetch = globalThis.fetch.bind(globalThis); + const admissionReports: Array> = []; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.includes("/sessions/control/commands/command-1/outcome")) { + const headers = new Headers(init?.headers); + assert.equal( + headers.get("x-agenta-runner-token"), + TEST_TOKEN, + "continuation outcome authenticates with the runner token", + ); + admissionReports.push(JSON.parse(String(init?.body))); + return Response.json({ + command: { id: "command-1", state: "applied" }, + admitted: admissionReports.length === 1, + }); + } + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-1" }, + is_current_turn: true, + }); + } + return Response.json({ ok: true }); + }); + const request = { + harness: "pi_core", + sessionId: "session-1", + turnId: "continuation-turn-1", + projectId: "project-1", + controlCommandId: "command-1", + messages: [{ role: "user", content: "approved" }], + }; + + try { + const deliver = () => + fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify(request), + }); + const first = await deliver(); + assert.equal(first.status, 200); + const firstRecords = (await first.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(firstRecords.at(-1).result.ok, true); + + const duplicate = await deliver(); + assert.equal(duplicate.status, 200); + const duplicateRecords = (await duplicate.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(duplicateRecords.at(-1).result.ok, true); + assert.equal( + duplicateRecords.at(-1).result.stopReason, + "control_command_duplicate", + ); + + assert.equal(runCalls, 1); + assert.equal(signals.length, 1); + assert.equal(signals[0].aborted, false); + assert.equal(admissionReports.length, 2); + for (const report of admissionReports) { + assert.equal(report.result, "applied"); + assert.equal(report.execution.id, "continuation-turn-1"); + assert.equal(report.execution.state, "started"); + assert.equal(typeof report.replica_id, "string"); + } + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + + it("keeps a continuation retryable when its admission outcome cannot be reported", async () => { + vi.stubEnv("AGENTA_API_URL", "https://api.example.test/api"); + let runCalls = 0; + let reportCalls = 0; + const s = await listen(async (_request, _emit, signal) => { + runCalls += 1; + assert.equal(signal?.aborted, false); + return { ok: true, output: "continued", events: [] }; + }); + const realFetch = globalThis.fetch.bind(globalThis); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.includes("/sessions/control/commands/command-retry/outcome")) { + reportCalls += 1; + return reportCalls === 1 + ? new Response("unavailable", { status: 503 }) + : Response.json({ + command: { id: "command-retry", state: "applied" }, + admitted: true, + }); + } + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-retry" }, + is_current_turn: true, + }); + } + return Response.json({ ok: true }); + }); + const request = { + harness: "pi_core", + sessionId: "session-retry", + turnId: "continuation-turn-retry", + projectId: "project-1", + controlCommandId: "command-retry", + messages: [{ role: "user", content: "approved" }], + }; + + try { + const deliver = async () => { + const response = await fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify(request), + }); + return (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + }; + + const failed = await deliver(); + assert.equal(failed.at(-1).result.ok, false); + assert.equal( + runCalls, + 0, + "engine does not start before durable admission", + ); + + const retried = await deliver(); + assert.equal(retried.at(-1).result.ok, true); + assert.equal(runCalls, 1); + assert.equal(reportCalls, 2); + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + + it("recovers after the API committed admission but its response was lost", async () => { + vi.stubEnv("AGENTA_API_URL", "https://api.example.test/api"); + let runCalls = 0; + let reportCalls = 0; + const s = await listen(async () => { + runCalls += 1; + return { ok: true, output: "continued", events: [] }; + }); + const realFetch = globalThis.fetch.bind(globalThis); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if ( + url.includes( + "/sessions/control/commands/command-response-loss/outcome", + ) + ) { + reportCalls += 1; + if (reportCalls === 1) { + // The API committed applied/running, but the runner never observed the response. + throw new Error("connection reset after response commit"); + } + return Response.json({ + command: { id: "command-response-loss", state: "applied" }, + // The immediate retry sees running. The later retry represents API watchdog/preflight + // recovery, whose recoverable->running CAS grants exactly one fresh admission. + admitted: reportCalls >= 3, + }); + } + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-response-loss" }, + is_current_turn: true, + }); + } + return Response.json({ ok: true }); + }); + const request = { + harness: "pi_core", + sessionId: "session-response-loss", + turnId: "continuation-turn-response-loss", + projectId: "project-1", + controlCommandId: "command-response-loss", + messages: [{ role: "user", content: "approved" }], + }; + + try { + const deliver = async () => { + const response = await fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify(request), + }); + return (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + }; + + assert.equal((await deliver()).at(-1).result.ok, false); + assert.equal( + (await deliver()).at(-1).result.stopReason, + "control_command_duplicate", + ); + assert.equal(runCalls, 0); + assert.equal((await deliver()).at(-1).result.ok, true); + assert.equal(runCalls, 1); + assert.equal(reportCalls, 3); + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + + it("does not report or run a continuation until a fresh controller owns the alive lock", async () => { + vi.stubEnv("AGENTA_API_URL", "https://api.example.test/api"); + let runCalls = 0; + let activeHeartbeatCalls = 0; + let reportCalls = 0; + const engineSignals: AbortSignal[] = []; + const s = await listen(async (_request, _emit, signal) => { + runCalls += 1; + assert.ok(signal); + engineSignals.push(signal); + return { ok: true, output: "continued", events: [] }; + }); + const realFetch = globalThis.fetch.bind(globalThis); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.endsWith("/sessions/streams/heartbeat")) { + const body = JSON.parse(String(init?.body)); + if (body.is_running) activeHeartbeatCalls += 1; + return Response.json({ + stream: { id: "stream-ownership" }, + is_current_turn: + body.is_running === false || activeHeartbeatCalls > 1, + }); + } + if ( + url.includes("/sessions/control/commands/command-ownership/outcome") + ) { + reportCalls += 1; + return Response.json({ + command: { id: "command-ownership", state: "applied" }, + admitted: true, + }); + } + return Response.json({ ok: true }); + }); + const request = { + harness: "pi_core", + sessionId: "session-ownership", + turnId: "continuation-turn-ownership", + projectId: "project-1", + controlCommandId: "command-ownership", + messages: [{ role: "user", content: "approved" }], + }; + + try { + const deliver = async () => { + const response = await fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify(request), + }); + return (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + }; + + const rejected = await deliver(); + assert.equal(rejected.at(-1).result.ok, false); + assert.equal(reportCalls, 0, "ownership rejection precedes outcome"); + assert.equal( + runCalls, + 0, + "ownership rejection precedes engine invocation", + ); + + const retried = await deliver(); + assert.equal(retried.at(-1).result.ok, true); + assert.equal(reportCalls, 1); + assert.equal(runCalls, 1); + assert.equal(engineSignals.length, 1); + assert.equal( + engineSignals[0].aborted, + false, + "the retry receives a fresh, un-aborted controller", + ); + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + + it("does not run when the API says another replica already admitted the command", async () => { + vi.stubEnv("AGENTA_API_URL", "https://api.example.test/api"); + let runCalls = 0; + let reportCalls = 0; + const s = await listen(async () => { + runCalls += 1; + return { ok: true, output: "must not run", events: [] }; + }); + const realFetch = globalThis.fetch.bind(globalThis); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = String(input); + if (url === `${s.url}/run`) return realFetch(input, init); + if (url.endsWith("/sessions/streams/heartbeat")) { + return Response.json({ + stream: { id: "stream-cross-replica" }, + is_current_turn: true, + }); + } + if ( + url.includes("/sessions/control/commands/command-applied/outcome") + ) { + reportCalls += 1; + return Response.json({ + command: { id: "command-applied", state: "applied" }, + admitted: false, + }); + } + return Response.json({ ok: true }); + }); + const request = { + harness: "pi_core", + sessionId: "session-cross-replica", + turnId: "continuation-turn-cross-replica", + projectId: "project-1", + controlCommandId: "command-applied", + messages: [{ role: "user", content: "approved" }], + }; + + try { + const deliver = async () => { + const response = await fetchSpy(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify(request), + }); + return (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + }; + + const first = await deliver(); + assert.equal(first.at(-1).result.ok, true); + assert.equal(first.at(-1).result.stopReason, "control_command_duplicate"); + const duplicate = await deliver(); + assert.equal( + duplicate.at(-1).result.stopReason, + "control_command_duplicate", + ); + assert.equal(runCalls, 0); + assert.equal(reportCalls, 2, "same-process duplicate re-acknowledges"); + } finally { + fetchSpy.mockRestore(); + await s.close(); + } + }); + 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 // throws with that key captured in the error message/stack (an auth failure echoing it, diff --git a/services/runner/tests/unit/session-persist.test.ts b/services/runner/tests/unit/session-persist.test.ts index d5781e28ffd..c6c3c7ead88 100644 --- a/services/runner/tests/unit/session-persist.test.ts +++ b/services/runner/tests/unit/session-persist.test.ts @@ -434,8 +434,19 @@ describe("buildPersistingEmitter turn/span tagging", () => { emit({ type: "tool_result", id: "call_1", output: "ok" }); await flush(); - const bodies = postedBodies as Array>; + const liveBatches = postedBodies.filter(Array.isArray); + assert.equal(liveBatches.length, 1); + for (const frame of liveBatches[0]) { + assert.equal(frame.execution_id, "turn-tc"); + } + const bodies = (postedBodies as Array>).filter( + (body) => "record_type" in body, + ); assert.equal(bodies.length, 3); + assert.equal( + (bodies[0]["attributes"] as Record)["message_id"], + "m1", + ); for (const body of bodies) { assert.equal(body["turn_id"], "turn-tc"); assert.equal("span_id" in body, false); diff --git a/services/runner/tests/unit/wire-contract.test.ts b/services/runner/tests/unit/wire-contract.test.ts index c8514beaa61..4da54c790ee 100644 --- a/services/runner/tests/unit/wire-contract.test.ts +++ b/services/runner/tests/unit/wire-contract.test.ts @@ -59,6 +59,7 @@ const KNOWN_REQUEST_KEYS = [ "turnId", "detached", "projectId", + "controlCommandId", "effectiveParameters", ] as const; diff --git a/services/uv.lock b/services/uv.lock index d0eec0aff2d..ce87d03577d 100644 --- a/services/uv.lock +++ b/services/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "agenta" -version = "0.115.1" +version = "0.115.2" source = { editable = "../sdks/python" } dependencies = [ { name = "agenta-client" }, @@ -72,7 +72,7 @@ dev = [ [[package]] name = "agenta-client" -version = "0.115.1" +version = "0.115.2" source = { editable = "../clients/python" } dependencies = [ { name = "httpx" }, @@ -2356,7 +2356,7 @@ wheels = [ [[package]] name = "services" -version = "0.115.1" +version = "0.115.2" source = { virtual = "." } dependencies = [ { name = "agenta" }, diff --git a/web/ee/package.json b/web/ee/package.json index 1da5d3cc2e8..6abf6e205c9 100644 --- a/web/ee/package.json +++ b/web/ee/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/ee", - "version": "0.115.1", + "version": "0.115.2", "private": true, "engines": { "node": "24.x" diff --git a/web/mobile/package.json b/web/mobile/package.json index 76e021f8ad1..ad1bf1b6653 100644 --- a/web/mobile/package.json +++ b/web/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/mobile", - "version": "0.115.1", + "version": "0.115.2", "private": true, "engines": { "node": "24.x" diff --git a/web/mobile/src/features/chat/ApprovalDock.tsx b/web/mobile/src/features/chat/ApprovalDock.tsx index 7cb8fc03f6e..8fe4df20261 100644 --- a/web/mobile/src/features/chat/ApprovalDock.tsx +++ b/web/mobile/src/features/chat/ApprovalDock.tsx @@ -30,6 +30,7 @@ export const ApprovalDock = ({ bottomMost?: boolean }) => { const busy = actions.phase === "resuming" + const answered = actions.phase === "answered" || actions.phase === "recoverable" if (approvals.length === 0) return null return ( @@ -44,6 +45,8 @@ export const ApprovalDock = ({ getPendingApprovals(messages), [messages]) const pendingCount = pendingApprovals.length const pendingApprovalIds = useMemo( diff --git a/web/mobile/src/features/chat/Composer.tsx b/web/mobile/src/features/chat/Composer.tsx index bf8df8cbadf..ea97cafc66d 100644 --- a/web/mobile/src/features/chat/Composer.tsx +++ b/web/mobile/src/features/chat/Composer.tsx @@ -1,6 +1,6 @@ import {useEffect, useRef, type MutableRefObject} from "react" -import {describeAccepted} from "@agenta/chat/assets" +import {describeAccepted, isComposerRunStoppable} from "@agenta/chat/assets" import { AttachmentDropOverlay, ChatComposer, @@ -31,16 +31,20 @@ import {useMotionPresets} from "@/lib/motion/presets" export const Composer = ({ sessionId, onSend, + onSteer, disabled = false, waitingOnUser = false, streaming = false, stopping = false, onStop, + queueEnabled = false, + inputBusy = streaming, inputRef, placeholder, }: { sessionId: string onSend: (input: {text: string; parts?: FileUIPart[]}) => void | Promise + onSteer?: (input: {text: string; parts?: FileUIPart[]}) => void | Promise /** No resolvable agent yet, or the screen is still hydrating. */ disabled?: boolean /** The run is parked on the user (pending approval) — sends will queue. */ @@ -50,6 +54,9 @@ export const Composer = ({ /** The durable Stop request has not settled yet. */ stopping?: boolean onStop?: () => void + queueEnabled?: boolean + steerEnabled?: boolean + inputBusy?: boolean /** Lets the host write into the input — a rewind puts the rewound message back to edit. */ inputRef?: MutableRefObject /** Full placeholder override — used when the composer is gated (no model key). */ @@ -60,12 +67,22 @@ export const Composer = ({ const richInputRef = inputRef ?? ownInputRef const sending = useRef(false) const presets = useMotionPresets() + const stoppable = isComposerRunStoppable({ + localStreaming: streaming, + serverBusy: inputBusy, + serverControlEnabled: queueEnabled, + waitingOnUser, + }) /** * `extraFiles` are takes that never entered the tray (a voice message sent outright), so * they upload here before the send — the same seam the desktop dock uses. */ - const submit = async (text: string, extraFiles: File[] = []) => { + const submit = async ( + text: string, + extraFiles: File[] = [], + policy: "queue" | "steer" = "queue", + ) => { // Enter and the send button (and a voice take completing) can all fire while an upload // is still in flight; a second pass would re-send the same staged tray. if (sending.current) return @@ -78,13 +95,17 @@ export const Composer = ({ // pop the keyboard straight back up. dismissSoftKeyboardAfterSend(() => richInputRef.current?.blur()) try { - await runSubmit(text, extraFiles) + await runSubmit(text, extraFiles, policy) } finally { sending.current = false } } - const runSubmit = async (text: string, extraFiles: File[] = []) => { + const runSubmit = async ( + text: string, + extraFiles: File[] = [], + policy: "queue" | "steer" = "queue", + ) => { const staged = attachments.files const uploadedExtras = extraFiles.length ? await attachments.uploadExtraFiles(extraFiles) @@ -96,7 +117,8 @@ export const Composer = ({ // `stagedFilesToParts` THROWS on a file whose upload hasn't settled — reachable via // Enter, which the send button's `sendDisabled` guard doesn't cover. const parts = outbound.length > 0 ? stagedFilesToParts(outbound, sessionId) : undefined - await onSend({text, parts}) + if (policy === "steer" && onSteer) await onSteer({text, parts}) + else await onSend({text, parts}) attachments.clearAttachments(staged.map((file) => file.uid)) } catch { // Nothing consumes this promise (RichChatInput's submit is fire-and-forget), so an @@ -183,7 +205,7 @@ export const Composer = ({ dictating={dictating} placeholder={placeholder} waitingOnUser={waitingOnUser} - streaming={streaming} + streaming={stoppable} stopping={stopping} onStop={onStop} extraPrefix={ diff --git a/web/mobile/src/features/chat/LiveConversation.tsx b/web/mobile/src/features/chat/LiveConversation.tsx index 4da841efef2..0c0359d1cb5 100644 --- a/web/mobile/src/features/chat/LiveConversation.tsx +++ b/web/mobile/src/features/chat/LiveConversation.tsx @@ -15,7 +15,6 @@ import { ConnectionWarningStrip, ElicitationDock, QueuedMessagesDock, - RunningElsewhereStrip, } from "@agenta/chat/components" import type {QueuedMessage} from "@agenta/chat/hooks" import { @@ -41,28 +40,31 @@ import { turnRowClass, } from "@agenta/ui/components/presentational" import type {RichChatInputHandle} from "@agenta/ui/rich-chat-input" +import {useQueryClient} from "@tanstack/react-query" import {useAtomValue, useSetAtom} from "jotai" import {User} from "lucide-react" import {ContentRail} from "@/components/ContentRail" import {ScreenScaffold} from "@/components/ScreenScaffold" +import {Button} from "@/components/ui/button" -import {pendingTasksAtom, takePendingTaskAtom} from "../home/pendingTask" +import {pendingTasksAtom, failPendingTaskAtom, sendPendingTaskAtom} from "../home/pendingTask" import {AppShell} from "../nav/AppShell" +import {livenessQueryKey} from "../sessions/useLivenessPoll" import {ApprovalDock} from "./ApprovalDock" import {Composer} from "./Composer" import {ConnectModelStrip} from "./ConnectModelStrip" -import { - MODEL_KEY_WAIT_LIMIT_MS, - PENDING_TASK_NOT_SENT_MESSAGE, - pendingTaskDecision, -} from "./pendingTaskPolicy" +import {MODEL_KEY_WAIT_LIMIT_MS, pendingTaskDecision} from "./pendingTaskPolicy" import {ChatLoading} from "./states/ChatStates" import {StopButton} from "./StopButton" import {cancelledStopAction} from "./stopHereState" import {TurnRow} from "./TurnRow" -import {deriveMobileRemoteTurnPresentation, showTrailingWorkingPulse} from "./turnStatus" +import { + deriveMobileRemoteTurnPresentation, + showRunningElsewhere, + showTrailingWorkingPulse, +} from "./turnStatus" import {TurnStatusLine} from "./TurnStatusLine" import {useApprovalActions, type ApprovalActions} from "./useApprovalActions" import {useSessionWatch} from "./useSessionWatch" @@ -168,61 +170,57 @@ export const LiveConversation = ({ input?.focus() }, [cancelEdit]) - // A task started from Home lands here as a stashed message: the session did not exist when - // it was typed, and the first send is what creates it. Ref-guarded and the slot is consumed - // on read, so a re-render (or React 18's double-invoke in dev) cannot send it twice. Held - // until hydration settles, or the engine would send into a transcript it is still filling, - // and held while the vault is unresolved or the model gate is up, so the first message is not - // spent on a run that cannot succeed — it goes out on its own the moment a key lands (or the - // vault says one already exists). The guard holds the SESSION it - // fired for, not a bare flag: this component survives a session switch, and a flag would - // swallow the next session's stashed task. - - // Peek at the parked task WITHOUT consuming it — used only for display while the gate holds. - // `takePendingTaskAtom` removes the entry; this read leaves it in place for the send effect. + // Keep Home tasks session-scoped until admission; failures require an explicit retry. const pendingTasks = useAtomValue(pendingTasksAtom) - const heldTaskText = pendingTasks[sessionId]?.text ?? null - - const takePendingTask = useSetAtom(takePendingTaskAtom) - const sentPendingTaskFor = useRef(null) - const [pendingTaskError, setPendingTaskError] = useState(null) + const pendingTask = pendingTasks[sessionId] + const heldTaskText = pendingTask?.delivery === "sending" ? null : pendingTask?.text + const sendPendingTask = useSetAtom(sendPendingTaskAtom) + const failPendingTask = useSetAtom(failPendingTaskAtom) + const pendingTaskError = pendingTask?.delivery === "failed" const {isHydrating, revalidate, send, stop, voidPendingResume} = conversation useEffect(() => { + if (!pendingTask || pendingTask.delivery) return const decision = pendingTaskDecision({ sessionId, - sentFor: sentPendingTaskFor.current, + sentFor: null, hydrating: isHydrating, modelKeyLoading, modelKeyWaitedMs, modelBlocked, }) if (decision === "hold") return - const task = takePendingTask(sessionId) - if (!task) return - // Consumed either way — a released task must not replay on the next render. - sentPendingTaskFor.current = sessionId if (decision === "abandon") { - setPendingTaskError(PENDING_TASK_NOT_SENT_MESSAGE) - // Hand the text back so "try again" is one tap. The composer is usable here: the gate - // is not up, because an unresolved vault never raises it. - if (task.text) composerRef.current?.setMarkdown(task.text) + failPendingTask(sessionId) return } - void send({text: task.text, parts: task.parts}) + void sendPendingTask({ + sessionId, + send: (task) => send({text: task.text, parts: task.parts}), + }) }, [ + pendingTask, isHydrating, modelKeyLoading, modelKeyWaitedMs, modelBlocked, send, sessionId, - takePendingTask, + sendPendingTask, + failPendingTask, ]) + const queryClient = useQueryClient() + useEffect(() => { + if (conversation.sharedSettledAt) { + void queryClient.invalidateQueries({queryKey: livenessQueryKey(projectId)}) + } + }, [conversation.sharedSettledAt, projectId, queryClient]) const streamingHere = conversation.status === "submitted" || conversation.status === "streaming" const remoteTurn = deriveMobileRemoteTurnPresentation({ livenessRunning: running, - snapshotRunning: conversation.runningFromSnapshot || conversation.acceptedRunPending, + livenessUpdatedAt, + sharedSettledAt: conversation.sharedSettledAt, + snapshotRunning: conversation.runningFromSnapshot, sharedReaderAdvertised: sharedReader, readerReady: conversation.readerReady, ownedContinuation: conversation.acceptedRunPending, @@ -264,10 +262,12 @@ export const LiveConversation = ({ }, [sessionId]) // Push invalidation folds cross-device changes into the guarded transcript. + const {interactionChanged} = conversation const watch = useSessionWatch({ sessionId, projectId, onRecordsChanged: revalidate, + onInteractionChanged: interactionChanged, sharedReaderAdvertised: sharedReader, }) // Poll slowly while a cross-device run cannot be watched live. @@ -443,8 +443,16 @@ export const LiveConversation = ({ }) const approvalActions: ApprovalActions = useMemo( () => ({ - phase: conversation.approvals.responding ? "resuming" : steerActions.phase, - errorText: steerActions.errorText, + phase: conversation.approvals.recoverable + ? "recoverable" + : conversation.approvals.answered + ? "answered" + : conversation.approvals.responding + ? "resuming" + : conversation.approvals.errorText + ? "error" + : steerActions.phase, + errorText: conversation.approvals.errorText ?? steerActions.errorText, respond: ({approved, message, approvalId}) => { if (message) { steerActions.respond({approvalId, approved, message}) @@ -482,9 +490,6 @@ export const LiveConversation = ({ approvalsPending: pendingApprovals.length > 0, elicitationPending: elicits.open, }) - // Any blocking dock on screen. The queue card yields to all of them rather than stacking, - // mid-edit included — the composer keeps the edit, so Enter still rewrites the held row. - const gateDockOpen = pendingApprovals.length > 0 || elicits.open || connects.open // A docked gate holds the jump pill back — same rule, same reasons, as the desktop. This // surface has no question-form dock yet, so only approvals and connect cards can gate it. const gateOpen = jumpGateOpen({ @@ -530,11 +535,7 @@ export const LiveConversation = ({ } else { body = ( - {/* A task typed before any provider key exists is held in `pendingTasksAtom` - (not yet sent — the gate is up). Render it as a user bubble so the person - can see what they wrote, matching desktop parity: the desktop shows the - held seed above the connect-model banner. Cleared the moment the gate - drops and the send effect fires (`takePendingTaskAtom` removes the entry). */} + {/* A held or failed Home task stays visible until accepted. */} {heldTaskText ? (
- {/* What you have lined up. Yields to the gate docks entirely: those are - blocked runs wanting an answer, and stacking a second card above one - buries the composer. It comes back when the gate clears. */} - {conversation.queued.length > 0 && !gateDockOpen ? ( + {/* What you have lined up stays visible while a gate is open: the queued + message is the acknowledgement that the user's Send was not lost. */} + {conversation.queued.length > 0 || conversation.editingId ? (
) : null} - {/* A run this device is not driving. Docked with the other strips above the - 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. */} - {remoteTurn.showStrip && !streamingHere ? ( + {showRunningElsewhere({ + running: remoteTurn.showRemoteStop, + localStatus: conversation.runStatus, + }) && !streamingHere ? ( - - } - /> +
+ +
) : null} {conversation.connectionWarning ? ( @@ -696,31 +698,65 @@ export const LiveConversation = ({ gateActive={modelBlocked} /> - {/* The parked task gave up waiting for the vault. Its text is back in the - composer, so this says what happened and the send is one tap away. */} + {/* Failed Home tasks retain their original text and files for retry. */} {pendingTaskError ? ( -

- {pendingTaskError} -

+
+ + The message was not sent. Your text and attachments are + saved. + + {pendingTask?.parts?.map((part, index) => ( + + {part.filename || "Attachment"} + + ))} + +
) : null} { + onSend={async ({text, parts}) => { setStoppingHere(false) // An open edit rewrites its held message instead of sending. The // input clears on submit, so the displaced draft goes back after. if (!conversation.editingId) { - conversation.send({text, parts}) + await conversation.send({text, parts}) return } - const draft = conversation.commitEdit({text, fileParts: parts}) + const draft = await conversation.commitEdit({ + text, + fileParts: parts, + }) if (draft) requestAnimationFrame(() => composerRef.current?.setMarkdown(draft), ) }} + onSteer={({text, parts}) => conversation.steer({text, parts})} disabled={conversation.isHydrating || modelBlocked} placeholder={ modelBlocked ? "Connect a model to start chatting…" : undefined @@ -732,6 +768,9 @@ export const LiveConversation = ({ })} stopping={stopping} onStop={stopHere} + queueEnabled={conversation.queueEnabled} + steerEnabled={conversation.steerEnabled} + inputBusy={conversation.inputBusy} inputRef={composerRef} />
diff --git a/web/mobile/src/features/chat/TurnRow.tsx b/web/mobile/src/features/chat/TurnRow.tsx index 5029b066803..203de3ed36e 100644 --- a/web/mobile/src/features/chat/TurnRow.tsx +++ b/web/mobile/src/features/chat/TurnRow.tsx @@ -42,6 +42,7 @@ import { import {Button} from "@/components/ui/button" import {AssistantMarkdown} from "./AssistantMarkdown" +import {continuationRetryAction} from "./continuationRetry" import {isLiveTextItem} from "./markdownStream" type ToolsItem = Extract @@ -358,9 +359,10 @@ export const TurnRow = ({ {turn.status.showError ? ( onRewind(turn) : undefined} + onRetry={continuationRetryAction( + turn, + onRewind ? () => onRewind(turn) : undefined, + )} /> ) : null} diff --git a/web/mobile/src/features/chat/approvalTargets.ts b/web/mobile/src/features/chat/approvalTargets.ts index 0d438035308..77d15d2cf4b 100644 --- a/web/mobile/src/features/chat/approvalTargets.ts +++ b/web/mobile/src/features/chat/approvalTargets.ts @@ -32,6 +32,12 @@ export const selectApprovalTargets = ( target: ApprovalTarget, ): SessionInteraction[] => { const pending = (rows ?? []).filter((row) => row.kind === "user_approval" && !!row.id) - if (target.all) return pending + if (target.all) { + const executionIds = new Set(pending.map((row) => row.turn_id ?? null)) + if (executionIds.size > 1) { + throw new Error("Approve all can only answer approvals from one execution.") + } + return pending + } return pending.filter((row) => row.token === target.approvalId) } diff --git a/web/mobile/src/features/chat/continuationRetry.ts b/web/mobile/src/features/chat/continuationRetry.ts new file mode 100644 index 00000000000..5b1ca068124 --- /dev/null +++ b/web/mobile/src/features/chat/continuationRetry.ts @@ -0,0 +1,10 @@ +import type {TurnViewModel} from "@agenta/chat/model" + +type RetryableTurn = Pick + +/** Only the latest continuation-race error can safely replay its originating message. */ +export const continuationRetryAction = ( + turn: RetryableTurn, + retry?: () => void, +): (() => void) | undefined => + turn.isLast && turn.status.errorCode === "continuation_resumed" ? retry : undefined diff --git a/web/mobile/src/features/chat/turnStatus.ts b/web/mobile/src/features/chat/turnStatus.ts index 018be97c168..cd696798785 100644 --- a/web/mobile/src/features/chat/turnStatus.ts +++ b/web/mobile/src/features/chat/turnStatus.ts @@ -1,4 +1,4 @@ -import {deriveRemoteTurnPresentation} from "@agenta/chat/model" +import {deriveRemoteTurnPresentation, type SessionRunStatus} from "@agenta/chat/model" /** Mobile presentation for a remote/shared-path run. */ export const deriveMobileRemoteTurnPresentation = deriveRemoteTurnPresentation @@ -15,3 +15,11 @@ export const showTrailingWorkingPulse = ( streaming: boolean, turns: {isUser: boolean; isStreamingTurn: boolean}[], ): boolean => streaming && !turns.some((turn) => !turn.isUser && turn.isStreamingTurn) + +export const showRunningElsewhere = ({ + running, + localStatus, +}: { + running: boolean + localStatus: SessionRunStatus +}): boolean => running && localStatus !== "running" && localStatus !== "awaiting" diff --git a/web/mobile/src/features/chat/useApprovalActions.ts b/web/mobile/src/features/chat/useApprovalActions.ts index 89ce7bb2e25..5c28ed96835 100644 --- a/web/mobile/src/features/chat/useApprovalActions.ts +++ b/web/mobile/src/features/chat/useApprovalActions.ts @@ -9,7 +9,7 @@ import { import {hasSettledResume, selectApprovalTargets, type ApprovalTarget} from "./approvalTargets" import {buildApprovalAnswer} from "./steer" -export type ResumePhase = "idle" | "resuming" | "error" +export type ResumePhase = "idle" | "resuming" | "answered" | "recoverable" | "error" /** Fern's `AgentaApiError` message is transport jargon — show the status instead. */ const respondErrorText = (error: unknown): string => { @@ -67,13 +67,13 @@ export const useApprovalActions = ({ useEffect(() => { const pending = pendingKey ? pendingKey.split(" ") : [] if (!hasSettledResume(submittedRef.current, pending)) return - setPhase((current) => (current === "resuming" ? "idle" : current)) + setPhase((current) => (current === "resuming" || current === "answered" ? "idle" : current)) }, [pendingKey]) // Failure-path re-arm: if the respond was accepted but the run dies before the gate // resolves, the poll never settles us — drop back to idle so the buttons re-arm. useEffect(() => { - if (phase !== "resuming") return + if (phase !== "resuming" && phase !== "answered" && phase !== "recoverable") return const handle = setTimeout(() => setPhase("idle"), 60_000) return () => clearTimeout(handle) }, [phase]) @@ -105,25 +105,42 @@ export const useApprovalActions = ({ submittedRef.current = targets .map((row) => row.token) .filter((token): token is string => typeof token === "string") - let answered = 0 - for (const row of targets) { - try { - await respondInteraction({ - interactionId: row.id as string, - projectId, - answer: buildApprovalAnswer(approved, message), - }) - answered += 1 - } catch (err) { - // Someone (desktop, another tab) already answered this gate — benign. - if (!isInteractionConflict(err)) throw new Error(respondErrorText(err)) + let answered = targets.length + try { + const ids = targets.map((row) => row.id as string).sort() + const result = await respondInteraction({ + interactionId: ids[0], + projectId, + ...(targets.length === 1 + ? {answer: buildApprovalAnswer(approved, message)} + : { + answers: targets.map((row) => ({ + interactionId: row.id as string, + answer: buildApprovalAnswer(approved, message), + })), + }), + expectedExecutionId: targets[0].turn_id ?? undefined, + idempotencyKey: + targets.length === 1 + ? `approval:${targets[0].id}:${approved ? "approve" : "deny"}` + : `approval-batch:${ids[0]}:${ids.length}:${approved ? "approve" : "deny"}`, + }) + if (result?.execution?.state === "recoverable") { + setPhase("recoverable") + return } + } catch (err) { + // Someone (desktop, another tab) already answered this gate — benign. + if (!isInteractionConflict(err)) throw new Error(respondErrorText(err)) + answered = 0 } // Every target was already answered: nothing is resuming, so re-arm now // instead of waiting out the 60s timeout. if (answered === 0) { submittedRef.current = [] setPhase("idle") + } else { + setPhase("answered") } } catch (err) { submittedRef.current = [] diff --git a/web/mobile/src/features/chat/useSessionTranscript.ts b/web/mobile/src/features/chat/useSessionTranscript.ts index 48b8d3a334b..dad5f13e011 100644 --- a/web/mobile/src/features/chat/useSessionTranscript.ts +++ b/web/mobile/src/features/chat/useSessionTranscript.ts @@ -1,12 +1,25 @@ import {useCallback, useEffect, useRef, useState} from "react" -import {loadSessionMessages, type SessionTranscript} from "@agenta/chat/assets" -import {revalidateSessionRecordsAtom} from "@agenta/entities/session" +import { + loadSessionMessages, + reconcileInteractionRowStates, + type SessionTranscript, +} from "@agenta/chat/assets" +import { + fetchSessionInteractionStatesAtom, + interactionStatesFromWatchEvent, + revalidateSessionInteractionsAtom, + revalidateSessionRecordsAtom, + type SessionInteractionRowStates, +} from "@agenta/entities/session" +import {isHitlPending} from "@agenta/playground" import type {UIMessage} from "ai" import {getDefaultStore} from "jotai" import {adoptTranscriptRead, shouldAdoptTranscript} from "./transcriptAdoption" +const INTERACTION_GATE_POLL_MS = 1_000 + /** * Read-only transcript for one session: server record replay via `loadSessionMessages` * (IndexedDB-restored, revalidation re-delivered through `onRefreshed`). `null` history @@ -129,5 +142,49 @@ export const useSessionTranscript = (sessionId: string, pollMs = 0) => { } }, [refresh, pollMs]) - return {messages, state, refresh} + const applyInteractionStates = useCallback( + (rows: SessionInteractionRowStates) => { + if (sessionRef.current !== sessionId) return + const current = messagesRef.current + const reconciled = reconcileInteractionRowStates(current, rows) + if (reconciled === current) return + messagesRef.current = reconciled + setMessages(reconciled) + }, + [sessionId], + ) + const refreshInteractions = useCallback(async () => { + const store = getDefaultStore() + await store.set(revalidateSessionInteractionsAtom, sessionId) + applyInteractionStates(await store.set(fetchSessionInteractionStatesAtom, sessionId)) + }, [applyInteractionStates, sessionId]) + const interactionChanged = useCallback( + (event: MessageEvent) => { + const pushed = interactionStatesFromWatchEvent(event.data, sessionId) + if (!pushed) { + void refreshInteractions() + return + } + applyInteractionStates(pushed) + void getDefaultStore().set(revalidateSessionInteractionsAtom, sessionId) + }, + [applyInteractionStates, refreshInteractions, sessionId], + ) + const interactionGateOpen = isHitlPending(messages) + useEffect(() => { + if (!interactionGateOpen) return + let cancelled = false + let timer: ReturnType | undefined + const poll = async () => { + await refreshInteractions().catch(() => undefined) + if (!cancelled) timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + } + timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [interactionGateOpen, refreshInteractions]) + + return {messages, state, refresh, interactionChanged} } diff --git a/web/mobile/src/features/chat/useSessionWatch.ts b/web/mobile/src/features/chat/useSessionWatch.ts index d95cdde7e48..443a6a0daa0 100644 --- a/web/mobile/src/features/chat/useSessionWatch.ts +++ b/web/mobile/src/features/chat/useSessionWatch.ts @@ -1,6 +1,7 @@ import {useEffect, useRef, useState} from "react" import {shouldRefreshLegacyObserverLiveness} from "@agenta/chat/model" +import {invalidateSessionDurableApprovalsCapability} from "@agenta/entities/session" import {useQueryClient} from "@tanstack/react-query" import {tryRefreshSession} from "@/lib/auth" @@ -15,13 +16,13 @@ import {sessionWatchUrl, watchRetryDelayMs} from "./watchRelay" const MIN_INTERVAL_MS = 3_000 /** - * One EventSource per foregrounded chat screen (M3 live relay). Events carry no payloads — - * every handler funnels into the existing revalidate paths: + * One EventSource per foregrounded chat screen (M3 live relay). Most events invalidate existing + * queries; interaction events also carry committed row state for immediate gate retirement: * * - `records-changed` (and every `open`, for missed-event coverage) → `onRecordsChanged`, * i.e. the transcript tick's body (`revalidateSessionRecordsAtom` + re-read). - * - `lifecycle` / `interaction` → invalidate the shared liveness + actionable-interactions - * queries, and the nav rail's own session queries (no duplicated state; each refetches). + * - `interaction` → reduce its committed row state and invalidate the shared badge queries. + * - `lifecycle` → invalidate liveness and the nav rail's session queries. * * Foreground-only: the source closes on `visibilitychange → hidden` and reopens on visible. * Transient errors ride EventSource's built-in reconnect (the server pins its delay with an @@ -40,7 +41,7 @@ export const useSessionWatch = ({ sessionId: string projectId: string onRecordsChanged: () => void - onInteractionChanged?: () => void + onInteractionChanged?: (event: MessageEvent) => void sharedReaderAdvertised?: boolean }): {connected: boolean} => { const [connected, setConnected] = useState(false) @@ -120,6 +121,7 @@ export const useSessionWatch = ({ // headers reach us before the server's Redis subscription is live, so a // change landing in that window would miss both this refetch and the stream. es.addEventListener("ready", () => { + invalidateSessionDurableApprovalsCapability({projectId, sessionId}) notifyOnConnect() invalidateBadges() }) @@ -137,9 +139,9 @@ export const useSessionWatch = ({ } }) es.addEventListener("lifecycle", () => invalidateBadges(true)) - es.addEventListener("interaction", () => { + es.addEventListener("interaction", (event) => { + onInteractionChangedRef.current?.(event as MessageEvent) invalidateBadges() - onInteractionChangedRef.current?.() }) es.onerror = () => { setConnected(false) diff --git a/web/mobile/src/features/home/pendingTask.ts b/web/mobile/src/features/home/pendingTask.ts index 46220793b7f..848e0430da9 100644 --- a/web/mobile/src/features/home/pendingTask.ts +++ b/web/mobile/src/features/home/pendingTask.ts @@ -6,6 +6,7 @@ export interface PendingTask { agentId: string text: string parts?: FileUIPart[] + delivery?: "sending" | "failed" } /** @@ -33,3 +34,46 @@ export const takePendingTaskAtom = atom(null, (get, set, sessionId: string) => { set(pendingTasksAtom, rest) return task }) + +export const failPendingTaskAtom = atom(null, (get, set, sessionId: string) => { + const tasks = get(pendingTasksAtom) + const task = tasks[sessionId] + if (task && task.delivery !== "sending") { + set(pendingTasksAtom, {...tasks, [sessionId]: {...task, delivery: "failed"}}) + } +}) + +export const sendPendingTaskAtom = atom( + null, + async ( + get, + set, + { + sessionId, + send, + retry = false, + }: { + sessionId: string + send: (task: PendingTask) => Promise + retry?: boolean + }, + ) => { + const task = get(pendingTasksAtom)[sessionId] + if (!task || task.delivery === "sending" || (task.delivery === "failed" && !retry)) return + const sending: PendingTask = {...task, delivery: "sending"} + set(pendingTasksAtom, {...get(pendingTasksAtom), [sessionId]: sending}) + try { + await send(sending) + } catch { + const tasks = get(pendingTasksAtom) + if (tasks[sessionId] === sending) { + set(pendingTasksAtom, {...tasks, [sessionId]: {...sending, delivery: "failed"}}) + } + return + } + const tasks = get(pendingTasksAtom) + if (tasks[sessionId] !== sending) return + const {[sessionId]: _sent, ...rest} = tasks + set(pendingTasksAtom, rest) + }, +) diff --git a/web/mobile/tests/unit/approvalTargets.test.ts b/web/mobile/tests/unit/approvalTargets.test.ts index ae386fa20f0..c2fe461c104 100644 --- a/web/mobile/tests/unit/approvalTargets.test.ts +++ b/web/mobile/tests/unit/approvalTargets.test.ts @@ -20,13 +20,27 @@ describe("selectApprovalTargets", () => { }) it("returns every pending approval for approve-all", () => { - const rows = [row(), row({id: "int-2", token: "appr-2"})] + const rows = [ + row({turn_id: "turn-1"}), + row({id: "int-2", token: "appr-2", turn_id: "turn-1"}), + ] expect(selectApprovalTargets(rows, {all: true}).map((r) => r.id)).toEqual([ "int-1", "int-2", ]) }) + it("rejects approve-all across executions before posting", () => { + const rows = [ + row({turn_id: "turn-1"}), + row({id: "int-2", token: "appr-2", turn_id: "turn-2"}), + ] + + expect(() => selectApprovalTargets(rows, {all: true})).toThrow( + "Approve all can only answer approvals from one execution.", + ) + }) + it("drops non-approval kinds", () => { const rows = [row({id: "int-3", token: "appr-3", kind: "client_tool"})] expect(selectApprovalTargets(rows, {all: true})).toEqual([]) diff --git a/web/mobile/tests/unit/continuationRetry.test.ts b/web/mobile/tests/unit/continuationRetry.test.ts new file mode 100644 index 00000000000..878ae78412b --- /dev/null +++ b/web/mobile/tests/unit/continuationRetry.test.ts @@ -0,0 +1,21 @@ +import {describe, expect, it, vi} from "vitest" + +import {continuationRetryAction} from "../../src/features/chat/continuationRetry" + +const turn = (isLast: boolean, errorCode: string | null) => + ({isLast, status: {errorCode}}) as Parameters[0] + +describe("continuationRetryAction", () => { + it("retries the latest continuation race error", () => { + const retry = vi.fn() + continuationRetryAction(turn(true, "continuation_resumed"), retry)?.() + expect(retry).toHaveBeenCalledOnce() + }) + + it("does not offer retry on historical or unrelated failures", () => { + const retry = vi.fn() + expect(continuationRetryAction(turn(false, "continuation_resumed"), retry)).toBeUndefined() + expect(continuationRetryAction(turn(true, "rate_limited"), retry)).toBeUndefined() + expect(retry).not.toHaveBeenCalled() + }) +}) diff --git a/web/mobile/tests/unit/pendingTaskAdmission.test.ts b/web/mobile/tests/unit/pendingTaskAdmission.test.ts new file mode 100644 index 00000000000..5e467414d13 --- /dev/null +++ b/web/mobile/tests/unit/pendingTaskAdmission.test.ts @@ -0,0 +1,82 @@ +import {createStore} from "jotai" +import {describe, expect, it, vi} from "vitest" + +import { + pendingTasksAtom, + sendPendingTaskAtom, + stashPendingTaskAtom, +} from "../../src/features/home/pendingTask" + +const task = { + agentId: "agent", + text: "keep this task", + parts: [ + { + type: "file" as const, + url: "https://files.test/brief.pdf", + mediaType: "application/pdf", + filename: "brief.pdf", + }, + ], +} + +describe("mobile Home task admission", () => { + it("retains failed text/files and retries only explicitly, clearing on success", async () => { + const store = createStore() + store.set(stashPendingTaskAtom, {sessionId: "one", task}) + const send = vi + .fn() + .mockRejectedValueOnce(new Error("capabilities unavailable")) + .mockResolvedValueOnce(undefined) + await store.set(sendPendingTaskAtom, {sessionId: "one", send}) + expect(store.get(pendingTasksAtom).one).toEqual({...task, delivery: "failed"}) + await store.set(sendPendingTaskAtom, {sessionId: "one", send}) + expect(send).toHaveBeenCalledOnce() + await store.set(sendPendingTaskAtom, {sessionId: "one", send, retry: true}) + expect(send).toHaveBeenLastCalledWith({...task, delivery: "sending"}) + expect(store.get(pendingTasksAtom).one).toBeUndefined() + }) + + it("deduplicates concurrent mounts while admission is pending", async () => { + const store = createStore() + store.set(stashPendingTaskAtom, {sessionId: "one", task}) + let resolve!: () => void + const send = vi.fn( + () => + new Promise((done) => { + resolve = done + }), + ) + const first = store.set(sendPendingTaskAtom, {sessionId: "one", send}) + await store.set(sendPendingTaskAtom, {sessionId: "one", send, retry: true}) + expect(send).toHaveBeenCalledOnce() + resolve() + await first + expect(store.get(pendingTasksAtom).one).toBeUndefined() + }) + + it.each([false, true])( + "does not overwrite a newer task or another session after old completion (failure=%s)", + async (failure) => { + const store = createStore() + store.set(stashPendingTaskAtom, {sessionId: "one", task}) + let resolve!: () => void + let reject!: (error: Error) => void + const send = vi.fn( + () => + new Promise((yes, no) => { + resolve = yes + reject = no + }), + ) + const pending = store.set(sendPendingTaskAtom, {sessionId: "one", send}) + const newer = {...task, text: "newer"} + store.set(stashPendingTaskAtom, {sessionId: "one", task: newer}) + store.set(stashPendingTaskAtom, {sessionId: "two", task}) + if (failure) reject(new Error("old failure")) + else resolve() + await pending + expect(store.get(pendingTasksAtom)).toEqual({one: newer, two: task}) + }, + ) +}) diff --git a/web/mobile/tests/unit/turnStatus.test.ts b/web/mobile/tests/unit/turnStatus.test.ts index 6b8ab043281..bb6acdf9836 100644 --- a/web/mobile/tests/unit/turnStatus.test.ts +++ b/web/mobile/tests/unit/turnStatus.test.ts @@ -1,7 +1,9 @@ +import {isComposerRunStoppable} from "@agenta/chat/assets" import {describe, expect, it} from "vitest" import { deriveMobileRemoteTurnPresentation, + showRunningElsewhere, showTrailingWorkingPulse, } from "@/features/chat/turnStatus" @@ -32,35 +34,35 @@ describe("showTrailingWorkingPulse", () => { describe("deriveMobileRemoteTurnPresentation", () => { it.each([ { - name: "renders activity and no strip for a ready reader", + name: "renders activity without remote Stop for a ready reader", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: true}, - expected: {showActivity: true, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, { - name: "renders the strip while the reader is not ready", + name: "renders activity and remote Stop while the reader reconnects", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "renders the strip when the feature is off", + name: "renders activity and remote Stop when the reader is off", input: {livenessRunning: true, sharedReaderAdvertised: false, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "does not render the strip in the tab that owns a continuation", + name: "renders activity without remote Stop for an owned continuation", input: { livenessRunning: true, sharedReaderAdvertised: true, readerReady: false, ownedContinuation: true, }, - expected: {showActivity: false, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, ])("$name", ({input, expected}) => { expect(deriveMobileRemoteTurnPresentation(input)).toEqual(expected) }) - it("shows the flag-off observer banner only while session-stream liveness is running", () => { + it("offers legacy remote Stop only while session-stream liveness is running", () => { const input = { snapshotRunning: true, sharedReaderAdvertised: false, @@ -68,20 +70,48 @@ describe("deriveMobileRemoteTurnPresentation", () => { } expect( - deriveMobileRemoteTurnPresentation({...input, livenessRunning: true}).showStrip, + deriveMobileRemoteTurnPresentation({...input, livenessRunning: true}).showRemoteStop, ).toBe(true) expect( - deriveMobileRemoteTurnPresentation({...input, livenessRunning: false}).showStrip, + deriveMobileRemoteTurnPresentation({...input, livenessRunning: false}).showRemoteStop, ).toBe(false) }) - it("hides the banner when the advertised reader is ready", () => { + it("hides remote Stop when the advertised reader is ready", () => { expect( deriveMobileRemoteTurnPresentation({ livenessRunning: true, sharedReaderAdvertised: true, readerReady: true, - }).showStrip, + }).showRemoteStop, ).toBe(false) }) }) + +describe("showRunningElsewhere", () => { + it("hides the strip for the tab that owns a detached continuation", () => { + expect(showRunningElsewhere({running: true, localStatus: "running"})).toBe(false) + }) + + it("shows the strip for an idle observer of the same backend run", () => { + expect(showRunningElsewhere({running: true, localStatus: "idle"})).toBe(true) + }) + + it("keeps a locally parked gate from being labeled remote", () => { + expect(showRunningElsewhere({running: true, localStatus: "awaiting"})).toBe(false) + }) + + it("renders exactly one Stop for a flag-off remote run", () => { + const stripStop = showRunningElsewhere({running: true, localStatus: "idle"}) + const composerStop = isComposerRunStoppable({ + localStreaming: false, + serverBusy: true, + serverControlEnabled: false, + waitingOnUser: false, + }) + + expect([stripStop, composerStop].filter(Boolean)).toHaveLength(1) + expect(stripStop).toBe(true) + expect(composerStop).toBe(false) + }) +}) diff --git a/web/oss/package.json b/web/oss/package.json index 593d9beb363..48bfdef0870 100644 --- a/web/oss/package.json +++ b/web/oss/package.json @@ -1,6 +1,6 @@ { "name": "@agenta/oss", - "version": "0.115.1", + "version": "0.115.2", "private": true, "engines": { "node": "24.x" diff --git a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx index 618e1c751da..1d0f836d763 100644 --- a/web/oss/src/components/AgentChatSlice/AgentConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentConversation.tsx @@ -14,6 +14,7 @@ import { useComposerAttachments, useAgentChatQueue, useSessionLivePreview, + useServerSessionInputs, type QueuedMessage, } from "@agenta/chat/hooks" import { @@ -55,6 +56,7 @@ import {DriveFileLinkProvider} from "@/oss/components/Drives/DriveFileLinkProvid import {useSessionFilesPane} from "@/oss/components/Drives/SessionFilesPane" import {TEMPLATE_STRIP_MODE} from "@/oss/components/pages/agent-home/assets/constants" +import {answerThenSteer} from "./assets/answerThenSteer" import {isAgentFileUploadsEnabled} from "./assets/constants" import {CONTENT_VISIBILITY_ENABLED} from "./assets/conversationLayout" import {runWithInFlightSubmit} from "./assets/inFlightSubmit" @@ -75,7 +77,11 @@ 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 { + deriveSessionRemoteTurnPresentation, + sessionLivenessUpdatedAtAtom, + refreshSessionLivenessAtom, +} from "./state/liveness" import {useChatScopeKey} from "./state/scope" import { activeSessionIdAtomFamily, @@ -150,19 +156,24 @@ const AgentConversation = ({ stopping, setStopped, handleStop, - handleClientToolOutput, + handleClientToolOutput: answerClientTool, markLiveGate, answerApproval, + answerApprovals, + retryContinuation, resumeOrphaned, isSeen, runningElsewhere: livenessRunningElsewhere, sharedReaderAdvertised, refreshFromRecords, + onCommittedRevision, + revalidate, setSharedSenderReady, } = useAgentChatSession({entityId, sessionId, initialMessages, intent: scrollIntent}) const { messages: previewMessages, runningFromSnapshot, + sharedSettledAt, readerReady, } = useSessionLivePreview({ sessionId, @@ -172,10 +183,18 @@ const AgentConversation = ({ onReadyChange: setSharedSenderReady, onExecutionSettled: settleSharedTurn, onDisconnect: refreshFromRecords, + onCommittedRevision, }) + const livenessUpdatedAt = useAtomValue(sessionLivenessUpdatedAtAtom) + const refreshLiveness = useSetAtom(refreshSessionLivenessAtom) + useEffect(() => { + if (sharedSettledAt) void refreshLiveness() + }, [sharedSettledAt, refreshLiveness]) const remoteTurn = deriveSessionRemoteTurnPresentation({ livenessRunning: livenessRunningElsewhere, - snapshotRunning: runningFromSnapshot || acceptedRunPending, + livenessUpdatedAt, + sharedSettledAt, + snapshotRunning: runningFromSnapshot, sharedReaderAdvertised, readerReady, ownedContinuation: acceptedRunPending, @@ -249,6 +268,18 @@ const AgentConversation = ({ // composer until connected — see `gateActive` on `useAgentModelKeyStatus` for the full chain. const modelKey = useAgentModelKeyStatus(entityId) const modelBlocked = modelKey.gateActive + const [recoverableContinuation, setRecoverableContinuation] = useState(false) + // Execution id of the continuation the last durable answer started (respond body, + // `execution.id`). The queue holds every send until that execution writes its terminal record: + // the transcript-derived hold cannot cover the seconds between the answer and the + // continuation's first record, and a transcript adopted inside that gap reads as settled. + const [continuationExecutionId, setContinuationExecutionId] = useState(null) + const approvalResponseOwnerRef = useRef(null) + const retryRecoverableContinuation = useCallback(async () => { + const resumed = await retryContinuation() + if (resumed) setRecoverableContinuation(false) + return resumed + }, [retryContinuation]) // Context-window denominator for the token-budget indicator: the SDK model catalog's own // `context_window`, delivered on the (global) harness-capabilities document — never hardcoded. @@ -352,6 +383,24 @@ const AgentConversation = ({ const consumedRunNonceRef = useRef(null) + const serverInputs = useServerSessionInputs({ + entityId, + sessionId, + messages, + locallyBusy: busy, + isSharedReaderReady: () => readerReady, + onExecuted: revalidate, + }) + + const previousServerInputsStatusRef = useRef(status) + useEffect(() => { + const previousStatus = previousServerInputsStatusRef.current + previousServerInputsStatusRef.current = status + if (previousStatus !== status && (status === "ready" || status === "error")) { + void serverInputs.refresh() + } + }, [status, serverInputs.refresh]) + // Send one released queued message. Stable (only depends on `sendMessage`) so the queue's // release effect doesn't churn on every token. const sendQueued = useCallback( @@ -374,6 +423,10 @@ const AgentConversation = ({ }, [sendMessage, sessionId], ) + const markRunOwned = useCallback( + () => setSessionStatus({id: sessionId, status: "running"}), + [sessionId, setSessionStatus], + ) // Queue messages typed while a turn is streaming or paused on a HITL approval; released // one-by-one once the turn truly settles (never mid-approval). A user stop is the exception — @@ -382,7 +435,13 @@ const AgentConversation = ({ const { queued, submit, + steer, removeQueued, + sendQueuedNow, + ownsContinuation, + queueEnabled, + steerEnabled, + serverBusy, hitlPending, editingId, beginEdit, @@ -395,15 +454,21 @@ const AgentConversation = ({ acceptedRunPending, stopped, resumeOrphaned, + recoverable: recoverableContinuation, + retryContinuation: retryRecoverableContinuation, + continuationExecutionId, + markRunOwned, sendQueued, sessionId, + server: serverInputs, }) // Approval responses flow through here (not bare `addToolApprovalResponse`) so a decision made // in THIS mount marks the resume as live — a restored approval-requested tail the user answers // after a reload genuinely auto-resumes, so the queue's pre-resume hold must apply to it. const handleApprovalResponse = useCallback( - (args: {id: string; approved: boolean; message?: string}) => { + async (args: {id: string; approved: boolean; message?: string}) => { + approvalResponseOwnerRef.current = args.id markLiveGate({kind: "approval", id: args.id}) // `answerApproval` owns the whole ordered click: the row first, then the part flip that // lets the SDK resume. Never flip here — an early flip lets the resume's stale sweep @@ -416,20 +481,64 @@ const AgentConversation = ({ // (The model still reasons about the bare denial first — the "flail" — because the // harness owns the reject continuation and exposes no reject-with-feedback seam; killing // that flail needs an upstream ACP change, not an FE one.) - const steer = args.message?.trim() - void answerApproval(args.id, args.approved).then(() => { - // After the answer for the same reason the flip is: a steer starts its own turn. - if (!args.approved && steer) submit({text: steer}) + // The outcome is RETURNED, not swallowed: the dock reads `recoverable` off it to show + // "Answer saved, retry needed" instead of "Answered, waiting for the agent". + const outcome = await answerThenSteer({ + approved: args.approved, + message: args.message, + answer: () => answerApproval(args.id, args.approved), + steer: (text) => submit({text}), }) + if (approvalResponseOwnerRef.current === args.id) { + setRecoverableContinuation(outcome?.recoverable === true) + setContinuationExecutionId(outcome?.executionId ?? null) + } + return outcome }, [answerApproval, markLiveGate, submit], ) + const handleClientToolOutput = useCallback( + async (args: Parameters[0]) => { + approvalResponseOwnerRef.current = args.toolCallId + const outcome = await answerClientTool(args) + if (approvalResponseOwnerRef.current === args.toolCallId) { + setRecoverableContinuation(outcome.recoverable) + setContinuationExecutionId(outcome.executionId ?? null) + } + }, + [answerClientTool], + ) + + const handleApprovalResponses = useCallback( + async (ids: string[], approved: boolean) => { + approvalResponseOwnerRef.current = ids[0] + markLiveGate({kind: "approval", id: ids[0]}) + const outcome = await answerApprovals(ids, approved) + if (approvalResponseOwnerRef.current === ids[0]) { + setRecoverableContinuation(outcome?.recoverable === true) + setContinuationExecutionId(outcome?.executionId ?? null) + } + return outcome + }, + [answerApprovals, markLiveGate], + ) + const interactionAvailability = getInteractionAvailability({stopped, stopping, streaming: busy}) const pendingApprovals = useMemo( () => getLivePendingApprovals(messages, {stopped: !interactionAvailability.approvals}), [messages, interactionAvailability.approvals], ) + const pendingApprovalId = pendingApprovals[0]?.approvalId + if (pendingApprovalId && approvalResponseOwnerRef.current !== pendingApprovalId) { + approvalResponseOwnerRef.current = pendingApprovalId + } + useEffect(() => { + if (pendingApprovalId) { + setRecoverableContinuation(false) + setContinuationExecutionId(null) + } + }, [pendingApprovalId]) // Parked connect interactions on the paused turn → the connect dock owns their actions (the // inline rows are passive markers). Gated off while busy (`input-streaming` isn't parked yet) // and after a user stop (the run is dead, nothing to settle — matches the queue's stop void). @@ -501,11 +610,19 @@ const AgentConversation = ({ ? "error" : hitlPending || anyPendingInteraction ? "awaiting" - : busy + : busy || ownsContinuation ? "running" : "idle" setSessionStatus({id: sessionId, status}) - }, [error, hitlPending, anyPendingInteraction, busy, sessionId, setSessionStatus]) + }, [ + error, + hitlPending, + anyPendingInteraction, + busy, + ownsContinuation, + sessionId, + setSessionStatus, + ]) // On unmount, retire the dot ONLY if the run went with us. A chat preserved past this mount // (route change with the tab still open) is still this browser's run to report, so it keeps its // status until it settles — `useAgentChatSession`'s `onFinish` retires it then. The session hook @@ -527,8 +644,14 @@ const AgentConversation = ({ if (consumedRunNonceRef.current === pendingRun.nonce) return consumedRunNonceRef.current = pendingRun.nonce scrollIntent.follow() - submit({text: pendingRun.text}) - setPendingRun(null) + void Promise.resolve(submit({text: pendingRun.text})) + .then(() => + setPendingRun((current) => (current?.nonce === pendingRun.nonce ? null : current)), + ) + .catch(() => { + richInputRef.current?.setMarkdown(pendingRun.text) + attachments.setRejections([{name: "Message", reason: "wasn't sent — try again."}]) + }) }, [pendingRun, activeSessionId, sessionId, submit, setPendingRun]) // Run-level shortcuts. They live here, not in the panel's session hook, because only this @@ -540,12 +663,6 @@ const AgentConversation = ({ // Radix cancels Escape for a layer but still lets it reach us, and it never touches // Alt+G, which only the overlay check catches. if (e.defaultPrevented || isOverlayOpen()) return - // An IME user presses Escape to cancel composition, not to stop the run. - if (e.key === "Escape" && !e.isComposing && busyRef.current) { - e.preventDefault() - handleStop() - return - } // Approve answers ONE gate, never the dock's "Approve all": a mis-press should not // grant a tool the user never read. if (isAltChord(e) && e.code === "KeyG" && pendingApprovals.length > 0) { @@ -555,7 +672,7 @@ const AgentConversation = ({ } document.addEventListener("keydown", onKey) return () => document.removeEventListener("keydown", onKey) - }, [activeSessionId, sessionId, busyRef, handleStop, pendingApprovals, handleApprovalResponse]) + }, [activeSessionId, sessionId, pendingApprovals, handleApprovalResponse]) // A keyboard switch (Alt+1…9 / Alt+Z / Alt+X) lands the caret here. antd mounts a never-visited // pane only on activation, so this effect runs on that mount and a first-visit switch focuses @@ -591,16 +708,17 @@ const AgentConversation = ({ useVirtuoso, }) - const finishSubmit = ( + const finishSubmit = async ( trimmed: string, fileParts: FileUIPart[] | undefined, consumedUids: string[], stagedFiles: typeof files, + policy: "queue" | "steer" = "queue", ) => { if (editingId) { // A rewrite of a held message: nothing is sent, so the transcript must not move. // The input clears itself on submit, so the displaced draft goes back after that. - const draft = commitEdit({text: trimmed, fileParts, stagedFiles}) + const draft = await commitEdit({text: trimmed, fileParts, stagedFiles}) if (draft) requestAnimationFrame(() => richInputRef.current?.setMarkdown(draft)) } else { // Glide to the bottom; the min-h-full active turn makes that show the new question at the @@ -608,8 +726,12 @@ const AgentConversation = ({ // Clear any prior "stopped" marker — it's resolved by asking again. scrollIntent.armGlide() setStopped(false) + // Clear only the pending run this manual retry took over, after admission succeeds. + const pendingRunNonce = consumedRunNonceRef.current // One path: `submit` sends now or queues behind held messages via the shared release gate. - submit({text: trimmed, fileParts, stagedFiles}) + if (policy === "steer") await steer({text: trimmed, fileParts}) + else await submit({text: trimmed, fileParts, stagedFiles}) + setPendingRun((current) => (current?.nonce === pendingRunNonce ? null : current)) } // The message left the composer — drop its persisted draft (and any pending capture). composer.clearDraft() @@ -619,7 +741,11 @@ const AgentConversation = ({ // A voice take awaits its upload, so the guard keeps a second send from starting meanwhile. const inFlightSubmitRef = useRef(false) - const handleSubmit = (text: string, extraFiles: File[] = []) => + const handleSubmit = ( + text: string, + extraFiles: File[] = [], + policy: "queue" | "steer" = "queue", + ) => runWithInFlightSubmit(inFlightSubmitRef, async () => { const trimmed = text.trim() if (!trimmed && files.length === 0 && extraFiles.length === 0) return @@ -650,7 +776,7 @@ const AgentConversation = ({ } fileParts = parts } - finishSubmit(trimmed, fileParts, stagedUids, files) + await finishSubmit(trimmed, fileParts, stagedUids, files, policy) return } @@ -663,7 +789,10 @@ const AgentConversation = ({ const fileParts = outboundFiles.length ? stagedFilesToParts(outboundFiles, sessionId) : undefined - finishSubmit(trimmed, fileParts, stagedUids, outboundFiles) + await finishSubmit(trimmed, fileParts, stagedUids, outboundFiles, policy) + }).catch(() => { + richInputRef.current?.setMarkdown(text) + attachments.setRejections([{name: "Message", reason: "wasn't sent — try again."}]) }) handleSubmitRef.current = handleSubmit @@ -903,15 +1032,16 @@ const AgentConversation = ({ entityId={entityId} messages={messages} busy={busy} - showRunningElsewhere={remoteTurn.showStrip} connectionWarning={connectionWarning} hitlPending={hitlPending} queue={{ queued, removeQueued, + sendQueuedNow, editingId, beginEdit, cancelEdit, + serverBusy, }} modelKey={{...modelKey, entityId}} modelBlocked={modelBlocked} @@ -920,12 +1050,17 @@ const AgentConversation = ({ showTemplateStrip={showTemplateStrip} pendingApprovals={pendingApprovals} onApprovalResponse={handleApprovalResponse} + onApprovalResponses={handleApprovalResponses} connects={connects} elicits={elicits} onClientToolOutput={handleClientToolOutput} onSubmit={handleSubmit} + onSteer={(text) => handleSubmit(text, [], "steer")} onStop={handleStop} stopping={stopping} + queueEnabled={queueEnabled} + steerEnabled={steerEnabled} + stopShortcutEnabled={activeSessionId === sessionId} richInputRef={richInputRef} composer={{...composer, handleComposerChange}} attachments={attachments} diff --git a/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.test.ts b/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.test.ts new file mode 100644 index 00000000000..9ff0b23a2b7 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.test.ts @@ -0,0 +1,74 @@ +/** + * The approval click keeps the submission outcome. + * + * The dock decides between "Answer saved, retry needed" and "Answered, waiting for the agent" from + * the `recoverable` flag on the value this wrapper resolves to. A wrapper that answered the gate + * and resolved to `undefined` showed a healthy card over a continuation the server could not + * deliver, which is the state the user has to act on. + */ +import {describe, expect, it, vi} from "vitest" + +import {answerThenSteer} from "./answerThenSteer" + +describe("answerThenSteer", () => { + it("resolves to the submission outcome, so a recoverable answer reaches the card", async () => { + const outcome = await answerThenSteer({ + approved: true, + answer: async () => ({durable: true, recoverable: true}), + steer: () => undefined, + }) + + expect(outcome).toEqual({durable: true, recoverable: true}) + }) + + it("still resolves to the outcome when a denial also sends a steer note", async () => { + const steer = vi.fn() + + const outcome = await answerThenSteer({ + approved: false, + message: " use the staging bucket ", + answer: async () => ({durable: true, recoverable: false}), + steer, + }) + + expect(outcome).toEqual({durable: true, recoverable: false}) + expect(steer).toHaveBeenCalledWith("use the staging bucket") + }) + + it("sends the steer note only after the answer, and only on a denial", async () => { + const order: string[] = [] + const steer = vi.fn(() => order.push("steer")) + + await answerThenSteer({ + approved: false, + message: "stop", + answer: async () => { + order.push("answer") + }, + steer, + }) + expect(order).toEqual(["answer", "steer"]) + + steer.mockClear() + await answerThenSteer({ + approved: true, + message: "stop", + answer: async () => undefined, + steer, + }) + expect(steer).not.toHaveBeenCalled() + }) + + it("ignores a blank note", async () => { + const steer = vi.fn() + + await answerThenSteer({ + approved: false, + message: " ", + answer: async () => undefined, + steer, + }) + + expect(steer).not.toHaveBeenCalled() + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.ts b/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.ts new file mode 100644 index 00000000000..ba1c3b9b002 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/answerThenSteer.ts @@ -0,0 +1,33 @@ +/** + * Answer one approval gate, then start the steer turn a denial carries — and hand the submission + * outcome back to the caller. + * + * The outcome is the whole point of the return value. The dock reads `recoverable` off it to tell + * "Answer saved, retry needed" from "Answered, waiting for the agent", so a wrapper that answers + * the gate and returns nothing makes an undeliverable continuation look like a healthy one for as + * long as the card stays open. Extracted so that contract has a test of its own. + * + * A steer note is sent only with a DENIAL, and only after the answer, because resuming a parked + * gate makes the harness continue the original prompt: a note fused into that resume is + * subordinated to the original intent. As its own turn it drives the redirect. + */ +import type {ApprovalSubmissionOutcome} from "@agenta/chat/assets" + +export type ApprovalAnswerResult = void | ApprovalSubmissionOutcome + +export async function answerThenSteer({ + approved, + message, + answer, + steer, +}: { + approved: boolean + message?: string + answer: () => Promise + steer: (text: string) => void +}): Promise { + const note = message?.trim() + const outcome = await answer() + if (!approved && note) steer(note) + return outcome +} diff --git a/web/oss/src/components/AgentChatSlice/assets/composerRunState.test.ts b/web/oss/src/components/AgentChatSlice/assets/composerRunState.test.ts new file mode 100644 index 00000000000..ca36ef7828b --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/assets/composerRunState.test.ts @@ -0,0 +1,26 @@ +import {isComposerRunStoppable} from "@agenta/chat/assets" +import {describe, expect, it} from "vitest" + +describe("desktop composer run state", () => { + it("does not expose Stop for another browser's run when capabilities are absent", () => { + expect( + isComposerRunStoppable({ + localStreaming: false, + serverBusy: true, + serverControlEnabled: false, + waitingOnUser: false, + }), + ).toBe(false) + }) + + it("keeps this browser's legacy stream stoppable when capabilities are absent", () => { + expect( + isComposerRunStoppable({ + localStreaming: true, + serverBusy: false, + serverControlEnabled: false, + waitingOnUser: false, + }), + ).toBe(true) + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx index dc150505d1d..4a4a2ca1c09 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx @@ -1,13 +1,16 @@ import {useCallback, useEffect, useRef, type RefObject} from "react" -import {CHAT_COLUMN, shouldShowStopControl} from "@agenta/chat/assets" +import { + CHAT_COLUMN, + isComposerRunStoppable, + type ApprovalSubmissionOutcome, +} from "@agenta/chat/assets" import type {ClientToolOutputHandler} from "@agenta/chat/clientTools" import { ChatComposer, ConnectionWarningStrip, MicPermissionNotice, RecordingBar, - RunningElsewhereStrip, VoiceInputButton, } from "@agenta/chat/components" import { @@ -59,7 +62,6 @@ const AgentComposerDock = ({ entityId, messages, busy, - showRunningElsewhere, connectionWarning, hitlPending, queue, @@ -70,12 +72,15 @@ const AgentComposerDock = ({ showTemplateStrip, pendingApprovals, onApprovalResponse, + onApprovalResponses, connects, elicits, onClientToolOutput, onSubmit, onStop, stopping, + queueEnabled, + stopShortcutEnabled, richInputRef, composer, attachments, @@ -88,17 +93,17 @@ const AgentComposerDock = ({ entityId: string messages: UIMessage[] busy: boolean - /** Show the disconnected/flag-off fallback for a run this browser is not driving. */ - showRunningElsewhere: boolean /** The sender request disconnected after the session accepted the turn. */ connectionWarning?: string hitlPending: boolean queue: { queued: QueuedMessage[] removeQueued: (id: string) => void + sendQueuedNow: ((id: string) => Promise) | undefined editingId: string | null beginEdit: (id: string, draft?: string) => void cancelEdit: () => string + serverBusy: boolean } modelKey: React.ComponentProps modelBlocked: boolean @@ -107,14 +112,26 @@ const AgentComposerDock = ({ /** The agent empty-chat template strip is on (owned by AgentConversation — see its comment). */ showTemplateStrip: boolean pendingApprovals: ReturnType - onApprovalResponse: (args: {id: string; approved: boolean; message?: string}) => void + onApprovalResponse: (args: { + id: string + approved: boolean + message?: string + }) => void | ApprovalSubmissionOutcome | Promise + onApprovalResponses: ( + ids: string[], + approved: boolean, + ) => void | ApprovalSubmissionOutcome | Promise connects: ConnectionDockState /** Parked question forms the run is blocked on (from `useElicitationDock`). */ elicits: ElicitationDockState onClientToolOutput: ClientToolOutputHandler onSubmit: (text: string) => void | Promise + onSteer: (text: string) => void | Promise onStop: () => void stopping: boolean + queueEnabled: boolean + steerEnabled: boolean + stopShortcutEnabled: boolean richInputRef: RefObject composer: ReturnType attachments: ReturnType @@ -127,6 +144,12 @@ const AgentComposerDock = ({ /** Read at event time — attachments are refused right now (a take in flight, or the above). */ attachmentsBlocked: () => boolean }) => { + const stoppable = isComposerRunStoppable({ + localStreaming: busy, + serverBusy: queue.serverBusy, + serverControlEnabled: queueEnabled, + waitingOnUser: hitlPending, + }) const { onboarding, onboardingActive, @@ -247,10 +270,6 @@ const AgentComposerDock = ({ // Permission rules live in the Advanced accordion's Permissions group. const openPermissionsConfig = useCallback(() => openConfigFor("advanced"), [openConfigFor]) - // Any blocking dock on screen. The queue card yields to all of them rather than stacking, - // mid-edit included — the composer keeps the edit, so Enter still rewrites the held row. - const gateDockOpen = pendingApprovals.length > 0 || elicits.open || connects.open - // Editing borrows the composer: the row's text goes in, the draft it displaces is stashed. const {beginEdit, cancelEdit} = queue const editQueued = useCallback( @@ -295,14 +314,14 @@ const AgentComposerDock = ({ /> ) : null} - {/* Above the gate docks, and hidden entirely while one is up: those are blocked - runs wanting an answer, and a second card stacked above one buries the composer. - Inside the `Reveal` so it shares the composer's `px-3` gutter and column. */} + {/* Above the gate docks so a held message remains visible while the run waits for + an answer. Inside the `Reveal` so it shares the composer's gutter and column. */} {/* Sits with the other docked strips so a session running in another browser reads as busy instead of frozen (#5530). */} - {showRunningElsewhere && !chromeHidden ? ( - - ) : null} {connectionWarning && !chromeHidden ? ( ) : null} @@ -325,6 +341,7 @@ const AgentComposerDock = ({ className={CHAT_COLUMN} approvals={pendingApprovals} onApprovalResponse={onApprovalResponse} + onApprovalResponses={onApprovalResponses} entityId={entityId} /> {/* Parked client-tool interactions (connect): same placement contract as the @@ -455,9 +472,10 @@ const AgentComposerDock = ({ initialMarkdown={composer.initialDraft} slashCommands={slash.sections} onChange={composer.handleComposerChange} - streaming={shouldShowStopControl({busy, hitlPending})} + streaming={stoppable} stopping={stopping} onStop={onStop} + stopShortcutEnabled={stopShortcutEnabled} attachments={attachments} attachmentsBlocked={attachmentsBlocked} composerDisabled={composerDisabled} diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx index 443ddb59c53..c3fe1af46e6 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx @@ -154,6 +154,7 @@ const STARTER_CREDIT_CODES = new Set([ /** Transient failure classes where the honest advice is simply to run the turn again. */ const RETRYABLE_CODES = new Set([ + "continuation_resumed", "credential_delivery_failed", "starter_credits_unavailable", "rate_limited", diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx index e32d76e8729..b47dccbf02a 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.test.tsx @@ -5,8 +5,12 @@ * REPLACES Approve as the single primary, "Deny all" mirrors it, and the detail rows list the * pending actions — the informed-click job the popover used to do. */ +import {act} from "react" + +import {createRoot} from "react-dom/client" import {renderToStaticMarkup} from "react-dom/server" import {describe, expect, it} from "vitest" +;(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true // No hook mock: the card imports `useAlwaysAllowTool` by its relative path inside the package, so // a `@agenta/chat/hooks` mock resolves elsewhere and does nothing. The always-allow row is covered @@ -67,3 +71,68 @@ describe("no pending gate", () => { expect(render([])).not.toContain("Needs your approval") }) }) + +describe("interaction-scoped response state", () => { + it("does not carry a settled recoverable card from interaction X to later interaction Y", async () => { + const onApprovalResponse = () => Promise.resolve({durable: true, recoverable: true}) + const host = document.createElement("div") + document.body.appendChild(host) + const root = createRoot(host) + const renderGate = (id: string) => ( + + ) + + await act(async () => root.render(renderGate("interaction-x"))) + const approveButton = [...host.querySelectorAll("button")].find((button) => + button.textContent?.includes("Approve"), + ) as HTMLButtonElement + await act(async () => approveButton.click()) + expect(host.textContent).toContain("Answer saved, retry needed") + + await act(async () => root.render(renderGate("interaction-y"))) + + expect(host.textContent).toContain("Needs your approval") + expect(host.textContent).not.toContain("Answer saved, retry needed") + + await act(async () => root.unmount()) + host.remove() + }) + + it("does not leak a late recoverable result onto the next desktop gate", async () => { + let resolveFirst: ((value: {durable: boolean; recoverable: boolean}) => void) | undefined + const onApprovalResponse = () => + new Promise<{durable: boolean; recoverable: boolean}>((resolve) => { + resolveFirst = resolve + }) + const host = document.createElement("div") + document.body.appendChild(host) + const root = createRoot(host) + const renderGate = (id: string) => ( + + ) + + await act(async () => root.render(renderGate("g1"))) + const approveButton = [...host.querySelectorAll("button")].find((button) => + button.textContent?.includes("Approve"), + ) as HTMLButtonElement + await act(async () => { + approveButton.click() + }) + await act(async () => root.render(renderGate("g2"))) + await act(async () => resolveFirst?.({durable: true, recoverable: true})) + + expect(host.textContent).toContain("Needs your approval") + expect(host.textContent).not.toContain("Answer saved, retry needed") + + await act(async () => root.unmount()) + host.remove() + }) +}) diff --git a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx index b63a2955ee1..30b498bb2fa 100644 --- a/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx @@ -1,5 +1,6 @@ import {memo, useEffect, useRef, useState} from "react" +import type {ApprovalSubmissionOutcome} from "@agenta/chat/assets" import {ApprovalCard} from "@agenta/chat/components" import type {PendingApproval} from "@agenta/chat/model" import {HeightCollapse} from "@agenta/ui" @@ -9,7 +10,15 @@ import {isAgentChatSteerEnabled} from "../assets/constants" interface ApprovalDockProps { /** Pending gates for the paused turn (index 0 is acted on first). */ approvals: PendingApproval[] - onApprovalResponse: (args: {id: string; approved: boolean; message?: string}) => void + onApprovalResponse: (args: { + id: string + approved: boolean + message?: string + }) => void | ApprovalSubmissionOutcome | Promise + onApprovalResponses?: ( + ids: string[], + approved: boolean, + ) => void | ApprovalSubmissionOutcome | Promise /** Selected agent revision — enables the always-allow grant. */ entityId?: string className?: string @@ -22,7 +31,13 @@ interface ApprovalDockProps { * shape, for every user); this dock is the desktop adapter: it owns the open/close animation, the * multi-gate resolve latch, and how a response actually fires. */ -const ApprovalDock = ({approvals, onApprovalResponse, entityId, className}: ApprovalDockProps) => { +const ApprovalDock = ({ + approvals, + onApprovalResponse, + onApprovalResponses, + entityId, + className, +}: ApprovalDockProps) => { const open = approvals.length > 0 // "Approve all" / "Deny all" answer SEVERAL gates at once, and each response settles // asynchronously (the SDK's serial job queue), so the pending set shrinks across renders. @@ -36,8 +51,13 @@ const ApprovalDock = ({approvals, onApprovalResponse, entityId, className}: Appr if (open && !resolving) shownRef.current = approvals const shown = shownRef.current const current = shown[0] + const currentIdRef = useRef(current?.approvalId) + currentIdRef.current = current?.approvalId const [responding, setResponding] = useState(false) + const [answered, setAnswered] = useState(false) + const [recoverable, setRecoverable] = useState(false) + const [errorText, setErrorText] = useState(null) // Feature flag: the "Redirect" (steer) control is OFF by default. The UI is complete, but the // redirect runs as a follow-up turn — the model reasons about the bare denial before it lands — // so we hide the entry point until the runner-level reject-and-redirect lands. @@ -46,6 +66,9 @@ const ApprovalDock = ({approvals, onApprovalResponse, entityId, className}: Appr // The current gate changed (we answered one, the next slid in) — re-enable. useEffect(() => { setResponding(false) + setAnswered(false) + setRecoverable(false) + setErrorText(null) }, [current?.approvalId]) // Once every gate we fired has settled, drop the latch — the dock then closes if nothing @@ -56,11 +79,44 @@ const ApprovalDock = ({approvals, onApprovalResponse, entityId, className}: Appr } }, [approvals, resolvingIds]) + const settle = async ( + responses: (void | ApprovalSubmissionOutcome | Promise)[], + ownerId: string | undefined, + ) => { + const results = await Promise.allSettled(responses) + if (currentIdRef.current !== ownerId) return + const failed = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ) + if (!failed) { + setRecoverable( + results.some( + (result) => result.status === "fulfilled" && result.value?.recoverable === true, + ), + ) + setAnswered(true) + return + } + setResponding(false) + setResolvingIds(null) + setErrorText( + failed.reason instanceof Error + ? failed.reason.message + : "Approval failed. Please try again.", + ) + } + const respondMany = (ids: string[], approved: boolean) => { if (responding) return setResponding(true) + setErrorText(null) setResolvingIds(ids) - ids.forEach((id) => onApprovalResponse({id, approved})) + void settle( + onApprovalResponses + ? [onApprovalResponses(ids, approved)] + : ids.map((id) => onApprovalResponse({id, approved})), + ids[0], + ) } // Always mounted; enter + leave animate via the shared HeightCollapse. `inert` while closed @@ -72,17 +128,26 @@ const ApprovalDock = ({approvals, onApprovalResponse, entityId, className}: Appr { if (responding) return setResponding(true) - onApprovalResponse({ - id: approvalId, - approved, - ...(message?.trim() ? {message: message.trim()} : {}), - }) + setErrorText(null) + void settle( + [ + onApprovalResponse({ + id: approvalId, + approved, + ...(message?.trim() ? {message: message.trim()} : {}), + }), + ], + approvalId, + ) }} onApproveAll={(ids) => respondMany(ids, true)} onDenyAll={(ids) => respondMany(ids, false)} diff --git a/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx b/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx index 8e1c0cc9a0e..ac06afc3f18 100644 --- a/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx +++ b/web/oss/src/components/AgentChatSlice/components/QueuedMessagesDock.tsx @@ -18,6 +18,7 @@ interface AgentQueuedMessagesDockProps { /** The run is parked on the user, so the queue is held rather than merely waiting. */ held: boolean onRemove: (id: string) => void + onSendNow?: (id: string) => Promise onEdit: (message: QueuedMessage) => void onCancelEdit: () => void editingId: string | null @@ -28,12 +29,13 @@ const AgentQueuedMessagesDock = ({ queued, held, onRemove, + onSendNow, onEdit, onCancelEdit, editingId, className, }: AgentQueuedMessagesDockProps) => { - const open = queued.length > 0 + const open = queued.length > 0 || !!editingId // Latch the last non-empty queue: emptying it starts the collapse, and without this the rows // would vanish first and leave an empty box folding shut. const shownRef = useRef(queued) @@ -46,6 +48,7 @@ const AgentQueuedMessagesDock = ({ queued={shownRef.current} held={held} onRemove={onRemove} + onSendNow={onSendNow} onEdit={onEdit} onCancelEdit={onCancelEdit} editingId={editingId} diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts index 2e0785b534b..0b8e2ac3779 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.test.ts @@ -13,6 +13,14 @@ const state = vi.hoisted(() => ({ | { prepareRequest: (args: {messages: UIMessage[]; id?: string}) => Promise onData: (part: {type: string; data?: unknown}) => void + onFinish: (args: { + message: UIMessage + messages: UIMessage[] + finishReason?: string + isAbort?: boolean + isDisconnect?: boolean + isError?: boolean + }) => void onError: () => void sendAutomaticallyWhen: (args: {messages: UIMessage[]}) => boolean } @@ -29,16 +37,54 @@ const state = vi.hoisted(() => ({ regenerate: vi.fn(() => Promise.resolve()), sendMessage: vi.fn(() => Promise.resolve()), turnIds: new Map(), + hydrationBusyRef: undefined as {current: boolean} | undefined, busy: false, stop: vi.fn(), + switchEntity: vi.fn(), + respondAnswer: vi.fn(), + addToolOutput: vi.fn(), + durableCapability: false, + setCommitSignal: vi.fn(), + invalidateCommit: vi.fn(), })) vi.mock("@agenta/chat/assets", () => ({ buildRequestWithinDeadline: (build: () => Promise) => build(), getMessageTraceId: () => undefined, latestTurnId: () => state.latestTurnId, + // The continuation preflight is a pass-through here: this suite drives the execution guard, + // not the durable retry, so the request builder must simply run. + prepareAfterContinuationPreflight: ( + _resume: unknown, + _sessionId: string, + build: () => Promise, + ) => build(), resolveStopExecution: state.resolveStopExecution, startupLabelFromDataPart: () => undefined, + submitApprovalForCapability: async ({ + durableApprovals, + submitDurable, + retireDurable, + recordLegacy, + releaseLegacy, + }: { + durableApprovals: boolean + submitDurable: () => Promise + retireDurable: () => void + recordLegacy: () => Promise + releaseLegacy: () => void + }) => { + if (durableApprovals) { + try { + return await submitDurable() + } finally { + retireDurable() + } + } + await recordLegacy() + releaseLegacy() + return {durable: false, recoverable: false} + }, })) vi.mock("@agenta/chat/hooks", () => ({ @@ -95,13 +141,17 @@ vi.mock("@agenta/entities/session", () => ({ invalidateSessionListQueries: vi.fn(), killSession: vi.fn(), recordInteractionAnswerAtom: "record-interaction-answer", + respondInteractionAnswerAtom: "respond-interaction-answer", + respondInteractionAnswersAtom: "respond-interaction-answers", + resumeSessionContinuationAtom: "resume-session-continuation", + sessionDurableApprovalsCapabilityAtom: "session-durable-approvals-capability", revalidateSessionMountsAtom: "revalidate-mounts", revalidateSessionRecordsAtom: "revalidate-records", })) vi.mock("@agenta/entities/trace", () => ({markTraceAsFresh: vi.fn()})) vi.mock("@agenta/entities/workflow", () => ({ - invalidateAgentCommittedRevisionCache: vi.fn(), + invalidateAgentCommittedRevisionCache: state.invalidateCommit, workflowMolecule: { selectors: {configuration: () => "workflow-configuration"}, }, @@ -131,7 +181,7 @@ vi.mock("@agenta/ui/app-message", () => ({message: {warning: vi.fn()}})) vi.mock("@ai-sdk/react", () => ({ useChat: () => ({ addToolApprovalResponse: vi.fn(), - addToolOutput: vi.fn(), + addToolOutput: state.addToolOutput, error: undefined, messages: state.messages, regenerate: state.regenerate, @@ -147,7 +197,16 @@ vi.mock("@tanstack/react-query", () => ({ vi.mock("jotai", () => ({ useAtomValue: () => state.projectId, - useSetAtom: () => vi.fn(), + useSetAtom: (atom: string) => + atom === "respond-interaction-answer" + ? state.respondAnswer + : atom === "session-durable-approvals-capability" + ? () => Promise.resolve(state.durableCapability) + : atom === "switch-entity" + ? state.switchEntity + : atom === "commit-signal" + ? state.setCommitSignal + : vi.fn(), useStore: () => ({ get: (atom: string) => { if (atom === "record-counts" || atom === "session-messages") return {} @@ -175,14 +234,17 @@ vi.mock("./useFileActivityDetector", () => ({ useFileActivityDetector: vi.fn(), })) vi.mock("./useSessionHydration", () => ({ - useSessionHydration: () => ({ - hydratedEmpty: false, - isHydrating: false, - runningElsewhere: false, - sessionTurnId: state.sessionTurnId, - stoppingTurnId: state.stoppingTurnId, - stopStateLoading: state.stopStateLoading, - }), + useSessionHydration: ({busyRef}: {busyRef: {current: boolean}}) => { + state.hydrationBusyRef = busyRef + return { + hydratedEmpty: false, + isHydrating: false, + runningElsewhere: false, + sessionTurnId: state.sessionTurnId, + stoppingTurnId: state.stoppingTurnId, + stopStateLoading: state.stopStateLoading, + } + }, })) vi.mock("./useToolCacheInvalidation", () => ({ useToolCacheInvalidation: vi.fn(), @@ -195,11 +257,22 @@ describe("useAgentChatSession execution guard", () => { state.acceptedRunBySession.clear() state.turnDeliverySourceBySession.clear() state.turnIds.clear() + state.respondAnswer.mockReset().mockResolvedValue({ + durable: true, + recoverable: false, + executionId: "questionnaire-child", + }) + state.addToolOutput.mockReset().mockResolvedValue(undefined) + state.durableCapability = false state.sendMessage.mockClear() state.regenerate.mockClear() state.cancelSessionExecution.mockReset() state.resolveStopExecution.mockReset() state.stop.mockReset() + state.switchEntity.mockClear() + state.setCommitSignal.mockClear() + state.invalidateCommit.mockClear() + state.messages = [] state.resolveStopExecution.mockImplementation(async ({readExecutionId}) => { const executionId = readExecutionId() return executionId ? {status: "resolved", executionId} : {status: "settled"} @@ -213,6 +286,158 @@ describe("useAgentChatSession execution guard", () => { state.busy = false }) + it("answers a queued questionnaire through server ownership without SDK auto-resume", async () => { + state.durableCapability = true + let result: ReturnType | undefined + const root = createRoot(document.createElement("div")) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId: "session-1", + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + const output = {action: "accept", content: {goal: "Correctness"}} + await act(async () => { + await expect( + result!.handleClientToolOutput({ + toolName: "request_input", + toolCallId: "questionnaire", + output, + }), + ).resolves.toEqual({ + durable: true, + recoverable: false, + executionId: "questionnaire-child", + }) + }) + expect(state.respondAnswer).toHaveBeenCalledWith({ + sessionId: "session-1", + toolCallId: "questionnaire", + resolution: { + tool_call_id: "questionnaire", + tool_name: "request_input", + outcome: "completed", + output, + }, + }) + expect(state.addToolOutput).not.toHaveBeenCalled() + expect(state.capturedHooks!.sendAutomaticallyWhen({messages: []})).toBe(false) + act(() => root.unmount()) + }) + + it("deduplicates live-reader and native commit notifications through the same config switch", () => { + let result: ReturnType | undefined + const root = createRoot(document.createElement("div")) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId: "session-1", + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + const revision = {revisionId: "revision-2", version: "2"} + act(() => { + result!.onCommittedRevision(revision) + result!.onCommittedRevision(revision) + }) + state.messages = [ + { + id: "commit", + role: "assistant", + parts: [{type: "data-committed-revision", data: revision}], + } as UIMessage, + ] + act(() => root.render(createElement(Probe))) + expect(state.invalidateCommit).toHaveBeenCalledOnce() + expect(state.switchEntity).toHaveBeenCalledExactlyOnceWith({ + currentEntityId: "revision-1", + newEntityId: "revision-2", + }) + expect(state.setCommitSignal).toHaveBeenCalledExactlyOnceWith({ + revisionId: "revision-2", + version: "2", + prevParameters: null, + at: expect.any(Number), + }) + act(() => root.unmount()) + }) + + it("allows durable hydration for an accepted shared sender while protecting local streaming", async () => { + state.busy = true + let result: ReturnType | undefined + const root = createRoot(document.createElement("div")) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId: "session-1", + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + expect(state.hydrationBusyRef!.current).toBe(true) + + await act(() => state.capturedHooks!.prepareRequest({messages: [], id: "session-1"})) + act(() => + state.capturedHooks!.onData({ + type: "data-session-accepted", + data: {executionId: "accepted-turn"}, + }), + ) + expect(result!.acceptedRunPending).toBe(true) + expect(state.hydrationBusyRef!.current).toBe(false) + + state.busy = false + act(() => root.render(createElement(Probe))) + expect(result!.acceptedRunPending).toBe(true) + expect(state.hydrationBusyRef!.current).toBe(false) + act(() => root.unmount()) + }) + + it("settles a desktop accepted turn when its shared invoke stream finishes", () => { + const sessionId = "session-1" + let result: ReturnType | undefined + const container = document.createElement("div") + const root = createRoot(container) + const Probe = () => { + result = useAgentChatSession({ + entityId: "revision-1", + sessionId, + initialMessages: [], + intent: {} as never, + }) + return null + } + act(() => root.render(createElement(Probe))) + + act(() => + state.capturedHooks!.onData({ + type: "data-session-accepted", + data: {executionId: "turn-1"}, + }), + ) + expect(result!.acceptedRunPending).toBe(true) + + act(() => + state.capturedHooks!.onFinish({ + message: {id: "assistant-1", role: "assistant", parts: []}, + messages: [], + }), + ) + expect(result!.acceptedRunPending).toBe(false) + expect(state.acceptedRunBySession.has(sessionId)).toBe(false) + + act(() => root.unmount()) + }) + it("clears the previous turn before sends, regeneration, and SDK automatic requests", async () => { const sessionId = "session-1" let result: ReturnType | undefined diff --git a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts index 3eb2121e6a5..b50a2413dc7 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts @@ -4,8 +4,10 @@ import { buildRequestWithinDeadline, getMessageTraceId, latestTurnId, + prepareAfterContinuationPreflight, resolveStopExecution, startupLabelFromDataPart, + submitApprovalForCapability, } from "@agenta/chat/assets" import type {ClientToolOutputHandler} from "@agenta/chat/clientTools" import {useSessionChat} from "@agenta/chat/hooks" @@ -44,6 +46,10 @@ import { invalidateSessionListQueries, killSession, recordInteractionAnswerAtom, + respondInteractionAnswerAtom, + respondInteractionAnswersAtom, + resumeSessionContinuationAtom, + sessionDurableApprovalsCapabilityAtom, revalidateSessionMountsAtom, revalidateSessionRecordsAtom, } from "@agenta/entities/session" @@ -57,7 +63,6 @@ import { isHitlPending, isResumeSend, playgroundController, - recordAnswerThenRelease, type LiveAgentInteraction, } from "@agenta/playground" import {agentSelfCommitSignalAtom} from "@agenta/shared/state" @@ -147,6 +152,10 @@ export const useAgentChatSession = ({ const revalidateSessionRecords = useSetAtom(revalidateSessionRecordsAtom) const setSessionStatus = useSetAtom(setSessionStatusAtom) const recordInteractionAnswer = useSetAtom(recordInteractionAnswerAtom) + const respondInteractionAnswer = useSetAtom(respondInteractionAnswerAtom) + const respondInteractionAnswers = useSetAtom(respondInteractionAnswersAtom) + const resumeSessionContinuation = useSetAtom(resumeSessionContinuationAtom) + const supportsDurableApprovals = useSetAtom(sessionDurableApprovalsCapabilityAtom) const queryClient = useQueryClient() // Only a gate settled in this mount may trigger an automatic resume; hydrated answers stay inert. // `null` means "no live gate" — voided by a stop, or spent once a resume really went out; @@ -171,36 +180,58 @@ export const useAgentChatSession = ({ const [turnDeliverySource, setTurnDeliverySource] = useState( () => turnDeliverySourceBySession.get(sessionId) ?? null, ) + const settleSharedTurn = useCallback( + (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) + }, + [sessionId], + ) const sharedSenderReadyRef = useRef(false) const setSharedSenderReady = useCallback((ready: boolean) => { sharedSenderReadyRef.current = ready }, []) + const retryContinuation = useCallback( + () => resumeSessionContinuation(sessionId), + [resumeSessionContinuation, sessionId], + ) // 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 // instead of sticking to the revision this session first mounted on. 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, - }), + return prepareAfterContinuationPreflight( + resumeSessionContinuation, + id ?? sessionId, + async () => { + 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). + const req = await buildRequestWithinDeadline(() => + buildAgentRequest(entityId, messages, { + sessionId: id ?? sessionId, + sharedResponse, + }), + ) + captureTurnRequest(buildTurnCapture(req, generateId(), Date.now())) + return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} + }, ) - captureTurnRequest(buildTurnCapture(req, generateId(), Date.now())) - return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} }, // ── #6047 startup states: capture the runner's observed startup boundary as it streams ── onData: (part) => { @@ -238,7 +269,16 @@ export const useAgentChatSession = ({ // `is_running: true` outlived the answer by up to 15s (#5844). Safe to refetch immediately — // the runner awaits its `is_running: false` heartbeat BEFORE closing this stream // (services/runner/src/server.ts `aliveWatchdog.release()`), so the flag is already cleared. - onFinish: ({message, messages: finishedMessages, finishReason}) => { + onFinish: ({ + message, + messages: finishedMessages, + finishReason, + isAbort, + isDisconnect, + isError, + }) => { + // A clean shared invoke close is terminal; a disconnect still waits for the durable event. + if (!isAbort && !isDisconnect && !isError) settleSharedTurn() dispatchStopped({ type: "stream-terminal", messages: finishedMessages, @@ -328,6 +368,9 @@ export const useAgentChatSession = ({ messagesRef.current = messages const busyRef = useRef(busy || acceptedRunPending) busyRef.current = busy || acceptedRunPending + // Accepted shared turns receive their content through durable hydration. + const localRenderBusyRef = useRef(busy && !acceptedRunPending) + localRenderBusyRef.current = busy && !acceptedRunPending useEffect(() => { dispatchStopped({type: "transcript", messages}) @@ -349,11 +392,12 @@ export const useAgentChatSession = ({ stoppingTurnId, sharedReaderAdvertised, refreshFromRecords, + revalidate, } = useSessionHydration({ sessionId, initialMessages, messagesRef, - busyRef, + busyRef: localRenderBusyRef, seenIdsRef, restoredIdsRef, recordWatermarkRef, @@ -379,26 +423,69 @@ export const useAgentChatSession = ({ liveGateInteractionRef.current = interaction }, []) - /** - * Answer an approval: record the decision on the row the runner parked, THEN flip the part. - * - * Ordered, not raced. This hook dispatches no resume of its own — the park stream ends with a - * clean finish, so the SDK's `sendAutomaticallyWhen` sends it — but the flip is what lets the - * SDK dispatch, and that resume's stale sweep cancels rows still `pending`, this one included. - * Released early, the sweep reached the API first and cancelled the row being answered. - */ + /** Choose the durable dispatcher only when the server advertises it. */ const answerApproval = useCallback( - (approvalId: string, approved: boolean) => - recordAnswerThenRelease({ - record: () => + async (approvalId: string, approved: boolean) => { + return submitApprovalForCapability({ + durableApprovals: supportsDurableApprovals(sessionId), + submitDurable: () => + respondInteractionAnswer({ + sessionId, + toolCallId: approvalId, + approved, + }), + retireDurable: () => { + // A lost HTTP response may still follow a committed continuation. + liveGateInteractionRef.current = null + }, + recordLegacy: () => recordInteractionAnswer({ sessionId, toolCallId: approvalId, resolution: approvalResolution(approvalId, approved), }), - release: () => addToolApprovalResponse({id: approvalId, approved}), - }), - [addToolApprovalResponse, recordInteractionAnswer, sessionId], + releaseLegacy: () => addToolApprovalResponse({id: approvalId, approved}), + }) + }, + [ + addToolApprovalResponse, + recordInteractionAnswer, + respondInteractionAnswer, + sessionId, + supportsDurableApprovals, + ], + ) + + const answerApprovals = useCallback( + async (toolCallIds: string[], approved: boolean) => { + return submitApprovalForCapability({ + durableApprovals: supportsDurableApprovals(sessionId), + submitDurable: () => respondInteractionAnswers({sessionId, toolCallIds, approved}), + retireDurable: () => { + liveGateInteractionRef.current = null + }, + recordLegacy: () => + Promise.all( + toolCallIds.map((approvalId) => + recordInteractionAnswer({ + sessionId, + toolCallId: approvalId, + resolution: approvalResolution(approvalId, approved), + }), + ), + ).then(() => undefined), + releaseLegacy: () => { + for (const id of toolCallIds) addToolApprovalResponse({id, approved}) + }, + }) + }, + [ + addToolApprovalResponse, + recordInteractionAnswer, + respondInteractionAnswers, + sessionId, + supportsDurableApprovals, + ], ) // A resume really went out (the SDK's), so the gate it carried is spent. Retired HERE, where a @@ -410,30 +497,30 @@ export const useAgentChatSession = ({ if (isResumeSend({from, to: status})) liveGateInteractionRef.current = null }, [status]) - // Settle a parked client tool (#4920). The dispatcher calls this from a widget (e.g. the connect - // widget) with the structured reference; `addToolOutput` matches the part by `toolCallId` on the - // last turn and the resume predicate auto-resends. `tool` is only the typed-tools key — matching - // is by id — so a cast onto the untyped UIMessage tool map is safe. - const handleClientToolOutput = useCallback( - ({toolName, toolCallId, output, errorText}) => { - // Set synchronously: it holds off transcript adoption for the whole ordered window. + // Durable gates resume on the server; legacy gates still release the local SDK. + const handleClientToolOutput = useCallback( + async ({ + toolName, + toolCallId, + output, + errorText, + }: Parameters[0]) => { liveGateInteractionRef.current = {kind: "client_tool", id: toolCallId} - // Ordered, not raced — the resume starts a turn whose sweep cancels every `pending` - // row, so the answer has to be durable first. Capped inside the helper. - void recordAnswerThenRelease({ - record: () => - recordInteractionAnswer({ - sessionId, - toolCallId, - resolution: { - tool_call_id: toolCallId, - tool_name: toolName, - ...(errorText !== undefined - ? {outcome: "error", error: errorText} - : {outcome: "completed", output: output ?? {}}), - }, - }), - release: () => { + const resolution = { + tool_call_id: toolCallId, + tool_name: toolName, + ...(errorText !== undefined + ? {outcome: "error", error: errorText} + : {outcome: "completed", output: output ?? {}}), + } + const outcome = await submitApprovalForCapability({ + durableApprovals: supportsDurableApprovals(sessionId), + submitDurable: () => respondInteractionAnswer({sessionId, toolCallId, resolution}), + retireDurable: () => { + liveGateInteractionRef.current = null + }, + recordLegacy: () => recordInteractionAnswer({sessionId, toolCallId, resolution}), + releaseLegacy: () => { if (errorText !== undefined) { addToolOutput({ state: "output-error", @@ -450,8 +537,15 @@ export const useAgentChatSession = ({ } }, }) + return outcome }, - [addToolOutput, recordInteractionAnswer, sessionId], + [ + addToolOutput, + recordInteractionAnswer, + respondInteractionAnswer, + sessionId, + supportsDurableApprovals, + ], ) // Orphan detection for the queue's pre-resume hold: the tail is a RESTORED message (this @@ -571,33 +665,33 @@ export const useAgentChatSession = ({ // config. Deduped by revision id so a re-render (token stream) doesn't re-invalidate. const committedRevisionsSeenRef = useRef>(new Set()) const setAgentCommitSignal = useSetAtom(agentSelfCommitSignalAtom) + const onCommittedRevision = useCallback( + (data?: {revisionId?: string; version?: string}) => { + const key = data?.revisionId ?? JSON.stringify(data ?? {}) ?? "committed" + if (committedRevisionsSeenRef.current.has(key)) return + committedRevisionsSeenRef.current.add(key) + invalidateAgentCommittedRevisionCache() + if (data?.revisionId && data.revisionId !== entityId) { + const prevParameters = store.get(workflowMolecule.selectors.configuration(entityId)) + setAgentCommitSignal({ + revisionId: data.revisionId, + version: data.version, + prevParameters: prevParameters ?? null, + at: Date.now(), + }) + switchEntity({currentEntityId: entityId, newEntityId: data.revisionId}) + } + }, + [entityId, switchEntity, store, setAgentCommitSignal], + ) useEffect(() => { for (const message of messages) { for (const part of message.parts) { if ((part as {type?: string}).type !== "data-committed-revision") continue - const data = (part as {data?: {revisionId?: string; version?: string}}).data - // A stable key per commit: prefer the revision id, fall back to the whole payload. - const key = data?.revisionId ?? JSON.stringify(data ?? {}) ?? "committed" - if (committedRevisionsSeenRef.current.has(key)) continue - committedRevisionsSeenRef.current.add(key) - invalidateAgentCommittedRevisionCache() - if (data?.revisionId && data.revisionId !== entityId) { - // Capture the OUTGOING revision's parameters before switching, so the config - // panel can show what the agent changed (per-section indicators + summary). - const prevParameters = store.get( - workflowMolecule.selectors.configuration(entityId), - ) - setAgentCommitSignal({ - revisionId: data.revisionId, - version: data.version, - prevParameters: prevParameters ?? null, - at: Date.now(), - }) - switchEntity({currentEntityId: entityId, newEntityId: data.revisionId}) - } + onCommittedRevision((part as {data?: {revisionId?: string; version?: string}}).data) } } - }, [messages, entityId, switchEntity, store, setAgentCommitSignal]) + }, [messages, onCommittedRevision]) const projectId = useAtomValue(projectIdAtom) const expectedStopExecutionIdRef = useRef(undefined) @@ -812,15 +906,7 @@ export const useAgentChatSession = ({ 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) - }, + settleSharedTurn, sendMessage: sendMessageWithFreshGuard, regenerate: regenerateWithFreshGuard, setMessages, @@ -832,7 +918,9 @@ export const useAgentChatSession = ({ runningElsewhere, sharedReaderAdvertised, refreshFromRecords, + onCommittedRevision, setSharedSenderReady, + revalidate, stopped, stopping, setStopped, @@ -840,6 +928,8 @@ export const useAgentChatSession = ({ handleClientToolOutput, markLiveGate, answerApproval, + answerApprovals, + retryContinuation, resumeOrphaned, isSeen, } diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx index 9bd0fc77e4a..a5db4189b7f 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.livePreview.test.tsx @@ -215,6 +215,7 @@ describe("desktop durable reconnect", () => { busy: false, setMessages, persistMessages: vi.fn(), + clearRunError: vi.fn(), intent: { armJump: vi.fn(), stickRef: {current: false}, diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts index 87c0dd154e8..4efaf19dc1c 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.test.ts @@ -17,10 +17,19 @@ import {describe, expect, it} from "vitest" import { hasStrandedTail, + nextInteractionGatePollDelay, shouldProtectRenderedInteraction, shouldSkipRecordsRefresh, } from "./useSessionHydration" +describe("nextInteractionGatePollDelay", () => { + it("backs off and caps long-lived interaction gate polling", () => { + expect(nextInteractionGatePollDelay(1_000)).toBe(2_000) + expect(nextInteractionGatePollDelay(32_000)).toBe(60_000) + expect(nextInteractionGatePollDelay(60_000)).toBe(60_000) + }) +}) + describe("shouldSkipRecordsRefresh", () => { it("does not skip when idle and no settle is pending a resume", () => { expect(shouldSkipRecordsRefresh({busy: false, pendingResume: false})).toBe(false) @@ -108,4 +117,14 @@ describe("shouldProtectRenderedInteraction", () => { ]) as SessionInteractionRowStates expect(shouldProtectRenderedInteraction([approval], settledRows)).toBe(false) }) + + it("does not protect a stale desktop card from a terminal server transcript", () => { + const finished = { + id: "a1", + role: "assistant", + parts: [{type: "text", text: "finished"}], + } as unknown as UIMessage + + expect(shouldProtectRenderedInteraction([approval], pendingRows, [finished])).toBe(false) + }) }) diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts index 4d8de30efda..d4522c2ec3c 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts @@ -1,11 +1,19 @@ import {type MutableRefObject, useCallback, useEffect, useRef, useState} from "react" -import {isSessionTranscript, loadSessionMessages, type SessionTranscript} from "@agenta/chat/assets" +import { + isSessionTranscript, + loadSessionMessages, + reconcileInteractionRowStates, + type SessionTranscript, +} from "@agenta/chat/assets" import {withoutSharedSenderAcceptanceMessages} from "@agenta/chat/model" import {hasSessionChat, isSessionFresh} from "@agenta/chat/state" import { + fetchSessionInteractionStatesAtom, fetchSessionRecordsAtom, hasWaitingInteraction, + interactionStatesFromWatchEvent, + revalidateSessionInteractionsAtom, revalidateSessionRecordsAtom, type SessionInteractionRowStates, shouldAdoptServerTranscript, @@ -35,6 +43,11 @@ const REMOTE_RUN_POLL_MS = 15_000 * resets to the fast cadence, so a long turn that is simply quiet (a slow tool call emits no * records until it returns) is still followed. */ const REMOTE_RUN_POLL_MAX_MS = 60_000 +const INTERACTION_GATE_POLL_MS = 1_000 +const INTERACTION_GATE_POLL_MAX_MS = 60_000 + +export const nextInteractionGatePollDelay = (delay: number): number => + Math.min(delay * 2, INTERACTION_GATE_POLL_MAX_MS) /** Retry budget for the stranded-first-send record check when the fetch itself fails * (`records: null`). Bounded so a down endpoint gets a short burst, not a hammer; when the budget @@ -70,12 +83,18 @@ export const hasStrandedTail = (messages: UIMessage[]): boolean => * Protect local interaction state only when the pending server row already has an actionable card * on screen. A pending row by itself is not enough: the browser may have cached the transcript * before the interaction_request record arrived. Treating that stale copy as user-owned state - * prevents hydration from ever delivering the missing approval or form. + * prevents hydration from ever delivering the missing approval or form. The server transcript + * participates too: once its terminal records have retired the gate, that durable completion must + * replace an answered desktop card even if the separately cached row query still says pending. */ export const shouldProtectRenderedInteraction = ( messages: UIMessage[], interactionRows: SessionInteractionRowStates | undefined, -): boolean => hasWaitingInteraction(interactionRows) && isHitlPending(messages) + serverMessages: UIMessage[] = messages, +): boolean => + hasWaitingInteraction(interactionRows) && + isHitlPending(messages) && + isHitlPending(serverMessages) /** Same carrier shape `useAgentChatSession`'s error effect uses, so the stamp renders through the * existing red error bubble. */ @@ -186,6 +205,7 @@ export const useSessionHydration = ({ awaitingUser: shouldProtectRenderedInteraction( messagesRef.current, interactionRows, + serverMsgs, ), }) if (!adopt) return false @@ -466,6 +486,8 @@ export const useSessionHydration = ({ const activeSessionId = useAtomValue(activeSessionIdAtomFamily(scopeKey)) const projectId = useAtomValue(projectIdAtom) const revalidateSessionRecords = useSetAtom(revalidateSessionRecordsAtom) + const revalidateSessionInteractions = useSetAtom(revalidateSessionInteractionsAtom) + const fetchSessionInteractionStates = useSetAtom(fetchSessionInteractionStatesAtom) const refreshFromRecords = useCallback( async (transcript?: SessionTranscript): Promise => { const adoptOrConfirm = (candidate: unknown): boolean => { @@ -526,26 +548,112 @@ export const useSessionHydration = ({ 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). + const applyInteractionStates = useCallback( + (rows: SessionInteractionRowStates) => { + if ( + shouldSkipRecordsRefresh({ + busy: busyRef.current, + pendingResume: !!pendingResumeRef.current, + }) + ) + return + const current = messagesRef.current + const reconciled = reconcileInteractionRowStates(current, rows) + if (reconciled === current) return + messagesRef.current = reconciled + setMessages(reconciled) + persistMessages({ + id: sessionId, + messages: reconciled, + recordCount: recordWatermarkRef.current, + }) + }, + [ + sessionId, + busyRef, + pendingResumeRef, + messagesRef, + recordWatermarkRef, + setMessages, + persistMessages, + ], + ) + const refreshFromInteractions = useCallback(async () => { + if ( + shouldSkipRecordsRefresh({ + busy: busyRef.current, + pendingResume: !!pendingResumeRef.current, + }) + ) + return + try { + await revalidateSessionInteractions(sessionId) + const rows = await fetchSessionInteractionStates(sessionId) + applyInteractionStates(rows) + } catch { + // Best-effort fallback; the live relay or next interval can still converge. + } + }, [ + sessionId, + busyRef, + pendingResumeRef, + revalidateSessionInteractions, + fetchSessionInteractionStates, + applyInteractionStates, + ]) + const refreshFromInteractionEvent = useCallback( + (event: MessageEvent) => { + const pushed = interactionStatesFromWatchEvent(event.data, sessionId) + if (!pushed) { + void refreshFromInteractions() + return + } + applyInteractionStates(pushed) + void revalidateSessionInteractions(sessionId) + }, + [sessionId, applyInteractionStates, refreshFromInteractions, revalidateSessionInteractions], + ) + // `ready` fires on every connect — each tab activation, each return to the foreground. Records + // can skip a duplicate mount read, but rows must always catch up because a response changes the + // interaction row without necessarily appending a record (#6296). const refreshOnReady = useCallback(() => { if ( - !shouldRefreshOnReady({ + shouldRefreshOnReady({ inFlight: logReadsInFlightRef.current > 0, lastLoadedAt: logReadCompletedAtRef.current, now: Date.now(), }) ) - return - refreshFromRecords() - }, [refreshFromRecords]) + refreshFromRecords() + void refreshFromInteractions() + }, [refreshFromRecords, refreshFromInteractions]) + const interactionGateOpen = isHitlPending(messagesRef.current) + useEffect(() => { + if (activeSessionId !== sessionId || !interactionGateOpen) return + let cancelled = false + let timer: ReturnType | undefined + let delay = INTERACTION_GATE_POLL_MS + const poll = async () => { + await refreshFromInteractions() + if (!cancelled) { + delay = nextInteractionGatePollDelay(delay) + timer = setTimeout(poll, delay) + } + } + timer = setTimeout(poll, delay) + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [activeSessionId, sessionId, interactionGateOpen, refreshFromInteractions]) useSessionRecordsWatch({ sessionId, projectId, - // #5919 relay; this surface re-reads records on any interaction change. - onInteractionChanged: () => { + // #5919 relay; this surface re-reads records on any interaction change, and applies the + // pushed interaction row, because a response changes a row without appending a record. + onInteractionChanged: (event) => { revalidateSessionRecords(sessionId) + refreshFromInteractionEvent(event) }, enabled: activeSessionId === sessionId, onReady: refreshOnReady, @@ -564,5 +672,6 @@ export const useSessionHydration = ({ stoppingTurnId: liveness.stoppingTurnId, sharedReaderAdvertised: liveness.sharedReader, refreshFromRecords, + revalidate: refreshFromRecords, } } diff --git a/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts b/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts index bf1db11cbce..80af395ae69 100644 --- a/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts +++ b/web/oss/src/components/AgentChatSlice/hooks/useSessionRecordsWatch.ts @@ -1,6 +1,7 @@ import {useRef} from "react" import {shouldRefreshLegacyObserverLiveness} from "@agenta/chat/model" +import {invalidateSessionDurableApprovalsCapability} from "@agenta/entities/session" import {useWatchEventSource} from "@agenta/sessions/watch" import {useQueryClient} from "@tanstack/react-query" @@ -35,7 +36,7 @@ export const useSessionRecordsWatch = ({ * `onRecordsChanged` so it can skip a log the caller has just read (#6296). */ onReady: () => void onRecordsChanged: () => void - onInteractionChanged: () => void + onInteractionChanged: (event: MessageEvent) => void sharedReaderAdvertised: boolean }): void => { const queryClient = useQueryClient() @@ -62,7 +63,12 @@ export const useSessionRecordsWatch = ({ enabled, refreshSession, on: { - ready: onReady, + ready: () => { + if (projectId) { + invalidateSessionDurableApprovalsCapability({projectId, sessionId}) + } + onReady() + }, "records-changed": () => { onRecordsChanged() refreshLegacyObserverLiveness() diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts index a10fe89f260..709ab5136b0 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.test.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.test.ts @@ -8,7 +8,11 @@ */ import {describe, expect, it} from "vitest" -import {deriveSessionRemoteTurnPresentation, isRunningElsewhere} from "./liveness" +import { + deriveSessionRemoteTurnPresentation, + isRunningElsewhere, + shouldShowRunningElsewhere, +} 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 @@ -39,6 +43,25 @@ describe("isRunningElsewhere", () => { } }) + it("hides an owned continuation in the answering tab but shows it in an observer", () => { + const continuationPoll = {isRunning: true, livenessUpdatedAt: 16_000} as const + + expect( + isRunningElsewhere({ + ...continuationPoll, + localStatus: "running", + localSettledAt: undefined, + }), + ).toBe(false) + expect( + isRunningElsewhere({ + ...continuationPoll, + localStatus: "idle", + localSettledAt: undefined, + }), + ).toBe(true) + }) + it("distrusts stale liveness after a local error", () => { expect( isRunningElsewhere({ @@ -87,35 +110,35 @@ describe("isRunningElsewhere", () => { describe("deriveSessionRemoteTurnPresentation", () => { it.each([ { - name: "renders activity and no strip for a ready reader", + name: "renders activity without remote Stop for a ready reader", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: true}, - expected: {showActivity: true, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, { - name: "renders the strip while the reader is not ready", + name: "renders activity and remote Stop while the reader reconnects", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "renders the strip when the feature is off", + name: "renders activity and remote Stop when the reader is off", input: {livenessRunning: true, sharedReaderAdvertised: false, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "does not render the strip in the tab that owns a continuation", + name: "renders activity without remote Stop for an owned continuation", input: { livenessRunning: true, sharedReaderAdvertised: true, readerReady: false, ownedContinuation: true, }, - expected: {showActivity: false, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, ])("$name", ({input, expected}) => { expect(deriveSessionRemoteTurnPresentation(input)).toEqual(expected) }) - it("shows the flag-off observer banner only while session-stream liveness is running", () => { + it("offers legacy remote Stop only while session-stream liveness is running", () => { const input = { snapshotRunning: true, sharedReaderAdvertised: false, @@ -123,20 +146,52 @@ describe("deriveSessionRemoteTurnPresentation", () => { } expect( - deriveSessionRemoteTurnPresentation({...input, livenessRunning: true}).showStrip, + deriveSessionRemoteTurnPresentation({...input, livenessRunning: true}).showRemoteStop, ).toBe(true) expect( - deriveSessionRemoteTurnPresentation({...input, livenessRunning: false}).showStrip, + deriveSessionRemoteTurnPresentation({...input, livenessRunning: false}).showRemoteStop, ).toBe(false) }) - it("hides the banner when the advertised reader is ready", () => { + it("hides remote Stop when the advertised reader is ready", () => { expect( deriveSessionRemoteTurnPresentation({ livenessRunning: true, sharedReaderAdvertised: true, readerReady: true, - }).showStrip, + }).showRemoteStop, + ).toBe(false) + }) +}) + +describe("shouldShowRunningElsewhere", () => { + it("hides stale remote liveness while an idle execution shows its queued input", () => { + expect( + shouldShowRunningElsewhere({ + runningElsewhere: true, + executionState: "idle", + pendingInputCount: 1, + }), ).toBe(false) }) + + it("keeps the warning for a genuinely running execution with queued work", () => { + expect( + shouldShowRunningElsewhere({ + runningElsewhere: true, + executionState: "running", + pendingInputCount: 1, + }), + ).toBe(true) + }) + + it("keeps the warning for an idle snapshot without queued work", () => { + expect( + shouldShowRunningElsewhere({ + runningElsewhere: true, + executionState: "idle", + pendingInputCount: 0, + }), + ).toBe(true) + }) }) diff --git a/web/oss/src/components/AgentChatSlice/state/liveness.ts b/web/oss/src/components/AgentChatSlice/state/liveness.ts index fdfa0f5e2c8..c35a886568f 100644 --- a/web/oss/src/components/AgentChatSlice/state/liveness.ts +++ b/web/oss/src/components/AgentChatSlice/state/liveness.ts @@ -34,6 +34,9 @@ const aliveStreamsQueryAtom = atomWithQuery((get) => { } }) +export const sessionLivenessUpdatedAtAtom = atom((get) => get(aliveStreamsQueryAtom).dataUpdatedAt) +export const refreshSessionLivenessAtom = atom(null, (get) => get(aliveStreamsQueryAtom).refetch()) + /** `session_id → live stream` map for O(1) per-dot lookup off the single shared query. */ const aliveStreamsMapAtom = atom((get) => { const streams = get(aliveStreamsQueryAtom).data ?? [] @@ -125,9 +128,24 @@ export const isRunningElsewhere = ({ return localSettledAt === undefined || livenessUpdatedAt > localSettledAt } -/** Desktop presentation for a remote/shared-path run. The strip is only the disconnected fallback. */ +/** Desktop presentation for a remote/shared-path run. */ export const deriveSessionRemoteTurnPresentation = deriveRemoteTurnPresentation +/** + * The session snapshot is the execution authority for the open conversation. A stale stream-row + * liveness flag must not put a remote-run warning beside a durable queued item when that snapshot + * already says the execution is idle. + */ +export const shouldShowRunningElsewhere = ({ + runningElsewhere, + executionState, + pendingInputCount, +}: { + runningElsewhere: boolean + executionState: "idle" | "running" | "stopping" + pendingInputCount: number +}): boolean => runningElsewhere && !(executionState === "idle" && pendingInputCount > 0) + /** `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/oss/tests/playwright/acceptance/playground/assets/secretPropagation.ts b/web/oss/tests/playwright/acceptance/playground/assets/secretPropagation.ts new file mode 100644 index 00000000000..b387c42e5ea --- /dev/null +++ b/web/oss/tests/playwright/acceptance/playground/assets/secretPropagation.ts @@ -0,0 +1,12 @@ +export const isSecretPropagationFailure = (response: Record | null): boolean => { + // A newly recreated fixture connection may precede the service's cached inventory. + if ( + response?.status?.code === 400 && + response.status.type === "https://agenta.ai/docs/misc/errors#v0:schemas:unknown-connection" + ) { + return true + } + + const raw = JSON.stringify(response ?? {}).toLowerCase() + return raw.includes("invalid-secrets") || raw.includes("no api key found for model") +} diff --git a/web/oss/tests/playwright/acceptance/playground/tests.ts b/web/oss/tests/playwright/acceptance/playground/tests.ts index 83d963ddd3a..8bc436f2e60 100644 --- a/web/oss/tests/playwright/acceptance/playground/tests.ts +++ b/web/oss/tests/playwright/acceptance/playground/tests.ts @@ -2,16 +2,12 @@ import {test as baseTest} from "@agenta/web-tests/tests/fixtures/base.fixture" import {getKnownLatestRevisionId} from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers" import {expect, pollLocatorState} from "@agenta/web-tests/utils" +import {isSecretPropagationFailure} from "./assets/secretPropagation" import {RoleType, VariantFixtures} from "./assets/types" const SECRET_PROPAGATION_TIMEOUT_MS = 65_000 const SECRET_PROPAGATION_POLL_MS = 5_000 -const isSecretPropagationFailure = (response: Record | null): boolean => { - const raw = JSON.stringify(response ?? {}).toLowerCase() - return raw.includes("invalid-secrets") || raw.includes("no api key found for model") -} - const waitForSuccessfulRun = async ( triggerRun: () => Promise, waitForRunResponse: () => Promise | null>, diff --git a/web/oss/tests/playwright/unit/api-helpers.spec.ts b/web/oss/tests/playwright/unit/api-helpers.spec.ts index 769000a9322..6ff0925cebd 100644 --- a/web/oss/tests/playwright/unit/api-helpers.spec.ts +++ b/web/oss/tests/playwright/unit/api-helpers.spec.ts @@ -1,12 +1,13 @@ import { appMatchesType, + getApp, selectLatestAppRevisions, } from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers" import type { APP_TYPE, ListAppsItem, } from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers/types" -import {expect, test} from "@playwright/test" +import {expect, test, type Page} from "@playwright/test" const app = (flags: ListAppsItem["flags"]): ListAppsItem => ({ @@ -85,3 +86,50 @@ test("latest revision selection keeps records with a missing version", () => { expect(latest?.version).toBeUndefined() expect(latest?.flags?.is_agent).toBe(true) }) + +test("app lookup ignores an old document response during navigation", async () => { + const artifact = app({is_application: true}) + let navigationFinished = false + const requestedUrls: string[] = [] + const fakePage = { + url: () => "https://example.test/w/workspace/p/project/settings?tab=llms", + goto: async () => { + navigationFinished = true + }, + waitForURL: async () => {}, + // A settings-page query can finish while goto replaces its document. Its + // status is valid, but Chromium no longer owns the response body. + waitForResponse: async () => ({ + ok: () => true, + text: async () => { + throw new Error("Network.getResponseBody: No resource with given identifier found") + }, + }), + request: { + post: async (url: string) => { + expect(navigationFinished).toBe(true) + requestedUrls.push(url) + return { + ok: () => true, + json: async () => + url.includes("/revisions/query") + ? { + workflow_revisions: [ + { + workflow_id: artifact.id, + version: "1", + flags: {is_chat: true}, + }, + ], + } + : {workflows: [artifact], count: 1}, + } + }, + }, + } as unknown as Page + + expect(await getApp(fakePage, "chat")).toEqual(artifact) + expect(requestedUrls).toHaveLength(2) + expect(new URL(requestedUrls[0]).pathname).toMatch(/\/workflows\/query$/) + expect(new URL(requestedUrls[0]).searchParams.get("project_id")).toBe("project") +}) diff --git a/web/oss/tests/playwright/unit/secret-propagation.spec.ts b/web/oss/tests/playwright/unit/secret-propagation.spec.ts new file mode 100644 index 00000000000..6fd9ae90fb0 --- /dev/null +++ b/web/oss/tests/playwright/unit/secret-propagation.spec.ts @@ -0,0 +1,30 @@ +import {expect, test} from "@playwright/test" + +import {isSecretPropagationFailure} from "../acceptance/playground/assets/secretPropagation" + +test("retries a stale named connection inventory without masking ordinary run errors", () => { + expect( + isSecretPropagationFailure({ + status: { + code: 400, + type: "https://agenta.ai/docs/misc/errors#v0:schemas:unknown-connection", + message: "No provider connection named 'replacement'. Known connections: previous.", + }, + }), + ).toBe(true) + expect(isSecretPropagationFailure({status: {code: 500, message: "unknown-connection"}})).toBe( + false, + ) + expect( + isSecretPropagationFailure({status: {code: 400, type: "other:unknown-connection"}}), + ).toBe(false) + expect(isSecretPropagationFailure({status: {code: 429, message: "Rate limit exceeded"}})).toBe( + false, + ) + expect(isSecretPropagationFailure({status: {code: 200}, data: "unknown-connection"})).toBe( + false, + ) + expect(isSecretPropagationFailure(null)).toBe(false) + expect(isSecretPropagationFailure({status: {type: "#v0:schemas:invalid-secrets"}})).toBe(true) + expect(isSecretPropagationFailure({status: {message: "No API key found for model"}})).toBe(true) +}) diff --git a/web/package.json b/web/package.json index cc0a88ef4e7..7cc28361a22 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "agenta-web", - "version": "0.115.1", + "version": "0.115.2", "workspaces": [ "ee", "mobile", diff --git a/web/packages/agenta-api-client/package.json b/web/packages/agenta-api-client/package.json index ea2c50c5da0..d47226ea241 100644 --- a/web/packages/agenta-api-client/package.json +++ b/web/packages/agenta-api-client/package.json @@ -1,6 +1,6 @@ { "name": "@agentaai/api-client", - "version": "0.115.1", + "version": "0.115.2", "private": true, "type": "module", "main": "./dist/index.js", 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 6f796e6e95c..67a332dabff 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 @@ -25,6 +25,66 @@ export class SessionsClient { this._options = normalizeClientOptionsWithAuth(options); } + /** Redeliver a recoverable durable continuation before admitting a fresh session turn. */ + public resumeSessionContinuation( + request: AgentaApi.ResumeSessionContinuationRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__resumeSessionContinuation(request, requestOptions)); + } + + private async __resumeSessionContinuation( + request: AgentaApi.ResumeSessionContinuationRequest, + 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)}/continuations/resume`, + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: {}, + 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.SessionContinuationResumeResponse, + rawResponse: _response.rawResponse, + }; + } + if (_response.error.reason === "status-code") { + throw new errors.AgentaApiError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + return handleNonStatusCodeError( + _response.error, + _response.rawResponse, + "POST", + "/sessions/{session_id}/continuations/resume", + ); + } + /** * @param {AgentaApi.FetchSessionStreamRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. @@ -2393,6 +2453,68 @@ export class SessionsClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "DELETE", "/sessions/"); } + /** Remove a pending input before it is promoted. */ + public removePendingSessionInput( + request: AgentaApi.RemovePendingSessionInputRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__removePendingSessionInput(request, requestOptions)); + } + + private async __removePendingSessionInput( + request: AgentaApi.RemovePendingSessionInputRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId, input_id: inputId } = 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)}/inputs/${core.url.encodePathParam(inputId)}`, + ), + method: "DELETE", + 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.PendingInputResponse, 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, + "DELETE", + "/sessions/{session_id}/inputs/{input_id}", + ); + } + /** * @param {AgentaApi.ArchiveSessionRequest} request * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. @@ -2680,4 +2802,162 @@ export class SessionsClient { return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/sessions/{session_id}"); } + + /** + * @param {AgentaApi.PendingInputUpdateRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.sessions.updatePendingSessionInput({ + * session_id: "session_id", + * input_id: "input_id", + * text: "text" + * }) + */ + public updatePendingSessionInput( + request: AgentaApi.PendingInputUpdateRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__updatePendingSessionInput(request, requestOptions)); + } + + private async __updatePendingSessionInput( + request: AgentaApi.PendingInputUpdateRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId, input_id: inputId, ..._body } = 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)}/inputs/${core.url.encodePathParam(inputId)}`, + ), + method: "PATCH", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: _body, + 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.PendingInputResponse, 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, + "PATCH", + "/sessions/{session_id}/inputs/{input_id}", + ); + } + + + /** + * @param {AgentaApi.SendPendingSessionInputNowRequest} request + * @param {SessionsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link AgentaApi.UnprocessableEntityError} + * + * @example + * await client.sessions.sendPendingSessionInputNow({ + * session_id: "session_id", + * input_id: "input_id" + * }) + */ + public sendPendingSessionInputNow( + request: AgentaApi.SendPendingSessionInputNowRequest, + requestOptions?: SessionsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__sendPendingSessionInputNow(request, requestOptions)); + } + + private async __sendPendingSessionInputNow( + request: AgentaApi.SendPendingSessionInputNowRequest, + requestOptions?: SessionsClient.RequestOptions, + ): Promise> { + const { session_id: sessionId, input_id: inputId } = 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)}/inputs/${core.url.encodePathParam(inputId)}/send-now`, + ), + method: "POST", + 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.PendingInputAdmissionResponse, + 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, + "POST", + "/sessions/{session_id}/inputs/{input_id}/send-now", + ); + } } diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/PendingInputUpdateRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/PendingInputUpdateRequest.ts new file mode 100644 index 00000000000..f02ff509a98 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/PendingInputUpdateRequest.ts @@ -0,0 +1,18 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../../../../index.js"; + +/** + * @example + * { + * session_id: "session_id", + * input_id: "input_id", + * text: "text" + * } + */ +export interface PendingInputUpdateRequest { + session_id: string; + input_id: string; + text: string; + attachments?: AgentaApi.PendingInputAttachment[]; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/RemovePendingSessionInputRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/RemovePendingSessionInputRequest.ts new file mode 100644 index 00000000000..1f06a0de023 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/RemovePendingSessionInputRequest.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface RemovePendingSessionInputRequest { + session_id: string; + input_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/ResumeSessionContinuationRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/ResumeSessionContinuationRequest.ts new file mode 100644 index 00000000000..5fc03545fa4 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/ResumeSessionContinuationRequest.ts @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * session_id: "session_id" + * } + */ +export interface ResumeSessionContinuationRequest { + session_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SendPendingSessionInputNowRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SendPendingSessionInputNowRequest.ts new file mode 100644 index 00000000000..7a3ae27efca --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SendPendingSessionInputNowRequest.ts @@ -0,0 +1,13 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * session_id: "session_id", + * input_id: "input_id" + * } + */ +export interface SendPendingSessionInputNowRequest { + session_id: string; + input_id: string; +} diff --git a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionInteractionRespondRequest.ts b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionInteractionRespondRequest.ts index e3e03ea31aa..40b8abd573c 100644 --- a/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionInteractionRespondRequest.ts +++ b/web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionInteractionRespondRequest.ts @@ -9,4 +9,9 @@ export interface SessionInteractionRespondRequest { interaction_id: string; answer?: Record | null; + answers?: Array<{ + interaction_id: string; + answer: Record; + }> | null; + expected_execution_id?: string | null; } 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 c6f8ae0fe7c..b1a49966361 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 @@ -11,6 +11,8 @@ 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 { ResumeSessionContinuationRequest } from "./ResumeSessionContinuationRequest.js"; +export type { RemovePendingSessionInputRequest } from "./RemovePendingSessionInputRequest.js"; export type { GetRecordEventRequest } from "./GetRecordEventRequest.js"; export type { SessionAttachmentReferenceRequest } from "./SessionAttachmentReferenceRequest.js"; export type { SessionDetachRequest } from "./SessionDetachRequest.js"; @@ -34,3 +36,5 @@ export type { SignSessionMountCredentialsRequest } from "./SignSessionMountCrede export type { UnarchiveSessionRequest } from "./UnarchiveSessionRequest.js"; export type { WatchProjectRequest } from "./WatchProjectRequest.js"; export type { WatchSessionStreamRequest } from "./WatchSessionStreamRequest.js"; +export type { SendPendingSessionInputNowRequest } from "./SendPendingSessionInputNowRequest.js"; +export { type PendingInputUpdateRequest } from "./PendingInputUpdateRequest.js"; diff --git a/web/packages/agenta-api-client/src/generated/api/types/PendingInput.ts b/web/packages/agenta-api-client/src/generated/api/types/PendingInput.ts new file mode 100644 index 00000000000..8c5e429ed89 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/PendingInput.ts @@ -0,0 +1,30 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface PendingInput { + created_at?: (string | null) | undefined; + updated_at?: (string | null) | undefined; + deleted_at?: (string | null) | undefined; + created_by_id?: (string | null) | undefined; + updated_by_id?: (string | null) | undefined; + deleted_by_id?: (string | null) | undefined; + id?: (string | null) | undefined; + project_id: string; + session_id: string; + content: Record; + position: number; + state: AgentaApi.PendingInputState; + policy: PendingInput.Policy; + idempotency_key: string; + request_fingerprint: string; + promoted_execution_id?: (string | null) | undefined; +} + +export namespace PendingInput { + export const Policy = { + Queue: "queue", + Steer: "steer", + } as const; + export type Policy = (typeof Policy)[keyof typeof Policy]; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/PendingInputAdmissionResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/PendingInputAdmissionResponse.ts new file mode 100644 index 00000000000..346dde22fc5 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/PendingInputAdmissionResponse.ts @@ -0,0 +1,17 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface PendingInputAdmissionResponse { + action: PendingInputAdmissionResponse.Action; + input?: (AgentaApi.PendingInput | null) | undefined; + execution_id?: (string | null) | undefined; +} + +export namespace PendingInputAdmissionResponse { + export const Action = { + Execute: "execute", + Pending: "pending", + } as const; + export type Action = (typeof Action)[keyof typeof Action]; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/PendingInputAttachment.ts b/web/packages/agenta-api-client/src/generated/api/types/PendingInputAttachment.ts new file mode 100644 index 00000000000..de6a34b0ac6 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/PendingInputAttachment.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface PendingInputAttachment { + uri: string; + mime_type: string; + filename?: (string | null) | undefined; + attachment_id?: (string | null) | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/PendingInputResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/PendingInputResponse.ts new file mode 100644 index 00000000000..b86833ced6c --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/PendingInputResponse.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as AgentaApi from "../index.js"; + +export interface PendingInputResponse { + input: AgentaApi.PendingInput; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/PendingInputState.ts b/web/packages/agenta-api-client/src/generated/api/types/PendingInputState.ts new file mode 100644 index 00000000000..db7fa013c30 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/PendingInputState.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +export const PendingInputState = { + Pending: "pending", + Promoted: "promoted", + Removed: "removed", +} as const; +export type PendingInputState = (typeof PendingInputState)[keyof typeof PendingInputState]; diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionCapabilities.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionCapabilities.ts new file mode 100644 index 00000000000..39303b0960b --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionCapabilities.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionCapabilities { + durable_approvals?: boolean | undefined; + queue?: boolean | undefined; + steer?: boolean | undefined; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionContinuationResumeResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionContinuationResumeResponse.ts new file mode 100644 index 00000000000..fe53baa8a7d --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionContinuationResumeResponse.ts @@ -0,0 +1,5 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionContinuationResumeResponse { + resumed: boolean; +} diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionExecutionSnapshot.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionExecutionSnapshot.ts new file mode 100644 index 00000000000..fa81e46bed8 --- /dev/null +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionExecutionSnapshot.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionExecutionSnapshot { + id?: (string | null) | undefined; + state?: SessionExecutionSnapshot.State | undefined; +} + +export namespace SessionExecutionSnapshot { + export const State = { + Idle: "idle", + Running: "running", + Stopping: "stopping", + } as const; + export type State = (typeof State)[keyof typeof State]; +} 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 index 9a107715202..d82246d13dd 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotPending.ts @@ -3,6 +3,6 @@ import type * as AgentaApi from "../index.js"; export interface SessionSnapshotPending { - inputs?: unknown[] | undefined; + inputs?: AgentaApi.PendingInput[] | 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 index 3253bdc3987..7c61975ca80 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionSnapshotResponse.ts @@ -3,8 +3,10 @@ import type * as AgentaApi from "../index.js"; export interface SessionSnapshotResponse { - session: AgentaApi.SessionStream; + session?: (AgentaApi.SessionStream | null) | undefined; execution?: (AgentaApi.SessionTurn | null) | undefined; + execution_state?: AgentaApi.SessionExecutionSnapshot | undefined; pending: AgentaApi.SessionSnapshotPending; - read: AgentaApi.SessionRecordsReadState; + read?: (AgentaApi.SessionRecordsReadState | null) | undefined; + capabilities?: AgentaApi.SessionCapabilities | undefined; } diff --git a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts index c94ce7ba5a9..8ffc7f3e09d 100644 --- a/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts +++ b/web/packages/agenta-api-client/src/generated/api/types/SessionStreamResponse.ts @@ -4,4 +4,5 @@ import type * as AgentaApi from "../index.js"; export interface SessionStreamResponse { stream?: (AgentaApi.SessionStream | null) | undefined; + capabilities?: AgentaApi.SessionCapabilities | undefined; } 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 cad4a931cad..21811f862d9 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 @@ -316,6 +316,9 @@ export * from "./OrganizationDetails.js"; export * from "./OrganizationDomainResponse.js"; export * from "./OrganizationProviderResponse.js"; export * from "./OrganizationUpdate.js"; +export * from "./PendingInput.js"; +export * from "./PendingInputResponse.js"; +export * from "./PendingInputState.js"; export * from "./OTelEventInput.js"; export * from "./OTelEventOutput.js"; export * from "./OTelHashInput.js"; @@ -380,6 +383,7 @@ export * from "./SessionAttachmentResponse.js"; export * from "./SessionAttachmentsResponse.js"; export * from "./SessionCancelRequest.js"; export * from "./SessionDelivery.js"; +export * from "./SessionExecutionSnapshot.js"; export * from "./SessionExcludeRequest.js"; export * from "./SessionExpansion.js"; export * from "./SessionHeartbeatResult.js"; @@ -392,6 +396,8 @@ export * from "./SessionInteractionQuery.js"; export * from "./SessionInteractionQueryFlags.js"; export * from "./SessionInteractionRequest.js"; export * from "./SessionInteractionResponse.js"; +export * from "./SessionCapabilities.js"; +export * from "./SessionContinuationResumeResponse.js"; export * from "./SessionInteractionStatus.js"; export * from "./SessionInteractionsResponse.js"; export * from "./SessionListItem.js"; @@ -417,6 +423,7 @@ export * from "./SessionStreamQueryFlags.js"; export * from "./SessionStreamResponse.js"; export * from "./SessionStreamsResponse.js"; export * from "./SessionTranscriptWindowing.js"; +export * from "./SessionSnapshotResponse.js"; export * from "./SessionsResponse.js"; export * from "./SessionTrigger.js"; export * from "./SessionTriggerKind.js"; @@ -710,3 +717,5 @@ export * from "./Workspace.js"; export * from "./WorkspaceMemberResponse.js"; export * from "./WorkspacePermission.js"; export * from "./WorkspaceResponse.js"; +export * from "./PendingInputAdmissionResponse.js"; +export * from "./PendingInputAttachment.js"; diff --git a/web/packages/agenta-chat/src/assets/committedRevisions.ts b/web/packages/agenta-chat/src/assets/committedRevisions.ts new file mode 100644 index 00000000000..8ff4fc7edc6 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/committedRevisions.ts @@ -0,0 +1,65 @@ +import type {SessionRecord} from "@agenta/entities/session" +import {canonicalClientToolName} from "@agenta/shared/clientTools" + +export interface CommittedRevision { + variantId: string + revisionId: string + version: string +} + +const committedRevisionData = (output: unknown): CommittedRevision | null => { + let value = output + if (typeof value === "string") { + try { + value = JSON.parse(value) + } catch { + return null + } + } + if (!value || typeof value !== "object") return null + const payload = value as Record + if (payload.status !== "committed" && !payload.count) return null + if (!payload.workflow_revision || typeof payload.workflow_revision !== "object") return null + const revision = payload.workflow_revision as Record + const variantId = revision.workflow_variant_id ?? revision.variant_id + const revisionId = revision.id ?? revision.workflow_revision_id ?? revision.revision_id + const version = revision.version + if ( + typeof variantId !== "string" || + !variantId || + typeof revisionId !== "string" || + !revisionId || + (typeof version !== "string" && typeof version !== "number") || + !String(version) + ) + return null + return {variantId, revisionId, version: String(version)} +} + +/** Notifications learned during this mounted reader, never historical side effects. */ +export const liveCommittedRevisions = ( + records: SessionRecord[], + afterSequence?: number, +): CommittedRevision[] => { + if (afterSequence === undefined) return [] + const names = new Map() + const revisions = new Map() + for (const row of records) { + const payload = row.payload + if (!payload || typeof payload.id !== "string") continue + if (payload.type === "tool_call" && typeof payload.name === "string") + names.set(payload.id, canonicalClientToolName(payload.name)) + if ( + payload.type !== "tool_result" || + payload.isError || + payload.denied || + typeof row.sequence !== "number" || + row.sequence <= afterSequence || + names.get(payload.id) !== "commit_revision" + ) + continue + const revision = committedRevisionData(payload.data ?? payload.output) + if (revision) revisions.set(revision.revisionId, revision) + } + return [...revisions.values()] +} diff --git a/web/packages/agenta-chat/src/assets/composerRunState.ts b/web/packages/agenta-chat/src/assets/composerRunState.ts new file mode 100644 index 00000000000..8011e97ccc7 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/composerRunState.ts @@ -0,0 +1,11 @@ +export const isComposerRunStoppable = ({ + localStreaming, + serverBusy, + serverControlEnabled, + waitingOnUser, +}: { + localStreaming: boolean + serverBusy: boolean + serverControlEnabled: boolean + waitingOnUser: boolean +}): boolean => (localStreaming || (serverBusy && serverControlEnabled)) && !waitingOnUser diff --git a/web/packages/agenta-chat/src/assets/continuationPreflight.ts b/web/packages/agenta-chat/src/assets/continuationPreflight.ts new file mode 100644 index 00000000000..2b6c5101377 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/continuationPreflight.ts @@ -0,0 +1,38 @@ +export type ResumeSessionContinuation = (sessionId: string) => Promise + +/** + * Preserve one owner for the next session turn. If the API redelivered a saved approval, + * abort before constructing or sending a competing direct runner invocation. + */ +export async function assertNoResumedSessionContinuation( + resume: ResumeSessionContinuation, + sessionId: string, +): Promise { + let resumed = false + try { + resumed = await resume(sessionId) + } catch (error) { + console.warn("[continuationPreflight] unavailable; continuing Send", error) + return + } + if (!resumed) return + throw new Error( + JSON.stringify({ + status: { + code: "continuation_resumed", + message: + "A saved approval is resuming. Wait for it to finish, then try this message again.", + }, + }), + ) +} + +/** Run request construction only after the durable continuation has declined ownership. */ +export async function prepareAfterContinuationPreflight( + resume: ResumeSessionContinuation, + sessionId: string, + prepare: () => Promise, +): Promise { + await assertNoResumedSessionContinuation(resume, sessionId) + return prepare() +} diff --git a/web/packages/agenta-chat/src/assets/index.ts b/web/packages/agenta-chat/src/assets/index.ts index 472c119882d..cbfdca91cff 100644 --- a/web/packages/agenta-chat/src/assets/index.ts +++ b/web/packages/agenta-chat/src/assets/index.ts @@ -10,6 +10,11 @@ export * from "./loadSession" export * from "./conversationLayout" export * from "./jumpToLatest" export * from "./boundedRequest" +export * from "./serverOwnedApproval" +export * from "./continuationPreflight" +export * from "./composerRunState" export {startupLabelFromDataPart} from "./startupPhases" export {getMessageTurnId, latestTurnId} from "./agentTurn" export * from "./resolveStopExecution" + +export {liveCommittedRevisions, type CommittedRevision} from "./committedRevisions" diff --git a/web/packages/agenta-chat/src/assets/loadSession.ts b/web/packages/agenta-chat/src/assets/loadSession.ts index d445f05f4b7..ec318eded5b 100644 --- a/web/packages/agenta-chat/src/assets/loadSession.ts +++ b/web/packages/agenta-chat/src/assets/loadSession.ts @@ -6,6 +6,7 @@ import { fetchSessionInteractionStatesAtom, fetchSessionRecordsAtom, + revalidateSessionInteractionsAtom, type SessionInteractionRowStates, } from "@agenta/entities/session" import type {UIMessage} from "ai" @@ -82,6 +83,9 @@ export const loadSessionMessages = async ( // notice instead of leaking an unhandled rejection. try { const store = getDefaultStore() + await Promise.resolve(store.set(revalidateSessionInteractionsAtom, sessionId)).catch( + () => undefined, + ) // The best-effort lifecycle join must never gate transcript loading. const [{records, refreshed}, interactionRowStates] = await Promise.all([ store.set(fetchSessionRecordsAtom, sessionId), @@ -89,15 +93,24 @@ export const loadSessionMessages = async ( ]) if (refreshed && onRefreshed) { void refreshed - .then((fresh) => { + .then(async (fresh) => { if (!fresh || fresh.length === 0) return - const freshMsgs = transcriptToMessages(fresh, {interactionRowStates}) + await Promise.resolve( + store.set(revalidateSessionInteractionsAtom, sessionId), + ).catch(() => undefined) + const freshInteractionRowStates = await store.set( + fetchSessionInteractionStatesAtom, + sessionId, + ) + const freshMsgs = transcriptToMessages(fresh, { + interactionRowStates: freshInteractionRowStates, + }) if (freshMsgs && freshMsgs.length > 0) { onRefreshed({ messages: freshMsgs, recordCount: fresh.length, sequenceCursor: sequenceCursorForRecords(fresh), - interactionRows: interactionRowStates, + interactionRows: freshInteractionRowStates, }) } }) diff --git a/web/packages/agenta-chat/src/assets/pendingInputs.ts b/web/packages/agenta-chat/src/assets/pendingInputs.ts new file mode 100644 index 00000000000..f585c5f1f27 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/pendingInputs.ts @@ -0,0 +1,112 @@ +import type {PendingSessionInput, SessionSnapshot} from "@agenta/entities/session" +import type {FileUIPart} from "ai" + +import type {QueuedMessage} from "../hooks/useAgentChatQueue" + +import {attachmentContentUrl} from "./transcriptToMessages" + +export interface SessionPendingInputView { + capabilities: {queue: boolean; steer: boolean} + executionState: "idle" | "running" | "stopping" + queued: QueuedMessage[] +} + +const asRecord = (value: unknown): Record | null => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null + +const filePartFromBlock = ( + block: Record, + sessionId: string, +): FileUIPart | null => { + const metadata = asRecord(block.providerMetadata) + const agenta = asRecord(metadata?.agenta) + const attachmentId = block.attachmentId ?? block.attachment_id ?? agenta?.attachmentId + const reference = typeof attachmentId === "string" && attachmentId ? attachmentId : null + const url = reference ? attachmentContentUrl(sessionId, reference) : (block.uri ?? block.url) + if (typeof url !== "string" || !url) return null + const mediaType = block.mimeType ?? block.mime_type ?? block.mediaType + const size = block.size ?? agenta?.size + return { + type: "file", + url, + mediaType: typeof mediaType === "string" ? mediaType : "application/octet-stream", + filename: typeof block.filename === "string" ? block.filename : undefined, + ...(reference + ? { + providerMetadata: { + agenta: { + attachmentId: reference, + ...(typeof size === "number" ? {size} : {}), + }, + }, + } + : {}), + } +} + +export const pendingInputToQueuedMessage = (input: PendingSessionInput): QueuedMessage | null => { + const data = asRecord(input.content.data) + const inputs = asRecord(data?.inputs) + const messages = Array.isArray(inputs?.messages) ? inputs.messages : [] + const message = [...messages] + .reverse() + .map(asRecord) + .find((candidate) => candidate?.role === "user") + if (!message || !input.id) return null + + let text = "" + const fileParts: FileUIPart[] = [] + let attachmentCount = 0 + if (typeof message.content === "string") { + text = message.content + } else if (Array.isArray(message.content)) { + for (const raw of message.content) { + const block = asRecord(raw) + if (!block) continue + if (block.type === "text" && typeof block.text === "string") text += block.text + if (["attachment", "image", "resource"].includes(String(block.type))) { + attachmentCount += 1 + const part = filePartFromBlock(block, input.session_id) + if (part) fileParts.push(part) + } + } + } else if (Array.isArray(message.parts)) { + for (const raw of message.parts) { + const part = asRecord(raw) + if (!part) continue + if (part.type === "text" && typeof part.text === "string") text += part.text + if (part.type === "file") { + attachmentCount += 1 + const filePart = filePartFromBlock(part, input.session_id) + if (filePart) fileParts.push(filePart) + } + } + } + + return { + id: input.id, + text, + fileParts: fileParts.length ? fileParts : undefined, + attachmentCount, + policy: input.policy, + source: "server", + editable: input.state === "pending", + } +} + +export const reduceSessionPendingInputs = ( + snapshot: SessionSnapshot | null, +): SessionPendingInputView => ({ + capabilities: { + queue: snapshot?.capabilities.queue ?? false, + steer: snapshot?.capabilities.steer ?? false, + }, + executionState: snapshot?.execution_state.state ?? "idle", + queued: (snapshot?.pending.inputs ?? []) + .filter((input) => input.state === "pending" || input.state === "promoted") + .sort((left, right) => left.position - right.position) + .map(pendingInputToQueuedMessage) + .filter((input): input is QueuedMessage => input !== null), +}) diff --git a/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts b/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts new file mode 100644 index 00000000000..f0f5373bb79 --- /dev/null +++ b/web/packages/agenta-chat/src/assets/serverOwnedApproval.ts @@ -0,0 +1,56 @@ +import {recordAnswerThenRelease} from "@agenta/playground/agent-chat" + +/** + * Keep the server as the sole continuation owner even when its HTTP response is ambiguous. + * A rejected request may have committed before the connection failed, so the browser must retire + * its local auto-resume marker on both success and failure while still propagating the error. + */ +export async function submitServerOwnedApproval({ + submit, + retire, +}: { + submit: () => Promise + retire: () => void +}): Promise { + try { + return await submit() + } finally { + retire() + } +} + +export interface ApprovalSubmissionOutcome { + durable: boolean + recoverable: boolean + /** `execution.id` from the respond body — the continuation turn the server just started. + * The queue holds every send until this execution writes its own terminal record. */ + executionId?: string +} + +/** Choose the approval owner from the server capability, preserving the original local path. */ +export async function submitApprovalForCapability({ + durableApprovals, + submitDurable, + retireDurable, + recordLegacy, + releaseLegacy, +}: { + durableApprovals: boolean | Promise + submitDurable: () => Promise + retireDurable: () => void + recordLegacy: () => Promise + releaseLegacy: () => void +}): Promise { + let durable: boolean + try { + durable = await durableApprovals + } catch (error) { + retireDurable() + throw error + } + if (durable) { + return submitServerOwnedApproval({submit: submitDurable, retire: retireDurable}) + } + await recordAnswerThenRelease({record: recordLegacy, release: releaseLegacy}) + return {durable: false, recoverable: false} +} diff --git a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts index d834ac38669..556a1369097 100644 --- a/web/packages/agenta-chat/src/assets/transcriptToMessages.ts +++ b/web/packages/agenta-chat/src/assets/transcriptToMessages.ts @@ -66,6 +66,17 @@ interface DraftMessage { paused?: boolean /** The turn paused for approval and then RESUMED to completion (a second, non-paused `done`). */ resumed?: boolean + /** A non-paused durable `done` closed this turn. */ + recordTerminal?: boolean + /** The durable approval resumed under a separate execution; queue release follows this one. */ + approvalContinuation?: { + sourceExecutionId: string + executionId: string + state: "running" | "done" | "error" + approvalIds: string[] + } + /** Execution id of the paused approval turn, kept internal while replay associates its resume. */ + pausedExecutionId?: string /** The turn's persisted `error` event — replayed through the same `metadata.runError` channel * the live stream stamps, so a failure renders as the error bubble, not as body text. */ runError?: string @@ -123,6 +134,14 @@ const newDraft = (id: string, role: "user" | "assistant"): DraftMessage => ({ reasoning: new Map(), }) +const pendingApprovalIds = (draft: DraftMessage): string[] => + draft.parts.flatMap((part) => { + const approval = part.approval as {id?: unknown} | undefined + return part.state === "approval-requested" && typeof approval?.id === "string" + ? [approval.id] + : [] + }) + const toolPartType = (name?: string | null): string => (name ? `tool-${name}` : "dynamic-tool") /** Envelope keys an MCP-style `tool_call` record wraps its real arguments in. */ @@ -196,8 +215,8 @@ const isRunnerSentinelError = (part: Part): boolean => { ) } -function settleClientToolPart(part: Part, row: SessionInteractionRowState): void { - if (part.state !== "input-available") return +function settleClientToolPart(part: Part, row: SessionInteractionRowState): boolean { + if (part.state !== "input-available") return false if (row.resolution) { if (row.resolution.outcome === "error") { @@ -213,13 +232,15 @@ function settleClientToolPart(part: Part, row: SessionInteractionRowState): void ? output : {...CLIENT_TOOL_INTERACTION_ENDED_OUTPUT} } - return + return true } if (row.status === "cancelled" || row.status === "responded" || row.status === "resolved") { part.state = "output-available" part.output = {...CLIENT_TOOL_INTERACTION_ENDED_OUTPUT} + return true } + return false } /** @@ -230,27 +251,32 @@ function settleClientToolPart(part: Part, row: SessionInteractionRowState): void * a dead gate left `approval-requested` holds the message queue forever once the scans that read * it cover the whole transcript. */ -function settleApprovalPart(part: Part, row: SessionInteractionRowState): void { - if (part.state !== "approval-requested") return +function settleApprovalPart(part: Part, row: SessionInteractionRowState): boolean { + if (part.state !== "approval-requested") return false const verdict = row.resolution?.verdict if (verdict === "approved" || verdict === "denied") { part.state = "approval-responded" part.approval = {id: row.token, approved: verdict === "approved"} - return + return true } if (row.status === "cancelled") { part.state = "output-denied" - return + return true + } + if (row.status === "responded" || row.status === "resolved") { + part.state = "approval-responded" + return true } - if (row.status === "responded" || row.status === "resolved") part.state = "approval-responded" + return false } function applyInteractionRowStates( index: TranscriptIndex, interactionRowStates: SessionInteractionRowStates | undefined, -): void { - if (!interactionRowStates || interactionRowStates.size === 0) return +): boolean { + if (!interactionRowStates || interactionRowStates.size === 0) return false + let changed = false for (const row of interactionRowStates.values()) { // Token equality supports rows written before the runner stamped the tool-call id; an // approval gate is also indexed under its interaction id, which IS the row token. @@ -258,10 +284,38 @@ function applyInteractionRowStates( const part = index.tools.get(toolCallId) ?? index.approvals.get(row.token) if (!part) continue - if (row.kind === "user_approval") settleApprovalPart(part, row) + if (row.kind === "user_approval") changed = settleApprovalPart(part, row) || changed else if (row.kind === "client_tool" || row.kind === "user_input") - settleClientToolPart(part, row) + changed = settleClientToolPart(part, row) || changed + } + return changed +} + +/** Apply row lifecycle changes to an already-rendered transcript without waiting for a record. */ +export function reconcileInteractionRowStates( + messages: UIMessage[], + interactionRowStates: SessionInteractionRowStates | undefined, +): UIMessage[] { + if (!interactionRowStates || interactionRowStates.size === 0) return messages + + const cloned = messages.map((message) => ({ + ...message, + parts: message.parts.map((part) => ({...part})) as UIMessage["parts"], + })) + const index: TranscriptIndex = {tools: new Map(), approvals: new Map()} + for (const message of cloned) { + for (const rawPart of message.parts) { + const part = rawPart as Part + const toolCallId = part.toolCallId + if (typeof toolCallId === "string" && toolCallId) index.tools.set(toolCallId, part) + const approval = part.approval as {id?: unknown} | undefined + if (typeof approval?.id === "string" && approval.id) { + index.approvals.set(approval.id, part) + } + } } + + return applyInteractionRowStates(index, interactionRowStates) ? cloned : messages } /** @@ -553,6 +607,11 @@ export function transcriptToMessages( ): UIMessage[] | null { const drafts: DraftMessage[] = [] let current: DraftMessage | null = null + let latestPaused: DraftMessage | null = null + const draftsByExecution = new Map< + string, + {user?: DraftMessage; assistant?: DraftMessage; hasUser: boolean} + >() // Paused resumes close the draft, but later answers and results still target its tool part. const index: TranscriptIndex = {tools: new Map(), approvals: new Map()} @@ -560,6 +619,7 @@ export function transcriptToMessages( const payload = row.payload if (!payload || typeof payload !== "object") continue const p = payload as Record + const executionId = row.turn_id ?? undefined // Speculative trace link (no-op until the backend stamps one) — the id can ride the `done` // row too, so read it before the turn closes. const traceId = extractTraceId(row, p) @@ -567,21 +627,33 @@ export function transcriptToMessages( // this every turn folds into one assistant bubble; closing the draft here starts a // fresh message per turn. if (row.session_update === "done" || p.type === "done") { + const target: DraftMessage | null = + (executionId ? draftsByExecution.get(executionId)?.assistant : undefined) ?? current // Last-wins: a paused turn folds into its resume (below), and that turn has two `done`s // with two traceIds — prefer the RESUME trace, where the approved tool actually executed. // A normal turn has a single `done`, so this is unchanged for it. - if (current && traceId) current.traceId = traceId - if (current && p.stopReason === "paused") { + if (target && traceId) target.traceId = traceId + if (target && p.stopReason === "paused") { // Paused mid-approval: the resume turn's records (the re-emitted call, its result, // the follow-up text) belong to the SAME assistant turn the user saw live, so keep // the draft OPEN and let them fold into it instead of splitting into a dangling // "awaiting approval" bubble + a resumed bubble. A paused turn blocks the session, // so it's always followed by its own resume or is the last (abandoned) turn. Mark it // paused for the adoption heuristic; the normal `done` below clears it on resume. - current.paused = true + target.paused = true + target.pausedExecutionId = executionId + latestPaused = target continue } if (p.stopReason === "cancelled") { + // A cancelled continuation is still a TERMINAL record for that execution. Settle it + // here too, or the durable-continuation hold waits for a `done` that never comes. + if ( + target?.approvalContinuation && + target.approvalContinuation.executionId === executionId + ) { + target.approvalContinuation.state = "done" + } // Keep a carrier so a content-free cancellation can still render Stopped. if (!current || current.role !== "assistant") { current = newDraft(row.id, "assistant") @@ -596,28 +668,86 @@ export function transcriptToMessages( continue } // A resumed-then-completed turn is no longer paused. - if (current?.paused) current.resumed = true - if (current) current.paused = false - current = null + if ( + target?.approvalContinuation && + target.approvalContinuation.executionId === executionId + ) { + target.approvalContinuation.state = "done" + } + if (target?.paused) target.resumed = true + if (target) { + target.paused = false + target.recordTerminal = true + } + if (latestPaused === target) latestPaused = null + if (current === target) current = null continue } const role = roleOf(row.sender) - if (!current || current.role !== role) { + if (executionId) { + let execution = draftsByExecution.get(executionId) + if (!execution) { + execution = {hasUser: false} + draftsByExecution.set(executionId, execution) + } + if (role === "user") execution.hasUser = true + current = execution[role] ?? null + if (!current && role === "assistant" && latestPaused && !execution.hasUser) { + current = latestPaused + execution.assistant = current + if ( + latestPaused.pausedExecutionId && + latestPaused.pausedExecutionId !== executionId + ) { + latestPaused.approvalContinuation = { + sourceExecutionId: latestPaused.pausedExecutionId, + executionId, + state: "running", + approvalIds: pendingApprovalIds(latestPaused), + } + } + } + if (!current) { + current = newDraft(row.id, role) + execution[role] = current + drafts.push(current) + } + } else if (!current || current.role !== role) { current = newDraft(row.id, role) drafts.push(current) } if (traceId && !current.traceId) current.traceId = traceId applyEvent(current, p, index, row.session_id) + if ( + p.type === "error" && + current.approvalContinuation && + current.approvalContinuation.executionId === executionId + ) { + current.approvalContinuation.state = "error" + } } // Recorded results win; otherwise saved answers, neutral terminal state, then pending. applyInteractionRowStates(index, options?.interactionRowStates) // A resumed turn's remaining approval gate was answered even when its response row is absent. + // A continuation turn proves the same thing: the runner emits no records under its new + // execution before the durable answer owns it, so an observer retires the card on the first + // continuation frame instead of waiting for that optional event or `done`. for (const d of drafts) { - if (!d.resumed) continue + if (!d.resumed && !d.approvalContinuation) continue + const continuationApprovalIds = d.approvalContinuation + ? new Set(d.approvalContinuation.approvalIds) + : null for (const part of d.parts) { - if (part.state === "approval-requested") part.state = "approval-responded" + const approval = part.approval as {id?: unknown} | undefined + if ( + part.state === "approval-requested" && + (!continuationApprovalIds || + (typeof approval?.id === "string" && continuationApprovalIds.has(approval.id))) + ) { + part.state = "approval-responded" + } } } @@ -633,6 +763,8 @@ export function transcriptToMessages( if (d.usage) metadata.usage = d.usage if (d.paused) metadata.paused = true if (d.runStopped) metadata.runStopped = true + if (d.recordTerminal) metadata.recordTerminal = true + if (d.approvalContinuation) metadata.approvalContinuation = d.approvalContinuation if (d.runError && !d.runStopped) metadata.runError = { message: d.runError, diff --git a/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx b/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx index c24aed461c8..47c2874a373 100644 --- a/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx +++ b/web/packages/agenta-chat/src/clientTools/ClientToolPart.tsx @@ -18,13 +18,13 @@ import {canonicalToolName, resolveClientToolWidget, resolveToolDisplay} from ".. import {clientToolMeta} from "./meta" import UnhandledClientTool from "./UnhandledClientTool" -/** Settle a parked client tool. The panel maps this onto `addToolOutput` (success or error). */ +/** Settle a parked client tool; await durable submission when the host owns it. */ export type ClientToolOutputHandler = (args: { toolName: string toolCallId: string output?: Record errorText?: string -}) => void +}) => void | Promise const ClientToolPart = ({ part, @@ -53,13 +53,13 @@ const ClientToolPart = ({ const settle = useCallback( (args: {output: Record} | {errorText: string}) => { if ("errorText" in args) { - onOutput({ + return onOutput({ toolName: meta.toolName, toolCallId: meta.toolCallId, errorText: args.errorText, }) } else { - onOutput({ + return onOutput({ toolName: meta.toolName, toolCallId: meta.toolCallId, output: args.output, diff --git a/web/packages/agenta-chat/src/components/ApprovalCard.tsx b/web/packages/agenta-chat/src/components/ApprovalCard.tsx index 0b68010ee67..0447c4bf02d 100644 --- a/web/packages/agenta-chat/src/components/ApprovalCard.tsx +++ b/web/packages/agenta-chat/src/components/ApprovalCard.tsx @@ -26,6 +26,10 @@ export interface ApprovalCardProps { approvals: PendingApproval[] /** A fired decision is settling (disables the controls, drives the spinner). */ responding?: boolean + /** The durable response was accepted; the card stays put while records catch up. */ + answered?: boolean + /** The durable continuation could not be delivered and will retry on the next Send. */ + recoverable?: boolean /** The agent revision — enables the always-allow row (a draft-config grant). */ entityId?: string /** Show the Redirect (deny + note) entry point — hosts gate it by their own flag. */ @@ -47,6 +51,8 @@ export interface ApprovalCardProps { export const ApprovalCard = ({ approvals, responding = false, + answered = false, + recoverable = false, entityId, steerEnabled = false, touch = false, @@ -197,7 +203,13 @@ export const ApprovalCard = ({ {/* Eyebrow: a quiet cue that a decision is owed, not an error tint. */}
- Needs your approval + + {answered + ? recoverable + ? "Answer saved, retry needed" + : "Answered, waiting for the agent" + : "Needs your approval"} +
{/* The whole ask, in one sentence — what happens, and what it costs. */} @@ -274,7 +286,7 @@ export const ApprovalCard = ({ {/* Actions. The whole row collapses while steering: an explicit deny+redirect shouldn't leave Approve competing, so the redirect panel becomes the entire action surface. */} - + {/* Wraps rather than squeezes: with Redirect on, the buttons drop to their own line instead of shoving Approve off a narrow screen. */}
@@ -338,7 +350,7 @@ export const ApprovalCard = ({ {/* Steer: an inline redirect note. Unmounted (not merely collapsed) while the flag is off — a collapsed HeightCollapse still leaves its controls in the tab order. */} - {steerEnabled ? ( + {steerEnabled && !answered ? (
@@ -385,7 +397,18 @@ export const ApprovalCard = ({ ) : null} - {errorText ?

{errorText}

: null} + {answered ? ( +

+ {recoverable + ? "The answer is saved. Send your next message to retry the continuation." + : "The answer is saved. Waiting for the agent’s next update…"} +

+ ) : null} + {errorText ? ( +

+ {errorText} +

+ ) : null}
) } diff --git a/web/packages/agenta-chat/src/components/ChatComposer.tsx b/web/packages/agenta-chat/src/components/ChatComposer.tsx index 79059a47974..e8979dba08a 100644 --- a/web/packages/agenta-chat/src/components/ChatComposer.tsx +++ b/web/packages/agenta-chat/src/components/ChatComposer.tsx @@ -7,8 +7,9 @@ * attachment ENGINE (staging + uploads) arrives as the `useComposerAttachments` result so * hosts control the rollout flag and the viewer wiring. */ -import {Suspense, lazy, useRef, type ReactNode, type RefObject} from "react" +import {Suspense, lazy, useEffect, useRef, type ReactNode, type RefObject} from "react" +import {isOverlayOpen} from "@agenta/shared/utils" import {HeightCollapse} from "@agenta/ui/height-collapse" import type {RichChatInputHandle, SlashCommandSection} from "@agenta/ui/rich-chat-input" import {Button, SimpleTooltip} from "@agenta/ui/ui" @@ -55,6 +56,10 @@ export interface ChatComposerProps { /** The Stop request is pending or accepted, awaiting the stream's terminal event. */ stopping?: boolean onStop?: () => void + /** Only the active session owns the global Escape shortcut. */ + stopShortcutEnabled?: boolean + /** Capability-gated controls shown beside Stop while the session is busy. */ + busyActions?: {label: string; onSubmit: (text: string) => void}[] /** Read at event time — attachments are refused right now (a voice take in flight…). */ attachmentsBlocked?: () => boolean /** The composer itself is unusable (gates the paperclip alongside `uploadsEnabled`). */ @@ -88,6 +93,8 @@ export const ChatComposer = ({ streaming, stopping, onStop, + stopShortcutEnabled = true, + busyActions, attachmentsBlocked, composerDisabled, onViewAttachment, @@ -117,6 +124,18 @@ export const ChatComposer = ({ // iPhone was being shown the `⌘` variant specifically. const hasKeyboard = useHardwareKeyboard() + useEffect(() => { + if (!streaming || !onStop || !stopShortcutEnabled) return + const stopOnEscape = (event: KeyboardEvent) => { + if (event.defaultPrevented || isOverlayOpen()) return + if (event.key !== "Escape" || event.isComposing) return + event.preventDefault() + onStop() + } + document.addEventListener("keydown", stopOnEscape) + return () => document.removeEventListener("keydown", stopOnEscape) + }, [onStop, stopShortcutEnabled, streaming]) + return ( { if (!attachmentsBlocked?.()) addFiles(Array.from(pasted)) }} - sendForceEnabled={files.length > 0 && attachmentsSettled} + sendForceEnabled={files.length > 0} sendDisabled={files.length > 0 && !attachmentsSettled} sendDisabledReason={uploadBlockReason} streaming={streaming} stopping={stopping} onStop={onStop} + busyActions={busyActions} prefix={
{extraPrefix} diff --git a/web/packages/agenta-chat/src/components/ConnectionDock.tsx b/web/packages/agenta-chat/src/components/ConnectionDock.tsx index 7d675fc691d..f7b8ccba99c 100644 --- a/web/packages/agenta-chat/src/components/ConnectionDock.tsx +++ b/web/packages/agenta-chat/src/components/ConnectionDock.tsx @@ -469,13 +469,13 @@ const ConnectBody = ({ const settle = useCallback( (args) => { if ("errorText" in args) { - onOutput({ + return onOutput({ toolName: meta.toolName, toolCallId: meta.toolCallId, errorText: args.errorText, }) } else { - onOutput({ + return onOutput({ toolName: meta.toolName, toolCallId: meta.toolCallId, output: args.output, @@ -537,7 +537,7 @@ const ConnectBody = ({ Connecting {name}… finish signing in from the popup window.
- ) : phase === "error" ? ( + ) : phase === "error" || errorText ? ( {errorText ?? "Connection failed."} @@ -560,6 +560,7 @@ const ConnectBody = ({ variant="ghost" className={`text-colorTextSecondary ${touchCls}`} onClick={decline} + disabled={Boolean(errorText)} > Not now @@ -573,7 +574,7 @@ const ConnectBody = ({ } onClick={() => runConnect(true)} > - {phase === "error" ? "Retry" : "Connect"} + {phase === "error" || errorText ? "Retry" : "Connect"} )} diff --git a/web/packages/agenta-chat/src/components/ElicitationDock.tsx b/web/packages/agenta-chat/src/components/ElicitationDock.tsx index bbab04c75e8..8b322a31150 100644 --- a/web/packages/agenta-chat/src/components/ElicitationDock.tsx +++ b/web/packages/agenta-chat/src/components/ElicitationDock.tsx @@ -16,7 +16,7 @@ * Escape here does NOT settle, unlike `ApprovalCard` and `ConnectionDock`. This card owns a text * field, and Escape-to-back-out-of-typing is the stronger expectation; dismissing is the header ✕. */ -import {useCallback, useEffect, useMemo, useRef} from "react" +import {useCallback, useEffect, useMemo, useRef, useState} from "react" import { buildAcceptResult, @@ -119,11 +119,27 @@ const ElicitationCard = ({ // One settle per card. `meta.settled` only flips after the host's durable write resolves, so the // buttons stay live in between without this latch. const settledRef = useRef(false) + const [submissionError, setSubmissionError] = useState(null) const settle = useCallback( (output: Record) => { if (settledRef.current) return settledRef.current = true - onOutput({toolName: meta.toolName, toolCallId: meta.toolCallId, output}) + setSubmissionError(null) + const failed = (error: unknown) => { + settledRef.current = false + setSubmissionError( + error instanceof Error + ? error.message + : "Could not submit your answer. Try again.", + ) + } + try { + void Promise.resolve( + onOutput({toolName: meta.toolName, toolCallId: meta.toolCallId, output}), + ).catch(failed) + } catch (error) { + failed(error) + } }, [onOutput, meta.toolName, meta.toolCallId], ) @@ -146,6 +162,7 @@ const ElicitationCard = ({ active={active} shortcutsEnabled={shortcutsEnabled} settle={settle} + submissionError={submissionError} /> ) } @@ -206,6 +223,7 @@ const LiveCard = ({ active, shortcutsEnabled, settle, + submissionError, }: { payload: ElicitationRequestPayload meta: ClientToolMeta @@ -214,6 +232,7 @@ const LiveCard = ({ active: boolean shortcutsEnabled: boolean settle: (output: Record) => void + submissionError?: string | null }) => { const cardRef = useRef(null) const form = useMemo(() => buildElicitationSteps(payload), [payload]) @@ -506,10 +525,12 @@ const LiveCard = ({ - {stepper.error ?? stepper.hold ?? ""} + {submissionError ?? stepper.error ?? stepper.hold ?? ""}
diff --git a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx index 370553cbef5..7f85477bda0 100644 --- a/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx +++ b/web/packages/agenta-chat/src/components/QueuedMessagesDock.tsx @@ -94,6 +94,7 @@ const Row = ({ onEdit, onCancelEdit, onRemove, + onSendNow, }: { message: QueuedMessage editing: boolean @@ -101,12 +102,16 @@ const Row = ({ onEdit?: (message: QueuedMessage) => void onCancelEdit?: () => void onRemove: (id: string) => void + onSendNow?: (id: string) => Promise }) => { + const [sending, setSending] = useState(false) + const [error, setError] = useState(null) const text = message.text.trim() const files = message.fileParts ?? [] + const attachmentCount = Math.max(files.length, message.attachmentCount ?? 0) return (
@@ -117,18 +122,38 @@ const Row = ({ ) : ( - {files.length ? "(attachments only)" : "(empty message)"} + {attachmentCount ? "(attachments only)" : "(empty message)"} )} - {/* Revealed on hover, but always present for keyboard and while this row is under - edit — an action you can only reach with a pointer is not an action on mobile. */} + {/* Keep Send Now visible without hover. */} + {onSendNow && message.source === "server" ? ( + + ) : null} {editing ? ( - ) : onEdit ? ( + ) : onEdit && message.editable !== false ? ( + {error ? ( + + {error} + + ) : null}
) } @@ -169,6 +199,7 @@ export interface QueuedMessagesDockProps { /** The run is parked on the user (HITL), so the queue is held rather than merely waiting. */ held?: boolean onRemove: (id: string) => void + onSendNow?: (id: string) => Promise /** Hand a row's content to the host's composer. Omit on surfaces without an editable input. */ onEdit?: (message: QueuedMessage) => void /** Abandon the edit; the host puts the stashed draft back. */ @@ -184,6 +215,7 @@ const QueuedMessagesDock = ({ queued, held = false, onRemove, + onSendNow, onEdit, onCancelEdit, editingId = null, @@ -209,6 +241,8 @@ const QueuedMessagesDock = ({ ? "relative after:absolute after:-inset-x-1 after:-inset-y-2 after:content-['']" : "" + const editingMissingRow = !!editingId && !queued.some((message) => message.id === editingId) + return (
{/* px-3 so the icon starts on the same 13px line as the row text below it and the @@ -217,7 +251,7 @@ const QueuedMessagesDock = ({ {queued.length} queued message{queued.length === 1 ? "" : "s"} - {held ? " · waiting on you" : ""} + {held ? " · waits for your answer" : ""} {/* Not `CollapseToggleButton`: it carries a tooltip, and a caret in a two-item @@ -237,6 +271,14 @@ const QueuedMessagesDock = ({ />
+ {editingMissingRow ? ( +
+ This message is no longer queued. + +
+ ) : null} {/* The composer sits directly below, so a hard mount/unmount teleports it by the body's full height. `HeightCollapse` is the app's one collapse primitive — the same motion as the accordion sections and the sibling docks — and it owns aria-hidden @@ -252,6 +294,7 @@ const QueuedMessagesDock = ({ onEdit={onEdit} onCancelEdit={onCancelEdit} onRemove={onRemove} + onSendNow={onSendNow} /> ))} diff --git a/web/packages/agenta-chat/src/components/RunningElsewhereStrip.tsx b/web/packages/agenta-chat/src/components/RunningElsewhereStrip.tsx deleted file mode 100644 index 9d692eb2b6f..00000000000 --- a/web/packages/agenta-chat/src/components/RunningElsewhereStrip.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type {ReactNode} from "react" - -import {cn} from "@agenta/ui/ui" - -/** - * "This session is running somewhere else" — shown when the backend reports a live run for the - * 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. 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 - * one that mounts and unmounts around every turn shifts the layout twice per run. - * - * The copy stops short of promising the transcript WILL move. `is_running` says a turn took the - * lock, not that anything is still serving it: a runner that dies mid-turn leaves the flag set - * until its shutdown drain completes, or failing that until the execution watchdog settles it - * (`ORPHAN_THRESHOLD_SECONDS`, 120s by default). Measured on a dev stack, that window runs from - * ~20s to a couple of minutes. Asserting progress through it told people to keep waiting on a run - * that was over, so the second sentence names that possibility instead. It is deliberately not a call to action: - * only /m passes a Stop here, and the desktop has no control to point at. - * - * Matches the `running` dot in the session bar (`bg-colorInfo`, pulsing) so the two read as one - * signal. - */ -export const RunningElsewhereStrip = ({ - className, - action, -}: { - className?: string - /** Optional trailing control — /m offers stopping a run this device is not driving. */ - action?: ReactNode -}) => ( -
- - - - - - 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/index.ts b/web/packages/agenta-chat/src/components/index.ts index 484450632a8..f5911610b3a 100644 --- a/web/packages/agenta-chat/src/components/index.ts +++ b/web/packages/agenta-chat/src/components/index.ts @@ -26,7 +26,6 @@ export {default as RecordingWaveform} from "./RecordingWaveform" 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" diff --git a/web/packages/agenta-chat/src/hooks/index.ts b/web/packages/agenta-chat/src/hooks/index.ts index 936322bdd70..fa02022c270 100644 --- a/web/packages/agenta-chat/src/hooks/index.ts +++ b/web/packages/agenta-chat/src/hooks/index.ts @@ -5,6 +5,7 @@ export * from "./useApprovalDock" export * from "./useConnectionDock" export * from "./useElicitationDock" export * from "./useAgentConversation" +export * from "./useServerSessionInputs" // Kept on this lane: the release consolidated this hook into its app copy, which this // lane does not have — oss renders the package copy. See F-18 / WP-0 rule 1. export * from "./useAgentModelKeyStatus" diff --git a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts index 0321c86d72b..733a5dc6885 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentChatQueue.ts @@ -1,7 +1,12 @@ // Canonical since the desktop re-plumb: the OSS copy is deleted and both apps import this. import {useCallback, useEffect, useRef, useState} from "react" -import {canReleaseQueuedMessage, isHitlPending} from "@agenta/playground/agent-chat" +import { + approvalContinuationSettled, + canReleaseQueuedMessage, + hasRunningApprovalContinuation, + isHitlPending, +} from "@agenta/playground/agent-chat" import {generateId} from "@agenta/shared/utils" import type {FileUIPart, UIMessage} from "ai" @@ -14,6 +19,21 @@ export interface QueuedMessage { text: string fileParts?: FileUIPart[] stagedFiles?: ComposerAttachment[] + attachmentCount?: number + policy?: "queue" | "steer" + source?: "local" | "server" + editable?: boolean +} + +export interface ServerQueueAdapter { + capabilities: {queue: boolean; steer: boolean} + resolveCapabilities?: () => Promise<{queue: boolean; steer: boolean}> + busy: boolean + queued: QueuedMessage[] + submit: (message: QueuedMessage, policy: "queue" | "steer") => Promise + remove: (id: string) => Promise + sendNow?: (id: string) => Promise + edit?: (id: string, item: {text: string; fileParts?: FileUIPart[]}) => Promise } interface UseAgentChatQueueArgs { @@ -31,17 +51,49 @@ interface UseAgentChatQueueArgs { * mount can fire the auto-resume. Holding for it would freeze the queue forever with no * dock and no stop (AGE-3937), so it voids the hold exactly like a user stop. */ resumeOrphaned?: boolean + /** The approval answer is durable but its continuation was not delivered. A composer Send + * keeps the message held and uses the click to retry that continuation first. */ + recoverable?: boolean + retryContinuation?: () => Promise + /** + * Execution id of the durable approval continuation this mount just started, read from the + * respond body (`execution.id`). Non-null means the server owns the next turn: nothing may + * release until that execution's own terminal record lands in the transcript. + * + * It exists because the transcript-derived hold cannot cover the whole window. The + * `approvalContinuation` metadata only appears once the continuation's FIRST record is + * persisted — measured at 8 seconds after the answer on a local sandbox — and a transcript + * adopted inside that gap shows a paused turn with an answered gate, which every release path + * reads as settled. + */ + continuationExecutionId?: string | null + /** Mark this tab as the next run's owner before a released send reaches the transport. */ + markRunOwned: () => void /** Send one released message into the conversation (wraps `useChat`'s `sendMessage`). Must be * referentially stable so the release effect doesn't churn on every streamed token. */ sendQueued: (item: QueuedMessage) => void /** Persist held messages under this key across pane remounts (route re-entry, tab * close/reopen) — a restored queue releases normally once the conversation settles. */ sessionId?: string + /** Durable server queue. Omit (or advertise queue=false) for the browser-local fallback. */ + server?: ServerQueueAdapter } // In-memory, page-session lifetime — same as the composer drafts it accompanies. const queuedBySession = new Map() +/** + * Ceiling on the id-keyed continuation hold. + * + * A continuation that is never delivered writes no records at all (observed twice in nine + * approvals), so its terminal record never arrives and an unbounded hold would freeze the queue + * with no dock to unblock it — the AGE-3937 trap this file already carries scars from. After the + * ceiling the hold falls back to the transcript-derived one, which is self-clearing: a + * continuation that produced records always produces a terminal record too. Well past the + * 8-to-11 seconds a local sandbox needs to write the continuation's first record. + */ +export const CONTINUATION_HOLD_MAX_MS = 45_000 + /** * Holds user messages typed while a turn is in flight and releases them ONE AT A TIME once the * stream truly settles. It never releases mid human-in-the-loop (a tool-approval gate) — that @@ -59,9 +111,16 @@ export const useAgentChatQueue = ({ acceptedRunPending = false, stopped, resumeOrphaned = false, + recoverable = false, + retryContinuation, + continuationExecutionId = null, + markRunOwned, sendQueued, sessionId, + server, }: UseAgentChatQueueArgs) => { + const serverBusyRef = useRef(server?.busy) + serverBusyRef.current = server?.busy const [queued, setQueued] = useState( () => (sessionId && queuedBySession.get(sessionId)) || [], ) @@ -75,12 +134,58 @@ export const useAgentChatQueue = ({ // Settled = the stream is over (done or failed). A stop lands here (abort → "ready"). const settled = status === "ready" || status === "error" + + // ── The durable-continuation hold ───────────────────────────────────────────────────────── + // A server-owned continuation is a TURN. Sending into it starts a second turn for the same + // session, and the runner resolves that collision by superseding: it tears down the warm + // sandbox mid-call, so the tool the user just approved comes back "Command aborted" and the + // sent message dies with it. Nothing below may release while one is in flight. + const [, forceHoldRecheck] = useState(0) + const holdStartedAtRef = useRef<{id: string; at: number} | null>(null) + if (continuationExecutionId) { + if (holdStartedAtRef.current?.id !== continuationExecutionId) { + holdStartedAtRef.current = {id: continuationExecutionId, at: Date.now()} + } + } else { + holdStartedAtRef.current = null + } + const holdStartedAt = holdStartedAtRef.current + const idHoldExpired = + !!holdStartedAt && Date.now() - holdStartedAt.at >= CONTINUATION_HOLD_MAX_MS + const idHold = + !!continuationExecutionId && + !idHoldExpired && + !approvalContinuationSettled(messages, continuationExecutionId) + // The ceiling needs a render to take effect; nothing else re-renders a queue that is holding. + useEffect(() => { + if (!holdStartedAt || idHoldExpired) return + const remaining = holdStartedAt.at + CONTINUATION_HOLD_MAX_MS - Date.now() + const timer = setTimeout(() => forceHoldRecheck((n) => n + 1), Math.max(remaining, 0)) + return () => clearTimeout(timer) + }, [holdStartedAt, idHoldExpired]) + + // A user stop cancels the continuation too, so it outranks the hold exactly as it outranks + // every other gate here. + const continuationHold = !stopped && (idHold || hasRunningApprovalContinuation(messages)) + // Ownership is scoped by the respond body's execution id, so an observer rendering the same + // continuation records never claims it. Keep ownership past the gap ceiling once that exact + // execution is visibly running; the ceiling only protects a continuation that wrote nothing. + const ownsContinuation = + idHold || + (!!continuationExecutionId && + hasRunningApprovalContinuation(messages) && + !approvalContinuationSettled(messages, continuationExecutionId)) + // 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 = !acceptedRunPending && + !continuationHold && (canReleaseQueuedMessage(status, messages) || ((stopped || resumeOrphaned) && settled)) + const canReleaseNowRef = useRef(canReleaseNow) + canReleaseNowRef.current = canReleaseNow + // 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` // sends immediately. Keep `hitlPending` in lockstep with the release decision. @@ -88,6 +193,7 @@ export const useAgentChatQueue = ({ // One latch shared by both send paths caps releases to one per settle and preserves FIFO. const releasingRef = useRef(false) + const retryingContinuationRef = useRef(false) const queuedRef = useRef(queued) useEffect(() => { queuedRef.current = queued @@ -109,37 +215,167 @@ export const useAgentChatQueue = ({ return message }, []) + const [editingId, setEditingId] = useState(null) + const stashRef = useRef("") + const editSessionRef = useRef<{id: string; server: boolean} | null>(null) + + // A message held before an approval answer predates the server-owned continuation. Move it + // under the same durable admission before that continuation can promote a different input. + const migrationRef = useRef(null) + const migrationPromiseRef = useRef<{ + id: string + promise: Promise + retry: () => Promise + failed: boolean + } | null>(null) + const migrationRetryTimerRef = useRef | null>(null) + const [migrationRetry, setMigrationRetry] = useState(0) + useEffect( + () => () => { + if (migrationRetryTimerRef.current) clearTimeout(migrationRetryTimerRef.current) + }, + [], + ) + useEffect(() => { + const head = queued[0] + const submitToServer = server?.submit + if ( + !continuationExecutionId || + !continuationHold || + !server?.capabilities.queue || + !submitToServer || + !head || + editingId === head.id || + migrationRef.current + ) { + return + } + + migrationRef.current = head.id + const retry = () => + submitToServer(head, "queue").then(() => { + if (editSessionRef.current?.id === head.id) editSessionRef.current.server = true + if (sessionId) { + const stored = queuedBySession.get(sessionId) + if (stored) { + const remaining = stored.filter((item) => item.id !== head.id) + if (remaining.length > 0) queuedBySession.set(sessionId, remaining) + else queuedBySession.delete(sessionId) + } + } + setQueued((items) => items.filter((item) => item.id !== head.id)) + }) + const migration = {id: head.id, promise: retry(), retry, failed: false} + migrationPromiseRef.current = migration + void migration.promise + .catch(() => { + migration.failed = true + if (migrationRef.current !== head.id) return + migrationRef.current = null + migrationRetryTimerRef.current = setTimeout(() => { + migrationRetryTimerRef.current = null + migrationRef.current = null + setMigrationRetry((attempt) => attempt + 1) + }, 2_000) + }) + .finally(() => { + if (migrationRef.current === head.id) migrationRef.current = null + if (migrationPromiseRef.current === migration && !migration.failed) + migrationPromiseRef.current = null + }) + }, [ + continuationExecutionId, + continuationHold, + editingId, + migrationRetry, + queued, + sessionId, + server?.capabilities.queue, + server?.submit, + ]) + // Send now only if idle, unlatched, and the queue is empty; otherwise append (FIFO). const submit = useCallback( (item: {text: string; fileParts?: FileUIPart[]; stagedFiles?: ComposerAttachment[]}) => { const message: QueuedMessage = {...item, id: generateId()} - if (!releasingRef.current && queuedRef.current.length === 0 && canReleaseNow) { - releasingRef.current = true - lastSentRef.current = message - sendQueued(message) - } else { - setQueued((q) => [...q, message]) + const admit = (queue: boolean) => { + if (queue && server) { + return server.submit(message, "queue") + } + if (recoverable && retryContinuation) { + setQueued((q) => [...q, message]) + if (!retryingContinuationRef.current) { + retryingContinuationRef.current = true + void retryContinuation() + .catch(() => false) + .finally(() => { + retryingContinuationRef.current = false + }) + } + return + } + if ( + !releasingRef.current && + queuedRef.current.length === 0 && + canReleaseNowRef.current + ) { + releasingRef.current = true + lastSentRef.current = message + markRunOwned() + sendQueued(message) + } else { + setQueued((q) => [...q, message]) + } } + return server?.resolveCapabilities + ? server.resolveCapabilities().then((capabilities) => admit(capabilities.queue)) + : admit(server?.capabilities.queue === true) }, - [canReleaseNow, sendQueued], + [canReleaseNow, recoverable, retryContinuation, markRunOwned, sendQueued, server], ) - const removeQueued = useCallback((id: string) => { - setQueued((q) => q.filter((m) => m.id !== id)) - }, []) + const removeQueued = useCallback( + (id: string) => { + if (server?.queued.some((message) => message.id === id)) { + void server.remove(id).catch(() => {}) + return + } + setQueued((q) => q.filter((m) => m.id !== id)) + }, + [server], + ) + + const steer = useCallback( + async (item: {text: string; fileParts?: FileUIPart[]}) => { + const capabilities = server?.resolveCapabilities + ? await server.resolveCapabilities() + : server?.capabilities + if (!capabilities?.steer || !serverBusyRef.current || !server) { + throw new Error("The session is not ready to accept a Steer input.") + } + const message: QueuedMessage = {...item, id: generateId()} + await server.submit(message, "steer") + }, + [server], + ) // ── Editing a held message ──────────────────────────────────────────────────────────────── // An edit session BORROWS the composer: the target's text goes in, and whatever the user had // already typed is stashed and handed back when the session ends (either way). Without that, // clicking edit on a half-written message would silently destroy it. - const [editingId, setEditingId] = useState(null) - const stashRef = useRef("") /** Open a session on `id`, stashing the composer's current draft. */ - const beginEdit = useCallback((id: string, draft = "") => { - stashRef.current = draft - setEditingId(id) - }, []) + const beginEdit = useCallback( + (id: string, draft = "") => { + editSessionRef.current = { + id, + server: !!server?.queued.some((message) => message.id === id), + } + stashRef.current = draft + setEditingId(id) + }, + [server], + ) /** Take the stashed draft back, once. Both ends of a session hand the composer back. */ const takeStash = useCallback(() => { @@ -150,6 +386,7 @@ export const useAgentChatQueue = ({ /** Close the session without touching the message. Returns the draft to restore. */ const cancelEdit = useCallback(() => { + editSessionRef.current = null setEditingId(null) return takeStash() }, [takeStash]) @@ -162,8 +399,7 @@ export const useAgentChatQueue = ({ * Attachments MERGE rather than replace — the composer only submits newly staged files, so * replacing would delete the queued message's originals on every text-only edit. * - * The queue drains on its own, so the target can leave mid-edit. Nothing is left to rewrite - * then, and the content becomes a new queued message instead of vanishing. + * A drained local target becomes a new message; durable edits instead preserve server refusal. * * Returns the stashed draft, exactly as `cancelEdit` does: committing consumes the composer, * so the text the session displaced has to come back here too or it is lost for good. @@ -171,13 +407,56 @@ export const useAgentChatQueue = ({ const commitEdit = useCallback( (item: {text: string; fileParts?: FileUIPart[]; stagedFiles?: ComposerAttachment[]}) => { const id = editingId - setEditingId(null) - const draft = takeStash() + const editSession = editSessionRef.current + const serverOwnsInput = + editSession?.server || server?.queued.some((message) => message.id === id) + if (id && serverOwnsInput) { + if (editSession) editSession.server = true + setQueued((queue) => queue.filter((message) => message.id !== id)) + if (migrationRef.current === id) migrationRef.current = null + if (migrationPromiseRef.current?.id === id) migrationPromiseRef.current = null + } + const migration = + migrationPromiseRef.current?.id === id ? migrationPromiseRef.current : null + if (id && (serverOwnsInput || migration)) { + const save = server?.edit + if (!save) return Promise.reject(new Error("This queued message cannot be edited.")) + if (migration?.failed) { + migration.failed = false + migration.promise = migration.retry().catch((error: unknown) => { + migration.failed = true + throw error + }) + } + const saved = migration + ? migration.promise.then(() => + editSessionRef.current === editSession ? save(id, item) : undefined, + ) + : save(id, item) + return saved.then( + () => { + if (editSessionRef.current !== editSession) return "" + editSessionRef.current = null + setEditingId(null) + return takeStash() + }, + (error: unknown) => { + if (editSessionRef.current !== editSession) return "" + throw error + }, + ) + } const target = id ? queuedRef.current.find((m) => m.id === id) : undefined if (!target) { - submit(item) - return draft + const submission = submit(item) + const finish = () => { + setEditingId(null) + return takeStash() + } + return submission ? submission.then(finish) : finish() } + setEditingId(null) + const draft = takeStash() const fileParts = [...(target.fileParts ?? []), ...(item.fileParts ?? [])] const stagedFiles = [...(target.stagedFiles ?? []), ...(item.stagedFiles ?? [])] // Edited down to nothing and carrying no files: there is no message left to hold. @@ -199,7 +478,7 @@ export const useAgentChatQueue = ({ ) return draft }, - [editingId, submit, takeStash], + [editingId, server, submit, takeStash], ) // Release the queue head once the stream settles; the latch caps it at one per settle. Both @@ -211,20 +490,29 @@ export const useAgentChatQueue = ({ releasingRef.current = false return } - if (releasingRef.current || queued.length === 0) return + if (releasingRef.current || migrationRef.current || queued.length === 0) return if (!canReleaseNow) return releasingRef.current = true const [head, ...rest] = queued setQueued(rest) // A released head also needs refusal recovery because it has left the queue. lastSentRef.current = head + markRunOwned() sendQueued(head) - }, [settled, canReleaseNow, queued, sendQueued]) + }, [settled, canReleaseNow, queued, markRunOwned, sendQueued]) return { - queued, + queued: [...(server?.queued ?? []), ...queued], submit, + steer, removeQueued, + sendQueuedNow: + server?.capabilities.queue && server.capabilities.steer ? server.sendNow : undefined, + /** This tab received the durable respond body for this still-running execution. */ + ownsContinuation, + queueEnabled: !!server?.capabilities.queue, + steerEnabled: !!server?.capabilities.steer, + serverBusy: !!server?.busy, /** The conversation is paused on a HITL approval — typed messages should queue, not send. */ hitlPending, /** Id of the held message the composer is currently editing, or null. */ diff --git a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts index 925965d1af0..080cbae0852 100644 --- a/web/packages/agenta-chat/src/hooks/useAgentConversation.ts +++ b/web/packages/agenta-chat/src/hooks/useAgentConversation.ts @@ -19,8 +19,15 @@ import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from "rea import { invalidateSessionListQueries, invalidateSessionLivenessQueries, + fetchSessionInteractionStatesAtom, + interactionStatesFromWatchEvent, recordInteractionAnswerAtom, + respondInteractionAnswerAtom, + respondInteractionAnswersAtom, + resumeSessionContinuationAtom, + sessionDurableApprovalsCapabilityAtom, revalidateSessionMountsAtom, + revalidateSessionInteractionsAtom, revalidateSessionRecordsAtom, shouldAdoptServerTranscript, } from "@agenta/entities/session" @@ -31,7 +38,6 @@ import { approvalResolution, buildAgentRequest, isResumeSend, - recordAnswerThenRelease, type LiveAgentInteraction, } from "@agenta/playground/agent-chat" import {generateId} from "@agenta/shared/utils" @@ -41,6 +47,7 @@ import {useSetAtom, useStore} from "jotai" import {latestTurnId} from "../assets/agentTurn" import {buildRequestWithinDeadline} from "../assets/boundedRequest" +import {prepareAfterContinuationPreflight} from "../assets/continuationPreflight" import {filesToParts} from "../assets/files" import { isSessionTranscript, @@ -48,8 +55,10 @@ import { type SessionTranscript, } from "../assets/loadSession" import {messageText, sideEffectingToolsInRange} from "../assets/rewind" +import {submitApprovalForCapability} from "../assets/serverOwnedApproval" import {startupLabelFromDataPart} from "../assets/startupPhases" import {getMessageTraceId} from "../assets/trace" +import {reconcileInteractionRowStates} from "../assets/transcriptToMessages" import {isClientToolPart as defaultIsClientToolPart} from "../clientTools" import {classifyAgentRunError, type ParsedRunError, type RunErrorMetadata} from "../model/error" import {withoutSharedSenderAcceptanceMessages} from "../model/livePreview" @@ -90,6 +99,7 @@ import {clearTurnClockAtom, startTurnClockAtom} from "../state/turnClock" import {useAgentChatQueue, type QueuedMessage} from "./useAgentChatQueue" import {useApprovalDock, type ApprovalDock} from "./useApprovalDock" +import {useServerSessionInputs} from "./useServerSessionInputs" import {useSessionChat} from "./useSessionChat" import {useSessionLivePreview} from "./useSessionLivePreview" @@ -97,6 +107,7 @@ import {useSessionLivePreview} from "./useSessionLivePreview" * error; swallow the floating `sendMessage`/`regenerate` rejection so it doesn't bubble to a * dev runtime-error overlay (F-033). */ const ignoreStreamRejection = () => {} +const INTERACTION_GATE_POLL_MS = 1_000 export interface SendInput { text: string @@ -176,10 +187,19 @@ export interface AgentConversation { stopped: boolean /** Messages held while a turn is in flight, in FIFO order. */ queued: QueuedMessage[] + /** Queue is server-owned for this session. */ + queueEnabled: boolean + /** Steer is server-owned and available as a second busy action. */ + steerEnabled: boolean + /** The server currently owns an execution, including one started in another browser. */ + inputBusy: boolean + /** Submit the current composer value as a priority Steer input. */ + steer: (input: SendInput) => Promise /** The run is parked on the USER — an approval gate or an unanswered client tool (elicitation, * connect). Typed messages queue rather than send while this holds. */ hitlPending: boolean removeQueued: (id: string) => void + sendQueuedNow?: (id: string) => Promise /** Id of the held message the composer is editing, or null. */ editingId: string | null /** Borrow the composer for `id`, stashing the draft it currently holds. */ @@ -188,21 +208,24 @@ export interface AgentConversation { cancelEdit: () => string /** Rewrite the edited message with the composer's content (or queue it anew if it drained). * Returns the draft the session displaced, for the host to put back. */ - commitEdit: (item: {text: string; fileParts?: FileUIPart[]}) => string + commitEdit: (item: {text: string; fileParts?: FileUIPart[]}) => string | Promise /** Headless approval-dock state wired to the live-gate-aware response path. */ approvals: ApprovalDock /** Settle a parked client tool part (widgets call this; the resume predicate auto-resends). */ - sendToolOutput: (args: ToolOutputSettleInput) => void + sendToolOutput: (args: ToolOutputSettleInput) => Promise /** Re-fetch the durable records and adopt the server transcript under the same guards as * 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 + sharedSettledAt: number /** 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 + /** Apply a pushed interaction row immediately, falling back to the row query for old events. */ + interactionChanged: (event: MessageEvent) => void } /** @@ -226,6 +249,8 @@ export const useAgentConversation = ({ const setSessionStatus = useSetAtom(setSessionStatusAtom) const revalidateSessionMounts = useSetAtom(revalidateSessionMountsAtom) const revalidateSessionRecords = useSetAtom(revalidateSessionRecordsAtom) + const revalidateSessionInteractions = useSetAtom(revalidateSessionInteractionsAtom) + const fetchSessionInteractionStates = useSetAtom(fetchSessionInteractionStatesAtom) const pruneExpanded = useSetAtom(pruneExpandedAtom) const stampMessagesCreatedAt = useSetAtom(stampMessagesCreatedAtAtom) const setTurnStartupLabel = useSetAtom(startTurnClockAtom) @@ -277,6 +302,22 @@ export const useAgentConversation = ({ // `undefined` means "no live marker", which falls back to the predicate's tail heuristics. const liveGateInteractionRef = useRef(null) const recordInteractionAnswer = useSetAtom(recordInteractionAnswerAtom) + const respondInteractionAnswer = useSetAtom(respondInteractionAnswerAtom) + const respondInteractionAnswers = useSetAtom(respondInteractionAnswersAtom) + const resumeSessionContinuation = useSetAtom(resumeSessionContinuationAtom) + const supportsDurableApprovals = useSetAtom(sessionDurableApprovalsCapabilityAtom) + const [recoverableContinuation, setRecoverableContinuation] = useState(false) + // Execution id of the continuation the last durable answer started (respond body, + // `execution.id`). The queue holds every send until that execution writes its terminal record: + // the transcript-derived hold cannot cover the seconds between the answer and the + // continuation's first record, and a transcript adopted inside that gap reads as settled. + const [continuationExecutionId, setContinuationExecutionId] = useState(null) + const approvalResponseOwnerRef = useRef(null) + const retryRecoverableContinuation = useCallback(async () => { + const resumed = await resumeSessionContinuation(sessionId) + if (resumed) setRecoverableContinuation(false) + return resumed + }, [resumeSessionContinuation, sessionId]) // 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 @@ -292,6 +333,18 @@ export const useAgentConversation = ({ const [turnDeliverySource, setTurnDeliverySource] = useState( () => turnDeliverySourceBySession.get(sessionId) ?? null, ) + const settleAcceptedRun = useCallback( + (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) + }, + [sessionId], + ) // 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, @@ -302,26 +355,30 @@ export const useAgentConversation = ({ 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; - // the same race reached /m through this hook). - const req = await buildRequestWithinDeadline(() => - buildAgentRequest(entityIdRef.current, messages, { - sessionId: id ?? sessionId, - sharedResponse, - }), + return prepareAfterContinuationPreflight( + resumeSessionContinuation, + id ?? sessionId, + async () => { + 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 races that fetch (#6042). + const req = await buildRequestWithinDeadline(() => + buildAgentRequest(entityIdRef.current, messages, { + sessionId: id ?? sessionId, + sharedResponse, + }), + ) + return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} + }, ) - return {api: req.invocationUrl, headers: req.headers, body: req.requestBody} }, // Approve AND deny both resume — a deny-only decision must re-send so the runner // gets the denial round-trip and the model continues (no `approval-responded` limbo). @@ -354,7 +411,16 @@ export const useAgentConversation = ({ const label = startupLabelFromDataPart(part) if (label) setTurnStartupLabel(sessionId, label) }, - onFinish: ({message, messages: finishedMessages, finishReason}) => { + onFinish: ({ + message, + messages: finishedMessages, + finishReason, + isAbort, + isDisconnect, + isError, + }) => { + // A clean shared invoke close is terminal; a disconnect still waits for the durable event. + if (!isAbort && !isDisconnect && !isError) settleAcceptedRun() dispatchStopped({ type: "stream-terminal", messages: finishedMessages, @@ -605,6 +671,10 @@ export const useAgentConversation = ({ }, [sendMessage, sessionId], ) + const markRunOwned = useCallback( + () => setSessionStatus({id: sessionId, status: "running"}), + [sessionId, setSessionStatus], + ) // Orphan detection for the queue's pre-resume hold: the tail is a RESTORED message (this // mount never streamed it) shaped like "auto-resume imminent", and no gate was settled live @@ -616,12 +686,38 @@ export const useAgentConversation = ({ restoredIdsRef.current.has(lastMessage.id) && agentShouldResumeAfterApproval({messages}) + const serverInputs = useServerSessionInputs({ + entityId, + sessionId, + messages, + locallyBusy: busy, + isSharedReaderReady: () => sharedSenderReadyRef.current, + onExecuted: () => { + void loadSessionMessages(sessionId, adoptServerTranscript).then(adoptServerTranscript) + }, + }) + + const previousServerInputsStatusRef = useRef(status) + useEffect(() => { + const previousStatus = previousServerInputsStatusRef.current + previousServerInputsStatusRef.current = status + if (previousStatus !== status && (status === "ready" || status === "error")) { + void serverInputs.refresh() + } + }, [status, serverInputs.refresh]) + // Queue messages typed while a turn is streaming or paused on a HITL approval; released // one-by-one once the turn truly settles (never mid-approval). const { queued, submit, + steer, removeQueued, + sendQueuedNow, + ownsContinuation, + queueEnabled, + steerEnabled, + serverBusy, hitlPending, editingId, beginEdit, @@ -633,31 +729,99 @@ export const useAgentConversation = ({ acceptedRunPending, stopped, resumeOrphaned, + recoverable: recoverableContinuation, + retryContinuation: retryRecoverableContinuation, + continuationExecutionId, + markRunOwned, sendQueued, sessionId, + server: serverInputs, }) - // Approval responses flow through here (not bare `addToolApprovalResponse`) so a decision - // made in THIS mount marks the resume as live — a restored approval-requested tail the user - // answers after a reload genuinely auto-resumes, so the queue's pre-resume hold applies. + // The server capability chooses one owner. Feature-off servers keep the original ordered row + // transition + AI SDK gate release; durable servers own continuation after their 202. const handleApprovalResponse = useCallback( - (args: {id: string; approved: boolean}) => { + async (args: {id: string; approved: boolean}) => { + approvalResponseOwnerRef.current = args.id liveGateInteractionRef.current = {kind: "approval", id: args.id} - // Ordered, not raced: the DECISION lands on the interaction row first, and only then - // does the part flip that lets the SDK dispatch its resume. Flipped first, that - // resume's stale sweep cancelled the row being answered. No resume from here either — - // the park stream finishes cleanly, so the SDK is the only sender. - void recordAnswerThenRelease({ - record: () => + const outcome = await submitApprovalForCapability({ + durableApprovals: supportsDurableApprovals(sessionId), + submitDurable: () => + respondInteractionAnswer({ + sessionId, + toolCallId: args.id, + approved: args.approved, + }), + retireDurable: () => { + liveGateInteractionRef.current = null + }, + recordLegacy: () => recordInteractionAnswer({ sessionId, toolCallId: args.id, resolution: approvalResolution(args.id, args.approved), }), - release: () => addToolApprovalResponse(args), + releaseLegacy: () => addToolApprovalResponse(args), + }) + if (approvalResponseOwnerRef.current === args.id) { + setRecoverableContinuation(outcome.recoverable) + setContinuationExecutionId(outcome.executionId ?? null) + } + return outcome + }, + [ + addToolApprovalResponse, + recordInteractionAnswer, + respondInteractionAnswer, + sessionId, + supportsDurableApprovals, + ], + ) + + const handleApprovalResponses = useCallback( + async (args: {ids: string[]; approved: boolean}) => { + approvalResponseOwnerRef.current = args.ids[0] + liveGateInteractionRef.current = {kind: "approval", id: args.ids[0]} + const outcome = await submitApprovalForCapability({ + durableApprovals: supportsDurableApprovals(sessionId), + submitDurable: () => + respondInteractionAnswers({ + sessionId, + toolCallIds: args.ids, + approved: args.approved, + }), + retireDurable: () => { + liveGateInteractionRef.current = null + }, + recordLegacy: () => + Promise.all( + args.ids.map((id) => + recordInteractionAnswer({ + sessionId, + toolCallId: id, + resolution: approvalResolution(id, args.approved), + }), + ), + ).then(() => undefined), + releaseLegacy: () => { + for (const id of args.ids) { + addToolApprovalResponse({id, approved: args.approved}) + } + }, }) + if (approvalResponseOwnerRef.current === args.ids[0]) { + setRecoverableContinuation(outcome.recoverable) + setContinuationExecutionId(outcome.executionId ?? null) + } + return outcome }, - [addToolApprovalResponse, recordInteractionAnswer, sessionId], + [ + addToolApprovalResponse, + recordInteractionAnswer, + respondInteractionAnswers, + sessionId, + supportsDurableApprovals, + ], ) // A resume really went out (the SDK's), so the gate it carried is spent. Retired HERE, where a @@ -678,31 +842,42 @@ export const useAgentConversation = ({ [messages], ) - const approvals = useApprovalDock({messages, respond: handleApprovalResponse}) + const approvals = useApprovalDock({ + messages, + respond: handleApprovalResponse, + respondAll: handleApprovalResponses, + }) + const pendingApprovalId = approvals.current?.approvalId + if (pendingApprovalId && approvalResponseOwnerRef.current !== pendingApprovalId) { + approvalResponseOwnerRef.current = pendingApprovalId + } + useEffect(() => { + if (pendingApprovalId) { + setRecoverableContinuation(false) + setContinuationExecutionId(null) + } + }, [pendingApprovalId]) - // Settle a parked client tool (#4920). A widget calls this with the structured reference; - // `addToolOutput` matches the part by `toolCallId` on the last turn and the resume predicate - // auto-resends. `tool` is only the typed-tools key — matching is by id — so a cast onto the - // untyped UIMessage tool map is safe. + // Durable gates resume on the server; legacy gates still release the local SDK. const sendToolOutput = useCallback( - ({toolName, toolCallId, output, errorText}: ToolOutputSettleInput) => { + async ({toolName, toolCallId, output, errorText}: ToolOutputSettleInput) => { + approvalResponseOwnerRef.current = toolCallId liveGateInteractionRef.current = {kind: "client_tool", id: toolCallId} - // Ordered like the approval half: the resume starts a turn whose sweep cancels every - // `pending` row, so the answer has to be durable first. Capped inside the helper. - void recordAnswerThenRelease({ - record: () => - recordInteractionAnswer({ - sessionId, - toolCallId, - resolution: { - tool_call_id: toolCallId, - tool_name: toolName, - ...(errorText !== undefined - ? {outcome: "error", error: errorText} - : {outcome: "completed", output: output ?? {}}), - }, - }), - release: () => { + const resolution = { + tool_call_id: toolCallId, + tool_name: toolName, + ...(errorText !== undefined + ? {outcome: "error", error: errorText} + : {outcome: "completed", output: output ?? {}}), + } + const outcome = await submitApprovalForCapability({ + durableApprovals: supportsDurableApprovals(sessionId), + submitDurable: () => respondInteractionAnswer({sessionId, toolCallId, resolution}), + retireDurable: () => { + liveGateInteractionRef.current = null + }, + recordLegacy: () => recordInteractionAnswer({sessionId, toolCallId, resolution}), + releaseLegacy: () => { if (errorText !== undefined) { addToolOutput({ state: "output-error", @@ -719,8 +894,18 @@ export const useAgentConversation = ({ } }, }) + if (approvalResponseOwnerRef.current === toolCallId) { + setRecoverableContinuation(outcome.recoverable) + setContinuationExecutionId(outcome.executionId ?? null) + } }, - [addToolOutput, recordInteractionAnswer, sessionId], + [ + addToolOutput, + recordInteractionAnswer, + respondInteractionAnswer, + sessionId, + supportsDurableApprovals, + ], ) // Publish this session's run state (single source of truth for session-list status dots). @@ -728,7 +913,7 @@ export const useAgentConversation = ({ const runStatus = deriveSessionRunStatus({ error: !!errorBoundary.runError, hitlPending, - busy: busy || acceptedRunPending, + busy: busy || acceptedRunPending || ownsContinuation, }) useEffect(() => { setSessionStatus({id: sessionId, status: runStatus}) @@ -864,6 +1049,7 @@ export const useAgentConversation = ({ const { messages: previewMessages, runningFromSnapshot, + sharedSettledAt, readerReady, } = useSessionLivePreview({ sessionId, @@ -873,15 +1059,7 @@ export const useAgentConversation = ({ 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) - }, + onExecutionSettled: settleAcceptedRun, onDisconnect: revalidate, }) const includePreview = turnDeliverySource !== "legacy" @@ -892,6 +1070,58 @@ export const useAgentConversation = ({ : transcriptMessages }, [includePreview, messages, previewMessages]) + const applyInteractionStates = useCallback( + (rows: ReturnType) => { + if (!rows || busyRef.current || liveGateInteractionRef.current) return + const current = messagesRef.current + const reconciled = reconcileInteractionRowStates(current, rows) + if (reconciled === current) return + messagesRef.current = reconciled + setMessages(reconciled) + persistMessages({ + id: sessionId, + messages: reconciled, + recordCount: recordWatermarkRef.current, + }) + }, + [persistMessages, sessionId, setMessages], + ) + const refreshInteractions = useCallback(async () => { + if (busyRef.current || liveGateInteractionRef.current) return + await revalidateSessionInteractions(sessionId) + applyInteractionStates(await fetchSessionInteractionStates(sessionId)) + }, [ + applyInteractionStates, + fetchSessionInteractionStates, + revalidateSessionInteractions, + sessionId, + ]) + const interactionChanged = useCallback( + (event: MessageEvent) => { + const pushed = interactionStatesFromWatchEvent(event.data, sessionId) + if (!pushed) { + void refreshInteractions() + return + } + applyInteractionStates(pushed) + void revalidateSessionInteractions(sessionId) + }, + [applyInteractionStates, refreshInteractions, revalidateSessionInteractions, sessionId], + ) + useEffect(() => { + if (!hitlPending) return + let cancelled = false + let timer: ReturnType | undefined + const poll = async () => { + await refreshInteractions().catch(() => undefined) + if (!cancelled) timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + } + timer = setTimeout(poll, INTERACTION_GATE_POLL_MS) + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [hitlPending, refreshInteractions]) // Fence a delayed approval release before the host's durable cancel request settles. const voidPendingResume = useCallback(() => { liveGateInteractionRef.current = null @@ -937,13 +1167,27 @@ export const useAgentConversation = ({ clearSessionTurnId(sessionId) setStopped(false) // One path: `submit` sends now or queues behind held messages via the release gate. - submit({text: trimmed, fileParts}) + await submit({text: trimmed, fileParts}) // The message left the composer — drop its persisted draft (per-session store). composerDraftBySession.delete(sessionId) }, [submit, sessionId], ) + const steerInput = useCallback( + async ({text, files, parts}: SendInput) => { + const trimmed = text.trim() + const fileObjs = files ?? [] + const refParts = parts ?? [] + if (!trimmed && fileObjs.length === 0 && refParts.length === 0) return + const encoded = fileObjs.length ? await filesToParts(fileObjs) : undefined + const merged = [...(encoded?.parts ?? []), ...refParts] + await steer({text: trimmed, fileParts: merged.length ? merged : undefined}) + composerDraftBySession.delete(sessionId) + }, + [sessionId, steer], + ) + const regenerateTurn = useCallback( (id: string) => { clearSessionTurnId(sessionId) @@ -1023,8 +1267,13 @@ export const useAgentConversation = ({ historyUnavailable, stopped, queued, + queueEnabled, + steerEnabled, + inputBusy: serverBusy, + steer: steerInput, hitlPending, removeQueued, + sendQueuedNow, editingId, beginEdit, cancelEdit, @@ -1033,7 +1282,9 @@ export const useAgentConversation = ({ sendToolOutput, revalidate, runningFromSnapshot, + sharedSettledAt, readerReady, acceptedRunPending, + interactionChanged, } } diff --git a/web/packages/agenta-chat/src/hooks/useApprovalDock.ts b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts index 7b23c92372f..772dd4ce667 100644 --- a/web/packages/agenta-chat/src/hooks/useApprovalDock.ts +++ b/web/packages/agenta-chat/src/hooks/useApprovalDock.ts @@ -10,12 +10,20 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react" import type {UIMessage} from "ai" +import type {ApprovalSubmissionOutcome} from "../assets/serverOwnedApproval" import {getPendingApprovals, type PendingApproval} from "../model/approvals" +type ApprovalResponse = void | ApprovalSubmissionOutcome + export interface UseApprovalDockArgs { messages: UIMessage[] /** Answer one gate — the host's approval-response path (which marks the resume live). */ - respond: (args: {id: string; approved: boolean}) => void + respond: (args: {id: string; approved: boolean}) => ApprovalResponse | Promise + /** Answer one paused turn's shown gates in a single server transaction. */ + respondAll?: (args: { + ids: string[] + approved: boolean + }) => ApprovalResponse | Promise } export interface ApprovalDock { @@ -27,6 +35,11 @@ export interface ApprovalDock { count: number /** A fired decision hasn't settled yet — disable the action buttons. */ responding: boolean + /** The server accepted the durable response; wait for records to replace the parked gate. */ + answered: boolean + /** The answer is durable, but delivery needs the user's next Send to retry. */ + recoverable: boolean + errorText: string | null /** Answer the current gate. */ respond: (approved: boolean) => void /** Approve every pending gate in one step (the shown set is frozen while they settle). */ @@ -42,6 +55,7 @@ export interface ApprovalDock { export const useApprovalDock = ({ messages, respond: onRespond, + respondAll: onRespondAll, }: UseApprovalDockArgs): ApprovalDock => { const approvals = useMemo(() => getPendingApprovals(messages), [messages]) const open = approvals.length > 0 @@ -62,15 +76,54 @@ export const useApprovalDock = ({ const shown = shownRef.current const current = shown[0] ?? null const count = shown.length + const currentIdRef = useRef(current?.approvalId) + currentIdRef.current = current?.approvalId const [responding, setResponding] = useState(false) + const [answered, setAnswered] = useState(false) + const [recoverable, setRecoverable] = useState(false) + const [errorText, setErrorText] = useState(null) // The current gate changed (we answered one, the next slid in) — re-enable. Held during a // resolve (current is frozen), so it fires only on a real step or a new batch. useEffect(() => { setResponding(false) + setAnswered(false) + setRecoverable(false) + setErrorText(null) }, [current?.approvalId]) + const settle = useCallback( + async ( + responses: (ApprovalResponse | Promise)[], + ownerId: string | undefined, + ) => { + const results = await Promise.allSettled(responses) + if (currentIdRef.current !== ownerId) return + const failed = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ) + if (!failed) { + setRecoverable( + results.some( + (result) => + result.status === "fulfilled" && result.value?.recoverable === true, + ), + ) + setAnswered(true) + return + } + setResponding(false) + setResolvingIds(null) + setErrorText( + failed.reason instanceof Error + ? failed.reason.message + : "Approval failed. Please try again.", + ) + }, + [], + ) + // Once every gate we fired has settled (left the pending set), drop the latch — the dock then // closes if nothing remains, or re-latches onto the uncovered gates (a mixed batch). useEffect(() => { @@ -83,19 +136,27 @@ export const useApprovalDock = ({ (approved: boolean) => { if (responding || !current) return setResponding(true) - onRespond({id: current.approvalId, approved}) + setErrorText(null) + void settle([onRespond({id: current.approvalId, approved})], current.approvalId) }, - [responding, current, onRespond], + [responding, current, onRespond, settle], ) const approveAll = useCallback(() => { if (responding || shown.length === 0) return setResponding(true) + setErrorText(null) // Freeze the card so the dock doesn't step through the batch as each response settles — // it holds "1 of N" and closes once all are answered (see `resolvingIds`). setResolvingIds(shown.map((a) => a.approvalId)) - shown.forEach((a) => onRespond({id: a.approvalId, approved: true})) - }, [responding, shown, onRespond]) + const ids = shown.map((approval) => approval.approvalId) + void settle( + onRespondAll + ? [onRespondAll({ids, approved: true})] + : ids.map((id) => onRespond({id, approved: true})), + ids[0], + ) + }, [responding, shown, onRespond, onRespondAll, settle]) - return {open, current, count, responding, respond, approveAll} + return {open, current, count, responding, answered, recoverable, errorText, respond, approveAll} } diff --git a/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts new file mode 100644 index 00000000000..373367e3507 --- /dev/null +++ b/web/packages/agenta-chat/src/hooks/useServerSessionInputs.ts @@ -0,0 +1,239 @@ +import {useCallback, useEffect, useRef, useState} from "react" + +import { + fetchSessionCapabilitiesAtom, + fetchSessionSnapshotAtom, + removePendingSessionInputAtom, + sendPendingSessionInputNowAtom, + updatePendingSessionInputAtom, +} from "@agenta/entities/session" +import {buildAgentRequest} from "@agenta/playground/agent-chat" +import {projectIdAtom} from "@agenta/shared/state" +import type {FileUIPart, UIMessage} from "ai" +import {useAtomValue, useSetAtom} from "jotai" + +import {attachmentIdForPart} from "../assets/files" +import {reduceSessionPendingInputs, type SessionPendingInputView} from "../assets/pendingInputs" + +import type {QueuedMessage} from "./useAgentChatQueue" + +export interface ServerSessionInputs { + capabilities: SessionPendingInputView["capabilities"] + executionState: SessionPendingInputView["executionState"] + busy: boolean + queued: QueuedMessage[] + submit: (message: QueuedMessage, policy: "queue" | "steer") => Promise + remove: (id: string) => Promise + sendNow: (id: string) => Promise + edit: (id: string, item: {text: string; fileParts?: FileUIPart[]}) => Promise + refresh: () => Promise + resolveCapabilities: () => Promise +} + +const emptyView = reduceSessionPendingInputs(null) + +export const useServerSessionInputs = ({ + entityId, + sessionId, + messages, + locallyBusy, + isSharedReaderReady, + onExecuted, +}: { + entityId: string + sessionId: string + messages: UIMessage[] + locallyBusy: boolean + /** Read current transport readiness when admitting input, including after reconnect. */ + isSharedReaderReady?: () => boolean + onExecuted?: () => void +}): ServerSessionInputs => { + const projectId = useAtomValue(projectIdAtom) + const scope = JSON.stringify([projectId, sessionId]) + const scopeRef = useRef(scope) + scopeRef.current = scope + const fetchSnapshot = useSetAtom(fetchSessionSnapshotAtom) + const fetchCapabilities = useSetAtom(fetchSessionCapabilitiesAtom) + const removeInput = useSetAtom(removePendingSessionInputAtom) + const sendInputNow = useSetAtom(sendPendingSessionInputNowAtom) + const updateInput = useSetAtom(updatePendingSessionInputAtom) + const [viewState, setViewState] = useState<{scope: string; view: SessionPendingInputView}>( + () => ({scope, view: emptyView}), + ) + const view = viewState.scope === scope ? viewState.view : emptyView + const messagesRef = useRef(messages) + const entityIdRef = useRef(entityId) + const onExecutedRef = useRef(onExecuted) + const isSharedReaderReadyRef = useRef(isSharedReaderReady) + const loadInFlightRef = useRef<{ + scope: string + promise: Promise + } | null>(null) + messagesRef.current = messages + entityIdRef.current = entityId + onExecutedRef.current = onExecuted + isSharedReaderReadyRef.current = isSharedReaderReady + + const load = useCallback((): Promise => { + if (loadInFlightRef.current?.scope === scope) { + return loadInFlightRef.current.promise + } + const promise = (async () => { + const capabilities = await fetchCapabilities(sessionId) + if (!capabilities) return null + if (!capabilities.queue) return emptyView + const snapshot = await fetchSnapshot(sessionId) + return snapshot ? reduceSessionPendingInputs(snapshot) : null + })() + const entry = {scope, promise} + loadInFlightRef.current = entry + const clear = () => { + if (loadInFlightRef.current === entry) loadInFlightRef.current = null + } + void promise.then(clear, clear) + return promise + }, [fetchCapabilities, fetchSnapshot, sessionId, scope]) + + const refresh = useCallback(async () => { + const next = await load() + if (next && scopeRef.current === scope) setViewState({scope, view: next}) + }, [load, scope]) + + useEffect(() => { + let cancelled = false + void load().then((next) => { + if (!cancelled && next) { + setViewState({scope, view: next}) + } + }) + return () => { + cancelled = true + } + }, [load, scope]) + + // Pending-input events arrive in a later increment. Until then, a small capability-gated + // snapshot poll gives every mounted browser the same durable order. + useEffect(() => { + if (!view.capabilities.queue) return + const timer = setInterval(() => void refresh(), 2_000) + return () => clearInterval(timer) + }, [refresh, view.capabilities.queue]) + + const resolveCapabilities = useCallback(async () => { + const capabilities = await fetchCapabilities(sessionId) + if (!capabilities || scopeRef.current !== scope) { + throw new Error("Session capabilities are unavailable. Please try again.") + } + return {queue: capabilities.queue, steer: capabilities.steer} + }, [fetchCapabilities, sessionId, scope]) + + const submit = useCallback( + async (message: QueuedMessage, policy: "queue" | "steer") => { + const outbound: UIMessage = { + id: message.id, + role: "user", + parts: [ + ...(message.text ? [{type: "text" as const, text: message.text}] : []), + ...(message.fileParts ?? []), + ], + } + const request = await buildAgentRequest( + entityIdRef.current, + [...messagesRef.current, outbound], + { + sessionId, + ...(isSharedReaderReadyRef.current?.() ? {sharedResponse: true} : {}), + }, + ) + if (!request) throw new Error("The agent is not ready to accept input.") + + const response = await fetch(request.invocationUrl, { + method: "POST", + headers: { + ...request.headers, + "Content-Type": "application/json", + "Idempotency-Key": message.id, + }, + body: JSON.stringify({...request.requestBody, on_busy: policy}), + }) + if (!response.ok) { + await response.body?.cancel() + throw new Error(`The input was not accepted (${response.status}).`) + } + + if (response.status === 202) { + await response.body?.cancel() + await refresh() + return + } + + // Admission succeeded when the response headers arrived. Keep consuming a fresh 200 + // run in the background so the composer can admit Queue/Steer while that run streams. + void response + .arrayBuffer() + .catch(() => undefined) + .then(async () => { + await refresh() + onExecutedRef.current?.() + }) + .catch(() => undefined) + }, + [refresh, sessionId], + ) + + const remove = useCallback( + async (id: string) => { + if (!(await removeInput({sessionId, inputId: id}))) { + throw new Error("The pending input could not be removed.") + } + await refresh() + }, + [refresh, removeInput, sessionId], + ) + + const edit = useCallback( + async (id: string, item: {text: string; fileParts?: FileUIPart[]}) => { + if (!view.capabilities.queue) throw new Error("Queue editing is not available.") + const updated = await updateInput({ + sessionId, + inputId: id, + text: item.text, + attachments: item.fileParts?.map((part) => ({ + uri: part.url, + mime_type: part.mediaType, + attachment_id: attachmentIdForPart(part) ?? undefined, + ...(part.filename ? {filename: part.filename} : {}), + })), + }) + if (!updated) throw new Error("The queued message could not be updated. Try again.") + await refresh() + }, + [refresh, sessionId, updateInput, view.capabilities.queue], + ) + + const sendNow = useCallback( + async (id: string) => { + if (!view.capabilities.queue || !view.capabilities.steer) { + throw new Error("Send Now is not available for this session.") + } + if (!(await sendInputNow({sessionId, inputId: id}))) { + throw new Error("The queued message could not be sent. Try again.") + } + await refresh() + }, + [refresh, sendInputNow, sessionId, view.capabilities.queue, view.capabilities.steer], + ) + + return { + capabilities: view.capabilities, + executionState: view.executionState, + busy: locallyBusy || view.executionState !== "idle", + queued: view.queued, + submit, + remove, + sendNow, + edit, + refresh, + resolveCapabilities, + } +} diff --git a/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts b/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts index e2293ec8378..40cd8d3b97e 100644 --- a/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts +++ b/web/packages/agenta-chat/src/hooks/useSessionLivePreview.ts @@ -12,6 +12,7 @@ import {projectIdAtom} from "@agenta/shared/state" import type {UIMessage} from "ai" import {useAtom, useAtomValue, useSetAtom} from "jotai" +import {liveCommittedRevisions, type CommittedRevision} from "../assets/committedRevisions" import type {SessionTranscript} from "../assets/loadSession" import {transcriptToMessages} from "../assets/transcriptToMessages" import { @@ -23,6 +24,9 @@ import { import { isSessionSnapshotRunning, reduceSessionLivePreview, + markSessionLivePreviewTerminal, + retireSessionLivePreview, + retireCoveredSessionLivePreview, sessionLivePreviewMessages, shouldSubscribeToSessionLivePreview, } from "../model/livePreview" @@ -40,6 +44,7 @@ export const useSessionLivePreview = ({ runningElsewhere, sender, onReadyChange, + onCommittedRevision, onExecutionSettled, onDisconnect, }: { @@ -50,13 +55,20 @@ export const useSessionLivePreview = ({ runningElsewhere: boolean /** Subscribe before this browser sends its next turn. */ sender?: boolean + /** Reports commits learned after initial hydration, once their transcript is adopted. */ + onCommittedRevision?: (revision: CommittedRevision) => void /** 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} => { +}): { + messages: UIMessage[] + runningFromSnapshot: boolean + readerReady: boolean + sharedSettledAt: number +} => { const projectId = useAtomValue(projectIdAtom) const [preview, setPreview] = useAtom(sessionLivePreviewAtomFamily(sessionId)) const clearPreview = useSetAtom(clearSessionLivePreviewAtom) @@ -64,6 +76,9 @@ export const useSessionLivePreview = ({ const revalidateInteractionStates = useSetAtom(revalidateSessionInteractionsAtom) const [runningFromSnapshot, setRunningFromSnapshot] = useState(false) const [readerReady, setReaderReady] = useState(false) + const [sharedSettledAt, setSharedSettledAt] = useState(0) + const onCommittedRevisionRef = useRef(onCommittedRevision) + onCommittedRevisionRef.current = onCommittedRevision const onDisconnectRef = useRef(onDisconnect) onDisconnectRef.current = onDisconnect const retryHydrationRef = useRef<() => void>(() => undefined) @@ -83,6 +98,7 @@ export const useSessionLivePreview = ({ useEffect(() => { clearPreview(sessionId) + setSharedSettledAt(0) setReaderReady(false) onReadyChangeRef.current?.(false) if (!sharedReaderAdvertised || !sessionId) return @@ -96,13 +112,13 @@ export const useSessionLivePreview = ({ let reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS let generation = 0 let durable = createSessionDurableEventState() + let liveBaselineSequence: number | undefined const close = () => { connection?.close() connection = null setReaderReady(false) onReadyChangeRef.current?.(false) - clearPreview(sessionId) } const scheduleReconnect = () => { @@ -127,25 +143,47 @@ export const useSessionLivePreview = ({ const readBoundedTranscript = async ( throughSequence: number, - ): Promise => { + ): Promise<{ + transcript: SessionTranscript + coveredEntityIds: Set + committedRevisions: CommittedRevision[] + } | null> => { if (!projectId) return null const [records, interactionRowStates] = await Promise.all([ querySessionTranscript({sessionId, projectId, throughSequence}), fetchInteractionStates(sessionId), ]) if (!Array.isArray(records)) return null + const coveredEntityIds = new Set( + records.flatMap((record) => { + const payload = record.payload + if (!payload) return [] + const id = + payload.type === "message" || payload.type === "thought" + ? payload.message_id + : payload.type === "tool_result" + ? payload.id + : undefined + return typeof id === "string" ? [id] : [] + }), + ) return { - messages: transcriptToMessages(records, {interactionRowStates}) ?? [], - recordCount: records.length, - sequenceCursor: throughSequence, - interactionRows: interactionRowStates, + transcript: { + messages: transcriptToMessages(records, {interactionRowStates}) ?? [], + recordCount: records.length, + sequenceCursor: throughSequence, + interactionRows: interactionRowStates, + }, + coveredEntityIds, + committedRevisions: liveCommittedRevisions(records, liveBaselineSequence), } } const hydrateAndOpen = async () => { if (disposed || connection || document.visibilityState !== "visible") return + if (reconnectTimer) clearTimeout(reconnectTimer) + reconnectTimer = null const currentGeneration = ++generation - clearPreview(sessionId) let snapshot try { @@ -157,22 +195,44 @@ export const useSessionLivePreview = ({ if (disposed || currentGeneration !== generation) return const snapshotRunning = isSessionSnapshotRunning(snapshot ?? undefined) setRunningFromSnapshot(snapshotRunning) - if (snapshot && !snapshotRunning) onExecutionSettledRef.current?.() + if (snapshot?.session && snapshot.read && !snapshotRunning) { + setSharedSettledAt(Date.now()) + onExecutionSettledRef.current?.() + } - if (snapshot && projectId) { + if (snapshot?.session && snapshot.read && projectId) { try { - const transcript = await readBoundedTranscript(snapshot.read.latest_sequence) - if (!transcript) { + let previewBoundary: typeof preview | undefined + setPreview((current) => { + previewBoundary = current + return current + }) + const bounded = await readBoundedTranscript(snapshot.read.latest_sequence) + if (!bounded) { scheduleReconnect() return } if (disposed || currentGeneration !== generation) return - const adopted = await adoptTranscript(transcript) + const adopted = await adoptTranscript(bounded.transcript) if (disposed || currentGeneration !== generation) return if (!adopted) { scheduleReconnect() return } + for (const revision of bounded.committedRevisions) + onCommittedRevisionRef.current?.(revision) + liveBaselineSequence ??= snapshot.read.latest_sequence + if (!snapshotRunning) clearPreview(sessionId) + else + setPreview((current) => ({ + ...retireCoveredSessionLivePreview( + current, + previewBoundary ?? current, + bounded.coveredEntityIds, + bounded.transcript.messages, + ), + gapDetected: false, + })) } catch { scheduleReconnect() return @@ -186,14 +246,17 @@ export const useSessionLivePreview = ({ } } durable = createSessionDurableEventState( - snapshot?.read.latest_sequence ?? durable.latestSequence, + snapshot?.read?.latest_sequence ?? durable.latestSequence, ) connection = connectSessionLiveEvents({ sessionId, after: durable.latestSequence, - onFrame: (frame) => - setPreview((current) => reduceSessionLivePreview(current, frame)), + onFrame: (frame) => { + if (disposed || currentGeneration !== generation) return + setPreview((current) => reduceSessionLivePreview(current, frame)) + }, onEvent: (event) => { + if (disposed || currentGeneration !== generation) return const next = reduceSessionDurableEvent(durable, event) if (!shouldRefetchSessionTranscript(durable, next, event)) { durable = next @@ -201,44 +264,69 @@ export const useSessionLivePreview = ({ } durable = next if (event.type === "execution.started") setRunningFromSnapshot(true) + let previewBoundary: typeof preview | undefined + setPreview((current) => { + previewBoundary = current + return ["execution.stopped", "execution.failed", "execution.lost"].includes( + event.type, + ) + ? markSessionLivePreviewTerminal(current, event) + : current + }) if ( event.type === "execution.stopped" || event.type === "execution.failed" || event.type === "execution.lost" ) { setRunningFromSnapshot(false) + setSharedSettledAt(Date.now()) 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() - }, - ) + void readBoundedTranscript(next.latestSequence) + .then(async (bounded) => { + if (disposed || currentGeneration !== generation) return + const transcript = bounded?.transcript + const adopted = transcript ? await adoptTranscript(transcript) : false + if (disposed || currentGeneration !== generation) return + if (adopted) { + for (const revision of bounded?.committedRevisions ?? []) + onCommittedRevisionRef.current?.(revision) + setPreview((current) => + retireSessionLivePreview( + bounded && previewBoundary + ? retireCoveredSessionLivePreview( + current, + previewBoundary ?? current, + bounded.coveredEntityIds, + bounded.transcript.messages, + ) + : current, + event, + previewBoundary, + transcript?.messages, + ), + ) + } else scheduleReconnect() + }) + .catch(() => { + if (!disposed && currentGeneration === generation) scheduleReconnect() + }) }, onReady: ({watermark}) => { + if (disposed || currentGeneration !== generation) return durable = completeSessionDurableEventReplay(durable, watermark) reconnectDelayMs = RECONNECT_INITIAL_DELAY_MS setReaderReady(true) onReadyChangeRef.current?.(true) }, onDisconnect: ({reconnect}) => { + if (disposed || currentGeneration !== generation) return close() - void adoptTranscript() + // Reconcile saved records and preview together during snapshot recovery. if (reconnect) scheduleReconnect() }, }) @@ -263,6 +351,7 @@ export const useSessionLivePreview = ({ if (reconnectTimer) clearTimeout(reconnectTimer) document.removeEventListener("visibilitychange", onVisibility) close() + clearPreview(sessionId) } }, [ clearPreview, @@ -277,15 +366,13 @@ export const useSessionLivePreview = ({ useEffect(() => { if (!preview.gapDetected) return - const retryHydration = retryHydrationRef.current - void Promise.resolve(onDisconnectRef.current()).then((adopted) => { - if (!adopted) retryHydration() - }, retryHydration) + retryHydrationRef.current() }, [preview.gapDetected]) return { messages: useMemo(() => sessionLivePreviewMessages(preview), [preview]), runningFromSnapshot: sharedReaderAdvertised && runningFromSnapshot, readerReady: sharedReaderAdvertised && subscribed && readerReady, + sharedSettledAt: sharedReaderAdvertised ? sharedSettledAt : 0, } } diff --git a/web/packages/agenta-chat/src/model/approvals.ts b/web/packages/agenta-chat/src/model/approvals.ts index bf308586c5b..102befbfe2a 100644 --- a/web/packages/agenta-chat/src/model/approvals.ts +++ b/web/packages/agenta-chat/src/model/approvals.ts @@ -48,11 +48,35 @@ export const getPendingApprovals = (messages: UIMessage[]): PendingApproval[] => const out: PendingApproval[] = [] for (const message of messages) { if (message.role !== "assistant") continue + const continuation = ( + message.metadata as + | { + approvalContinuation?: { + state?: string + approvalIds?: unknown + } + } + | undefined + )?.approvalContinuation + const terminalApprovalIds = + (continuation?.state === "done" || continuation?.state === "error") && + Array.isArray(continuation.approvalIds) + ? new Set( + continuation.approvalIds.filter( + (id): id is string => typeof id === "string" && id.length > 0, + ), + ) + : null const manifests = manifestsByToolCallId(message.parts) for (const part of message.parts ?? []) { const p = part as ToolUIPart const approval = (p as {approval?: ApprovalRef}).approval - if (isToolPart(p.type as string) && p.state === "approval-requested" && approval?.id) { + if ( + isToolPart(p.type as string) && + p.state === "approval-requested" && + approval?.id && + !terminalApprovalIds?.has(approval.id) + ) { out.push({ approvalId: approval.id, toolName: partToolName(p), diff --git a/web/packages/agenta-chat/src/model/error.ts b/web/packages/agenta-chat/src/model/error.ts index dd026f6ee86..d0eca58b4c1 100644 --- a/web/packages/agenta-chat/src/model/error.ts +++ b/web/packages/agenta-chat/src/model/error.ts @@ -82,7 +82,16 @@ export const parseAgentRunError = (err: unknown, serverErrorProvenance = false): ? (obj.message as string) : null if (message) { - return {message, code: typeof status?.code === "number" ? status.code : undefined} + const type = typeof status?.type === "string" ? status.type : undefined + const code = type?.endsWith("#continuation-resumed") + ? "continuation_resumed" + : typeof status?.code === "number" || typeof status?.code === "string" + ? status.code + : undefined + return { + message, + code, + } } } catch { // raw isn't JSON — it's already the human message. diff --git a/web/packages/agenta-chat/src/model/livePreview.ts b/web/packages/agenta-chat/src/model/livePreview.ts index 0e552eb9756..cd4831ed964 100644 --- a/web/packages/agenta-chat/src/model/livePreview.ts +++ b/web/packages/agenta-chat/src/model/livePreview.ts @@ -1,6 +1,7 @@ import { createSessionLivePreviewState, type SessionSnapshot, + type SessionDurableEvent, type SessionLiveFrame, type SessionLivePreviewExecution, type SessionLivePreviewState, @@ -33,9 +34,11 @@ export const shouldSubscribeToSessionLivePreview = ({ sender?: boolean }): boolean => sharedReaderAdvertised && (sender || runningElsewhere) -/** Choose the live activity treatment only while the shared reader is actually connected. */ +/** Run activity follows execution state, not whether its reader is connected. */ export const deriveRemoteTurnPresentation = ({ livenessRunning, + livenessUpdatedAt = Infinity, + sharedSettledAt = 0, snapshotRunning = false, sharedReaderAdvertised, readerReady, @@ -43,18 +46,22 @@ export const deriveRemoteTurnPresentation = ({ }: { /** Milestone-1 session-stream liveness; the only running source when the reader is disabled. */ livenessRunning: boolean + livenessUpdatedAt?: number + sharedSettledAt?: number /** 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 +}): {showActivity: boolean; showRemoteStop: boolean} => { + const livenessIsFresh = !sharedReaderAdvertised || livenessUpdatedAt > sharedSettledAt + const running = + (livenessRunning && livenessIsFresh) || + (sharedReaderAdvertised && (snapshotRunning || ownedContinuation)) return { - showActivity, - showStrip: running && !showActivity && !ownedContinuation, + showActivity: running, + showRemoteStop: running && !(sharedReaderAdvertised && readerReady) && !ownedContinuation, } } @@ -73,7 +80,7 @@ export const withoutSharedSenderAcceptanceMessages = (messages: UIMessage[]): UI /** 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?.session?.flags?.is_running === true && snapshot.execution != null && snapshot.execution.end_time == null @@ -153,18 +160,36 @@ export const reduceSessionLivePreview = ( state: SessionLivePreviewState, frame: SessionLiveFrame, ): SessionLivePreviewState => { - if (state.gapDetected) return state - const current = state.byExecution[frame.execution_id] + // Both timestamps originate at the runner: buffered frames can arrive after their done row. + if ( + current?.terminalCreatedAt && + Date.parse(frame.created_at) < Date.parse(current.terminalCreatedAt) + ) + return state 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 gap = Boolean(current && frame.frame_index !== expectedFrameIndex) + const incompleteEntityIds = new Set(current?.incompleteEntityIds ?? []) + if (gap) { + for (const [id, entity] of Object.entries(current?.byEntity ?? {})) { + if (entity.part.type === "text" || entity.part.type === "reasoning") + incompleteEntityIds.add(id) + } } + const isDelta = frame.type === "text-delta" || frame.type === "reasoning-delta" + if (isDelta && (gap || (!current?.byEntity[frame.entity_id] && frame.frame_index !== 0))) + incompleteEntityIds.add(frame.entity_id) + if (frame.type === "text-start" || frame.type === "reasoning-start") + incompleteEntityIds.delete(frame.entity_id) const previousPart = current?.byEntity[frame.entity_id]?.part - const nextPart = applyFrame(previousPart, frame) + const nextPart = current?.retiredEntityIds?.includes(frame.entity_id) + ? undefined + : incompleteEntityIds.has(frame.entity_id) && isDelta + ? previousPart + : applyFrame(previousPart, frame) const execution: SessionLivePreviewExecution = current ?? { entityOrder: [], byEntity: {}, @@ -175,10 +200,11 @@ export const reduceSessionLivePreview = ( executionOrder: current ? state.executionOrder : [...state.executionOrder, frame.execution_id], - gapDetected: false, + gapDetected: state.gapDetected || gap, byExecution: { ...state.byExecution, [frame.execution_id]: { + ...execution, entityOrder: nextPart && !previousPart ? [...execution.entityOrder, frame.entity_id] @@ -186,15 +212,170 @@ export const reduceSessionLivePreview = ( byEntity: nextPart ? { ...execution.byEntity, - [frame.entity_id]: {part: nextPart}, + [frame.entity_id]: { + part: nextPart, + complete: [ + "text-end", + "reasoning-end", + "tool-output-available", + "tool-output-error", + ].includes(frame.type), + }, } : execution.byEntity, lastFrameIndex: frame.frame_index, + incompleteEntityIds: [...incompleteEntityIds], + }, + }, + } +} + +/** A prompt boundary closes its old entities, but leaves their text visible until adoption. */ +export const markSessionLivePreviewTerminal = ( + state: SessionLivePreviewState, + event: Pick, +): SessionLivePreviewState => { + const executionId = event.execution_id + const execution = state.byExecution[executionId] ?? { + entityOrder: [], + byEntity: {}, + lastFrameIndex: -1, + } + return { + ...state, + executionOrder: state.executionOrder.includes(executionId) + ? state.executionOrder + : [...state.executionOrder, executionId], + byExecution: { + ...state.byExecution, + [executionId]: { + ...execution, + lastFrameIndex: -1, + terminalCreatedAt: event.created_at, + retiredEntityIds: [ + ...new Set([...(execution.retiredEntityIds ?? []), ...execution.entityOrder]), + ], + }, + }, + } +} + +/** Retire only output captured before adoption; a resumed prompt can already be streaming. */ +export const retireSessionLivePreview = ( + state: SessionLivePreviewState, + event: SessionDurableEvent, + boundary: SessionLivePreviewState = state, + adoptedMessages: UIMessage[] = [], +): SessionLivePreviewState => { + const terminal = ["execution.stopped", "execution.failed", "execution.lost"].includes( + event.type, + ) + const entityId = + event.type === "message.completed" + ? event.payload.message_id + : event.type === "tool.completed" + ? event.payload.tool_call_id + : undefined + if (!terminal && typeof entityId !== "string") return state + const execution = state.byExecution[event.execution_id] ?? { + entityOrder: [], + byEntity: {}, + lastFrameIndex: -1, + } + const captured = boundary.byExecution[event.execution_id] + const durableReasoning = new Set( + adoptedMessages.flatMap((message) => + message.parts.flatMap((part) => (part.type === "reasoning" ? [part.text] : [])), + ), + ) + const capturedReasoning = (captured?.entityOrder ?? []).filter((id) => { + const part = captured?.byEntity[id]?.part + return ( + captured?.byEntity[id]?.complete === true && + part?.type === "reasoning" && + durableReasoning.has(String(part.text)) + ) + }) + const candidates = terminal + ? (captured?.entityOrder ?? []) + : [entityId as string, ...capturedReasoning] + const retired = candidates.filter( + (id) => execution.byEntity[id]?.part === captured?.byEntity[id]?.part, + ) + const byEntity = {...execution.byEntity} + for (const id of retired) delete byEntity[id] + return { + ...state, + executionOrder: state.executionOrder.includes(event.execution_id) + ? state.executionOrder + : [...state.executionOrder, event.execution_id], + byExecution: { + ...state.byExecution, + [event.execution_id]: { + ...execution, + entityOrder: execution.entityOrder.filter((id) => !retired.includes(id)), + byEntity, + retiredEntityIds: [...new Set([...(execution.retiredEntityIds ?? []), ...retired])], }, }, } } +/** Reconcile a running snapshot without dropping parts absent from its committed record prefix. */ +export const retireCoveredSessionLivePreview = ( + state: SessionLivePreviewState, + boundary: SessionLivePreviewState, + coveredEntityIds: ReadonlySet, + adoptedMessages: UIMessage[], +): SessionLivePreviewState => { + const durableReasoning = new Set( + adoptedMessages.flatMap((message) => + message.parts.flatMap((part) => (part.type === "reasoning" ? [part.text] : [])), + ), + ) + const durableToolIds = new Set( + adoptedMessages.flatMap((message) => + message.parts.flatMap((part) => + (part.type === "dynamic-tool" || part.type.startsWith("tool-")) && + "toolCallId" in part && + "state" in part && + part.state !== "input-streaming" + ? [part.toolCallId] + : [], + ), + ), + ) + const byExecution = {...state.byExecution} + for (const executionId of boundary.executionOrder) { + const captured = boundary.byExecution[executionId] + const current = state.byExecution[executionId] + if (!captured || !current) continue + const retired = captured.entityOrder.filter((id) => { + const entity = captured.byEntity[id] + if (!entity || current.byEntity[id]?.part !== entity.part) return false + return ( + coveredEntityIds.has(id) || + ((entity.part.state === "input-streaming" || + entity.part.state === "input-available") && + durableToolIds.has(String(entity.part.toolCallId))) || + (entity.complete === true && + entity.part.type === "reasoning" && + durableReasoning.has(String(entity.part.text))) + ) + }) + if (!retired.length) continue + const byEntity = {...current.byEntity} + for (const id of retired) delete byEntity[id] + byExecution[executionId] = { + ...current, + byEntity, + entityOrder: current.entityOrder.filter((id) => !retired.includes(id)), + retiredEntityIds: [...new Set([...(current.retiredEntityIds ?? []), ...retired])], + } + } + return {...state, byExecution} +} + /** Build disposable UI messages from the collapsed entity state. */ export const sessionLivePreviewMessages = (state: SessionLivePreviewState): UIMessage[] => state.executionOrder.flatMap((executionId) => { diff --git a/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx b/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx index c3c64c5f8bf..1b4b411c9e7 100644 --- a/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx +++ b/web/packages/agenta-chat/tests/unit/ApprovalCard.test.tsx @@ -100,6 +100,57 @@ describe("the auto-approve row", () => { }) }) +describe("durable response state", () => { + it("shows that the answer was accepted while the continuation catches up", () => { + const markup = renderToStaticMarkup( + undefined} + onApproveAll={() => undefined} + />, + ) + + expect(markup).toContain("Answered, waiting for the agent") + expect(markup).toContain("The answer is saved") + // HeightCollapse keeps its child mounted for the leave animation, but removes it from + // layout, accessibility, and interaction while the answered state is visible. + expect(markup).toContain('aria-hidden="true" inert=""') + expect(markup).toContain('disabled=""') + }) + + it("keeps a failed answer pending and surfaces the retryable error", () => { + const markup = renderToStaticMarkup( + undefined} + onApproveAll={() => undefined} + />, + ) + + expect(markup).toContain("Needs your approval") + expect(markup).toContain("Approval failed. Please try again.") + expect(markup).toContain(">Approve<") + }) + + it("explains a recoverable 202 on the shared desktop and mobile card", () => { + const markup = renderToStaticMarkup( + undefined} + onApproveAll={() => undefined} + />, + ) + + expect(markup).toContain("Answer saved, retry needed") + expect(markup).toContain("Send your next message to retry the continuation") + }) +}) + describe("granting a batch", () => { const mount = (approvals: {approvalId: string; toolName: string; input: unknown}[]) => { const host = document.createElement("div") diff --git a/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx b/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx new file mode 100644 index 00000000000..f765d80d7e0 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/ChatComposer.runControls.test.tsx @@ -0,0 +1,169 @@ +/** + * @vitest-environment jsdom + */ +import {cleanup, fireEvent, render, screen} from "@testing-library/react" +import {afterEach, describe, expect, it, vi} from "vitest" + +import {DEFAULT_ATTACHMENT_LIMITS} from "../../src/assets/attachmentRules" +import {isComposerRunStoppable} from "../../src/assets/composerRunState" +import {ChatComposer} from "../../src/components/ChatComposer" +import QueuedMessagesDock from "../../src/components/QueuedMessagesDock" +import type {useComposerAttachments} from "../../src/hooks/useComposerAttachments" + +afterEach(cleanup) + +const attachments = { + uploadsEnabled: false, + files: [], + rejections: [], + limits: DEFAULT_ATTACHMENT_LIMITS, + atMax: false, + attachmentsSettled: true, + uploadBlockReason: undefined, + addFiles: vi.fn(), + removeFile: vi.fn(), + dismissRejection: vi.fn(), + uploads: {retry: vi.fn(), canRetry: vi.fn()}, +} as unknown as ReturnType + +const renderComposer = async ({ + localStreaming, + serverBusy = false, + serverControlEnabled = false, + queued = false, + busyActions, +}: { + localStreaming: boolean + serverBusy?: boolean + serverControlEnabled?: boolean + queued?: boolean + busyActions?: {label: string; onSubmit: (text: string) => void}[] +}) => { + const onStop = vi.fn() + const streaming = isComposerRunStoppable({ + localStreaming, + serverBusy, + serverControlEnabled, + waitingOnUser: false, + }) + render( + <> + {queued ? ( + + ) : null} + + , + ) + await screen.findByRole("button", {name: "Stop"}, {timeout: 5_000}) + return onStop +} + +describe("ChatComposer running controls", () => { + it("keeps Stop and Escape on a fresh session's first running turn", async () => { + const onStop = await renderComposer({localStreaming: true}) + + expect(screen.getByRole("button", {name: "Stop"}).getAttribute("aria-keyshortcuts")).toBe( + "Escape", + ) + fireEvent.keyDown(document, {key: "Escape"}) + expect(onStop).toHaveBeenCalledOnce() + }) + + it("keeps Stop and Escape beside Queue and Steer for a durable queued turn", async () => { + const onStop = await renderComposer({ + localStreaming: false, + serverBusy: true, + serverControlEnabled: true, + queued: true, + busyActions: [ + {label: "Queue", onSubmit: vi.fn()}, + {label: "Steer", onSubmit: vi.fn()}, + ], + }) + + expect(screen.getByText("1 queued message")).toBeTruthy() + expect(screen.getByRole("button", {name: "Queue"})).toBeTruthy() + expect(screen.getByRole("button", {name: "Steer"})).toBeTruthy() + expect(screen.getByRole("button", {name: "Stop"})).toBeTruthy() + fireEvent.keyDown(document, {key: "Escape"}) + expect(onStop).toHaveBeenCalledOnce() + }) + + it("keeps a flag-off remote run out of the desktop composer controls", () => { + const onStop = vi.fn() + const streaming = isComposerRunStoppable({ + localStreaming: false, + serverBusy: true, + serverControlEnabled: false, + waitingOnUser: false, + }) + + render( + , + ) + + expect(screen.queryByRole("button", {name: "Stop"})).toBeNull() + fireEvent.keyDown(document, {key: "Escape"}) + expect(onStop).not.toHaveBeenCalled() + }) +}) + +describe("queued row Send Now", () => { + it("targets the chosen row and retains every row when admission fails", async () => { + const sendNow = vi.fn().mockRejectedValue(new Error("unavailable")) + const remove = vi.fn() + render( + , + ) + fireEvent.click(screen.getAllByRole("button", {name: "Send Now"})[1]) + expect(await screen.findByRole("alert")).toBeTruthy() + expect(sendNow).toHaveBeenCalledWith("selected") + expect(remove).not.toHaveBeenCalled() + expect(screen.getByText("older message")).toBeTruthy() + expect(screen.getByText("chosen message")).toBeTruthy() + sendNow.mockResolvedValueOnce(undefined) + fireEvent.click(screen.getAllByRole("button", {name: "Send Now"})[1]) + expect(sendNow).toHaveBeenCalledTimes(2) + }) + + it("does not offer the server action on a local fallback row", () => { + render( + , + ) + expect(screen.queryByRole("button", {name: "Send Now"})).toBeNull() + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/QueuedMessagesDock.test.tsx b/web/packages/agenta-chat/tests/unit/QueuedMessagesDock.test.tsx new file mode 100644 index 00000000000..3ad9a88d939 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/QueuedMessagesDock.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +import {renderToStaticMarkup} from "react-dom/server" +import {cleanup, fireEvent, render, screen} from "@testing-library/react" +import {afterEach, describe, expect, it, vi} from "vitest" + +afterEach(cleanup) + +import QueuedMessagesDock from "../../src/components/QueuedMessagesDock" + +describe("QueuedMessagesDock", () => { + it("explains that a held message waits for the open answer", () => { + const markup = renderToStaticMarkup( + undefined} + />, + ) + + expect(markup).toContain("1 queued message · waits for your answer") + expect(markup).toContain("continue afterward") + }) +}) + +it.each([false, true])( + "keeps cancel editing reachable after the edited row leaves (touch=%s)", + (touch) => { + const cancel = vi.fn() + const props = {onRemove: vi.fn(), onCancelEdit: cancel, editingId: "edited", touch} + const {rerender} = render( + , + ) + fireEvent.click(screen.getByRole("button", {name: "Collapse"})) + rerender() + expect(screen.getByText("This message is no longer queued.")).toBeTruthy() + fireEvent.click(screen.getByRole("button", {name: "Cancel editing"})) + expect(cancel).toHaveBeenCalledOnce() + expect(props.onRemove).not.toHaveBeenCalled() + }, +) diff --git a/web/packages/agenta-chat/tests/unit/assets/__fixtures__/approvalDockRetirement.records.json b/web/packages/agenta-chat/tests/unit/assets/__fixtures__/approvalDockRetirement.records.json new file mode 100644 index 00000000000..a1b77b1d01e --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/__fixtures__/approvalDockRetirement.records.json @@ -0,0 +1,231 @@ +[ + { + "id": "df76c981-7098-49b9-92ad-d5f2fc759580", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "user", + "session_update": "message", + "payload": { + "text": "Run the shell command: echo inc6-r8-dock. Report its exact output.", + "type": "message", + "attachments": [] + }, + "turn_id": "9ad2aa67-77bf-41a1-b2a6-90eadda1a3d7", + "created_at": "2026-09-04 20:31:43.438+00" + }, + { + "id": "09dd3c78-7e32-4ee9-b084-4d30d3117505", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 1, + "sender": "agent", + "session_update": "thought", + "payload": { + "text": "The user wants me to run a simple shell command and report its output.", + "type": "thought" + }, + "turn_id": "9ad2aa67-77bf-41a1-b2a6-90eadda1a3d7", + "created_at": "2026-09-04 20:31:51.665+00" + }, + { + "id": "a5d965ef-3703-5097-bdaf-49a68f733a7d", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 2, + "sender": "agent", + "session_update": "tool_call", + "payload": { + "id": "call_616b5f199712455793b6d76e", + "name": "bash", + "type": "tool_call", + "input": { + "command": "echo inc6-r8-dock" + } + }, + "turn_id": "9ad2aa67-77bf-41a1-b2a6-90eadda1a3d7", + "created_at": "2026-09-04 20:31:51.846+00" + }, + { + "id": "ea6333e4-cab3-542f-8842-d61996291ada", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 3, + "sender": "agent", + "session_update": "interaction_request", + "payload": { + "id": "995951ee-bfec-4ef3-bd82-ea8bd0bbe313", + "kind": "user_approval", + "type": "interaction_request", + "payload": { + "options": [ + { + "kind": "allow_once", + "name": "Yes", + "optionId": "yes" + }, + { + "kind": "reject_once", + "name": "No", + "optionId": "no" + } + ], + "toolCall": { + "kind": "other", + "title": "agenta-approval", + "status": "pending", + "rawInput": { + "command": "echo inc6-r8-dock" + }, + "toolCallId": "call_616b5f199712455793b6d76e", + "resolvedName": "Bash" + }, + "toolCallId": "call_616b5f199712455793b6d76e", + "availableReplies": ["once", "reject"] + } + }, + "turn_id": "9ad2aa67-77bf-41a1-b2a6-90eadda1a3d7", + "created_at": "2026-09-04 20:31:51.86+00" + }, + { + "id": "663d522e-c1c6-40e8-8363-6bd3dcab9cc7", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 4, + "sender": "agent", + "session_update": "done", + "payload": { + "type": "done", + "traceId": "27ec280560f31d2d40fc235c58c9f33e", + "stopReason": "paused" + }, + "turn_id": "9ad2aa67-77bf-41a1-b2a6-90eadda1a3d7", + "created_at": "2026-09-04 20:31:51.87+00" + }, + { + "id": "43b9b722-287b-43d1-98d3-176358c34397", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "agent", + "session_update": "thought", + "payload": { + "text": "The user approved the bash command and wants me to execute it now.", + "type": "thought" + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:31:59.872+00" + }, + { + "id": "38dc3726-352c-5298-be69-dee73acc5be4", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 1, + "sender": "agent", + "session_update": "tool_call", + "payload": { + "id": "call_730a4e6cf1ba4acfa98d3fbc", + "name": "bash", + "type": "tool_call", + "input": { + "command": "echo inc6-r8-dock" + } + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:00.899+00" + }, + { + "id": "eeb0ad94-215f-58a6-b2d3-840f47e9c751", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 2, + "sender": "agent", + "session_update": "interaction_response", + "payload": { + "id": "995951ee-bfec-4ef3-bd82-ea8bd0bbe313", + "kind": "user_approval", + "type": "interaction_response", + "payload": { + "approved": true, + "toolCallId": "call_730a4e6cf1ba4acfa98d3fbc" + } + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:00.908+00" + }, + { + "id": "73df09a2-7a8f-5075-80a4-09e40955deea", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 3, + "sender": "agent", + "session_update": "tool_result", + "payload": { + "id": "call_730a4e6cf1ba4acfa98d3fbc", + "type": "tool_result", + "output": "inc6-r8-dock\n", + "isError": false + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:00.916+00" + }, + { + "id": "393b1fdb-131d-48aa-9149-73628a51c063", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 4, + "sender": "agent", + "session_update": "thought", + "payload": { + "text": "The command executed successfully. The exact output is \"inc6-r8-dock\".", + "type": "thought" + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:04.304+00" + }, + { + "id": "8ab873ee-67ba-44ad-b1d7-e37b33c2856e", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 5, + "sender": "agent", + "session_update": "usage", + "payload": { + "cost": 0.000369396, + "type": "usage", + "input": 3296, + "total": 6465, + "output": 97 + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:05.333+00" + }, + { + "id": "5c35ae31-eae1-48cd-986a-e540be258889", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 6, + "sender": "agent", + "session_update": "message", + "payload": { + "text": "The exact output is:\n\n```\ninc6-r8-dock\n```", + "type": "message" + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:05.344+00" + }, + { + "id": "2036b6a3-f853-42f4-9beb-e2c7b31673e1", + "session_id": "973bfdbd-0226-477d-865a-479f4c3e3db1", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 7, + "sender": "agent", + "session_update": "done", + "payload": { + "type": "done", + "traceId": "114dca2ee59c63cf28c8a8dd83bb31ad" + }, + "turn_id": "3c40c8ce-7e4a-49c7-909d-0aac09346291", + "created_at": "2026-09-04 20:32:05.349+00" + } +] diff --git a/web/packages/agenta-chat/tests/unit/assets/__fixtures__/heldMessageDuringContinuation.records.json b/web/packages/agenta-chat/tests/unit/assets/__fixtures__/heldMessageDuringContinuation.records.json new file mode 100644 index 00000000000..2b7d8fe81cc --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/__fixtures__/heldMessageDuringContinuation.records.json @@ -0,0 +1,252 @@ +[ + { + "id": "3e5b0402-9c55-4aee-b01c-258a7cd5c53c", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "user", + "session_update": "message", + "payload": { + "text": "Run this shell command exactly once: sleep 25 && echo inc6-r8-slow. Report its exact output when it finishes.", + "type": "message", + "attachments": [] + }, + "turn_id": "7d7efee2-09af-4503-b8dd-4282569ed2c8", + "created_at": "2026-09-04 20:27:14.208+00" + }, + { + "id": "098ce6b7-aeb4-45ad-b841-b25a55771f1a", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 1, + "sender": "agent", + "session_update": "thought", + "payload": { + "text": "The user wants me to run a shell command that sleeps for 25 seconds and then echoes a string. Let me execute it with a timeout long enough to cover the 25-second sleep.", + "type": "thought" + }, + "turn_id": "7d7efee2-09af-4503-b8dd-4282569ed2c8", + "created_at": "2026-09-04 20:27:22.441+00" + }, + { + "id": "7ad1f0f9-333a-5133-9a09-d532bac1b880", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 2, + "sender": "agent", + "session_update": "tool_call", + "payload": { + "id": "call_d5db7a06b78e45078475f501", + "name": "bash", + "type": "tool_call", + "input": { + "command": "sleep 25 && echo inc6-r8-slow", + "timeout": 60 + } + }, + "turn_id": "7d7efee2-09af-4503-b8dd-4282569ed2c8", + "created_at": "2026-09-04 20:27:23.872+00" + }, + { + "id": "6e072490-fc95-54b6-a3d7-d74f3d764f42", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 3, + "sender": "agent", + "session_update": "interaction_request", + "payload": { + "id": "bd87965d-f19c-4a4e-9bff-cd36a3eb2830", + "kind": "user_approval", + "type": "interaction_request", + "payload": { + "options": [ + { + "kind": "allow_once", + "name": "Yes", + "optionId": "yes" + }, + { + "kind": "reject_once", + "name": "No", + "optionId": "no" + } + ], + "toolCall": { + "kind": "other", + "title": "agenta-approval", + "status": "pending", + "rawInput": { + "command": "sleep 25 && echo inc6-r8-slow", + "timeout": 60 + }, + "toolCallId": "call_d5db7a06b78e45078475f501", + "resolvedName": "Bash" + }, + "toolCallId": "call_d5db7a06b78e45078475f501", + "availableReplies": ["once", "reject"] + } + }, + "turn_id": "7d7efee2-09af-4503-b8dd-4282569ed2c8", + "created_at": "2026-09-04 20:27:23.888+00" + }, + { + "id": "fe72acb2-29fe-4529-b146-6cf4334d5e89", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 4, + "sender": "agent", + "session_update": "done", + "payload": { + "type": "done", + "traceId": "97e22b7f8e005905dc4234527a59bf12", + "stopReason": "paused" + }, + "turn_id": "7d7efee2-09af-4503-b8dd-4282569ed2c8", + "created_at": "2026-09-04 20:27:23.899+00" + }, + { + "id": "beb812b6-b22c-4a5f-8609-bf3dd07f4c61", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "agent", + "session_update": "thought", + "payload": { + "text": "The user approved the bash call. I need to execute the exact same command again.", + "type": "thought" + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:07.439+00" + }, + { + "id": "b1d41b96-eedb-50b9-8edb-f69283a54829", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 1, + "sender": "agent", + "session_update": "tool_call", + "payload": { + "id": "call_2f8bcacc4e65494ba5141733", + "name": "bash", + "type": "tool_call", + "input": { + "command": "sleep 25 && echo inc6-r8-slow", + "timeout": 60 + } + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:08.223+00" + }, + { + "id": "f9fefda8-a2c4-563a-819e-680d50d95d58", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 2, + "sender": "agent", + "session_update": "interaction_response", + "payload": { + "id": "bd87965d-f19c-4a4e-9bff-cd36a3eb2830", + "kind": "user_approval", + "type": "interaction_response", + "payload": { + "approved": true, + "toolCallId": "call_2f8bcacc4e65494ba5141733" + } + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:08.24+00" + }, + { + "id": "e5d8373d-aba6-4e2c-9297-1f0ddf0c83e7", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "user", + "session_update": "message", + "payload": { + "text": "Then reply with the marker inc6-r8-held.", + "type": "message", + "attachments": [] + }, + "turn_id": "7e22e78c-c15e-4d49-b528-adfc54ffd1b9", + "created_at": "2026-09-04 20:28:08.298+00" + }, + { + "id": "6279bf85-7443-5daf-8fe4-8c1fa58b89a0", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 3, + "sender": "agent", + "session_update": "tool_result", + "payload": { + "id": "call_2f8bcacc4e65494ba5141733", + "type": "tool_result", + "output": "Command aborted", + "isError": true + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:08.331+00" + }, + { + "id": "73e9a0e5-24f8-47f4-aa36-c5d5bd1f6d44", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 4, + "sender": "agent", + "session_update": "usage", + "payload": { + "cost": 0.00029988, + "type": "usage", + "input": 3156, + "total": 3244, + "output": 88 + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:09.086+00" + }, + { + "id": "401f61ae-ce9c-43bc-bf7d-479c4d16ee81", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 5, + "sender": "agent", + "session_update": "done", + "payload": { + "type": "done", + "traceId": "4de387f5cdaceedc4c8ba68a88d682a5", + "stopReason": "cancelled" + }, + "turn_id": "943f3c99-5816-4a46-b6e3-7a10fe587575", + "created_at": "2026-09-04 20:28:09.098+00" + }, + { + "id": "00159e20-1069-5b01-a977-2025f4da53dc", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 0, + "sender": "agent", + "session_update": "error", + "payload": { + "code": "execution_lost", + "type": "error", + "message": "The agent stopped responding and the run was closed. Send the message again to retry.", + "settled_by": "watchdog" + }, + "turn_id": "7e22e78c-c15e-4d49-b528-adfc54ffd1b9", + "created_at": "2026-09-04 20:30:23.941613+00" + }, + { + "id": "8aebaa8d-e653-52dc-abd7-90abbf4eeca1", + "session_id": "9d40cfcc-6485-4250-8d2e-17f1f12f55f4", + "project_id": "01a06d32-a122-79f2-8d44-161f16791461", + "event_index": 1, + "sender": "agent", + "session_update": "done", + "payload": { + "type": "done", + "settled_by": "watchdog" + }, + "turn_id": "7e22e78c-c15e-4d49-b528-adfc54ffd1b9", + "created_at": "2026-09-04 20:30:23.942613+00" + } +] diff --git a/web/packages/agenta-chat/tests/unit/assets/committedRevisions.test.ts b/web/packages/agenta-chat/tests/unit/assets/committedRevisions.test.ts new file mode 100644 index 00000000000..849240e608d --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/committedRevisions.test.ts @@ -0,0 +1,75 @@ +import type {SessionRecord} from "@agenta/entities/session" +import {describe, expect, it} from "vitest" +import {liveCommittedRevisions} from "../../../src/assets/committedRevisions" + +const output = { + status: "committed", + workflow_revision: { + id: "01a0740f-777a-79e3-90cf-da5cf00adba7", + workflow_variant_id: "01a07403-a0b6-7b03-ab5a-a8380ddc80ea", + version: "2", + }, +} +const row = (sequence: number, payload: Record): SessionRecord => ({ + id: `row-${sequence}`, + session_id: "session-1", + project_id: "project-1", + sequence, + event_index: sequence, + sender: "agent", + session_update: String(payload.type), + payload, + created_at: null, +}) +const records = ( + name = "commit_revision", + result: Record = {output: JSON.stringify(output)}, +) => [ + row(24, {type: "tool_call", id: "call-1", name, input: {}}), + row(25, {type: "tool_result", id: "call-1", ...result}), +] + +describe("liveCommittedRevisions", () => { + it.each([ + "commit_revision", + "mcp__agenta-tools__commit_revision", + "mcp.agenta-tools.commit_revision", + ])("projects the captured successful output for %s", (name) => { + expect(liveCommittedRevisions(records(name), 23)).toEqual([ + { + revisionId: output.workflow_revision.id, + variantId: output.workflow_revision.workflow_variant_id, + version: "2", + }, + ]) + }) + it("keeps initial and reopened history inert", () => { + expect(liveCommittedRevisions(records())).toEqual([]) + expect(liveCommittedRevisions(records(), 25)).toEqual([]) + }) + it("ignores failed, denied, malformed and unrelated tool results", () => { + for (const result of [ + {output, isError: true}, + {output, denied: true}, + {output: "invalid json"}, + {output: {status: "committed"}}, + ]) + expect(liveCommittedRevisions(records("commit_revision", result), 23)).toEqual([]) + expect(liveCommittedRevisions(records("other_tool"), 23)).toEqual([]) + }) + it("supports the legacy successful result and deduplicates a revision", () => { + const legacy = { + count: 1, + workflow_revision: {revision_id: "rev-2", variant_id: "var-1", version: 2}, + } + expect( + liveCommittedRevisions( + [ + ...records("commit_revision", {data: legacy}), + ...records("commit_revision", {data: legacy}), + ], + 23, + ), + ).toEqual([{revisionId: "rev-2", variantId: "var-1", version: "2"}]) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/assets/continuationPreflight.test.ts b/web/packages/agenta-chat/tests/unit/assets/continuationPreflight.test.ts new file mode 100644 index 00000000000..a56f27d8920 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/continuationPreflight.test.ts @@ -0,0 +1,56 @@ +import {describe, expect, it, vi} from "vitest" + +import { + assertNoResumedSessionContinuation, + prepareAfterContinuationPreflight, +} from "../../../src/assets/continuationPreflight" +import {parseAgentRunError} from "../../../src/model/error" + +describe("assertNoResumedSessionContinuation", () => { + it("allows the ordinary request when the API did not resume a continuation", async () => { + await expect( + assertNoResumedSessionContinuation(vi.fn().mockResolvedValue(false), "session-1"), + ).resolves.toBeUndefined() + }) + + it("throws the retryable typed error when the saved continuation owns the turn", async () => { + let error: unknown + try { + await assertNoResumedSessionContinuation(vi.fn().mockResolvedValue(true), "session-1") + } catch (caught) { + error = caught + } + + expect(parseAgentRunError(error)).toEqual({ + code: "continuation_resumed", + message: + "A saved approval is resuming. Wait for it to finish, then try this message again.", + }) + }) + + it("never builds a request when the continuation takes ownership", async () => { + const prepare = vi.fn().mockResolvedValue({body: "must not run"}) + + await expect( + prepareAfterContinuationPreflight( + vi.fn().mockResolvedValue(true), + "session-1", + prepare, + ), + ).rejects.toThrow("continuation_resumed") + expect(prepare).not.toHaveBeenCalled() + }) + + it("still builds a request when the additive preflight transport fails", async () => { + const prepare = vi.fn().mockResolvedValue({body: "ordinary send"}) + + await expect( + prepareAfterContinuationPreflight( + vi.fn().mockRejectedValue(new Error("older API")), + "session-1", + prepare, + ), + ).resolves.toEqual({body: "ordinary send"}) + expect(prepare).toHaveBeenCalledOnce() + }) +}) 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 efcc3dfa734..6435de7f4f2 100644 --- a/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/loadSession.test.ts @@ -1,4 +1,5 @@ import type {SessionInteractionRowState, SessionRecord} from "@agenta/entities/session" +import type {UIMessage} from "ai" import {atom} from "jotai" import {beforeEach, describe, expect, it, vi} from "vitest" @@ -12,6 +13,7 @@ let interactionRowStates = new Map() vi.mock("@agenta/entities/session", () => ({ fetchSessionRecordsAtom: atom(null, async () => fetchResult), fetchSessionInteractionStatesAtom: atom(null, async () => interactionRowStates), + revalidateSessionInteractionsAtom: atom(null, async () => undefined), })) const {loadSessionMessages} = await import("../../../src/assets/loadSession") @@ -25,9 +27,34 @@ const record = (id: string, payload: Record, sender = "agent"): sender, session_update: String(payload.type), payload, + turn_id: null, created_at: null, }) +const approvalRecords = (): SessionRecord[] => [ + record("r-call", {type: "tool_call", id: "call-1", name: "bash", input: {command: "ls"}}), + record("r-gate", { + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "call-1"}, + }), + record("r-paused", {type: "done", stopReason: "paused"}), +] + +const approvalRow = (status: "pending" | "responded" | "resolved") => + new Map([ + [ + "approval-1", + { + token: "approval-1", + status, + kind: "user_approval" as const, + toolCallId: "call-1", + }, + ], + ]) + describe("loadSessionMessages", () => { beforeEach(() => { fetchResult = {records: null} @@ -53,6 +80,20 @@ describe("loadSessionMessages", () => { expect(transcript?.messages[0]).toMatchObject({parts: [{type: "text", text: "hi"}]}) }) + it.each(["responded", "resolved"] as const)( + "retires a replayed gate whose interaction row is %s", + async (status) => { + fetchResult = {records: approvalRecords()} + interactionRowStates = approvalRow(status) + + const transcript = await loadSessionMessages("session-1") + + expect(transcript?.messages.flatMap((message) => message.parts)).not.toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + }, + ) + // The adoption watermark: records, not messages — a turn that grows in place keeps its // message count (issue #5530), so only this number sees the log move. it("reports how many records the transcript was built from", async () => { @@ -91,10 +132,9 @@ describe("loadSessionMessages", () => { } const onRefreshed = vi.fn() await loadSessionMessages("session-1", onRefreshed) - // `refreshed` resolves asynchronously after the function returns — flush microtasks. - await Promise.resolve() - await Promise.resolve() - expect(onRefreshed).toHaveBeenCalledTimes(1) + // `refreshed` resolves asynchronously after the function returns and refreshes the row + // join before delivery. + await vi.waitFor(() => expect(onRefreshed).toHaveBeenCalledTimes(1)) const delivered = onRefreshed.mock.calls[0][0] as { messages: {parts: unknown}[] recordCount: number @@ -104,6 +144,31 @@ describe("loadSessionMessages", () => { expect(delivered.recordCount).toBe(2) }) + it("joins refreshed records with refreshed interaction rows", async () => { + let resolveRecords: ((records: SessionRecord[]) => void) | undefined + fetchResult = { + records: approvalRecords(), + refreshed: new Promise((resolve) => { + resolveRecords = resolve + }), + } + interactionRowStates = approvalRow("pending") + const onRefreshed = vi.fn() + const initial = await loadSessionMessages("session-1", onRefreshed) + expect(initial?.messages.flatMap((message) => message.parts)).toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + + interactionRowStates = approvalRow("responded") + resolveRecords?.(approvalRecords()) + await vi.waitFor(() => expect(onRefreshed).toHaveBeenCalledOnce()) + + const refreshed = onRefreshed.mock.calls[0][0] + expect(refreshed.messages.flatMap((message: UIMessage) => message.parts)).not.toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + }) + // The chain outlives the call, so the function's own try/catch never sees a rejection here. it("survives a rejected background revalidation without an unhandled rejection", async () => { const unhandled = vi.fn() diff --git a/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts new file mode 100644 index 00000000000..34ec2dea640 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/pendingInputs.test.ts @@ -0,0 +1,155 @@ +import {describe, expect, it} from "vitest" + +import { + pendingInputToQueuedMessage, + reduceSessionPendingInputs, +} from "../../../src/assets/pendingInputs" + +const input = ( + id: string, + position: number, + content: unknown, + policy: "queue" | "steer" = "queue", + state: "pending" | "promoted" = "pending", +) => ({ + id, + session_id: "session-1", + content: {data: {inputs: {messages: [{role: "user", content}]}}}, + position, + state, + policy, + created_at: null, + promoted_execution_id: state === "promoted" ? "continuation-1" : null, +}) + +describe("pending input reducer", () => { + it("orders the server snapshot and preserves Steer priority", () => { + const view = reduceSessionPendingInputs({ + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "run-1", state: "stopping"}, + read: {latest_sequence: 0, history_complete: true}, + pending: { + inputs: [input("older", 20, "queued"), input("steer", 10, "redirect", "steer")], + interactions: [], + }, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + + expect(view.executionState).toBe("stopping") + expect(view.capabilities).toEqual({queue: true, steer: true}) + expect(view.queued.map(({id, policy}) => [id, policy])).toEqual([ + ["steer", "steer"], + ["older", "queue"], + ]) + }) + + it("makes pending rows editable and retains uploaded attachment references", () => { + const queued = pendingInputToQueuedMessage( + input("input-1", 1, [ + {type: "text", text: "Check this"}, + {type: "attachment", attachment_id: "asset-1", filename: "brief.pdf"}, + {type: "image", uri: "https://files.test/image.png", mime_type: "image/png"}, + ]), + ) + + expect(queued).toMatchObject({ + id: "input-1", + text: "Check this", + attachmentCount: 2, + source: "server", + editable: true, + }) + expect(queued?.fileParts).toEqual([ + { + type: "file", + url: expect.stringContaining( + "/sessions/attachments/asset-1/content?session_id=session-1", + ), + mediaType: "application/octet-stream", + filename: "brief.pdf", + providerMetadata: {agenta: {attachmentId: "asset-1"}}, + }, + { + type: "file", + url: "https://files.test/image.png", + mediaType: "image/png", + filename: undefined, + }, + ]) + }) + + it.each(["content", "parts"])("preserves durable file identity from %s", (field) => { + const attachmentId = "01995d1a-2f83-7c4d-8a6b-123456789abc" + const row = input("input-1", 1, "") + const block = + field === "content" + ? { + type: "attachment", + attachmentId, + mimeType: "text/plain", + filename: "notes.txt", + size: 42, + } + : { + type: "file", + url: "https://old-host.test/content", + mediaType: "text/plain", + filename: "notes.txt", + providerMetadata: {agenta: {attachmentId, size: 42}}, + } + row.content.data.inputs.messages = [ + {role: "user", [field]: [block]}, + ] as typeof row.content.data.inputs.messages + const queued = pendingInputToQueuedMessage(row) + expect(queued?.fileParts).toEqual([ + { + type: "file", + url: expect.stringContaining( + `/sessions/attachments/${attachmentId}/content?session_id=session-1`, + ), + mediaType: "text/plain", + filename: "notes.txt", + providerMetadata: {agenta: {attachmentId, size: 42}}, + }, + ]) + }) + + it("keeps a promoted input visible while its continuation is recoverable", () => { + const recoverable = input("input-1", 1, "retry me", "queue", "promoted") + + const view = reduceSessionPendingInputs({ + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: null, state: "idle"}, + read: {latest_sequence: 0, history_complete: true}, + pending: {inputs: [recoverable], interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + + expect(view.queued).toEqual([ + expect.objectContaining({ + id: "input-1", + text: "retry me", + source: "server", + editable: false, + }), + ]) + }) + + it("defaults an absent or failed snapshot to the legacy client queue", () => { + expect(reduceSessionPendingInputs(null)).toEqual({ + capabilities: {queue: false, steer: false}, + executionState: "idle", + queued: [], + }) + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts b/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts new file mode 100644 index 00000000000..a8e5b0b1c2c --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/assets/serverOwnedApproval.test.ts @@ -0,0 +1,72 @@ +import {describe, expect, it, vi} from "vitest" + +import { + submitApprovalForCapability, + submitServerOwnedApproval, +} from "../../../src/assets/serverOwnedApproval" + +describe("submitServerOwnedApproval", () => { + it("retires local resume ownership after a successful response", async () => { + const retire = vi.fn() + + await expect( + submitServerOwnedApproval({submit: () => Promise.resolve("accepted"), retire}), + ).resolves.toBe("accepted") + expect(retire).toHaveBeenCalledOnce() + }) + + it("retires local resume ownership when a committed response may have been lost", async () => { + const retire = vi.fn() + const lostResponse = new Error("connection closed") + + await expect( + submitServerOwnedApproval({submit: () => Promise.reject(lostResponse), retire}), + ).rejects.toBe(lostResponse) + expect(retire).toHaveBeenCalledOnce() + }) +}) + +describe("submitApprovalForCapability", () => { + it("uses the legacy row transition and local gate release when capability is off", async () => { + const submitDurable = vi.fn() + const retireDurable = vi.fn() + const recordLegacy = vi.fn().mockResolvedValue(undefined) + const releaseLegacy = vi.fn() + + await expect( + submitApprovalForCapability({ + durableApprovals: false, + submitDurable, + retireDurable, + recordLegacy, + releaseLegacy, + }), + ).resolves.toEqual({durable: false, recoverable: false}) + + expect(submitDurable).not.toHaveBeenCalled() + expect(retireDurable).not.toHaveBeenCalled() + expect(recordLegacy).toHaveBeenCalledOnce() + expect(releaseLegacy).toHaveBeenCalledOnce() + }) +}) + +it("retires only local ownership when capability discovery fails before answering", async () => { + const failure = new Error("Session is unavailable") + const submitDurable = vi.fn() + const retireDurable = vi.fn() + const recordLegacy = vi.fn() + const releaseLegacy = vi.fn() + await expect( + submitApprovalForCapability({ + durableApprovals: Promise.reject(failure), + submitDurable, + retireDurable, + recordLegacy, + releaseLegacy, + }), + ).rejects.toBe(failure) + expect(retireDurable).toHaveBeenCalledOnce() + expect(submitDurable).not.toHaveBeenCalled() + expect(recordLegacy).not.toHaveBeenCalled() + expect(releaseLegacy).not.toHaveBeenCalled() +}) diff --git a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts index 28e76ebbc56..746bb186c06 100644 --- a/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts +++ b/web/packages/agenta-chat/tests/unit/assets/transcriptToMessages.test.ts @@ -3,17 +3,25 @@ import type { SessionInteractionRowStates, SessionRecord, } from "@agenta/entities/session" +import {interactionStatesFromWatchEvent} from "@agenta/entities/session" import {CLIENT_TOOL_INTERACTION_ENDED_OUTPUT} from "@agenta/shared/clientTools" +import type {UIMessage} from "ai" import {describe, expect, it} from "vitest" import { APPROVED_EXECUTION_RESULT_UNKNOWN, + reconcileInteractionRowStates, transcriptToMessages, } from "../../../src/assets/transcriptToMessages" import abandonedFormSession from "./__fixtures__/abandonedFormSession.json" -const record = (id: string, payload: Record, sender = "agent"): SessionRecord => ({ +const record = ( + id: string, + payload: Record, + sender = "agent", + turnId: string | null = null, +): SessionRecord => ({ id, session_id: "session-1", project_id: "project-1", @@ -21,9 +29,17 @@ const record = (id: string, payload: Record, sender = "agent"): sender, session_update: String(payload.type), payload, + turn_id: turnId, created_at: null, }) +const firstAssistantMetadata = ( + messages: UIMessage[] | null, +): Record | undefined => + messages?.find((message) => message.role === "assistant")?.metadata as + | Record + | undefined + describe("transcriptToMessages", () => { it("replays the approved-content manifest as the egress's sibling data part", () => { // `tool-approval-request` is a strict object, so the manifest cannot ride the approval @@ -242,6 +258,152 @@ const approvalRecords = (): SessionRecord[] => [ * turn the user already answered. */ describe("transcriptToMessages approval resume", () => { + it("retires tab A's pending card on the first continuation frame without a response event", () => { + const pendingRecords = [ + record("r-user", {type: "message", text: "run it"}, "user", "source-turn"), + record( + "r-call", + {type: "tool_call", id: "tool-1", name: "bash", input: {}}, + "agent", + "source-turn", + ), + record( + "r-req", + { + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-1"}, + }, + "agent", + "source-turn", + ), + record("r-source-done", {type: "done", stopReason: "paused"}, "agent", "source-turn"), + ] + const pendingParts = transcriptToMessages(pendingRecords)![1].parts as unknown as Record< + string, + unknown + >[] + expect(pendingParts).toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + + const continuationRunning = transcriptToMessages([ + ...pendingRecords, + record( + "r-continuation-thought", + {type: "thought", text: "approved"}, + "agent", + "continuation-turn", + ), + ])! + expect(continuationRunning.flatMap((message) => message.parts)).not.toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + expect(firstAssistantMetadata(continuationRunning)).toMatchObject({ + approvalContinuation: { + executionId: "continuation-turn", + state: "running", + approvalIds: ["approval-1"], + }, + }) + + const continuationDone = transcriptToMessages([ + ...pendingRecords, + record( + "r-continuation-thought", + {type: "thought", text: "approved"}, + "agent", + "continuation-turn", + ), + record("r-continuation-done", {type: "done"}, "agent", "continuation-turn"), + ])! + expect(continuationDone.flatMap((message) => message.parts)).not.toEqual( + expect.arrayContaining([expect.objectContaining({state: "approval-requested"})]), + ) + expect(firstAssistantMetadata(continuationDone)).toMatchObject({ + approvalContinuation: { + executionId: "continuation-turn", + state: "done", + approvalIds: ["approval-1"], + }, + }) + }) + + it("tracks a durable continuation by its own execution through running and terminal records", () => { + const source = [ + record("r-user", {type: "message", text: "run it"}, "user", "source-turn"), + record( + "r-call", + {type: "tool_call", id: "tool-1", name: "bash", input: {}}, + "agent", + "source-turn", + ), + record( + "r-req", + { + type: "interaction_request", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-1"}, + }, + "agent", + "source-turn", + ), + record("r-source-done", {type: "done", stopReason: "paused"}, "agent", "source-turn"), + ] + const running = [ + ...source, + record( + "r-continuation-thought", + {type: "thought", text: "approved"}, + "agent", + "continuation-turn", + ), + record( + "r-response", + { + type: "interaction_response", + id: "approval-1", + kind: "user_approval", + payload: {toolCallId: "tool-2", approved: true}, + }, + "agent", + "continuation-turn", + ), + ] + + expect(firstAssistantMetadata(transcriptToMessages(running))).toMatchObject({ + paused: true, + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state: "running", + approvalIds: ["approval-1"], + }, + }) + + const finished = transcriptToMessages([ + ...running, + record( + "r-result", + {type: "tool_result", id: "tool-2", output: "ok"}, + "agent", + "continuation-turn", + ), + record("r-continuation-done", {type: "done"}, "agent", "continuation-turn"), + ]) + expect(firstAssistantMetadata(finished)).toMatchObject({ + recordTerminal: true, + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state: "done", + approvalIds: ["approval-1"], + }, + }) + }) + it("merges a paused turn with its resume into one message and settles the re-emitted call once", () => { // Real cold-replay shape (verified against records): a Write call pauses for approval, the // turn ends stopReason:"paused", then the resume turn RE-EMITS the same call id, settles it, @@ -880,12 +1042,16 @@ describe("transcriptToMessages interaction-row precedence", () => { it("still settles a resumed turn's gate when no row carries a verdict", () => { // The sweep's own job, unchanged: a resumed gate must not replay as still awaiting the user. - const parts = allParts(resumedApprovalRecords()) + const messages = transcriptToMessages(resumedApprovalRecords()) ?? [] + const parts = messages.flatMap( + (message) => message.parts as unknown as Record[], + ) expect(parts.some((part) => part.state === "approval-requested")).toBe(false) expect(parts.find((part) => part.toolCallId === "tool-1")).toMatchObject({ state: "approval-responded", }) + expect(messages.at(-1)?.metadata).toMatchObject({recordTerminal: true}) }) it("keeps an answered approval row's approved verdict", () => { @@ -906,6 +1072,44 @@ describe("transcriptToMessages interaction-row precedence", () => { }) }) + it("replays the observer tab sequence from pending record to pushed resolution", () => { + const live = transcriptToMessages(abandonedApprovalRecords()) ?? [] + const pushed = interactionStatesFromWatchEvent( + JSON.stringify({ + type: "interaction", + session_id: "session-1", + status: "resolved", + interactions: [ + { + id: "interaction-row-1", + session_id: "session-1", + turn_id: "turn-1", + token: "approval-1", + kind: "user_approval", + status: "responded", + data: { + request: {tool_call_id: "tool-1"}, + resolution: {verdict: "approved", tool_call_id: "tool-1"}, + }, + }, + ], + }), + "session-1", + ) + const reconciled = reconcileInteractionRowStates(live, pushed) + + expect( + reconciled + .flatMap((message) => message.parts) + .find((part) => ("toolCallId" in part ? part.toolCallId === "tool-1" : false)), + ).toMatchObject({state: "approval-responded", approval: {approved: true}}) + expect( + live + .flatMap((message) => message.parts) + .find((part) => ("toolCallId" in part ? part.toolCallId === "tool-1" : false)), + ).toMatchObject({state: "approval-requested"}) + }) + it("preserves record-only replay when row states are omitted", () => { expect(toolParts([elicitationRequest()])[0]).toMatchObject({state: "input-available"}) }) diff --git a/web/packages/agenta-chat/tests/unit/components/elicitationDockSettle.test.tsx b/web/packages/agenta-chat/tests/unit/components/elicitationDockSettle.test.tsx index bc92dac6502..06d6b055e7c 100644 --- a/web/packages/agenta-chat/tests/unit/components/elicitationDockSettle.test.tsx +++ b/web/packages/agenta-chat/tests/unit/components/elicitationDockSettle.test.tsx @@ -631,3 +631,15 @@ describe("the controls the dialect grew", () => { expect(screen.getByText("Region")).toBeTruthy() }) }) + +it("shows a failed durable answer and permits retry without a second in-flight submission", async () => { + const {onOutput} = setup(ONE_QUESTION) + onOutput + .mockRejectedValueOnce(new Error("Answer could not be saved")) + .mockResolvedValue(undefined) + fireEvent.click(screen.getByRole("button", {name: "Send answers"})) + await waitFor(() => expect(screen.getByText("Answer could not be saved")).toBeTruthy()) + fireEvent.click(screen.getByRole("button", {name: "Send answers"})) + await waitFor(() => expect(onOutput).toHaveBeenCalledTimes(2)) + expect(screen.queryByText("Answer could not be saved")).toBeNull() +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts b/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts new file mode 100644 index 00000000000..356f41be586 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/durableContinuationHold.test.ts @@ -0,0 +1,213 @@ +// @vitest-environment jsdom +/** + * Regression for the increment-6 browser pass, round 8, item 2. + * + * A user typed while an approval card was open, approved, and the client held the message for + * about sixteen seconds. Then it sent it into the running continuation: the runner superseded the + * continuation's warm sandbox, the approved `sleep 25 && echo …` came back "Command aborted", and + * the released message's own turn was declared lost. The user lost both. + * + * The records below are the REAL durable record log of that session + * (9d40cfcc-6485-4250-8d2e-17f1f12f55f4), exported from the increment-6 stack and ordered exactly + * as `GET /sessions/records` returns them (timestamp, then record index). Replaying them prefix by + * prefix is what pins the two holes the round-8 fix left open: + * + * 1. `resumeOrphaned` walked around the gate. `canReleaseQueuedMessage` holds correctly on + * `approvalContinuation.state === "running"`, but the hook ORs that gate with the orphan + * escape hatch, and a durable answer makes the hatch true every time: the answer retires the + * local gate marker, and the first adopted server transcript makes the tail a restored + * "resume imminent" message. + * 2. The transcript-derived hold starts too late. `approvalContinuation` is stamped from the + * continuation's FIRST record, which landed 8.1 s after the answer here (20:27:59 → 20:28:07). + * A transcript adopted inside that gap shows a paused turn whose gate is answered — settled, + * by every predicate. The respond body's `execution.id` covers that window. + */ +import {act, renderHook} from "@testing-library/react" +import type {UIMessage} from "ai" +import {afterEach, describe, expect, it, vi} from "vitest" + +import {transcriptToMessages} from "../../../src/assets/transcriptToMessages" +import {CONTINUATION_HOLD_MAX_MS, useAgentChatQueue} from "../../../src/hooks/useAgentChatQueue" + +import records from "../assets/__fixtures__/heldMessageDuringContinuation.records.json" + +/** The continuation execution the respond body named (`execution.id`). */ +const CONTINUATION_EXECUTION_ID = "943f3c99-5816-4a46-b6e3-7a10fe587575" + +/** Record indices in the fixture, by the event that closes each prefix. */ +const AFTER_SOURCE_PAUSED_DONE = 5 +const AFTER_CONTINUATION_FIRST_THOUGHT = 6 +const AFTER_CONTINUATION_TOOL_CALL = 7 +const AFTER_CONTINUATION_INTERACTION_RESPONSE = 8 +const AFTER_CONTINUATION_DONE = 12 + +const messagesAfter = (count: number): UIMessage[] => + transcriptToMessages(records.slice(0, count) as never) ?? [] + +/** + * The hook exactly as the desktop mounts it after a durable approve: the answer left no live gate + * marker, so the conversation's `resumeOrphaned` is true, and the stream itself has been "ready" + * since the turn paused. Only the continuation hold can stop a release here. + */ +const renderQueue = (initial: {messages: UIMessage[]; continuationExecutionId?: string | null}) => { + const sendQueued = vi.fn() + const view = renderHook( + (props: {messages: UIMessage[]; continuationExecutionId?: string | null}) => + useAgentChatQueue({ + status: "ready", + messages: props.messages, + stopped: false, + resumeOrphaned: true, + markRunOwned: vi.fn(), + sendQueued, + ...(props.continuationExecutionId !== undefined + ? {continuationExecutionId: props.continuationExecutionId} + : {}), + }), + {initialProps: initial}, + ) + act(() => { + view.result.current.submit({text: "Then reply with the marker inc6-r8-held."}) + }) + return {...view, sendQueued} +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe("a held message must outlive the durable continuation", () => { + it("keeps continuation ownership only in the tab that received the respond execution id", () => { + const answering = renderQueue({ + messages: messagesAfter(AFTER_SOURCE_PAUSED_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + const observer = renderQueue({ + messages: messagesAfter(AFTER_SOURCE_PAUSED_DONE), + continuationExecutionId: null, + }) + + expect(answering.result.current.ownsContinuation).toBe(true) + expect(observer.result.current.ownsContinuation).toBe(false) + + for (const count of [ + AFTER_CONTINUATION_FIRST_THOUGHT, + AFTER_CONTINUATION_TOOL_CALL, + AFTER_CONTINUATION_INTERACTION_RESPONSE, + ]) { + const messages = messagesAfter(count) + answering.rerender({ + messages, + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + observer.rerender({messages, continuationExecutionId: null}) + + expect( + answering.result.current.ownsContinuation, + `answering tab lost ownership after record ${count}`, + ).toBe(true) + expect( + observer.result.current.ownsContinuation, + `observer claimed ownership after record ${count}`, + ).toBe(false) + } + + answering.rerender({ + messages: messagesAfter(AFTER_CONTINUATION_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(answering.result.current.ownsContinuation).toBe(false) + }) + + it("holds through every continuation record and releases on its terminal one", () => { + const {rerender, result, sendQueued} = renderQueue({ + messages: messagesAfter(AFTER_SOURCE_PAUSED_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(1) + + // The prefixes the browser really walked through, in order. The last one is where the + // round-8 build sent: the continuation's re-raised tool call and its interaction response + // together make the tail read as settled to every predicate that ignores the execution. + for (const count of [ + AFTER_CONTINUATION_FIRST_THOUGHT, + AFTER_CONTINUATION_TOOL_CALL, + AFTER_CONTINUATION_INTERACTION_RESPONSE, + ]) { + rerender({ + messages: messagesAfter(count), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued, `released after record ${count}`).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(1) + } + + rerender({ + messages: messagesAfter(AFTER_CONTINUATION_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued).toHaveBeenCalledOnce() + expect(sendQueued.mock.calls[0][0]).toMatchObject({ + text: "Then reply with the marker inc6-r8-held.", + }) + expect(result.current.queued).toHaveLength(0) + }) + + it("holds on the execution id alone, before the continuation writes its first record", () => { + // The 8.1-second window between the answer and the continuation's first record. Nothing + // in the transcript says a continuation exists; only the respond body does. + const paused = messagesAfter(AFTER_SOURCE_PAUSED_DONE) + const {result, sendQueued} = renderQueue({ + messages: paused, + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(1) + }) + + it("releases in that same window when no continuation was started", () => { + // The guard must be the execution id, not the paused shape: an approval whose respond + // returned no execution has nothing to wait for, and holding it would strand the queue. + const {sendQueued} = renderQueue({ + messages: messagesAfter(AFTER_SOURCE_PAUSED_DONE), + continuationExecutionId: null, + }) + expect(sendQueued).toHaveBeenCalledOnce() + }) + + it("gives up the id-keyed hold at the ceiling, so an undelivered continuation cannot strand the queue", () => { + vi.useFakeTimers() + const {result, sendQueued} = renderQueue({ + messages: messagesAfter(AFTER_SOURCE_PAUSED_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(CONTINUATION_HOLD_MAX_MS + 1) + }) + expect(sendQueued).toHaveBeenCalledOnce() + expect(result.current.queued).toHaveLength(0) + }) + + it("keeps holding past the ceiling while the transcript still shows the continuation running", () => { + vi.useFakeTimers() + const {rerender, result, sendQueued} = renderQueue({ + messages: messagesAfter(AFTER_CONTINUATION_FIRST_THOUGHT), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + act(() => { + vi.advanceTimersByTime(CONTINUATION_HOLD_MAX_MS + 1) + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.ownsContinuation).toBe(true) + + rerender({ + messages: messagesAfter(AFTER_CONTINUATION_DONE), + continuationExecutionId: CONTINUATION_EXECUTION_ID, + }) + expect(sendQueued).toHaveBeenCalledOnce() + expect(result.current.ownsContinuation).toBe(false) + }) +}) 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 6557396d56e..d4f0d8ae68b 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentChatQueue.test.ts @@ -3,7 +3,7 @@ import {act, renderHook} from "@testing-library/react" import type {FileUIPart, UIMessage} from "ai" import {describe, expect, it, vi} from "vitest" -import {useAgentChatQueue} from "../../../src/hooks/useAgentChatQueue" +import {useAgentChatQueue, type ServerQueueAdapter} from "../../../src/hooks/useAgentChatQueue" // The pure release predicates (`canReleaseQueuedMessage`, `isHitlPending`) are unit-tested in // the playground package; these tests cover the HOOK's stateful behavior on top of them: @@ -32,21 +32,51 @@ const assistantAwaitingApproval = (id: string): UIMessage => ], }) as unknown as UIMessage +const assistantContinuation = (id: string, state: "running" | "done" | "error"): UIMessage => + ({ + ...assistantAwaitingApproval(id), + metadata: { + ...(state === "done" ? {recordTerminal: true} : {}), + approvalContinuation: { + sourceExecutionId: `${id}-source-execution`, + executionId: `${id}-continuation-execution`, + state, + approvalIds: [`${id}-approval`], + }, + }, + parts: [ + { + type: "tool-send_email", + state: "approval-responded", + toolCallId: `${id}-call`, + input: {to: "a@b.c"}, + approval: {id: `${id}-approval`, approved: true}, + }, + ], + }) as unknown as UIMessage + interface HarnessProps { status: string messages: UIMessage[] stopped: boolean acceptedRunPending?: boolean resumeOrphaned?: boolean + recoverable?: boolean + continuationExecutionId?: string | null sessionId?: string + server?: ServerQueueAdapter } const setup = (initial: HarnessProps) => { const sendQueued = vi.fn() - const view = renderHook((props: HarnessProps) => useAgentChatQueue({...props, sendQueued}), { - initialProps: initial, - }) - return {sendQueued, ...view} + const markRunOwned = vi.fn() + const retryContinuation = vi.fn(() => Promise.resolve(true)) + const view = renderHook( + (props: HarnessProps) => + useAgentChatQueue({...props, markRunOwned, sendQueued, retryContinuation}), + {initialProps: initial}, + ) + return {markRunOwned, sendQueued, retryContinuation, ...view} } const settledEmpty: HarnessProps = {status: "ready", messages: [], stopped: false} @@ -101,6 +131,212 @@ describe("useAgentChatQueue", () => { expect(result.current.queued).toHaveLength(0) }) + it("hands the primary composer submit and explicit Steer to durable admission", async () => { + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + } + const {result, sendQueued} = setup({...settledEmpty, server}) + + await act(async () => { + result.current.submit({text: "wait next"}) + result.current.steer({text: "change direction"}) + }) + + expect(server.submit).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({text: "wait next"}), + "queue", + ) + expect(server.submit).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({text: "change direction"}), + "steer", + ) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(0) + }) + + it("lets the server admit Queue-capable sends from a stale-idle snapshot", async () => { + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: false, + queued: [], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + } + const {result, sendQueued} = setup({...settledEmpty, server}) + + await act(async () => { + result.current.submit({text: "server decides"}) + }) + + expect(server.submit).toHaveBeenCalledWith( + expect.objectContaining({text: "server decides"}), + "queue", + ) + expect(sendQueued).not.toHaveBeenCalled() + }) + + it("never reports a failed durable admission as a client-only queued message", async () => { + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi.fn().mockRejectedValue(new Error("admission unavailable")), + remove: vi.fn().mockResolvedValue(undefined), + } + const {result, sendQueued} = setup({...settledEmpty, server}) + + await act(async () => { + await expect(result.current.submit({text: "keep this draft"})).rejects.toThrow( + "admission unavailable", + ) + }) + + expect(result.current.queued).toHaveLength(0) + expect(sendQueued).not.toHaveBeenCalled() + }) + + it("propagates a refused Steer without inventing a client-only queued message", async () => { + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi.fn().mockRejectedValue(new Error("steer refused")), + remove: vi.fn().mockResolvedValue(undefined), + } + const {result, sendQueued} = setup({...settledEmpty, server}) + + await act(async () => { + await expect(result.current.steer({text: "keep steering draft"})).rejects.toThrow( + "steer refused", + ) + }) + + expect(result.current.queued).toHaveLength(0) + expect(sendQueued).not.toHaveBeenCalled() + }) + + it("moves a client-held input behind the durable queue when its continuation starts", async () => { + const durable = { + id: "already-queued", + text: "server first", + source: "server" as const, + editable: false, + } + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: false, + queued: [durable], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + } + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + } + const {result, rerender, sendQueued} = setup(paused) + + await act(async () => { + await result.current.submit({text: "held by this tab"}) + }) + expect(server.submit).not.toHaveBeenCalled() + expect(result.current.queued).toEqual([expect.objectContaining({text: "held by this tab"})]) + + await act(async () => { + rerender({ + ...paused, + server, + continuationExecutionId: "a1-continuation-execution", + messages: [userTurn("u1", "go"), assistantContinuation("a1", "running")], + }) + await Promise.resolve() + }) + + expect(server.submit).toHaveBeenCalledOnce() + expect(server.submit).toHaveBeenCalledWith( + expect.objectContaining({text: "held by this tab"}), + "queue", + ) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toEqual([durable]) + }) + + it("does not release an input locally while durable admission is still in flight", async () => { + let acceptAdmission: (() => void) | undefined + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: false, + queued: [], + submit: vi.fn( + () => + new Promise((resolve) => { + acceptAdmission = resolve + }), + ), + remove: vi.fn().mockResolvedValue(undefined), + } + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + server, + } + const {result, rerender, sendQueued} = setup(paused) + + act(() => void result.current.submit({text: "held by this tab"})) + rerender({ + ...paused, + continuationExecutionId: "a1-continuation-execution", + messages: [userTurn("u1", "go"), assistantContinuation("a1", "running")], + }) + expect(server.submit).toHaveBeenCalledOnce() + + rerender({ + ...paused, + continuationExecutionId: "a1-continuation-execution", + messages: [userTurn("u1", "go"), assistantContinuation("a1", "done")], + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(0) + + await act(async () => { + acceptAdmission?.() + await Promise.resolve() + }) + + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(0) + }) + + it("renders and removes server rows without releasing them through the local queue", () => { + const durable = { + id: "input-1", + text: "shared", + source: "server" as const, + editable: false, + } + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: false}, + busy: true, + queued: [durable], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + } + const {result, sendQueued} = setup({...settledEmpty, server}) + + expect(result.current.queued).toEqual([durable]) + act(() => result.current.removeQueued("input-1")) + + expect(server.remove).toHaveBeenCalledWith("input-1") + expect(sendQueued).not.toHaveBeenCalled() + }) + it("releases held messages one per settle, in FIFO order", () => { const streaming: HarnessProps = { status: "streaming", @@ -140,6 +376,24 @@ describe("useAgentChatQueue", () => { expect(result.current.queued.map((m) => m.text)).toEqual(["while paused"]) }) + it("keeps a recoverable Send visible and retries the saved continuation", () => { + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + recoverable: true, + } + const {result, sendQueued, retryContinuation} = setup(paused) + + act(() => result.current.submit({text: "send after the approval"})) + + expect(sendQueued).not.toHaveBeenCalled() + expect(retryContinuation).toHaveBeenCalledOnce() + expect(result.current.queued.map((message) => message.text)).toEqual([ + "send after the approval", + ]) + }) + it("releases a held message once the approval gate resolves", () => { const paused: HarnessProps = { status: "ready", @@ -158,6 +412,56 @@ describe("useAgentChatQueue", () => { expect(result.current.queued).toHaveLength(0) }) + it("marks a released send as locally owned before dispatching it", () => { + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + } + const {result, rerender, markRunOwned, sendQueued} = setup(paused) + act(() => result.current.submit({text: "held during the continuation"})) + + rerender({...paused, messages: [userTurn("u1", "go"), assistantText("a2", "done")]}) + + expect(markRunOwned).toHaveBeenCalledOnce() + expect(sendQueued).toHaveBeenCalledOnce() + expect(markRunOwned.mock.invocationCallOrder[0]).toBeLessThan( + sendQueued.mock.invocationCallOrder[0], + ) + }) + + it("holds through a different continuation execution and drains once after its terminal", () => { + const paused: HarnessProps = { + status: "ready", + messages: [userTurn("u1", "go"), assistantAwaitingApproval("a1")], + stopped: false, + } + const {result, rerender, sendQueued} = setup(paused) + act(() => result.current.submit({text: "after continuation"})) + + rerender({ + ...paused, + messages: [userTurn("u1", "go"), assistantContinuation("a1", "running")], + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(result.current.queued).toHaveLength(1) + + rerender({ + ...paused, + messages: [userTurn("u1", "go"), assistantContinuation("a1", "done")], + }) + + expect(sendQueued).toHaveBeenCalledOnce() + expect(sendQueued.mock.calls[0][0]).toMatchObject({text: "after continuation"}) + expect(result.current.queued).toHaveLength(0) + + rerender({ + ...paused, + messages: [userTurn("u1", "go"), assistantContinuation("a1", "done")], + }) + expect(sendQueued).toHaveBeenCalledOnce() + }) + it("a user stop voids the HITL hold: settled sends go immediately and hitlPending clears", () => { const stoppedPaused: HarnessProps = { status: "ready", @@ -480,3 +784,463 @@ describe("useAgentChatQueue: reclaiming a sent message", () => { expect(result.current.takeLastSent()).toMatchObject({text: "held"}) }) }) + +describe("durable queued edits", () => { + it("keeps the edit and draft until same-row persistence succeeds, including a retry", async () => { + const edit = vi + .fn() + .mockRejectedValueOnce(new Error("conflict")) + .mockResolvedValueOnce(undefined) + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [ + {id: "first", text: "first", source: "server"}, + {id: "selected", text: "old", source: "server"}, + ], + submit: vi.fn(), + remove: vi.fn(), + edit, + } + const {result, sendQueued} = setup({...settledEmpty, server}) + act(() => result.current.beginEdit("selected", "original draft")) + await act(async () => { + await expect(result.current.commitEdit({text: "new"})).rejects.toThrow("conflict") + }) + expect(result.current.editingId).toBe("selected") + expect(result.current.queued.map((row) => row.id)).toEqual(["first", "selected"]) + let restored: string | undefined + await act(async () => { + restored = await result.current.commitEdit({text: "new"}) + }) + expect(restored).toBe("original draft") + expect(result.current.editingId).toBeNull() + expect(edit).toHaveBeenNthCalledWith(2, "selected", {text: "new"}) + expect(server.submit).not.toHaveBeenCalled() + expect(server.remove).not.toHaveBeenCalled() + expect(sendQueued).not.toHaveBeenCalled() + }) + + it("does not submit a new message if the durable row leaves the queue during editing", async () => { + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [{id: "selected", text: "old", source: "server"}], + submit: vi.fn(), + remove: vi.fn(), + edit: vi.fn().mockRejectedValue(new Error("already promoted")), + } + const {result, rerender, sendQueued} = setup({...settledEmpty, server}) + act(() => result.current.beginEdit("selected", "draft")) + rerender({...settledEmpty, server: {...server, queued: []}}) + await act(async () => { + await expect(result.current.commitEdit({text: "new"})).rejects.toThrow( + "already promoted", + ) + }) + expect(result.current.editingId).toBe("selected") + expect(server.submit).not.toHaveBeenCalled() + expect(sendQueued).not.toHaveBeenCalled() + let restored = "" + act(() => { + restored = result.current.cancelEdit() + }) + expect(restored).toBe("draft") + }) +}) + +it.each([false, true])( + "does not overwrite a newer edit when an older save settles (failure=%s)", + async (failure) => { + let resolve!: () => void + let reject!: (error: Error) => void + const edit = vi.fn( + () => + new Promise((yes, no) => { + resolve = yes + reject = no + }), + ) + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [ + {id: "first", text: "old", source: "server"}, + {id: "second", text: "other", source: "server"}, + ], + submit: vi.fn(), + remove: vi.fn(), + edit, + } + const {result} = setup({...settledEmpty, server}) + act(() => result.current.beginEdit("first", "original draft")) + let saving!: string | Promise + act(() => { + saving = result.current.commitEdit({text: "changed"}) + }) + act(() => result.current.beginEdit("second", "new draft")) + await act(async () => { + if (failure) reject(new Error("old failure")) + else resolve() + expect(await saving).toBe("") + }) + expect(result.current.editingId).toBe("second") + let restored = "" + act(() => { + restored = result.current.cancelEdit() + }) + expect(restored).toBe("new draft") + }, +) + +describe("cold session capability admission", () => { + it.each([true, false])( + "waits for queue=%s before choosing the first send owner", + async (queue) => { + let resolve!: (value: {queue: boolean; steer: boolean}) => void + const capability = new Promise<{queue: boolean; steer: boolean}>((done) => { + resolve = done + }) + const submitServer = vi.fn().mockResolvedValue(undefined) + const server = { + capabilities: {queue: false, steer: false}, + busy: false, + queued: [], + submit: submitServer, + remove: vi.fn(), + resolveCapabilities: () => capability, + } + const {result, sendQueued} = setup({...settledEmpty, server}) + const fileParts = [ + { + type: "file", + url: "https://qa.invalid/file", + mediaType: "text/plain", + filename: "notes.txt", + }, + ] as FileUIPart[] + let submission: unknown + act(() => { + submission = result.current.submit({text: "first", fileParts}) + }) + expect(sendQueued).not.toHaveBeenCalled() + expect(submitServer).not.toHaveBeenCalled() + await act(async () => { + resolve({queue, steer: queue}) + await submission + }) + if (queue) { + expect(submitServer).toHaveBeenCalledWith( + expect.objectContaining({text: "first", fileParts}), + "queue", + ) + expect(sendQueued).not.toHaveBeenCalled() + } else { + expect(sendQueued).toHaveBeenCalledWith( + expect.objectContaining({text: "first", fileParts}), + ) + expect(submitServer).not.toHaveBeenCalled() + } + }, + ) + it("rejects unknown capability admission instead of falling back to native", async () => { + const failure = new Error("Session is unavailable") + const server = { + capabilities: {queue: false, steer: false}, + busy: false, + queued: [], + submit: vi.fn(), + remove: vi.fn(), + resolveCapabilities: () => Promise.reject(failure), + } + const {result, sendQueued} = setup({...settledEmpty, server}) + await expect(result.current.submit({text: "keep this draft"})).rejects.toBe(failure) + expect(sendQueued).not.toHaveBeenCalled() + expect(server.submit).not.toHaveBeenCalled() + }) +}) + +it("keeps an edit and its displaced draft when a drained target cannot be readmitted", async () => { + const server = { + capabilities: {queue: false, steer: false}, + busy: false, + queued: [], + submit: vi.fn(), + remove: vi.fn(), + resolveCapabilities: vi.fn().mockRejectedValue(new Error("unavailable")), + } + const view = setup({status: "ready", messages: [], stopped: false, server}) + act(() => view.result.current.beginEdit("already-drained", "my displaced draft")) + await act(async () => { + await expect(view.result.current.commitEdit({text: "edited answer"})).rejects.toThrow( + "unavailable", + ) + }) + expect(view.result.current.editingId).toBe("already-drained") + let restored: string | undefined + act(() => { + restored = view.result.current.cancelEdit() + }) + expect(restored).toBe("my displaced draft") + expect(view.sendQueued).not.toHaveBeenCalled() +}) + +it("uses current busy state when validated legacy capability arrives", async () => { + let resolve!: (caps: {queue: boolean; steer: boolean}) => void + const server = { + capabilities: {queue: false, steer: false}, + busy: false, + queued: [], + submit: vi.fn(), + remove: vi.fn(), + resolveCapabilities: () => + new Promise<{queue: boolean; steer: boolean}>((done) => { + resolve = done + }), + } + const view = setup({status: "ready", messages: [], stopped: false, server}) + let pending: void | Promise + act(() => { + pending = view.result.current.submit({text: "hold while starting"}) + }) + view.rerender({status: "streaming", messages: [], stopped: false, server}) + await act(async () => { + resolve({queue: false, steer: false}) + await pending + }) + expect(view.sendQueued).not.toHaveBeenCalled() + expect(view.result.current.queued.map((message) => message.text)).toEqual([ + "hold while starting", + ]) +}) + +it("waits for cold Steer capabilities before admitting the explicit input", async () => { + let resolve!: (capabilities: {queue: boolean; steer: boolean}) => void + const server = { + capabilities: {queue: false, steer: false}, + busy: true, + queued: [], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn(), + resolveCapabilities: () => + new Promise<{queue: boolean; steer: boolean}>((done) => { + resolve = done + }), + } + const view = setup({status: "streaming", messages: [], stopped: false, server}) + let pending!: Promise + act(() => { + pending = view.result.current.steer({text: "change direction"}) + }) + void pending.catch(() => undefined) + expect(server.submit).not.toHaveBeenCalled() + expect(resolve).toBeTypeOf("function") + await act(async () => { + resolve({queue: true, steer: true}) + await pending + }) + expect(server.submit).toHaveBeenCalledWith( + expect.objectContaining({text: "change direction"}), + "steer", + ) + expect(view.sendQueued).not.toHaveBeenCalled() +}) + +it("refuses cold Steer if the session settles while capabilities resolve", async () => { + let resolve!: (capabilities: {queue: boolean; steer: boolean}) => void + const server = { + capabilities: {queue: false, steer: false}, + busy: true, + queued: [], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn(), + resolveCapabilities: () => + new Promise<{queue: boolean; steer: boolean}>((done) => { + resolve = done + }), + } + const view = setup({status: "streaming", messages: [], stopped: false, server}) + let pending!: Promise + act(() => { + pending = view.result.current.steer({text: "too late"}) + }) + void pending.catch(() => undefined) + view.rerender({status: "ready", messages: [], stopped: false, server: {...server, busy: false}}) + await act(async () => { + resolve({queue: true, steer: true}) + await expect(pending).rejects.toThrow("not ready") + }) + expect(server.submit).not.toHaveBeenCalled() + expect(view.sendQueued).not.toHaveBeenCalled() +}) + +it("holds local-to-server migration while its row is being edited", async () => { + const props = {...settledEmpty, status: "streaming"} + const {result, rerender} = setup(props) + act(() => result.current.submit({text: "old"})) + const id = result.current.queued[0].id + act(() => result.current.beginEdit(id, "draft")) + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi.fn().mockResolvedValue(undefined), + remove: vi.fn(), + edit: vi.fn(), + } + rerender({...props, server, continuationExecutionId: "continuation"}) + expect(server.submit).not.toHaveBeenCalled() + await act(async () => { + await result.current.commitEdit({text: "edited before migration"}) + }) + expect(server.submit).toHaveBeenCalledOnce() + expect(server.submit).toHaveBeenCalledWith( + expect.objectContaining({id, text: "edited before migration"}), + "queue", + ) +}) + +it.each(["pending", "accepted", "promoted"] as const)( + "keeps same-row durable editing when migration is %s and snapshot lags", + async (state) => { + const props = {...settledEmpty, status: "streaming"} + const {result, rerender, sendQueued} = setup(props) + act(() => result.current.submit({text: "old"})) + const id = result.current.queued[0].id + let accept!: () => void + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi.fn( + () => + new Promise((resolve) => { + accept = resolve + }), + ), + remove: vi.fn(), + edit: + state === "promoted" + ? vi.fn().mockRejectedValue(new Error("already promoted")) + : vi.fn().mockResolvedValue(undefined), + } + rerender({...props, server, continuationExecutionId: "continuation"}) + expect(server.submit).toHaveBeenCalledOnce() + act(() => result.current.beginEdit(id, "original draft")) + if (state !== "pending") + await act(async () => { + accept() + await Promise.resolve() + }) + let saving!: string | Promise + act(() => { + saving = result.current.commitEdit({text: "corrected"}) + }) + if (state === "pending") { + expect(server.edit).not.toHaveBeenCalled() + await act(async () => { + accept() + expect(await saving).toBe("original draft") + }) + } else if (state === "promoted") { + await act(async () => { + await expect(saving).rejects.toThrow("already promoted") + }) + expect(result.current.editingId).toBe(id) + } else { + await act(async () => { + expect(await saving).toBe("original draft") + }) + } + expect(server.edit).toHaveBeenCalledWith(id, {text: "corrected"}) + expect(server.submit).toHaveBeenCalledOnce() + expect(sendQueued).not.toHaveBeenCalled() + }, +) + +it("retains observed server ownership after a failed edit and a later missing snapshot row", async () => { + const props = {...settledEmpty, status: "streaming"} + const {result, rerender} = setup(props) + act(() => result.current.submit({text: "old"})) + const id = result.current.queued[0].id + act(() => result.current.beginEdit(id, "draft")) + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [{id, text: "old", source: "server"}], + submit: vi.fn(), + remove: vi.fn(), + edit: vi + .fn() + .mockRejectedValueOnce(new Error("retry")) + .mockRejectedValueOnce(new Error("promoted")), + } + rerender({...props, server}) + await act(async () => { + await expect(result.current.commitEdit({text: "corrected"})).rejects.toThrow("retry") + }) + rerender({...props, server: {...server, queued: []}}) + await act(async () => { + await expect(result.current.commitEdit({text: "corrected"})).rejects.toThrow("promoted") + }) + expect(server.edit).toHaveBeenCalledTimes(2) + expect(server.submit).not.toHaveBeenCalled() + expect(result.current.queued).toEqual([]) + expect(result.current.editingId).toBe(id) +}) + +it.each([false, true])( + "recovers an ambiguous migration before editing (server observed=%s)", + async (observed) => { + const props = {...settledEmpty, status: "streaming"} + const {result, rerender, unmount} = setup(props) + act(() => result.current.submit({text: "original admission"})) + const original = result.current.queued[0] + let reject!: (error: Error) => void + const server: ServerQueueAdapter = { + capabilities: {queue: true, steer: true}, + busy: true, + queued: [], + submit: vi + .fn() + .mockImplementationOnce( + () => + new Promise((_yes, no) => { + reject = no + }), + ) + .mockResolvedValueOnce(undefined), + remove: vi.fn(), + edit: vi.fn().mockResolvedValue(undefined), + } + rerender({...props, server, continuationExecutionId: "continuation"}) + act(() => result.current.beginEdit(original.id, "draft")) + let saving!: string | Promise + act(() => { + saving = result.current.commitEdit({text: "corrected"}) + }) + await act(async () => { + reject(new Error("response lost")) + await expect(saving).rejects.toThrow("response lost") + }) + expect(result.current.editingId).toBe(original.id) + expect(server.edit).not.toHaveBeenCalled() + if (observed) { + rerender({ + ...props, + server: {...server, queued: [{...original, source: "server"}]}, + continuationExecutionId: "continuation", + }) + } + await act(async () => { + expect(await result.current.commitEdit({text: "corrected"})).toBe("draft") + }) + expect(server.submit).toHaveBeenNthCalledWith(1, original, "queue") + if (observed) expect(server.submit).toHaveBeenCalledOnce() + else expect(server.submit).toHaveBeenNthCalledWith(2, original, "queue") + expect(server.edit).toHaveBeenCalledOnce() + expect(server.edit).toHaveBeenCalledWith(original.id, {text: "corrected"}) + expect(result.current.queued).toEqual(observed ? [{...original, source: "server"}] : []) + unmount() + }, +) 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 c281b1bdc12..79eca6f4407 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useAgentConversation.test.ts @@ -21,6 +21,20 @@ import type {UIMessage} from "ai" import {createStore, Provider} from "jotai" import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" +const { + capabilitiesViaAtom, + snapshotViaAtom, + resumeContinuation, + durableApprovalCapability, + respondAnswer, +} = vi.hoisted(() => ({ + capabilitiesViaAtom: vi.fn(), + snapshotViaAtom: vi.fn(), + resumeContinuation: vi.fn(), + durableApprovalCapability: vi.fn(), + respondAnswer: vi.fn(), +})) + const approvalRecord = vi.hoisted(() => ({ defer: false, resolve: undefined as (() => void) | undefined, @@ -60,6 +74,15 @@ vi.mock("@agenta/entities/session", async (importOriginal) => { fetchSessionInteractionStatesAtom: atom(null, () => new Map()), fetchSessionSnapshot: vi.fn(), querySessionTranscript: vi.fn(), + fetchSessionCapabilitiesAtom: atom(null, (_get, _set, sessionId: string) => + capabilitiesViaAtom(sessionId), + ), + fetchSessionSnapshotAtom: atom(null, (_get, _set, sessionId: string) => + snapshotViaAtom(sessionId), + ), + resumeSessionContinuationAtom: atom(null, () => resumeContinuation()), + sessionDurableApprovalsCapabilityAtom: atom(null, () => durableApprovalCapability()), + respondInteractionAnswerAtom: atom(null, (_get, _set, args) => respondAnswer(args)), } }) @@ -70,6 +93,7 @@ vi.mock("@agenta/entities/trace", () => ({ import {useAgentConversation} from "../../../src/hooks/useAgentConversation" import {ACCEPTED_SENDER_DISCONNECT_MESSAGE, TRANSPORT_ERROR_MESSAGE} from "../../../src/model/error" import { + composerDraftBySession, getSessionTurnId, markSessionFresh, setSessionTurnId, @@ -248,6 +272,35 @@ const controlledLegacyResponse = () => { return {response, finish: () => finish()} } +const controlledSharedResponse = (sessionId: string) => { + const encoder = new TextEncoder() + let finish = () => {} + const response = new Response( + new ReadableStream({ + start(controller) { + for (const chunk of [ + {type: "start", messageId: "shared-assistant"}, + {type: "start-step"}, + { + type: "data-session-accepted", + data: {sessionId, turnId: "turn-1", executionId: "turn-1"}, + transient: true, + }, + ]) + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)) + finish = () => { + for (const chunk of [{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) @@ -265,6 +318,10 @@ const mount = (store: ReturnType, entityId: string, sessionI ) beforeEach(() => { + durableApprovalCapability.mockReset().mockResolvedValue(false) + respondAnswer + .mockReset() + .mockResolvedValue({durable: true, recoverable: false, executionId: "questionnaire-child"}) approvalRecord.defer = false approvalRecord.resolve = undefined FakeEventSource.instances = [] @@ -284,6 +341,12 @@ beforeEach(() => { } as SessionSnapshot) vi.mocked(querySessionTranscript).mockReset() vi.mocked(querySessionTranscript).mockResolvedValue([]) + snapshotViaAtom.mockReset() + snapshotViaAtom.mockResolvedValue(null) + capabilitiesViaAtom.mockReset() + capabilitiesViaAtom.mockResolvedValue({durableApprovals: false, queue: false, steer: false}) + resumeContinuation.mockReset() + resumeContinuation.mockResolvedValue(false) vi.mocked(buildAgentRequest).mockClear() // Restore the ready-workflow build: one test replaces it with a not-yet-loaded one, and // `mockClear` keeps the implementation. @@ -297,6 +360,130 @@ beforeEach(() => { afterEach(() => vi.useRealTimers()) describe("useAgentConversation", () => { + it("releases one mobile-held message after a flag-off shared turn finishes", async () => { + const store = createStore() + store.set(projectIdAtom, "project-1") + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const first = controlledSharedResponse(sessionId) + const second = controlledSharedResponse(sessionId) + fetchMock.mockResolvedValueOnce(first.response).mockResolvedValueOnce(second.response) + vi.mocked(buildAgentRequest).mockImplementation(async (_entityId, _messages, opts) => ({ + invocationUrl: "https://agent.test/invoke", + headers: { + Accept: "text/event-stream", + "content-type": "application/json", + ...(opts?.sharedResponse ? {"x-ag-session-response": "shared"} : {}), + }, + requestBody: {session_id: opts?.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(() => FakeEventSource.instances[0].ready()) + await waitFor(() => expect(result.current.readerReady).toBe(true)) + + act(() => void result.current.send({text: "start"})) + await waitFor(() => expect(result.current.acceptedRunPending).toBe(true)) + await act(async () => { + await result.current.send({text: "held on mobile"}) + }) + expect(result.current.queued.map((message) => message.text)).toEqual(["held on mobile"]) + expect(fetchMock).toHaveBeenCalledTimes(1) + + act(() => first.finish()) + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + expect(result.current.queued).toHaveLength(0) + act(() => second.finish()) + await waitFor(() => expect(result.current.acceptedRunPending).toBe(false)) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it("keeps the composer draft when initial capabilities are unknown", async () => { + capabilitiesViaAtom.mockResolvedValue(null) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + composerDraftBySession.set(sessionId, "keep first message") + const {result} = mount(store, "rev-1", sessionId) + await act(async () => { + await expect(result.current.send({text: "keep first message"})).rejects.toThrow( + "capabilities are unavailable", + ) + }) + expect(composerDraftBySession.get(sessionId)).toBe("keep first message") + expect(fetchMock).not.toHaveBeenCalled() + }) + + it("keeps a Steer draft when durable admission is refused", async () => { + capabilitiesViaAtom.mockResolvedValue({ + durableApprovals: true, + queue: true, + steer: true, + }) + snapshotViaAtom.mockResolvedValue({ + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "turn-1", state: "running"}, + read: {latest_sequence: 0, history_complete: true}, + pending: {inputs: [], interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + fetchMock.mockResolvedValue(new Response(null, {status: 409})) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + composerDraftBySession.set(sessionId, "keep steering draft") + const {result} = mount(store, "rev-1", sessionId) + await waitFor(() => expect(result.current.steerEnabled).toBe(true)) + + await act(async () => { + await expect(result.current.steer({text: "keep steering draft"})).rejects.toThrow( + "The input was not accepted (409).", + ) + }) + + expect(composerDraftBySession.get(sessionId)).toBe("keep steering draft") + }) + + it("redelivers a durable continuation before request build and suppresses direct invoke", async () => { + resumeContinuation.mockResolvedValueOnce(true) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + + await act(async () => { + await result.current.send({text: "do not race"}) + }) + await waitFor(() => expect(result.current.status).toBe("error")) + + expect(resumeContinuation).toHaveBeenCalledOnce() + expect(vi.mocked(buildAgentRequest)).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + expect(result.current.error).toEqual({ + code: "continuation_resumed", + message: + "A saved approval is resuming. Wait for it to finish, then try this message again.", + }) + }) + it("runs a full turn: send → stream → settle → persist + status publish", async () => { fetchMock.mockResolvedValue(streamResponse("Hello back")) const store = createStore() @@ -998,3 +1185,76 @@ describe("useAgentConversation", () => { }) }) }) + +describe("server-owned client-tool answers", () => { + it("waits for initial capabilities before submitting a questionnaire answer", async () => { + let resolve!: (enabled: boolean) => void + durableApprovalCapability.mockImplementation( + () => + new Promise((done) => { + resolve = done + }), + ) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + const answer = { + toolName: "request_input", + toolCallId: "questionnaire", + output: {action: "accept", content: {goal: "Correctness"}}, + } + let pending!: Promise + act(() => { + pending = result.current.sendToolOutput(answer) + }) + await act(async () => { + await Promise.resolve() + }) + expect(respondAnswer).not.toHaveBeenCalled() + expect(resumeContinuation).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + await act(async () => { + resolve(true) + await pending + }) + expect(respondAnswer).toHaveBeenCalledOnce() + expect(resumeContinuation).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it.each([false, true])( + "submits client-tool answer durably without a competing local resume (error=%s)", + async (failed) => { + durableApprovalCapability.mockResolvedValue(true) + resumeContinuation.mockResolvedValue(true) + const store = createStore() + const sessionId = nextSessionId() + markSessionFresh(sessionId) + const {result} = mount(store, "rev-1", sessionId) + const output = {action: "accept", content: {goal: "Correctness"}} + + await act(async () => { + await result.current.sendToolOutput({ + toolName: "request_input", + toolCallId: "questionnaire", + ...(failed ? {errorText: "Questionnaire could not be rendered"} : {output}), + }) + }) + + expect(respondAnswer).toHaveBeenCalledWith({ + sessionId, + toolCallId: "questionnaire", + resolution: { + tool_call_id: "questionnaire", + tool_name: "request_input", + ...(failed + ? {outcome: "error", error: "Questionnaire could not be rendered"} + : {outcome: "completed", output}), + }, + }) + expect(resumeContinuation).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }, + ) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts index 62fa011ce9b..182b3f46ede 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts +++ b/web/packages/agenta-chat/tests/unit/hooks/useApprovalDock.test.ts @@ -1,8 +1,10 @@ // @vitest-environment jsdom import {act, renderHook} from "@testing-library/react" +import type {SessionInteractionRowStates} from "@agenta/entities/session" import type {UIMessage} from "ai" import {describe, expect, it, vi} from "vitest" +import {reconcileInteractionRowStates} from "../../../src/assets/transcriptToMessages" import {useApprovalDock} from "../../../src/hooks/useApprovalDock" const gatePart = (approvalId: string, toolName = "send_email") => ({ @@ -103,6 +105,24 @@ describe("useApprovalDock", () => { expect(result.current.open).toBe(false) }) + it("approveAll uses one batch response when the host supports it", () => { + const respond = vi.fn() + const respondAll = vi.fn() + const {result} = renderHook(() => + useApprovalDock({ + messages: [userTurn, assistantWithGates("g1", "g2")], + respond, + respondAll, + }), + ) + + act(() => result.current.approveAll()) + + expect(respond).not.toHaveBeenCalled() + expect(respondAll).toHaveBeenCalledOnce() + expect(respondAll).toHaveBeenCalledWith({ids: ["g1", "g2"], approved: true}) + }) + it("keeps the last card latched while closed so a leave transition has content", () => { const {result, rerender} = setup([userTurn, assistantWithGates("g1")]) expect(result.current.current?.approvalId).toBe("g1") @@ -111,4 +131,112 @@ describe("useApprovalDock", () => { // The latched gate is still available for the closing animation frame. expect(result.current.current?.approvalId).toBe("g1") }) + + it("closes when the continuation reaches a terminal record", () => { + const terminal = { + ...assistantWithGates("g1"), + metadata: { + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state: "done", + approvalIds: ["g1"], + }, + }, + } as UIMessage + + const {result} = setup([userTurn, terminal]) + + expect(result.current.open).toBe(false) + }) + + it("closes when another reader resolves the interaction row", () => { + const pending = assistantWithGates("g1") + const rows: SessionInteractionRowStates = new Map([ + [ + "g1", + { + token: "g1", + kind: "user_approval", + status: "resolved", + resolution: {verdict: "approved"}, + }, + ], + ]) + const reconciled = reconcileInteractionRowStates([pending], rows) + const {result} = setup(reconciled) + + expect(result.current.open).toBe(false) + }) + + it("moves from sending to answered only after the response promise resolves", async () => { + let accept: (() => void) | undefined + const respond = vi.fn( + () => + new Promise((resolve) => { + accept = resolve + }), + ) + const {result} = renderHook(() => + useApprovalDock({messages: [assistantWithGates("g1")], respond}), + ) + + act(() => result.current.respond(true)) + expect(result.current.responding).toBe(true) + expect(result.current.answered).toBe(false) + + await act(async () => accept?.()) + expect(result.current.answered).toBe(true) + expect(result.current.errorText).toBeNull() + }) + + it("re-arms the pending decision and shows an error when submission fails", async () => { + const respond = vi.fn(() => Promise.reject(new Error("Network unavailable"))) + const {result} = renderHook(() => + useApprovalDock({messages: [assistantWithGates("g1")], respond}), + ) + + await act(async () => result.current.respond(false)) + + expect(result.current.responding).toBe(false) + expect(result.current.answered).toBe(false) + expect(result.current.errorText).toBe("Network unavailable") + act(() => result.current.respond(false)) + expect(respond).toHaveBeenCalledTimes(2) + }) + + it("preserves a recoverable durable response for the shared card", async () => { + const respond = vi.fn(() => Promise.resolve({durable: true, recoverable: true})) + const {result} = renderHook(() => + useApprovalDock({messages: [assistantWithGates("g1")], respond}), + ) + + await act(async () => result.current.respond(true)) + + expect(result.current.answered).toBe(true) + expect(result.current.recoverable).toBe(true) + }) + + it("does not apply a late recoverable result to the next interaction", async () => { + let resolveFirst: ((value: {durable: boolean; recoverable: boolean}) => void) | undefined + const respond = vi.fn( + () => + new Promise<{durable: boolean; recoverable: boolean}>((resolve) => { + resolveFirst = resolve + }), + ) + const {result, rerender} = renderHook( + (props: {messages: UIMessage[]}) => + useApprovalDock({messages: props.messages, respond}), + {initialProps: {messages: [assistantWithGates("g1")]}}, + ) + + act(() => result.current.respond(true)) + rerender({messages: [assistantWithGates("g2")]}) + await act(async () => resolveFirst?.({durable: true, recoverable: true})) + + expect(result.current.current?.approvalId).toBe("g2") + expect(result.current.answered).toBe(false) + expect(result.current.recoverable).toBe(false) + }) }) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts new file mode 100644 index 00000000000..59975ad1309 --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/hooks/useServerSessionInputs.test.ts @@ -0,0 +1,720 @@ +// @vitest-environment jsdom +import {createElement, createRef, Fragment, useMemo, useRef, useState, type RefObject} from "react" + +import {projectIdAtom} from "@agenta/shared/state" +import {createStore, Provider} from "jotai" +import type {RichChatInputHandle} from "@agenta/ui/rich-chat-input" +import {act, cleanup, fireEvent, render, renderHook, screen, waitFor} from "@testing-library/react" +import type {UIMessage} from "ai" +import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest" + +import {DEFAULT_ATTACHMENT_LIMITS} from "../../../src/assets/attachmentRules" +import {isComposerRunStoppable} from "../../../src/assets/composerRunState" +import {ChatComposer} from "../../../src/components/ChatComposer" +import QueuedMessagesDock from "../../../src/components/QueuedMessagesDock" +import {useAgentChatQueue} from "../../../src/hooks/useAgentChatQueue" +import type {useComposerAttachments} from "../../../src/hooks/useComposerAttachments" +import {useServerSessionInputs} from "../../../src/hooks/useServerSessionInputs" + +const { + buildAgentRequest, + fetchCapabilities, + fetchSnapshot, + removeInput, + sendInputNow, + updateInput, +} = vi.hoisted(() => ({ + buildAgentRequest: vi.fn(), + fetchCapabilities: vi.fn(), + fetchSnapshot: vi.fn(), + removeInput: vi.fn(), + sendInputNow: vi.fn(), + updateInput: vi.fn(), +})) + +vi.mock("@agenta/entities/session", async () => { + const {atom} = await import("jotai") + return { + fetchSessionCapabilitiesAtom: atom(null, (_get, _set, sessionId: string) => + fetchCapabilities(sessionId), + ), + fetchSessionSnapshotAtom: atom(null, (_get, _set, sessionId: string) => + fetchSnapshot(sessionId), + ), + updatePendingSessionInputAtom: atom(null, (_get, _set, params) => updateInput(params)), + sendPendingSessionInputNowAtom: atom( + null, + (_get, _set, params: {sessionId: string; inputId: string}) => sendInputNow(params), + ), + removePendingSessionInputAtom: atom( + null, + (_get, _set, params: {sessionId: string; inputId: string}) => removeInput(params), + ), + } +}) + +vi.mock("@agenta/playground/agent-chat", async (importOriginal) => ({ + ...(await importOriginal()), + buildAgentRequest, +})) + +const fetchMock = vi.fn() +vi.stubGlobal("fetch", fetchMock) + +beforeAll(async () => { + // Load the real lazy editor before the one-second interaction assertions start. + await import("@agenta/ui/rich-chat-input") + // Lexical asks the DOM selection's text node for geometry after Enter clears the editor. + const rect = () => new DOMRect() + for (const prototype of [ + Node.prototype, + Text.prototype, + HTMLElement.prototype, + Range.prototype, + ]) { + Object.defineProperty(prototype, "getBoundingClientRect", { + configurable: true, + value: rect, + }) + } + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }) +}) + +beforeEach(() => { + buildAgentRequest.mockReset() + fetchCapabilities.mockReset() + fetchCapabilities.mockResolvedValue({durableApprovals: true, queue: true, steer: true}) + fetchSnapshot.mockReset() + removeInput.mockReset() + sendInputNow.mockReset() + updateInput.mockReset() + fetchMock.mockReset() +}) + +afterEach(cleanup) + +interface PendingInput { + id: string + session_id: string + content: {data: {inputs: {messages: {role: string; content: string}[]}}} + position: number + state: "pending" + policy: "queue" | "steer" + created_at: null + promoted_execution_id: null +} + +/** The unified snapshot: the reconnect half plus the queue half, as the API now returns it. */ +const runningSnapshot = (inputs: PendingInput[] = []) => ({ + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "turn-1", state: "running" as const}, + pending: {inputs, interactions: []}, + read: {latest_sequence: 0, history_complete: true}, + capabilities: {durable_approvals: true, queue: true, steer: true}, +}) + +const RunningElsewhereAdmissionHarness = ({ + inputRef, +}: { + inputRef: RefObject +}) => { + const server = useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + // The browser owns no AI-SDK stream; only the server snapshot says the run is active. + locallyBusy: false, + }) + const queue = useAgentChatQueue({ + status: "ready", + messages: [], + stopped: false, + markRunOwned: vi.fn(), + sendQueued: vi.fn(), + server, + }) + const sending = useRef(false) + const [freshAdmissionReleased, setFreshAdmissionReleased] = useState(false) + const [rejections, setRejections] = useState<{name: string; reason: string}[]>([]) + const attachments = useMemo( + () => + ({ + uploadsEnabled: false, + files: [], + rejections, + limits: DEFAULT_ATTACHMENT_LIMITS, + atMax: false, + attachmentsSettled: true, + uploadBlockReason: undefined, + addFiles: vi.fn(), + removeFile: vi.fn(), + dismissRejection: (index: number) => + setRejections((items) => items.filter((_, at) => at !== index)), + uploads: {retry: vi.fn(), canRetry: vi.fn()}, + }) as unknown as ReturnType, + [rejections], + ) + + const submit = async (text: string, policy: "queue" | "steer" = "queue") => { + // Mirrors the desktop/mobile submit guard that exposed the original loss: the initial + // fresh-run response must release this before any busy action can be admitted. + if (sending.current) return + sending.current = true + try { + if (policy === "steer") await queue.steer({text}) + else await queue.submit({text}) + } catch { + inputRef.current?.setMarkdown(text) + setRejections([{name: "Message", reason: "wasn't sent — try again."}]) + } finally { + sending.current = false + } + } + + const startFreshRun = async () => { + await submit("start the turn") + setFreshAdmissionReleased(true) + } + const stoppable = isComposerRunStoppable({ + localStreaming: false, + serverBusy: server.busy, + serverControlEnabled: queue.queueEnabled, + waitingOnUser: false, + }) + + return createElement( + Fragment, + null, + createElement( + "button", + {type: "button", onClick: () => void startFreshRun()}, + "Start fresh run", + ), + freshAdmissionReleased ? createElement("span", null, "Fresh admission released") : null, + createElement(QueuedMessagesDock, { + queued: queue.queued, + onRemove: vi.fn(), + held: false, + }), + createElement(ChatComposer, { + inputRef, + onSubmit: (text) => submit(text), + attachments, + streaming: stoppable, + onStop: vi.fn(), + busyActions: + server.busy && queue.queueEnabled + ? [ + {label: "Queue", onSubmit: (text) => void submit(text)}, + ...(queue.steerEnabled + ? [ + { + label: "Steer", + onSubmit: (text: string) => void submit(text, "steer"), + }, + ] + : []), + ] + : undefined, + }), + ) +} + +const setupRunningElsewhereAdmission = async ({refuse = false}: {refuse?: boolean} = {}) => { + const pending: PendingInput[] = [] + let requestCount = 0 + let closeFreshResponse = () => {} + + fetchSnapshot.mockImplementation(async () => runningSnapshot(pending)) + buildAgentRequest.mockImplementation( + async (_entityId: string, messages: UIMessage[], options: {sessionId: string}) => { + const outbound = messages.at(-1) + const content = (outbound?.parts ?? []) + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("") + return { + invocationUrl: "https://agent.test/invoke", + headers: {Accept: "text/event-stream"}, + requestBody: { + session_id: options.sessionId, + data: {inputs: {messages: [{role: "user", content}]}}, + }, + } + }, + ) + fetchMock.mockImplementation(async (_input, init) => { + requestCount += 1 + if (requestCount === 1) { + const body = new ReadableStream({ + start(controller) { + closeFreshResponse = () => controller.close() + }, + }) + return new Response(body, {status: 200}) + } + if (refuse) return new Response(null, {status: 409}) + + const request = JSON.parse(String(init?.body)) as { + data: {inputs: {messages: {role: string; content: string}[]}} + on_busy: "queue" | "steer" + } + const headers = init?.headers as Record + pending.push({ + id: headers["Idempotency-Key"], + session_id: "session-1", + content: {data: {inputs: {messages: request.data.inputs.messages}}}, + position: pending.length + 1, + state: "pending", + policy: request.on_busy, + created_at: null, + promoted_execution_id: null, + }) + return new Response(null, {status: 202}) + }) + + const inputRef = createRef() + render(createElement(RunningElsewhereAdmissionHarness, {inputRef})) + await screen.findByLabelText("Chat message") + await screen.findByRole("button", {name: "Start fresh run"}) + fireEvent.click(screen.getByRole("button", {name: "Start fresh run"})) + await screen.findByText("Fresh admission released") + + return {closeFreshResponse, inputRef} +} + +describe("useServerSessionInputs", () => { + it("reloads capabilities when project scope becomes available", async () => { + const store = createStore() + fetchCapabilities.mockImplementation(async () => + store.get(projectIdAtom) ? {queue: true, steer: true, durableApprovals: true} : null, + ) + fetchSnapshot.mockResolvedValue(runningSnapshot([])) + const {result} = renderHook( + () => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: false, + }), + {wrapper: ({children}) => createElement(Provider, {store}, children)}, + ) + await waitFor(() => expect(fetchCapabilities).toHaveBeenCalledOnce()) + expect(fetchSnapshot).not.toHaveBeenCalled() + act(() => store.set(projectIdAtom, "project-ready")) + await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + expect(fetchCapabilities).toHaveBeenCalledTimes(2) + }) + + it("does not request a queue snapshot when the capability is absent", async () => { + fetchCapabilities.mockResolvedValue({ + durableApprovals: false, + queue: false, + steer: false, + }) + + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: false, + }), + ) + + await waitFor(() => expect(fetchCapabilities).toHaveBeenCalledOnce()) + expect(fetchSnapshot).not.toHaveBeenCalled() + expect(result.current.capabilities).toEqual({queue: false, steer: false}) + expect(result.current.busy).toBe(false) + }) + + it("enables Queue from a snapshot without reconnect data", async () => { + fetchSnapshot.mockResolvedValue({ + session: null, + execution: null, + execution_state: {id: null, state: "idle"}, + read: null, + pending: {inputs: [], interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: false, + }), + ) + + await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + expect(fetchSnapshot).toHaveBeenCalledOnce() + expect(result.current.capabilities.steer).toBe(true) + expect(result.current.executionState).toBe("idle") + }) + + it("shares the mount load with an immediate ready-state refresh", async () => { + let resolveSnapshot!: (snapshot: ReturnType) => void + fetchSnapshot.mockImplementation( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }), + ) + + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: false, + }), + ) + + await waitFor(() => expect(fetchSnapshot).toHaveBeenCalledOnce()) + await act(async () => { + const refresh = result.current.refresh() + resolveSnapshot(runningSnapshot()) + await refresh + }) + + expect(fetchSnapshot).toHaveBeenCalledOnce() + expect(result.current.executionState).toBe("running") + }) + + it.each(["queue", "steer"] as const)( + "negotiates the current reader readiness for %s admission", + async (policy) => { + fetchSnapshot.mockResolvedValue(runningSnapshot()) + buildAgentRequest.mockResolvedValue({ + invocationUrl: "https://agent.test/invoke", + headers: {}, + requestBody: {}, + }) + fetchMock.mockImplementation(async () => new Response(null, {status: 202})) + const {result, rerender} = renderHook( + ({ready}) => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: false, + isSharedReaderReady: () => ready, + }), + {initialProps: {ready: false}}, + ) + await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + const submit = result.current.submit + await act(() => submit({id: "before-ready", text: "first", source: "local"}, policy)) + expect(buildAgentRequest.mock.calls.at(-1)?.[2]).toEqual({sessionId: "session-1"}) + rerender({ready: true}) + await act(() => submit({id: "ready", text: "next", source: "local"}, policy)) + expect(buildAgentRequest.mock.calls.at(-1)?.[2]).toEqual({ + sessionId: "session-1", + sharedResponse: true, + }) + rerender({ready: false}) + await act(() => submit({id: "disconnected", text: "last", source: "local"}, policy)) + expect(buildAgentRequest.mock.calls.at(-1)?.[2]).toEqual({sessionId: "session-1"}) + expect( + fetchMock.mock.calls.map(([, init]) => JSON.parse(String(init?.body)).on_busy), + ).toEqual([policy, policy, policy]) + }, + ) + + it("reads queue support from the snapshot and submits durable admission", async () => { + fetchSnapshot.mockResolvedValue({ + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "turn-1", state: "running"}, + read: {latest_sequence: 0, history_complete: true}, + pending: {inputs: [], interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + buildAgentRequest.mockResolvedValue({ + invocationUrl: "https://agent.test/invoke", + headers: {Accept: "text/event-stream"}, + requestBody: {session_id: "session-1", data: {inputs: {messages: []}}}, + }) + fetchMock.mockResolvedValue(new Response(null, {status: 202})) + + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: true, + }), + ) + + await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + expect(result.current.executionState).toBe("running") + + await act(async () => { + await result.current.submit( + {id: "input-1", text: "run this next", source: "local"}, + "queue", + ) + }) + + expect(fetchSnapshot).toHaveBeenCalledWith("session-1") + expect(buildAgentRequest).toHaveBeenCalledWith( + "revision-1", + [expect.objectContaining({id: "input-1", role: "user"})], + {sessionId: "session-1"}, + ) + expect(fetchMock).toHaveBeenCalledWith( + "https://agent.test/invoke", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({"Idempotency-Key": "input-1"}), + body: JSON.stringify({ + session_id: "session-1", + data: {inputs: {messages: []}}, + on_busy: "queue", + }), + }), + ) + }) + + it("releases admission after a fresh run's headers while its response keeps streaming", async () => { + fetchSnapshot.mockResolvedValue({ + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "turn-1", state: "running"}, + read: {latest_sequence: 0, history_complete: true}, + pending: {inputs: [], interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + buildAgentRequest.mockResolvedValue({ + invocationUrl: "https://agent.test/invoke", + headers: {Accept: "text/event-stream"}, + requestBody: {session_id: "session-1", data: {inputs: {messages: []}}}, + }) + let closeResponse!: () => void + const body = new ReadableStream({ + start(controller) { + closeResponse = () => controller.close() + }, + }) + fetchMock.mockResolvedValue(new Response(body, {status: 200})) + const onExecuted = vi.fn() + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: false, + onExecuted, + }), + ) + await waitFor(() => expect(result.current.capabilities.steer).toBe(true)) + + await act(async () => { + await result.current.submit({id: "input-1", text: "start"}, "queue") + }) + + expect(onExecuted).not.toHaveBeenCalled() + closeResponse() + await waitFor(() => expect(onExecuted).toHaveBeenCalledOnce()) + }) + + it("rejects a refused Steer admission", async () => { + fetchSnapshot.mockResolvedValue({ + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session-1", + }, + execution: null, + execution_state: {id: "turn-1", state: "running"}, + read: {latest_sequence: 0, history_complete: true}, + pending: {inputs: [], interactions: []}, + capabilities: {durable_approvals: true, queue: true, steer: true}, + }) + buildAgentRequest.mockResolvedValue({ + invocationUrl: "https://agent.test/invoke", + headers: {Accept: "text/event-stream"}, + requestBody: {session_id: "session-1", data: {inputs: {messages: []}}}, + }) + fetchMock.mockResolvedValue(new Response(null, {status: 409})) + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [] as UIMessage[], + locallyBusy: true, + }), + ) + await waitFor(() => expect(result.current.capabilities.steer).toBe(true)) + + await act(async () => { + await expect( + result.current.submit({id: "steer-1", text: "redirect"}, "steer"), + ).rejects.toThrow("The input was not accepted (409).") + }) + }) + + it.each([ + ["Enter", "queue"], + ["Queue button", "queue"], + ["Steer button", "steer"], + ] as const)( + "admits %s durably while the source tab looks running elsewhere", + async (interaction, policy) => { + const {closeFreshResponse, inputRef} = await setupRunningElsewhereAdmission() + const text = `say ${interaction}` + act(() => inputRef.current?.setMarkdown(text)) + await waitFor(() => expect(inputRef.current?.getMarkdown()).toBe(text)) + + if (interaction === "Enter") { + const editor = screen.getByLabelText("Chat message") + fireEvent.focus(editor) + fireEvent.keyDown(editor, { + key: "Enter", + code: "Enter", + keyCode: 13, + which: 13, + }) + } else { + await waitFor(() => + expect( + screen.getByRole("button", {name: interaction.split(" ")[0]}), + ).toBeTruthy(), + ) + fireEvent.click(screen.getByRole("button", {name: interaction.split(" ")[0]})) + } + + await screen.findByText("1 queued message") + const admission = fetchMock.mock.calls.at(-1)?.[1] + expect(JSON.parse(String(admission?.body))).toMatchObject({on_busy: policy}) + closeFreshResponse() + }, + ) + + it("keeps the draft and shows the failure card when admission is refused elsewhere", async () => { + const {closeFreshResponse, inputRef} = await setupRunningElsewhereAdmission({refuse: true}) + act(() => inputRef.current?.setMarkdown("keep this draft")) + fireEvent.click(await screen.findByRole("button", {name: "Queue"})) + + await screen.findByTitle("Message wasn't sent — try again.") + expect(inputRef.current?.getMarkdown()).toBe("keep this draft") + expect(screen.queryByText("1 queued message")).toBeNull() + closeFreshResponse() + }) +}) + +describe("selected queued input Send Now", () => { + it("uses the selected row identity without invoking or removing its content", async () => { + fetchSnapshot.mockResolvedValue(runningSnapshot()) + sendInputNow.mockResolvedValue(true) + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: true, + }), + ) + await waitFor(() => expect(result.current.capabilities.steer).toBe(true)) + await act(() => result.current.sendNow("selected-row")) + expect(sendInputNow).toHaveBeenCalledWith({sessionId: "session-1", inputId: "selected-row"}) + expect(removeInput).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + expect(fetchSnapshot.mock.calls.length).toBeGreaterThan(1) + }) + + it("surfaces an admission failure without removing the pending input", async () => { + fetchSnapshot.mockResolvedValue(runningSnapshot()) + sendInputNow.mockResolvedValue(false) + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: true, + }), + ) + await waitFor(() => expect(result.current.capabilities.steer).toBe(true)) + await expect(result.current.sendNow("selected-row")).rejects.toThrow("could not be sent") + expect(removeInput).not.toHaveBeenCalled() + }) + + it("does not call the action when the server capability is disabled", async () => { + fetchCapabilities.mockResolvedValue({durableApprovals: false, queue: false, steer: false}) + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: true, + }), + ) + await expect(result.current.sendNow("selected-row")).rejects.toThrow("not available") + expect(sendInputNow).not.toHaveBeenCalled() + }) +}) + +describe("durable queued input editing", () => { + it("patches only the chosen row text and new attachments, then reloads the shared snapshot", async () => { + fetchSnapshot.mockResolvedValue(runningSnapshot()) + updateInput.mockResolvedValue(true) + const {result} = renderHook(() => + useServerSessionInputs({ + entityId: "revision-1", + sessionId: "session-1", + messages: [], + locallyBusy: true, + }), + ) + await waitFor(() => expect(result.current.capabilities.queue).toBe(true)) + await act(() => + result.current.edit("row-2", { + text: "corrected", + fileParts: [ + { + type: "file", + url: "https://files.test/new.pdf", + mediaType: "application/pdf", + filename: "new.pdf", + providerMetadata: {agenta: {attachmentId: "attachment-1"}}, + }, + ], + }), + ) + expect(updateInput).toHaveBeenCalledWith({ + sessionId: "session-1", + inputId: "row-2", + text: "corrected", + attachments: [ + { + uri: "https://files.test/new.pdf", + mime_type: "application/pdf", + filename: "new.pdf", + attachment_id: "attachment-1", + }, + ], + }) + expect(fetchSnapshot.mock.calls.length).toBeGreaterThan(1) + expect(removeInput).not.toHaveBeenCalled() + expect(buildAgentRequest).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx index 0da1e6c84b7..d4c71467170 100644 --- a/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx +++ b/web/packages/agenta-chat/tests/unit/hooks/useSessionLivePreview.test.tsx @@ -2,7 +2,7 @@ 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 {act, cleanup, renderHook, waitFor} from "@testing-library/react" import {createStore, Provider} from "jotai" import {afterEach, beforeEach, describe, expect, it, vi} from "vitest" @@ -61,10 +61,221 @@ describe("useSessionLivePreview", () => { }) afterEach(() => { + cleanup() vi.useRealTimers() vi.unstubAllGlobals() }) + it.each(["live", "reconnect", "history"])( + "delivers live commit callbacks after confirmed adoption without replaying history (%s)", + async (mode) => { + const output = { + status: "committed", + workflow_revision: { + id: "revision-2", + workflow_variant_id: "variant-1", + version: "2", + }, + } + const rows = [ + { + ...record("call", { + type: "tool_call", + id: "commit-1", + name: "commit_revision", + input: {}, + }), + sequence: 1, + }, + { + ...record("result", { + type: "tool_result", + id: "commit-1", + output: JSON.stringify(output), + }), + sequence: 2, + }, + ] + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + read: {latest_sequence: mode === "history" ? 2 : 0}, + }) + mocks.querySessionTranscript.mockResolvedValue(mode === "history" ? rows : []) + // A watch may already have adopted this watermark: confirmation returns true + // without replacing messages. The notification must still reach the host. + const onDisconnect = vi.fn().mockResolvedValue(true) + const onCommittedRevision = vi.fn() + 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: false, + sender: true, + onDisconnect, + onCommittedRevision, + }), + {wrapper}, + ) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce()) + const notificationParts = () => + onCommittedRevision.mock.calls.map(([revision]) => revision) + expect(notificationParts()).toEqual([]) + if (mode === "history") return + + mocks.querySessionTranscript.mockResolvedValue(rows) + const connection = mocks.connectSessionLiveEvents.mock.calls[0][0] + const event = { + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "result", + sequence: 2, + watermark: 2, + type: "tool.completed", + payload: {tool_call_id: "commit-1", name: "commit_revision", output}, + created_at: "2026-09-06T00:00:00Z", + } + if (mode === "live") act(() => connection.onEvent(event)) + else { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: false}}, + read: {latest_sequence: 2}, + }) + act(() => { + connection.onDisconnect({reason: "connection_lost", reconnect: true}) + document.dispatchEvent(new Event("visibilitychange")) + }) + } + await waitFor(() => expect(notificationParts()).toHaveLength(1)) + expect(notificationParts()[0]).toEqual({ + revisionId: "revision-2", + variantId: "variant-1", + version: "2", + }) + const current = mocks.connectSessionLiveEvents.mock.calls.at(-1)![0] + act(() => current.onEvent(event)) + await act(async () => Promise.resolve()) + expect(notificationParts()).toHaveLength(1) + // A later terminal adoption must not lose the live commit; host revision-ID dedup + // handles repeated notifications while transcript messages remain side-effect-free. + act(() => + current.onEvent({ + ...event, + sequence: 3, + watermark: 3, + frame_or_event_id: "done", + type: "execution.stopped", + payload: {}, + }), + ) + await waitFor(() => expect(notificationParts()).toHaveLength(2)) + for (const [transcript] of onDisconnect.mock.calls) { + expect( + transcript.messages + .flatMap((message: {parts: {type: string}[]}) => message.parts) + .some((part: {type: string}) => part.type === "data-committed-revision"), + ).toBe(false) + } + }, + ) + + it("retires an old approval preview when recovery adopts a newer running turn", async () => { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: false}}, + execution: null, + read: {latest_sequence: 0}, + }) + mocks.querySessionTranscript.mockResolvedValue([]) + const onDisconnect = vi.fn().mockResolvedValue(true) + 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, + }), + {wrapper}, + ) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce()) + const first = mocks.connectSessionLiveEvents.mock.calls[0][0] + const tool = { + type: "tool_call", + id: "bash-call", + name: "bash", + input: {command: "sleep 12"}, + } + mocks.querySessionTranscript.mockResolvedValue([record("call", tool)]) + onDisconnect.mockResolvedValueOnce(false) + act(() => { + first.onFrame({ + version: 1, + kind: "frame", + session_id: "session-1", + execution_id: "old-turn", + frame_or_event_id: "old:0", + frame_index: 0, + entity_id: "bash-call", + type: "tool-input-available", + payload: {toolCallId: "bash-call", toolName: "bash", input: tool.input}, + created_at: "2026-09-06T00:00:00Z", + }) + first.onEvent({ + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "old-turn", + frame_or_event_id: "paused", + sequence: 2, + watermark: 2, + type: "execution.stopped", + payload: {reason: "paused"}, + created_at: "2026-09-06T00:00:01Z", + }) + }) + await waitFor(() => expect(onDisconnect).toHaveBeenCalledTimes(2)) + expect(result.current.messages[0].parts).toMatchObject([{toolCallId: "bash-call"}]) + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + execution: {turn_id: "new-turn", end_time: null}, + read: {latest_sequence: 3}, + }) + act(() => { + first.onDisconnect({reason: "connection_lost", reconnect: true}) + document.dispatchEvent(new Event("visibilitychange")) + }) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(2)) + expect(result.current.messages).toEqual([]) + const second = mocks.connectSessionLiveEvents.mock.calls[1][0] + act(() => + second.onFrame({ + version: 1, + kind: "frame", + session_id: "session-1", + execution_id: "new-turn", + frame_or_event_id: "new:0", + frame_index: 0, + entity_id: "new-answer", + type: "text-delta", + payload: {delta: "New answer stays visible"}, + created_at: "2026-09-06T00:00:02Z", + }), + ) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "New answer stays visible"}, + ]) + }) + it("keeps the flag-off path snapshot-free", async () => { const store = createStore() store.set(projectIdAtom, "project-1") @@ -89,6 +300,40 @@ describe("useSessionLivePreview", () => { expect(result.current.readerReady).toBe(false) }) + it("treats a null-session snapshot as no reconnect data", async () => { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: null, + execution: null, + execution_state: {state: "idle"}, + pending: {inputs: [], interactions: []}, + read: null, + capabilities: {queue: true, steer: true}, + }) + const onDisconnect = vi.fn().mockResolvedValue(true) + const onExecutionSettled = vi.fn() + 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, + onExecutionSettled, + }), + {wrapper}, + ) + + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce()) + expect(mocks.querySessionTranscript).not.toHaveBeenCalled() + expect(onDisconnect).toHaveBeenCalledWith(undefined) + expect(onExecutionSettled).not.toHaveBeenCalled() + }) + it("loads and adopts the transcript through the snapshot before following its cursor", async () => { const records = deferred<[]>() const adopted = deferred() @@ -378,6 +623,233 @@ describe("useSessionLivePreview", () => { expect(mocks.revalidateInteractionStates).toHaveBeenCalledOnce() }) + it("keeps streamed text during durable adoption and preserves its cursor after a tool completes", async () => { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + execution: {turn_id: "turn-1", end_time: null}, + read: {latest_sequence: 0}, + }) + mocks.querySessionTranscript.mockResolvedValue([]) + const adopted = deferred() + const onDisconnect = vi.fn().mockResolvedValueOnce(true).mockReturnValue(adopted.promise) + 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, + }), + {wrapper}, + ) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce()) + const connection = mocks.connectSessionLiveEvents.mock.calls[0][0] + const emit = ( + index: number, + type: string, + payload: Record, + entity = "text-1", + ) => + connection.onFrame({ + version: 1, + kind: "frame", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: `turn-1:${index}`, + frame_index: index, + entity_id: entity, + type, + payload, + created_at: "2026-09-06T00:00:00Z", + }) + act(() => { + emit(0, "text-start", {}) + emit(1, "text-delta", {delta: "Still writing"}) + emit( + 2, + "tool-input-available", + {toolCallId: "tool-1", toolName: "shell", input: {}}, + "tool-1", + ) + connection.onEvent({ + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "record-1", + sequence: 1, + watermark: 1, + type: "tool.completed", + payload: {tool_call_id: "tool-1"}, + created_at: "2026-09-06T00:00:00Z", + }) + }) + expect(result.current.messages[0].parts).toContainEqual({ + type: "text", + text: "Still writing", + }) + await waitFor(() => expect(onDisconnect).toHaveBeenCalledTimes(2)) + act(() => emit(3, "text-delta", {delta: " more"})) + await act(async () => adopted.resolve(true)) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "Still writing more"}, + ]) + act(() => emit(4, "text-delta", {delta: " text"})) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "Still writing more text"}, + ]) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce() + act(() => connection.onDisconnect({reason: "connection_lost", reconnect: true})) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "Still writing more text"}, + ]) + }) + + it("retires snapshot-covered preview after reconnect while keeping unfinished text", async () => { + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + execution: {turn_id: "turn-1", end_time: null}, + read: {latest_sequence: 0}, + }) + mocks.querySessionTranscript.mockResolvedValue([]) + const onDisconnect = vi.fn().mockResolvedValue(true) + 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, + }), + {wrapper}, + ) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledOnce()) + const first = mocks.connectSessionLiveEvents.mock.calls[0][0] + const emit = ( + connection: typeof first, + index: number, + type: string, + payload: Record, + entity: string, + ) => + connection.onFrame({ + version: 1, + kind: "frame", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: `turn-1:${index}`, + frame_index: index, + entity_id: entity, + type, + payload, + created_at: "2026-09-06T00:00:00Z", + }) + act(() => { + emit(first, 0, "text-start", {}, "text-1") + emit(first, 1, "text-delta", {delta: "Saved answer"}, "text-1") + emit(first, 2, "text-end", {}, "text-1") + emit(first, 3, "text-start", {}, "text-2") + emit(first, 4, "text-delta", {delta: "Live prefix"}, "text-2") + first.onDisconnect({reason: "connection_lost", reconnect: true}) + }) + // Disconnection must not adopt an unbounded transcript while its matching + // preview remains visible; snapshot recovery reconciles them together. + expect(onDisconnect).toHaveBeenCalledOnce() + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + execution: {turn_id: "turn-1", end_time: null}, + read: {latest_sequence: 1}, + }) + mocks.querySessionTranscript.mockResolvedValue([ + record("saved-row", {type: "message", message_id: "text-1", text: "Saved answer"}), + ]) + act(() => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }) + document.dispatchEvent(new Event("visibilitychange")) + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }) + document.dispatchEvent(new Event("visibilitychange")) + }) + await waitFor(() => expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(2)) + expect(result.current.messages[0].parts).toEqual([{type: "text", text: "Live prefix"}]) + const second = mocks.connectSessionLiveEvents.mock.calls[1][0] + expect(second.after).toBe(1) + act(() => emit(second, 5, "text-delta", {delta: " continues"}, "text-2")) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "Live prefix continues"}, + ]) + expect(onDisconnect.mock.calls.at(-1)?.[0].messages[0].parts).toContainEqual({ + type: "text", + text: "Saved answer", + }) + act(() => emit(second, 7, "text-delta", {delta: " missing middle"}, "text-2")) + expect(onDisconnect).toHaveBeenCalledTimes(2) + expect(result.current.messages[0].parts).toEqual([ + {type: "text", text: "Live prefix continues"}, + ]) + }) + + it("does not let a pending retry interrupt a reader reopened after visibility changes", async () => { + vi.useFakeTimers() + mocks.fetchSessionSnapshot.mockResolvedValue({ + session: {flags: {is_running: true}}, + execution: {turn_id: "execution-1", end_time: 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 () => vi.advanceTimersByTimeAsync(0)) + const first = mocks.connectSessionLiveEvents.mock.calls[0][0] + act(() => first.onDisconnect({reason: "connection_lost", reconnect: true})) + await act(async () => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }) + document.dispatchEvent(new Event("visibilitychange")) + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }) + document.dispatchEvent(new Event("visibilitychange")) + await vi.advanceTimersByTimeAsync(0) + }) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(2) + const second = mocks.connectSessionLiveEvents.mock.calls[1][0] + act(() => second.onReady({watermark: 7})) + expect(result.current.readerReady).toBe(true) + await act(async () => vi.advanceTimersByTimeAsync(5_000)) + expect(mocks.connectSessionLiveEvents).toHaveBeenCalledTimes(2) + expect(result.current.readerReady).toBe(true) + expect(result.current.runningFromSnapshot).toBe(true) + }) + it("backs reconnects off and resets the delay only after ready", async () => { vi.useFakeTimers() mocks.fetchSessionSnapshot.mockResolvedValue({ diff --git a/web/packages/agenta-chat/tests/unit/model/approvalDockRetirement.test.ts b/web/packages/agenta-chat/tests/unit/model/approvalDockRetirement.test.ts new file mode 100644 index 00000000000..420456b637a --- /dev/null +++ b/web/packages/agenta-chat/tests/unit/model/approvalDockRetirement.test.ts @@ -0,0 +1,45 @@ +/** + * Increment-6 browser pass, round 8, item 3 — the desktop approval dock after a clean approve. + * + * The records below are the REAL durable record log of session + * 973bfdbd-0226-477d-865a-479f4c3e3db1, ordered as `GET /sessions/records` returns them. The dock + * is `open = getPendingApprovals(messages).length > 0`, so this replay is the whole retirement + * contract: from the continuation's first record onward the gate must be gone. + * + * The round-8 report said the dock stayed open reading "Answered, waiting for the agent". The + * screenshots taken at the same moment (evidence 41 and 47) show no dock at all. A closed + * `HeightCollapse` keeps its latched card mounted at height 0 with `aria-hidden` and `inert` + * (web/packages/agenta-ui/src/components/HeightCollapse.tsx), and `ApprovalDock` only resets its + * `answered` flag when the current approval id changes — so a DOM or text read still finds the + * stale eyebrow long after the dock has closed. This test pins the state that actually drives the + * pixels, so the next round measures the same thing the user sees. + */ +import {describe, expect, it} from "vitest" + +import {transcriptToMessages} from "../../../src/assets/transcriptToMessages" +import {getPendingApprovals} from "../../../src/model/approvals" + +import records from "../assets/__fixtures__/approvalDockRetirement.records.json" + +const APPROVAL_ID = "995951ee-bfec-4ef3-bd82-ea8bd0bbe313" +const AFTER_INTERACTION_REQUEST = 4 +const AFTER_SOURCE_PAUSED_DONE = 5 +const AFTER_CONTINUATION_FIRST_THOUGHT = 6 + +const pendingAfter = (count: number): string[] => + getPendingApprovals( + (transcriptToMessages(records.slice(0, count) as never) ?? []) as never, + ).map((approval) => approval.approvalId) + +describe("the approval dock over a real durable continuation", () => { + it("holds the gate while the source turn is parked", () => { + expect(pendingAfter(AFTER_INTERACTION_REQUEST)).toEqual([APPROVAL_ID]) + expect(pendingAfter(AFTER_SOURCE_PAUSED_DONE)).toEqual([APPROVAL_ID]) + }) + + it("retires the gate on the continuation's first record and never re-opens it", () => { + for (let count = AFTER_CONTINUATION_FIRST_THOUGHT; count <= records.length; count += 1) { + expect(pendingAfter(count), `record ${count}`).toEqual([]) + } + }) +}) diff --git a/web/packages/agenta-chat/tests/unit/model/approvals.test.ts b/web/packages/agenta-chat/tests/unit/model/approvals.test.ts index ef15add4998..9ea749ba53b 100644 --- a/web/packages/agenta-chat/tests/unit/model/approvals.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/approvals.test.ts @@ -31,4 +31,50 @@ describe("getPendingApprovals", () => { it("is empty for an empty message list", () => { expect(getPendingApprovals([])).toEqual([]) }) + + it.each(["done", "error"])("retires a stale approval after its continuation is %s", (state) => { + const [, message] = approvalTurnFixture as UIMessage[] + const stale = { + ...message, + metadata: { + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state, + approvalIds: ["appr_1", "appr_2"], + }, + }, + } as UIMessage + + expect(getPendingApprovals([stale])).toEqual([]) + }) + + it("keeps a later interaction out of an earlier continuation's terminal sweep", () => { + const [, message] = approvalTurnFixture as UIMessage[] + const withLaterGate = { + ...message, + parts: [ + ...message.parts, + { + type: "tool-create_issue", + toolCallId: "call_4", + state: "approval-requested", + input: {title: "Follow-up"}, + approval: {id: "appr_3"}, + }, + ], + metadata: { + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state: "done", + approvalIds: ["appr_1", "appr_2"], + }, + }, + } as UIMessage + + expect(getPendingApprovals([withLaterGate])).toEqual([ + {approvalId: "appr_3", toolName: "create_issue", input: {title: "Follow-up"}}, + ]) + }) }) 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 c66ac1d27af..c4c7ce76f4c 100644 --- a/web/packages/agenta-chat/tests/unit/model/error.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/error.test.ts @@ -22,6 +22,20 @@ describe("parseAgentRunError", () => { expect(parseAgentRunError(raw)).toEqual({message: "Boom", code: 500}) }) + it("preserves the continuation race class when the workflow envelope also uses HTTP 409", () => { + const raw = JSON.stringify({ + status: { + type: "https://agenta.ai/docs/errors#continuation-resumed", + code: 409, + message: "The durable continuation owns this session.", + }, + }) + expect(parseAgentRunError(raw)).toEqual({ + message: "The durable continuation owns this session.", + code: "continuation_resumed", + }) + }) + it("falls back to a top-level message when there's no status wrapper", () => { const raw = JSON.stringify({message: "Top level"}) expect(parseAgentRunError(raw)).toEqual({message: "Top level", code: undefined}) diff --git a/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts index 420c50ff6d6..1edd99aacae 100644 --- a/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts +++ b/web/packages/agenta-chat/tests/unit/model/livePreview.test.ts @@ -6,6 +6,9 @@ import { deriveRemoteTurnPresentation, isSessionSnapshotRunning, reduceSessionLivePreview, + retireSessionLivePreview, + retireCoveredSessionLivePreview, + markSessionLivePreviewTerminal, sessionLivePreviewMessages, shouldRefreshLegacyObserverLiveness, shouldSubscribeToSessionLivePreview, @@ -31,6 +34,60 @@ const frame = ( }) describe("session live preview reducer", () => { + it("hands a paused tool preview to its durable approval without leaving running dots", () => { + const toolCallId = "native-bash-call" + const state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame( + 0, + "tool-input-available", + {toolCallId, toolName: "bash", input: {command: "sleep 12"}}, + toolCallId, + ), + ) + const durable = [ + { + id: "saved-approval", + role: "assistant" as const, + parts: [ + { + type: "tool-bash" as const, + toolCallId, + state: "approval-requested" as const, + input: {command: "sleep 12"}, + approval: {id: "approval-1"}, + }, + ], + }, + ] + const retired = retireCoveredSessionLivePreview(state, state, new Set(), durable) + expect(sessionLivePreviewMessages(retired)).toEqual([]) + expect(durable[0].parts[0].state).toBe("approval-requested") + + const completedWhileReading = reduceSessionLivePreview( + state, + frame(1, "tool-output-error", {toolCallId, errorText: "tool failed"}, toolCallId), + ) + const preserved = retireCoveredSessionLivePreview( + completedWhileReading, + state, + new Set(), + durable, + ) + expect(sessionLivePreviewMessages(preserved)[0].parts).toMatchObject([ + {state: "output-error"}, + ]) + const alreadyCompleted = retireCoveredSessionLivePreview( + completedWhileReading, + completedWhileReading, + new Set(), + durable, + ) + expect(sessionLivePreviewMessages(alreadyCompleted)[0].parts).toMatchObject([ + {state: "output-error"}, + ]) + }) + it("removes the control-only invoke message but preserves an invoke error", () => { const accepted = { id: "accepted", @@ -129,20 +186,20 @@ describe("session live preview reducer", () => { 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( + it("accepts a late join cursor without rendering a missing text prefix", () => { + const joined = reduceSessionLivePreview( createSessionLivePreviewState(), frame(2, "text-delta", {delta: "tail"}), ) - const later = reduceSessionLivePreview(gapped, frame(3, "text-delta", {delta: "later"})) + const later = reduceSessionLivePreview(joined, frame(3, "text-delta", {delta: " later"})) - expect(gapped.gapDetected).toBe(true) - expect(gapped.executionOrder).toEqual([]) - expect(sessionLivePreviewMessages(gapped)).toEqual([]) - expect(later).toBe(gapped) + expect(joined.gapDetected).toBe(false) + expect(sessionLivePreviewMessages(joined)).toEqual([]) + expect(sessionLivePreviewMessages(later)).toEqual([]) + expect(later.byExecution["turn-1"].lastFrameIndex).toBe(3) }) - it("clears and suppresses a preview after an internal frame gap", () => { + it("preserves a preview and suppresses further deltas after an internal frame gap", () => { const first = reduceSessionLivePreview( createSessionLivePreviewState(), frame(0, "text-delta", {delta: "hello"}), @@ -151,8 +208,8 @@ describe("session live preview reducer", () => { const missing = reduceSessionLivePreview(gapped, frame(1, "text-delta", {delta: " world"})) expect(gapped.gapDetected).toBe(true) - expect(gapped.executionOrder).toEqual([]) - expect(sessionLivePreviewMessages(gapped)).toEqual([]) + expect(gapped.executionOrder).toEqual(["turn-1"]) + expect(sessionLivePreviewMessages(gapped)).toEqual(sessionLivePreviewMessages(first)) expect(missing).toBe(gapped) }) @@ -257,32 +314,354 @@ describe("legacy observer liveness refresh", () => { }) }) +describe("durable preview handoff", () => { + it("retires only adopted tool output while preserving text and the next frame cursor", () => { + let state = createSessionLivePreviewState() + state = reduceSessionLivePreview(state, frame(0, "text-start", {})) + state = reduceSessionLivePreview(state, frame(1, "text-delta", {delta: "Still writing"})) + state = reduceSessionLivePreview( + state, + frame( + 2, + "tool-input-available", + { + toolCallId: "tool-1", + toolName: "shell", + input: {}, + }, + "tool-1", + ), + ) + state = retireSessionLivePreview(state, { + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "record-1", + sequence: 1, + watermark: 1, + type: "tool.completed", + payload: {tool_call_id: "tool-1"}, + created_at: "2026-09-06T00:00:00Z", + }) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Still writing"}, + ]) + state = reduceSessionLivePreview(state, frame(3, "text-delta", {delta: " more"})) + expect(state.gapDetected).toBe(false) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Still writing more"}, + ]) + state = reduceSessionLivePreview( + state, + frame( + 4, + "tool-output-available", + { + toolCallId: "tool-1", + output: "done", + }, + "tool-1", + ), + ) + expect(sessionLivePreviewMessages(state)[0].parts).toHaveLength(1) + }) + + it("renders a resumed execution whose paused turn had no preview frames", () => { + let state = markSessionLivePreviewTerminal(createSessionLivePreviewState(), { + execution_id: "turn-1", + created_at: "2026-09-06T00:00:00Z", + }) + state = reduceSessionLivePreview(state, { + ...frame(0, "text-delta", {delta: "Resumed"}), + created_at: "2026-09-06T00:00:01Z", + }) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Resumed"}, + ]) + }) + + it("preserves a same-execution resumed prompt when paused adoption finishes late", () => { + let state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "text-delta", {delta: "Before approval"}), + ) + const boundary = state + state = markSessionLivePreviewTerminal(state, { + execution_id: "turn-1", + created_at: "2026-09-06T00:00:00Z", + }) + const marked = state + state = reduceSessionLivePreview(state, { + ...frame(12, "text-delta", {delta: "Delayed old text"}, "late-old-entity"), + created_at: "2026-09-05T23:59:59Z", + }) + expect(state).toBe(marked) + state = reduceSessionLivePreview(state, { + ...frame(0, "text-start", {}, "resumed-text"), + created_at: "2026-09-06T00:00:01Z", + }) + state = reduceSessionLivePreview(state, { + ...frame(1, "text-delta", {delta: "Resumed"}, "resumed-text"), + created_at: "2026-09-06T00:00:01Z", + }) + state = retireSessionLivePreview( + state, + { + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "paused-record", + sequence: 2, + watermark: 2, + type: "execution.stopped", + payload: {reason: "paused"}, + created_at: "2026-09-06T00:00:00Z", + }, + boundary, + ) + state = reduceSessionLivePreview(state, { + ...frame(2, "text-delta", {delta: " successfully"}, "resumed-text"), + created_at: "2026-09-06T00:00:01Z", + }) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Resumed successfully"}, + ]) + expect(state.gapDetected).toBe(false) + }) + + it("retires only reasoning actually present in the adopted durable transcript", () => { + let state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "reasoning-start", {}, "reason-1"), + ) + state = reduceSessionLivePreview( + state, + frame(1, "reasoning-delta", {delta: "Earlier thought"}, "reason-1"), + ) + state = reduceSessionLivePreview(state, frame(2, "reasoning-end", {}, "reason-1")) + state = reduceSessionLivePreview(state, frame(3, "text-start", {}, "text-1")) + state = reduceSessionLivePreview(state, frame(4, "text-delta", {delta: "Answer"}, "text-1")) + const boundary = state + state = reduceSessionLivePreview(state, frame(5, "reasoning-start", {}, "reason-2")) + state = reduceSessionLivePreview( + state, + frame(6, "reasoning-delta", {delta: "Still thinking"}, "reason-2"), + ) + const durable = [ + { + id: "durable", + role: "assistant" as const, + parts: [ + {type: "reasoning" as const, text: "Earlier thought"}, + {type: "text" as const, text: "Answer"}, + ], + }, + ] + state = retireSessionLivePreview( + state, + { + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "text-record", + sequence: 2, + watermark: 2, + type: "message.completed", + payload: {message_id: "text-1"}, + created_at: "2026-09-06T00:00:00Z", + }, + boundary, + durable, + ) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "reasoning", text: "Still thinking"}, + ]) + expect( + [...durable, ...sessionLivePreviewMessages(state)] + .flatMap((message) => message.parts) + .filter((part) => part.type === "reasoning" && part.text === "Earlier thought"), + ).toHaveLength(1) + }) + + it("continues new complete entities after a gap without joining incomplete text", () => { + let state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "text-delta", {delta: "Prefix"}), + ) + state = reduceSessionLivePreview(state, frame(2, "text-delta", {delta: "missing middle"})) + state = {...state, gapDetected: false} + state = reduceSessionLivePreview(state, frame(5, "text-start", {}, "new-text")) + state = reduceSessionLivePreview( + state, + frame(6, "text-delta", {delta: "New complete message"}, "new-text"), + ) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Prefix"}, + {type: "text", text: "New complete message"}, + ]) + }) + + it("retires a terminal execution without erasing another live execution", () => { + let state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "text-delta", {delta: "Finished"}), + ) + state = reduceSessionLivePreview(state, { + ...frame(0, "text-delta", {delta: "Next turn"}), + execution_id: "turn-2", + }) + state = retireSessionLivePreview(state, { + version: 1, + kind: "event", + session_id: "session-1", + execution_id: "turn-1", + frame_or_event_id: "record-done", + sequence: 2, + watermark: 2, + type: "execution.stopped", + payload: {}, + created_at: "2026-09-06T00:00:00Z", + }) + expect(sessionLivePreviewMessages(state).map((message) => message.parts)).toEqual([ + [{type: "text", text: "Next turn"}], + ]) + const late = reduceSessionLivePreview(state, frame(1, "text-delta", {delta: "late"})) + expect(sessionLivePreviewMessages(late)).toEqual(sessionLivePreviewMessages(state)) + }) + + it("retains visible text when a missing frame requires durable catch-up", () => { + let state = reduceSessionLivePreview( + createSessionLivePreviewState(), + frame(0, "text-start", {}), + ) + state = reduceSessionLivePreview(state, frame(1, "text-delta", {delta: "Visible prefix"})) + state = reduceSessionLivePreview(state, frame(3, "text-delta", {delta: "after gap"})) + expect(state.gapDetected).toBe(true) + expect(sessionLivePreviewMessages(state)[0].parts).toEqual([ + {type: "text", text: "Visible prefix"}, + ]) + }) +}) + describe("remote turn presentation", () => { + it("ignores liveness cached before shared completion", () => { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: true, + snapshotRunning: false, + sharedSettledAt: 20, + livenessUpdatedAt: 10, + sharedReaderAdvertised: true, + readerReady: true, + }), + ).toEqual({showActivity: false, showRemoteStop: false}) + }) + + it("keeps an accepted continuation active before its first shared event", () => { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: false, + snapshotRunning: false, + sharedSettledAt: 20, + livenessUpdatedAt: 10, + sharedReaderAdvertised: true, + readerReady: true, + ownedContinuation: true, + }), + ).toEqual({showActivity: true, showRemoteStop: false}) + }) + + it("allows a new remote run after liveness is refreshed", () => { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: true, + livenessUpdatedAt: 30, + sharedSettledAt: 20, + snapshotRunning: false, + sharedReaderAdvertised: true, + readerReady: true, + }).showActivity, + ).toBe(true) + }) + + it("uses liveness until any shared completion is known", () => { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: true, + snapshotRunning: false, + sharedSettledAt: 0, + livenessUpdatedAt: 10, + sharedReaderAdvertised: true, + readerReady: true, + }).showActivity, + ).toBe(true) + }) + + it("keeps activity across reader connection changes and clears when the execution settles", () => { + for (const readerReady of [false, true, false]) { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: false, + snapshotRunning: true, + sharedReaderAdvertised: true, + readerReady, + }).showActivity, + ).toBe(true) + } + for (const sharedReaderAdvertised of [false, true]) { + for (const readerReady of [false, true]) { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: false, + snapshotRunning: false, + sharedReaderAdvertised, + readerReady, + }), + ).toEqual({showActivity: false, showRemoteStop: false}) + } + } + }) + + it("shows activity for accepted sender ownership before snapshot liveness catches up", () => { + expect( + deriveRemoteTurnPresentation({ + livenessRunning: false, + snapshotRunning: true, + sharedReaderAdvertised: true, + readerReady: false, + ownedContinuation: true, + }), + ).toEqual({showActivity: true, showRemoteStop: false}) + }) + it.each([ { name: "uses turn activity once the advertised reader is ready", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: true}, - expected: {showActivity: true, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, { - name: "uses the fallback strip before the reader is ready", + name: "keeps activity while the reader connects and offers remote Stop", input: {livenessRunning: true, sharedReaderAdvertised: true, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "uses the fallback strip when the feature is off", + name: "keeps activity for a legacy observer and offers remote Stop", input: {livenessRunning: true, sharedReaderAdvertised: false, readerReady: false}, - expected: {showActivity: false, showStrip: true}, + expected: {showActivity: true, showRemoteStop: true}, }, { - name: "never gives an owned continuation the fallback strip", + name: "shows activity for an owned continuation without remote Stop", input: { livenessRunning: true, sharedReaderAdvertised: true, readerReady: false, ownedContinuation: true, }, - expected: {showActivity: false, showStrip: false}, + expected: {showActivity: true, showRemoteStop: false}, }, ])("$name", ({input, expected}) => { expect(deriveRemoteTurnPresentation(input)).toEqual(expected) @@ -296,16 +675,16 @@ describe("remote turn presentation", () => { } expect(deriveRemoteTurnPresentation({...base, livenessRunning: true})).toEqual({ - showActivity: false, - showStrip: true, + showActivity: true, + showRemoteStop: true, }) expect(deriveRemoteTurnPresentation({...base, livenessRunning: false})).toEqual({ showActivity: false, - showStrip: false, + showRemoteStop: false, }) }) - it("uses activity instead of the banner when the shared reader is ready", () => { + it("uses snapshot activity when the shared reader is ready", () => { expect( deriveRemoteTurnPresentation({ livenessRunning: false, @@ -313,6 +692,6 @@ describe("remote turn presentation", () => { sharedReaderAdvertised: true, readerReady: true, }), - ).toEqual({showActivity: true, showStrip: false}) + ).toEqual({showActivity: true, showRemoteStop: false}) }) }) diff --git a/web/packages/agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts b/web/packages/agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts index 11c8f0bd6ef..2c00805b5bb 100644 --- a/web/packages/agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts +++ b/web/packages/agenta-chat/tests/unit/transport/sessionLiveEvents.test.ts @@ -46,7 +46,9 @@ describe("connectSessionLiveEvents", () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined) connectSessionLiveEvents({ sessionId: "session-1", + after: 0, onFrame, + onEvent: vi.fn(), onReady: vi.fn(), onDisconnect: vi.fn(), }) diff --git a/web/packages/agenta-entities/src/session/api/api.ts b/web/packages/agenta-entities/src/session/api/api.ts index 6d2fd8dd08a..6d8ebfdd10a 100644 --- a/web/packages/agenta-entities/src/session/api/api.ts +++ b/web/packages/agenta-entities/src/session/api/api.ts @@ -13,6 +13,8 @@ import {z} from "zod" import {safeParseWithLogging} from "../../shared/utils/zodSchema" import { mountFileContentResponseSchema, + pendingInputAdmissionResponseSchema, + pendingInputResponseSchema, mountFileListResponseSchema, sessionInteractionResponseSchema, sessionInteractionsResponseSchema, @@ -152,7 +154,12 @@ export interface SessionScopedParams { abortSignal?: AbortSignal } -/** Fetch the lifecycle/pending state and durable sequence watermark used to reconnect safely. */ +/** + * The one snapshot read. It carries the reconnect half (the stream row, the last turn, the + * durable watermark) and the queue half (the current lifecycle, the pending inputs, the feature + * capabilities), so the live preview and the durable queue never ask two endpoints that can + * disagree. + */ export async function fetchSessionSnapshot({ sessionId, projectId, @@ -169,7 +176,183 @@ export async function fetchSessionSnapshot({ ) if (!data) return null - return safeParseWithLogging(sessionSnapshotSchema, data, "[fetchSessionSnapshot]") + return safeParseWithLogging(sessionSnapshotSchema, data, "[fetchSessionSnapshot]") ?? null +} + +export async function removePendingSessionInput({ + sessionId, + projectId, + appId, + abortSignal, + inputId, +}: SessionScopedParams & {inputId: string}): Promise { + if (!projectId || !sessionId || !inputId) return false + + const data = await callFern("[removePendingSessionInput]", () => + getSessionsClient().removePendingSessionInput( + {session_id: sessionId, input_id: inputId}, + projectScopedRequest(projectId, appId, abortSignal), + ), + ) + return !!data +} + +export async function updatePendingSessionInput({ + sessionId, + projectId, + appId, + abortSignal, + inputId, + text, + attachments, +}: SessionScopedParams & { + inputId: string + text: string + attachments?: { + uri: string + mime_type: string + filename?: string + attachment_id?: string + }[] +}): Promise { + if (!projectId || !sessionId || !inputId) return false + const data = await callFern("[updatePendingSessionInput]", () => + getSessionsClient().updatePendingSessionInput( + {session_id: sessionId, input_id: inputId, text, attachments}, + projectScopedRequest(projectId, appId, abortSignal), + ), + ) + return ( + safeParseWithLogging(pendingInputResponseSchema, data, "[updatePendingSessionInput]") !== + null + ) +} + +export async function sendPendingSessionInputNow({ + sessionId, + projectId, + appId, + abortSignal, + inputId, +}: SessionScopedParams & {inputId: string}): Promise { + if (!projectId || !sessionId || !inputId) return false + const data = await callFern("[sendPendingSessionInputNow]", () => + getSessionsClient().sendPendingSessionInputNow( + {session_id: sessionId, input_id: inputId}, + projectScopedRequest(projectId, appId, abortSignal), + ), + ) + return ( + safeParseWithLogging( + pendingInputAdmissionResponseSchema, + data, + "[sendPendingSessionInputNow]", + ) !== null + ) +} + +const SESSION_CAPABILITY_TIMEOUT_SECONDS = 2 +const SESSION_CAPABILITY_NEGATIVE_RETRY_MS = 30_000 + +export interface SessionFeatureCapabilities { + durableApprovals: boolean + queue: boolean + steer: boolean +} + +interface SessionCapabilityCacheEntry { + result?: SessionFeatureCapabilities + retryAt?: number + request?: Promise +} + +const durableApprovalsCapabilityCache = new Map() + +const durableApprovalsCapabilityKey = ({projectId, sessionId}: SessionScopedParams): string => + JSON.stringify([projectId, sessionId]) + +export function invalidateSessionDurableApprovalsCapability( + params?: Pick, +): void { + if (!params) { + durableApprovalsCapabilityCache.clear() + return + } + durableApprovalsCapabilityCache.delete(durableApprovalsCapabilityKey(params)) +} + +const hasSessionCapability = (capabilities: SessionFeatureCapabilities): boolean => + capabilities.durableApprovals || capabilities.queue || capabilities.steer + +const cachedSessionCapabilities = (key: string): SessionFeatureCapabilities | null => { + const cached = durableApprovalsCapabilityCache.get(key) + if (!cached?.result) return null + if (hasSessionCapability(cached.result) || Date.now() < (cached.retryAt ?? 0)) { + return cached.result + } + return null +} + +export const fetchSessionCapabilities = async ({ + sessionId, + projectId, + appId, + abortSignal, +}: SessionScopedParams): Promise => { + if (!projectId || !sessionId) return null + + const key = durableApprovalsCapabilityKey({projectId, sessionId}) + const cached = cachedSessionCapabilities(key) + if (cached) return cached + + const existing = durableApprovalsCapabilityCache.get(key) + if (existing?.request) return existing.request + + const entry: SessionCapabilityCacheEntry = {} + const request = (async () => { + let capabilities: SessionFeatureCapabilities | null = null + try { + const data = await callFern("[fetchSessionDurableApprovalsCapability]", () => + getSessionsClient().fetchSessionStream( + {session_id: sessionId}, + { + ...projectScopedRequest(projectId, appId, abortSignal), + timeoutInSeconds: SESSION_CAPABILITY_TIMEOUT_SECONDS, + maxRetries: 0, + }, + ), + ) + const validated = data + ? safeParseWithLogging( + sessionStreamResponseSchema, + data, + "[fetchSessionDurableApprovalsCapability]", + ) + : null + capabilities = validated + ? { + durableApprovals: validated.capabilities.durable_approvals, + queue: validated.capabilities.queue, + steer: validated.capabilities.steer, + } + : null + } catch { + capabilities = null + } + + if (durableApprovalsCapabilityCache.get(key) === entry) { + entry.result = capabilities ?? undefined + entry.retryAt = + !capabilities || hasSessionCapability(capabilities) + ? undefined + : Date.now() + SESSION_CAPABILITY_NEGATIVE_RETRY_MS + entry.request = undefined + } + return capabilities + })() + entry.request = request + durableApprovalsCapabilityCache.set(key, entry) + return request } export interface QueryInteractionsParams extends Omit { @@ -249,13 +432,28 @@ export async function fetchInteraction({ export interface RespondInteractionParams extends InteractionScopedParams { /** The answer payload (e.g. an approval decision). Shape is interaction-kind specific. */ - answer: Record + answer?: Record + /** Atomic same-turn answers used by Approve all. */ + answers?: {interactionId: string; answer: Record}[] + /** The execution the approval belongs to. Durable mode serializes this against Stop. */ + expectedExecutionId?: string + /** Stable retry identity. Reusing it with a different answer is a conflict. */ + idempotencyKey?: string +} + +export interface RespondInteractionResult { + interaction: SessionInteraction | null + /** True only when the durable continuation transaction was accepted with HTTP 202. */ + accepted: boolean + command?: {id?: string; state?: string} + execution?: {id?: string; state?: string} } /** True for the backend's `409 Interaction is no longer pending` (someone already answered). * Fern stashes the HTTP status on the thrown `AgentaApiError` as `statusCode`. */ export const isInteractionConflict = (error: unknown): boolean => - (error as {statusCode?: number} | null)?.statusCode === 409 + (error as {statusCode?: number; response?: {status?: number}} | null)?.statusCode === 409 || + (error as {response?: {status?: number}} | null)?.response?.status === 409 /** True for the backend's `404 No such file or folder`. */ const isNotFound = (error: unknown): boolean => @@ -318,20 +516,50 @@ export async function respondInteraction({ appId, abortSignal, answer, -}: RespondInteractionParams): Promise { + answers, + expectedExecutionId, + idempotencyKey, +}: RespondInteractionParams): Promise { if (!projectId || !interactionId) return null - const data = await getSessionsClient().respondInteraction( - {interaction_id: interactionId, answer}, - projectScopedRequest(projectId, appId, abortSignal), - ) - + // Fern preserves the status through `withRawResponse`: 200 is the flag-off dispatcher and + // 202 is durable command acceptance. Both are server-owned continuations; callers must never + // also release the local AI SDK gate. + const request = { + interaction_id: interactionId, + ...(answers + ? { + answers: answers.map((item) => ({ + interaction_id: item.interactionId, + answer: item.answer, + })), + } + : {answer}), + ...(expectedExecutionId ? {expected_execution_id: expectedExecutionId} : {}), + } + const {data, rawResponse} = await getSessionsClient() + .respondInteraction(request, { + ...projectScopedRequest(projectId, appId, abortSignal), + headers: idempotencyKey ? {"Idempotency-Key": idempotencyKey} : undefined, + }) + .withRawResponse() + + const responseData = data as { + interaction?: unknown + command?: {id?: string; state?: string} + execution?: {id?: string; state?: string} + } const validated = safeParseWithLogging( sessionInteractionResponseSchema, - data, + responseData, "[respondInteraction]", ) - return validated?.interaction ?? null + return { + interaction: validated?.interaction ?? null, + accepted: rawResponse.status === 202, + ...(responseData.command ? {command: responseData.command} : {}), + ...(responseData.execution ? {execution: responseData.execution} : {}), + } } /** @@ -632,6 +860,15 @@ export async function fetchSessionStream({ return validated?.stream ?? null } +/** Resolve the approval owner before mutating either the server gate or the local transcript. */ +export async function fetchSessionDurableApprovalsCapability( + params: SessionScopedParams, +): Promise { + const capabilities = await fetchSessionCapabilities(params) + if (!capabilities) throw new Error("Session capabilities are unavailable. Please try again.") + return capabilities.durableApprovals +} + export interface CommandSessionStreamParams extends SessionScopedParams { /** Steal the run lock from whoever holds it. */ force?: boolean @@ -1109,6 +1346,39 @@ export interface CancelSessionExecutionResult { conflict: boolean } +export interface ResumeSessionContinuationParams extends SessionScopedParams {} + +/** + * Ask the API to redeliver an already-durable approval continuation before a direct invoke. + * + * This mutation fails open: continuation recovery is an additive capability and can never make + * an ordinary Send depend on a new route being available. + */ +export async function resumeSessionContinuation({ + sessionId, + projectId, + appId, + abortSignal, +}: ResumeSessionContinuationParams): Promise { + if (!projectId || !sessionId) return false + + try { + const data = await getSessionsClient().resumeSessionContinuation( + {session_id: sessionId}, + projectScopedRequest(projectId, appId, abortSignal), + ) + const parsed = z.object({resumed: z.boolean()}).safeParse(data) + if (!parsed.success) { + console.warn("[resumeSessionContinuation] invalid response; continuing Send") + return false + } + return parsed.data.resumed + } catch (error) { + console.warn("[resumeSessionContinuation] preflight failed; continuing Send", error) + return false + } +} + /** Cancel current work through Fern while keeping the session warm. */ export async function cancelSessionExecution({ sessionId, diff --git a/web/packages/agenta-entities/src/session/core/schema.ts b/web/packages/agenta-entities/src/session/core/schema.ts index b7e55b2f34b..7cc6a2d8b66 100644 --- a/web/packages/agenta-entities/src/session/core/schema.ts +++ b/web/packages/agenta-entities/src/session/core/schema.ts @@ -26,6 +26,7 @@ export const sessionRecordSchema = z record_source: z.string().nullish(), record_type: z.string().nullish(), attributes: z.record(z.string(), z.unknown()).nullish(), + turn_id: z.string().nullish(), timestamp: z.string().nullish(), created_at: z.string().nullish(), }) @@ -38,6 +39,7 @@ export const sessionRecordSchema = z sender: r.record_source ?? null, session_update: r.record_type ?? null, payload: r.attributes ?? null, + turn_id: r.turn_id ?? null, created_at: r.created_at ?? r.timestamp ?? null, })) @@ -90,6 +92,13 @@ export const sessionInteractionResponseSchema = z.object({ interaction: sessionInteractionSchema.nullish(), }) +export const sessionInteractionWatchEventSchema = z.object({ + type: z.literal("interaction"), + session_id: z.string(), + status: z.string(), + interactions: z.array(sessionInteractionSchema).nullish(), +}) + export type SessionInteraction = z.infer /** HITL lifecycle codes. `pending` is the only actionable state. */ @@ -245,15 +254,60 @@ export const sessionRecordsReadStateSchema = z.object({ history_complete: z.boolean(), }) -/** Atomic reconnect read: durable watermark plus lifecycle and pending-work context. */ +export const pendingSessionInputSchema = z.object({ + id: z.string(), + session_id: z.string(), + content: z.record(z.string(), z.unknown()), + position: z.number(), + state: z.enum(["pending", "promoted", "removed"]), + policy: z.enum(["queue", "steer"]), + created_at: z.string().nullish(), + promoted_execution_id: z.string().nullish(), +}) + +export const pendingInputResponseSchema = z.object({ + input: pendingSessionInputSchema, +}) + +export const pendingInputAdmissionResponseSchema = z.object({ + action: z.enum(["execute", "pending"]), + input: pendingSessionInputSchema.nullish(), + execution_id: z.string().nullish(), +}) + +/** + * Atomic read for every reader of an open session. + * + * The nullable reconnect half is `session`, `execution` and `read`: the stream row, the last turn + * (whose `end_time` says whether it is still live), and the durable watermark to replay from. + * + * The queue half is `execution_state` and `pending.inputs`. `execution_state` is the session's + * CURRENT lifecycle, derived server-side from the stream row, which is a different question from + * `execution`: that names the last turn, this says whether anything is running right now. + * `capabilities` mirrors the streams endpoint from the same server helper, so the two can never + * disagree. + */ export const sessionSnapshotSchema = z.object({ - session: sessionStreamSchema, - execution: z.record(z.string(), z.unknown()).nullable().optional(), + session: sessionStreamSchema.nullish().default(null), + execution: z.record(z.string(), z.unknown()).nullish().default(null), + execution_state: z + .object({ + id: z.string().nullish(), + state: z.enum(["idle", "running", "stopping"]).default("idle"), + }) + .default({state: "idle"}), pending: z.object({ - inputs: z.array(z.unknown()).default([]), + inputs: z.array(pendingSessionInputSchema).default([]), interactions: z.array(sessionInteractionSchema).default([]), }), - read: sessionRecordsReadStateSchema, + read: sessionRecordsReadStateSchema.nullish().default(null), + capabilities: z + .object({ + durable_approvals: z.boolean().optional().default(false), + queue: z.boolean().optional().default(false), + steer: z.boolean().optional().default(false), + }) + .default({durable_approvals: false, queue: false, steer: false}), }) export const sessionStreamsResponseSchema = z.object({ @@ -272,6 +326,14 @@ export const sessionsQueryResponseSchema = z.object({ export const sessionStreamResponseSchema = z.object({ stream: sessionStreamSchema.nullish(), + capabilities: z + .object({ + durable_approvals: z.boolean().optional().default(false), + queue: z.boolean().optional().default(false), + steer: z.boolean().optional().default(false), + }) + .optional() + .default({durable_approvals: false, queue: false, steer: false}), }) /** Control-call result for the prompt × force command matrix. */ @@ -311,6 +373,7 @@ export type SessionMessagePreview = z.infer export type SessionWindowing = z.infer export type SessionsQueryResponse = z.infer export type SessionStreamCommandResponse = z.infer +export type PendingSessionInput = z.infer /** One entry in a mount's durable file listing. `path` is relative to the mount root; folders * are flagged (`is_folder`) or implied by nested file paths. The backend lists the whole tree diff --git a/web/packages/agenta-entities/src/session/index.ts b/web/packages/agenta-entities/src/session/index.ts index 1ade6569aca..5b1189e978b 100644 --- a/web/packages/agenta-entities/src/session/index.ts +++ b/web/packages/agenta-entities/src/session/index.ts @@ -19,11 +19,18 @@ export { querySessions, setSessionHeader, fetchSessionStream, + fetchSessionCapabilities, + fetchSessionDurableApprovalsCapability, + removePendingSessionInput, + sendPendingSessionInputNow, + updatePendingSessionInput, + invalidateSessionDurableApprovalsCapability, commandSessionStream, cancelSessionExecution, cancelSessionStream, type CancelSessionOutcome, type CancelSessionStreamParams, + resumeSessionContinuation, killSession, deleteSession as deleteSessionRemote, archiveSession as archiveSessionRemote, @@ -40,6 +47,7 @@ export { type QuerySessionsPageParams, type QuerySessionsParams, type SessionScopedParams, + type SessionFeatureCapabilities, type QueryInteractionsParams, type InteractionScopedParams, type RespondInteractionParams, @@ -47,6 +55,7 @@ export { type CommandSessionStreamParams, type CancelSessionExecutionParams, type CancelSessionExecutionResult, + type ResumeSessionContinuationParams, } from "./api/api" export { getSessionsClient, @@ -65,6 +74,7 @@ export { sessionDurableEventTypeSchema, sessionRecordsReadStateSchema, sessionSnapshotSchema, + pendingSessionInputSchema, sessionsQueryResponseSchema, type SessionRecord, type SessionRecordsQueryResponse, @@ -88,6 +98,7 @@ export { type SessionMessagePreview, type SessionWindowing, type SessionStreamCommandResponse, + type PendingSessionInput, type StreamStatusCode, type CommandMode, mountFileSchema, @@ -95,6 +106,13 @@ export { type MountFile, type Mount, } from "./core/schema" +export { + fetchSessionCapabilitiesAtom, + fetchSessionSnapshotAtom, + removePendingSessionInputAtom, + sendPendingSessionInputNowAtom, + updatePendingSessionInputAtom, +} from "./state/pendingInputs" export { deriveStreamNest, deriveSessionLifecycle, @@ -137,11 +155,19 @@ export { export { fetchSessionInteractionStatesAtom, hasWaitingInteraction, + interactionStatesFromRows, + interactionStatesFromWatchEvent, revalidateSessionInteractionsAtom, type SessionInteractionRowState, type SessionInteractionRowStates, } from "./state/interactionStatus" -export {recordInteractionAnswerAtom} from "./state/interactionAnswer" +export { + recordInteractionAnswerAtom, + respondInteractionAnswerAtom, + respondInteractionAnswersAtom, + resumeSessionContinuationAtom, + sessionDurableApprovalsCapabilityAtom, +} from "./state/interactionAnswer" export { sessionMountsQueryFamily, mountFilesQueryFamily, diff --git a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts index 8d29fda8603..28bba5bb8bb 100644 --- a/web/packages/agenta-entities/src/session/state/interactionAnswer.ts +++ b/web/packages/agenta-entities/src/session/state/interactionAnswer.ts @@ -2,7 +2,12 @@ import {projectIdAtom} from "@agenta/shared/state" import {atom} from "jotai" import {queryClientAtom} from "jotai-tanstack-query" -import {transitionInteraction} from "../api/api" +import { + fetchSessionDurableApprovalsCapability, + respondInteraction, + resumeSessionContinuation, + transitionInteraction, +} from "../api/api" import { fetchSessionInteractionStatesAtom, @@ -21,6 +26,148 @@ const tokenForToolCall = ( return states.has(toolCallId) ? toolCallId : null } +const rowForToolCall = (states: SessionInteractionRowStates, toolCallId: string) => { + for (const state of states.values()) { + if (state.toolCallId === toolCallId) return state + } + return states.get(toolCallId) ?? null +} + +/** + * The final admission check before a chat transport invokes the runner directly. `true` means a + * saved approval continuation owns the session and was redelivered, so the caller must abort its + * competing fresh turn. In flag-off mode the API returns false and this is a no-op. + */ +export const resumeSessionContinuationAtom = atom( + null, + async (get, _set, sessionId: string): Promise => { + const projectId = get(projectIdAtom) ?? "" + if (!(await fetchSessionDurableApprovalsCapability({projectId, sessionId}))) { + return false + } + return resumeSessionContinuation({projectId, sessionId}) + }, +) + +export const sessionDurableApprovalsCapabilityAtom = atom( + null, + async (get, _set, sessionId: string): Promise => { + const projectId = get(projectIdAtom) ?? "" + return fetchSessionDurableApprovalsCapability({projectId, sessionId}) + }, +) + +/** + * Submit a gate answer through the response endpoint and preserve its failure for the card. + * HTTP 202 means the server durably owns continuation; HTTP 200 is the flag-off server dispatcher + * path. Both are server-owned, so callers never also release the local AI SDK gate. + */ +export const respondInteractionAnswerAtom = atom( + null, + async ( + get, + set, + params: { + sessionId: string + toolCallId: string + } & ({approved: boolean} | {resolution: Record}), + ): Promise<{durable: boolean; recoverable: boolean; executionId?: string}> => { + const {sessionId, toolCallId} = params + const answer = + "resolution" in params + ? params.resolution + : {approved: params.approved, tool_call_id: toolCallId} + const projectId = get(projectIdAtom) ?? "" + if (!projectId || !sessionId) throw new Error("Approval has no project or session scope.") + + const queryClient = get(queryClientAtom) + const rowsQueryKey = sessionInteractionRowsQueryKey(projectId, sessionId) + let states = await set(fetchSessionInteractionStatesAtom, sessionId) + let row = rowForToolCall(states, toolCallId) + if (!row) { + await queryClient.invalidateQueries({queryKey: rowsQueryKey}) + states = await set(fetchSessionInteractionStatesAtom, sessionId) + row = rowForToolCall(states, toolCallId) + } + if (!row?.id) throw new Error("This approval is no longer pending. Refresh and retry.") + + const result = await respondInteraction({ + interactionId: row.id, + projectId, + answer, + expectedExecutionId: row.turnId, + idempotencyKey: + "resolution" in params + ? `client-tool:${row.id}` + : `approval:${row.id}:${params.approved ? "approve" : "deny"}`, + }) + if (!result) throw new Error("Approval could not be submitted.") + await queryClient.invalidateQueries({queryKey: rowsQueryKey}) + return { + durable: result.accepted, + recoverable: result.execution?.state === "recoverable", + ...(result.execution?.id ? {executionId: result.execution.id} : {}), + } + }, +) + +/** Submit every approval currently shown by Approve all as one durable transaction. */ +export const respondInteractionAnswersAtom = atom( + null, + async ( + get, + set, + params: { + sessionId: string + toolCallIds: string[] + approved: boolean + }, + ): Promise<{durable: boolean; recoverable: boolean; executionId?: string}> => { + const {sessionId, toolCallIds, approved} = params + const projectId = get(projectIdAtom) ?? "" + if (!projectId || !sessionId) throw new Error("Approval has no project or session scope.") + if (toolCallIds.length === 0) throw new Error("No pending approvals were selected.") + + const queryClient = get(queryClientAtom) + const rowsQueryKey = sessionInteractionRowsQueryKey(projectId, sessionId) + let states = await set(fetchSessionInteractionStatesAtom, sessionId) + let rows = toolCallIds.map((toolCallId) => rowForToolCall(states, toolCallId)) + if (rows.some((row) => !row?.id)) { + await queryClient.invalidateQueries({queryKey: rowsQueryKey}) + states = await set(fetchSessionInteractionStatesAtom, sessionId) + rows = toolCallIds.map((toolCallId) => rowForToolCall(states, toolCallId)) + } + if (rows.some((row) => !row?.id)) { + throw new Error("One or more approvals are no longer pending. Refresh and retry.") + } + + const resolvedRows = rows as NonNullable<(typeof rows)[number]>[] + const executionIds = new Set(resolvedRows.map((row) => row.turnId).filter(Boolean)) + if (executionIds.size !== 1) { + throw new Error("Approve all can only answer approvals from one execution.") + } + const decision = approved ? "approve" : "deny" + const sortedIds = resolvedRows.map((row) => row.id as string).sort() + const result = await respondInteraction({ + interactionId: sortedIds[0], + projectId, + answers: resolvedRows.map((row, index) => ({ + interactionId: row.id as string, + answer: {approved, tool_call_id: toolCallIds[index]}, + })), + expectedExecutionId: resolvedRows[0].turnId, + idempotencyKey: `approval-batch:${sortedIds[0]}:${sortedIds.length}:${decision}`, + }) + if (!result) throw new Error("Approvals could not be submitted.") + await queryClient.invalidateQueries({queryKey: rowsQueryKey}) + return { + durable: result.accepted, + recoverable: result.execution?.state === "recoverable", + ...(result.execution?.id ? {executionId: result.execution.id} : {}), + } + }, +) + /** * Best-effort by design: failures preserve today's in-band resume behavior. * It never blocks or rejects the client-tool resume path. diff --git a/web/packages/agenta-entities/src/session/state/interactionStatus.ts b/web/packages/agenta-entities/src/session/state/interactionStatus.ts index f3478bcf5d5..a86e962258f 100644 --- a/web/packages/agenta-entities/src/session/state/interactionStatus.ts +++ b/web/packages/agenta-entities/src/session/state/interactionStatus.ts @@ -12,6 +12,7 @@ import type { SessionInteractionKind, SessionInteractionStatusCode, } from "../core/schema" +import {sessionInteractionWatchEventSchema} from "../core/schema" const SESSION_INTERACTION_ROWS_STALE_MS = 15_000 @@ -26,7 +27,9 @@ const sessionInteractionRowsQueryOptions = (projectId: string, sessionId: string }) export interface SessionInteractionRowState { + id?: string token: string + turnId?: string status: SessionInteractionStatusCode kind: SessionInteractionKind resolution?: Record @@ -35,23 +38,40 @@ export interface SessionInteractionRowState { export type SessionInteractionRowStates = ReadonlyMap -function interactionStatesFromRows(rows: SessionInteraction[]): SessionInteractionRowStates { +export function interactionStatesFromRows(rows: SessionInteraction[]): SessionInteractionRowStates { const states = new Map() for (const row of rows) { if (typeof row.token !== "string" || !row.token) continue const toolCallId = row.data?.request?.tool_call_id states.set(row.token, { + id: row.id ?? row.token, token: row.token, status: row.status as SessionInteractionStatusCode, kind: row.kind as SessionInteractionKind, ...(row.data?.resolution ? {resolution: row.data.resolution} : {}), ...(typeof toolCallId === "string" && toolCallId ? {toolCallId} : {}), + ...(typeof row.turn_id === "string" && row.turn_id ? {turnId: row.turn_id} : {}), }) } return states } +/** Row states delivered by the session watch relay; undefined means the caller must refetch. */ +export function interactionStatesFromWatchEvent( + data: string, + sessionId: string, +): SessionInteractionRowStates | undefined { + try { + const parsed = sessionInteractionWatchEventSchema.safeParse(JSON.parse(data)) + if (!parsed.success || parsed.data.session_id !== sessionId || !parsed.data.interactions) + return undefined + return interactionStatesFromRows(parsed.data.interactions) + } catch { + return undefined + } +} + /** * Imperative, best-effort fetch through the shared query cache. Never throws — a failure (network, * missing project scope) resolves to an empty map, so a replay-join miss degrades to today's @@ -75,15 +95,18 @@ export const fetchSessionInteractionStatesAtom = atom( }, ) -export const revalidateSessionInteractionsAtom = atom(null, (get, _set, sessionId: string) => { - const projectId = get(projectIdAtom) ?? "" - if (!projectId || !sessionId) return - // Keep an initial rows fetch in flight while marking its cache entry stale. - void get(queryClientAtom).invalidateQueries( - {queryKey: sessionInteractionRowsQueryKey(projectId, sessionId)}, - {cancelRefetch: false}, - ) -}) +export const revalidateSessionInteractionsAtom = atom( + null, + async (get, _set, sessionId: string) => { + const projectId = get(projectIdAtom) ?? "" + if (!projectId || !sessionId) return + // Keep an initial rows fetch in flight while marking its cache entry stale. + await get(queryClientAtom).invalidateQueries( + {queryKey: sessionInteractionRowsQueryKey(projectId, sessionId)}, + {cancelRefetch: false}, + ) + }, +) /** A row whose lifecycle has ended; `pending` is the only other value the API returns. */ const isTerminalRow = (row: SessionInteractionRowState): boolean => diff --git a/web/packages/agenta-entities/src/session/state/livePreview.ts b/web/packages/agenta-entities/src/session/state/livePreview.ts index b8360c34efd..25fe92aec62 100644 --- a/web/packages/agenta-entities/src/session/state/livePreview.ts +++ b/web/packages/agenta-entities/src/session/state/livePreview.ts @@ -3,12 +3,16 @@ import {atomFamily} from "jotai-family" export interface SessionLivePreviewEntityState { part: Record & {type: string} + complete?: boolean } export interface SessionLivePreviewExecution { entityOrder: string[] byEntity: Record lastFrameIndex: number + retiredEntityIds?: string[] + incompleteEntityIds?: string[] + terminalCreatedAt?: string } /** diff --git a/web/packages/agenta-entities/src/session/state/pendingInputs.ts b/web/packages/agenta-entities/src/session/state/pendingInputs.ts new file mode 100644 index 00000000000..a37aef415b5 --- /dev/null +++ b/web/packages/agenta-entities/src/session/state/pendingInputs.ts @@ -0,0 +1,58 @@ +import {projectIdAtom} from "@agenta/shared/state" +import {atom} from "jotai" + +import { + fetchSessionCapabilities, + fetchSessionSnapshot, + removePendingSessionInput, + sendPendingSessionInputNow, + updatePendingSessionInput, +} from "../api/api" + +export const fetchSessionCapabilitiesAtom = atom(null, async (get, _set, sessionId: string) => { + const projectId = get(projectIdAtom) ?? "" + return fetchSessionCapabilities({projectId, sessionId}) +}) + +export const fetchSessionSnapshotAtom = atom(null, async (get, _set, sessionId: string) => { + const projectId = get(projectIdAtom) ?? "" + return fetchSessionSnapshot({projectId, sessionId}) +}) + +export const removePendingSessionInputAtom = atom( + null, + async (get, _set, params: {sessionId: string; inputId: string}) => { + const projectId = get(projectIdAtom) ?? "" + return removePendingSessionInput({projectId, ...params}) + }, +) + +export const sendPendingSessionInputNowAtom = atom( + null, + async (get, _set, params: {sessionId: string; inputId: string}) => { + const projectId = get(projectIdAtom) ?? "" + return sendPendingSessionInputNow({projectId, ...params}) + }, +) + +export const updatePendingSessionInputAtom = atom( + null, + async ( + get, + _set, + params: { + sessionId: string + inputId: string + text: string + attachments?: { + uri: string + mime_type: string + filename?: string + attachment_id?: string + }[] + }, + ) => { + const projectId = get(projectIdAtom) ?? "" + return updatePendingSessionInput({projectId, ...params}) + }, +) diff --git a/web/packages/agenta-entities/tests/unit/interaction-watch-event.test.ts b/web/packages/agenta-entities/tests/unit/interaction-watch-event.test.ts new file mode 100644 index 00000000000..18ecd593477 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/interaction-watch-event.test.ts @@ -0,0 +1,48 @@ +import {describe, expect, it} from "vitest" + +import {interactionStatesFromWatchEvent} from "../../src/session/state/interactionStatus" + +const event = (sessionId = "session-1") => + JSON.stringify({ + type: "interaction", + session_id: sessionId, + status: "resolved", + interactions: [ + { + id: "interaction-1", + session_id: sessionId, + turn_id: "turn-1", + token: "approval-1", + kind: "user_approval", + status: "responded", + data: { + request: {tool_call_id: "tool-1"}, + resolution: {verdict: "approved", tool_call_id: "tool-1"}, + }, + }, + ], + }) + +describe("interactionStatesFromWatchEvent", () => { + it("decodes the committed resolution carried by the session relay", () => { + expect( + interactionStatesFromWatchEvent(event(), "session-1")?.get("approval-1"), + ).toMatchObject({ + id: "interaction-1", + turnId: "turn-1", + toolCallId: "tool-1", + status: "responded", + resolution: {verdict: "approved", tool_call_id: "tool-1"}, + }) + }) + + it("falls back to a query for metadata-only and foreign-session events", () => { + expect( + interactionStatesFromWatchEvent( + JSON.stringify({type: "interaction", session_id: "session-1", status: "resolved"}), + "session-1", + ), + ).toBeUndefined() + expect(interactionStatesFromWatchEvent(event("session-2"), "session-1")).toBeUndefined() + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts new file mode 100644 index 00000000000..92e946afe97 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/session-continuation-resume-api.test.ts @@ -0,0 +1,237 @@ +import type {SessionCapabilities, SessionStreamResponse} from "@agentaai/api-client" +import {beforeEach, describe, expect, expectTypeOf, it, vi} from "vitest" + +const {resume, fetchStream, sendNow, updateInput} = vi.hoisted(() => ({ + resume: vi.fn(), + fetchStream: vi.fn(), + sendNow: vi.fn(), + updateInput: vi.fn(), +})) + +vi.mock("@agenta/sdk/resources", () => ({ + getSessionsClient: () => ({ + resumeSessionContinuation: resume, + sendPendingSessionInputNow: sendNow, + updatePendingSessionInput: updateInput, + fetchSessionStream: fetchStream, + }), + getLowPrioritySessionsClient: vi.fn(), + getMountsClient: vi.fn(), + getLowPriorityMountsClient: vi.fn(), +})) + +import { + fetchSessionCapabilities, + updatePendingSessionInput, + sendPendingSessionInputNow, + fetchSessionDurableApprovalsCapability, + invalidateSessionDurableApprovalsCapability, + resumeSessionContinuation, +} from "../../src/session/api/api" + +beforeEach(() => { + resume.mockReset() + fetchStream.mockReset() + invalidateSessionDurableApprovalsCapability() +}) + +describe("resumeSessionContinuation", () => { + it.each([true, false])("returns resumed=%s from the scoped preflight", async (resumed) => { + resume.mockResolvedValue({resumed}) + + await expect( + resumeSessionContinuation({ + projectId: "project-1", + sessionId: "session/1", + }), + ).resolves.toBe(resumed) + + expect(resume).toHaveBeenCalledWith( + {session_id: "session/1"}, + expect.objectContaining({queryParams: {project_id: "project-1"}}), + ) + }) + + it("fails open when the API response cannot establish ownership", async () => { + resume.mockResolvedValue({resumed: "maybe"}) + + await expect( + resumeSessionContinuation({projectId: "project-1", sessionId: "session-1"}), + ).resolves.toBe(false) + }) + + it("fails open on a continuation transport failure", async () => { + resume.mockRejectedValue(new Error("route missing")) + + await expect( + resumeSessionContinuation({projectId: "project-1", sessionId: "session-1"}), + ).resolves.toBe(false) + }) +}) + +describe("fetchSessionDurableApprovalsCapability", () => { + it("uses the generated named capability model", () => { + expectTypeOf< + NonNullable + >().toEqualTypeOf() + }) + + it("uses the authenticated session response as the capability source", async () => { + fetchStream.mockResolvedValue({ + stream: null, + capabilities: {durable_approvals: true}, + }) + + const scope = {projectId: "project-1", sessionId: "session-1"} + + await expect(fetchSessionDurableApprovalsCapability(scope)).resolves.toBe(true) + await vi.waitFor(() => + expect(fetchSessionDurableApprovalsCapability(scope)).resolves.toBe(true), + ) + expect(fetchStream).toHaveBeenCalledWith( + {session_id: "session-1"}, + expect.objectContaining({timeoutInSeconds: 2, maxRetries: 0}), + ) + }) + + it("returns queue capabilities from the same cached negotiation", async () => { + fetchStream.mockResolvedValue({ + stream: null, + capabilities: {durable_approvals: false, queue: true, steer: true}, + }) + const scope = {projectId: "project-1", sessionId: "session-1"} + + await expect(fetchSessionCapabilities(scope)).resolves.toEqual({ + durableApprovals: false, + queue: true, + steer: true, + }) + await expect(fetchSessionCapabilities(scope)).resolves.toEqual({ + durableApprovals: false, + queue: true, + steer: true, + }) + expect(fetchStream).toHaveBeenCalledOnce() + }) + + it("shares one request per session until the session reconnects", async () => { + fetchStream.mockResolvedValue({ + stream: null, + capabilities: {durable_approvals: true}, + }) + const scope = {projectId: "project-1", sessionId: "session-1"} + + await Promise.all([ + fetchSessionDurableApprovalsCapability(scope), + fetchSessionDurableApprovalsCapability(scope), + ]) + await vi.waitFor(() => expect(fetchStream).toHaveBeenCalledTimes(1)) + await fetchSessionDurableApprovalsCapability(scope) + + expect(fetchStream).toHaveBeenCalledTimes(1) + + invalidateSessionDurableApprovalsCapability(scope) + await fetchSessionDurableApprovalsCapability(scope) + + expect(fetchStream).toHaveBeenCalledTimes(2) + }) + + it.each([["older API", {stream: null}]])( + "uses legacy behavior for %s", + async (_case, response) => { + fetchStream.mockResolvedValue(response) + + await expect( + fetchSessionDurableApprovalsCapability({ + projectId: "project-1", + sessionId: "session-1", + }), + ).resolves.toBe(false) + }, + ) + + it("retries unknown capability without caching it as unsupported", async () => { + fetchStream + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({stream: null, capabilities: {durable_approvals: true}}) + const scope = {projectId: "project-1", sessionId: "session-1"} + await expect(fetchSessionDurableApprovalsCapability(scope)).rejects.toThrow( + "capabilities are unavailable", + ) + await expect(fetchSessionDurableApprovalsCapability(scope)).resolves.toBe(true) + expect(fetchStream).toHaveBeenCalledTimes(2) + }) +}) + +it("keeps an initial approval answer waiting for capability discovery", async () => { + let resolve!: (value: unknown) => void + fetchStream.mockImplementation( + () => + new Promise((done) => { + resolve = done + }), + ) + let settled = false + const result = fetchSessionDurableApprovalsCapability({ + projectId: "project-1", + sessionId: "session-1", + }).then((value) => { + settled = true + return value + }) + await Promise.resolve() + expect(settled).toBe(false) + resolve({stream: null, capabilities: {durable_approvals: true}}) + await expect(result).resolves.toBe(true) +}) +it("keeps missing project scope unknown", async () => { + await expect( + fetchSessionCapabilities({projectId: "", sessionId: "session-1"}), + ).resolves.toBeNull() + expect(fetchStream).not.toHaveBeenCalled() +}) + +it.each([ + [{action: "execute", execution_id: "execution"}, true], + [{action: "pending"}, true], + [{action: "unknown"}, false], + [{}, false], + [{action: "pending", input: {id: "incomplete"}}, false], + [null, false], +])("validates Send Now admission %j", async (response, accepted) => { + sendNow.mockResolvedValue(response) + await expect( + sendPendingSessionInputNow({projectId: "project", sessionId: "session", inputId: "input"}), + ).resolves.toBe(accepted) +}) + +it.each([ + [ + { + input: { + id: "input", + session_id: "session", + content: {data: {inputs: {messages: []}}}, + position: 1, + state: "pending", + policy: "queue", + }, + }, + true, + ], + [{}, false], + [{input: null}, false], + [{input: {id: "input"}}, false], + [{action: "pending"}, false], + [null, false], +])("validates a queued edit receipt %j", async (response, accepted) => { + updateInput.mockResolvedValue(response) + await expect( + updatePendingSessionInput({ + projectId: "project", + sessionId: "session", + inputId: "input", + text: "edited", + }), + ).resolves.toBe(accepted) +}) diff --git a/web/packages/agenta-entities/tests/unit/session-interaction-answer.test.ts b/web/packages/agenta-entities/tests/unit/session-interaction-answer.test.ts new file mode 100644 index 00000000000..87cece6e7ff --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/session-interaction-answer.test.ts @@ -0,0 +1,84 @@ +import {projectIdAtom} from "@agenta/shared/state" +import {QueryClient} from "@tanstack/react-query" +import {createStore} from "jotai" +import {queryClientAtom} from "jotai-tanstack-query" +import {beforeEach, expect, it, vi} from "vitest" +const {respond, transition} = vi.hoisted(() => ({respond: vi.fn(), transition: vi.fn()})) +vi.mock("../../src/session/api/api", () => ({ + respondInteraction: respond, + transitionInteraction: transition, + fetchSessionDurableApprovalsCapability: vi.fn(), + resumeSessionContinuation: vi.fn(), +})) +vi.mock("../../src/session/state/interactionStatus", async () => { + const {atom} = await import("jotai") + return { + sessionInteractionRowsQueryKey: () => ["interaction-rows"], + fetchSessionInteractionStatesAtom: atom( + null, + () => + new Map([ + [ + "questionnaire", + { + id: "interaction-id", + toolCallId: "questionnaire", + token: "token", + turnId: "queued-parent", + }, + ], + ]), + ), + } +}) +import {respondInteractionAnswerAtom} from "../../src/session/state/interactionAnswer" +beforeEach(() => { + respond.mockReset().mockResolvedValue({ + accepted: true, + execution: {id: "answer-child", state: "pending_delivery"}, + }) + transition.mockReset() +}) +it("preserves questionnaire content and stable retry identity without legacy transition", async () => { + const store = createStore() + store.set(projectIdAtom, "project-id") + store.set(queryClientAtom, new QueryClient()) + const resolution = { + tool_call_id: "questionnaire", + tool_name: "request_input", + outcome: "completed", + output: {action: "accept", content: {goal: "Correctness", unchangedDefault: "yes"}}, + } + const args = {sessionId: "session-id", toolCallId: "questionnaire", resolution} + expect(await store.set(respondInteractionAnswerAtom, args)).toEqual({ + durable: true, + recoverable: false, + executionId: "answer-child", + }) + await store.set(respondInteractionAnswerAtom, args) + expect(respond).toHaveBeenNthCalledWith(1, { + projectId: "project-id", + interactionId: "interaction-id", + answer: resolution, + expectedExecutionId: "queued-parent", + idempotencyKey: "client-tool:interaction-id", + }) + expect(respond.mock.calls[1]).toEqual(respond.mock.calls[0]) + expect(transition).not.toHaveBeenCalled() +}) +it("preserves native approval answer and retry identity", async () => { + const store = createStore() + store.set(projectIdAtom, "project-id") + store.set(queryClientAtom, new QueryClient()) + await store.set(respondInteractionAnswerAtom, { + sessionId: "session-id", + toolCallId: "questionnaire", + approved: false, + }) + expect(respond).toHaveBeenCalledWith( + expect.objectContaining({ + answer: {approved: false, tool_call_id: "questionnaire"}, + idempotencyKey: "approval:interaction-id:deny", + }), + ) +}) diff --git a/web/packages/agenta-entities/tests/unit/session-interaction-response-api.test.ts b/web/packages/agenta-entities/tests/unit/session-interaction-response-api.test.ts new file mode 100644 index 00000000000..2e5e49b2e59 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/session-interaction-response-api.test.ts @@ -0,0 +1,114 @@ +import {beforeEach, describe, expect, it, vi} from "vitest" + +const {respond} = vi.hoisted(() => ({respond: vi.fn()})) + +vi.mock("@agenta/sdk/resources", () => ({ + getSessionsClient: () => ({respondInteraction: respond}), + getLowPrioritySessionsClient: vi.fn(), + getMountsClient: vi.fn(), + getLowPriorityMountsClient: vi.fn(), +})) + +import {respondInteraction} from "../../src/session/api/api" + +const interaction = { + id: "interaction-1", + session_id: "session-1", + turn_id: "turn-1", + token: "approval-1", + kind: "user_approval", + status: "responded", + data: {resolution: {approved: true}}, +} + +const response = (status: number, data: unknown) => ({ + withRawResponse: () => Promise.resolve({data, rawResponse: {status}}), +}) + +beforeEach(() => respond.mockReset()) + +describe("respondInteraction", () => { + it("forwards the execution guard and stable retry key and recognizes durable 202", async () => { + respond.mockReturnValue( + response(202, { + interaction, + command: {id: "command-1", state: "pending"}, + execution: {id: "turn-2", state: "pending"}, + }), + ) + + const result = await respondInteraction({ + interactionId: "interaction-1", + projectId: "project-1", + answer: {approved: true, tool_call_id: "approval-1"}, + expectedExecutionId: "turn-1", + idempotencyKey: "approval:interaction-1:approve", + }) + + expect(respond).toHaveBeenCalledWith( + { + interaction_id: "interaction-1", + answer: {approved: true, tool_call_id: "approval-1"}, + expected_execution_id: "turn-1", + }, + expect.objectContaining({ + queryParams: {project_id: "project-1"}, + headers: {"Idempotency-Key": "approval:interaction-1:approve"}, + }), + ) + expect(result).toMatchObject({ + accepted: true, + interaction: {id: "interaction-1"}, + command: {id: "command-1"}, + execution: {id: "turn-2"}, + }) + }) + + it("sends a same-turn approval batch in one request", async () => { + respond.mockReturnValue( + response(202, { + interaction, + command: {id: "command-1", state: "pending"}, + execution: {id: "turn-2", state: "recoverable"}, + }), + ) + + const result = await respondInteraction({ + interactionId: "interaction-1", + projectId: "project-1", + answers: [ + {interactionId: "interaction-1", answer: {approved: true}}, + {interactionId: "interaction-2", answer: {approved: true}}, + ], + expectedExecutionId: "turn-1", + idempotencyKey: "approval-batch:interaction-1:2:approve", + }) + + expect(respond).toHaveBeenCalledWith( + { + interaction_id: "interaction-1", + answers: [ + {interaction_id: "interaction-1", answer: {approved: true}}, + {interaction_id: "interaction-2", answer: {approved: true}}, + ], + expected_execution_id: "turn-1", + }, + expect.objectContaining({ + headers: {"Idempotency-Key": "approval-batch:interaction-1:2:approve"}, + }), + ) + expect(result?.execution?.state).toBe("recoverable") + }) + + it("keeps the flag-off server dispatcher response distinguishable without local resume", async () => { + respond.mockReturnValue(response(200, {interaction})) + + const result = await respondInteraction({ + interactionId: "interaction-1", + projectId: "project-1", + answer: {approved: true}, + }) + + expect(result?.accepted).toBe(false) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts b/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts new file mode 100644 index 00000000000..806390d8bc8 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/session-pending-input-api.test.ts @@ -0,0 +1,88 @@ +import {beforeEach, describe, expect, it, vi} from "vitest" + +const {fetchSnapshot, removeInput} = vi.hoisted(() => ({ + fetchSnapshot: vi.fn(), + removeInput: vi.fn(), +})) + +vi.mock("@agenta/sdk/resources", () => ({ + getSessionsClient: () => ({ + getSessionSnapshot: fetchSnapshot, + removePendingSessionInput: removeInput, + }), + getLowPrioritySessionsClient: vi.fn(), + getMountsClient: vi.fn(), + getLowPriorityMountsClient: vi.fn(), +})) + +import { + fetchSessionSnapshot as readSnapshot, + removePendingSessionInput, +} from "../../src/session/api/api" + +beforeEach(() => { + fetchSnapshot.mockReset() + removeInput.mockReset() +}) + +describe("session pending-input API", () => { + it("reads the shared snapshot through the scoped Fern client", async () => { + fetchSnapshot.mockResolvedValue({ + session: { + id: "11111111-1111-4111-8111-111111111111", + project_id: "22222222-2222-4222-8222-222222222222", + session_id: "session/1", + }, + execution: null, + execution_state: {state: "running"}, + pending: {inputs: [], interactions: []}, + read: {latest_sequence: 0, history_complete: true}, + capabilities: {queue: true, steer: false}, + }) + + await expect( + readSnapshot({projectId: "project-1", sessionId: "session/1"}), + ).resolves.toMatchObject({capabilities: {queue: true, steer: false}}) + expect(fetchSnapshot).toHaveBeenCalledWith( + {session_id: "session/1"}, + expect.objectContaining({queryParams: {project_id: "project-1"}}), + ) + }) + + it("accepts a queue snapshot without reconnect data", async () => { + fetchSnapshot.mockResolvedValue({ + session: null, + execution: null, + execution_state: {state: "idle"}, + pending: {inputs: [], interactions: []}, + read: null, + capabilities: {queue: true, steer: true}, + }) + + await expect( + readSnapshot({projectId: "project-1", sessionId: "session-1"}), + ).resolves.toMatchObject({ + session: null, + execution: null, + read: null, + execution_state: {state: "idle"}, + capabilities: {queue: true, steer: true}, + }) + }) + + it("removes a pending input through the generated route", async () => { + removeInput.mockResolvedValue({input: {id: "input-1"}}) + + await expect( + removePendingSessionInput({ + projectId: "project-1", + sessionId: "session/1", + inputId: "input-1", + }), + ).resolves.toBe(true) + expect(removeInput).toHaveBeenCalledWith( + {session_id: "session/1", input_id: "input-1"}, + expect.objectContaining({queryParams: {project_id: "project-1"}}), + ) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/session-record-schema.test.ts b/web/packages/agenta-entities/tests/unit/session-record-schema.test.ts index 4b65ab4b26e..5c85229ab87 100644 --- a/web/packages/agenta-entities/tests/unit/session-record-schema.test.ts +++ b/web/packages/agenta-entities/tests/unit/session-record-schema.test.ts @@ -21,6 +21,7 @@ const wireRecord = { record_source: "runner", record_type: "message", attributes: {type: "message", text: "hello"}, + turn_id: "turn-1", timestamp: "2026-07-07T00:00:00Z", created_at: "2026-07-07T00:00:01Z", } @@ -34,6 +35,7 @@ describe("sessionRecordSchema", () => { expect(out.payload).toEqual({type: "message", text: "hello"}) expect(out.event_index).toBe(3) expect(out.session_update).toBe("message") + expect(out.turn_id).toBe("turn-1") expect(out.created_at).toBe("2026-07-07T00:00:01Z") }) @@ -63,6 +65,7 @@ describe("sessionRecordSchema", () => { expect(out.id).toBe("rec-2") expect(out.payload).toEqual({type: "thought", text: "…"}) expect(out.sender).toBeNull() + expect(out.turn_id).toBeNull() }) it("validates the query response envelope and remaps each record", () => { diff --git a/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts b/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts index 5123c679348..f955aeeabfe 100644 --- a/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts +++ b/web/packages/agenta-entity-ui/src/clientTools/useConnectFlow.ts @@ -209,6 +209,7 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a // One-shot guard so THIS instance settles the parked call at most once, plus shared cleanup for // the running popup's listener/poll/timeout. `meta.settled` covers the OTHER instance's settle. const settledRef = useRef(false) + const pendingAnswerRef = useRef(null) const activeRef = useRef(active) activeRef.current = active const popupRef = useRef(null) @@ -228,12 +229,33 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a teardown() // Leave "connecting" and record the terminal result so the chip paints now. setPhase("idle") - if ("errorText" in result) { - setOutcome({connected: false, reason: result.errorText}) - settle({errorText: result.errorText}) - } else { - setOutcome({connected: result.connected === true, reason: result.reason}) - settle({output: result as Record}) + setErrorText(null) + const onSubmissionError = (error: unknown) => { + // Retry the same answer without creating the connection again. + pendingAnswerRef.current = result + settledRef.current = false + setOutcome(null) + setErrorText( + error instanceof Error + ? error.message + : "Could not save the answer. Try again.", + ) + } + try { + const submission = + "errorText" in result + ? settle({errorText: result.errorText}) + : settle({output: result as Record}) + setOutcome( + "errorText" in result + ? {connected: false, reason: result.errorText} + : {connected: result.connected === true, reason: result.reason}, + ) + void Promise.resolve(submission).then(() => { + pendingAnswerRef.current = null + }, onSubmissionError) + } catch (error) { + onSubmissionError(error) } }, [settle, teardown], @@ -262,6 +284,10 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a if (phase === "connecting") return if (settleParkedCall && (!activeRef.current || settledRef.current || meta.settled)) return + if (settleParkedCall && pendingAnswerRef.current) { + finish(pendingAnswerRef.current) + return + } // The integration-detail lookup that picks the real auth mode hasn't resolved yet — // proceeding here would send the agent's raw (possibly wrong, e.g. "oauth" for a // toolkit that only supports api_key) hint. The button is disabled for this same @@ -391,6 +417,7 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a // Explicit cancel while the popup is open: settle the parked call as cancelled (or, when the // call is already settled — a manual retry — just stop). const cancel = useCallback(() => { + if (pendingAnswerRef.current) return teardown() if (!settledRef.current && !meta.settled) finish({connected: false, integration, slug, reason: "cancelled"}) @@ -401,7 +428,7 @@ export const useConnectFlow = (meta: ClientToolMeta, settle: SettleClientTool, a // can respond gracefully / offer an alternative. Distinct from "cancelled" (abandoned popup) so // the agent can tell an explicit decline from a mishap. const decline = useCallback(() => { - if (settledRef.current || meta.settled) return + if (settledRef.current || meta.settled || pendingAnswerRef.current) return finish({connected: false, integration, slug, reason: "declined"}) }, [finish, integration, slug, meta.settled]) diff --git a/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts b/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts index 3c1d1288484..e739d2b28e2 100644 --- a/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts +++ b/web/packages/agenta-entity-ui/tests/unit/useConnectFlow.test.ts @@ -8,12 +8,25 @@ * error surfaced anywhere — see the ConnectToolWidget KNOWN_CONNECT_REASONS branch this * message feeds). */ -import {describe, expect, it} from "vitest" +import {act, createElement} from "react" +import {createRoot} from "react-dom/client" +import {describe, expect, it, vi} from "vitest" + +const {handleCreate} = vi.hoisted(() => ({handleCreate: vi.fn(async () => ({connection: {}}))})) + +vi.mock("@agenta/entities/gatewayTool", () => ({ + useToolIntegrationDetail: () => ({integration: {auth_schemes: ["oauth"]}, isLoading: false}), + useToolsConnections: () => ({ + handleCreate, + invalidate: vi.fn(), + }), +})) import { extractConnectErrorMessage, isConnectModeResolving, resolveConnectMode, + useConnectFlow, } from "../../src/clientTools/useConnectFlow" describe("resolveConnectMode", () => { @@ -102,3 +115,53 @@ describe("extractConnectErrorMessage", () => { expect(extractConnectErrorMessage(null)).toBe("Connection failed. Please try again.") }) }) + +describe("durable connection answer", () => { + it("keeps a rejected parked answer retryable instead of reporting connected", async () => { + const settle = vi + .fn() + .mockRejectedValueOnce(new Error("Answer was not saved")) + .mockResolvedValue(undefined) + const meta = { + toolCallId: "connect-1", + input: {integration: "github"}, + settled: false, + } as Parameters[0] + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true) + const host = document.createElement("div") + const root = createRoot(host) + let flow!: ReturnType + const Probe = () => { + flow = useConnectFlow(meta, settle) + return null + } + await act(async () => { + root.render(createElement(Probe)) + }) + await act(async () => { + await flow.runConnect(true) + }) + expect(flow.errorText).toBe("Answer was not saved") + expect(flow.outcome).toBeNull() + expect(flow.phase).toBe("idle") + await act(async () => { + flow.decline() + flow.cancel() + }) + expect(settle).toHaveBeenCalledTimes(1) + await act(async () => { + await flow.runConnect(true) + }) + expect(flow.outcome?.connected).toBe(true) + expect(flow.errorText).toBeNull() + expect(handleCreate).toHaveBeenCalledTimes(1) + expect(settle).toHaveBeenCalledTimes(2) + expect(settle).toHaveBeenLastCalledWith({ + output: {connected: true, integration: "github", slug: "github"}, + }) + await act(async () => { + root.unmount() + }) + vi.unstubAllGlobals() + }) +}) diff --git a/web/packages/agenta-playground/src/agentChat.ts b/web/packages/agenta-playground/src/agentChat.ts index d7de7e781f9..c56b4fffcec 100644 --- a/web/packages/agenta-playground/src/agentChat.ts +++ b/web/packages/agenta-playground/src/agentChat.ts @@ -21,5 +21,10 @@ export { type ChatStatusLike, } from "./state/execution/approvalAnswer" export {RECORD_ANSWER_TIMEOUT_MS, recordAnswerThenRelease} from "./state/execution/answerOrdering" -export {canReleaseQueuedMessage, isHitlPending} from "./state/execution/agentMessageQueue" +export { + approvalContinuationSettled, + canReleaseQueuedMessage, + hasRunningApprovalContinuation, + isHitlPending, +} from "./state/execution/agentMessageQueue" export {createNegotiatingFetch, type NegotiatingFetch} from "./state/execution/agentNegotiation" diff --git a/web/packages/agenta-playground/src/index.ts b/web/packages/agenta-playground/src/index.ts index ebdac541c57..7127516f028 100644 --- a/web/packages/agenta-playground/src/index.ts +++ b/web/packages/agenta-playground/src/index.ts @@ -90,7 +90,13 @@ export {RECORD_ANSWER_TIMEOUT_MS, recordAnswerThenRelease} from "./state" // Render-hint map for interaction kinds (sibling `data-render` parts → toolCallId lookup). export {buildRenderMap, renderKindFor, type RenderHintLike} from "./state" // Queued-message release gate for the agent chat composer (HITL-safe, one-by-one). -export {canReleaseQueuedMessage, isHitlPending, messageHasPendingHitl} from "./state" +export { + approvalContinuationSettled, + canReleaseQueuedMessage, + hasRunningApprovalContinuation, + isHitlPending, + messageHasPendingHitl, +} from "./state" // Per-turn request capture + correlation helpers (Turn Inspector Context/Raw tabs). export { appendCapped, diff --git a/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts b/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts index 05ea701ecf5..18390795daf 100644 --- a/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts +++ b/web/packages/agenta-playground/src/state/execution/agentMessageQueue.ts @@ -25,6 +25,56 @@ interface ToolPartLike { interface MessageLike { role?: string parts?: ToolPartLike[] + metadata?: unknown +} + +type ApprovalContinuationState = "running" | "done" | "error" + +interface ApprovalContinuationMeta { + executionId?: string + state?: ApprovalContinuationState +} + +const latestApprovalContinuation = ( + messages: MessageLike[], +): ApprovalContinuationMeta | undefined => { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const continuation = ( + messages[i]?.metadata as {approvalContinuation?: ApprovalContinuationMeta} | undefined + )?.approvalContinuation + if (continuation?.state) return continuation + } + return undefined +} + +const latestApprovalContinuationState = ( + messages: MessageLike[], +): ApprovalContinuationState | undefined => latestApprovalContinuation(messages)?.state + +/** + * A durable approval continuation is still in flight for this conversation. + * + * `canReleaseQueuedMessage` already holds on this, but the gate is not the only release path: + * `useAgentChatQueue` also releases on a user stop and on an ORPHANED restored resume shape. The + * orphan hatch is true for EVERY durable answer — the answer retires the local gate marker, and + * the first adopted server transcript makes the tail a restored "resume imminent" message — so + * without this predicate it walks around the hold and sends into the running continuation, which + * supersedes it on the runner and aborts the tool call the user just approved. + */ +export function hasRunningApprovalContinuation(messages: MessageLike[]): boolean { + return latestApprovalContinuationState(messages) === "running" +} + +/** + * The transcript carries a terminal record for `executionId` — the only proof a client that never + * streamed the continuation has that the continuation is over. A DIFFERENT execution id counts as + * settled: a later continuation replaced this one, so this id can never terminate. + */ +export function approvalContinuationSettled(messages: MessageLike[], executionId: string): boolean { + const continuation = latestApprovalContinuation(messages) + if (!continuation) return false + if (continuation.executionId !== executionId) return true + return continuation.state === "done" || continuation.state === "error" } const isToolPart = (part: ToolPartLike): boolean => { @@ -76,7 +126,18 @@ export function messageHasPendingHitl(message: MessageLike): boolean { * freeze the queue permanently. `isHitlPending` still holds — its dock IS the unblock UI. */ export function canReleaseQueuedMessage(status: string, messages: MessageLike[]): boolean { + const continuationState = latestApprovalContinuationState(messages) + if (continuationState === "running") return false if (status === "error") return !isHitlPending(messages) + if (status === "ready" && (continuationState === "done" || continuationState === "error")) { + return !isHitlPending(messages) + } + const lastAssistant = messages.findLast((message) => message.role === "assistant") + const recordTerminal = (lastAssistant?.metadata as {recordTerminal?: unknown} | undefined) + ?.recordTerminal + if (status === "ready" && recordTerminal === true) { + return !isHitlPending(messages) + } return ( status === "ready" && !isHitlPending(messages) && diff --git a/web/packages/agenta-playground/src/state/execution/index.ts b/web/packages/agenta-playground/src/state/execution/index.ts index ed9c1a48d44..fd8eeefd487 100644 --- a/web/packages/agenta-playground/src/state/execution/index.ts +++ b/web/packages/agenta-playground/src/state/execution/index.ts @@ -376,7 +376,13 @@ export {RECORD_ANSWER_TIMEOUT_MS, recordAnswerThenRelease} from "./answerOrderin // Render-hint map: sibling `data-render` parts → toolCallId lookup (interaction kinds). export {buildRenderMap, renderKindFor, type RenderHintLike} from "./renderMap" // Agent-lane queued-message release gate (never releases mid-HITL or pre-resume). -export {canReleaseQueuedMessage, isHitlPending, messageHasPendingHitl} from "./agentMessageQueue" +export { + approvalContinuationSettled, + canReleaseQueuedMessage, + hasRunningApprovalContinuation, + isHitlPending, + messageHasPendingHitl, +} from "./agentMessageQueue" // Per-turn request capture + correlation helpers (Turn Inspector Context/Raw tabs). export { appendCapped, diff --git a/web/packages/agenta-playground/src/state/index.ts b/web/packages/agenta-playground/src/state/index.ts index 72bc67efa2e..2ad9982a4d7 100644 --- a/web/packages/agenta-playground/src/state/index.ts +++ b/web/packages/agenta-playground/src/state/index.ts @@ -191,7 +191,13 @@ export { export {approvalResolution, isResumeSend, type ChatStatusLike} from "./execution" export {RECORD_ANSWER_TIMEOUT_MS, recordAnswerThenRelease} from "./execution" export {buildRenderMap, renderKindFor, type RenderHintLike} from "./execution" -export {canReleaseQueuedMessage, isHitlPending, messageHasPendingHitl} from "./execution" +export { + approvalContinuationSettled, + canReleaseQueuedMessage, + hasRunningApprovalContinuation, + isHitlPending, + messageHasPendingHitl, +} from "./execution" export { appendCapped, buildTurnCapture, diff --git a/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts b/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts index b8544ec9dde..6c6c784f48b 100644 --- a/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts +++ b/web/packages/agenta-playground/tests/unit/agentMessageQueue.test.ts @@ -221,4 +221,35 @@ describe("canReleaseQueuedMessage", () => { ]), ).toBe(true) }) + + it("releases an answered approval after its durable terminal record", () => { + expect( + canReleaseQueuedMessage("ready", [ + user("do it"), + { + ...assistantWithTool("approval-responded", true), + metadata: {recordTerminal: true}, + }, + ]), + ).toBe(true) + }) + + it("holds an answered approval while its continuation execution is running", () => { + expect( + canReleaseQueuedMessage("ready", [ + user("do it"), + { + ...assistantWithTool("approval-responded", true), + metadata: { + approvalContinuation: { + sourceExecutionId: "source-turn", + executionId: "continuation-turn", + state: "running", + approvalIds: ["perm_1"], + }, + }, + }, + ]), + ).toBe(false) + }) }) diff --git a/web/packages/agenta-sessions/src/watch/watchEventSource.ts b/web/packages/agenta-sessions/src/watch/watchEventSource.ts index e3b9c7f77ea..3fa13b7a149 100644 --- a/web/packages/agenta-sessions/src/watch/watchEventSource.ts +++ b/web/packages/agenta-sessions/src/watch/watchEventSource.ts @@ -4,6 +4,8 @@ const RETRY_BASE_MS = 1_000 const RETRY_MAX_MS = 30_000 const MIN_INTERVAL_MS = 3_000 +export const shouldCoalesceWatchEvent = (eventName: string): boolean => eventName !== "interaction" + const retryDelayMs = (attempt: number): number => Math.round(Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS) * (0.5 + Math.random() / 2)) @@ -26,8 +28,10 @@ export type RefreshSession = () => Promise * refreshes the session first, because the usual fatal cause is a 401 at the access-token * refresh boundary and a stream carries no interceptor to refresh-and-retry the way the * Fern/axios calls do. - * - Handlers are coalesced to one call per event name per `MIN_INTERVAL_MS`, so a burst of server - * events (or a reconnect loop) cannot fan out into a refetch storm. + * - Most handlers are coalesced to one call per event name per `MIN_INTERVAL_MS`, so a burst of + * server events (or a reconnect loop) cannot fan out into a refetch storm. Interaction events + * bypass that window because a reader must see an approval answer within one second even when + * it follows the pending event immediately. */ export const useWatchEventSource = ({ url, @@ -68,6 +72,10 @@ export const useWatchEventSource = ({ } const notify = (eventName: string, event: MessageEvent) => { + if (!shouldCoalesceWatchEvent(eventName)) { + onRef.current[eventName]?.(event) + return + } pendingEvents.set(eventName, event) const now = Date.now() const elapsed = lastNotifiedAt === null ? MIN_INTERVAL_MS : now - lastNotifiedAt diff --git a/web/packages/agenta-sessions/tests/unit/watchEventSource.test.ts b/web/packages/agenta-sessions/tests/unit/watchEventSource.test.ts new file mode 100644 index 00000000000..13d31705fb0 --- /dev/null +++ b/web/packages/agenta-sessions/tests/unit/watchEventSource.test.ts @@ -0,0 +1,10 @@ +import {describe, expect, it} from "vitest" + +import {shouldCoalesceWatchEvent} from "../../src/watch/watchEventSource" + +describe("watch event coalescing", () => { + it("does not delay interaction resolution behind the shared refetch window", () => { + expect(shouldCoalesceWatchEvent("interaction")).toBe(false) + expect(shouldCoalesceWatchEvent("record")).toBe(true) + }) +}) diff --git a/web/packages/agenta-shared/src/clientTools/index.ts b/web/packages/agenta-shared/src/clientTools/index.ts index ba9c26f1b05..bb68a6124af 100644 --- a/web/packages/agenta-shared/src/clientTools/index.ts +++ b/web/packages/agenta-shared/src/clientTools/index.ts @@ -94,8 +94,8 @@ export interface ClientToolMeta { /** Settle the parked part. Mirrors OSS `SettleClientTool`: exactly one of `output`/`errorText`. */ export interface SettleClientTool { - (args: {output: Record}): void - (args: {errorText: string}): void + (args: {output: Record}): void | Promise + (args: {errorText: string}): void | Promise } /** Props every client-tool widget receives — mirrors OSS `ClientToolHandlerProps`. */ diff --git a/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx b/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx index 75fd4a1b35b..260911076e3 100644 --- a/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx +++ b/web/packages/agenta-ui/src/RichChatInput/RichChatInput.tsx @@ -104,6 +104,8 @@ export interface RichChatInputProps { stopping?: boolean /** Request a durable stop (used while `streaming`). */ onStop?: () => void + /** Submit choices displayed beside Stop while `streaming` (for example Queue and Steer). */ + busyActions?: {label: string; onSubmit: (markdown: string) => void}[] /** Min-height class for the editor area (default `min-h-[72px]`). */ minHeightClassName?: string /** Visual density: `compact` (default, chat) or `comfortable` (hero-scale surfaces) — @@ -167,6 +169,7 @@ export const RichChatInput = forwardRef streaming, stopping, onStop, + busyActions, minHeightClassName = "min-h-[72px]", size = "compact", textSizeClassName = "text-xs", @@ -371,6 +374,7 @@ export const RichChatInput = forwardRef streaming={streaming} onStop={onStop} stopping={stopping} + busyActions={busyActions} /> )} {trailing} diff --git a/web/packages/agenta-ui/src/RichChatInput/plugins/SendButton.tsx b/web/packages/agenta-ui/src/RichChatInput/plugins/SendButton.tsx index 322df735bbb..1591ded8540 100644 --- a/web/packages/agenta-ui/src/RichChatInput/plugins/SendButton.tsx +++ b/web/packages/agenta-ui/src/RichChatInput/plugins/SendButton.tsx @@ -16,16 +16,17 @@ interface SendButtonProps { disabled?: boolean /** Tooltip shown when a caller blocks submit. */ disabledReason?: ReactNode - /** When true, the button becomes a Stop button for the in-flight stream. */ + /** Keep Stop accessible for the in-flight stream, beside Send when a draft exists. */ streaming?: boolean stopping?: boolean /** Request a durable stop — required for the `streaming` state. */ onStop?: () => void + /** Additional submit choices shown beside Stop while a run is active. */ + busyActions?: {label: string; onSubmit: (markdown: string) => void}[] } /** Circular send button. Mirrors the Cmd/Ctrl+Enter path via the shared submit helper. - * While a stream is in flight it morphs into a Stop button (single affordance, no extra - * stop control alongside it). */ + * While a stream is in flight, an empty composer shows Stop; a draft shows Send beside Stop. */ export function SendButton({ onSubmit, forceEnabled, @@ -34,6 +35,7 @@ export function SendButton({ streaming, stopping, onStop, + busyActions, }: SendButtonProps) { const [editor] = useLexicalComposerContext() const [empty, setEmpty] = useState(true) @@ -45,6 +47,7 @@ export function SendButton({ }, [editor]) const handleClick = () => { + if (disabled) return if (empty) { if (forceEnabled) onSubmit("") return @@ -52,36 +55,64 @@ export function SendButton({ submitEditorAsMarkdown(editor, onSubmit) } - if (streaming) { + if (streaming || busyActions?.length) { // A spinning ring (stream in progress) around a Stop square — one affordance that both // signals progress and stops the run on click. Two-layer ring: a faint neutral track under // a thin, muted-primary arc, so the accent reads as a calm progress cue rather than a loud // full-saturation halo; the Stop glyph stays neutral so the accent isn't doubled up. return ( - - - - + + {busyActions?.map((action) => ( + + ))} + {streaming ? ( + + + + + + ) : null} + {!empty || forceEnabled ? ( + + ) : null} ) } diff --git a/web/packages/agenta-ui/tests/unit/richChatInputBusySend.render.test.tsx b/web/packages/agenta-ui/tests/unit/richChatInputBusySend.render.test.tsx new file mode 100644 index 00000000000..2337ee27ea5 --- /dev/null +++ b/web/packages/agenta-ui/tests/unit/richChatInputBusySend.render.test.tsx @@ -0,0 +1,49 @@ +/** @vitest-environment jsdom */ +import {createRef} from "react" + +import {act, cleanup, fireEvent, render, screen} from "@testing-library/react" +import {afterEach, describe, expect, it, vi} from "vitest" + +import {RichChatInput, type RichChatInputHandle} from "../../src/RichChatInput" + +afterEach(cleanup) + +describe("busy composer standard Send", () => { + it("keeps Stop when empty and sends a draft through the normal callback", async () => { + const onSubmit = vi.fn() + const onStop = vi.fn() + const ref = createRef() + render() + expect(screen.getByRole("button", {name: "Stop"})).toBeTruthy() + expect(screen.queryByRole("button", {name: "Send"})).toBeNull() + await act(async () => ref.current?.setMarkdown("next message")) + fireEvent.click(screen.getByRole("button", {name: "Send"})) + expect(onSubmit).toHaveBeenCalledWith("next message") + expect(screen.queryByRole("button", {name: "Queue"})).toBeNull() + expect(screen.queryByRole("button", {name: "Steer"})).toBeNull() + fireEvent.click(screen.getByRole("button", {name: "Stop"})) + expect(onStop).toHaveBeenCalledOnce() + }) + + it("shows standard Send for attachment-only drafts and respects upload blocking", () => { + const onSubmit = vi.fn() + const view = render( + , + ) + const send = screen.getByRole("button", {name: "Send"}) as HTMLButtonElement + expect(send.disabled).toBe(true) + fireEvent.click(send) + expect(onSubmit).not.toHaveBeenCalled() + view.rerender( + , + ) + fireEvent.click(screen.getByRole("button", {name: "Send"})) + expect(onSubmit).toHaveBeenCalledWith("") + }) +}) diff --git a/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx b/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx index bbbf2e87a15..bcbbe302caa 100644 --- a/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx +++ b/web/storybook/stories/presentational/QueuedMessagesDock.stories.tsx @@ -76,6 +76,27 @@ export const Held: Story = { render: () => , } +/** Durable rows are shared across browsers, with a selected-row Send Now action. */ +export const ServerBacked: Story = { + render: () => ( + {}} + touch + editable + initial={[ + {...THREE[0], source: "server", editable: true, policy: "steer"}, + { + ...THREE[1], + source: "server", + editable: true, + policy: "queue", + attachmentCount: 1, + }, + ]} + /> + ), +} + /** Past five rows the body scrolls and the card stops growing; the header stays put. */ export const Overflowing: Story = { render: () => ( @@ -135,3 +156,29 @@ export const WithAttachments: Story = { export const Touch: Story = { render: () => , } + +export const SendNowFailure: Story = { + render: () => ( + { + throw new Error("Unavailable") + }} + /> + ), +} + +export const EditedRowNoLongerQueued: Story = { + render: () => ( + + + + ), +} diff --git a/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts b/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts index ba0119801ed..429df735112 100644 --- a/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts +++ b/web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts @@ -460,16 +460,17 @@ export const appMatchesType = ( } export const getApp = async (page: Page, type: APP_TYPE = "completion") => { - const appsResponse = waitForApiResponse<{workflows: ListAppsItem[]; count: number}>(page, { - route: "/workflows/query", - method: "POST", - }) - const projectBasePath = getProjectScopedBasePath(page) await page.goto(`${projectBasePath}/prompts`, {waitUntil: "domcontentloaded"}) await page.waitForURL("**/prompts", {waitUntil: "domcontentloaded"}) - const data = await appsResponse + // A background query from the previous document can finish during navigation. + // Read fixture data through the request context, whose response survives that navigation. + const queryUrl = new URL(`${getApiURL(page)}/workflows/query`) + queryUrl.searchParams.set("project_id", getProjectId(page)) + const response = await page.request.post(queryUrl.toString(), {data: {}}) + expect(response.ok()).toBe(true) + const data = (await response.json()) as {workflows: ListAppsItem[]; count: number} const apps = data.workflows ?? [] expect(Array.isArray(apps)).toBe(true)