mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-01 11:36:29 +00:00
* 🧊 feat: Persist Context Fading Tier Across Agent Runs Carry the pruner's latched context-fading tier from `@librechat/agents` through `contextMeta` the way `calibrationRatio` already travels, so a historical tool result keeps the same truncated bytes from one run to the next and Anthropic's prefix-based prompt cache survives across turns (danny-avila/agents#497). - Add `IAgentFadingTier` to the message and conversation `contextMeta` types, schemas and the data-provider zod schema - Add `isAgentFadingTier`, `resolvePersistableFadingTier` and `resolveRunContextMeta` in `packages/api`; a tier is persisted once it carries information (masking active or a budget below the window), and a latched tier is kept even at a neutral calibration ratio - Seed `createRun({ fadingTier })` from the parent response's contextMeta regardless of encoding, since caps are character-based; the field rides the existing forward-compatible `runConfig` variable and older SDK versions ignore it, as does `Run.getFadingTier` being absent - Accept a valid `fading` field in event actor context and reject a malformed one, matching the calibration checks - Capture run context meta through one module-level helper for both completion paths instead of two inline copies Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Seed Fading Tier Across HITL Resume, Validate at the DB Layer Address review findings on fading tier persistence: - Leave the "is this tier worth persisting" decision to the SDK, whose `Run.getFadingTier()` now returns only informative tiers; comparing against the client's window misclassified untouched conversations whenever a reserve ratio was configured - Carry `contextMeta` through the HITL pause projection (staged approval, job metadata, resume state) and seed the rebuilt client from it, so a paused turn resumes with the same tier and calibration instead of re-deriving a shallower one and rewriting the prefix - Share run seeding between `chatCompletion` and `resumeCompletion`, and make capture tolerate partial runs and bare resume contexts - Validate `contextMeta.fading` in `commitAgentEventActorState` with a shared `isAgentFadingTier` guard in data-schemas, so a malformed tier is rejected at the DB layer instead of silently cold-starting the actor - Reuse `IAgentEventActorContextMeta` for `IMessage.contextMeta` instead of extending an inline duplicate Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 test: Cover Resume Seeding From Paused Context Meta Assert the resume controller seeds the rebuilt client from the context meta captured at the pause before rebuilding the run, and move the capture helper's JSDoc back above its function. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧹 style: Sort Imports in Fading Tier Plumbing Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Persist Fading Tier Through Redis Jobs and Branches Address Codex and Copilot review findings on fading tier persistence: - Deserialize `contextMeta` in `RedisJobStore`, whose explicit read mapper rebuilt every other pause field but this one, so Redis-backed deployments resumed with no tier to seed after every HITL pause - Copy server-private `contextMeta` onto the assistant message created by `POST /api/messages/branch`, so the next turn seeds its pruner the same way it would from the parallel source response - Constrain the `fading` subdocuments in both Mongoose schemas (version enum, positive budget, required fields) to match the zod schema - Throw a distinct "fading tier is invalid" error from both context meta validators instead of reporting a calibration failure Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Carry Context Meta Through Stop and Re-Pause Addresses the second Codex pass on the fading-tier persistence: - A re-pause whose resumed segment has nothing persistable now clears the job's `contextMeta` in the same transition instead of leaving the first pause's calibration and tier for the next resume to seed from. - The client publishes the run's live calibration and fading tier onto the job after each pre-invoke context snapshot (deduplicated on value), and the Stop path copies `jobData.contextMeta` onto the stopped response, so a follow-up from a stopped turn keeps the prompt prefix stable. - Mid-run captures (HITL pause, Stop) read the graph's live state; `Run` refreshes its own getters only after the stream settles, so the pause used to persist the seeds it started from. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Fence Context Meta Publication Ahead of Stop A Stop handled by another replica reads the job before the owner is signaled, so a fire-and-forget publish could lose the race, and a run stopped before its first context snapshot never published the tier it inherited. The client now publishes the inherited seed before the run streams or resumes, and the context-usage handler awaits each snapshot's publish so the write lands before the model call it describes begins. Failures still only log; the pre-run publish is optional-called so bare resume contexts keep working. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Read Back Context Meta After the Abort Claim The run owner publishes its context meta ahead of each model call and awaits the write, so a publish landing between the abort's initial job read and its terminal claim describes the call whose partial output the abort snapshot carries. abortJob now reads the same-epoch job back after the claim and content re-read and takes its context meta, so the stopped response persists the tier that produced its bytes. The refresh is best-effort and logs on failure like the content refresh beside it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Share In-Flight Context Meta Writes and Clear Neutral Live State Two snapshot callbacks in a parallel-agent run can capture the same tier before the first write settles; the publisher now caches the in-flight promise per serialized value so the second caller awaits the same durable write instead of treating an uncommitted value as published. A live snapshot with nothing left to persist after an earlier publish now writes a neutral record (ratio 1, no tier), since a running job's fields cannot be deleted through the metadata writer, so a Stop no longer persists an earlier non-neutral state onto the response. The pre-run seed publish is unchanged and is told apart from live snapshots by a flag from the context-usage sink. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 chore: Drop Unused Rest Parameter in Context Meta Test Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 feat: Persist Per-Agent Fading Tiers as Compact Context Meta Reconciles the host with @librechat/agents at 9aacad1a, where graph history stays canonical and each Run derives a provider-only projection from the latched tiers. LibreChat therefore persists only compact state: the calibration ratio, the default agent's tier, and now the per-agent tiers from Run.getFadingTiers(), stored as validated entries so agent IDs never become MongoDB field names and restored onto a null-prototype record for RunConfig.fadingTiers. Truncated messages, canonical tool content, projection provenance and per-message truncation state are never persisted; the SDK's latched flag is stripped on capture. The per-agent map travels everywhere the single tier already did: response contextMeta, event-actor checkpoint state, HITL pause and resume job metadata in both stores, the Stop path, and branch creation. A shared Mongoose definition keeps the message and conversation schemas identical, the zod schema validates entries, and commitAgentEventActorState rejects malformed maps. Comments no longer describe graph messages as truncated; only the provider projection is. Tests cover: tool results and inputs reach the persisted content parts at full size; contextMeta carries exactly the compact tier and calibration fields; a persisted snapshot round-trips into the seeded RunConfig unchanged and prototype-safe; per-agent tiers survive pause, Redis job round-trips, and event-actor context validation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Restore Event Actor Types and Publish Seeds Before Abortable Setup The previous commit's doc rewording in the conversation types dropped the event-actor interfaces that precede the fading tier, which broke the declaration bundle in CI; they are restored unchanged. The inherited context meta is now published onto the job as soon as the parent's state is loaded and awaited at the top of chatCompletion and resumeCompletion, ahead of run creation and the other abortable setup stages, so a Stop during setup still persists the parent's tiers onto the stopped response. The partial response saved when every subscriber disconnects now carries the job's context meta like the Stop and pause paths do. The branch route keeps the source's context meta in the saved message but strips it from the HTTP response, matching client reads. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Serialize Distinct Context Meta Publications Overlapping snapshots from parallel agents could carry different tiers; each started its own write and both job stores keep whichever write finished last, so an older snapshot could overwrite a newer one. Each distinct publication is now issued after the previous one settles, in call order, so the newest snapshot is what a Stop reads back. Equal values still share one write. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Project Root Message Reads and Keep Inherited Meta on Setup Failure The root GET /api/messages paths returned raw documents: the single-message query read called getMessages without a projection and the cursor page performed an unprojected find. Both now apply CLIENT_MESSAGE_SELECT, with getMessagesByCursor accepting a select option, so the persisted fading tiers never reach the client through the paginated endpoint; the search-hydration read projects the same way. A turn that loads a parent's context meta but fails before its run exists used to replace the inherited meta with the capture of a missing run, so the persisted error response lost the last valid tiers. Both finalizers now keep the inherited meta when no run was created while a created run's neutral state may still clear it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 refactor: Move Context Meta Publication Into packages/api The publication coordinator (ordering, deduplication, in-flight sharing, failure state) now lives in packages/api as createContextMetaPublisher, with selectRunContextMetaToPublish deciding what a publication carries; the legacy client keeps only the wiring to the job metadata writer. A transiently failing write is retried with a short backoff before the publication is reported and forgotten, so a snapshot's model call no longer proceeds on the first rejection while the job holds the previous record. The SDK swallows handler errors, so retrying is the only way to strengthen the fence without failing the turn. A terminal save now supplies contextMeta: null when the finished run has nothing to carry, and saveMessage unsets a previously stored value in that case, so a disconnect snapshot's record cannot outlive the run that completed neutrally. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Widen the saveMessage Method Type for the Null Unset The exported method interface still typed contextMeta as the message field, so the spec exercising the null unset failed typechecking; it now mirrors the implementation signature. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Publish Every Agent's Snapshot, Sanitize Search Hits, Unbound Agent IDs The context-usage handler now awaits the run's context meta publish for every ON_CONTEXT_USAGE event, hidden sequential agents included, while the visible-snapshot bookkeeping and the client emission keep their gate; a Stop before the next visible snapshot finds the tier a hidden agent's model call latched. Search results are built from the hydrated hit minus its server-private contextMeta, since Meilisearch hydration projects every schema field. The per-agent tier entry guard no longer caps the agent ID length: ephemeral agent IDs encode endpoint, model and sender without a bound, and dropping such an entry would let that agent alone re-derive its tier on the next turn. Also formats the publication spec so the static checks pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Unset Context Meta Atomically, Keep Publication State After a Failed Write - `saveMessage` folds the `contextMeta` unset into the same update that persists the terminal response, on both the plain and the provenance-merge paths, so a failure between two writes can no longer leave a completed row carrying a disconnect snapshot's state. - `createContextMetaPublisher` tracks whether any record has committed separately from the retryable latest publication, so `hasPublished` stays true after a later publication exhausts its retries and a following neutral snapshot still overwrites the earlier record on the job. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Carry Context Meta Through the Resumable Stop Route and Neutral Resumed Completions - The resumable Stop route copies the refreshed `jobData.contextMeta` onto the terminal assistant message it persists, as the abort middleware already does, so a stopped resumable response keeps the tiers that produced its partial output. - A resumed run that completes with neutral context state now saves `contextMeta: null`, which unsets the paused segment's record instead of letting an omitted field keep it on the completed row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Trust Only Server-Authored Context Meta, Unset It on a Neutral Stop, Read It From the Job on Disconnect - `POST /api/messages/:conversationId` and conversation imports drop any client-supplied `contextMeta`, and the run seeds its calibration and fading tiers only from a server-authored parent response, so a forged tier can never shape a provider projection. - Both Stop paths save `contextMeta: jobData.contextMeta ?? null`, so a job that re-paused with neutral state unsets what an earlier pause stored on the row instead of leaving it behind. - The disconnect partial save reads the same-epoch job record for the run's published context meta, since the client-facing resume snapshot never carries server-private state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧪 test: Mock the Job Store in the Resume Metadata Spec The disconnect partial save now reads the same-epoch job record through `GenerationJobManager.getJobStore()`, so the resume metadata spec's manager mock provides it as the other disconnect spec already does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 🧊 fix: Keep Context Meta Off the POST Message Response `POST /api/messages/:conversationId` returned the saved row verbatim, so a client write against an existing server-authored message echoed the row's stored context meta. The route now projects the row through the same client projection the branch route uses, which drops the server-private field. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF * 📦 chore: Bump @librechat/agents to 3.7.16 for Persisted Fading Tiers 3.7.16 is the first release that carries danny-avila/agents#497, so `Run.getFadingTier()` / `getFadingTiers()` now report the latched tier this branch persists and `RunConfig.fadingTier` / `fadingTiers` seed the next run from it. The dependency set is unchanged from 3.7.15; only the package entry moves. Adds an SDK-backed round trip to the fading spec: a first pruner latches an informative tier, `resolveRunContextMeta` reduces it to the persisted shape, and a second pruner seeded from that shape reproduces the first run's projected tool-exchange bytes under instruction and calibration drift, while an unseeded pruner under the same drift relaxes its tier and rewrites them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNbgMeR2aZDyhsqAy4L8gF --------- Co-authored-by: Claude <noreply@anthropic.com>
1369 lines
45 KiB
JavaScript
1369 lines
45 KiB
JavaScript
const { Tools, StepEvents } = require('librechat-data-provider');
|
|
|
|
// Mock all dependencies before requiring the module
|
|
jest.mock('nanoid', () => ({
|
|
nanoid: jest.fn(() => 'mock-id'),
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
sendEvent: jest.fn(),
|
|
writeAttachmentEvent: jest.fn(),
|
|
GenerationJobManager: {
|
|
emitChunk: jest.fn(),
|
|
},
|
|
HOST_FILE_AUTHORING_ARTIFACT_KEY: '__librechat_file_authoring',
|
|
getToolInputValidationDetails: jest.fn((result, validationError) =>
|
|
validationError != null
|
|
? {
|
|
toolName: result.tool_call.name,
|
|
reason: 'option_label_too_long',
|
|
fieldPath: validationError.fieldPath,
|
|
}
|
|
: null,
|
|
),
|
|
isCodeSessionToolName: jest.fn((name) =>
|
|
['execute_code', 'bash_tool', 'read_file'].includes(name),
|
|
),
|
|
}));
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
debug: jest.fn(),
|
|
error: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
jest.mock('@librechat/agents', () => ({
|
|
...jest.requireActual('@librechat/agents'),
|
|
getMessageId: jest.fn(),
|
|
ToolEndHandler: jest.fn(),
|
|
handleToolCalls: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/Citations', () => ({
|
|
processFileCitations: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/Code/process', () => ({
|
|
processCodeOutput: jest.fn(),
|
|
/* `runPreviewFinalize` is the runtime pairing for `finalize` (defined
|
|
* alongside processCodeOutput in process.js). The callback wires
|
|
* the deferred render through it; reproduce the basic happy-path here so the
|
|
* SSE-emit assertions still work. The catch/defensive-updateFile
|
|
* branch is unit-tested directly against the real helper in
|
|
* process.spec.js — exercising it here would add test coupling
|
|
* without coverage benefit. */
|
|
runPreviewFinalize: ({ finalize, onResolved }) => {
|
|
if (typeof finalize !== 'function') {
|
|
return;
|
|
}
|
|
finalize()
|
|
.then((updated) => {
|
|
if (!updated || !onResolved) {
|
|
return;
|
|
}
|
|
onResolved(updated);
|
|
})
|
|
.catch(() => {
|
|
/* swallowed in the mock — see process.spec.js for catch coverage */
|
|
});
|
|
},
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/Code/preflight', () => ({
|
|
preflightCodeOutputBatch: jest.fn(async ({ artifact }) =>
|
|
(artifact.files ?? [])
|
|
.filter((file) => file.inherited !== true)
|
|
.map((file) => ({
|
|
file,
|
|
sessionId: file.storage_session_id ?? artifact.session_id,
|
|
})),
|
|
),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
|
loadAuthValues: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/process', () => ({
|
|
saveBase64Image: jest.fn(),
|
|
}));
|
|
|
|
describe('resumable event generation fencing', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('forwards the originating job epoch with run-step events', async () => {
|
|
const { GenerationJobManager } = require('@librechat/api');
|
|
const { GraphEvents } = jest.requireActual('@librechat/agents');
|
|
const { getDefaultHandlers } = require('../callbacks');
|
|
const data = {
|
|
id: 'step-1',
|
|
index: 0,
|
|
stepDetails: {
|
|
type: 'tool_calls',
|
|
tool_calls: [{ id: 'call-1', name: 'approval_probe', args: '{}' }],
|
|
},
|
|
};
|
|
const handlers = getDefaultHandlers({
|
|
res: { write: jest.fn() },
|
|
aggregateContent: jest.fn(),
|
|
toolEndCallback: jest.fn(),
|
|
collectedUsage: [],
|
|
streamId: 'conversation-1',
|
|
jobCreatedAt: 1234,
|
|
});
|
|
|
|
await handlers[GraphEvents.ON_RUN_STEP].handle(GraphEvents.ON_RUN_STEP, data);
|
|
|
|
expect(GenerationJobManager.emitChunk).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
{ event: GraphEvents.ON_RUN_STEP, data },
|
|
{ expectedCreatedAt: 1234 },
|
|
);
|
|
});
|
|
|
|
it('keeps a tool result and its input at full size in the content parts LibreChat persists', async () => {
|
|
const { GraphEvents, createContentAggregator } = jest.requireActual('@librechat/agents');
|
|
const { getDefaultHandlers } = require('../callbacks');
|
|
/** Far beyond any cap the SDK's provider-only projection would apply. */
|
|
const output = 'r'.repeat(600_000);
|
|
const args = { query: 'q'.repeat(120_000) };
|
|
const { contentParts, stepMap, aggregateContent } = createContentAggregator();
|
|
const handlers = getDefaultHandlers({
|
|
res: { write: jest.fn() },
|
|
aggregateContent,
|
|
contentParts,
|
|
stepMap,
|
|
toolEndCallback: jest.fn(),
|
|
collectedUsage: [],
|
|
streamId: 'conversation-1',
|
|
jobCreatedAt: 1234,
|
|
});
|
|
const step = {
|
|
id: 'step-full-size',
|
|
index: 0,
|
|
stepDetails: {
|
|
type: 'tool_calls',
|
|
tool_calls: [{ id: 'call-full-size', name: 'fetch', args: JSON.stringify(args) }],
|
|
},
|
|
};
|
|
|
|
await handlers[GraphEvents.ON_RUN_STEP].handle(GraphEvents.ON_RUN_STEP, step);
|
|
await handlers[GraphEvents.ON_RUN_STEP_COMPLETED].handle(GraphEvents.ON_RUN_STEP_COMPLETED, {
|
|
result: {
|
|
id: step.id,
|
|
index: 0,
|
|
tool_call: { id: 'call-full-size', name: 'fetch', args, output },
|
|
},
|
|
});
|
|
|
|
expect(contentParts).toHaveLength(1);
|
|
expect(contentParts[0].tool_call.output).toBe(output);
|
|
expect(contentParts[0].tool_call.args).toEqual(args);
|
|
expect(JSON.stringify(contentParts[0])).not.toContain('[truncated:');
|
|
});
|
|
|
|
it('publishes a hidden sequential agent snapshot without recording or forwarding it', async () => {
|
|
const { GenerationJobManager } = require('@librechat/api');
|
|
const { GraphEvents } = jest.requireActual('@librechat/agents');
|
|
const { getDefaultHandlers } = require('../callbacks');
|
|
const onSnapshot = jest.fn(async () => undefined);
|
|
const contextUsageSink = { latest: null, count: 0, onSnapshot };
|
|
const handlers = getDefaultHandlers({
|
|
res: { write: jest.fn() },
|
|
aggregateContent: jest.fn(),
|
|
toolEndCallback: jest.fn(),
|
|
collectedUsage: [],
|
|
streamId: 'conversation-1',
|
|
jobCreatedAt: 1234,
|
|
contextUsageSink,
|
|
usageEmitSink: [],
|
|
});
|
|
|
|
await handlers[GraphEvents.ON_CONTEXT_USAGE].handle(
|
|
GraphEvents.ON_CONTEXT_USAGE,
|
|
{ contextBudget: 1000, remainingContextTokens: 400 },
|
|
{
|
|
hide_sequential_outputs: true,
|
|
last_agent_id: 'agent-final',
|
|
langgraph_node: 'agent-intermediate',
|
|
},
|
|
);
|
|
|
|
expect(onSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(contextUsageSink).toMatchObject({ latest: null, count: 0 });
|
|
expect(GenerationJobManager.emitChunk).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('records a context snapshot and notifies the sink before forwarding it', async () => {
|
|
const { GenerationJobManager } = require('@librechat/api');
|
|
const { GraphEvents } = jest.requireActual('@librechat/agents');
|
|
const { getDefaultHandlers } = require('../callbacks');
|
|
let releaseSnapshot;
|
|
const onSnapshot = jest.fn(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
releaseSnapshot = resolve;
|
|
}),
|
|
);
|
|
const contextUsageSink = { latest: null, count: 0, onSnapshot };
|
|
const usageEmitSink = [{ input_tokens: 10 }];
|
|
const data = { contextBudget: 1000, remainingContextTokens: 400 };
|
|
const handlers = getDefaultHandlers({
|
|
res: { write: jest.fn() },
|
|
aggregateContent: jest.fn(),
|
|
toolEndCallback: jest.fn(),
|
|
collectedUsage: [],
|
|
streamId: 'conversation-1',
|
|
jobCreatedAt: 1234,
|
|
contextUsageSink,
|
|
usageEmitSink,
|
|
});
|
|
|
|
let settled = false;
|
|
const handled = handlers[GraphEvents.ON_CONTEXT_USAGE]
|
|
.handle(GraphEvents.ON_CONTEXT_USAGE, data, { hide_sequential_outputs: false })
|
|
.then(() => {
|
|
settled = true;
|
|
});
|
|
await Promise.resolve();
|
|
|
|
expect(contextUsageSink).toMatchObject({ latest: data, count: 1, latestUsageIndex: 1 });
|
|
expect(onSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(settled).toBe(false);
|
|
releaseSnapshot();
|
|
await handled;
|
|
expect(settled).toBe(true);
|
|
expect(GenerationJobManager.emitChunk).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
{ event: GraphEvents.ON_CONTEXT_USAGE, data },
|
|
{ expectedCreatedAt: 1234 },
|
|
);
|
|
});
|
|
|
|
it('resolves MCP identity from a function-shaped root tool call', async () => {
|
|
const { GraphEvents } = jest.requireActual('@librechat/agents');
|
|
const { getDefaultHandlers } = require('../callbacks');
|
|
const resolveMcpServerName = jest.fn(() => 'server');
|
|
const data = {
|
|
id: 'step-function-tool',
|
|
index: 0,
|
|
stepDetails: {
|
|
type: 'tool_calls',
|
|
tool_calls: [
|
|
{
|
|
id: 'call-function-tool',
|
|
function: { name: 'lookup_mcp_server', arguments: '{}' },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const handlers = getDefaultHandlers({
|
|
res: { write: jest.fn() },
|
|
aggregateContent: jest.fn(),
|
|
toolEndCallback: jest.fn(),
|
|
collectedUsage: [],
|
|
resolveMcpServerName,
|
|
});
|
|
|
|
await handlers[GraphEvents.ON_RUN_STEP].handle(GraphEvents.ON_RUN_STEP, data, {
|
|
agent_id: 'lazy-agent',
|
|
});
|
|
|
|
expect(resolveMcpServerName).toHaveBeenCalledWith('lookup_mcp_server', 'lazy-agent');
|
|
expect(data.stepDetails.tool_calls[0]).toEqual(
|
|
expect.objectContaining({ name: 'lookup_mcp_server', mcpServerName: 'server' }),
|
|
);
|
|
});
|
|
|
|
it('publishes root event-child progress through the child activity transport', async () => {
|
|
const { nanoid } = require('nanoid');
|
|
nanoid.mockReturnValueOnce('invocation-1').mockReturnValueOnce('invocation-2');
|
|
const { GraphEvents } = jest.requireActual('@librechat/agents');
|
|
const { getDefaultHandlers } = require('../callbacks');
|
|
const publish = jest.fn().mockResolvedValue(undefined);
|
|
const data = {
|
|
id: 'step-1',
|
|
index: 0,
|
|
stepDetails: { type: 'message_creation' },
|
|
};
|
|
const handlers = getDefaultHandlers({
|
|
res: { write: jest.fn() },
|
|
aggregateContent: jest.fn(),
|
|
toolEndCallback: jest.fn(),
|
|
collectedUsage: [],
|
|
streamId: 'event-thread',
|
|
jobCreatedAt: 1234,
|
|
eventChildActivity: {
|
|
runId: 'event-thread',
|
|
parentRunId: 'parent-conversation',
|
|
subagentRunId: 'delivery-1',
|
|
subagentType: 'agent-1',
|
|
subagentAgentId: 'agent-1',
|
|
parentAgentId: 'director',
|
|
publish,
|
|
},
|
|
});
|
|
|
|
await handlers[GraphEvents.ON_RUN_STEP].handle(GraphEvents.ON_RUN_STEP, data);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
const firstUpdate = publish.mock.calls[0][0];
|
|
expect(firstUpdate).toEqual(
|
|
expect.objectContaining({
|
|
runId: 'event-thread',
|
|
parentRunId: 'parent-conversation',
|
|
subagentRunId: 'delivery-1',
|
|
phase: 'run_step',
|
|
activityEventId: expect.stringMatching(/^delivery-1:.+:0$/),
|
|
data,
|
|
}),
|
|
);
|
|
expect(firstUpdate).not.toHaveProperty('activitySequence');
|
|
|
|
const resumedPublish = jest.fn().mockResolvedValue(undefined);
|
|
const resumedHandlers = getDefaultHandlers({
|
|
res: { write: jest.fn() },
|
|
aggregateContent: jest.fn(),
|
|
toolEndCallback: jest.fn(),
|
|
collectedUsage: [],
|
|
streamId: 'event-thread',
|
|
jobCreatedAt: 1234,
|
|
eventChildActivity: {
|
|
runId: 'event-thread',
|
|
parentRunId: 'parent-conversation',
|
|
subagentRunId: 'delivery-1',
|
|
subagentType: 'agent-1',
|
|
subagentAgentId: 'agent-1',
|
|
parentAgentId: 'director',
|
|
publish: resumedPublish,
|
|
},
|
|
});
|
|
await resumedHandlers[GraphEvents.ON_RUN_STEP].handle(GraphEvents.ON_RUN_STEP, data);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(resumedPublish.mock.calls[0][0].activityEventId).not.toBe(firstUpdate.activityEventId);
|
|
});
|
|
|
|
it('forwards the originating job epoch with deferred attachments', () => {
|
|
const { GenerationJobManager } = require('@librechat/api');
|
|
const { createAttachmentEmitter } = require('../callbacks');
|
|
const attachment = { file_id: 'file-1', status: 'ready' };
|
|
const emitAttachment = createAttachmentEmitter({
|
|
res: { write: jest.fn() },
|
|
streamId: 'conversation-1',
|
|
jobCreatedAt: 1234,
|
|
});
|
|
|
|
emitAttachment(attachment);
|
|
|
|
expect(GenerationJobManager.emitChunk).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
{ event: 'attachment', data: attachment },
|
|
{ expectedCreatedAt: 1234 },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('createPtcProgressEmitter', () => {
|
|
const ptcEvent = {
|
|
tool_call_id: 'call_ptc',
|
|
call_id: 'call_ptc:0',
|
|
name: 'read_file',
|
|
status: 'running',
|
|
args: 'path=a.ts',
|
|
};
|
|
|
|
beforeEach(() => jest.clearAllMocks());
|
|
|
|
it('emits the inner tool-call event on the resumable job stream', () => {
|
|
const { GenerationJobManager } = require('@librechat/api');
|
|
const { createPtcProgressEmitter } = require('../callbacks');
|
|
const emit = createPtcProgressEmitter({
|
|
res: { write: jest.fn() },
|
|
streamId: 'conversation-1',
|
|
jobCreatedAt: 1234,
|
|
});
|
|
|
|
emit(ptcEvent);
|
|
|
|
expect(GenerationJobManager.emitChunk).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
{ event: StepEvents.ON_PTC_TOOL_CALL, data: ptcEvent },
|
|
{ expectedCreatedAt: 1234 },
|
|
);
|
|
});
|
|
|
|
it('writes to the live response when no stream id is in play', () => {
|
|
const { sendEvent } = require('@librechat/api');
|
|
const { createPtcProgressEmitter } = require('../callbacks');
|
|
const res = { write: jest.fn(), headersSent: true, writableEnded: false };
|
|
const emit = createPtcProgressEmitter({ res });
|
|
|
|
emit(ptcEvent);
|
|
|
|
expect(sendEvent).toHaveBeenCalledWith(res, {
|
|
event: StepEvents.ON_PTC_TOOL_CALL,
|
|
data: ptcEvent,
|
|
});
|
|
});
|
|
|
|
it('absorbs a rejected resumable emit instead of leaving an unhandled rejection', async () => {
|
|
const { GenerationJobManager } = require('@librechat/api');
|
|
const { createPtcProgressEmitter } = require('../callbacks');
|
|
GenerationJobManager.emitChunk.mockRejectedValueOnce(new Error('transport down'));
|
|
const unhandled = jest.fn();
|
|
process.on('unhandledRejection', unhandled);
|
|
|
|
const emit = createPtcProgressEmitter({
|
|
res: { write: jest.fn() },
|
|
streamId: 'conversation-1',
|
|
jobCreatedAt: 1234,
|
|
});
|
|
|
|
expect(() => emit(ptcEvent)).not.toThrow();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
process.off('unhandledRejection', unhandled);
|
|
|
|
expect(unhandled).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('drops the event once the response has closed', () => {
|
|
const { sendEvent } = require('@librechat/api');
|
|
const { createPtcProgressEmitter } = require('../callbacks');
|
|
const emit = createPtcProgressEmitter({
|
|
res: { write: jest.fn(), headersSent: true, writableEnded: true },
|
|
});
|
|
|
|
emit(ptcEvent);
|
|
|
|
expect(sendEvent).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('createToolEndCallback', () => {
|
|
let req, res, artifactPromises, createToolEndCallback;
|
|
let logger;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Get the mocked logger
|
|
logger = require('@librechat/data-schemas').logger;
|
|
|
|
// Now require the module after all mocks are set up
|
|
const callbacks = require('../callbacks');
|
|
createToolEndCallback = callbacks.createToolEndCallback;
|
|
|
|
req = {
|
|
user: { id: 'user123' },
|
|
};
|
|
res = {
|
|
headersSent: false,
|
|
write: jest.fn(),
|
|
};
|
|
artifactPromises = [];
|
|
});
|
|
|
|
describe('ui_resources artifact handling', () => {
|
|
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [
|
|
{ type: 'button', label: 'Click me' },
|
|
{ type: 'input', placeholder: 'Enter text' },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
|
|
// Wait for all promises to resolve
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
// When headers are not sent, it returns attachment without writing
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
|
|
const attachment = results[0];
|
|
expect(attachment).toEqual({
|
|
type: Tools.ui_resources,
|
|
messageId: 'run456',
|
|
toolCallId: 'tool123',
|
|
conversationId: 'thread789',
|
|
[Tools.ui_resources]: [
|
|
{ type: 'button', label: 'Click me' },
|
|
{ type: 'input', placeholder: 'Enter text' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should write to response when headers are already sent', async () => {
|
|
res.headersSent = true;
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [{ type: 'carousel', items: [] }],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(res.write).toHaveBeenCalled();
|
|
expect(results[0]).toEqual({
|
|
type: Tools.ui_resources,
|
|
messageId: 'run456',
|
|
toolCallId: 'tool123',
|
|
conversationId: 'thread789',
|
|
[Tools.ui_resources]: [{ type: 'carousel', items: [] }],
|
|
});
|
|
});
|
|
|
|
it('should handle errors when processing ui_resources', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
// Mock res.write to throw an error
|
|
res.headersSent = true;
|
|
res.write.mockImplementation(() => {
|
|
throw new Error('Write failed');
|
|
});
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [{ type: 'test' }],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'Error processing artifact content:',
|
|
expect.any(Error),
|
|
);
|
|
expect(results[0]).toBeNull();
|
|
});
|
|
|
|
it('should handle multiple artifacts including ui_resources', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [{ type: 'chart', data: [] }],
|
|
},
|
|
[Tools.web_search]: {
|
|
results: ['result1', 'result2'],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
// Both ui_resources and web_search should be processed
|
|
expect(artifactPromises).toHaveLength(2);
|
|
expect(results).toHaveLength(2);
|
|
|
|
// Check ui_resources attachment
|
|
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
|
|
expect(uiResourceAttachment).toBeTruthy();
|
|
expect(uiResourceAttachment[Tools.ui_resources]).toEqual([{ type: 'chart', data: [] }]);
|
|
|
|
// Check web_search attachment
|
|
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
|
|
expect(webSearchAttachment).toBeTruthy();
|
|
expect(webSearchAttachment[Tools.web_search]).toEqual({
|
|
results: ['result1', 'result2'],
|
|
});
|
|
});
|
|
|
|
it('should not process artifacts when output has no artifacts', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
content: 'Some regular content',
|
|
// No artifact property
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
|
|
expect(artifactPromises).toHaveLength(0);
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle empty ui_resources data object', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(results[0]).toEqual({
|
|
type: Tools.ui_resources,
|
|
messageId: 'run456',
|
|
toolCallId: 'tool123',
|
|
conversationId: 'thread789',
|
|
[Tools.ui_resources]: [],
|
|
});
|
|
});
|
|
|
|
it('should handle ui_resources with complex nested data', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const complexData = {
|
|
0: {
|
|
type: 'form',
|
|
fields: [
|
|
{ name: 'field1', type: 'text', required: true },
|
|
{ name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
|
|
],
|
|
nested: {
|
|
deep: {
|
|
value: 123,
|
|
array: [1, 2, 3],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: complexData,
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(results[0][Tools.ui_resources]).toEqual(complexData);
|
|
});
|
|
|
|
it('should handle when output is undefined', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output: undefined }, metadata);
|
|
|
|
expect(artifactPromises).toHaveLength(0);
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle when data parameter is undefined', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback(undefined, metadata);
|
|
|
|
expect(artifactPromises).toHaveLength(0);
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('code execution deferred-preview emit', () => {
|
|
/* The deferred-preview code-execution flow emits the attachment twice over
|
|
* SSE: the initial emit with `status: 'pending'` and the current run's
|
|
* messageId, the deferred render with the resolved record. The preview update emit
|
|
* must use the CURRENT run's messageId (not the persisted DB one)
|
|
* because `processCodeOutput` intentionally preserves the original
|
|
* `messageId` on cross-turn filename reuse — `getCodeGeneratedFiles`
|
|
* needs that for prior-turn priming.
|
|
*
|
|
* Codex P1 review on PR #12957: shipping `updated.messageId`
|
|
* straight from the DB record routed preview-update patches to the wrong
|
|
* message slot, leaving the current turn's pending chip stuck. */
|
|
|
|
const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
|
const { preflightCodeOutputBatch } = require('~/server/services/Files/Code/preflight');
|
|
|
|
function makeCodeExecutionEvent({
|
|
runId,
|
|
threadId,
|
|
toolCallId,
|
|
fileId,
|
|
name,
|
|
toolName = 'execute_code',
|
|
hostFileAuthoring = false,
|
|
created,
|
|
codeExecutionContext,
|
|
}) {
|
|
return {
|
|
output: {
|
|
name: toolName,
|
|
tool_call_id: toolCallId,
|
|
artifact: {
|
|
...(hostFileAuthoring ? { __librechat_file_authoring: true } : {}),
|
|
...(created === undefined ? {} : { created }),
|
|
path: name,
|
|
session_id: 'sess-1',
|
|
files: [{ id: fileId, name, session_id: 'sess-1' }],
|
|
},
|
|
},
|
|
metadata: { run_id: runId, thread_id: threadId, codeExecutionContext },
|
|
};
|
|
}
|
|
|
|
/** Parse the SSE frame `res.write` produces back to a payload object. */
|
|
function parseSseAttachment(call) {
|
|
const frame = call[0];
|
|
const dataLine = frame.split('\n').find((l) => l.startsWith('data: '));
|
|
return JSON.parse(dataLine.slice('data: '.length));
|
|
}
|
|
|
|
it('the preview update emit uses the current run messageId, not the persisted DB messageId (cross-turn filename reuse)', async () => {
|
|
/* Simulate turn-2 reusing `output.csv` from turn-1. The DB record
|
|
* surfaced by `updateFile` carries the original `turn-1-msg`
|
|
* messageId; the runtime emit must rewrite to `turn-2-msg`. */
|
|
res.headersSent = true;
|
|
const finalize = jest.fn().mockResolvedValue({
|
|
file_id: 'fid-shared',
|
|
filename: 'output.csv',
|
|
filepath: '/uploads/output.csv',
|
|
type: 'text/csv',
|
|
conversationId: 'thread789',
|
|
messageId: 'turn-1-original-msg', // persisted DB id (older turn)
|
|
status: 'ready',
|
|
text: '<table></table>',
|
|
textFormat: 'html',
|
|
});
|
|
processCodeOutput.mockResolvedValue({
|
|
file: {
|
|
file_id: 'fid-shared',
|
|
filename: 'output.csv',
|
|
filepath: '/uploads/output.csv',
|
|
type: 'text/csv',
|
|
conversationId: 'thread789',
|
|
messageId: 'turn-2-current-run', // runtime overlay (current turn)
|
|
toolCallId: 'tool-2',
|
|
status: 'pending',
|
|
text: null,
|
|
textFormat: null,
|
|
},
|
|
finalize,
|
|
});
|
|
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
const event = makeCodeExecutionEvent({
|
|
runId: 'turn-2-current-run',
|
|
threadId: 'thread789',
|
|
toolCallId: 'tool-2',
|
|
fileId: 'fid-shared',
|
|
name: 'output.csv',
|
|
});
|
|
await toolEndCallback({ output: event.output }, event.metadata);
|
|
await Promise.all(artifactPromises);
|
|
// Wait one more tick so the fire-and-forget finalize() chain settles.
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
// Two SSE writes: the initial emit (pending) and the deferred render (ready).
|
|
expect(res.write).toHaveBeenCalledTimes(2);
|
|
const phase1 = parseSseAttachment(res.write.mock.calls[0]);
|
|
const phase2 = parseSseAttachment(res.write.mock.calls[1]);
|
|
|
|
// Initial emit already used the runtime messageId (sourced from result.file).
|
|
expect(phase1.messageId).toBe('turn-2-current-run');
|
|
expect(phase1.status).toBe('pending');
|
|
|
|
/* The preview update MUST also route to the current run's messageId so the
|
|
* frontend's `useAttachmentHandler` upserts under the same
|
|
* messageAttachmentsMap slot as the initial emit. Routing to
|
|
* `turn-1-original-msg` would land the patch on a stale message
|
|
* and leave turn-2's pending chip stuck. */
|
|
expect(phase2.messageId).toBe('turn-2-current-run');
|
|
expect(phase2.file_id).toBe('fid-shared');
|
|
expect(phase2.status).toBe('ready');
|
|
expect(phase2.text).toBe('<table></table>');
|
|
expect(phase2.toolCallId).toBe('tool-2');
|
|
/* Wire-shape parity with the initial emit: preview update emits the full updated
|
|
* record so the client doesn't see one shape on the initial emit and a
|
|
* narrower projection on the deferred render. (Codex audit on PR #12957
|
|
* Finding 1.) */
|
|
expect(phase2.filename).toBe('output.csv');
|
|
expect(phase2.filepath).toBe('/uploads/output.csv');
|
|
expect(phase2.type).toBe('text/csv');
|
|
expect(phase2.conversationId).toBe('thread789');
|
|
expect(phase2.textFormat).toBe('html');
|
|
});
|
|
|
|
it('the preview update emit is skipped when finalize resolves to null (no DB update happened)', async () => {
|
|
res.headersSent = true;
|
|
processCodeOutput.mockResolvedValue({
|
|
file: {
|
|
file_id: 'fid-1',
|
|
filename: 'data.xlsx',
|
|
filepath: '/uploads/data.xlsx',
|
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
messageId: 'run-1',
|
|
toolCallId: 'tool-1',
|
|
status: 'pending',
|
|
},
|
|
finalize: jest.fn().mockResolvedValue(null),
|
|
});
|
|
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
const event = makeCodeExecutionEvent({
|
|
runId: 'run-1',
|
|
threadId: 'thread-1',
|
|
toolCallId: 'tool-1',
|
|
fileId: 'fid-1',
|
|
name: 'data.xlsx',
|
|
});
|
|
await toolEndCallback({ output: event.output }, event.metadata);
|
|
await Promise.all(artifactPromises);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
// Only the initial emit fired; preview update noop'd because finalize returned null.
|
|
expect(res.write).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('the preview update emit is skipped when the response stream has already closed', async () => {
|
|
res.headersSent = true;
|
|
/* Hand-rolled deferred so we can hold finalize() open until
|
|
* AFTER setting `res.writableEnded = true`. Otherwise the mock
|
|
* resolves synchronously, the .then() runs in the same microtask
|
|
* queue as the artifactPromises await, and writableEnded is set
|
|
* too late. */
|
|
let resolveFinalize;
|
|
const finalizeDeferred = new Promise((resolve) => {
|
|
resolveFinalize = resolve;
|
|
});
|
|
processCodeOutput.mockResolvedValue({
|
|
file: {
|
|
file_id: 'fid-1',
|
|
filename: 'data.xlsx',
|
|
filepath: '/uploads/data.xlsx',
|
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
messageId: 'run-1',
|
|
toolCallId: 'tool-1',
|
|
status: 'pending',
|
|
},
|
|
finalize: jest.fn().mockReturnValue(finalizeDeferred),
|
|
});
|
|
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
const event = makeCodeExecutionEvent({
|
|
runId: 'run-1',
|
|
threadId: 'thread-1',
|
|
toolCallId: 'tool-1',
|
|
fileId: 'fid-1',
|
|
name: 'data.xlsx',
|
|
});
|
|
await toolEndCallback({ output: event.output }, event.metadata);
|
|
await Promise.all(artifactPromises);
|
|
// Simulate the response closing AFTER the initial emit fires but BEFORE
|
|
// the deferred render lands. The frontend's polling path will catch the
|
|
// resolved record on its next tick.
|
|
res.writableEnded = true;
|
|
// Now resolve finalize and let the .then() chain run.
|
|
resolveFinalize({
|
|
file_id: 'fid-1',
|
|
filename: 'data.xlsx',
|
|
messageId: 'run-1',
|
|
status: 'ready',
|
|
text: '<x/>',
|
|
textFormat: 'html',
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
// Initial emit wrote; preview update noop'd because writableEnded.
|
|
expect(res.write).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not call finalize for a non-office file (no preview expected)', async () => {
|
|
res.headersSent = true;
|
|
processCodeOutput.mockResolvedValue({
|
|
file: {
|
|
file_id: 'fid-txt',
|
|
filename: 'note.txt',
|
|
filepath: '/uploads/note.txt',
|
|
type: 'text/plain',
|
|
messageId: 'run-1',
|
|
toolCallId: 'tool-1',
|
|
// No status — non-office files skip the deferred render entirely.
|
|
},
|
|
// No finalize key — caller should not call anything.
|
|
});
|
|
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
const event = makeCodeExecutionEvent({
|
|
runId: 'run-1',
|
|
threadId: 'thread-1',
|
|
toolCallId: 'tool-1',
|
|
fileId: 'fid-txt',
|
|
name: 'note.txt',
|
|
});
|
|
await toolEndCallback({ output: event.output }, event.metadata);
|
|
await Promise.all(artifactPromises);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(res.write).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('processes create_file sandbox artifacts like code execution outputs', async () => {
|
|
res.headersSent = true;
|
|
processCodeOutput.mockResolvedValue({
|
|
file: {
|
|
file_id: 'fid-created',
|
|
filename: 'created.txt',
|
|
filepath: '/uploads/created.txt',
|
|
type: 'text/plain',
|
|
conversationId: 'thread789',
|
|
messageId: 'run-create',
|
|
toolCallId: 'tool-create',
|
|
status: 'pending',
|
|
},
|
|
finalize: jest.fn().mockResolvedValue({
|
|
file_id: 'fid-created',
|
|
filename: 'created.txt',
|
|
filepath: '/uploads/created.txt',
|
|
type: 'text/plain',
|
|
conversationId: 'thread789',
|
|
messageId: 'run-create',
|
|
status: 'ready',
|
|
}),
|
|
});
|
|
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
const event = makeCodeExecutionEvent({
|
|
runId: 'run-create',
|
|
threadId: 'thread789',
|
|
toolCallId: 'tool-create',
|
|
fileId: 'fid-created',
|
|
name: 'created.txt',
|
|
toolName: 'create_file',
|
|
hostFileAuthoring: true,
|
|
created: true,
|
|
codeExecutionContext: {
|
|
baseUrl: 'https://code-stateful.example.com',
|
|
executionProfile: 'stateful',
|
|
executionRouteKey: `stateful:${'a'.repeat(32)}`,
|
|
},
|
|
});
|
|
await toolEndCallback({ output: event.output }, event.metadata);
|
|
await Promise.all(artifactPromises);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(processCodeOutput).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: 'fid-created',
|
|
name: 'created.txt',
|
|
messageId: 'run-create',
|
|
toolCallId: 'tool-create',
|
|
conversationId: 'thread789',
|
|
codeApiBaseUrl: 'https://code-stateful.example.com',
|
|
executionProfile: 'stateful',
|
|
executionRouteKey: `stateful:${'a'.repeat(32)}`,
|
|
}),
|
|
);
|
|
expect(res.write).toHaveBeenCalledTimes(2);
|
|
expect(parseSseAttachment(res.write.mock.calls[0]).workspaceChange).toEqual({
|
|
profile: 'stateful',
|
|
operation: 'created',
|
|
path: 'created.txt',
|
|
});
|
|
expect(parseSseAttachment(res.write.mock.calls[1]).workspaceChange).toEqual({
|
|
profile: 'stateful',
|
|
operation: 'created',
|
|
path: 'created.txt',
|
|
});
|
|
await expect(artifactPromises[0]).resolves.toEqual(
|
|
expect.objectContaining({
|
|
workspaceChange: {
|
|
profile: 'stateful',
|
|
operation: 'created',
|
|
path: 'created.txt',
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not mark stateless file authoring outputs as stateful workspace changes', async () => {
|
|
res.headersSent = true;
|
|
processCodeOutput.mockResolvedValue({
|
|
file: {
|
|
file_id: 'fid-default',
|
|
filename: 'default.txt',
|
|
filepath: '/uploads/default.txt',
|
|
type: 'text/plain',
|
|
conversationId: 'thread789',
|
|
messageId: 'run-default',
|
|
toolCallId: 'tool-default',
|
|
status: 'ready',
|
|
},
|
|
});
|
|
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
const event = makeCodeExecutionEvent({
|
|
runId: 'run-default',
|
|
threadId: 'thread789',
|
|
toolCallId: 'tool-default',
|
|
fileId: 'fid-default',
|
|
name: 'default.txt',
|
|
toolName: 'create_file',
|
|
hostFileAuthoring: true,
|
|
created: true,
|
|
codeExecutionContext: {
|
|
baseUrl: 'https://code-default.example.com',
|
|
executionProfile: 'default',
|
|
},
|
|
});
|
|
await toolEndCallback({ output: event.output }, event.metadata);
|
|
await Promise.all(artifactPromises);
|
|
|
|
expect(res.write).toHaveBeenCalledTimes(1);
|
|
expect(parseSseAttachment(res.write.mock.calls[0]).workspaceChange).toBeUndefined();
|
|
});
|
|
|
|
it('preserves stateful workspace changes in Open Responses attachment events', async () => {
|
|
const { writeAttachmentEvent } = require('@librechat/api');
|
|
const { createResponsesToolEndCallback } = require('../callbacks');
|
|
res.headersSent = true;
|
|
res.writableEnded = false;
|
|
processCodeOutput.mockResolvedValue({
|
|
file: {
|
|
file_id: 'fid-responses',
|
|
filename: 'summary.csv',
|
|
filepath: '/uploads/summary.csv',
|
|
type: 'text/csv',
|
|
conversationId: 'thread789',
|
|
messageId: 'run-responses',
|
|
toolCallId: 'tool-responses',
|
|
status: 'pending',
|
|
},
|
|
finalize: jest.fn().mockResolvedValue({
|
|
file_id: 'fid-responses',
|
|
filename: 'summary.csv',
|
|
filepath: '/uploads/summary.csv',
|
|
type: 'text/csv',
|
|
conversationId: 'thread789',
|
|
messageId: 'run-responses',
|
|
status: 'ready',
|
|
}),
|
|
});
|
|
|
|
const tracker = { nextSequence: jest.fn().mockReturnValueOnce(1).mockReturnValueOnce(2) };
|
|
const toolEndCallback = createResponsesToolEndCallback({
|
|
req,
|
|
res,
|
|
tracker,
|
|
artifactPromises,
|
|
});
|
|
const event = makeCodeExecutionEvent({
|
|
runId: 'run-responses',
|
|
threadId: 'thread789',
|
|
toolCallId: 'tool-responses',
|
|
fileId: 'fid-responses',
|
|
name: 'summary.csv',
|
|
toolName: 'edit_file',
|
|
hostFileAuthoring: true,
|
|
created: false,
|
|
codeExecutionContext: {
|
|
baseUrl: 'https://code-stateful.example.com',
|
|
executionProfile: 'stateful',
|
|
},
|
|
});
|
|
event.output.artifact.path = 'reports/summary.csv';
|
|
|
|
await toolEndCallback({ output: event.output }, event.metadata);
|
|
await Promise.all(artifactPromises);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(writeAttachmentEvent).toHaveBeenCalledTimes(2);
|
|
expect(writeAttachmentEvent.mock.calls[0][2].workspaceChange).toEqual({
|
|
profile: 'stateful',
|
|
operation: 'updated',
|
|
path: 'reports/summary.csv',
|
|
});
|
|
expect(writeAttachmentEvent.mock.calls[1][2].workspaceChange).toEqual({
|
|
profile: 'stateful',
|
|
operation: 'updated',
|
|
path: 'reports/summary.csv',
|
|
});
|
|
await expect(artifactPromises[0]).resolves.toEqual(
|
|
expect.objectContaining({
|
|
workspaceChange: {
|
|
profile: 'stateful',
|
|
operation: 'updated',
|
|
path: 'reports/summary.csv',
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not process arbitrary user tool artifacts named create_file as code outputs', async () => {
|
|
res.headersSent = true;
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
const event = makeCodeExecutionEvent({
|
|
runId: 'run-user-create',
|
|
threadId: 'thread789',
|
|
toolCallId: 'tool-user-create',
|
|
fileId: 'fid-user-created',
|
|
name: 'created.txt',
|
|
toolName: 'create_file',
|
|
});
|
|
|
|
await toolEndCallback({ output: event.output }, event.metadata);
|
|
await Promise.all(artifactPromises);
|
|
|
|
expect(processCodeOutput).not.toHaveBeenCalled();
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects blocked generated bytes before queuing any persistence', async () => {
|
|
const blocked = new Error('Generated file content blocked');
|
|
preflightCodeOutputBatch.mockRejectedValueOnce(blocked);
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
const event = makeCodeExecutionEvent({
|
|
runId: 'run-blocked',
|
|
threadId: 'thread-1',
|
|
toolCallId: 'tool-blocked',
|
|
fileId: 'fid-blocked',
|
|
name: 'blocked.txt',
|
|
});
|
|
|
|
await expect(toolEndCallback({ output: event.output }, event.metadata)).rejects.toBe(blocked);
|
|
|
|
expect(processCodeOutput).not.toHaveBeenCalled();
|
|
expect(artifactPromises).toHaveLength(0);
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects blocked generated bytes in the Responses callback before persistence', async () => {
|
|
const blocked = new Error('Generated file content blocked');
|
|
preflightCodeOutputBatch.mockRejectedValueOnce(blocked);
|
|
const { createResponsesToolEndCallback } = require('../callbacks');
|
|
const callback = createResponsesToolEndCallback({
|
|
req,
|
|
res,
|
|
tracker: { nextSequence: jest.fn(() => 1) },
|
|
artifactPromises,
|
|
});
|
|
const event = makeCodeExecutionEvent({
|
|
runId: 'run-responses-blocked',
|
|
threadId: 'thread-1',
|
|
toolCallId: 'tool-responses-blocked',
|
|
fileId: 'fid-responses-blocked',
|
|
name: 'blocked.txt',
|
|
});
|
|
|
|
await expect(callback({ output: event.output }, event.metadata)).rejects.toBe(blocked);
|
|
|
|
expect(processCodeOutput).not.toHaveBeenCalled();
|
|
expect(artifactPromises).toHaveLength(0);
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('tool input validation marker', () => {
|
|
it('marks the streamed result and persisted content part out of band', async () => {
|
|
const { GraphEvents, createContentAggregator } = jest.requireActual('@librechat/agents');
|
|
const { getDefaultHandlers } = require('../callbacks');
|
|
const { contentParts, aggregateContent, stepMap } = createContentAggregator();
|
|
const toolInputValidationErrors = new Map([
|
|
['tool-1', { fieldPath: 'options[0].label', isLengthLimit: true }],
|
|
]);
|
|
const handlers = getDefaultHandlers({
|
|
res: { write: jest.fn() },
|
|
contentParts,
|
|
stepMap,
|
|
aggregateContent,
|
|
toolInputValidationErrors,
|
|
toolEndCallback: jest.fn(),
|
|
collectedUsage: [],
|
|
});
|
|
|
|
aggregateContent({
|
|
event: GraphEvents.ON_RUN_STEP,
|
|
data: {
|
|
id: 'step-1',
|
|
index: 0,
|
|
stepDetails: {
|
|
type: 'tool_calls',
|
|
tool_calls: [{ id: 'tool-1', name: 'ask_user_question', args: '{}' }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const data = {
|
|
result: {
|
|
id: 'step-1',
|
|
tool_call: {
|
|
id: 'tool-1',
|
|
name: 'ask_user_question',
|
|
output:
|
|
'Error processing tool: Received tool input did not match expected schema ' +
|
|
'→ at options[0].label',
|
|
},
|
|
},
|
|
};
|
|
|
|
await handlers[GraphEvents.ON_RUN_STEP_COMPLETED].handle(
|
|
GraphEvents.ON_RUN_STEP_COMPLETED,
|
|
data,
|
|
{ run_id: 'run-1', thread_id: 'conversation-1' },
|
|
);
|
|
|
|
expect(data.result.tool_call.inputValidationError).toBe(true);
|
|
expect(contentParts[0].tool_call.inputValidationError).toBe(true);
|
|
expect(contentParts[0].tool_call.stepId).toBe('step-1');
|
|
expect(toolInputValidationErrors.size).toBe(0);
|
|
});
|
|
|
|
it('does not mark successful output that resembles a schema error', async () => {
|
|
const { GraphEvents, createContentAggregator } = jest.requireActual('@librechat/agents');
|
|
const { getDefaultHandlers } = require('../callbacks');
|
|
const { contentParts, aggregateContent, stepMap } = createContentAggregator();
|
|
const handlers = getDefaultHandlers({
|
|
res: { write: jest.fn() },
|
|
contentParts,
|
|
stepMap,
|
|
aggregateContent,
|
|
toolInputValidationErrors: new Map(),
|
|
toolEndCallback: jest.fn(),
|
|
collectedUsage: [],
|
|
});
|
|
|
|
aggregateContent({
|
|
event: GraphEvents.ON_RUN_STEP,
|
|
data: {
|
|
id: 'step-1',
|
|
index: 0,
|
|
stepDetails: {
|
|
type: 'tool_calls',
|
|
tool_calls: [{ id: 'tool-1', name: 'ask_user_question', args: '{}' }],
|
|
},
|
|
},
|
|
});
|
|
|
|
const data = {
|
|
result: {
|
|
id: 'step-1',
|
|
tool_call: {
|
|
id: 'tool-1',
|
|
name: 'ask_user_question',
|
|
output: 'Received tool input did not match expected schema → at options[0].label',
|
|
},
|
|
},
|
|
};
|
|
|
|
await handlers[GraphEvents.ON_RUN_STEP_COMPLETED].handle(
|
|
GraphEvents.ON_RUN_STEP_COMPLETED,
|
|
data,
|
|
{ run_id: 'run-1', thread_id: 'conversation-1' },
|
|
);
|
|
|
|
expect(data.result.tool_call).not.toHaveProperty('inputValidationError');
|
|
expect(contentParts[0].tool_call).not.toHaveProperty('inputValidationError');
|
|
expect(contentParts[0].tool_call.stepId).toBe('step-1');
|
|
});
|
|
});
|
|
|
|
describe('isStreamWritable', () => {
|
|
/* Direct parametric coverage of the predicate that gates SSE writes
|
|
* in both the chat-completions and Open Responses callbacks. The
|
|
* existing deferred-preview tests cover this indirectly via the
|
|
* `writeAttachmentUpdate` writableEnded path; these tests pin down
|
|
* each individual branch so a future modification (e.g. adding a
|
|
* new condition) can't silently regress.
|
|
* (Comprehensive review NIT on PR #12957.) */
|
|
const { isStreamWritable } = require('../callbacks');
|
|
|
|
it('returns true when streamId is truthy regardless of res state', () => {
|
|
/* Resumable mode writes go to the job emitter; res state is
|
|
* irrelevant. Even a closed res with no headers should not block. */
|
|
expect(isStreamWritable(null, 'stream-1')).toBe(true);
|
|
expect(isStreamWritable({ headersSent: false, writableEnded: true }, 'stream-1')).toBe(true);
|
|
expect(isStreamWritable(undefined, 'stream-1')).toBe(true);
|
|
});
|
|
|
|
it('returns false when streamId is falsy and res is null/undefined', () => {
|
|
expect(isStreamWritable(null, null)).toBe(false);
|
|
expect(isStreamWritable(undefined, null)).toBe(false);
|
|
});
|
|
|
|
it('returns false when headers have not been sent yet', () => {
|
|
expect(isStreamWritable({ headersSent: false, writableEnded: false }, null)).toBe(false);
|
|
});
|
|
|
|
it('returns false when the stream has already ended', () => {
|
|
expect(isStreamWritable({ headersSent: true, writableEnded: true }, null)).toBe(false);
|
|
});
|
|
|
|
it('returns true on the happy path: headers sent, not ended, no streamId', () => {
|
|
expect(isStreamWritable({ headersSent: true, writableEnded: false }, null)).toBe(true);
|
|
});
|
|
});
|