feat(agent-chat): complete context guardrail event coverage

This commit is contained in:
2026-07-05 14:14:42 +03:00
parent 45e781fb74
commit 147d711657
13 changed files with 587 additions and 24 deletions

View File

@@ -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)

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View File

@@ -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>

View File

@@ -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"

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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. */

View File

@@ -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