feat(agent-chat): complete context guardrail event coverage
This commit is contained in:
@@ -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