From 30a179162eed35ebf666ac0a41124cba04dc99db Mon Sep 17 00:00:00 2001 From: Y1fe1Zh0u Date: Thu, 23 Jul 2026 16:17:12 +0800 Subject: [PATCH 1/7] Restore reliable v1.11.2 runtime and E2E behavior The merged regression candidate exposed model lifecycle, group workspace, runtime terminal-state, logging, multimodal, scheduling, and acknowledgement failures that could not be accepted independently. Keep the fixes together as one deployable 3010 verification candidate based on upstream/v1.11.2-test. Constraint: Tool capability probes are diagnostic only; saved enabled tenant-valid models must remain runnable. Rejected: Keep the group ACK beside the live typing indicator | duplicates the same progress signal. Confidence: high Scope-risk: broad Reversibility: clean Directive: Do not restore supports_tool_calling as a Chat, Agent Runtime, Group planning, or context eligibility gate. Tested: backend 2056 tests; frontend 84 tests and production build; scoped Ruff; diff check; Qwen single-image and multi-image E2E on the pre-fix deployment. Not-tested: deployed fix candidate; controlled context overflow; deterministic provider protocol exhaustion; restart-boundary scheduling. --- backend/app/api/enterprise.py | 7 -- backend/app/api/groups.py | 8 ++ backend/app/api/websocket.py | 10 +- backend/app/services/agent_runtime/adapter.py | 18 +-- .../agent_runtime/checkpoint_side_effects.py | 15 +++ .../app/services/agent_runtime/delivery.py | 23 +--- .../agent_runtime/group_acknowledgement.py | 84 -------------- .../services/agent_runtime/group_handoff.py | 4 +- .../agent_runtime/model_capabilities.py | 22 ---- .../agent_runtime/model_step_service.py | 46 +++++--- .../agent_runtime/runtime_model_settings.py | 54 ++++++++- .../services/agent_runtime/worker_service.py | 7 +- backend/app/services/group_file_service.py | 45 +++++++- backend/app/services/group_message_service.py | 1 - backend/app/services/heartbeat.py | 19 ++-- backend/app/services/llm/caller.py | 8 -- backend/app/services/llm/model_resolution.py | 16 +-- .../sandbox/local/subprocess_backend.py | 53 +++++++-- .../app/services/storage_runtime/facade.py | 19 +++- backend/tests/test_agent_runtime_adapter.py | 33 +++--- ...t_agent_runtime_checkpoint_side_effects.py | 29 ++++- backend/tests/test_agent_runtime_delivery.py | 27 +++-- .../test_agent_runtime_group_scheduling.py | 106 +----------------- .../test_agent_runtime_model_step_service.py | 61 +++++++--- .../test_agent_runtime_worker_service.py | 8 +- backend/tests/test_finish_protocol.py | 36 +++--- backend/tests/test_group_api.py | 38 +++++++ backend/tests/test_group_file_service.py | 35 ++++++ backend/tests/test_group_message_service.py | 3 + backend/tests/test_heartbeat_runtime.py | 96 ++++++++++++++++ backend/tests/test_model_capabilities.py | 38 ++----- backend/tests/test_model_resolution.py | 10 +- .../tests/test_runtime_model_settings_api.py | 43 ++++++- .../test_runtime_model_settings_resolution.py | 81 +++++++++++++ .../tests/test_sandbox_subprocess_backend.py | 75 +++++++++++++ backend/tests/test_websocket_runtime_chat.py | 15 +++ frontend/src/components/ModelSwitcher.tsx | 11 +- .../pages/agent-detail/AgentDetailPage.tsx | 25 ++++- .../pages/enterprise-settings/tabs/LlmTab.tsx | 6 +- frontend/src/services/modelCacheEvents.ts | 21 ++++ .../tests/chatMultimodalContract.test.mjs | 17 +++ .../tests/groupInteractionContract.test.mjs | 19 ++++ .../runtimeModelSettingsContract.test.mjs | 47 ++++++++ 43 files changed, 908 insertions(+), 431 deletions(-) delete mode 100644 backend/app/services/agent_runtime/group_acknowledgement.py create mode 100644 backend/tests/test_runtime_model_settings_resolution.py create mode 100644 backend/tests/test_sandbox_subprocess_backend.py create mode 100644 frontend/src/services/modelCacheEvents.ts create mode 100644 frontend/tests/chatMultimodalContract.test.mjs diff --git a/backend/app/api/enterprise.py b/backend/app/api/enterprise.py index f506efdae..84cdd7d06 100644 --- a/backend/app/api/enterprise.py +++ b/backend/app/api/enterprise.py @@ -936,7 +936,6 @@ async def _runtime_model_settings_payload(db: AsyncSession, *, tenant_id: uuid.U or_(LLMModel.tenant_id.is_(None), LLMModel.tenant_id == tenant_id), LLMModel.enabled.is_(True), LLMModel.deleted_at.is_(None), - LLMModel.supports_tool_calling.is_(True), ) .order_by(LLMModel.created_at.desc()) ) @@ -1000,12 +999,6 @@ async def update_runtime_model_settings( raise HTTPException(status_code=422, detail=f"Model {model_id} belongs to another tenant") if not model.enabled: raise HTTPException(status_code=422, detail=f"Model {model_id} is disabled") - if model.supports_tool_calling is not True: - raise HTTPException( - status_code=422, - detail=f"Model {model_id} has not passed the native tool-calling test", - ) - result = await db.execute( select(SystemSetting).where( SystemSetting.key == runtime_model_setting_key(resolved_tenant_id) diff --git a/backend/app/api/groups.py b/backend/app/api/groups.py index 55af6aa5e..02a5870d0 100644 --- a/backend/app/api/groups.py +++ b/backend/app/api/groups.py @@ -1596,6 +1596,14 @@ async def download_group_workspace_file( } if allow_inline and media_type == "image/svg+xml": headers["Content-Security-Policy"] = "sandbox; default-src 'none'; style-src 'unsafe-inline'" + _stage_audit( + db, + current_user=current_user, + action="group:workspace_download", + tenant_id=tenant_id, + group_id=group_id, + details={"path": value.path, "inline": allow_inline}, + ) return Response(content=value.content, media_type=media_type, headers=headers) diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py index 93850cd51..2ced670b8 100644 --- a/backend/app/api/websocket.py +++ b/backend/app/api/websocket.py @@ -162,6 +162,14 @@ async def is_user_viewing_session(self, agent_id: str, session_id: str, user_id: manager = ConnectionManager() +def _websocket_content_log_summary(content: object) -> str: + """Return payload-free metadata for one inbound WebSocket message.""" + if not isinstance(content, str): + return f"content_type={type(content).__name__}" + image_count = content.count("[image_data:data:image/") + return f"content_chars={len(content)} image_count={image_count}" + + def _runtime_error_packet( *, code: str, @@ -680,7 +688,7 @@ async def _accept_client_message( override_model_id = data.get("model_id") is_onboarding_trigger = data.get("kind") == "onboarding_trigger" logger.info( - f"[WS] Received: {str(content)[:50]}" + f"[WS] Received: {_websocket_content_log_summary(content)}" + (" [onboarding]" if is_onboarding_trigger else "") ) if not isinstance(content, str) or (not content and not is_onboarding_trigger): diff --git a/backend/app/services/agent_runtime/adapter.py b/backend/app/services/agent_runtime/adapter.py index 371e103e3..38a573add 100644 --- a/backend/app/services/agent_runtime/adapter.py +++ b/backend/app/services/agent_runtime/adapter.py @@ -20,10 +20,6 @@ StartRunCommand, ) from app.services.agent_runtime.graph import RuntimeGraphIdentity -from app.services.agent_runtime.model_capabilities import ( - ModelCapabilityError, - ModelCapabilityResolver, -) from app.services.agent_runtime.persistence import ( RunRegistration, enqueue_cancel, @@ -156,7 +152,7 @@ async def _require_agent_runtime_model( command: StartRunCommand, agent: Agent | None, ) -> uuid.UUID | None: - """Reject new tool-driven Agent Runs before any durable Run is created.""" + """Pin an active tenant-valid model before a durable Run is created.""" if command.run_kind == "orchestration": return command.model_id model = await load_active_model( @@ -165,20 +161,12 @@ async def _require_agent_runtime_model( tenant_id=command.tenant_id, ) if model is None and agent is not None: - model = await resolve_active_agent_model( - self._db, - agent, - require_tool_calling=True, - ) + model = await resolve_active_agent_model(self._db, agent) if model is None: raise RuntimeAdapterError( "model_unavailable", - "Agent Runtime has no active tool-capable model in the command tenant", + "Agent Runtime has no active model in the command tenant", ) - try: - ModelCapabilityResolver.require_native_tool_calling(model) - except ModelCapabilityError as exc: - raise RuntimeAdapterError(exc.code, str(exc)) from exc return model.id @staticmethod diff --git a/backend/app/services/agent_runtime/checkpoint_side_effects.py b/backend/app/services/agent_runtime/checkpoint_side_effects.py index ffd3d8d0a..6d7024776 100644 --- a/backend/app/services/agent_runtime/checkpoint_side_effects.py +++ b/backend/app/services/agent_runtime/checkpoint_side_effects.py @@ -467,6 +467,21 @@ async def _record_lifecycle_events( agent_id = uuid.UUID(run.agent_id) if run.agent_id is not None else None events: list[tuple[str, str, dict, str, str | None]] = [] if checkpoint is None: + terminal_result = await db.execute( + select(AgentRunEvent.id) + .where( + AgentRunEvent.tenant_id == run.tenant_id, + AgentRunEvent.run_id == run.run_id, + AgentRunEvent.event_type.in_( + ("run_completed", "run_failed", "run_cancelled") + ), + ) + .limit(1) + ) + if terminal_result.scalar_one_or_none() is not None: + # A later cancel command may release control resources, but it must + # not append a second, contradictory terminal outcome. + return events.append( ( "run_cancelled", diff --git a/backend/app/services/agent_runtime/delivery.py b/backend/app/services/agent_runtime/delivery.py index ebe3cc466..fd4ca77b5 100644 --- a/backend/app/services/agent_runtime/delivery.py +++ b/backend/app/services/agent_runtime/delivery.py @@ -29,6 +29,9 @@ from app.services.participant_identity import get_or_create_agent_participant +# ``ack`` remains readable only for historical delivery receipts created before +# native Group start acknowledgements were retired. New requests accept only +# waiting and terminal delivery kinds. DeliveryKind = Literal["ack", "waiting", "terminal"] DeliveryLifecycleStatus = Literal[ "waiting_user", @@ -85,8 +88,6 @@ class DeliveryRequest: @property def idempotency_key(self) -> str: - if self.kind == "ack": - return f"run:{self.run_id}:ack" if self.kind == "waiting": return f"run:{self.run_id}:waiting:{self.interrupt_id}" return f"run:{self.run_id}:terminal:{self.lifecycle_status}" @@ -152,7 +153,7 @@ def _require_text( def _validate_request(request: DeliveryRequest) -> None: - if request.kind not in {"ack", "waiting", "terminal"}: + if request.kind not in {"waiting", "terminal"}: raise DeliveryServiceError( "invalid_delivery_request", f"unsupported delivery kind: {request.kind!r}", @@ -162,22 +163,6 @@ def _validate_request(request: DeliveryRequest) -> None: "invalid_delivery_request", "original_target_outcome must be not_attempted or unknown", ) - if request.kind == "ack": - _require_text(request.content, field="content") - if ( - request.checkpoint_id is not None - or request.lifecycle_status is not None - or request.interrupt_id is not None - or request.group_handoff_intent is not None - or request.failure_code is not None - or request.failure_message is not None - ): - raise DeliveryServiceError( - "invalid_delivery_request", - "ack delivery cannot carry checkpoint lifecycle fields", - ) - return - _require_text(request.checkpoint_id, field="checkpoint_id", max_length=255) if request.kind == "waiting": _require_text(request.content, field="content") diff --git a/backend/app/services/agent_runtime/group_acknowledgement.py b/backend/app/services/agent_runtime/group_acknowledgement.py deleted file mode 100644 index 4d537f104..000000000 --- a/backend/app/services/agent_runtime/group_acknowledgement.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Durable public acknowledgement for accepted group mention Runs.""" - -from __future__ import annotations - -from collections.abc import Mapping -import uuid - -from loguru import logger -from sqlalchemy import select - -from app.models.agent_run import AgentRun -from app.services.agent_runtime.command_worker import ( - CheckpointObservation, - RuntimeCommandRecord, - RuntimeRunRecord, - RuntimeSessionFactory, -) -from app.services.agent_runtime.delivery import ( - DeliveryReceipt, - DeliveryRequest, - deliver_runtime_message, -) -from app.services.group_realtime import publish_stored_group_message - - -_ACK_CONTENT = "收到,我开始处理。" - - -class RuntimeGroupStartAcknowledgementHandler: - """Write one ordinary Agent group message only after intake is committed.""" - - def __init__(self, *, session_factory: RuntimeSessionFactory) -> None: - self._session_factory = session_factory - - async def handle( - self, - *, - run: RuntimeRunRecord, - command: RuntimeCommandRecord, - checkpoint: CheckpointObservation | None, - ) -> None: - del checkpoint - if command.command_type != "start" or run.run_kind == "orchestration": - return - - receipt: DeliveryReceipt | None = None - async with self._session_factory() as db: - async with db.begin(): - result = await db.execute( - select(AgentRun.delivery_target).where( - AgentRun.tenant_id == run.tenant_id, - AgentRun.id == run.run_id, - ) - ) - target = result.scalar_one_or_none() - if not isinstance(target, Mapping) or target.get("kind") != "group": - return - receipt = await deliver_runtime_message( - db, - DeliveryRequest( - tenant_id=run.tenant_id, - run_id=run.run_id, - kind="ack", - content=_ACK_CONTENT, - ), - ) - if ( - receipt is not None - and receipt.status == "delivered" - and isinstance(receipt.actual_session_id, uuid.UUID) - and isinstance(receipt.message_id, uuid.UUID) - ): - try: - await publish_stored_group_message( - self._session_factory, - tenant_id=run.tenant_id, - session_id=receipt.actual_session_id, - message_id=receipt.message_id, - ) - except Exception as exc: - logger.warning(f"[GroupRealtime] ACK publish lookup failed: {exc}") - - -__all__ = ["RuntimeGroupStartAcknowledgementHandler"] diff --git a/backend/app/services/agent_runtime/group_handoff.py b/backend/app/services/agent_runtime/group_handoff.py index 8f6f72cd3..2bbe8eb56 100644 --- a/backend/app/services/agent_runtime/group_handoff.py +++ b/backend/app/services/agent_runtime/group_handoff.py @@ -395,8 +395,8 @@ def _source_run_matches( or source_run.system_role is not None or source_run.runtime_type != "langgraph" or source_run.runtime_thread_id != str(source_run.id) - # Group start acknowledgement may already be delivered while the Run - # remains active. This projection is not a Runtime lifecycle state. + # Historical Runs may already have delivered the retired start ACK; + # current Runs remain pending until waiting or terminal delivery. or source_run.delivery_status not in {"pending", "delivered"} ): raise GroupAgentHandoffError( diff --git a/backend/app/services/agent_runtime/model_capabilities.py b/backend/app/services/agent_runtime/model_capabilities.py index 69bdba1e4..8966cca1e 100644 --- a/backend/app/services/agent_runtime/model_capabilities.py +++ b/backend/app/services/agent_runtime/model_capabilities.py @@ -76,28 +76,6 @@ def _legacy_output_limit(value: int | None) -> int | None: class ModelCapabilityResolver: """Resolve model semantics without performing provider I/O.""" - @staticmethod - def require_native_tool_calling(model: LLMModel) -> None: - """Require a concrete model row to be safe for an Agent tool Runtime.""" - model_label = getattr(model, "label", None) or model.model - if model.supports_tool_calling is True: - return - if model.supports_tool_calling is False: - raise ModelCapabilityError( - "model_tool_calling_unsupported", - ( - f"Model {model_label!r} did not produce a valid " - "native tool call during its capability test." - ), - ) - raise ModelCapabilityError( - "model_tool_calling_unverified", - ( - f"Model {model_label!r} has not passed the native " - "tool-calling capability test required by Agent Runtime." - ), - ) - @staticmethod def capabilities( model: LLMModel, diff --git a/backend/app/services/agent_runtime/model_step_service.py b/backend/app/services/agent_runtime/model_step_service.py index aedfec3db..e276d64f5 100644 --- a/backend/app/services/agent_runtime/model_step_service.py +++ b/backend/app/services/agent_runtime/model_step_service.py @@ -730,15 +730,40 @@ def _repair( repair_code: str | None = None, repair_tool_name: str | None = None, ) -> ModelStepResult: + assistant_message = _assistant_message(state, context, step) + if ( + not str(assistant_message.get("content") or "").strip() + and not assistant_message.get("tool_calls") + ): + # Invalid/truncated tool calls cannot be replayed in provider history. + # Persist only the user-role repair instruction; an empty assistant + # message is rejected by providers such as Cohere. + assistant_message = None return ModelStepResult( intent="text", - assistant_message=_assistant_message(state, context, step), + assistant_message=assistant_message, repair_instruction=instruction, repair_code=repair_code, repair_tool_name=repair_tool_name, ) +def _safe_provider_failure_message(error: Exception) -> str: + """Return bounded user-facing provider diagnostics; raw bodies stay in logs.""" + match = re.search( + r"(? LLMModel | None: async with self._session_factory() as db: - candidates = await active_agent_model_candidates( - db, - agent, - require_tool_calling=True, - ) + candidates = await active_agent_model_candidates(db, agent) return next((model for model in candidates if model.id != primary_model.id), None) async def compact_inputs( @@ -1530,7 +1546,7 @@ async def complete_once( ) raise RuntimeModelCallError( "model_call_failed", - str(primary_error) or type(primary_error).__name__, + _safe_provider_failure_message(primary_error), ) from primary_error tenant_id = uuid.UUID(context.tenant_id) fallback = await self._fallback_model( @@ -1620,7 +1636,7 @@ async def complete_once( ) raise RuntimeModelCallError( "model_failover_failed", - str(fallback_error) or type(fallback_error).__name__, + _safe_provider_failure_message(fallback_error), ) from fallback_error actual_model = fallback failed_over_from = model @@ -1724,7 +1740,7 @@ async def complete_once( ) return _error( "model_call_failed", - str(exc) or type(exc).__name__, + "The model call failed.", ) diff --git a/backend/app/services/agent_runtime/runtime_model_settings.py b/backend/app/services/agent_runtime/runtime_model_settings.py index e6f105bd9..aa1fad906 100644 --- a/backend/app/services/agent_runtime/runtime_model_settings.py +++ b/backend/app/services/agent_runtime/runtime_model_settings.py @@ -5,9 +5,10 @@ import uuid from dataclasses import dataclass -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.models.llm import LLMModel from app.models.system_settings import SystemSetting @@ -66,9 +67,52 @@ async def resolve_runtime_model_settings( value.get("compact_model_id"), setting_name="compact_model_id", ) + requested_ids = { + model_id + for model_id in ( + configured_planning, + configured_compact, + environment_planning_model_id, + environment_compact_model_id, + ) + if model_id is not None + } + eligible_ids: set[uuid.UUID] = set() + if requested_ids: + eligible_result = await db.execute( + select(LLMModel.id).where( + LLMModel.id.in_(requested_ids), + or_(LLMModel.tenant_id.is_(None), LLMModel.tenant_id == tenant_id), + LLMModel.enabled.is_(True), + LLMModel.deleted_at.is_(None), + ) + ) + eligible_ids = { + value.id if isinstance(value, LLMModel) else value + for value in eligible_result.scalars().all() + } + + def resolve_one( + configured_id: uuid.UUID | None, + environment_id: uuid.UUID | None, + ) -> tuple[uuid.UUID | None, str]: + if configured_id in eligible_ids: + return configured_id, "database" + if environment_id in eligible_ids: + return environment_id, "environment" + return None, "unavailable" + + planning_id, planning_source = resolve_one( + configured_planning, + environment_planning_model_id, + ) + compact_id, compact_source = resolve_one( + configured_compact, + environment_compact_model_id, + ) return RuntimeModelSettings( - planning_model_id=configured_planning or environment_planning_model_id, - compact_model_id=configured_compact or environment_compact_model_id, - planning_source="database" if configured_planning else "environment", - compact_source="database" if configured_compact else "environment", + planning_model_id=planning_id, + compact_model_id=compact_id, + planning_source=planning_source, + compact_source=compact_source, ) diff --git a/backend/app/services/agent_runtime/worker_service.py b/backend/app/services/agent_runtime/worker_service.py index 43ac5aa6b..9dd2c567d 100644 --- a/backend/app/services/agent_runtime/worker_service.py +++ b/backend/app/services/agent_runtime/worker_service.py @@ -48,9 +48,6 @@ RuntimeGraphIdentity, build_agent_runtime_graph, ) -from app.services.agent_runtime.group_acknowledgement import ( - RuntimeGroupStartAcknowledgementHandler, -) from app.services.agent_runtime.heartbeat_completion import ( HeartbeatRuntimeCompletionHandler, ) @@ -321,9 +318,7 @@ def build_runtime_worker_components( lock_engine=lock_engine, checkpoint_reader=driver, command_executor=driver, - pre_command_handler=RuntimeGroupStartAcknowledgementHandler( - session_factory=session_factory, - ), + pre_command_handler=None, post_checkpoint_handler=post_checkpoint_handler, rejection_handler=post_checkpoint_handler, claimant=resolved_claimant, diff --git a/backend/app/services/group_file_service.py b/backend/app/services/group_file_service.py index c852e74c3..b56b7a323 100644 --- a/backend/app/services/group_file_service.py +++ b/backend/app/services/group_file_service.py @@ -8,6 +8,8 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import PurePosixPath +from urllib.parse import unquote import uuid from sqlalchemy import select @@ -43,6 +45,9 @@ def __init__(self, code: str, message: str) -> None: self.code = code +_FORBIDDEN_WORKSPACE_EXTENSIONS = frozenset({".exe"}) + + @dataclass(frozen=True, slots=True) class GroupTextFile: """Business-level view of one group text file.""" @@ -123,7 +128,18 @@ def _memory_key(group_id: uuid.UUID, agent_id: uuid.UUID) -> str: def _normalize_workspace_relative(path: str, *, allow_empty: bool) -> str: raw = (path or "").replace("\\", "/").strip() - if raw.startswith("/") or ".." in raw.split("/"): + decoded = raw + for _ in range(2): + next_decoded = unquote(decoded).replace("\\", "/") + if next_decoded == decoded: + break + decoded = next_decoded + if ( + raw.startswith("/") + or ".." in raw.split("/") + or decoded.startswith("/") + or ".." in decoded.split("/") + ): raise GroupFileServiceError( "group_workspace_path_invalid", "Group workspace paths must be relative and cannot contain '..'", @@ -137,6 +153,29 @@ def _normalize_workspace_relative(path: str, *, allow_empty: bool) -> str: return normalized +def _validate_workspace_write(path: str, content: bytes | None = None) -> None: + suffix = PurePosixPath(path).suffix.lower() + if suffix in _FORBIDDEN_WORKSPACE_EXTENSIONS: + raise GroupFileServiceError( + "group_workspace_file_type_forbidden", + f"Files with the {suffix} extension are not allowed in Group workspace", + ) + if content is None or suffix in BINARY_REVISION_EXTENSIONS: + return + if b"\x00" in content: + raise GroupFileServiceError( + "group_file_content_invalid", + "Group text files cannot contain NUL bytes", + ) + try: + content.decode("utf-8") + except UnicodeDecodeError as exc: + raise GroupFileServiceError( + "group_file_content_invalid", + "Group text files must contain valid UTF-8", + ) from exc + + def _workspace_key(group_id: uuid.UUID, path: str, *, allow_empty: bool) -> tuple[str, str]: normalized = _normalize_workspace_relative(path, allow_empty=allow_empty) root = normalize_storage_key(f"{_group_root(group_id)}/workspace") @@ -270,6 +309,8 @@ async def _prepare_runtime_workspace_operation( actor_participant_id=actor_participant_id, ) normalized, key = _workspace_key(group_id, path, allow_empty=False) + if operation == "write": + _validate_workspace_write(normalized) storage = get_storage_backend() current = await storage.get_version(key) if current.is_dir: @@ -997,6 +1038,7 @@ async def write_workspace_file( actor_participant_id=actor_participant_id, ) normalized, key = _workspace_key(group_id, path, allow_empty=False) + _validate_workspace_write(normalized) return await _write_text( db, group_id=group_id, @@ -1032,6 +1074,7 @@ async def write_workspace_binary_file( actor_participant_id=actor_participant_id, ) normalized, key = _workspace_key(group_id, path, allow_empty=False) + _validate_workspace_write(normalized, content) if require_absent and expected_version_token is not None: raise GroupFileServiceError( "group_file_write_condition_invalid", diff --git a/backend/app/services/group_message_service.py b/backend/app/services/group_message_service.py index 28e64109b..4b74f505b 100644 --- a/backend/app/services/group_message_service.py +++ b/backend/app/services/group_message_service.py @@ -331,7 +331,6 @@ async def _resolve_mentions( LLMModel.id.in_(model_ids), LLMModel.deleted_at.is_(None), LLMModel.enabled.is_(True), - LLMModel.supports_tool_calling.is_(True), ) ) models = { diff --git a/backend/app/services/heartbeat.py b/backend/app/services/heartbeat.py index a42e8cd8c..c930356bd 100644 --- a/backend/app/services/heartbeat.py +++ b/backend/app/services/heartbeat.py @@ -220,6 +220,11 @@ async def _heartbeat_tick(): triggered = 0 for agent in agents: + # Capture diagnostic identity before a nested transaction can + # roll back and expire ORM attributes. Exception handlers must + # never lazy-load from an expired async ORM instance. + agent_id = agent.id + agent_name = agent.name # Skip expired agents if agent.is_expired: continue @@ -250,7 +255,7 @@ async def _heartbeat_tick(): if not runtime_decision.use_v2: logger.error( "Heartbeat for {} remains due because Runtime is disabled ({})", - agent.name, + agent_name, runtime_decision.reason, ) continue @@ -295,7 +300,7 @@ async def _heartbeat_tick(): except HeartbeatRuntimeIntakeError as exc: logger.error( "Heartbeat Runtime intake failed for {} ({}): {}", - agent.name, + agent_name, exc.code, exc, ) @@ -303,30 +308,30 @@ async def _heartbeat_tick(): except Exception as exc: logger.exception( "Heartbeat claim failed for {}: {}", - agent.name, + agent_name, exc, ) continue logger.info( "💓 Queued heartbeat for {} as Runtime Run {}", - agent.name, + agent_name, runtime_handle.run_id, ) try: await write_audit_log( "heartbeat_fire", { - "agent_name": agent.name, + "agent_name": agent_name, "runtime_type": runtime_handle.runtime_type, "run_id": str(runtime_handle.run_id), }, - agent_id=agent.id, + agent_id=agent_id, ) except Exception as exc: logger.warning( "Failed to write heartbeat_fire audit log for {}: {}", - agent.name, + agent_name, exc, ) triggered += 1 diff --git a/backend/app/services/llm/caller.py b/backend/app/services/llm/caller.py index 5e4ad21d1..742aadf0d 100644 --- a/backend/app/services/llm/caller.py +++ b/backend/app/services/llm/caller.py @@ -491,14 +491,6 @@ async def call_llm( _max_tool_rounds, _token_limit_msg = await _get_agent_config(agent_id) if _token_limit_msg: return _token_limit_msg - from app.services.agent_runtime.model_capabilities import ( - ModelCapabilityError, - ModelCapabilityResolver, - ) - try: - ModelCapabilityResolver.require_native_tool_calling(model) - except ModelCapabilityError as exc: - return f"[Error] {exc.code}: {exc}" if max_tool_rounds_override and max_tool_rounds_override < _max_tool_rounds: _max_tool_rounds = max_tool_rounds_override diff --git a/backend/app/services/llm/model_resolution.py b/backend/app/services/llm/model_resolution.py index eddcfca26..ae5fb5cde 100644 --- a/backend/app/services/llm/model_resolution.py +++ b/backend/app/services/llm/model_resolution.py @@ -16,14 +16,11 @@ def _is_usable( model: LLMModel, *, tenant_id: uuid.UUID | None, - require_tool_calling: bool, ) -> bool: if getattr(model, "deleted_at", None) is not None or not model.enabled: return False if model.tenant_id not in {None, tenant_id}: return False - if require_tool_calling and model.supports_tool_calling is not True: - return False return True @@ -32,7 +29,6 @@ async def load_active_model( *, model_id: uuid.UUID | None, tenant_id: uuid.UUID | None, - require_tool_calling: bool = False, ) -> LLMModel | None: """Load one enabled, non-deleted model valid for the requested tenant.""" if model_id is None: @@ -49,7 +45,6 @@ async def load_active_model( if model is None or not _is_usable( model, tenant_id=tenant_id, - require_tool_calling=require_tool_calling, ): return None return model @@ -58,8 +53,6 @@ async def load_active_model( async def active_agent_model_candidates( db: AsyncSession, agent: Agent, - *, - require_tool_calling: bool = False, ) -> tuple[LLMModel, ...]: """Resolve primary, fallback, then tenant default without rewriting stored IDs.""" if getattr(agent, "deleted_at", None) is not None: @@ -102,7 +95,6 @@ async def active_agent_model_candidates( and _is_usable( model, tenant_id=agent.tenant_id, - require_tool_calling=require_tool_calling, ) ) @@ -110,14 +102,8 @@ async def active_agent_model_candidates( async def resolve_active_agent_model( db: AsyncSession, agent: Agent, - *, - require_tool_calling: bool = False, ) -> LLMModel | None: - candidates = await active_agent_model_candidates( - db, - agent, - require_tool_calling=require_tool_calling, - ) + candidates = await active_agent_model_candidates(db, agent) return candidates[0] if candidates else None diff --git a/backend/app/services/sandbox/local/subprocess_backend.py b/backend/app/services/sandbox/local/subprocess_backend.py index a3bf9d6ce..fd738cf23 100644 --- a/backend/app/services/sandbox/local/subprocess_backend.py +++ b/backend/app/services/sandbox/local/subprocess_backend.py @@ -15,6 +15,7 @@ MAX_STDOUT_CAPTURE_BYTES = 1_000_000 MAX_STDERR_CAPTURE_BYTES = 500_000 +VENV_CREATION_TIMEOUT_SECONDS = 120 # Security patterns - reused from agent_tools.py @@ -153,18 +154,48 @@ def _bind_if_exists(self, host_path: str, guest_path: str | None = None, *, read bind_flag = "--ro-bind" if read_only else "--bind" return [bind_flag, str(host), target] - def _ensure_workspace_venv(self, venv_path: Path) -> None: + async def _ensure_workspace_venv(self, venv_path: Path) -> None: venv_python = venv_path / "bin" / "python" if not venv_python.exists(): - import subprocess - # Use uv to create the virtual environment for extreme speed # --seed ensures pip is still present in the venv - subprocess.run( - ["uv", "venv", "--seed", str(venv_path)], - check=True, + proc = await asyncio.create_subprocess_exec( + "uv", + "venv", + "--seed", + str(venv_path), cwd=str(venv_path.parent), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, ) + try: + _, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=VENV_CREATION_TIMEOUT_SECONDS, + ) + except (asyncio.TimeoutError, asyncio.CancelledError) as exc: + if proc.returncode is None: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError): + proc.kill() + try: + await asyncio.wait_for(proc.wait(), timeout=5) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + if isinstance(exc, asyncio.CancelledError): + raise + raise RuntimeError( + "Timed out while creating the execute_code virtual environment" + ) from exc + if proc.returncode != 0: + detail = stderr.decode("utf-8", errors="replace").strip()[:500] + raise RuntimeError( + "Failed to create the execute_code virtual environment" + + (f": {detail}" if detail else "") + ) # Fix shebang lines in pip scripts to use bwrap-visible path # venv creates scripts with absolute paths to the host Python, @@ -174,10 +205,8 @@ def _ensure_workspace_venv(self, venv_path: Path) -> None: def _fix_pip_shebangs(self, venv_path: Path) -> None: """Replace pip with a bash wrapper that delegates to uv pip for extreme performance.""" venv_bin = venv_path / "bin" - sandbox_python = "/workspace/.venv/bin/python" - - wrapper_script = f"#!/bin/bash\nexec uv pip \"$@\"\n" - + wrapper_script = '#!/bin/bash\nexec uv pip "$@"\n' + for pip_cmd in ["pip", "pip3", "pip3.12"]: pip_path = venv_bin / pip_cmd if pip_path.parent.exists(): @@ -392,7 +421,7 @@ async def execute( script_path = work_path / f"_exec_tmp{ext}" try: - self._ensure_workspace_venv(venv_path) + await self._ensure_workspace_venv(venv_path) script_path.write_text(code, encoding="utf-8") sandbox_command = self._build_command(language, f"/workspace/{script_path.name}") @@ -492,7 +521,7 @@ async def read_stream(stream, out, label="stdout"): except Exception as e: duration_ms = int((time.time() - start_time) * 1000) - logger.exception(f"[Subprocess] Execution error") + logger.exception("[Subprocess] Execution error") return ExecutionResult( success=False, stdout="", diff --git a/backend/app/services/storage_runtime/facade.py b/backend/app/services/storage_runtime/facade.py index 00133a217..8b25a2dd2 100644 --- a/backend/app/services/storage_runtime/facade.py +++ b/backend/app/services/storage_runtime/facade.py @@ -16,7 +16,19 @@ tenant_storage_prefix, ) +__all__ = [ + "agent_storage_prefix", + "ensure_local_path", + "get_storage_backend", + "guess_content_type", + "normalize_storage_key", + "tenant_storage_prefix", +] + _storage_backend: StorageBackend | None = None +_CONTENT_TYPE_OVERRIDES = { + ".webp": "image/webp", +} def get_storage_backend() -> StorageBackend: @@ -57,4 +69,9 @@ async def ensure_local_path(key: str) -> Path: def guess_content_type(filename: str) -> str: - return mimetypes.guess_type(filename)[0] or "application/octet-stream" + suffix = Path(filename).suffix.lower() + return ( + _CONTENT_TYPE_OVERRIDES.get(suffix) + or mimetypes.guess_type(filename)[0] + or "application/octet-stream" + ) diff --git a/backend/tests/test_agent_runtime_adapter.py b/backend/tests/test_agent_runtime_adapter.py index 333ce7784..74537bfc3 100644 --- a/backend/tests/test_agent_runtime_adapter.py +++ b/backend/tests/test_agent_runtime_adapter.py @@ -272,16 +272,9 @@ async def test_missing_or_invalid_agent_budget_fails_without_runtime_fallback( @pytest.mark.asyncio -@pytest.mark.parametrize( - ("supports_tool_calling", "error_code"), - [ - (None, "model_tool_calling_unverified"), - (False, "model_tool_calling_unsupported"), - ], -) -async def test_new_agent_run_fails_before_persistence_without_verified_tool_calling( +@pytest.mark.parametrize("supports_tool_calling", [None, False]) +async def test_new_agent_run_accepts_saved_model_without_verified_tool_calling( supports_tool_calling: bool | None, - error_code: str, ) -> None: tenant_id = uuid.uuid4() agent = _agent(tenant_id) @@ -292,19 +285,25 @@ async def test_new_agent_run_fails_before_persistence_without_verified_tool_call supports_tool_calling=supports_tool_calling, ) db = _session(agent, model) + run = _run( + tenant_id=tenant_id, + agent_id=agent.id, + model_turn_limit=50, + ) with patch( "app.services.agent_runtime.adapter.register_run_with_start", - new=AsyncMock(), + new=AsyncMock( + return_value=RegisteredRun(run, _stored_command(run, "start"), True) + ), ) as persist: - with pytest.raises(RuntimeAdapterError) as raised: - await RuntimeCommandIntake( - db, - settings=_settings(enabled=True), - ).start_run(command) + await RuntimeCommandIntake( + db, + settings=_settings(enabled=True), + ).start_run(command) - assert raised.value.code == error_code - persist.assert_not_awaited() + persist.assert_awaited_once() + assert persist.await_args.args[1].model_id == model.id @pytest.mark.asyncio diff --git a/backend/tests/test_agent_runtime_checkpoint_side_effects.py b/backend/tests/test_agent_runtime_checkpoint_side_effects.py index aa6fa9303..004f49f9a 100644 --- a/backend/tests/test_agent_runtime_checkpoint_side_effects.py +++ b/backend/tests/test_agent_runtime_checkpoint_side_effects.py @@ -46,8 +46,9 @@ class _StoredRun: class _Session: - def __init__(self, value: object) -> None: + def __init__(self, value: object, *, terminal_event: object | None = None) -> None: self.value = value + self.terminal_event = terminal_event self.flush_count = 0 self.statements = [] @@ -62,6 +63,8 @@ def begin(self) -> _Transaction: async def execute(self, statement) -> _ScalarResult: self.statements.append(statement) + if "FROM agent_run_events" in str(statement): + return _ScalarResult(self.terminal_event) return _ScalarResult(self.value) async def flush(self) -> None: @@ -512,6 +515,30 @@ async def test_cancel_before_start_releases_lane_without_fabricating_checkpoint( assert stored.lane_held is False assert stored.lane_claimed_at is None assert sessions.sessions[0].flush_count == 1 + event = sessions.sessions[0].statements[-1].compile( + dialect=postgresql.dialect() + ).params + assert event["event_type"] == "run_cancelled" + + +@pytest.mark.asyncio +async def test_cancel_after_rejected_start_does_not_append_second_terminal_event() -> None: + run, command, _ = _records(command_type="cancel") + stored = _StoredRun() + session = _Session(stored, terminal_event=uuid.uuid4()) + handler = RuntimeCheckpointSideEffects( + session_factory=_SessionFactory(), # type: ignore[arg-type] + ) + handler._session_factory = lambda: session # type: ignore[method-assign] + + await handler.handle(run=run, command=command, checkpoint=None) + + assert stored.lane_held is False + assert stored.lane_claimed_at is None + assert not any( + "INSERT INTO agent_run_events" in str(statement) + for statement in session.statements + ) def test_waiting_delivery_uses_correlation_id_and_prompt() -> None: diff --git a/backend/tests/test_agent_runtime_delivery.py b/backend/tests/test_agent_runtime_delivery.py index 8df895076..92be49557 100644 --- a/backend/tests/test_agent_runtime_delivery.py +++ b/backend/tests/test_agent_runtime_delivery.py @@ -209,12 +209,6 @@ def test_delivery_request_uses_the_documented_stable_keys() -> None: run_id = uuid.uuid4() tenant_id = uuid.uuid4() - ack = DeliveryRequest( - tenant_id=tenant_id, - run_id=run_id, - kind="ack", - content="Accepted", - ) waiting = DeliveryRequest( tenant_id=tenant_id, run_id=run_id, @@ -233,11 +227,30 @@ def test_delivery_request_uses_the_documented_stable_keys() -> None: lifecycle_status="completed", ) - assert ack.idempotency_key == f"run:{run_id}:ack" assert waiting.idempotency_key == f"run:{run_id}:waiting:interrupt-7" assert terminal.idempotency_key == f"run:{run_id}:terminal:completed" +@pytest.mark.asyncio +async def test_new_ack_delivery_requests_are_rejected() -> None: + run_id = uuid.uuid4() + tenant_id = uuid.uuid4() + db = _RecordingDB() + + with pytest.raises(DeliveryServiceError) as exc_info: + await deliver_runtime_message( + db, + DeliveryRequest( + tenant_id=tenant_id, + run_id=run_id, + kind="ack", + content="Accepted", + ), + ) + + assert exc_info.value.code == "invalid_delivery_request" + + @pytest.mark.asyncio async def test_direct_delivery_accepts_the_session_scoped_langgraph_thread() -> None: tenant_id = uuid.uuid4() diff --git a/backend/tests/test_agent_runtime_group_scheduling.py b/backend/tests/test_agent_runtime_group_scheduling.py index 03104733c..c318bd379 100644 --- a/backend/tests/test_agent_runtime_group_scheduling.py +++ b/backend/tests/test_agent_runtime_group_scheduling.py @@ -1,9 +1,8 @@ -"""Group acknowledgement and checkpoint-authoritative lane release tests.""" +"""Checkpoint-authoritative Group scheduling-lane release tests.""" from __future__ import annotations from datetime import UTC, datetime -from unittest.mock import AsyncMock, patch import uuid import pytest @@ -14,10 +13,6 @@ RuntimeCommandRecord, RuntimeRunRecord, ) -from app.services.agent_runtime.delivery import DeliveryReceipt, DeliveryRequest -from app.services.agent_runtime.group_acknowledgement import ( - RuntimeGroupStartAcknowledgementHandler, -) from app.services.agent_runtime.scheduling_lane import SchedulingLaneCompletionHandler from app.services.agent_runtime.state import ( RunInputSnapshots, @@ -161,105 +156,6 @@ def _checkpoint(run: RuntimeRunRecord, *, status: str) -> CheckpointObservation: ) -@pytest.mark.asyncio -async def test_group_ack_is_an_idempotent_delivery_before_graph_execution() -> None: - run_record, command, run = _records() - session = _Session(run.delivery_target) - handler = RuntimeGroupStartAcknowledgementHandler( - session_factory=_SessionFactory(session), # type: ignore[arg-type] - ) - - with patch( - "app.services.agent_runtime.group_acknowledgement.deliver_runtime_message", - new=AsyncMock(), - ) as deliver: - await handler.handle(run=run_record, command=command, checkpoint=None) - - request = deliver.await_args.args[1] - assert isinstance(request, DeliveryRequest) - assert request.run_id == run.id - assert request.kind == "ack" - assert request.content == "收到,我开始处理。" - assert request.idempotency_key == f"run:{run.id}:ack" - - -@pytest.mark.asyncio -async def test_non_group_start_does_not_emit_group_ack() -> None: - run_record, command, run = _records(target_kind="direct") - handler = RuntimeGroupStartAcknowledgementHandler( - session_factory=_SessionFactory(_Session(run.delivery_target)), # type: ignore[arg-type] - ) - - with patch( - "app.services.agent_runtime.group_acknowledgement.deliver_runtime_message", - new=AsyncMock(), - ) as deliver: - await handler.handle(run=run_record, command=command, checkpoint=None) - - deliver.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_group_ack_realtime_publish_runs_after_delivery_commit() -> None: - run_record, command, run = _records() - events: list[str] = [] - - class _OrderedTransaction: - async def __aenter__(self): - events.append("begin") - return self - - async def __aexit__(self, exc_type, exc, traceback): - events.append("commit") - return False - - class _OrderedSession(_Session): - def begin(self): - return _OrderedTransaction() - - session = _OrderedSession(run.delivery_target) - handler = RuntimeGroupStartAcknowledgementHandler( - session_factory=_SessionFactory(session), # type: ignore[arg-type] - ) - message_id = uuid.uuid4() - receipt = DeliveryReceipt( - tenant_id=run_record.tenant_id, - run_id=run_record.run_id, - idempotency_key=f"run:{run_record.run_id}:ack", - status="delivered", - delivery_kind="ack", - checkpoint_id=None, - message_id=message_id, - requested_session_id=uuid.UUID(run_record.session_id), - actual_session_id=uuid.UUID(run_record.session_id), - fallback_reason=None, - error_code=None, - ) - - async def fake_deliver(*_args, **_kwargs): - events.append("deliver") - return receipt - - async def fake_publish(*_args, **_kwargs): - assert events == ["begin", "deliver", "commit"] - events.append("publish") - return True - - with ( - patch( - "app.services.agent_runtime.group_acknowledgement.deliver_runtime_message", - new=fake_deliver, - ), - patch( - "app.services.agent_runtime.group_acknowledgement.publish_stored_group_message", - new=fake_publish, - ), - ): - await handler.handle(run=run_record, command=command, checkpoint=None) - - assert events == ["begin", "deliver", "commit", "publish"] - - @pytest.mark.asyncio async def test_terminal_checkpoint_releases_lane_without_reading_projection() -> None: run_record, _, run = _records() diff --git a/backend/tests/test_agent_runtime_model_step_service.py b/backend/tests/test_agent_runtime_model_step_service.py index ef2b9afba..e4ce7ab36 100644 --- a/backend/tests/test_agent_runtime_model_step_service.py +++ b/backend/tests/test_agent_runtime_model_step_service.py @@ -370,23 +370,24 @@ def _failover_service( @pytest.mark.asyncio -@pytest.mark.parametrize( - ("supports_tool_calling", "error_code"), - [ - (None, "model_tool_calling_unverified"), - (False, "model_tool_calling_unsupported"), - ], -) -async def test_agent_model_step_fails_closed_before_provider_call( +@pytest.mark.parametrize("supports_tool_calling", [None, False]) +async def test_agent_model_step_calls_saved_model_without_verified_tool_calling( supports_tool_calling: bool | None, - error_code: str, ) -> None: tenant_id = uuid.uuid4() model = _model(tenant_id) model.supports_tool_calling = supports_tool_calling agent = _agent(tenant_id) state = _state(tenant_id, model, agent) - completion = AsyncMock() + completion = AsyncMock( + return_value=LLMCompletionStep( + content="Completed with the saved model.", + tool_calls=(), + reasoning_content=None, + retry_instruction=None, + usage=TokenUsage(total_tokens=20), + ) + ) result = await _service( model, @@ -395,9 +396,9 @@ async def test_agent_model_step_fails_closed_before_provider_call( completion, ).complete_once(state, _context(state)) - assert result.intent == "error" - assert result.error["code"] == error_code - completion.assert_not_awaited() + assert result.intent == "finish" + assert result.finish_content == "Completed with the saved model." + completion.assert_awaited_once() @pytest.mark.asyncio @@ -483,6 +484,7 @@ async def complete(*args, **kwargs): assert result.intent == "text" assert result.repair_code == "invalid_tool_call" assert result.repair_tool_name == "write_file" + assert result.assistant_message is None @pytest.mark.asyncio @@ -2197,8 +2199,7 @@ async def complete(*args, **kwargs): assert result.tool_calls == () assert result.repair_instruction is not None assert "only tool call" in result.repair_instruction - assert result.assistant_message is not None - assert "tool_calls" not in result.assistant_message + assert result.assistant_message is None @pytest.mark.asyncio @@ -2400,9 +2401,39 @@ async def complete(*args, **kwargs): assert result.intent == "error" assert result.error is not None assert result.error["code"] == "model_call_failed" + assert result.error["message"] == "Model provider request failed." + assert "invalid API key" not in result.error["message"] assert calls == 1 +@pytest.mark.asyncio +async def test_provider_validation_error_is_redacted_from_runtime_delivery() -> None: + tenant_id = uuid.uuid4() + model = _model(tenant_id) + agent = _agent(tenant_id) + state = _state(tenant_id, model, agent) + + async def complete(*args, **kwargs): + del args, kwargs + raise RuntimeError( + 'HTTP 400: {"error":{"metadata":{"provider_name":"Cohere"},' + '"user_id":"private-user-id","message":"invalid request"}}' + ) + + result = await _service( + model, + agent, + _ContextBuilder(_build()), + complete, + ).complete_once(state, _context(state)) + + assert result.intent == "error" + assert result.error == { + "code": "model_call_failed", + "message": "Model provider rejected the request (HTTP 400).", + } + + @pytest.mark.asyncio async def test_retryable_primary_error_without_fallback_pauses_for_resume() -> None: tenant_id = uuid.uuid4() diff --git a/backend/tests/test_agent_runtime_worker_service.py b/backend/tests/test_agent_runtime_worker_service.py index 851e00009..f2aa6be51 100644 --- a/backend/tests/test_agent_runtime_worker_service.py +++ b/backend/tests/test_agent_runtime_worker_service.py @@ -19,9 +19,6 @@ from app.services.agent_runtime.heartbeat_completion import ( HeartbeatRuntimeCompletionHandler, ) -from app.services.agent_runtime.group_acknowledgement import ( - RuntimeGroupStartAcknowledgementHandler, -) from app.services.agent_runtime.onboarding_completion import ( OnboardingRuntimeCompletionHandler, ) @@ -316,10 +313,7 @@ def test_component_builder_installs_current_agent_and_planning_graphs() -> None: ) assert components.channel_delivery_worker._claimant == "worker-test" assert components.async_tool_poll_scheduler._session_factory is not None - assert isinstance( - components.worker._pre_command_handler, - RuntimeGroupStartAcknowledgementHandler, - ) + assert components.worker._pre_command_handler is None terminal_handlers = components.worker._post_checkpoint_handler._terminal_handlers assert [type(handler) for handler in terminal_handlers] == [ SessionContextCompletionHandler, diff --git a/backend/tests/test_finish_protocol.py b/backend/tests/test_finish_protocol.py index ada20f9fc..de38a2790 100644 --- a/backend/tests/test_finish_protocol.py +++ b/backend/tests/test_finish_protocol.py @@ -361,28 +361,34 @@ async def test_call_llm_requires_finish_tool_to_stop(monkeypatch): @pytest.mark.asyncio -@pytest.mark.parametrize( - ("supports_tool_calling", "error_code"), - [ - (None, "model_tool_calling_unverified"), - (False, "model_tool_calling_unsupported"), - ], -) -async def test_legacy_tool_loop_fails_closed_before_provider_call( +@pytest.mark.parametrize("supports_tool_calling", [None, False]) +async def test_legacy_tool_loop_calls_saved_model_without_verified_tool_calling( monkeypatch, supports_tool_calling, - error_code, ): from app.services.llm import caller model = _model() model.supports_tool_calling = supports_tool_calling - - def create_client(**_kwargs): - raise AssertionError("provider must not be called") + fake_client = FakeStreamClient([_finish_response("Final answer.")]) monkeypatch.setattr(caller, "_get_agent_config", lambda _agent_id: _async_return((50, None))) - monkeypatch.setattr(caller, "create_llm_client", create_client) + monkeypatch.setattr(caller, "_get_user_name", lambda _user_id: _async_return("Ray")) + monkeypatch.setattr( + "app.services.agent_context.build_agent_context", + lambda *_args, **_kwargs: _async_return(("static", "dynamic")), + ) + monkeypatch.setattr( + caller, + "get_agent_tools_for_llm", + lambda _agent_id: _async_return([]), + ) + monkeypatch.setattr(caller, "create_llm_client", lambda **_kwargs: fake_client) + monkeypatch.setattr( + caller, + "record_token_usage", + lambda *_args, **_kwargs: _async_return(None), + ) result = await caller.call_llm( model, @@ -390,9 +396,11 @@ def create_client(**_kwargs): "Agent", "", agent_id=uuid.uuid4(), + user_id=uuid.uuid4(), ) - assert result.startswith(f"[Error] {error_code}:") + assert result == "Final answer." + assert len(fake_client.messages_seen) == 1 @pytest.mark.asyncio diff --git a/backend/tests/test_group_api.py b/backend/tests/test_group_api.py index 321f8d492..58fb2dcb2 100644 --- a/backend/tests/test_group_api.py +++ b/backend/tests/test_group_api.py @@ -380,6 +380,10 @@ async def fake_read(_db, **kwargs): assert response.body == b"\x89PNG\r\n" assert response.media_type == "image/png" assert response.headers["content-disposition"].startswith("inline;") + audit = next(value for value in db.added if isinstance(value, AuditLog)) + assert audit.action == "group:workspace_download" + assert audit.details["path"] == "images/chart.png" + assert audit.details["inline"] is True assert calls == [ { "tenant_id": tenant_id, @@ -390,6 +394,40 @@ async def fake_read(_db, **kwargs): ] +@pytest.mark.asyncio +async def test_workspace_download_uses_portable_webp_content_type(monkeypatch) -> None: + tenant_id = uuid.uuid4() + user = _user(tenant_id) + participant = _participant(user) + group = _group(tenant_id, participant.id) + db = _RecordingDB() + + async def fake_download_user(**_kwargs): + return user + + async def fake_participant(_db, _user): + return participant + + async def fake_read(_db, **_kwargs): + return SimpleNamespace(path="images/chart.webp", content=b"RIFFpayloadWEBP") + + monkeypatch.setattr(groups_api, "_download_user", fake_download_user) + monkeypatch.setattr(groups_api, "_current_participant", fake_participant) + monkeypatch.setattr(groups_api.group_file_service, "read_workspace_binary_file", fake_read) + + response = await groups_api.download_group_workspace_file( + group.id, + path="images/chart.webp", + token="download-token", + inline=True, + credentials=None, + db=db, + ) + + assert response.media_type == "image/webp" + assert response.headers["content-disposition"].startswith("inline;") + + @pytest.mark.asyncio async def test_create_group_stages_domain_change_and_audit_in_one_transaction(monkeypatch) -> None: tenant_id = uuid.uuid4() diff --git a/backend/tests/test_group_file_service.py b/backend/tests/test_group_file_service.py index b15cdd6f0..0aa40e5a9 100644 --- a/backend/tests/test_group_file_service.py +++ b/backend/tests/test_group_file_service.py @@ -184,6 +184,41 @@ async def test_group_workspace_rejects_traversal_and_stale_writes( ) assert path_error.value.code == "group_workspace_path_invalid" + with pytest.raises(group_file_service.GroupFileServiceError) as encoded_path: + await group_file_service.write_workspace_file( + db, + tenant_id=tenant_id, + group_id=group_id, + actor_participant_id=actor.id, + path="%2e%2e/system/announcement.md", + content="escape", + ) + assert encoded_path.value.code == "group_workspace_path_invalid" + + with pytest.raises(group_file_service.GroupFileServiceError) as executable: + await group_file_service.write_workspace_binary_file( + db, + tenant_id=tenant_id, + group_id=group_id, + actor_participant_id=actor.id, + path="payload.exe", + content=b"MZ", + content_type="application/octet-stream", + ) + assert executable.value.code == "group_workspace_file_type_forbidden" + + with pytest.raises(group_file_service.GroupFileServiceError) as invalid_text: + await group_file_service.write_workspace_binary_file( + db, + tenant_id=tenant_id, + group_id=group_id, + actor_participant_id=actor.id, + path="invalid.txt", + content=b"\xff\xfe", + content_type="text/plain", + ) + assert invalid_text.value.code == "group_file_content_invalid" + current = await group_file_service.write_workspace_file( db, tenant_id=tenant_id, diff --git a/backend/tests/test_group_message_service.py b/backend/tests/test_group_message_service.py index 14d8109a3..6127669fd 100644 --- a/backend/tests/test_group_message_service.py +++ b/backend/tests/test_group_message_service.py @@ -191,6 +191,7 @@ def test_mentions_are_deduplicated_in_client_order() -> None: @pytest.mark.asyncio async def test_mention_resolution_only_exposes_active_group_members() -> None: tenant_id, user, scope, target, mention = _records() + mention.model.supports_tool_calling = False human_target = Participant( id=uuid.uuid4(), type="user", @@ -241,6 +242,8 @@ async def test_mention_resolution_only_exposes_active_group_members() -> None: assert resolved[0].valid is True and resolved[0].triggers_agent is True assert resolved[0].agent is mention.agent + assert resolved[0].model is mention.model + assert "llm_models.supports_tool_calling IS true" not in str(db.statements[-1]) assert resolved[1].valid is True and resolved[1].triggers_agent is False assert resolved[1].participant_type == "user" assert resolved[2].valid is False diff --git a/backend/tests/test_heartbeat_runtime.py b/backend/tests/test_heartbeat_runtime.py index 92467a82c..aa3d591db 100644 --- a/backend/tests/test_heartbeat_runtime.py +++ b/backend/tests/test_heartbeat_runtime.py @@ -2,7 +2,9 @@ from __future__ import annotations +from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta, timezone +from types import SimpleNamespace from unittest.mock import AsyncMock, patch import uuid @@ -124,6 +126,100 @@ def test_default_heartbeat_prompt_does_not_advertise_hardcoded_tools() -> None: assert hardcoded_tool not in prompt +@pytest.mark.asyncio +async def test_heartbeat_intake_error_does_not_read_expired_agent_identity() -> None: + class _ExpiringAgent: + def __init__(self) -> None: + self._id = uuid.uuid4() + self._name = "Heartbeat Agent" + self.expired = False + self.name_reads = 0 + self.tenant_id = None + self.is_expired = False + self.expires_at = None + self.heartbeat_active_hours = "00:00-23:59" + self.heartbeat_interval_minutes = 1 + self.last_heartbeat_at = None + + @property + def id(self): + return self._id + + @property + def name(self): + self.name_reads += 1 + if self.expired: + raise RuntimeError("expired ORM attribute was accessed") + return self._name + + agent = _ExpiringAgent() + + class _Result: + rowcount = 1 + + def scalars(self): + return self + + def all(self): + return [agent] + + class _NestedTransaction: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + if exc_type is not None: + agent.expired = True + return False + + class _HeartbeatSession: + async def execute(self, _statement): + return _Result() + + def begin_nested(self): + return _NestedTransaction() + + async def commit(self): + return None + + @asynccontextmanager + async def fake_session(): + yield _HeartbeatSession() + + async def fail_intake(*_args, **_kwargs): + raise HeartbeatRuntimeIntakeError( + "model_unavailable", + "Heartbeat Agent has no primary model", + ) + + audit = AsyncMock() + with ( + patch("app.database.async_session", new=fake_session), + patch( + "app.services.agent_runtime.config.decide_runtime_v2", + return_value=SimpleNamespace(use_v2=True, reason="enabled"), + ), + patch( + "app.services.timezone_utils.get_agent_timezone_sync", + return_value="UTC", + ), + patch( + "app.services.heartbeat._build_heartbeat_instruction", + new=AsyncMock(return_value=("Review", {})), + ), + patch( + "app.services.heartbeat_runtime.enqueue_heartbeat_runtime", + new=fail_intake, + ), + patch("app.services.audit_logger.write_audit_log", new=audit), + ): + await heartbeat_service._heartbeat_tick() + + assert agent.expired is True + assert agent.name_reads == 1 + audit.assert_not_awaited() + + @pytest.mark.asyncio async def test_disabled_heartbeat_rollout_leaves_claim_for_legacy_execution() -> None: agent = _agent() diff --git a/backend/tests/test_model_capabilities.py b/backend/tests/test_model_capabilities.py index 3d0e553ae..d431159bd 100644 --- a/backend/tests/test_model_capabilities.py +++ b/backend/tests/test_model_capabilities.py @@ -87,31 +87,6 @@ def test_llm_capability_columns_and_checks_are_declared() -> None: assert source in capability_source_check -@pytest.mark.parametrize( - ("supports_tool_calling", "error_code"), - [ - (None, "model_tool_calling_unverified"), - (False, "model_tool_calling_unsupported"), - ], -) -def test_agent_runtime_requires_verified_native_tool_calling( - supports_tool_calling: bool | None, - error_code: str, -) -> None: - model = _model(supports_tool_calling=supports_tool_calling) - - with pytest.raises(ModelCapabilityError) as exc_info: - ModelCapabilityResolver.require_native_tool_calling(model) - - assert exc_info.value.code == error_code - - -def test_verified_native_tool_calling_is_accepted() -> None: - ModelCapabilityResolver.require_native_tool_calling( - _model(supports_tool_calling=True) - ) - - def test_matching_overrides_win_without_changing_limit_semantics() -> None: model = _model( context_window_tokens=100_000, @@ -284,8 +259,13 @@ async def test_platform_model_resolution_rejects_unusable_models( async def test_global_runtime_model_resolvers_accept_only_enabled_platform_models() -> None: tenant_id = uuid.uuid4() compact_id = uuid.uuid4() - planning_id = uuid.uuid4() - model = _model(tenant_id=None, enabled=True) + planning_id = compact_id + model = _model( + id=compact_id, + tenant_id=None, + enabled=True, + supports_tool_calling=True, + ) settings = Settings( _env_file=None, MULTI_AGENT_COMPACT_MODEL_ID=compact_id, @@ -295,7 +275,9 @@ async def test_global_runtime_model_resolvers_accept_only_enabled_platform_model assert await resolve_multi_agent_compact_model(session, settings, tenant_id=tenant_id) is model # type: ignore[arg-type] assert await resolve_multi_agent_planning_model(session, settings, tenant_id=tenant_id) is model # type: ignore[arg-type] - assert len(session.statements) == 4 + # Each resolver reads settings, validates the configured ID is currently + # runnable, then loads the selected model. + assert len(session.statements) == 6 assert settings.AGENT_RUNTIME_SUMMARY_THRESHOLD_RATIO == 0.85 assert settings.AGENT_RUNTIME_MODEL_CAPABILITY_REFRESH_SECONDS == 86400 assert settings.MULTI_AGENT_COMPACT_MODEL_ID == compact_id diff --git a/backend/tests/test_model_resolution.py b/backend/tests/test_model_resolution.py index 026f4b6b9..8c2767c90 100644 --- a/backend/tests/test_model_resolution.py +++ b/backend/tests/test_model_resolution.py @@ -92,7 +92,7 @@ async def test_candidates_skip_deleted_disabled_cross_tenant_and_duplicate_model @pytest.mark.asyncio -async def test_tool_calling_requirement_filters_incapable_candidate(): +async def test_tool_calling_diagnostic_does_not_filter_saved_candidate(): from app.services.llm.model_resolution import active_agent_model_candidates tenant_id = uuid.uuid4() @@ -110,13 +110,9 @@ async def test_tool_calling_requirement_filters_incapable_candidate(): DummyResult([primary, fallback]), ]) - candidates = await active_agent_model_candidates( - db, - agent, - require_tool_calling=True, - ) + candidates = await active_agent_model_candidates(db, agent) - assert candidates == (fallback,) + assert candidates == (primary, fallback) @pytest.mark.asyncio diff --git a/backend/tests/test_runtime_model_settings_api.py b/backend/tests/test_runtime_model_settings_api.py index 06ed4223e..502c65450 100644 --- a/backend/tests/test_runtime_model_settings_api.py +++ b/backend/tests/test_runtime_model_settings_api.py @@ -2,6 +2,7 @@ import uuid from types import SimpleNamespace +from unittest.mock import AsyncMock, patch import pytest from fastapi import HTTPException @@ -24,16 +25,23 @@ def scalars(self) -> "_ModelResult": def all(self) -> list[LLMModel]: return self.models + def scalar_one_or_none(self) -> LLMModel | None: + return self.models[0] if self.models else None + class _ModelSession: def __init__(self, models: list[LLMModel]) -> None: self.models = models self.execute_count = 0 + self.commit = AsyncMock() async def execute(self, _statement: object) -> _ModelResult: self.execute_count += 1 return _ModelResult(self.models) + def add(self, _value: object) -> None: + pass + def _model(model_id: uuid.UUID, **overrides: object) -> LLMModel: values: dict[str, object] = { @@ -76,7 +84,6 @@ async def test_runtime_model_settings_are_company_admin_only() -> None: [ {"tenant_id": uuid.uuid4()}, {"enabled": False}, - {"supports_tool_calling": False}, ], ) async def test_runtime_model_settings_reject_ineligible_models( @@ -103,3 +110,37 @@ async def test_runtime_model_settings_reject_ineligible_models( assert exc_info.value.status_code == 422 assert session.execute_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("supports_tool_calling", [None, False]) +async def test_runtime_model_settings_accept_saved_model_without_verified_tools( + supports_tool_calling: bool | None, +) -> None: + model_id = uuid.uuid4() + tenant_id = uuid.uuid4() + session = _ModelSession( + [_model(model_id, supports_tool_calling=supports_tool_calling)] + ) + expected = {"planning_model_id": str(model_id)} + + with patch( + "app.api.enterprise._runtime_model_settings_payload", + new=AsyncMock(return_value=expected), + ): + result = await update_runtime_model_settings( + RuntimeModelSettingsUpdate( + planning_model_id=model_id, + compact_model_id=model_id, + ), + tenant_id=str(tenant_id), + current_user=SimpleNamespace( + role="platform_admin", + identity=None, + tenant_id=tenant_id, + ), # type: ignore[arg-type] + db=session, # type: ignore[arg-type] + ) + + assert result == expected + session.commit.assert_awaited_once() diff --git a/backend/tests/test_runtime_model_settings_resolution.py b/backend/tests/test_runtime_model_settings_resolution.py new file mode 100644 index 000000000..69448f8b7 --- /dev/null +++ b/backend/tests/test_runtime_model_settings_resolution.py @@ -0,0 +1,81 @@ +"""Runtime model settings must never resolve stale or unrunnable model IDs.""" + +from types import SimpleNamespace +import uuid + +import pytest + +from app.services.agent_runtime.runtime_model_settings import ( + resolve_runtime_model_settings, + runtime_model_setting_key, +) + + +class _Result: + def __init__(self, values: list[object]) -> None: + self._values = values + + def scalars(self): + return self + + def all(self) -> list[object]: + return self._values + + +class _Session: + def __init__(self, *results: _Result) -> None: + self._results = iter(results) + self.statements = [] + + async def execute(self, statement): + self.statements.append(statement) + return next(self._results) + + +@pytest.mark.asyncio +async def test_deleted_configured_model_falls_back_only_to_eligible_environment_model() -> None: + tenant_id = uuid.uuid4() + deleted_id = uuid.uuid4() + environment_id = uuid.uuid4() + setting = SimpleNamespace( + key=runtime_model_setting_key(tenant_id), + value={ + "planning_model_id": str(deleted_id), + "compact_model_id": str(deleted_id), + }, + ) + db = _Session( + _Result([setting]), + _Result([environment_id]), + ) + + resolved = await resolve_runtime_model_settings( + db, # type: ignore[arg-type] + tenant_id=tenant_id, + environment_planning_model_id=environment_id, + environment_compact_model_id=None, + ) + + assert resolved.planning_model_id == environment_id + assert resolved.planning_source == "environment" + assert resolved.compact_model_id is None + assert resolved.compact_source == "unavailable" + assert "supports_tool_calling" not in str(db.statements[1]) + + +@pytest.mark.asyncio +async def test_no_configured_models_requires_no_model_lookup() -> None: + tenant_id = uuid.uuid4() + db = _Session(_Result([])) + + resolved = await resolve_runtime_model_settings( + db, # type: ignore[arg-type] + tenant_id=tenant_id, + environment_planning_model_id=None, + environment_compact_model_id=None, + ) + + assert resolved.planning_model_id is None + assert resolved.compact_model_id is None + assert resolved.planning_source == "unavailable" + assert resolved.compact_source == "unavailable" diff --git a/backend/tests/test_sandbox_subprocess_backend.py b/backend/tests/test_sandbox_subprocess_backend.py new file mode 100644 index 000000000..260b9ac0a --- /dev/null +++ b/backend/tests/test_sandbox_subprocess_backend.py @@ -0,0 +1,75 @@ +"""Local sandbox bootstrap must not block the Backend event loop.""" + +import asyncio +from pathlib import Path + +import pytest + +from app.services.sandbox.config import SandboxConfig +from app.services.sandbox.local import subprocess_backend +from app.services.sandbox.local.subprocess_backend import SubprocessBackend + + +@pytest.mark.asyncio +async def test_workspace_venv_uses_async_subprocess(monkeypatch, tmp_path: Path) -> None: + venv_path = tmp_path / ".venv" + calls: list[tuple[object, ...]] = [] + + class _Process: + returncode = 0 + pid = 123 + + async def communicate(self): + await asyncio.sleep(0) + (venv_path / "bin").mkdir(parents=True) + (venv_path / "bin" / "python").write_text("", encoding="utf-8") + return b"", b"" + + async def fake_create(*args, **kwargs): + calls.append((*args, kwargs)) + return _Process() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create) + backend = SubprocessBackend(SandboxConfig()) + + await backend._ensure_workspace_venv(venv_path) + + assert calls + assert calls[0][:3] == ("uv", "venv", "--seed") + + +@pytest.mark.asyncio +async def test_workspace_venv_timeout_terminates_child(monkeypatch, tmp_path: Path) -> None: + terminated: list[int] = [] + + class _Process: + returncode = None + pid = 456 + + async def communicate(self): + await asyncio.Event().wait() + + async def wait(self): + self.returncode = -15 + return self.returncode + + def kill(self): + self.returncode = -9 + + async def fake_create(*_args, **_kwargs): + return _Process() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create) + monkeypatch.setattr(subprocess_backend, "VENV_CREATION_TIMEOUT_SECONDS", 0.01) + monkeypatch.setattr(subprocess_backend.os, "getpgid", lambda pid: pid) + monkeypatch.setattr( + subprocess_backend.os, + "killpg", + lambda pid, _signal: terminated.append(pid), + ) + backend = SubprocessBackend(SandboxConfig()) + + with pytest.raises(RuntimeError, match="Timed out"): + await backend._ensure_workspace_venv(tmp_path / ".venv") + + assert terminated == [456] diff --git a/backend/tests/test_websocket_runtime_chat.py b/backend/tests/test_websocket_runtime_chat.py index 052517698..570b86dc5 100644 --- a/backend/tests/test_websocket_runtime_chat.py +++ b/backend/tests/test_websocket_runtime_chat.py @@ -16,6 +16,7 @@ AcceptedWebChatMessage, WebChatRuntimeIntake, WebSocketChatHandler, + _websocket_content_log_summary, ) from app.models.agent_run import AgentRun from app.models.agent_run_command import AgentRunCommand @@ -51,6 +52,20 @@ async def close(self, code: int) -> None: self.closed_code = code +def test_websocket_log_summary_never_includes_message_or_image_payload() -> None: + payload = ( + "[image_data:data:image/png;base64,SECRET_IMAGE_PAYLOAD]" + "[image_data:data:image/jpeg;base64,SECOND_SECRET_PAYLOAD]" + " user secret" + ) + + summary = _websocket_content_log_summary(payload) + + assert summary == f"content_chars={len(payload)} image_count=2" + assert "SECRET" not in summary + assert "data:image" not in summary + + class _Transaction: async def __aenter__(self): return self diff --git a/frontend/src/components/ModelSwitcher.tsx b/frontend/src/components/ModelSwitcher.tsx index ac5b1f3a3..5b106aff5 100644 --- a/frontend/src/components/ModelSwitcher.tsx +++ b/frontend/src/components/ModelSwitcher.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { IconChevronDown, IconCheck } from '@tabler/icons-react'; import { enterpriseApi } from '../services/api'; +import { subscribeModelCacheInvalidation } from '../services/modelCacheEvents'; interface Model { id: string; @@ -11,6 +12,7 @@ interface Model { model: string; label?: string; enabled?: boolean; + supports_tool_calling?: boolean | null; } interface Props { @@ -39,11 +41,18 @@ export default function ModelSwitcher({ value, onChange, tenantDefaultId, disabl { top: number; bottom: number; left: number; width: number; placement: 'above' | 'below'; maxHeight: number } | null >(null); - const { data: models = [] } = useQuery({ + const { data: models = [], refetch: refetchModels } = useQuery({ queryKey: ['llm-models'], queryFn: enterpriseApi.llmModels, }); + useEffect( + () => subscribeModelCacheInvalidation(() => { + void refetchModels(); + }), + [refetchModels], + ); + const enabled = (models as Model[]).filter(m => m.enabled !== false); const selected = enabled.find(m => m.id === value) || enabled.find(m => tenantDefaultId && m.id === tenantDefaultId) diff --git a/frontend/src/pages/agent-detail/AgentDetailPage.tsx b/frontend/src/pages/agent-detail/AgentDetailPage.tsx index 4a356cf46..ad75ec881 100644 --- a/frontend/src/pages/agent-detail/AgentDetailPage.tsx +++ b/frontend/src/pages/agent-detail/AgentDetailPage.tsx @@ -3189,8 +3189,18 @@ export default function AgentDetailPage() { const qMatch = msg.content.match(/\nQuestion: ([\s\S]+)$/); parsed = { ...msg, fileName, content: qMatch ? qMatch[1].trim() : '' }; } - // If file is an image and no imageUrl yet, build download URL for preview - if (parsed.fileName && !parsed.imageUrl && id) { + // A multi-image message stores all names in the legacy file_name field. + // Never turn that comma-joined label into one invalid download path; + // ChatMessageItem renders each persisted image_data marker instead. + const inlineImageMarkerCount = ( + parsed.content.match(/\[image_data:data:image\//g) || [] + ).length; + if ( + parsed.fileName + && !parsed.imageUrl + && inlineImageMarkerCount <= 1 + && id + ) { const ext = parsed.fileName.split('.').pop()?.toLowerCase() || ''; if (IMAGE_EXTS.includes(ext)) { parsed.imageUrl = `/api/agents/${id}/files/download?path=workspace/uploads/${encodeURIComponent(parsed.fileName)}&token=${token}`; @@ -4676,9 +4686,14 @@ export default function AgentDetailPage() { () => (llmModels as any[]).filter((m: any) => m.enabled), [llmModels], ); - const effectiveChatModelId = overrideModelId - || agent?.primary_model_id - || myTenant?.default_model_id + const effectiveChatModelId = [ + overrideModelId, + agent?.primary_model_id, + myTenant?.default_model_id, + ].find( + (candidate): candidate is string => Boolean(candidate) + && enabledLlmModels.some((model: any) => model.id === candidate), + ) || enabledLlmModels[0]?.id || null; diff --git a/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx b/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx index 2eeb12665..50a5b1790 100644 --- a/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx +++ b/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx @@ -5,6 +5,7 @@ import { IconEdit } from '@tabler/icons-react'; import { useDialog } from '../../../components/Dialog/DialogProvider'; import { useToast } from '../../../components/Toast/ToastProvider'; import { useAuthStore } from '../../../stores'; +import { notifyModelCacheInvalidated } from '../../../services/modelCacheEvents'; import { fetchJson } from '../utils/fetchJson'; interface LLMModel { @@ -40,8 +41,8 @@ interface RuntimeModelSettings { tenant_id: string; planning_model_id: string | null; compact_model_id: string | null; - planning_source: 'database' | 'environment'; - compact_source: 'database' | 'environment'; + planning_source: 'database' | 'environment' | 'unavailable'; + compact_source: 'database' | 'environment' | 'unavailable'; candidates: Array>; } @@ -98,6 +99,7 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { qc.invalidateQueries({ queryKey: ['tenant-default-model'] }); qc.invalidateQueries({ queryKey: ['agents'] }); qc.invalidateQueries({ queryKey: ['agent'] }); + notifyModelCacheInvalidated(); }; const { data: models = [] } = useQuery({ diff --git a/frontend/src/services/modelCacheEvents.ts b/frontend/src/services/modelCacheEvents.ts new file mode 100644 index 000000000..ae2c5de6d --- /dev/null +++ b/frontend/src/services/modelCacheEvents.ts @@ -0,0 +1,21 @@ +const MODEL_CACHE_EVENT = 'clawith:model-cache-invalidated'; +const MODEL_CACHE_STORAGE_KEY = 'clawith_model_cache_version'; + +export function notifyModelCacheInvalidated(): void { + const version = `${Date.now()}:${Math.random()}`; + localStorage.setItem(MODEL_CACHE_STORAGE_KEY, version); + window.dispatchEvent(new Event(MODEL_CACHE_EVENT)); +} + +export function subscribeModelCacheInvalidation(callback: () => void): () => void { + const onLocal = () => callback(); + const onStorage = (event: StorageEvent) => { + if (event.key === MODEL_CACHE_STORAGE_KEY) callback(); + }; + window.addEventListener(MODEL_CACHE_EVENT, onLocal); + window.addEventListener('storage', onStorage); + return () => { + window.removeEventListener(MODEL_CACHE_EVENT, onLocal); + window.removeEventListener('storage', onStorage); + }; +} diff --git a/frontend/tests/chatMultimodalContract.test.mjs b/frontend/tests/chatMultimodalContract.test.mjs new file mode 100644 index 000000000..aae8a9c60 --- /dev/null +++ b/frontend/tests/chatMultimodalContract.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync( + new URL('../src/pages/agent-detail/AgentDetailPage.tsx', import.meta.url), + 'utf8', +); + +test('persisted multi-image chat renders individual image markers', () => { + assert.match( + source, + /const inlineImageMarkerCount = \(\s*parsed\.content\.match\(\/\\\[image_data:data:image\\\/\/g\) \|\| \[\]\s*\)\.length/, + ); + assert.match(source, /inlineImageMarkerCount <= 1/); + assert.match(source, /inlineImages\.map\(\(url, idx\) =>/); +}); diff --git a/frontend/tests/groupInteractionContract.test.mjs b/frontend/tests/groupInteractionContract.test.mjs index 8bf7bc5f2..853178d1f 100644 --- a/frontend/tests/groupInteractionContract.test.mjs +++ b/frontend/tests/groupInteractionContract.test.mjs @@ -18,6 +18,10 @@ const groupUnread = readFileSync( new URL('../src/hooks/useGroupUnread.ts', import.meta.url), 'utf8', ); +const messageStream = readFileSync( + new URL('../src/pages/groups/MessageStream.tsx', import.meta.url), + 'utf8', +); test('new group sessions may use the backend default title while group names stay required', () => { assert.match(promptModal, /allowEmpty\?: boolean/); @@ -84,6 +88,21 @@ test('group composer and stream use session-wide active runs', () => { assert.match(groupsPage, /runningAgents=\{runningAgents\}/); }); +test('planning and running agents keep the single transient typing indicator', () => { + assert.match(messageStream, /\{isPlanning && \(/); + assert.match(messageStream, /groups\.taskPlanning/); + assert.match(messageStream, /\{runningAgents\.map\(\(agent\) => \(/); + assert.match(messageStream, /\{agent\.name\}/); + assert.equal( + (messageStream.match(/group-run-indicator-bubble/g) ?? []).length, + 2, + ); + assert.equal( + (messageStream.match(//g) ?? []).length, + 2, + ); +}); + test('group planning failures preserve the backend message and diagnostics', () => { assert.match(groupsPage, /intake\.error\?\.message/); assert.match(groupsPage, /intake\.error\?\.trace_id/); diff --git a/frontend/tests/runtimeModelSettingsContract.test.mjs b/frontend/tests/runtimeModelSettingsContract.test.mjs index 9713c15cd..d93b7df62 100644 --- a/frontend/tests/runtimeModelSettingsContract.test.mjs +++ b/frontend/tests/runtimeModelSettingsContract.test.mjs @@ -6,6 +6,18 @@ const source = readFileSync( new URL('../src/pages/enterprise-settings/tabs/LlmTab.tsx', import.meta.url), 'utf8', ); +const modelSwitcher = readFileSync( + new URL('../src/components/ModelSwitcher.tsx', import.meta.url), + 'utf8', +); +const modelCacheEvents = readFileSync( + new URL('../src/services/modelCacheEvents.ts', import.meta.url), + 'utf8', +); +const agentDetail = readFileSync( + new URL('../src/pages/agent-detail/AgentDetailPage.tsx', import.meta.url), + 'utf8', +); test('company admins can select planning and group context models', () => { assert.match(source, /\/enterprise\/runtime-model-settings/); @@ -24,3 +36,38 @@ test('runtime model choices are restricted to tenant-safe backend candidates', ( assert.match(source, /当前公司的模型或平台模型/); assert.match(source, /保存后立即生效/); }); + +test('stale runtime model ids stay unselected instead of selecting the first option', () => { + assert.match(source, /planning_source: 'database' \| 'environment' \| 'unavailable'/); + assert.match(source, /planning_model_id: runtimeModelSettings\.planning_model_id \|\| ''/); + assert.match(source, /compact_model_id: runtimeModelSettings\.compact_model_id \|\| ''/); + assert.match(source, /