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

This commit is contained in:
2026-07-05 14:14:42 +03:00
parent dbc6314d2e
commit 1bb6a5b5a7
13 changed files with 587 additions and 24 deletions

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