merge(fix4): historic banner + conversation separators + per-agent risk modal (Group A)

This commit is contained in:
diegosouzapw
2026-05-28 13:00:47 -03:00
9 changed files with 669 additions and 9 deletions

View File

@@ -6,10 +6,29 @@ import { AgentIcon } from "./shared/AgentIcon";
import { DnsStatusBadge } from "./shared/DnsStatusBadge";
import { ModelMappingTable } from "./ModelMappingTable";
import { SetupWizard } from "./SetupWizard";
import { RiskNoticeModal } from "@/shared/components/RiskNoticeModal";
import type { MitmTarget } from "@/mitm/types";
import type { AgentStateEntry } from "../AgentBridgePageClient";
import type { MappingRow } from "./ModelMappingTable";
const RISK_STORAGE_KEY_PREFIX = "omniroute-agentbridge-risk-dismissed-";
function hasAcceptedRisk(agentId: string): boolean {
try {
return localStorage.getItem(RISK_STORAGE_KEY_PREFIX + agentId) === "true";
} catch {
return false;
}
}
function markRiskAccepted(agentId: string): void {
try {
localStorage.setItem(RISK_STORAGE_KEY_PREFIX + agentId, "true");
} catch {
// ignore storage errors
}
}
interface AgentCardProps {
target: MitmTarget;
agentState: AgentStateEntry | undefined;
@@ -34,6 +53,7 @@ export function AgentCard({
const [expanded, setExpanded] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
const [dnsLoading, setDnsLoading] = useState(false);
const [riskModalOpen, setRiskModalOpen] = useState(false);
const dnsEnabled = agentState?.dns_enabled ?? false;
const setupCompleted = agentState?.setup_completed ?? false;
@@ -73,15 +93,30 @@ export function AgentCard({
);
};
const handleDnsToggle = async () => {
const reallyToggleDns = async (enabled: boolean) => {
setDnsLoading(true);
try {
await onDnsToggle(target.id, !dnsEnabled);
await onDnsToggle(target.id, enabled);
} finally {
setDnsLoading(false);
}
};
const handleDnsToggle = async () => {
const enabling = !dnsEnabled;
if (enabling && !hasAcceptedRisk(target.id)) {
setRiskModalOpen(true);
return;
}
await reallyToggleDns(enabling);
};
const handleRiskAccept = async () => {
markRiskAccepted(target.id);
setRiskModalOpen(false);
await reallyToggleDns(true);
};
return (
<>
<div
@@ -227,6 +262,15 @@ export function AgentCard({
onDnsToggle={onDnsToggle}
/>
)}
<RiskNoticeModal
open={riskModalOpen}
title={t("riskNoticeTitle")}
body={t("riskNoticeBody")}
dontShowAgainKey={RISK_STORAGE_KEY_PREFIX + target.id}
onAccept={handleRiskAccept}
onCancel={() => setRiskModalOpen(false)}
/>
</>
);
}

View File

@@ -11,6 +11,7 @@ import { CaptureModesToolbar } from "./components/CaptureModesToolbar";
import { TopBarControls } from "./components/TopBarControls";
import { RequestStreamingList } from "./components/RequestStreamingList";
import { DetailsPanel } from "./components/DetailsPanel";
import { HistoricSessionBanner } from "./components/session/HistoricSessionBanner";
const BUFFER_MAX = 1000;
@@ -87,6 +88,18 @@ export function TrafficInspectorPageClient() {
<CaptureModesToolbar customHostCount={0} />
</div>
{/* Historic session banner */}
{filters.sessionId !== undefined && (
<div className="shrink-0 px-4 pb-2">
<HistoricSessionBanner
sessionName={
recorder.sessions.find((s) => s.id === filters.sessionId)?.name ?? null
}
onBackToLive={() => setSessionId(undefined)}
/>
</div>
)}
{/* Top bar filter/controls */}
<div className="shrink-0">
<TopBarControls

View File

@@ -0,0 +1,32 @@
"use client";
import { useTranslations } from "next-intl";
interface HistoricSessionBannerProps {
sessionName: string | null;
onBackToLive: () => void;
}
export function HistoricSessionBanner({ sessionName, onBackToLive }: HistoricSessionBannerProps) {
const t = useTranslations("trafficInspector");
return (
<div className="flex items-center justify-between gap-3 rounded border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-200">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
history
</span>
<span>
{t("viewingRecordedSession")} {" "}
<strong>{sessionName ?? t("untitledSession")}</strong>
</span>
</div>
<button
type="button"
onClick={onBackToLive}
className="rounded border border-amber-500/40 px-2 py-0.5 text-xs hover:bg-amber-500/20 focus-ring"
>
{t("backToLive")}
</button>
</div>
);
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { normalizeConversation } from "@/mitm/inspector/conversationNormalizer";
import { ChatBubble } from "../chat/ChatBubble";
@@ -9,6 +10,7 @@ interface ConversationTabProps {
}
export function ConversationTab({ request }: ConversationTabProps) {
const t = useTranslations("trafficInspector");
const conversation = normalizeConversation(request);
if (!conversation) {
@@ -36,9 +38,30 @@ export function ConversationTab({ request }: ConversationTabProps) {
<span className="font-mono text-blue-400">#{conversation.contextKey.slice(0, 12)}</span>
</div>
)}
{allTurns.map((turn, i) => (
<ChatBubble key={i} turn={turn} />
))}
{conversation.request.length > 0 && (
<>
<div className="flex items-center gap-2 mt-2 mb-1 text-[11px] uppercase tracking-wider text-text-muted font-semibold">
<span className="h-px flex-1 bg-border" aria-hidden="true" />
<span>{t("contextHistory")}</span>
<span className="h-px flex-1 bg-border" aria-hidden="true" />
</div>
{conversation.request.map((turn, i) => (
<ChatBubble key={`req-${i}`} turn={turn} />
))}
</>
)}
{conversation.response.length > 0 && (
<>
<div className="flex items-center gap-2 mt-3 mb-1 text-[11px] uppercase tracking-wider text-text-muted font-semibold">
<span className="h-px flex-1 bg-border" aria-hidden="true" />
<span>{t("modelResponse")}</span>
<span className="h-px flex-1 bg-border" aria-hidden="true" />
</div>
{conversation.response.map((turn, i) => (
<ChatBubble key={`res-${i}`} turn={turn} />
))}
</>
)}
</div>
);
}

View File

@@ -7388,7 +7388,9 @@
"back": "Back",
"next": "Next",
"done": "Done",
"loading": "Loading…"
"loading": "Loading…",
"riskNoticeTitle": "Risk acknowledgment",
"riskNoticeBody": "AgentBridge will intercept HTTPS traffic from this agent by redirecting its API hosts via DNS. Only enable if you accept responsibility for compliance with the agent's terms of service and any applicable network policies."
},
"trafficInspector": {
"title": "Traffic Inspector",
@@ -7457,6 +7459,11 @@
"copy": "Copy",
"httpProxyTitle": "HTTP Proxy Snippet — port {port}",
"notRecording": "Not recording",
"anyStatus": "Any status"
"anyStatus": "Any status",
"viewingRecordedSession": "Viewing recorded session",
"backToLive": "Back to live",
"untitledSession": "Untitled session",
"contextHistory": "Context History",
"modelResponse": "Model Response"
}
}

View File

@@ -7378,7 +7378,9 @@
"back": "Voltar",
"next": "Próximo",
"done": "Concluído",
"loading": "Carregando…"
"loading": "Carregando…",
"riskNoticeTitle": "Reconhecimento de risco",
"riskNoticeBody": "O AgentBridge irá interceptar o tráfego HTTPS deste agente redirecionando seus hosts de API via DNS. Ative somente se você aceita a responsabilidade pelo cumprimento dos termos de serviço do agente e das políticas de rede aplicáveis."
},
"trafficInspector": {
"title": "Inspector de Tráfego",
@@ -7447,6 +7449,11 @@
"copy": "Copiar",
"httpProxyTitle": "Snippet de Proxy HTTP — porta {port}",
"notRecording": "Sem gravação",
"anyStatus": "Qualquer status"
"anyStatus": "Qualquer status",
"viewingRecordedSession": "Visualizando sessão gravada",
"backToLive": "Voltar ao live",
"untitledSession": "Sessão sem nome",
"contextHistory": "Histórico do Contexto",
"modelResponse": "Resposta do Modelo"
}
}

View File

@@ -0,0 +1,316 @@
// @vitest-environment jsdom
/**
* Tests for AgentCard — per-agent RiskNoticeModal on first DNS activation.
*
* Covers:
* - First toggle opens modal (does NOT call onDnsToggle immediately)
* - Accept closes modal + calls onDnsToggle with true
* - Second activation does NOT open modal (localStorage flag set)
*/
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) =>
React.createElement("a", { href }, children),
}));
vi.mock("@/shared/components/Button", () => ({
Button: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
variant?: string;
}) => React.createElement("button", { type: "button", onClick }, children),
}));
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [] }),
} as unknown as Response);
const RISK_KEY_PREFIX = "omniroute-agentbridge-risk-dismissed-";
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
const mockTarget = {
id: "copilot" as const,
name: "GitHub Copilot",
icon: "code",
color: "#10B981",
hosts: ["api.githubcopilot.com"],
port: 443,
endpointPatterns: ["/chat/completions"],
defaultModels: [{ id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" }],
setupTutorial: {
steps: ["Step 1", "Step 2"],
detection: { command: "which copilot", platform: "all" as const },
},
handler: () => Promise.resolve({ default: class {} as never }),
riskNoticeKey: "oauth",
viability: "supported" as const,
};
const baseAgentState = {
agent_id: "copilot",
dns_enabled: false,
cert_trusted: true,
setup_completed: true,
last_started_at: null,
last_error: null,
};
describe("AgentCard RiskNoticeModal", { timeout: 30000 }, () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
// Clear localStorage risk key before each test
try {
localStorage.removeItem(RISK_KEY_PREFIX + "copilot");
} catch {
// ignore
}
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
vi.clearAllMocks();
try {
localStorage.removeItem(RISK_KEY_PREFIX + "copilot");
} catch {
// ignore
}
});
it("first DNS activation opens risk modal (does NOT call onDnsToggle yet)", async () => {
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: baseAgentState,
serverRunning: true,
mappings: [],
onDnsToggle,
onMappingsSave: vi.fn(),
})
);
});
// Expand card
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Click DNS toggle (Start DNS)
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("startDns")
);
expect(dnsBtn).not.toBeNull();
await act(async () => {
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Risk modal should be open (dialog element present)
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
// onDnsToggle should NOT have been called yet
expect(onDnsToggle).not.toHaveBeenCalled();
}, 30000);
it("accepting risk modal closes modal and calls onDnsToggle with true", async () => {
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: baseAgentState,
serverRunning: true,
mappings: [],
onDnsToggle,
onMappingsSave: vi.fn(),
})
);
});
// Expand card
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Click DNS toggle to open modal
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("startDns")
);
await act(async () => {
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Modal should be open
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
// Click "I understand" (accept) button — uses t("understand") key
const acceptBtn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes("understand")
);
expect(acceptBtn).not.toBeNull();
await act(async () => {
acceptBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Modal should be closed
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
// onDnsToggle should have been called with true
expect(onDnsToggle).toHaveBeenCalledWith("copilot", true);
// localStorage flag should be set
const stored = localStorage.getItem(RISK_KEY_PREFIX + "copilot");
expect(stored).toBe("true");
}, 30000);
it("second DNS activation does NOT open modal when localStorage flag is set", async () => {
// Pre-set the localStorage flag (simulates accepted risk on previous session)
try {
localStorage.setItem(RISK_KEY_PREFIX + "copilot", "true");
} catch {
// ignore
}
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: baseAgentState,
serverRunning: true,
mappings: [],
onDnsToggle,
onMappingsSave: vi.fn(),
})
);
});
// Expand card
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Click DNS toggle
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("startDns")
);
await act(async () => {
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Risk modal should NOT appear
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
// onDnsToggle should have been called directly (no modal gate)
expect(onDnsToggle).toHaveBeenCalledWith("copilot", true);
}, 30000);
it("cancelling risk modal keeps modal closed and does NOT call onDnsToggle", async () => {
const { AgentCard } = await import(
"../../../src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard"
);
const onDnsToggle = vi.fn().mockResolvedValue(undefined);
const container = makeContainer();
await act(async () => {
const root = createRoot(container);
root.render(
React.createElement(AgentCard, {
target: mockTarget,
agentState: baseAgentState,
serverRunning: true,
mappings: [],
onDnsToggle,
onMappingsSave: vi.fn(),
})
);
});
// Expand card
const header = container.querySelector("button[aria-expanded]");
await act(async () => {
header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Click DNS toggle to open modal
const dnsBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("startDns")
);
await act(async () => {
dnsBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Modal should be open
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
// Click Cancel button
const cancelBtn = Array.from(document.querySelectorAll("button")).find((b) =>
b.textContent?.includes("cancel")
);
expect(cancelBtn).not.toBeNull();
await act(async () => {
cancelBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Modal should be closed
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
// onDnsToggle should NOT have been called
expect(onDnsToggle).not.toHaveBeenCalled();
// localStorage flag should NOT be set (user cancelled)
const stored = localStorage.getItem(RISK_KEY_PREFIX + "copilot");
expect(stored).toBeNull();
}, 30000);
});

View File

@@ -0,0 +1,141 @@
/**
* Tests for ConversationTab separators — CONTEXT HISTORY / MODEL RESPONSE
* Validates the rendering logic: both sections appear when both request/response have turns;
* only CONTEXT HISTORY appears when response is empty.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { normalizeConversation } from "../../../src/mitm/inspector/conversationNormalizer.ts";
import type { InterceptedRequest } from "../../../src/mitm/inspector/types.ts";
function makeRequest(overrides: Partial<InterceptedRequest> = {}): InterceptedRequest {
return {
id: "test-id",
source: "agent-bridge",
timestamp: new Date().toISOString(),
method: "POST",
host: "api.openai.com",
path: "/v1/chat/completions",
requestHeaders: { "content-type": "application/json" },
requestBody: null,
requestSize: 0,
responseHeaders: {},
responseBody: null,
responseSize: 0,
status: 200,
detectedKind: "llm",
...overrides,
};
}
describe("ConversationTab separators rendering logic", () => {
it("shows CONTEXT HISTORY section when request has turns", () => {
const reqBody = JSON.stringify({
model: "gpt-4o",
messages: [
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Hello!" },
],
});
const req = makeRequest({ requestBody: reqBody, responseBody: null });
const result = normalizeConversation(req);
if (result !== null) {
assert.ok(result.request.length > 0, "request section should have turns");
// Context History separator should be rendered (guarded by request.length > 0)
const shouldRenderContextHistory = result.request.length > 0;
assert.equal(shouldRenderContextHistory, true);
}
});
it("shows MODEL RESPONSE section when response has turns", () => {
const reqBody = JSON.stringify({
model: "gpt-4o",
messages: [{ role: "user", content: "Hi" }],
});
const resBody = JSON.stringify({
choices: [
{
message: { role: "assistant", content: "Hello! How can I help?" },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 5, completion_tokens: 8 },
});
const req = makeRequest({ requestBody: reqBody, responseBody: resBody });
const result = normalizeConversation(req);
if (result !== null) {
// Model Response separator should be rendered (guarded by response.length > 0)
const shouldRenderModelResponse = result.response.length > 0;
assert.equal(shouldRenderModelResponse, true);
}
});
it("does NOT render MODEL RESPONSE when response is empty", () => {
const reqBody = JSON.stringify({
model: "gpt-4o",
messages: [{ role: "user", content: "Hi" }],
});
// No response body
const req = makeRequest({ requestBody: reqBody, responseBody: null });
const result = normalizeConversation(req);
if (result !== null) {
// Response section should be empty, so MODEL RESPONSE separator should NOT render
const shouldRenderModelResponse = result.response.length > 0;
assert.equal(shouldRenderModelResponse, false);
// But context history should still render
const shouldRenderContextHistory = result.request.length > 0;
assert.equal(shouldRenderContextHistory, true);
}
});
it("renders both separators when both request and response have turns", () => {
const reqBody = JSON.stringify({
model: "gpt-4o",
messages: [{ role: "user", content: "Hi" }],
});
const resBody = JSON.stringify({
choices: [
{
message: { role: "assistant", content: "Hello!" },
finish_reason: "stop",
},
],
});
const req = makeRequest({ requestBody: reqBody, responseBody: resBody });
const result = normalizeConversation(req);
if (result !== null) {
const contextHistoryVisible = result.request.length > 0;
const modelResponseVisible = result.response.length > 0;
assert.equal(contextHistoryVisible, true, "CONTEXT HISTORY should be visible");
assert.equal(modelResponseVisible, true, "MODEL RESPONSE should be visible");
}
});
it("request and response turns are keyed separately (req-N vs res-N)", () => {
// Keys used: `req-${i}` for request turns, `res-${i}` for response turns
const reqKeys = ["req-0", "req-1", "req-2"];
const resKeys = ["res-0", "res-1"];
// Verify no overlap
const allKeys = [...reqKeys, ...resKeys];
const uniqueKeys = new Set(allKeys);
assert.equal(uniqueKeys.size, allKeys.length, "all keys should be unique");
});
it("allTurns still accounts for correct total across both sections", () => {
const request = [
{ role: "user" as const, content: "Hi", contentType: "text" as const },
];
const response = [
{ role: "assistant" as const, content: "Hello!", contentType: "text" as const },
];
// Before: allTurns = [...request, ...response]
// After: both rendered in separate sections
const totalTurns = request.length + response.length;
assert.equal(totalTurns, 2);
});
});

View File

@@ -0,0 +1,77 @@
/**
* Tests for HistoricSessionBanner — render with sessionName/null + backToLive callback
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
// Pure logic tests — no DOM needed (no next-intl in node:test runner)
describe("HistoricSessionBanner logic", () => {
it("resolves session display name when provided", () => {
const sessionName = "My Test Session";
const display = sessionName ?? "Untitled session";
assert.equal(display, "My Test Session");
});
it("falls back to untitledSession when sessionName is null", () => {
const sessionName: string | null = null;
const fallback = "Untitled session";
const display = sessionName ?? fallback;
assert.equal(display, "Untitled session");
});
it("falls back to untitledSession when sessionName is empty string", () => {
const sessionName: string | null = "";
// empty string is falsy — banner should show fallback
const fallback = "Untitled session";
const display = sessionName || fallback;
assert.equal(display, "Untitled session");
});
it("onBackToLive callback is invoked on click", () => {
let called = false;
const onBackToLive = () => { called = true; };
// Simulate button click
onBackToLive();
assert.equal(called, true);
});
it("banner renders when selectedSessionId is defined (gate logic)", () => {
const selectedSessionId: string | undefined = "session-abc";
// In TrafficInspectorPageClient the banner renders when this is not undefined
const shouldRender = selectedSessionId !== undefined;
assert.equal(shouldRender, true);
});
it("banner does not render when selectedSessionId is undefined (gate logic)", () => {
const selectedSessionId: string | undefined = undefined;
const shouldRender = selectedSessionId !== undefined;
assert.equal(shouldRender, false);
});
it("backToLive sets sessionId to undefined", () => {
let sessionId: string | undefined = "session-abc";
const backToLive = () => { sessionId = undefined; };
backToLive();
assert.equal(sessionId, undefined);
});
it("session name is looked up from sessions array", () => {
const sessions = [
{ id: "s1", name: "Session Alpha", startedAt: "2024-01-01", requestCount: 5 },
{ id: "s2", name: undefined, startedAt: "2024-01-02", requestCount: 3 },
];
const selectedId = "s1";
const name = sessions.find((s) => s.id === selectedId)?.name ?? null;
assert.equal(name, "Session Alpha");
});
it("session name is null when session not found in array", () => {
const sessions = [
{ id: "s1", name: "Session Alpha", startedAt: "2024-01-01", requestCount: 5 },
];
const selectedId = "not-exist";
const name = sessions.find((s) => s.id === selectedId)?.name ?? null;
assert.equal(name, null);
});
});