Compare commits

...

2 Commits

Author SHA1 Message Date
diegosouzapw
e6a1233809 fix(sse): redact video transcript in pre-guardrail rejected-request logs (#12150 P2 item 7)
recordRejectedRequestUsage persists the raw client body for a request rejected
BEFORE handleChatCore runs (provider circuit-breaker OPEN / combo all-targets
exhausted), so the video-bridge guardrail never got a chance to redact the
transcript — a rejected video request kept its raw transcript cues in the
call-log artifact.

Route that body through redactVideoTranscriptFieldsForLog before persistence: a
no-op clone for any non-video body, structured field substitution (never
bypassable by cue content) for a video one. The model path is unaffected — this
request never reaches a provider.

TDD: RED→GREEN — a gate-rejected request carrying an input_video transcript now
persists [redacted-video-transcript] with the raw cue text gone.

Refs #12150, #12430 (item 7).
2026-09-04 03:59:37 -03:00
Markus Hartung
008da6d19a feat(dashboard): link a log entry's Conversation Context to its owning conversation (#12646)
Validado sobre o tip de `release/v3.8.51`, com duas coisas resolvidas antes do merge.

**A falha de CI era stale.** O job `No new ESLint warnings` deste PR apontava `react-hooks/set-state-in-effect` em `src/app/(dashboard)/dashboard/combos/page.tsx:774` — arquivo que este PR não toca, e o mesmo erro aparecia em #12668 e #12672, que também não o tocam. A linha do tempo: o #12355 introduziu a violação de manhã, os CIs rodaram nessa janela, e o #12607 acrescentou a entrada de supressão à tarde. Medido no tip atual com o comando exato do job: **0 ocorrências não suprimidas**. A supressão sobrevivente é "unpruned", e o script passa `--pass-on-unpruned-suppressions` justamente para isso não bloquear.

**Faltava o teste que a regra do projeto exige** para mudanças em `src/`. Acrescentei `tests/unit/ui/log-detail-conversation-link-12646.test.tsx`, verificado **RED-then-GREEN** em vez de escrito contra o código pronto: revertendo `RequestLoggerDetail.sections.tsx` para o tip, 2 dos 3 casos falham; com a mudança deste PR, 3/3 passam.

Detalhe que valeu a pena descobrir: a seção curto-circuita em `allTurns.length === 0`, então o fixture precisa de um `requestBody` que normalize em pelo menos um turno — sem isso o cabeçalho inteiro nunca monta e as asserções passariam pelo motivo errado. O teste fixa três coisas: o href para um `sessionTag` simples, o percent-encoding para um que não é URL-safe, e a ausência de link quando não há `sessionTag`.

Obrigado, @hartmark.
2026-09-04 03:39:09 -03:00
4 changed files with 190 additions and 1 deletions

View File

@@ -273,6 +273,21 @@ export function ConversationContextSection({ log, detail }) {
continues from parent
</a>
)}
{liveDetail?.sessionTag && (
// /dashboard/conversations reads its own `?tree=<id>` deep-link param
// from a fresh mount too (useState(() => searchParams.get("tree")) in
// that page) -- same full-navigation reasoning as the parent-log link
// above. sessionTag is the same conv_<id> the conversations list and
// /api/conversations/[id]/tree both key on.
<a
href={`/dashboard/conversations?tree=${encodeURIComponent(liveDetail.sessionTag)}`}
className="flex items-center gap-1 text-[11px] text-text-muted hover:text-primary transition-colors"
title={`Open conversation ${liveDetail.sessionTag}`}
>
<span className="material-symbols-outlined text-[14px]">forum</span>
view conversation
</a>
)}
</div>
{open && (
<div className="flex items-center gap-1">

View File

@@ -18,6 +18,7 @@
* never turn into a second failure on the response path.
*/
import { saveCallLog, saveRequestUsage } from "@/lib/usageDb";
import { redactVideoTranscriptFieldsForLog } from "@/lib/guardrails/videoBridgeSnapshotRedaction";
export interface RejectedRequestUsageInput {
status: number;
@@ -82,7 +83,12 @@ export async function recordRejectedRequestUsage(input: RejectedRequestUsageInpu
duration,
tokens: {},
error: error || null,
requestBody,
// #12150 P2 item 7: this request was rejected BEFORE the guardrail chain ran
// (circuit-breaker-open / combo-exhausted), so the video-bridge guardrail
// never redacted the transcript. Redact defensively here — a no-op clone for
// any non-video body, structured field substitution (never bypassable by cue
// content) for a video one. See videoBridgeSnapshotRedaction.ts.
requestBody: requestBody == null ? requestBody : redactVideoTranscriptFieldsForLog(requestBody),
comboName,
comboStepId,
comboExecutionKey,

View File

@@ -136,6 +136,68 @@ test("combo-exhausted rejection persists the client request body for dashboard i
});
});
// #12150 P2 item 7: recordRejectedRequestUsage persists the raw client body for
// a request rejected BEFORE the guardrail chain runs (circuit-breaker-open /
// combo-exhausted), so the video-bridge guardrail never got a chance to redact
// the transcript. The body is persisted defensively through
// redactVideoTranscriptFieldsForLog, so a rejected video request's stored log
// never retains the raw transcript cues.
test("#12150 P2 item 7: a rejected request's persisted body has its video transcript redacted", async () => {
const SECRET = "top secret cue text";
await recordRejectedRequestUsage({
status: 503,
model: "default",
requestedModel: "default",
provider: "-",
endpoint: "/v1/chat/completions",
error: "[503] Pipeline gate rejected",
apiKeyId: "key-video-reject",
apiKeyName: "video-reject-test",
correlationId: "corr-video-reject",
startTime: Date.now() - 10,
requestBody: {
model: "default",
messages: [
{
role: "user",
content: [
{ type: "text", text: "look at this video" },
{
type: "input_video",
video_url: "https://example.com/clip.mp4",
transcript: { cues: [{ text: SECRET, startSeconds: 0, endSeconds: 2 }] },
},
],
},
],
},
});
let rejected: { id: string } | undefined;
for (let i = 0; i < 50 && !rejected; i++) {
const logs = await callLogs.getCallLogs({});
const list = (logs.logs ?? logs) as Array<{ apiKeyName?: string | null }>;
const found = (list ?? []).find((l) => l.apiKeyName === "video-reject-test");
if (found) rejected = found as unknown as { id: string };
else await new Promise((r) => setTimeout(r, 10));
}
assert.ok(rejected, "expected a call_logs row for the rejected video request");
const detail = await callLogs.getCallLogById(rejected.id);
assert.ok(detail, "expected to load the call log detail");
assert.equal(
JSON.stringify(detail!.requestBody).includes(SECRET),
false,
"the rejected request's persisted body must not retain the raw video transcript"
);
const transcriptField = (
detail!.requestBody as {
messages: Array<{ content: Array<{ transcript?: unknown }> }>;
}
).messages[0].content[1].transcript;
assert.equal(transcriptField, "[redacted-video-transcript]");
});
test("combo-exhausted rejection without a request body still logs cleanly (no request body available)", async () => {
await recordRejectedRequestUsage({
status: 503,

View File

@@ -0,0 +1,106 @@
// @vitest-environment jsdom
/**
* Guard for #12646: a log entry's Conversation Context header links to the
* conversation that owns it.
*
* The link is a plain `<a href>` on purpose, not a client-side route push:
* /dashboard/conversations reads its own `?tree=<id>` deep-link param from a
* fresh mount (`useState(() => searchParams.get("tree"))`), so it only picks
* the param up on a full navigation. A future refactor to a Next `<Link>`
* would silently stop opening the right tree — which is what this test pins.
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const RequestLoggerDetail = (await import("../../../src/shared/components/RequestLoggerDetail.tsx"))
.default;
let container: HTMLElement;
let root: Root;
function baseLog(overrides: Record<string, unknown> = {}) {
return {
id: "log-12646",
status: 200,
method: "POST",
path: "/v1/chat/completions",
model: "gpt-test",
provider: "openai",
timestamp: new Date().toISOString(),
duration: 42,
tokens: { in: 1, out: 2 },
...overrides,
};
}
const noop = () => {};
// The section short-circuits with `if (allTurns.length === 0) return null`, so the
// fixture needs a request body that normalizes into at least one turn — otherwise
// the whole header, link included, never mounts and the assertions would pass or
// fail for the wrong reason.
const REQUEST_BODY_WITH_A_TURN = {
model: "gpt-test",
messages: [{ role: "user", content: "hello" }],
};
async function renderDetail(detail: Record<string, unknown>) {
const log = baseLog();
await act(async () => {
root.render(
<RequestLoggerDetail
log={log}
detail={{ ...log, requestBody: REQUEST_BODY_WITH_A_TURN, ...detail }}
loading={false}
debugEnabled={false}
onClose={noop}
onCopy={async () => true}
/>
);
});
}
function conversationLink(): HTMLAnchorElement | null {
return container.querySelector<HTMLAnchorElement>('a[href^="/dashboard/conversations?tree="]');
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root.unmount();
});
}
container?.remove();
});
describe("log detail — Conversation Context link (#12646)", () => {
it("deep-links to the owning conversation when the detail carries a sessionTag", async () => {
await renderDetail({ sessionTag: "conv_abc123" });
const link = conversationLink();
expect(link).not.toBeNull();
expect(link!.getAttribute("href")).toBe("/dashboard/conversations?tree=conv_abc123");
});
it("percent-encodes a sessionTag that is not URL-safe", async () => {
await renderDetail({ sessionTag: "conv_a b/c?d" });
expect(conversationLink()!.getAttribute("href")).toBe(
`/dashboard/conversations?tree=${encodeURIComponent("conv_a b/c?d")}`
);
});
it("renders no conversation link when the detail has no sessionTag", async () => {
await renderDetail({});
expect(conversationLink()).toBeNull();
});
});