feat(agent-chat): complete context guardrail event coverage
This commit is contained in:
@@ -52,7 +52,12 @@ from src.agent.context import set_user_jwt, set_user_role
|
||||
from src.agent.document_parser import parse_upload
|
||||
from src.agent.langgraph_setup import create_agent
|
||||
from src.agent.middleware import log_tool_event
|
||||
from src.agent.tools import _redact_sensitive_fields, get_all_tools
|
||||
from src.agent.tools import (
|
||||
_redact_sensitive_fields,
|
||||
drain_tool_retry_events,
|
||||
get_all_tools,
|
||||
start_tool_retry_event_buffer,
|
||||
)
|
||||
from src.core.auth.jwt import decode_token
|
||||
from src.core.cot_logger import seed_trace_id
|
||||
from src.core.logger import logger
|
||||
@@ -517,6 +522,15 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
user_role,
|
||||
uicontext.get("objectType") if uicontext else None,
|
||||
)
|
||||
yield json.dumps({
|
||||
"content": "",
|
||||
"metadata": {
|
||||
"type": "pipeline_result",
|
||||
"tools": [tool.name for tool in agent_tools],
|
||||
"object_type": uicontext.get("objectType") if uicontext else None,
|
||||
"user_role": user_role,
|
||||
},
|
||||
})
|
||||
# Auto-inject environment_id into tool calls when LLM omits it
|
||||
agent_tools = _inject_env_id_into_tools(agent_tools, env_id)
|
||||
agent = await create_agent(agent_tools, env_id)
|
||||
@@ -524,6 +538,7 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
|
||||
assistant_parts: list[str] = []
|
||||
max_attempts = 2
|
||||
start_tool_retry_event_buffer()
|
||||
|
||||
try:
|
||||
for attempt in range(max_attempts):
|
||||
@@ -534,6 +549,9 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
config=config,
|
||||
version="v2",
|
||||
):
|
||||
for retry_event in drain_tool_retry_events():
|
||||
emitted_any = True
|
||||
yield json.dumps(retry_event)
|
||||
kind = event.get("event")
|
||||
if kind in ("on_tool_start", "on_tool_end", "on_tool_error"):
|
||||
await log_tool_event(event, conv_id)
|
||||
@@ -589,12 +607,31 @@ async def agent_handler( # noqa: C901 — intentionally complex C4 orchestratio
|
||||
user_role=denied_role,
|
||||
)
|
||||
continue
|
||||
if "timed out" in err.lower() or "timeout" in err.lower():
|
||||
is_write_tool = "operation status unknown" in err.lower() or tool_name in {
|
||||
"deploy_dashboard", "commit_changes", "create_branch", "run_backup",
|
||||
"execute_migration", "start_maintenance", "end_maintenance",
|
||||
}
|
||||
yield json.dumps({
|
||||
"content": f"⏱️ {tool_name} — timeout",
|
||||
"metadata": {
|
||||
"type": "tool_timeout",
|
||||
"tool": tool_name,
|
||||
"timeout_seconds": 30,
|
||||
"is_write_tool": is_write_tool,
|
||||
"retryable": not is_write_tool,
|
||||
},
|
||||
})
|
||||
continue
|
||||
yield json.dumps({
|
||||
"content": f"❌ {tool_name} — {err}",
|
||||
"metadata": {"type": "tool_error", "tool": tool_name, "error": err},
|
||||
})
|
||||
|
||||
state = await agent.aget_state(config)
|
||||
for retry_event in drain_tool_retry_events():
|
||||
emitted_any = True
|
||||
yield json.dumps(retry_event)
|
||||
if getattr(state, "next", None):
|
||||
emitted_any = True
|
||||
yield confirmation_payload(conv_id, state, visible_user_text, user_role, env_id)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# @INVARIANT Every @tool function MUST have a Python docstring (triple-quoted string immediately after the signature). LangChain raises ValueError("Function must have a docstring if description not provided.") when args_schema is provided without a description param AND the function lacks a docstring. This is a runtime blocker — the entire Gradio agent fails to import when ANY single tool violates this invariant. The GRACE @BRIEF comment is NOT a substitute — it's structural metadata invisible to the Python runtime. Rule: BEFORE decorating with @tool(args_schema=...), ensure `"""One-line description."""` is present on the next line.
|
||||
|
||||
import asyncio
|
||||
from contextvars import ContextVar
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
@@ -20,9 +21,47 @@ from src.core.logger import logger
|
||||
|
||||
TOOL_RESPONSE_LIMIT = 4000
|
||||
TOOL_TIMEOUT_SECONDS = 30
|
||||
_TOOL_RETRY_EVENTS: ContextVar[list[dict[str, Any]] | None] = ContextVar(
|
||||
"agent_tool_retry_events",
|
||||
default=None,
|
||||
)
|
||||
|
||||
# ── Internal helpers ─────────────────────────────────────────────
|
||||
|
||||
# #region AgentChat.Tools.RetryEvents [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,retry,sse]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Context-local retry event buffer used by Gradio stream to emit tool_retry metadata.
|
||||
def start_tool_retry_event_buffer() -> None:
|
||||
"""Initialise an empty retry-event buffer for the current agent request."""
|
||||
_TOOL_RETRY_EVENTS.set([])
|
||||
|
||||
|
||||
def drain_tool_retry_events() -> list[dict[str, Any]]:
|
||||
"""Return and clear pending tool_retry events for the current request."""
|
||||
events = _TOOL_RETRY_EVENTS.get()
|
||||
if not events:
|
||||
return []
|
||||
drained = list(events)
|
||||
events.clear()
|
||||
return drained
|
||||
|
||||
|
||||
def _queue_tool_retry_event(tool_name: str, attempt: int, max_attempts: int) -> None:
|
||||
"""Queue a tool_retry event if a request-local buffer is active."""
|
||||
events = _TOOL_RETRY_EVENTS.get()
|
||||
if events is None:
|
||||
return
|
||||
events.append({
|
||||
"content": f"🔁 {tool_name} retry {attempt}/{max_attempts}",
|
||||
"metadata": {
|
||||
"type": "tool_retry",
|
||||
"tool": tool_name,
|
||||
"attempt": attempt,
|
||||
"max_attempts": max_attempts,
|
||||
},
|
||||
})
|
||||
# #endregion AgentChat.Tools.RetryEvents
|
||||
|
||||
# #region AgentChat.Tools.DualAuthHeaders [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,auth,helper]
|
||||
# @ingroup AgentChat
|
||||
# @BRIEF Build dual-identity auth headers for tool→FastAPI calls per FR-007/FR-019.
|
||||
@@ -132,6 +171,7 @@ async def _retry_read_tool(
|
||||
except (httpx.HTTPStatusError, httpx.ConnectError, httpx.ReadTimeout) as e:
|
||||
last_error = e
|
||||
if attempt < max_attempts:
|
||||
_queue_tool_retry_event(tool_name, attempt, max_attempts)
|
||||
logger.reason("Tool retry", payload={"tool": tool_name, "attempt": attempt, "error": str(e)[:100]}, extra={"src": "AgentChat.Tools.Retry"})
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
70
backend/tests/test_agent_confirmation_v2.py
Normal file
70
backend/tests/test_agent_confirmation_v2.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# #region Test.AgentChat.ConfirmationV2 [C:3] [TYPE Module] [SEMANTICS test,agent-chat,confirmation,guardrails]
|
||||
# @BRIEF Contract tests for build_confirmation_contract_v2 and permission_denied payloads.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Confirmation.GuardV2]
|
||||
# @RELATION BINDS_TO -> [AgentChat.Confirmation.PermissionDenied]
|
||||
# @TEST_EDGE: deploy_prod -> guarded write with prod env_context.
|
||||
# @TEST_EDGE: delete_any -> dangerous operation.
|
||||
# @TEST_EDGE: analyst_deploy -> permission_denied SSE metadata.
|
||||
|
||||
import json
|
||||
|
||||
from src.agent._confirmation import build_confirmation_contract_v2, permission_denied_payload
|
||||
|
||||
|
||||
# #region test_deploy_prod_guarded [C:2] [TYPE Function]
|
||||
# @BRIEF deploy_dashboard targeting prod resolves guarded write + env_context prod.
|
||||
def test_deploy_prod_guarded():
|
||||
contract = build_confirmation_contract_v2(
|
||||
"deploy_dashboard",
|
||||
{"env_id": "prod-eu"},
|
||||
user_role="admin",
|
||||
target_env="staging",
|
||||
)
|
||||
|
||||
assert contract["risk"] == "write"
|
||||
assert contract["risk_level"] == "guarded"
|
||||
assert contract["env_context"] == "prod"
|
||||
assert contract["permission_granted"] is True
|
||||
# #endregion test_deploy_prod_guarded
|
||||
|
||||
|
||||
# #region test_delete_any_is_dangerous [C:2] [TYPE Function]
|
||||
# @BRIEF delete-prefixed operations are classified as dangerous.
|
||||
def test_delete_any_is_dangerous():
|
||||
contract = build_confirmation_contract_v2("delete_dashboard", {}, user_role="admin")
|
||||
|
||||
assert contract["risk"] == "write"
|
||||
assert contract["risk_level"] == "dangerous"
|
||||
assert contract["dangerous"] is True
|
||||
# #endregion test_delete_any_is_dangerous
|
||||
|
||||
|
||||
# #region test_read_only_safe [C:2] [TYPE Function]
|
||||
# @BRIEF search_dashboards remains safe read.
|
||||
def test_read_only_safe():
|
||||
contract = build_confirmation_contract_v2("search_dashboards", {}, user_role="analyst")
|
||||
|
||||
assert contract["risk"] == "read"
|
||||
assert contract["risk_level"] == "safe"
|
||||
assert contract["permission_granted"] is True
|
||||
# #endregion test_read_only_safe
|
||||
|
||||
|
||||
# #region test_analyst_deploy_permission_denied_payload [C:2] [TYPE Function]
|
||||
# @BRIEF Analyst deploy produces permission_denied metadata, not confirm_required metadata.
|
||||
def test_analyst_deploy_permission_denied_payload():
|
||||
contract = build_confirmation_contract_v2("deploy_dashboard", {}, user_role="analyst")
|
||||
payload = json.loads(permission_denied_payload(
|
||||
"deploy_dashboard",
|
||||
required_role=contract["required_role"],
|
||||
user_role="analyst",
|
||||
alternatives=contract["alternatives"],
|
||||
))
|
||||
|
||||
assert contract["permission_granted"] is False
|
||||
assert payload["metadata"]["type"] == "permission_denied"
|
||||
assert payload["metadata"]["required_role"] == "admin"
|
||||
assert payload["metadata"]["user_role"] == "analyst"
|
||||
# #endregion test_analyst_deploy_permission_denied_payload
|
||||
|
||||
# #endregion Test.AgentChat.ConfirmationV2
|
||||
61
backend/tests/test_agent_context.py
Normal file
61
backend/tests/test_agent_context.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# #region Test.AgentChat.Context [C:3] [TYPE Module] [SEMANTICS test,agent-chat,context,validation]
|
||||
# @BRIEF Contract tests for UIContext validation at the backend trust boundary.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Context.Validate]
|
||||
# @TEST_EDGE: valid -> returns unchanged payload.
|
||||
# @TEST_EDGE: invalid_objectType -> raises validation error.
|
||||
# @TEST_EDGE: oversized -> raises validation error.
|
||||
# @TEST_EDGE: objectName_too_long -> raises validation error.
|
||||
# ruff: noqa: I001
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agent._context import UIContextValidationError, validate_uicontext
|
||||
|
||||
|
||||
VALID_CONTEXT = {
|
||||
"objectType": "dashboard",
|
||||
"objectId": "42",
|
||||
"objectName": "Energy",
|
||||
"envId": "ss-dev",
|
||||
"route": "/dashboards/42",
|
||||
"contextVersion": 1,
|
||||
}
|
||||
|
||||
|
||||
# #region test_valid_uicontext_returns_payload [C:2] [TYPE Function]
|
||||
# @BRIEF Valid UIContext payload returns unchanged.
|
||||
def test_valid_uicontext_returns_payload():
|
||||
assert validate_uicontext(dict(VALID_CONTEXT)) == VALID_CONTEXT
|
||||
# #endregion test_valid_uicontext_returns_payload
|
||||
|
||||
|
||||
# #region test_invalid_object_type_rejected [C:2] [TYPE Function]
|
||||
# @BRIEF Unknown objectType is rejected by backend validation.
|
||||
def test_invalid_object_type_rejected():
|
||||
payload = dict(VALID_CONTEXT, objectType="chart")
|
||||
|
||||
with pytest.raises(UIContextValidationError, match="objectType"):
|
||||
validate_uicontext(payload)
|
||||
# #endregion test_invalid_object_type_rejected
|
||||
|
||||
|
||||
# #region test_oversized_payload_rejected [C:2] [TYPE Function]
|
||||
# @BRIEF Payloads over 4KB are rejected before prompt injection.
|
||||
def test_oversized_payload_rejected():
|
||||
payload = dict(VALID_CONTEXT, debug_padding="x" * 5000)
|
||||
|
||||
with pytest.raises(UIContextValidationError, match="4 KB"):
|
||||
validate_uicontext(payload)
|
||||
# #endregion test_oversized_payload_rejected
|
||||
|
||||
|
||||
# #region test_object_name_too_long_rejected [C:2] [TYPE Function]
|
||||
# @BRIEF objectName longer than 256 chars is rejected.
|
||||
def test_object_name_too_long_rejected():
|
||||
payload = dict(VALID_CONTEXT, objectName="x" * 257)
|
||||
|
||||
with pytest.raises(UIContextValidationError, match="objectName"):
|
||||
validate_uicontext(payload)
|
||||
# #endregion test_object_name_too_long_rejected
|
||||
|
||||
# #endregion Test.AgentChat.Context
|
||||
@@ -7,14 +7,17 @@
|
||||
# @TEST_EDGE: connect_error -> Retries on ConnectError too.
|
||||
# @TEST_EDGE: read_timeout -> Retries on ReadTimeout too.
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.agent.tools import _execute_with_timeout, _retry_read_tool
|
||||
|
||||
from src.agent.tools import (
|
||||
_execute_with_timeout,
|
||||
_retry_read_tool,
|
||||
drain_tool_retry_events,
|
||||
start_tool_retry_event_buffer,
|
||||
)
|
||||
|
||||
# ── Shared fixtures ──────────────────────────────────────────────────
|
||||
|
||||
@@ -48,6 +51,7 @@ class TestRetryReadTool:
|
||||
expected = {"status": "ok", "data": [1, 2, 3]}
|
||||
error_502 = _make_502_error()
|
||||
mock_fn = AsyncMock(side_effect=[error_502, expected])
|
||||
start_tool_retry_event_buffer()
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||||
result = await _retry_read_tool("read_dashboards", mock_fn)
|
||||
@@ -55,6 +59,15 @@ class TestRetryReadTool:
|
||||
assert result == expected
|
||||
assert mock_fn.call_count == 2
|
||||
mock_sleep.assert_awaited_once_with(1)
|
||||
assert drain_tool_retry_events() == [{
|
||||
"content": "🔁 read_dashboards retry 1/2",
|
||||
"metadata": {
|
||||
"type": "tool_retry",
|
||||
"tool": "read_dashboards",
|
||||
"attempt": 1,
|
||||
"max_attempts": 2,
|
||||
},
|
||||
}]
|
||||
# #endregion test_first_attempt_502_retries_once
|
||||
|
||||
# #region test_both_attempts_502_raises [C:2] [TYPE Function]
|
||||
@@ -64,9 +77,8 @@ class TestRetryReadTool:
|
||||
error_502 = _make_502_error()
|
||||
mock_fn = AsyncMock(side_effect=[error_502, error_502])
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await _retry_read_tool("read_dashboards", mock_fn)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock), pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await _retry_read_tool("read_dashboards", mock_fn)
|
||||
|
||||
assert exc_info.value is error_502
|
||||
assert mock_fn.call_count == 2
|
||||
@@ -124,9 +136,8 @@ class TestRetryReadTool:
|
||||
non_http_err = ValueError("something broken in business logic")
|
||||
mock_fn = AsyncMock(side_effect=non_http_err)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await _retry_read_tool("broken_tool", mock_fn)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, pytest.raises(ValueError) as exc_info:
|
||||
await _retry_read_tool("broken_tool", mock_fn)
|
||||
|
||||
assert exc_info.value is non_http_err
|
||||
assert mock_fn.call_count == 1
|
||||
@@ -149,14 +160,13 @@ class TestWriteToolNoRetry:
|
||||
error_502 = _make_502_error()
|
||||
write_op = AsyncMock(side_effect=error_502)
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await _execute_with_timeout(
|
||||
"create_dashboard",
|
||||
write_op,
|
||||
is_write=True,
|
||||
timeout_s=5,
|
||||
)
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await _execute_with_timeout(
|
||||
"create_dashboard",
|
||||
write_op,
|
||||
is_write=True,
|
||||
timeout_s=5,
|
||||
)
|
||||
|
||||
assert exc_info.value is error_502
|
||||
assert write_op.call_count == 1
|
||||
|
||||
78
backend/tests/test_agent_tool_filter.py
Normal file
78
backend/tests/test_agent_tool_filter.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# #region Test.AgentChat.ToolFilter [C:3] [TYPE Module] [SEMANTICS test,agent-chat,tools,filter]
|
||||
# @BRIEF Contract tests for AgentChat.ToolFilter — RBAC first, then context affinity.
|
||||
# @RELATION BINDS_TO -> [AgentChat.ToolFilter.Pipeline]
|
||||
# @TEST_EDGE: null_context -> all RBAC-allowed tools are returned.
|
||||
# @TEST_EDGE: dashboard_analyst -> dashboard tools minus admin-only tools.
|
||||
# @TEST_EDGE: unknown_context -> graceful fallback to RBAC-only filtering.
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.agent._tool_filter import build_tool_pipeline, enforce_tool_permission
|
||||
|
||||
|
||||
def _tool(name: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(name=name)
|
||||
|
||||
|
||||
TOOLS = [
|
||||
_tool("show_capabilities"),
|
||||
_tool("search_dashboards"),
|
||||
_tool("get_health_summary"),
|
||||
_tool("deploy_dashboard"),
|
||||
_tool("create_branch"),
|
||||
_tool("commit_changes"),
|
||||
_tool("superset_explore_database"),
|
||||
_tool("run_backup"),
|
||||
]
|
||||
|
||||
|
||||
# #region test_null_context_filters_by_rbac_only [C:2] [TYPE Function]
|
||||
# @BRIEF Null objectType returns every tool the role may invoke.
|
||||
def test_null_context_filters_by_rbac_only():
|
||||
result = build_tool_pipeline(TOOLS, user_role="analyst", object_type=None)
|
||||
|
||||
assert [tool.name for tool in result] == [
|
||||
"show_capabilities",
|
||||
"search_dashboards",
|
||||
"get_health_summary",
|
||||
"superset_explore_database",
|
||||
]
|
||||
# #endregion test_null_context_filters_by_rbac_only
|
||||
|
||||
|
||||
# #region test_dashboard_analyst_filters_context_after_rbac [C:2] [TYPE Function]
|
||||
# @BRIEF Dashboard context excludes non-dashboard tools after RBAC removes admin-only tools.
|
||||
def test_dashboard_analyst_filters_context_after_rbac():
|
||||
result = build_tool_pipeline(TOOLS, user_role="analyst", object_type="dashboard")
|
||||
|
||||
assert [tool.name for tool in result] == [
|
||||
"show_capabilities",
|
||||
"search_dashboards",
|
||||
"get_health_summary",
|
||||
]
|
||||
# #endregion test_dashboard_analyst_filters_context_after_rbac
|
||||
|
||||
|
||||
# #region test_unknown_context_falls_back_to_rbac_only [C:2] [TYPE Function]
|
||||
# @BRIEF Unknown objectType does not drop RBAC-allowed tools.
|
||||
def test_unknown_context_falls_back_to_rbac_only():
|
||||
result = build_tool_pipeline(TOOLS, user_role="analyst", object_type="unknown")
|
||||
|
||||
assert [tool.name for tool in result] == [
|
||||
"show_capabilities",
|
||||
"search_dashboards",
|
||||
"get_health_summary",
|
||||
"superset_explore_database",
|
||||
]
|
||||
# #endregion test_unknown_context_falls_back_to_rbac_only
|
||||
|
||||
|
||||
# #region test_enforce_tool_permission_rejects_admin_only [C:2] [TYPE Function]
|
||||
# @BRIEF Invocation guard rejects analyst access to admin-only tools.
|
||||
def test_enforce_tool_permission_rejects_admin_only():
|
||||
assert enforce_tool_permission("deploy_dashboard", "analyst") is False
|
||||
assert enforce_tool_permission("deploy_dashboard", "admin") is True
|
||||
assert enforce_tool_permission("search_dashboards", "analyst") is True
|
||||
# #endregion test_enforce_tool_permission_rejects_admin_only
|
||||
|
||||
# #endregion Test.AgentChat.ToolFilter
|
||||
@@ -338,6 +338,7 @@
|
||||
// ── Tool calls (full objects) ──
|
||||
active_tool_calls: model.activeToolCalls.length,
|
||||
active_tool_calls_full: model.activeToolCalls,
|
||||
effective_tool_pipeline: model.effectiveToolPipeline,
|
||||
// ── HITL / Confirmation ──
|
||||
confirmation_message: model.confirmationMessage,
|
||||
pending_tool_name: model.pendingToolName,
|
||||
@@ -642,6 +643,20 @@
|
||||
<p class="text-xs text-text-subtle">Контекст не передан</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Backend tool pipeline ── -->
|
||||
<div class="mb-3">
|
||||
<h3 class="text-xs font-semibold text-text-muted uppercase tracking-wide mb-1">Pipeline</h3>
|
||||
{#if model.effectiveToolPipeline.length > 0}
|
||||
<div class="flex flex-wrap gap-1 rounded-md bg-surface-muted p-2">
|
||||
{#each model.effectiveToolPipeline as tool (tool)}
|
||||
<span class="rounded border border-border bg-surface-card px-1.5 py-0.5 font-mono text-[11px] text-text-muted">{tool}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-text-subtle">Pipeline ещё не получен от backend</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
"Требуется подтверждение",
|
||||
);
|
||||
let isPermissionDenied = $derived(model.pendingRiskLevel === "permission_denied");
|
||||
let envBadge = $derived(
|
||||
model.pendingEnvContext === "prod" ? "⚠️ PROD" :
|
||||
model.pendingEnvContext === "staging" ? "STAGING" :
|
||||
model.pendingEnvContext === "dev" ? "DEV" : "",
|
||||
);
|
||||
let toneClasses = $derived(
|
||||
model.confirmationTone === "success"
|
||||
? {
|
||||
@@ -176,6 +181,15 @@
|
||||
{model.pendingRiskLevel === 'dangerous' ? '💀 dangerous' : model.pendingRiskLevel === 'guarded' ? '⚠️ guarded' : model.pendingRiskLevel === 'permission_denied' ? '🔒 permission_denied' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
{#if envBadge}
|
||||
<span class="ml-1 rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold
|
||||
{model.pendingEnvContext === 'prod' ? 'bg-destructive-light text-destructive' : ''}
|
||||
{model.pendingEnvContext === 'staging' ? 'bg-warning-light text-warning' : ''}
|
||||
{model.pendingEnvContext === 'dev' ? 'bg-info-light text-info' : ''}
|
||||
">
|
||||
{envBadge}
|
||||
</span>
|
||||
{/if}
|
||||
<p id="confirm-desc" class="mt-0.5 text-xs text-text-subtle">
|
||||
{fallbackDescription}
|
||||
</p>
|
||||
@@ -194,7 +208,7 @@
|
||||
</div>
|
||||
{#if argEntries.length > 0}
|
||||
<div class="mt-2 space-y-1">
|
||||
{#each argEntries as [key, value]}
|
||||
{#each argEntries as [key, value] (key)}
|
||||
<div class="flex items-baseline gap-2 text-xs">
|
||||
<span class="shrink-0 font-mono text-text-muted">{key}:</span>
|
||||
<span class="truncate text-text" title={String(value)}>{String(value).slice(0, 120)}</span>
|
||||
@@ -221,11 +235,14 @@
|
||||
onclick={handleConfirm}
|
||||
>
|
||||
{model.dangerousCountdownActive && model.dangerousCountdown > 0
|
||||
? `Подтвердить (${model.dangerousCountdown})`
|
||||
? `💀 Удалить (${model.dangerousCountdown})`
|
||||
: loading === "confirming"
|
||||
? ($t.assistant?.confirming || "Подтверждение…")
|
||||
: ($t.assistant?.confirm || "Подтвердить")}
|
||||
</Button>
|
||||
{#if model.dangerousCountdownActive}
|
||||
<span class="sr-only" aria-live="assertive">Осталось {model.dangerousCountdown} секунд</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// #region Test.AgentChat.ConfirmationCard.Ux [C:3] [TYPE Module] [SEMANTICS test,agent-chat,confirmation,ux]
|
||||
// @BRIEF L2 UX tests for ConfirmationCard risk/env states, dangerous countdown, and permission_denied dismiss-only flow.
|
||||
// @RELATION BINDS_TO -> [AgentChat.ConfirmationCard]
|
||||
// @TEST_EDGE: read -> success/read confirmation renders confirm button.
|
||||
// @TEST_EDGE: write_prod -> destructive PROD badge renders.
|
||||
// @TEST_EDGE: dangerous -> model-owned countdown disables confirm button.
|
||||
// @TEST_EDGE: permission_denied -> dismiss-only card without confirm button.
|
||||
import { fireEvent, render, screen } from "@testing-library/svelte";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("$lib/api/assistant.js", () => ({
|
||||
getAssistantConversations: vi.fn(),
|
||||
getAssistantHistory: vi.fn(),
|
||||
deleteAssistantConversation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/assistantChat.svelte.js", () => ({
|
||||
assistantChatStore: { value: { isOpen: false, conversationId: null } },
|
||||
setAssistantConversationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
|
||||
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
|
||||
|
||||
const mockTranslations = {
|
||||
assistant: {
|
||||
confirmation_card_title: "Требуется подтверждение",
|
||||
confirmation_read_title: "Чтение данных",
|
||||
confirmation_write_title: "Изменение данных",
|
||||
confirmation_read_description: "Разрешить чтение данных?",
|
||||
confirmation_write_description: "Разрешить изменение данных?",
|
||||
confirm: "Подтвердить",
|
||||
cancel: "Отклонить",
|
||||
confirming: "Подтверждение…",
|
||||
cancelling: "Отмена…",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("$lib/i18n/index.svelte.js", () => ({
|
||||
t: { subscribe: (fn: (value: typeof mockTranslations) => void) => { fn(mockTranslations); return () => {}; } },
|
||||
}));
|
||||
|
||||
import { AgentChatModel } from "$lib/models/AgentChatModel.svelte.ts";
|
||||
import ConfirmationCard from "../ConfirmationCard.svelte";
|
||||
|
||||
function confirmationModel(overrides: Partial<AgentChatModel> = {}): AgentChatModel {
|
||||
const model = new AgentChatModel();
|
||||
model.streamingState = "awaiting_confirmation";
|
||||
model.pendingThreadId = "thread-1";
|
||||
model.pendingToolName = "search_dashboards";
|
||||
model.pendingToolArgs = { env_id: "ss-dev" };
|
||||
model.pendingConfirmationRisk = "read";
|
||||
model.pendingRiskLevel = "safe";
|
||||
model.confirmationMessage = "Разрешить действие?";
|
||||
model.resumeConfirm = vi.fn(async () => {}) as unknown as AgentChatModel["resumeConfirm"];
|
||||
model.dismissPermissionDenied = vi.fn() as unknown as AgentChatModel["dismissPermissionDenied"];
|
||||
Object.assign(model, overrides);
|
||||
return model;
|
||||
}
|
||||
|
||||
describe("ConfirmationCard — UX states", () => {
|
||||
// #region test_read_confirmation_renders_confirm [C:2] [TYPE Function]
|
||||
// @BRIEF Read confirmation renders confirm and deny buttons.
|
||||
it("renders read confirmation controls", () => {
|
||||
render(ConfirmationCard, { props: { model: confirmationModel() } });
|
||||
|
||||
expect(screen.getByRole("button", { name: /Подтвердить/ })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Отклонить/ })).toBeTruthy();
|
||||
});
|
||||
// #endregion test_read_confirmation_renders_confirm
|
||||
|
||||
// #region test_write_prod_renders_prod_badge [C:2] [TYPE Function]
|
||||
// @BRIEF Production write state renders a PROD badge.
|
||||
it("renders PROD badge for write_prod confirmation", () => {
|
||||
render(ConfirmationCard, {
|
||||
props: { model: confirmationModel({
|
||||
pendingToolName: "deploy_dashboard",
|
||||
pendingConfirmationRisk: "write",
|
||||
pendingRiskLevel: "guarded",
|
||||
pendingEnvContext: "prod",
|
||||
}) },
|
||||
});
|
||||
|
||||
expect(screen.getByText("⚠️ PROD")).toBeTruthy();
|
||||
});
|
||||
// #endregion test_write_prod_renders_prod_badge
|
||||
|
||||
// #region test_dangerous_countdown_disables_confirm [C:2] [TYPE Function]
|
||||
// @BRIEF Dangerous countdown disables confirm button and announces remaining seconds.
|
||||
it("disables confirm during dangerous countdown", () => {
|
||||
render(ConfirmationCard, {
|
||||
props: { model: confirmationModel({
|
||||
pendingToolName: "delete_dashboard",
|
||||
pendingConfirmationRisk: "write",
|
||||
pendingRiskLevel: "dangerous",
|
||||
dangerousCountdownActive: true,
|
||||
dangerousCountdown: 7,
|
||||
}) },
|
||||
});
|
||||
|
||||
const confirm = screen.getByRole("button", { name: /Удалить \(7\)/ });
|
||||
expect(confirm).toHaveProperty("disabled", true);
|
||||
expect(screen.getByText("Осталось 7 секунд")).toBeTruthy();
|
||||
});
|
||||
// #endregion test_dangerous_countdown_disables_confirm
|
||||
|
||||
// #region test_permission_denied_is_dismiss_only [C:2] [TYPE Function]
|
||||
// @BRIEF Permission denied shows close only and calls dismiss action.
|
||||
it("renders permission_denied as dismiss-only", async () => {
|
||||
const model = confirmationModel({
|
||||
pendingToolName: "deploy_dashboard",
|
||||
pendingConfirmationRisk: "unknown",
|
||||
pendingRiskLevel: "permission_denied",
|
||||
confirmationMessage: "Недостаточно прав",
|
||||
});
|
||||
render(ConfirmationCard, { props: { model } });
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Подтвердить/ })).toBeNull();
|
||||
await fireEvent.click(screen.getByRole("button", { name: /Закрыть/ }));
|
||||
expect(model.dismissPermissionDenied).toHaveBeenCalledOnce();
|
||||
});
|
||||
// #endregion test_permission_denied_is_dismiss_only
|
||||
});
|
||||
// #endregion Test.AgentChat.ConfirmationCard.Ux
|
||||
@@ -33,7 +33,9 @@ export interface StreamProcessorHost {
|
||||
pendingToolArgs: Record<string, unknown>;
|
||||
pendingConfirmationRisk: "read" | "write" | "unknown" | null;
|
||||
pendingRiskLevel: string | null;
|
||||
pendingEnvContext: "prod" | "staging" | "dev" | null;
|
||||
permissionAlternatives: Array<{ action: string; prompt: string }> | null;
|
||||
effectiveToolPipeline: string[];
|
||||
startDangerousCountdown(): void;
|
||||
/** Populated by file_uploaded metadata — attached to user message on stream end. */
|
||||
_pendingAttachment: { file_name: string; file_path: string; file_size: number } | null;
|
||||
@@ -204,6 +206,7 @@ export class StreamProcessor {
|
||||
? meta.risk
|
||||
: null;
|
||||
this.host.pendingRiskLevel = meta.risk_level ?? null;
|
||||
this.host.pendingEnvContext = meta.env_context ?? null;
|
||||
if (meta.risk_level === "dangerous") {
|
||||
this.host.startDangerousCountdown();
|
||||
}
|
||||
@@ -216,6 +219,7 @@ export class StreamProcessor {
|
||||
this.host.pendingThreadId = null;
|
||||
this.host.pendingConfirmationRisk = null;
|
||||
this.host.pendingRiskLevel = null;
|
||||
this.host.pendingEnvContext = null;
|
||||
// Push cancelled tool card when user denies
|
||||
if (meta.result === "denied") {
|
||||
this.host.activeToolCalls = [...this.host.activeToolCalls, {
|
||||
@@ -269,6 +273,10 @@ export class StreamProcessor {
|
||||
break;
|
||||
}
|
||||
|
||||
case "pipeline_result":
|
||||
this.host.effectiveToolPipeline = meta.tools ?? [];
|
||||
break;
|
||||
|
||||
case "file_uploaded":
|
||||
if (meta.file_name && meta.file_path) {
|
||||
this.host._pendingAttachment = {
|
||||
@@ -288,6 +296,7 @@ export class StreamProcessor {
|
||||
this.host.pendingToolArgs = {};
|
||||
this.host.pendingConfirmationRisk = "unknown";
|
||||
this.host.pendingRiskLevel = "permission_denied";
|
||||
this.host.pendingEnvContext = null;
|
||||
this.host.permissionAlternatives = meta.alternatives ?? null;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ export class AgentChatModel {
|
||||
pendingConfirmationRisk: ConfirmationRisk | null = $state(null);
|
||||
/** Backend-provided detailed risk level: safe, guarded, dangerous, unknown. */
|
||||
pendingRiskLevel: string | null = $state(null);
|
||||
/** Backend-provided normalized confirmation environment: prod/staging/dev/null. */
|
||||
pendingEnvContext: "prod" | "staging" | "dev" | null = $state(null);
|
||||
/** Set to true when user clicks Stop — prevents false 'agent unavailable' fallback. */
|
||||
_userCancelled: boolean = $state(false);
|
||||
/** Populated by file_uploaded metadata — attached to user message on stream end. */
|
||||
@@ -114,6 +116,7 @@ export class AgentChatModel {
|
||||
dangerousCountdownActive: boolean = $state(false);
|
||||
_activeEnvId: string | null = $state(null);
|
||||
permissionAlternatives: Array<{ action: string; prompt: string }> | null = $state(null);
|
||||
effectiveToolPipeline: string[] = $state([]);
|
||||
|
||||
// ── Private fields ─────────────────────────────────────────────
|
||||
_client: GradioClient | null = null; // non-private for legacy Object.assign usage
|
||||
@@ -312,6 +315,8 @@ export class AgentChatModel {
|
||||
return "write";
|
||||
});
|
||||
confirmationTone = $derived.by((): AgentPhaseTone => {
|
||||
if (this.pendingRiskLevel === "dangerous" || this.pendingEnvContext === "prod") return "destructive";
|
||||
if (this.pendingEnvContext === "dev") return "muted";
|
||||
if (this.confirmationRisk === "write") return "warning";
|
||||
if (this.confirmationRisk === "read") return "success";
|
||||
return "muted";
|
||||
@@ -447,6 +452,13 @@ export class AgentChatModel {
|
||||
const envId = params.get("envId") || null;
|
||||
const route = params.get("route") || "/agent";
|
||||
|
||||
if (!objectType && !objectId && !objectName && !envId && !params.get("route")) {
|
||||
this.uiContext = null;
|
||||
this._activeEnvId = null;
|
||||
log("AgentChat.Model", "REASON", "UI context absent from params", {});
|
||||
return;
|
||||
}
|
||||
|
||||
this.uiContext = { objectType, objectId, objectName, envId, route, contextVersion: 1 };
|
||||
this._activeEnvId = envId;
|
||||
log("AgentChat.Model", "REASON", "UI context set from params", { objectType, objectId, envId });
|
||||
@@ -485,6 +497,7 @@ export class AgentChatModel {
|
||||
this.pendingToolArgs = {};
|
||||
this.pendingConfirmationRisk = null;
|
||||
this.pendingRiskLevel = null;
|
||||
this.pendingEnvContext = null;
|
||||
this.permissionAlternatives = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,10 @@ export type ConnectionState =
|
||||
|
||||
export interface StreamMetadata {
|
||||
type?: "stream_token" | "tool_start" | "tool_end" | "tool_error"
|
||||
| "tool_retry" | "tool_timeout"
|
||||
| "confirm_required" | "confirm_resolved" | "error"
|
||||
| "file_uploaded" | "permission_denied";
|
||||
| "tool_retry" | "tool_timeout"
|
||||
| "pipeline_result"
|
||||
| "confirm_required" | "confirm_resolved" | "error"
|
||||
| "file_uploaded" | "permission_denied";
|
||||
token?: string;
|
||||
tool?: string;
|
||||
input?: Record<string, unknown>;
|
||||
@@ -40,6 +41,13 @@ export interface StreamMetadata {
|
||||
is_write_tool?: boolean;
|
||||
/** Tool name being requested for confirmation (HITL). */
|
||||
tool_name?: string;
|
||||
/** pipeline_result: effective backend tool list after RBAC/context filtering. */
|
||||
tools?: string[];
|
||||
object_type?: string | null;
|
||||
user_role?: string;
|
||||
/** confirm_required: normalized target environment tier. */
|
||||
env_context?: "prod" | "staging" | "dev" | null;
|
||||
required_role?: string;
|
||||
/** Tool arguments for the pending confirmation call. */
|
||||
tool_args?: Record<string, unknown>;
|
||||
/** Coarse UI risk category for the pending confirmation. */
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// #region Test.AgentChat.Model.Context [C:3] [TYPE Module] [SEMANTICS test,agent-chat,model,context]
|
||||
// @BRIEF L1 model tests for UIContext URL parsing and contextPillLabel; no render.
|
||||
// @RELATION BINDS_TO -> [AgentChat.Model]
|
||||
// @TEST_FIXTURE: full_params -> uiContext populated and dashboard pill shown.
|
||||
// @TEST_FIXTURE: no_params -> uiContext null and no-context pill shown.
|
||||
// @TEST_FIXTURE: malformed_objectType -> objectType treated as null.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("$lib/api/assistant.js", () => ({
|
||||
getAssistantConversations: vi.fn(),
|
||||
getAssistantHistory: vi.fn(),
|
||||
deleteAssistantConversation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/assistantChat.svelte.js", () => ({
|
||||
assistantChatStore: { value: { isOpen: false, conversationId: null } },
|
||||
setAssistantConversationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/toasts.svelte.js", () => ({ addToast: vi.fn() }));
|
||||
vi.mock("$lib/cot-logger", () => ({ log: vi.fn() }));
|
||||
|
||||
import { AgentChatModel } from "../AgentChatModel.svelte.ts";
|
||||
|
||||
describe("AgentChatModel — UIContext", () => {
|
||||
// #region test_full_params_populate_context [C:2] [TYPE Function]
|
||||
// @BRIEF Full URL params populate UIContext and dashboard pill label.
|
||||
it("populates UIContext and context pill from full params", () => {
|
||||
const model = new AgentChatModel();
|
||||
|
||||
model.setUIContextFromParams(new URLSearchParams({
|
||||
objectType: "dashboard",
|
||||
objectId: "42",
|
||||
objectName: "Energy",
|
||||
envId: "ss-dev",
|
||||
route: "/dashboards/42",
|
||||
}));
|
||||
|
||||
expect(model.uiContext).toMatchObject({
|
||||
objectType: "dashboard",
|
||||
objectId: "42",
|
||||
objectName: "Energy",
|
||||
envId: "ss-dev",
|
||||
route: "/dashboards/42",
|
||||
contextVersion: 1,
|
||||
});
|
||||
expect(model.contextPillLabel).toBe("📋 dashboard #42 · ss-dev");
|
||||
});
|
||||
// #endregion test_full_params_populate_context
|
||||
|
||||
// #region test_no_params_keeps_context_null [C:2] [TYPE Function]
|
||||
// @BRIEF Empty params leave UIContext null for general-mode agent.
|
||||
it("keeps UIContext null for empty params", () => {
|
||||
const model = new AgentChatModel();
|
||||
|
||||
model.setUIContextFromParams(new URLSearchParams());
|
||||
|
||||
expect(model.uiContext).toBeNull();
|
||||
expect(model.serializedUIContext()).toBeNull();
|
||||
expect(model.contextPillLabel).toBe("⚪ Контекст не выбран");
|
||||
});
|
||||
// #endregion test_no_params_keeps_context_null
|
||||
|
||||
// #region test_malformed_object_type_treated_as_null [C:2] [TYPE Function]
|
||||
// @BRIEF Unknown objectType does not become trusted structured context type.
|
||||
it("treats malformed objectType as null", () => {
|
||||
const model = new AgentChatModel();
|
||||
|
||||
model.setUIContextFromParams(new URLSearchParams({
|
||||
objectType: "chart",
|
||||
objectId: "42",
|
||||
envId: "ss-dev",
|
||||
route: "/dashboards/42",
|
||||
}));
|
||||
|
||||
expect(model.uiContext?.objectType).toBeNull();
|
||||
expect(model.contextPillLabel).toBe("🌐 ss-dev");
|
||||
});
|
||||
// #endregion test_malformed_object_type_treated_as_null
|
||||
});
|
||||
// #endregion Test.AgentChat.Model.Context
|
||||
Reference in New Issue
Block a user