LibreChat/e2e/setup/model-replay.js
Danny Avila 1489623fa3
🎬 test: Record-Once/Replay-Forever Model Fixtures for Mock E2E (#15210)
* 🎬 test: Record-Once/Replay-Forever Model Fixtures for Mock E2E

The mock e2e lane's only credential-free model is a hand-authored script:
`fake-model.js` decides responses from ~60 `E2E_*` prompt markers. That
covers scripted shapes well, but no scenario replays a *real* recorded
provider conversation through the assembled chain, so real streaming
shapes — provider chunk cadence, reasoning deltas, usage metadata — are
only ever approximated.

This adds a record-once/replay-forever tier alongside the marker routing.

Record (`E2E_MODEL_FIXTURES=record`, needs a provider key): the run hook
appends a LangChain callback handler to every agent context's
`clientOptions.callbacks` instead of overriding the model, so the REAL
provider streams while each invocation's `AIMessageChunk`s serialize to
`e2e/fixtures/model-replay/<name>.jsonl` — text deltas, tool_call_chunks,
reasoning kwargs, and genuine usage metadata. Only the latest human text
is recorded for binding; system prompts and tool schemas never enter the
fixture.

Replay (default, keyless): `fake-model.js` consults `tryBindReplay` ahead
of marker routing, binding a conversation whose prompt matches the next
unconsumed invocation. The replaying model is not hand-assigned — it is
registered as SDK provider `librechat-e2e-replay` via `registerProvider`
and constructed through the SDK's own `initializeModel`, so registry
lookup, constructor clientOptions, and real `bindTools` all run the way a
live provider's would. Recorded chunks therefore stream through the same
createRun → graph → SSE → persistence chain.

Consumption is enforced rather than assumed: every invocation re-checks
its prompt against the recording, an invocation past the end of the
script throws, and a per-fixture ledger lets the spec assert at teardown
that every recorded invocation and chunk was drained. Streaming
incrementality is asserted from that ledger, not by sampling transient
DOM, which is a race by construction.

The credential-free profile is unchanged when not recording: the record
provider and its selector entry are template markers that stay comments,
and no existing spec's routing is touched (a fixture only binds on an
exact prompt match; everything else falls through).

Verified: record vs the real DeepSeek API 1 passed (15.2s); keyless
replay 1 passed twice (11.4s, 12.7s) with the ledger fully drained (2/2
invocations, 10/10 chunks, no overruns or mismatches); app-load,
completion, and chat 10 passed unchanged.

* fix(e2e): rebind a replay fixture from the top for a new conversation

The replay cursor is process-global while the web server outlives a
Playwright retry, so a fully consumed fixture left the retry unable to
bind its first prompt: it fell through to marker routing and failed
deterministically, burning every configured CI retry. A partially
consumed attempt failed the same way.

Binding now restarts the fixture when the incoming prompt matches its
first recorded invocation, resetting the ledger with the cursor so the
new attempt is judged on its own consumption instead of accumulating the
previous one's counts. Continuing an in-progress binding still outranks
restarting, so a fixture whose opening prompt repeats later in the script
advances rather than rewinding.

The over-consumption guard is untouched — it fires inside the stream when
the cursor passes the end, not at bind time.

* fix(e2e): close three replay-lane gaps found in review

Restart the recorder on a retry. Its state is process-global like the
replay cursor, so a failed attempt that had already recorded invocations
left the counter advanced: the retry appended 2/3 after 0/1, or kept the
previous attempt's `error` line, and the fixture was unusable for replay.
Recording now truncates and restarts when the opening prompt reappears,
mirroring the replay side's rule and its caveat.

Retain a consumed binding for the conversation that drove it. An extra
user turn past the final recorded invocation found no next invocation and
fell through to ordinary fake-model routing, so it was answered with a
mock reply: the over-consumption guard never ran and the already-drained
ledger still passed. Such a conversation is now recognized by its human
turns opening with the fixture's recorded prompts, and stays bound so the
stream raises the overrun. Continuing an in-progress binding still
outranks restarting, which outranks retaining a consumed one, so a
retry's fresh conversation rewinds rather than being read as an extra
turn.

Validate the fixture the recording actually wrote. Record mode honors
`E2E_MODEL_FIXTURE_NAME`, but the spec always inspected the committed
`deepseek-two-turn`; another name wrote elsewhere while the assertions
read the pre-existing file, and because the prompts are fixed the stale
answers could match and green a run that verified nothing it produced.

* fix(e2e): make replay binding correct for tool and subagent fixtures

Round two's retry and consumed-binding fixes both assumed one model
invocation per user turn. A turn that calls a tool breaks that: the model
is invoked again after the tool result under the same latest human
message.

Identify a retry by the conversation boundary, not the prompt. The
recorder ran per invocation and truncated whenever the opening prompt
reappeared, so a tool round trip looked like a retry and discarded the
recorded tool-call invocation. Restart detection now sits in
`installRecorder`, which runs once per `createRun`: a turn whose history
holds no prior human message begins a conversation.

Compare consumed bindings against user turns, not invocations. Several
recorded invocations can share one prompt, so a one-to-one comparison
could not recognize the originating conversation — invocations `[A, A, B]`
against history `[A, B, C]` failed on both length and elements, and the
extra turn fell through to the fake model with the drained ledger still
passing. Fixtures now carry their collapsed turn sequence.

Override the subagent model too. `graph.overrideModel` is not inherited
by child executors, so a fixture recording a subagent call — record mode
captures child invocations already — would leave the child on its
configured provider: an underrun, and a real provider request in a lane
that must stay keyless.

Reject ambiguous prompt matches. Binding order followed filesystem
enumeration, so a second fixture sharing a prompt could silently redirect
a scenario to the wrong chunks and ledger; the spec's choice never
reaches the server-side loop, so ambiguity fails instead of picking a
winner. Fixture identity is the file name for the same reason — a
recorded `meta.name` is descriptive, and trusting it let a copied fixture
collapse onto another's registry key and ledger.

Prove the recording is fresh. The spec removes the selected fixture
before driving, so a run whose hook never installed the recorder fails
instead of greening against a stale artifact whose answers still match
these deterministic prompts.

* fix(e2e): rewind a replay fixture at the conversation boundary

Consecutive invocations can share a prompt — a tool call produces exactly
that — so an attempt stopping mid-turn left the cursor on an invocation
whose text still equalled the opening prompt. Matching the cursor first
meant a retry's fresh conversation resumed after the tool call instead of
rewinding, consuming the post-tool invocation and silently replaying a
different script than was recorded.

A conversation boundary now outranks an in-progress cursor: a fresh
conversation whose prompt opens the fixture rewinds even when the cursor
would have matched. Continuing still outranks restarting within a
conversation, so a turn that calls a tool advances to its post-tool
invocation rather than rewinding on its own repeated prompt.

* fix(e2e): refuse cross-conversation binding and prove content streaming

A fresh conversation could steal a partly consumed fixture's later turn.
Only a conversation opening with the fixture's first prompt was treated
as a boundary, so after `[A, B]` had consumed `A`, an unrelated new
conversation whose first message was `B` matched the cursor, received the
recorded second-turn response, and advanced the shared cursor without
ever having driven `A`. A conversation start may now only rewind a partly
consumed fixture, never continue it; continuation within a conversation
is unaffected.

The incrementality assertion counted empty frames. Providers emit empty
initialization and usage-metadata chunks around the content deltas, so a
total chunk count above one was satisfied by a single delta: the previous
fixture's closing turn had four chunks and one content-bearing delta
carrying the whole answer, and both modes stayed green without proving
incremental assistant-content streaming at all. Fixtures now track
content-bearing chunks separately, the closing prompt asks for prose
rather than a number, and both modes require several content deltas on
that turn. Re-recorded: the closing turn now carries 28 content deltas.

* fix(e2e): scope record mode to the fixture spec

`E2E_MODEL_FIXTURES=record` replaces the fake-model hook globally, so an
unfiltered entry point such as `npm run e2e:mock` sent every spec under
specs/mock to the paid real-provider endpoint, while each fresh
conversation truncated and rewrote the one selected fixture — leaving an
artifact from whichever scenario happened to run last.

Record mode now matches only the fixture spec: an unfiltered recording
run lists one test instead of 203. Replay mode is untouched and still
collects the full suite.

* 🪪 fix: Bind Replay Fixtures by Conversation, Not Prompt Text

Prompt text was standing in for conversation identity, and three review
rounds found the same class of defect underneath it: a tool call repeats
a prompt across invocations, a retry repeats it across attempts, and a
resumed run has neither prompt nor history because `createRun` is rebuilt
with no messages while state comes from the checkpoint. Each fix in that
space created the next gap.

Thread the identity instead. `createRun` accepts a `conversationId` and
passes it to the run hook, which the agents controller supplies at both
call sites — the same value it already uses as the checkpointer's
`thread_id`. The field is optional and the hook is env-gated, so nothing
changes when the harness is not in use.

Binding then collapses to ownership. A fixture is owned by the
conversation that claimed it, and its cursor is authoritative wherever it
stands: an extra turn reaches the over-consumption guard rather than
falling through to the scripted fake model, and a resumed run keeps
replaying with no prompt to match. A different conversation may claim the
fixture only by opening it, which rewinds — what a Playwright retry looks
like. Everything else is refused, so an unrelated conversation can no
longer continue someone else's partly consumed script by repeating a
later prompt. The prompt is still re-checked on every real turn; only a
resume, which structurally carries no human message, is exempt. The
previous text-and-history rules remain as a fallback when identity is
absent.

The recorder keys the same way: a new attempt is a new conversation, so a
resume no longer truncates the fixture mid-turn and discards its
tool-call invocation.

Record summarization too. The summary provider runs on its own model with
its own callback list, so a scenario crossing the context-pruning
threshold recorded the agent's invocations but not the summariser's,
leaving a fixture that could not reproduce the pruned context.

* 🧾 fix: Harden Record Mode and Make the Rendered-Text Assertion Honest

CI caught what local runs had not: the committed fixture was never
replayed locally, because the record run overwrote it after the replay
check rather than before. Re-recording and replaying in that order is
what surfaced the rest of this.

The DOM assertion compared raw recorded text against rendered markdown.
The previous answer opened with `52.`, which Markdown renders as an
ordered-list marker, so those characters never appear in the DOM and the
match failed on all three CI attempts while replay itself was correct.
The closing prompt now asks for prose beginning with a word, a leading
enumerator is stripped before matching, and only a prose prefix is
compared.

Derived configs discarded the record-mode restriction. `config.redis.ts`
and `config.mermaid.ts` spread this config and then replace `testMatch`,
so `e2e:mock:redis` in record mode would still send its specs to the paid
provider. A restriction expressed as an overridable value cannot hold, so
record mode now refuses any config but the mock one.

Superseded recording callbacks could write across a reset. A failed
attempt with a provider call still in flight keeps its handler on the old
graph; after the retry reset, that call would allocate an invocation from
the new counter or append an `error` entry with a cleared mapping.
Handlers now carry the recording generation they were installed for and
ignore everything from an older one, and attachment dedupes against the
current generation so a graph carried across a restart is not left with
an inert handler.

* 🚧 fix: Make Summarization an Explicit Boundary, Not a Half-Feature

Recording summarization invocations without replaying them is worse than
ignoring them. Replay routes the agent model and subagents only, so a
recorded summarization entry takes a slot in the fixture sequence that
replay never consumes, and the next primary call reads the summariser's
chunks — a prompt mismatch or, worse, silently wrong content.

The attachment was also aimed at the wrong shape: the SDK reads
`summarizationConfig.parameters`, not `.parameters` nested under
`.config`, so the previous attempt would have attached to nothing in a
real run. Its test passed only because the test built the shape the code
expected rather than the shape the SDK provides.

Rather than ship a half-routed feature, recording now fails the moment
summarization runs, naming the reason. Both shapes are guarded so the
guard cannot miss the way the recorder did. Summarization fixtures need
replay routing for that model before they can be supported.

The derived-config guard added alongside it was itself broken: workers do
not carry `--config`, and the argument lookup fell through to
`process.argv[0]`, so every recording run aborted claiming the node
binary was an unexpected config. The flag is now located explicitly and
absence is treated as "not the process that parsed the CLI".

* 🔒 fix: Close the -c Config Alias and Pin the Recorder's Fixture Name

Playwright documents `-c` as an alias for `--config`, so record mode
launched as `playwright test -c e2e/playwright.config.redis.ts` slipped
past a guard that recognised only the long spelling. Both spellings and
both `=` and space forms are now parsed.

Accepting arbitrary fixture names also worked against the ambiguity
check. This spec drives one fixed prompt pair, so recording under another
name left two fixtures sharing those prompts; replay then refused to bind
either and the keyless lane stopped working — a successful documented
recording run could disable the suite it exists to serve. The spec now
records only the fixture it owns and says so when asked for another.

* 🔧 test: Record a Real Tool-Call Turn and Replay It Through the Tool Node

The fixture format carried `tool_call_chunks` and the binding advanced
through a turn's invocations, but nothing had recorded a real
tool-calling conversation end to end — the path was covered only by
hand-written synthetic fixtures, and it is the first one a new scenario
would exercise.

This records one: the provider calls the `remember_fact` MCP tool, the
tool runs, and the model is invoked a second time with its result. That
is the shape a single prompt cannot express — one user turn spanning
several model invocations, all sharing one prompt — so it is what proves
the turn-vs-invocation distinction the binding rules were built around.

Replay drives the real tool node rather than replaying its output, so the
tool executes again and the assertion checks its live result.

Two fixtures now coexist, which the record path had to grow for: the
config keeps an allowlist so an unknown name is still refused, record
mode collects every replay spec, and each spec records only the fixture
it owns and stands down for the others.

MCP tools reach the model under a server-qualified name
(`remember_fact_mcp_e2e-memory`); that qualification has changed before,
so the assertions match the base name as a prefix rather than pinning the
suffix.

Verified: record 1 passed (15.6s, real API) then replay 1 passed (13.6s)
against that fixture, ledger drained 2/2 invocations and 35/35 chunks;
both replay specs together 2 passed; app-load, completion, chat and
mcp-ephemeral 12 passed.
2026-08-25 18:29:55 -04:00

732 lines
26 KiB
JavaScript

/**
* Record-once/replay-forever model fixtures for the mock e2e harness.
*
* Record mode (`E2E_MODEL_FIXTURES=record` + `E2E_MODEL_FIXTURE_NAME=<name>`):
* `record-model.js` replaces the fake-model run hook; instead of overriding the
* graph's model it appends a LangChain callback handler to every agent
* context's `clientOptions.callbacks`, so the REAL provider model carries the
* recorder. Each model invocation's streamed `ChatGenerationChunk`s are
* serialized to `e2e/fixtures/model-replay/<name>.jsonl` exactly as the
* provider emitted them (text deltas, tool_call_chunks, reasoning
* additional_kwargs, usage_metadata). Only the invocation's latest human text
* is recorded for binding — system prompts and tool schemas never enter the
* fixture.
*
* Replay mode (default, keyless): `fake-model.js` consults `tryBindReplay`
* before its marker routing. A conversation binds to a fixture when its latest
* user text equals the fixture's next unconsumed invocation's recorded user
* text. The replaying model is not hand-assigned: `ReplayChatModel` is
* registered as the SDK provider `librechat-e2e-replay` via the agents
* package's `registerProvider`, and the bound instance is constructed through
* the SDK's own `initializeModel` — registry lookup, constructor
* `clientOptions` (carrying the model-bound callbacks the way
* `withModelCallbacks` does), and real `bindTools` over the run's tools — so
* the recorded chunks stream through the same SDK machinery a live provider
* uses: createRun → registered provider model → graph → SSE → persistence.
* Every invocation re-checks its prompt against the recording, an invocation
* past the end of the script throws (over-consumption fails loud in the
* turn), and a per-fixture consumption ledger under
* `e2e/specs/.test-results/model-replay/` lets specs assert at teardown that
* every recorded invocation and chunk was drained (under-consumption fails the
* spec, not silently).
*
* Constraint carried over from the recording model: one live binding per
* fixture per server process — scenarios replaying the same fixture must not
* run concurrently.
*/
const fs = require('fs');
const path = require('path');
const { FakeChatModel, registerProvider, initializeModel } = require('@librechat/agents');
const { ChatGenerationChunk } = require('@langchain/core/outputs');
const { AIMessageChunk } = require('@langchain/core/messages');
const FIXTURES_DIR = path.resolve(__dirname, '../fixtures/model-replay');
const LEDGER_DIR = path.resolve(__dirname, '../specs/.test-results/model-replay');
const RECORDER_HANDLER_NAME = 'librechat-e2e-model-recorder';
const SUMMARIZATION_GUARD_NAME = 'librechat-e2e-summarization-guard';
const REPLAY_CHUNK_DELAY_MS = Number(process.env.MOCK_LLM_CHUNK_DELAY_MS) || 10;
function extractText(content) {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
return '';
}
const parts = [];
for (const part of content) {
if (typeof part === 'string') {
parts.push(part);
} else if (part && typeof part.text === 'string') {
parts.push(part.text);
}
}
return parts.join('');
}
function messageType(message) {
if (typeof message?.getType === 'function') {
return message.getType();
}
if (typeof message?._getType === 'function') {
return message._getType();
}
return message?.role;
}
/** Every human message's text, oldest first. */
function humanTexts(messages) {
if (!Array.isArray(messages)) {
return [];
}
const texts = [];
for (const message of messages) {
const type = messageType(message);
if (type === 'human' || type === 'user') {
texts.push(extractText(message.content));
}
}
return texts;
}
/** The latest human message's text — the binding and prompt-check key. */
function latestHumanText(messages) {
const texts = humanTexts(messages);
return texts.length > 0 ? texts[texts.length - 1] : '';
}
/**
* The distinct user turns a fixture records, in order. A turn that calls a
* tool spans several model invocations under one prompt, so the invocation
* sequence is not the turn sequence and only this collapsed view can be
* compared against a conversation's human messages.
*/
function fixtureTurnTexts(invocations) {
const turns = [];
for (const invocation of invocations) {
if (turns[turns.length - 1] !== invocation.userText) {
turns.push(invocation.userText);
}
}
return turns;
}
/**
* Whether this conversation is the one that already drove the fixture: its
* human turns open with exactly the fixture's recorded turns, in order. A
* consumed binding has to be retained for such a conversation, or an extra
* user turn would find no next invocation, fall through to ordinary
* fake-model routing, and be answered with a mock reply — leaving the
* over-consumption guard unreached and the drained ledger still passing.
*/
function conversationDroveFixture(messages, fixture) {
const texts = humanTexts(messages);
const turns = fixture.turns;
if (texts.length <= turns.length) {
return false;
}
return turns.every((turn, index) => turn === texts[index]);
}
function jsonClone(value) {
if (value == null) {
return undefined;
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return undefined;
}
}
/** Minimal AIMessageChunk projection that reconstructs the streamed message. */
function serializeChunk(chunk, token) {
const message = chunk?.message;
const serialized = { text: chunk?.text ?? token ?? '' };
if (message) {
serialized.message = {
content: jsonClone(message.content) ?? '',
additional_kwargs: jsonClone(message.additional_kwargs),
response_metadata: jsonClone(message.response_metadata),
tool_call_chunks: jsonClone(message.tool_call_chunks),
usage_metadata: jsonClone(message.usage_metadata),
id: typeof message.id === 'string' ? message.id : undefined,
};
}
return serialized;
}
function deserializeChunk(serialized) {
const recorded = serialized.message;
const message = new AIMessageChunk({
content: recorded?.content ?? serialized.text ?? '',
additional_kwargs: recorded?.additional_kwargs ?? {},
response_metadata: recorded?.response_metadata ?? {},
tool_call_chunks: recorded?.tool_call_chunks ?? [],
usage_metadata: recorded?.usage_metadata,
id: recorded?.id,
});
return new ChatGenerationChunk({ text: serialized.text ?? '', message });
}
/* ------------------------------- recording ------------------------------- */
const recordingState = {
initialized: false,
fixturePath: undefined,
invocationCounter: 0,
conversationId: undefined,
/** Bumped on every (re)start so handlers left on a superseded graph can be
* told apart from the current attempt's. */
generation: 0,
runIdToInvocation: new Map(),
};
function appendFixtureLine(entry) {
fs.appendFileSync(recordingState.fixturePath, `${JSON.stringify(entry)}\n`);
}
function initializeRecording(fixtureName) {
fs.mkdirSync(FIXTURES_DIR, { recursive: true });
recordingState.fixturePath = path.join(FIXTURES_DIR, `${fixtureName}.jsonl`);
fs.writeFileSync(recordingState.fixturePath, '');
appendFixtureLine({
type: 'meta',
name: fixtureName,
recordedAt: new Date().toISOString(),
});
recordingState.initialized = true;
recordingState.invocationCounter = 0;
recordingState.conversationId = undefined;
recordingState.generation += 1;
recordingState.runIdToInvocation.clear();
console.log(`[e2e model-replay] recording fixture ${recordingState.fixturePath}`);
}
/**
* The recorder's state is process-global and the web server outlives a
* Playwright retry, so a failed attempt that already recorded invocations
* would otherwise leave the counter advanced: the retry appends 2/3 after
* 0/1 (or keeps a previous attempt's `error` line) and the fixture is
* unusable for replay.
*
* A new attempt is a new conversation. Identity comes from `conversationId`
* rather than from the prompt or the history: a turn that calls a tool
* invokes the model again under the same latest human message, and a resumed
* run after a tool-approval pause rebuilds `createRun` with no messages at
* all because state is rehydrated from the checkpoint. Both would look like
* fresh attempts to any text- or history-based rule, and truncate the fixture
* mid-turn.
*/
function isConversationStart(messages) {
return humanTexts(messages).length <= 1;
}
function startsNewRecording(conversationId, messages) {
if (recordingState.invocationCounter === 0) {
return false;
}
if (conversationId != null && recordingState.conversationId != null) {
return recordingState.conversationId !== conversationId;
}
return isConversationStart(messages);
}
/**
* Handlers are stamped with the recording generation they were installed for.
* A failed attempt can still have a provider call in flight when the retry
* resets the recording, and its graph keeps this handler: without the stamp
* that stale call would allocate an invocation index from the new attempt's
* counter, or append an `error` entry whose mapping was cleared, corrupting
* the freshly reset fixture.
*/
function createRecorderHandler() {
const generation = recordingState.generation;
const superseded = () => generation !== recordingState.generation;
return {
name: RECORDER_HANDLER_NAME,
generation,
/** Callbacks must settle before the model call resolves, or the `end`
* line races the durable-completion barrier the recording spec waits on
* (the same contract ModelBoundChatModelCallback declares). */
awaitHandlers: true,
raiseError: true,
handleChatModelStart(_llm, messageBatches, runId) {
if (superseded()) {
return;
}
const index = recordingState.invocationCounter++;
recordingState.runIdToInvocation.set(runId, index);
appendFixtureLine({
type: 'invocation',
index,
userText: latestHumanText(messageBatches?.[0]),
});
},
handleLLMNewToken(token, _idx, runId, _parentRunId, _tags, fields) {
const invocation = recordingState.runIdToInvocation.get(runId);
if (superseded() || invocation == null) {
return;
}
appendFixtureLine({
type: 'chunk',
invocation,
...serializeChunk(fields?.chunk, token),
});
},
handleLLMEnd(output, runId) {
const invocation = recordingState.runIdToInvocation.get(runId);
if (superseded() || invocation == null) {
return;
}
recordingState.runIdToInvocation.delete(runId);
const generation = output?.generations?.[0]?.[0];
appendFixtureLine({
type: 'end',
invocation,
text: generation?.text ?? extractText(generation?.message?.content),
});
},
handleLLMError(error, runId) {
const invocation = recordingState.runIdToInvocation.get(runId);
recordingState.runIdToInvocation.delete(runId);
if (superseded()) {
return;
}
appendFixtureLine({
type: 'error',
invocation: invocation ?? null,
message: error instanceof Error ? error.message : String(error),
});
},
};
}
/**
* Attach the recorder to every agent context's model client options. The model
* is created per-invocation from `agentContext.clientOptions`, so appending a
* callback here puts the recorder on the real provider stream without
* replacing the model.
*/
function installRecorder({ graph, messages, conversationId }) {
const fixtureName = process.env.E2E_MODEL_FIXTURE_NAME;
if (!fixtureName) {
console.warn('[e2e model-replay] E2E_MODEL_FIXTURE_NAME unset; not recording');
return;
}
if (!recordingState.initialized || startsNewRecording(conversationId, messages)) {
initializeRecording(fixtureName);
}
if (conversationId != null) {
recordingState.conversationId = conversationId;
}
const contexts = graph?.agentContexts;
if (!contexts || typeof contexts.values !== 'function') {
console.warn('[e2e model-replay] graph.agentContexts unavailable; not recording');
return;
}
for (const context of contexts.values()) {
if (!context.clientOptions) {
context.clientOptions = {};
}
attachRecorder(context.clientOptions);
/** Summarization runs on its own model with its own callback list.
* Recording those invocations without replaying them is worse than
* ignoring them: they would take slots in the fixture sequence that
* replay never consumes, so the next primary call would read the
* summariser's chunks. Replay routes only the agent model
* (`graph.overrideModel`) and subagents, so the honest boundary is to
* refuse a recording the lane could not reproduce. */
const summarizationParameters =
context.summarizationConfig?.parameters ?? context.summarizationConfig?.config?.parameters;
if (summarizationParameters) {
attachSummarizationGuard(summarizationParameters);
}
}
}
/**
* Fails a recording the moment the summarization model runs. Its invocations
* would otherwise enter the fixture sequence unreplayable — see
* `installRecorder`. Summarization fixtures need replay routing for that model
* before they can be supported.
*/
function attachSummarizationGuard(options) {
const handler = {
name: SUMMARIZATION_GUARD_NAME,
raiseError: true,
awaitHandlers: true,
handleChatModelStart() {
throw new Error(
'[e2e model-replay] summarization ran during recording, and replay cannot route the ' +
'summarization model — its invocations would desynchronise the fixture. Record a ' +
'scenario that stays under the context-pruning threshold.',
);
},
};
const existing = options.callbacks;
if (Array.isArray(existing)) {
if (!existing.some((entry) => entry?.name === SUMMARIZATION_GUARD_NAME)) {
options.callbacks = [...existing, handler];
}
return;
}
if (existing == null) {
options.callbacks = [handler];
return;
}
if (
typeof existing.addHandler === 'function' &&
!existing.handlers?.some((entry) => entry?.name === SUMMARIZATION_GUARD_NAME)
) {
existing.addHandler(handler);
}
}
/** Append the recorder to a client-options object's callbacks, once. */
function attachRecorder(options) {
/** Dedupe against the CURRENT generation only: a graph carried across a
* recording restart still holds a superseded handler, which is inert, so
* matching on name alone would leave that options object recording
* nothing. */
const isCurrent = (handler) =>
handler?.name === RECORDER_HANDLER_NAME && handler.generation === recordingState.generation;
const existing = options.callbacks;
if (Array.isArray(existing)) {
if (!existing.some(isCurrent)) {
options.callbacks = [
...existing.filter((handler) => handler?.name !== RECORDER_HANDLER_NAME),
createRecorderHandler(),
];
}
return;
}
if (existing == null) {
options.callbacks = [createRecorderHandler()];
return;
}
if (typeof existing.addHandler === 'function') {
if (!existing.handlers?.some(isCurrent)) {
existing.addHandler(createRecorderHandler());
}
}
}
/* -------------------------------- replay --------------------------------- */
/** name -> { meta, invocations: [{ userText, chunks: [], finalText }] } */
let fixtureRegistry;
/** name -> { cursor, chunksConsumed, ledger } */
const replayState = new Map();
function parseFixtureFile(filePath) {
const name = path.basename(filePath, '.jsonl');
const invocations = [];
let meta = { name };
const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
for (const line of lines) {
const entry = JSON.parse(line);
if (entry.type === 'meta') {
meta = entry;
} else if (entry.type === 'invocation') {
invocations[entry.index] = { userText: entry.userText, chunks: [], finalText: '' };
} else if (entry.type === 'chunk') {
invocations[entry.invocation]?.chunks.push(entry);
} else if (entry.type === 'end') {
const invocation = invocations[entry.invocation];
if (invocation) {
invocation.finalText = entry.text ?? '';
}
} else if (entry.type === 'error') {
throw new Error(
`[e2e model-replay] fixture ${name} recorded a provider error (${entry.message}); ` +
're-record it before replaying',
);
}
}
const missing = invocations.findIndex((invocation) => invocation == null);
if (missing !== -1) {
throw new Error(`[e2e model-replay] fixture ${name} is missing invocation ${missing}`);
}
/** The file name is the fixture's identity — it is what `E2E_MODEL_FIXTURE_NAME`
* selects, what the spec names, and what the ledger is written under. A
* recorded `meta.name` is descriptive only: trusting it would let a renamed
* or copied fixture collapse onto another's registry key and ledger. */
return { meta: { ...meta, name }, invocations, turns: fixtureTurnTexts(invocations) };
}
function loadFixtureRegistry() {
if (fixtureRegistry) {
return fixtureRegistry;
}
fixtureRegistry = new Map();
if (!fs.existsSync(FIXTURES_DIR)) {
return fixtureRegistry;
}
for (const file of fs.readdirSync(FIXTURES_DIR)) {
if (file.endsWith('.jsonl')) {
const fixture = parseFixtureFile(path.join(FIXTURES_DIR, file));
fixtureRegistry.set(fixture.meta.name, fixture);
}
}
return fixtureRegistry;
}
function writeLedger(name) {
const state = replayState.get(name);
if (!state) {
return;
}
fs.mkdirSync(LEDGER_DIR, { recursive: true });
fs.writeFileSync(
path.join(LEDGER_DIR, `${name}.json`),
`${JSON.stringify({ fixture: name, ...state.ledger }, null, 2)}\n`,
);
}
function freshReplayState(fixture) {
return {
cursor: 0,
ledger: {
invocationsTotal: fixture.invocations.length,
chunksTotal: fixture.invocations.reduce(
(total, invocation) => total + invocation.chunks.length,
0,
),
invocationsConsumed: 0,
chunksConsumed: 0,
overruns: [],
promptMismatches: [],
},
};
}
function getReplayState(fixture) {
let state = replayState.get(fixture.meta.name);
if (!state) {
state = freshReplayState(fixture);
replayState.set(fixture.meta.name, state);
}
return state;
}
/**
* Start the fixture over for a new conversation. The web server outlives a
* Playwright retry, so without this a consumed cursor would leave the retry
* unable to bind its first prompt — it would fall through to marker routing
* and fail deterministically, burning every configured retry. The ledger
* resets with the cursor so the new attempt is judged on its own consumption
* rather than accumulating the previous one's counts.
*/
function restartReplayState(fixture) {
const state = freshReplayState(fixture);
replayState.set(fixture.meta.name, state);
return state;
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const REPLAY_PROVIDER = 'librechat-e2e-replay';
/**
* Constructed by the SDK's `initializeModel` through the provider registry, so
* `clientOptions` is the full constructor contract: the fixture binding, the
* shared cursor state, and the run's model-bound callbacks.
*/
class ReplayChatModel extends FakeChatModel {
constructor(clientOptions = {}) {
super({ responses: [''], sleep: 0, emitCustomEvent: false });
this.fixture = clientOptions.fixture;
this.state = clientOptions.state;
this.boundToolNames = clientOptions.boundToolNames ?? [];
if (clientOptions.callbacks) {
this.callbacks = clientOptions.callbacks;
}
}
/** Real SDK tool binding: returns a bound copy sharing the replay cursor. */
bindTools(tools) {
return new ReplayChatModel({
fixture: this.fixture,
state: this.state,
callbacks: this.callbacks,
boundToolNames: (tools ?? []).map((tool) => tool?.name ?? tool?.function?.name ?? 'unknown'),
});
}
async *_streamResponseChunks(messages, _options, runManager) {
const { fixture, state } = this;
const invocation = fixture.invocations[state.cursor];
if (!invocation) {
state.ledger.overruns.push({
at: new Date().toISOString(),
userText: latestHumanText(messages),
});
writeLedger(fixture.meta.name);
throw new Error(
`[e2e model-replay] fixture ${fixture.meta.name} over-consumed: model invoked ` +
`after all ${fixture.invocations.length} recorded invocations were drained`,
);
}
/** A resumed run carries no human message — state is rehydrated from the
* checkpoint — so there is no prompt to check against. Ownership already
* established which conversation this is; enforcing the recorded prompt
* here would reject every resume. Every real turn still gets checked. */
const promptText = latestHumanText(messages);
const carriesHumanTurn = humanTexts(messages).length > 0;
if (carriesHumanTurn && promptText !== invocation.userText) {
state.ledger.promptMismatches.push({
invocation: state.cursor,
expected: invocation.userText,
received: promptText,
});
writeLedger(fixture.meta.name);
throw new Error(
`[e2e model-replay] fixture ${fixture.meta.name} invocation ${state.cursor} ` +
`prompt mismatch: recorded ${JSON.stringify(invocation.userText)}, ` +
`received ${JSON.stringify(promptText)}`,
);
}
state.cursor += 1;
for (const chunk of invocation.chunks) {
await sleep(REPLAY_CHUNK_DELAY_MS);
yield deserializeChunk(chunk);
void runManager?.handleLLMNewToken(chunk.text ?? '');
state.ledger.chunksConsumed += 1;
}
state.ledger.invocationsConsumed += 1;
writeLedger(fixture.meta.name);
}
}
let replayProviderRegistered = false;
function ensureReplayProviderRegistered() {
if (replayProviderRegistered) {
return;
}
try {
registerProvider({ provider: REPLAY_PROVIDER, model: ReplayChatModel });
} catch (error) {
/** The SDK registry is globalThis-scoped while this guard is
* module-scoped: a reloaded copy of this module finds the provider
* already registered. The registered class is stateless (fixture and
* cursor ride `clientOptions`), so any copy's registration serves all. */
if (!String(error instanceof Error ? error.message : error).includes('already registered')) {
throw error;
}
}
replayProviderRegistered = true;
}
/**
* Bind a conversation to a recorded fixture when its latest user text matches
* the fixture's next unconsumed invocation. The replay model is built through
* the SDK's registered-provider path (`registerProvider` +
* `initializeModel`), including real `bindTools` over the run's tools.
* Returns true when the graph's model was overridden with the replaying
* model; false lets the fake-model marker routing proceed unchanged.
*/
/**
* Decide how this run relates to a fixture.
*
* `own` — the conversation that claimed the fixture is back. Its cursor is
* authoritative wherever it stands, including past the end, so an extra turn
* reaches the over-consumption guard instead of falling through to the
* scripted fake model, and a resumed run after a tool-approval pause keeps
* replaying even though it arrives with no messages and no prompt text.
*
* `claim` — a different (or first) conversation opening the fixture: rewind
* and take ownership. This is what a Playwright retry looks like.
*
* Anything else is refused, so an unrelated conversation can never continue
* someone else's partly consumed script by happening to repeat a later prompt.
*/
function classifyBinding({ fixture, state, text, messages, conversationId }) {
if (conversationId != null && state.conversationId != null) {
if (state.conversationId === conversationId) {
return 'own';
}
return fixture.turns[0] === text ? 'claim' : 'refuse';
}
/** Identity unavailable (an older `@librechat/api` does not supply it):
* fall back to the text and history rules this lane used before. */
if (state.cursor !== 0 && isConversationStart(messages)) {
return fixture.turns[0] === text ? 'claim' : 'refuse';
}
if (fixture.invocations[state.cursor]?.userText === text) {
return 'own';
}
if (state.cursor !== 0 && fixture.turns[0] === text) {
return 'claim';
}
if (state.cursor >= fixture.invocations.length && conversationDroveFixture(messages, fixture)) {
return 'own';
}
return 'refuse';
}
function tryBindReplay({ graph, agents, text, messages, conversationId, modelCallbacks }) {
const registry = loadFixtureRegistry();
const matches = [];
for (const fixture of registry.values()) {
let state = getReplayState(fixture);
const binding = classifyBinding({ fixture, state, text, messages, conversationId });
if (binding === 'refuse') {
continue;
}
if (binding === 'claim') {
state = restartReplayState(fixture);
}
if (conversationId != null) {
state.conversationId = conversationId;
}
matches.push({ fixture, state });
}
if (matches.length === 0) {
return false;
}
/** Binding order would otherwise follow filesystem enumeration, so a second
* fixture sharing this prompt could silently redirect a scenario to the
* wrong chunks and ledger. The spec's fixture choice never reaches this
* server-side loop, so ambiguity has to fail rather than pick a winner. */
if (matches.length > 1) {
throw new Error(
`[e2e model-replay] prompt matches ${matches.length} fixtures ` +
`(${matches.map(({ fixture }) => fixture.meta.name).join(', ')}); ` +
'fixtures must not share a bindable prompt',
);
}
const { fixture, state } = matches[0];
ensureReplayProviderRegistered();
const model = initializeModel({
provider: REPLAY_PROVIDER,
clientOptions: { fixture, state, callbacks: modelCallbacks },
tools: agents?.[0]?.tools ?? [],
});
state.ledger.toolsBound = model.boundToolNames ?? [];
graph.overrideModel = model;
/** `graph.overrideModel` is not inherited by child executors, so a fixture
* recording a subagent call — record mode captures child invocations, since
* the recorder attaches to every agent context — would otherwise leave the
* child on its configured provider: an underrun here, and a real provider
* request in a lane that must stay keyless. */
if (typeof graph.setSubagentModelOverride === 'function') {
graph.setSubagentModelOverride(model);
}
writeLedger(fixture.meta.name);
return true;
}
module.exports = {
FIXTURES_DIR,
LEDGER_DIR,
installRecorder,
tryBindReplay,
latestHumanText,
serializeChunk,
deserializeChunk,
parseFixtureFile,
};