fix(video): match log redaction by content and gate response-derived memory for observed requests

Fix round 1 (adversarial review) of #12150 P1b. Two findings:

CRITICAL: applyVideoBridgeLogRedaction matched the video-bridge redaction map
by positional {messageIndex, partIndex}, but those positions are computed by
the guardrail's preCall while injectSystemPrompt (prepends a system message
when none exists), context-relay handoff injection, and reasoning-rule body
rewrites all run afterward and can prepend/splice the message array before
persistAttemptLogs serializes the log -- silently invalidating the map. A
stale index either misses the video part (transcript logged unredacted) or,
worse, overwrites an unrelated legitimate message while the transcript still
leaks. Fixed by switching to content-address matching: videoBridge.ts's
VideoBridgeLogRedactionEntry now carries fullText (the exact unredacted text
placed into the part), and applyVideoBridgeLogRedaction scans every part in
the named container for an exact text+type match instead of trusting
position. messageIndex/partIndex are kept as advisory/debugging metadata
only. New "Scenario A" regression test in
video-bridge-log-redaction.test.ts reproduces the real injectSystemPrompt
shape and proves both the leak and the corruption are fixed.

Important: an observed request's response-derived text (the model's own
reply, which received the full unredacted transcript) could still populate
durable Memory -- only the request-derived text was gated. Extracted the
shared decision + extraction wiring into runMemoryExtractionGate
(memoryExtraction.ts), which gates both request- and response-derived
extractFacts calls behind one shouldExtractMemory() check; chatCore.ts's two
call sites (non-streaming, streaming) now each collapse to a single call.
video-bridge-memory-suppression.test.ts's hand-mirrored stub was replaced
with tests against the real runMemoryExtractionGate, including one isolating
the response-derived path specifically.

Refs #12150
This commit is contained in:
diegosouzapw
2026-09-02 02:39:05 -03:00
parent 2d31fb71fd
commit 380cb2ff3a
7 changed files with 430 additions and 178 deletions

View File

@@ -123,12 +123,7 @@ export {
buildStreamingResponseHeaders,
stripStaleForwardingHeaders,
};
import {
extractMemoryTextFromResponse,
extractMemoryTextFromRequestBody,
resolveMemoryOwnerId,
shouldExtractMemory,
} from "./chatCore/memoryExtraction.ts";
import { resolveMemoryOwnerId, runMemoryExtractionGate } from "./chatCore/memoryExtraction.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
import { checkResourcePressureGuard } from "../utils/resourcePressure.ts";
import { normalizeHeaders } from "../utils/headers.ts";
@@ -555,8 +550,9 @@ export async function handleChatCore({
}) {
let { provider, model, extendedContext } = modelInfo;
// #12150 P1b: true iff the video-bridge guardrail rendered >=1 transcript
// cue into a replaced part of this request. Gates request-derived Memory
// extraction (chatCore/memoryExtraction.ts::shouldExtractMemory).
// cue into a replaced part of this request. Gates both request- and
// response-derived Memory extraction
// (chatCore/memoryExtraction.ts::runMemoryExtractionGate).
const videoBridgeObserved: boolean =
(videoBridgeLog as VideoBridgeLogParam | undefined)?.observed === true;
const resilienceSettings = resolveResilienceSettings(cachedSettings);
@@ -5163,36 +5159,22 @@ export async function handleChatCore({
}
);
if (memoryOwnerId && memorySettings?.enabled && memorySettings.maxTokens > 0) {
// #12150 P1b surface 3: a video-bridge-observed request's request-derived
// text is a flattened transcript description, not user-authored
// conversation — never persist it into durable Memory. The
// response-derived extraction just below (the model's own reply) is
// unaffected — out of scope for this gap-closure task.
if (
shouldExtractMemory({
enabled: memorySettings.enabled,
maxTokens: memorySettings.maxTokens,
memoryOwnerId,
videoBridgeObserved,
})
) {
const requestMemoryText = extractMemoryTextFromRequestBody(body as Record<string, unknown>);
if (requestMemoryText) {
extractFacts(requestMemoryText, memoryOwnerId, pipelineSessionId);
}
} else if (videoBridgeObserved) {
log?.debug?.(
"MEMORY",
"Skipping request-derived memory extraction: video-bridge transcript observed"
);
}
const memoryText = extractMemoryTextFromResponse(memoryExtractionResponse);
if (memoryText) {
extractFacts(memoryText, memoryOwnerId, pipelineSessionId);
}
}
// #12150 P1b surface 3 (fix round 1): a video-bridge-observed request's
// request- AND response-derived text both carry the full transcript (the
// flattened description on the request side, the model's own reply on
// the response side) — neither may populate durable Memory. See
// runMemoryExtractionGate for the shared gate + extraction wiring, unit
// tested directly in tests/unit/video-bridge-memory-suppression.test.ts.
runMemoryExtractionGate({
memoryOwnerId,
memorySettings,
videoBridgeObserved,
pipelineSessionId,
requestBody: body as Record<string, unknown>,
responseBody: memoryExtractionResponse as Record<string, unknown> | null,
extractFacts,
log,
});
const customSkillExecutionEnabled =
Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true;
@@ -5806,40 +5788,20 @@ export async function handleChatCore({
});
// === /Quota Share POST-hook streaming ===
if (
memoryOwnerId &&
memorySettings?.enabled &&
memorySettings.maxTokens > 0 &&
streamStatus === 200
) {
// #12150 P1b surface 3: see the matching non-streaming gate above —
// suppresses only the request-derived extraction for an observed
// request; the streamed-response extraction just below is unaffected.
if (
shouldExtractMemory({
enabled: memorySettings.enabled,
maxTokens: memorySettings.maxTokens,
memoryOwnerId,
videoBridgeObserved,
})
) {
const requestMemoryText = extractMemoryTextFromRequestBody(body as Record<string, unknown>);
if (requestMemoryText) {
extractFacts(requestMemoryText, memoryOwnerId, pipelineSessionId);
}
} else if (videoBridgeObserved) {
log?.debug?.(
"MEMORY",
"Skipping request-derived memory extraction: video-bridge transcript observed"
);
}
const streamedMemoryText = extractMemoryTextFromResponse(
(streamResponseBody ?? null) as Record<string, unknown> | null
);
if (streamedMemoryText) {
extractFacts(streamedMemoryText, memoryOwnerId, pipelineSessionId);
}
if (streamStatus === 200) {
// #12150 P1b surface 3 (fix round 1): see the matching non-streaming
// gate above — an observed request populates NO durable memory from
// either the request-derived text or this streamed response.
runMemoryExtractionGate({
memoryOwnerId,
memorySettings,
videoBridgeObserved,
pipelineSessionId,
requestBody: body as Record<string, unknown>,
responseBody: (streamResponseBody ?? null) as Record<string, unknown> | null,
extractFacts,
log,
});
}
// Semantic cache: store assembled streaming response for future cache hits

View File

@@ -35,6 +35,21 @@ import { attachLogMeta } from "./cacheUsageMeta.ts";
* original objects. Returns `body` unchanged (same reference, no allocation)
* when there is nothing to redact, so the common non-video path is
* byte-identical to before this function existed.
*
* #12150 fix round 1 (adversarial review, CRITICAL): matches by CONTENT
* (`entry.fullText === part.text`), never by `entry.messageIndex`/
* `entry.partIndex`. Those positions are computed by the guardrail's preCall,
* but request-mutation stages that run AFTER it and BEFORE this log write —
* `injectSystemPrompt` (prepends a message when no system/developer message
* exists), context-relay handoff injection, reasoning-rule body rewrites —
* can prepend or splice the message array, silently invalidating any
* positional index. A stale index either misses the real part (the
* transcript is logged unredacted) or, worse, lands on and overwrites an
* unrelated legitimate message. Scanning every part in the named container
* for an exact text match finds the video part wherever it ended up and
* never touches a part whose text differs — see
* `tests/unit/video-bridge-log-redaction.test.ts`'s "Scenario A" test for the
* reproduction this fixes.
*/
export function applyVideoBridgeLogRedaction(
body: unknown,
@@ -44,45 +59,64 @@ export function applyVideoBridgeLogRedaction(
if (!body || typeof body !== "object") return body;
const source = body as Record<string, unknown>;
const clone: Record<string, unknown> = { ...source };
let rootClone: Record<string, unknown> | null = null;
let redacted = false;
const clonedContainers = new Map<string, unknown[]>();
const clonedMessages = new Map<string, Record<string, unknown>>();
for (const entry of redaction) {
const { container, messageIndex, partIndex, redactedText } = entry;
const { container, fullText, redactedText } = entry;
if (typeof fullText !== "string" || fullText.length === 0) continue;
const originalContainer = source[container];
if (!Array.isArray(originalContainer)) continue;
// Mirrors the exact `type` replaceVideoParts() writes for this container
// (videoBridgeHelpers.ts) — a stronger anchor than a loose "text-like"
// check, at zero extra cost.
const expectedPartType = container === "input" ? "input_text" : "text";
let containerClone = clonedContainers.get(container);
if (!containerClone) {
containerClone = [...originalContainer];
clonedContainers.set(container, containerClone);
clone[container] = containerClone;
for (let messageIndex = 0; messageIndex < originalContainer.length; messageIndex++) {
const originalMessage = originalContainer[messageIndex];
if (!originalMessage || typeof originalMessage !== "object") continue;
const originalContent = (originalMessage as Record<string, unknown>).content;
if (!Array.isArray(originalContent)) continue;
for (let partIndex = 0; partIndex < originalContent.length; partIndex++) {
const originalPart = originalContent[partIndex];
if (!originalPart || typeof originalPart !== "object") continue;
const partRecord = originalPart as Record<string, unknown>;
if (partRecord.type !== expectedPartType) continue;
if (partRecord.text !== fullText) continue;
// Content-address match — clone the path down to this part lazily
// (root -> container array -> this message -> its content array),
// leaving every other sibling on the original references.
if (!rootClone) rootClone = { ...source };
let containerClone = clonedContainers.get(container);
if (!containerClone) {
containerClone = [...originalContainer];
clonedContainers.set(container, containerClone);
rootClone[container] = containerClone;
}
const messageKey = `${container}:${messageIndex}`;
let messageClone = clonedMessages.get(messageKey);
if (!messageClone) {
messageClone = {
...(originalMessage as Record<string, unknown>),
content: [...originalContent],
};
clonedMessages.set(messageKey, messageClone);
containerClone[messageIndex] = messageClone;
}
const contentClone = messageClone.content as unknown[];
contentClone[partIndex] = { ...partRecord, text: redactedText };
redacted = true;
}
}
const originalMessage = originalContainer[messageIndex];
if (!originalMessage || typeof originalMessage !== "object") continue;
const originalContent = (originalMessage as Record<string, unknown>).content;
if (!Array.isArray(originalContent)) continue;
const messageKey = `${container}:${messageIndex}`;
let messageClone = clonedMessages.get(messageKey);
if (!messageClone) {
messageClone = {
...(originalMessage as Record<string, unknown>),
content: [...originalContent],
};
clonedMessages.set(messageKey, messageClone);
containerClone[messageIndex] = messageClone;
}
const contentClone = messageClone.content as unknown[];
const originalPart = originalContent[partIndex];
if (!originalPart || typeof originalPart !== "object") continue;
contentClone[partIndex] = { ...(originalPart as Record<string, unknown>), text: redactedText };
}
return clone;
return redacted && rootClone ? rootClone : body;
}
/**

View File

@@ -131,17 +131,18 @@ export function resolveMemoryOwnerId(apiKeyInfo: Record<string, unknown> | null)
}
/**
* Pure decision for whether the request-derived text should be extracted into
* durable Memory (#12150 P1b, surface 3). Extracted from chatCore.ts's inline
* Pure decision for whether durable Memory should be extracted from this
* request at all (#12150 P1b, surface 3). Wraps chatCore.ts's original inline
* `memoryOwnerId && memorySettings?.enabled && memorySettings.maxTokens > 0`
* check (unchanged) plus one new condition: a video-bridge-observed request's
* request-derived text is a flattened transcript description, not
* user-authored conversation, so it must never be persisted as a "memory
* fact". `videoBridgeObserved` is optional and defaults to falsy, so every
* existing non-video caller (which never passes it) keeps today's exact
* behavior. Governs ONLY the request-derived extractFacts call — the
* response-derived one (the model's own reply) is out of scope and
* unaffected by this function.
* check (unchanged) plus one new condition: a video-bridge-observed request
* must never populate durable Memory — not from its request-derived text (a
* flattened transcript description, not user-authored conversation) and, per
* fix round 1 (adversarial review), not from its response-derived text
* either, since the model's own reply also received the full transcript and
* can echo it back. `videoBridgeObserved` is optional and defaults to falsy,
* so every existing non-video caller (which never passes it) keeps today's
* exact behavior. See `runMemoryExtractionGate` below for the call-site
* wiring that applies this decision to both extraction sources at once.
*/
export function shouldExtractMemory(input: {
enabled: boolean | null | undefined;
@@ -156,3 +157,69 @@ export function shouldExtractMemory(input: {
if (videoBridgeObserved) return false;
return true;
}
/**
* Runs the full request+response Memory-extraction gate shared by
* chatCore.ts's non-streaming and streaming completion paths (#12150 P1b fix
* round 1). Extracted so this wiring — not just the pure `shouldExtractMemory`
* decision — is unit-testable against the REAL
* `extractMemoryTextFromRequestBody`/`extractMemoryTextFromResponse`, rather
* than a test file hand-mirroring the call sites' shape.
*
* `extractFacts` is injected (not imported directly) purely for testability —
* production callers pass the real `@/lib/memory/extraction` one. When
* `shouldExtractMemory` says no (memory disabled/unconfigured, OR a
* video-bridge-observed request), this is a complete no-op: neither the
* request- nor the response-derived text is extracted, so an observed
* request populates NO durable memory from either source.
*/
export function runMemoryExtractionGate(input: {
memoryOwnerId: string | null | undefined;
memorySettings: { enabled?: boolean | null; maxTokens?: number | null } | null | undefined;
videoBridgeObserved: boolean;
pipelineSessionId: string;
requestBody: Record<string, unknown> | null | undefined;
responseBody: Record<string, unknown> | null | undefined;
extractFacts: (text: string, memoryOwnerId: string, sessionId: string) => void;
log?: { debug?: (tag: string, message: string) => void } | null;
}): void {
const {
memoryOwnerId,
memorySettings,
videoBridgeObserved,
pipelineSessionId,
requestBody,
responseBody,
extractFacts,
log,
} = input;
if (!memoryOwnerId) return;
const allowed = shouldExtractMemory({
enabled: memorySettings?.enabled,
maxTokens: memorySettings?.maxTokens,
memoryOwnerId,
videoBridgeObserved,
});
if (!allowed) {
// Only worth a log line for the video-bridge case — memory being
// disabled/unconfigured entirely is the normal, silent, non-video path.
if (videoBridgeObserved && memorySettings?.enabled) {
log?.debug?.(
"MEMORY",
"Skipping request+response memory extraction: video-bridge transcript observed"
);
}
return;
}
const requestMemoryText = extractMemoryTextFromRequestBody(requestBody ?? null);
if (requestMemoryText) {
extractFacts(requestMemoryText, memoryOwnerId, pipelineSessionId);
}
const responseMemoryText = extractMemoryTextFromResponse(responseBody ?? null);
if (responseMemoryText) {
extractFacts(responseMemoryText, memoryOwnerId, pipelineSessionId);
}
}

View File

@@ -38,11 +38,31 @@ export type { VideoAnalysisContext } from "./videoBridgePipeline";
* `redactedText` is the structured-redaction shadow (see
* `DescribedVideo.descriptionRedacted`) for that same part — never derived
* from the model-bound text, so it cannot be bypassed by cue content.
*
* #12150 fix round 1 (adversarial review): the downstream log-redaction
* consumer (`applyVideoBridgeLogRedaction`, chatCore/attemptLogging.ts)
* matches by CONTENT (`fullText`), not by `messageIndex`/`partIndex`.
* Between this guardrail's preCall and the eventual log write, other
* request-mutation stages (system-prompt injection when no existing system
* message is found, context-relay handoff injection, reasoning-rule body
* rewrites) can prepend/splice messages, silently invalidating any
* positional index. `messageIndex`/`partIndex` are kept as advisory/
* debugging metadata only — never used for matching.
*/
export interface VideoBridgeLogRedactionEntry {
container: "messages" | "input";
/** Advisory only (see interface doc) — may be stale by the time the log is written. */
messageIndex: number;
/** Advisory only (see interface doc) — may be stale by the time the log is written. */
partIndex: number;
/**
* The exact, unredacted text placed into the replaced part
* (`descriptions[i]`, identical to what `replaceVideoParts` writes to
* `content[partIndex].text`). The downstream consumer matches parts by
* `part.text === fullText`, so it finds the video part wherever a later
* stage moved it, and never touches a part whose text differs.
*/
fullText: string;
redactedText: string;
}
@@ -210,6 +230,11 @@ export class VideoBridgeGuardrail extends BaseGuardrail {
container: part.container,
messageIndex: part.messageIndex,
partIndex: part.partIndex,
// The exact text `replaceVideoParts` is about to write into
// content[partIndex].text (same `result.description` value pushed
// to `descriptions` just above) — the content-address key the
// downstream consumer matches on. See the interface doc.
fullText: result.description,
redactedText: result.descriptionRedacted,
});
}

View File

@@ -176,11 +176,17 @@ test("reports only validated transcript provenance in guardrail metadata and car
// must mark itself observed and hand a redaction map keyed to the exact
// replaced part, with the placeholder in and the secret text out.
assert.equal(result.meta?.videoBridgeObserved, true);
// #12150 fix round 1: the downstream consumer matches by content
// (`fullText`), not by messageIndex/partIndex (see the interface doc on
// VideoBridgeLogRedactionEntry) — assert fullText is present and is the
// exact unredacted text that was placed into the part, alongside the
// still-positional (now advisory) fields and the placeholder text.
assert.deepEqual(result.meta?.videoBridgeLogRedaction, [
{
container: "messages",
messageIndex: 0,
partIndex: 0,
fullText: "[Video description: caption; transcript[source=client] spoken words]",
redactedText:
"[Video description: caption; transcript[source=client] [redacted-video-transcript]]",
},
@@ -528,8 +534,17 @@ test("real Video Bridge cache hit preserves the redacted transcript shadow acros
for (const result of [first, second]) {
assert.equal(result.meta?.videoBridgeObserved, true);
const redaction = result.meta?.videoBridgeLogRedaction as
Array<{ redactedText: string }> | undefined;
Array<{ fullText: string; redactedText: string }> | undefined;
assert.equal(redaction?.length, 1);
// #12150 fix round 1: fullText must be the exact unredacted text that
// landed in the replaced part — the content-address key the downstream
// consumer matches on — verified against the guardrail's own
// modifiedPayload rather than a hardcoded string (the rendered text
// here comes from the real describeVideoPart pipeline, not a fixture).
const modifiedPart = (result.modifiedPayload as ReturnType<typeof buildBody>).messages[0]
.content[0] as { text: string };
assert.equal(redaction?.[0]?.fullText, modifiedPart.text);
assert.doesNotMatch(redaction?.[0]?.fullText ?? "", /\[redacted-video-transcript\]/);
assert.match(redaction?.[0]?.redactedText ?? "", /\[redacted-video-transcript\]/);
assert.doesNotMatch(redaction?.[0]?.redactedText ?? "", /cached secret cue/);
}

View File

@@ -11,6 +11,15 @@
// and the caller's own `body` object is never mutated in the process (the
// model already received the untouched original earlier in the request
// lifecycle; this call must not reach back and change it).
//
// #12150 fix round 1 (adversarial review, CRITICAL): also proves the
// content-address fix for the positional-drift bug — real request-mutation
// stages (injectSystemPrompt's "no existing system message" branch,
// context-relay handoff injection, reasoning-rule body rewrites) can
// prepend/splice messages between the guardrail's preCall and this log
// write, making a stale (messageIndex, partIndex) point at the wrong message
// or an out-of-bounds slot. applyVideoBridgeLogRedaction must locate the
// video part by matching `fullText` against part text, not by position.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
@@ -25,6 +34,7 @@ const { getCallLogById } = await import("../../src/lib/usage/callLogs.ts");
const { persistAttemptLogs } = await import("../../open-sse/handlers/chatCore/attemptLogging.ts");
const SECRET = "secret words";
const FULL_TEXT = `[Video 1]: A person talks. transcript[00:00-00:02]: ${SECRET}`;
const PLACEHOLDER_TEXT =
"[Video 1]: A person talks. transcript[00:00-00:02]: [redacted-video-transcript]";
@@ -39,7 +49,7 @@ function videoBody() {
{ type: "text", text: "look at this video" },
{
type: "text",
text: `[Video 1]: A person talks. transcript[00:00-00:02]: ${SECRET}`,
text: FULL_TEXT,
},
],
},
@@ -105,7 +115,13 @@ test("persisted requestBody carries the placeholder and never the raw transcript
baseCtx({
pendingRequestId: id,
videoBridgeLogRedaction: [
{ container: "messages", messageIndex: 1, partIndex: 1, redactedText: PLACEHOLDER_TEXT },
{
container: "messages",
messageIndex: 1,
partIndex: 1,
fullText: FULL_TEXT,
redactedText: PLACEHOLDER_TEXT,
},
],
})
);
@@ -146,7 +162,13 @@ test("the caller's body object is never mutated by the redaction", async () => {
pendingRequestId: id,
body,
videoBridgeLogRedaction: [
{ container: "messages", messageIndex: 1, partIndex: 1, redactedText: PLACEHOLDER_TEXT },
{
container: "messages",
messageIndex: 1,
partIndex: 1,
fullText: FULL_TEXT,
redactedText: PLACEHOLDER_TEXT,
},
],
})
);
@@ -157,3 +179,79 @@ test("the caller's body object is never mutated by the redaction", async () => {
"ctx.body must be byte-identical after persistAttemptLogs runs"
);
});
test("Scenario A (adversarial review): a message prepended AFTER the guardrail built the redaction map does not leak the transcript, and the prepended message is untouched", async () => {
const id = "video-scenario-a-1";
// The body exactly as the video-bridge guardrail saw it when it computed
// the redaction map: a single user message, no system message yet — this
// is precisely the shape that makes injectSystemPrompt's "no existing
// system message" branch (open-sse/services/systemPrompt.ts) fire.
const userMessageWithVideo = {
role: "user",
content: [
{ type: "text", text: "look at this video" },
{ type: "text", text: FULL_TEXT },
],
};
// The map the guardrail built, correct AT THAT MOMENT: the video part was
// messages[0].content[1].
const redactionMap = [
{
container: "messages" as const,
messageIndex: 0,
partIndex: 1,
fullText: FULL_TEXT,
redactedText: PLACEHOLDER_TEXT,
},
];
// Real production shape: AFTER the guardrail ran, injectSystemPrompt found
// no existing system/developer message and unshifted a brand-new one —
// `result.messages = [{ role: "system", content: combined }, ...result.messages]`
// — shifting the video message from index 0 to index 1. The map above is
// now stale by the time persistAttemptLogs serializes the log: a purely
// positional lookup at (messageIndex: 0, partIndex: 1) would land on this
// new system message instead.
const bodyAfterSystemPromptInjection = {
messages: [{ role: "system", content: "You are a helpful assistant." }, userMessageWithVideo],
};
persistAttemptLogs(
{ status: 200 },
baseCtx({
pendingRequestId: id,
body: bodyAfterSystemPromptInjection,
videoBridgeLogRedaction: redactionMap,
})
);
const row = await pollForCallLog(id);
assert.ok(row, "call log row should be persisted");
const persisted = row.requestBody as {
messages: Array<{ role: string; content: unknown }>;
};
// (A) the leak: the video part, now shifted to index 1, must still be
// found and redacted by content, not silently skipped.
const shiftedContent = persisted.messages[1].content as Array<{ text: string }>;
assert.equal(
shiftedContent[1].text,
PLACEHOLDER_TEXT,
"the shifted video part must still be redacted despite the stale positional map"
);
assert.ok(
!shiftedContent[1].text.includes(SECRET),
"the shifted video part must not leak the raw transcript"
);
assert.equal(JSON.stringify(persisted).includes(SECRET), false);
// (B) the corruption: the newly prepended system message — which a
// positional lookup at the stale index would have landed on — must be
// completely untouched.
assert.equal(
persisted.messages[0].content,
"You are a helpful assistant.",
"the prepended system message must be untouched"
);
});

View File

@@ -1,25 +1,25 @@
// tests/unit/video-bridge-memory-suppression.test.ts
// P1b of #12150 (Video Bridge transcript retention) — surface 3 (Memory sink).
//
// chatCore.ts gates its two extractFacts(requestMemoryText, ...) call sites
// (non-streaming ~L5134, streaming ~L5758) on an inline
// `memoryOwnerId && memorySettings?.enabled && memorySettings.maxTokens > 0`
// check. This adds a fourth condition — the request must not be a
// video-bridge-observed one — extracted to a pure, exported decision function
// so it is unit-testable without invoking the handleChatCore monolith (same
// god-file-decomposition convention as chatCore/attemptLogging.ts,
// chatCore/nonStreamingUsageStats.ts, etc.).
// chatCore.ts's two Memory-extraction call sites (non-streaming + streaming)
// now each delegate to a single `runMemoryExtractionGate` (extracted so this
// wiring — not just the underlying `shouldExtractMemory` decision — is
// unit-testable against the REAL extractMemoryTextFromRequestBody/
// extractMemoryTextFromResponse, same god-file-decomposition convention as
// chatCore/attemptLogging.ts, chatCore/nonStreamingUsageStats.ts, etc.).
//
// Only the REQUEST-derived extractFacts call is gated (per the design doc,
// "surface 3: gate extractFacts on !videoBridgeObserved for request-derived
// text") — the response-derived extractFacts call (the model's own reply) is
// out of scope and untouched.
// #12150 fix round 1 (adversarial review, Important): a video-bridge-observed
// request must populate NO durable memory from EITHER source — the
// request-derived text (a flattened transcript description) AND the
// response-derived text (the model's own reply, which also received the full
// transcript and can echo it back). Both are gated by the same
// shouldExtractMemory() decision inside runMemoryExtractionGate.
import test from "node:test";
import assert from "node:assert/strict";
import {
shouldExtractMemory,
extractMemoryTextFromRequestBody,
runMemoryExtractionGate,
} from "../../open-sse/handlers/chatCore/memoryExtraction.ts";
// ─── shouldExtractMemory: pure decision table ──────────────────────────────
@@ -95,45 +95,14 @@ test("shouldExtractMemory: still false when memoryOwnerId is null, regardless of
);
});
// ─── Integration stub: wire the real decision + the real request-text ─────
// extractor together against a stubbed extractFacts, mirroring the exact
// shape of the two chatCore.ts call sites (only the DB-writing extractFacts
// is stubbed — everything else is the real exported implementation).
// ─── runMemoryExtractionGate: the REAL chatCore.ts call-site wiring ────────
// No hand-mirrored stub — this imports and calls the exact function both
// chatCore.ts completion paths call. Only `extractFacts` (the DB-writing,
// fire-and-forget side effect) is injected as a spy; extraction of the
// request/response text runs through the real
// extractMemoryTextFromRequestBody/extractMemoryTextFromResponse.
function runRequestMemoryExtractionStub(params: {
memoryOwnerId: string | null;
memorySettings: { enabled: boolean; maxTokens: number };
videoBridgeObserved: boolean;
body: Record<string, unknown>;
pipelineSessionId: string;
extractFactsSpy: (text: string, ownerId: string, sessionId: string) => void;
}): void {
const {
memoryOwnerId,
memorySettings,
videoBridgeObserved,
body,
pipelineSessionId,
extractFactsSpy,
} = params;
if (memoryOwnerId && memorySettings?.enabled && memorySettings.maxTokens > 0) {
if (
shouldExtractMemory({
enabled: memorySettings.enabled,
maxTokens: memorySettings.maxTokens,
memoryOwnerId,
videoBridgeObserved,
})
) {
const requestMemoryText = extractMemoryTextFromRequestBody(body);
if (requestMemoryText) {
extractFactsSpy(requestMemoryText, memoryOwnerId, pipelineSessionId);
}
}
}
}
const flattenedVideoBody = {
const flattenedVideoRequestBody = {
messages: [
{
role: "user",
@@ -142,29 +111,111 @@ const flattenedVideoBody = {
],
};
test("integration stub: zero extractFacts calls for a video-bridge-observed request", () => {
const modelReplyEchoingTranscript = {
choices: [
{
message: {
content: "Sure — the video shows: secret words",
},
},
],
};
function spy() {
const calls: Array<[string, string, string]> = [];
runRequestMemoryExtractionStub({
return {
calls,
fn: (text: string, ownerId: string, sessionId: string) => {
calls.push([text, ownerId, sessionId]);
},
};
}
test("runMemoryExtractionGate: zero extractFacts calls (request AND response) for a video-bridge-observed request", () => {
const extractFacts = spy();
runMemoryExtractionGate({
memoryOwnerId: "key-1",
memorySettings: { enabled: true, maxTokens: 2000 },
videoBridgeObserved: true,
body: flattenedVideoBody,
pipelineSessionId: "session-1",
extractFactsSpy: (text, ownerId, sessionId) => calls.push([text, ownerId, sessionId]),
requestBody: flattenedVideoRequestBody,
responseBody: modelReplyEchoingTranscript,
extractFacts: extractFacts.fn,
});
assert.equal(calls.length, 0, "extractFacts must not be called when videoBridgeObserved=true");
assert.equal(
extractFacts.calls.length,
0,
"extractFacts must not be called for either source when videoBridgeObserved=true"
);
});
test("integration stub: extractFacts IS called for the same body when video-bridge was not observed", () => {
const calls: Array<[string, string, string]> = [];
runRequestMemoryExtractionStub({
test("runMemoryExtractionGate: response-derived extraction specifically is skipped when observed (fix round 1 regression)", () => {
const extractFacts = spy();
runMemoryExtractionGate({
memoryOwnerId: "key-1",
memorySettings: { enabled: true, maxTokens: 2000 },
videoBridgeObserved: true,
pipelineSessionId: "session-1",
// No request-derived text at all (e.g. request body already consumed/
// reshaped) — isolates the assertion to the response-derived source,
// which is the one fix round 1 found still leaking into Memory.
requestBody: { messages: [] },
responseBody: modelReplyEchoingTranscript,
extractFacts: extractFacts.fn,
});
assert.equal(
extractFacts.calls.length,
0,
"the model's reply (which also received the full transcript) must not be extracted when observed"
);
});
test("runMemoryExtractionGate: extracts BOTH request and response text when video-bridge was not observed", () => {
const extractFacts = spy();
runMemoryExtractionGate({
memoryOwnerId: "key-1",
memorySettings: { enabled: true, maxTokens: 2000 },
videoBridgeObserved: false,
body: flattenedVideoBody,
pipelineSessionId: "session-1",
extractFactsSpy: (text, ownerId, sessionId) => calls.push([text, ownerId, sessionId]),
requestBody: flattenedVideoRequestBody,
responseBody: { choices: [{ message: { content: "a normal reply" } }] },
extractFacts: extractFacts.fn,
});
assert.equal(calls.length, 1, "extractFacts must run on the ordinary (non-video) path");
assert.match(calls[0][0], /secret words/);
assert.equal(
extractFacts.calls.length,
2,
"both request- and response-derived extraction run on the ordinary (non-video) path"
);
assert.match(extractFacts.calls[0][0], /secret words/);
assert.match(extractFacts.calls[1][0], /a normal reply/);
assert.equal(extractFacts.calls[0][1], "key-1");
assert.equal(extractFacts.calls[0][2], "session-1");
});
test("runMemoryExtractionGate: no-ops when memory is disabled, regardless of videoBridgeObserved", () => {
const extractFacts = spy();
runMemoryExtractionGate({
memoryOwnerId: "key-1",
memorySettings: { enabled: false, maxTokens: 2000 },
videoBridgeObserved: false,
pipelineSessionId: "session-1",
requestBody: flattenedVideoRequestBody,
responseBody: { choices: [{ message: { content: "a normal reply" } }] },
extractFacts: extractFacts.fn,
});
assert.equal(extractFacts.calls.length, 0);
});
test("runMemoryExtractionGate: no-ops when memoryOwnerId is missing, regardless of videoBridgeObserved", () => {
const extractFacts = spy();
runMemoryExtractionGate({
memoryOwnerId: null,
memorySettings: { enabled: true, maxTokens: 2000 },
videoBridgeObserved: false,
pipelineSessionId: "session-1",
requestBody: flattenedVideoRequestBody,
responseBody: { choices: [{ message: { content: "a normal reply" } }] },
extractFacts: extractFacts.fn,
});
assert.equal(extractFacts.calls.length, 0);
});