LibreChat/api/server/services/Files/Code/process.spec.js
Danny Avila 6c6c72def7
🚀 feat: Decouple File Attachment Persistence from Preview Rendering (#12957)
* 🗂️ feat: add `status` lifecycle to file records for two-phase previews

Schema and model foundation for decoupling the agent's final response
from CPU-heavy office-format HTML extraction.

- `MongoFile.status: 'pending' | 'ready' | 'failed'` (indexed) and
  `previewError?: string` mirror the lifecycle: phase-1 emits the file
  record at `pending` so the response is unblocked; phase-2 transitions
  to `ready` (with text/textFormat) or `failed` (with previewError) in
  the background. Absent for legacy records — clients treat that as
  `ready` for back-compat.
- Mirror types added to `TFile` in data-provider so frontend cache
  consumers see the new fields.
- New `sweepOrphanedPreviews(maxAgeMs)` method on the file model
  recovers stale `pending` records left behind by a process restart
  mid-extraction; transitions them to `failed` with
  `previewError: 'orphaned'`. Cheap because `status` is indexed.

*  feat: two-phase code-execution preview flow (unblocks final response)

The agent's final response no longer waits on CPU-heavy office HTML
extraction. Phase-1 (download + storage save + DB record at
`status: 'pending'`) is awaited as before; phase-2 (extract +
`updateFile`) runs in the background with a hard 60s ceiling.

Three flows, all funneling through `processCodeOutput` and updated to
the new `{ file, finalize? }` return shape:

- `callbacks.js` (chat-completions + Open Responses streaming): emit
  the phase-1 attachment immediately (carries `status: 'pending'` for
  office buckets so the UI shows "preparing preview…"), then
  fire-and-forget `finalize()`. If the SSE stream is still open when
  phase-2 lands, push an `attachment` update event with the same
  `file_id` so the client merges over the placeholder in place.

- `tools.js` direct endpoint: same split — return the phase-1
  metadata immediately, run extraction in the background. Client
  polls for the resolved record.

`finalize()` wraps the existing 12s per-render timeout in a 60s outer
`withTimeout`. The HTML-or-null contract from #12934 is preserved:
office types that fail extraction transition to `status: 'failed'`
with `previewError: 'parser-error' | 'timeout'` rather than falling
back to plain text (would be an XSS vector).

Promises continue running after the HTTP response closes (Node
doesn't kill them). The boot-time orphan sweep covers the only case
that loses progress — actual process restart mid-extraction.

`primeFiles` annotates the agent's `toolContext` line for prior-turn
files: `(preview not yet generated)` for pending, `(preview
unavailable: <reason>)` for failed. The model can volunteer "you can
still download it" instead of pretending the preview is fine.

`hasOfficeHtmlPath` exported from `@librechat/api` so `processCodeOutput`
can decide whether a file expects a preview at all.

* 🔍 feat: `GET /api/files/:file_id/preview` endpoint and boot orphan sweep

- New `GET /api/files/:file_id/preview` route returns
  `{ status, text?, textFormat?, previewError? }`. The frontend's
  `useFilePreview` React Query hook polls this while phase-2 is in
  flight, then auto-stops on terminal status. ACL identical to the
  download route (reuses `fileAccess` middleware). Defaults `status`
  to `'ready'` for legacy records so back-compat is implicit.
  `text` only included when `status === 'ready'` and non-null —
  preserves the HTML-or-null security contract from #12934.

- `sweepOrphanedPreviews()` invoked on boot in both `server/index.js`
  and `server/experimental.js`. Recovers any `pending` records left
  behind by a process restart mid-extraction (the only case the
  in-process two-phase flow can't handle on its own). Fire-and-forget
  so a transient sweep failure doesn't block startup.

* 🖥️ feat: frontend two-phase preview consumer (polling + UI states)

Wires the React side to the new lifecycle so the user sees what's
happening with their file while phase-2 extraction runs in the
background and after the response stream closes.

- `useAttachmentHandler` upserts by `file_id` (was append-only) so
  the phase-2 SSE update event merges over the pending placeholder
  in place. Lightweight attachments without a `file_id`
  (web_search / file_search citations) keep the legacy append path.

- `useFilePreview(file_id)` React Query hook with
  `refetchInterval: (data) => data?.status === 'pending' ? 2500 : false`
  so polling auto-stops on the first terminal response without the
  caller having to flip `enabled`.

- `useAttachmentPreviewSync(attachment)` bridges polled data into
  `messageAttachmentsMap`. Polling enabled iff
  `status === 'pending' && isAnySubmitting` — per the design ask:
  active polling while the LLM is still generating, then quiet.
  Process-restart and post-stream cases are covered by polling on
  the next interaction.

- `Attachment.tsx` renders a small `PreviewStatusIndicator` (spinner +
  "Preparing preview…" for pending, alert icon + "Preview unavailable"
  for failed) inside `FileAttachment`. Download button stays fully
  functional in both states. Two new English locale keys.

- Data-provider scaffolding: `TFilePreview` type, `endpoints.filePreview`,
  `dataService.getFilePreview`, `QueryKeys.filePreview`.

* 🧪 fix: stub `useAttachmentPreviewSync` in pre-existing Attachment test mocks

The new `useAttachmentPreviewSync` hook is called unconditionally inside
`FileAttachment` (added in the prior commit). Two pre-existing test
files mock `~/hooks` to provide `useLocalize` only — the un-mocked
preview hook reference resolved to undefined and crashed render with
`(0 , _hooks.useAttachmentPreviewSync) is not a function` on the
Ubuntu/Windows CI runners.

Fix is local to the test mocks: add a no-op stub that returns
`{ status: 'ready' }` so the component renders the legacy chip path.
The two-phase preview behavior itself has its own dedicated suites
(`useAttachmentHandler.spec.tsx`, `useAttachmentPreviewSync.spec.tsx`).

* 🐛 fix: route phase-2 attachment update to current-run messageId

Codex P1 review on PR #12957. `processCodeOutput` intentionally
preserves the original DB `messageId` across cross-turn filename reuse
so `getCodeGeneratedFiles` can still trace a file back to the
assistant message that originally produced it. The phase-1 SSE emit
already routes by the current run's messageId — `processCodeOutput`
runtime-overlays it via `Object.assign(file, { messageId, toolCallId })`
and the callback writes `result.file` directly.

Phase-2 was passing the raw `updateFile` return through
`attachmentFromFileMetadata`, which read `messageId` straight off the
DB record. On a turn-N run that re-emitted a filename from turn-1
(e.g. agent writes `output.csv` again), the phase-2 SSE update
routed to `turn-1-msg` instead of `turn-N-msg`. Frontend's
`useAttachmentHandler` upserts under the wrong messageAttachmentsMap
slot — turn-N's pending chip stays stuck at "preparing preview…"
while turn-1's already-resolved attachment gets re-merged.

Fix: thread `runtimeMessageId` through `attachmentFromFileMetadata`
and pass `metadata.run_id` from the phase-2 emit site. Mirrors how
phase-1 sources its messageId. Tests cover the cross-turn reuse case
plus the writableEnded / null-finalize / no-finalize paths to lock
in the broader phase-2 emit contract.

* 🛠️ refactor: address codex audit findings (wire-shape parity, DRY, defensive catch)

Comprehensive audit on PR #12957. Resolves all valid findings:

- **MAJOR #1 — Wire-shape parity**: phase-1 ships the full `fileMetadata`
  record over SSE; phase-2 was using a tight `attachmentFromFileMetadata`
  projection. Drop the projection and have phase-2 spread `{...updated,
  messageId, toolCallId}` so both events match the long-standing
  legacy phase-1 shape clients depend on.

- **MAJOR #2 — DRY**: extract `runPhase2Finalize({ finalize, fileId,
  onResolved })` into `process.js` (alongside `processCodeOutput` whose
  contract it pairs with). Both `callbacks.js` paths and `tools.js`
  now flow through it. Single catch path eliminates divergence
  surface — the fix landed in 01704d4f0 (cross-turn messageId routing)
  was a symptom of this duplication risk.

- **MINOR #3 — JSDoc accuracy**: `finalizePreview`'s buffer is bounded
  by `fileSizeLimit`, not the 1MB extractor cap. Updated and added a
  note about peak heap from queued buffers.

- **MINOR #4 — Defensive catch**: `runPhase2Finalize`'s catch attempts
  a best-effort `updateFile({ status: 'failed', previewError:
  'unexpected' })` for the file_id, so a programming bug in
  `finalizePreview` doesn't leave the record stuck `'pending'` until
  the next boot-time orphan sweep.

- **NIT #6 — Stale PR refs**: 12952 → 12957 in 3 places.

- **NIT #7 — Schema bound**: `previewError` capped at `maxlength: 200`
  to prevent a future codepath from accidentally persisting a stack
  trace.

Skipped per audit verdict (non-blocking):
- #5 (memory pressure): documented in JSDoc; impl change was reviewer's
  "consider", not actionable.
- #8 (double DB query per poll): low cost, indexed by_id, polling is
  gated narrow.
- #9 (TAttachment cast): the union type is intentional; the casts are
  safe widening, refactoring TAttachment is invasive and out of scope.

Tests: 11 new (7 `runPhase2Finalize` unit tests covering happy path,
null-finalize, throws, double-fail, no-fileId, no-onResolved; +4
wire-shape parity assertions in the existing cross-turn test). 328
backend tests pass; 528 frontend tests pass; lint and typecheck clean.

* 🛡️ refactor: address codex P1+P2 + rename to drop phase-1/2 jargon

Codex round 2 review on PR #12957 caught two race conditions and one
recovery gap, all triggered by cross-turn filename reuse (`claimCodeFile`
intentionally returns the same `file_id` for the same
`(filename, conversationId)` across turns). Plus naming cleanup the
user requested — internal "phase 1 / phase 2" vocabulary leaks across
sprints, replace it everywhere with terms describing what's actually
happening.

P1 — stale render overwrites newer revision (process.js)
  Two turns reusing `output.csv` share a `file_id`. If turn-1's
  background render resolves AFTER turn-2's persist step, the
  unconditional `updateFile` writes turn-1's stale text/status over
  turn-2's pending placeholder. Fix: stamp a fresh `previewRevision`
  UUID on every emit, thread it through `finalizePreview`, and make
  the commit conditional via a new optional `extraFilter` argument
  on `updateFile` (`{ previewRevision: <expected> }`). The defensive
  `updateFile` in `runPreviewFinalize`'s catch uses the same guard
  so a programming error from an older render also can't override a
  newer turn.

P1 — stale React Query cache on pending remount (queries.ts)
  Same root cause from the frontend side. Cache key
  `[QueryKeys.filePreview, file_id]` may hold a prior turn's `'ready'`
  payload; with `refetchOnMount: false` and the polling gate on
  `pending`, polling never starts for the new placeholder. Fix:
  `useAttachmentHandler` invalidates that query whenever an attachment
  with a `file_id` arrives. Both initial-emit and update events
  trigger invalidation — uniform gate.

P2 — quick-restart orphans skipped by boot sweep (files.js)
  Boot `sweepOrphanedPreviews` uses a 5-min cutoff for multi-instance
  safety. A crash + restart inside the cutoff leaves `pending` records
  that never get touched again. Fix: lazy sweep inside the preview
  endpoint — if a polled record is `pending` and `updatedAt` is older
  than 5 min, mark it `failed:orphaned` on the spot before responding.
  Conditional on the same `updatedAt` we observed so a concurrent
  legitimate update wins. Cheap, bounded by user activity.

Naming cleanup
  - `runPhase2Finalize` → `runPreviewFinalize`
  - `PHASE_TWO_TIMEOUT_MS` → `PREVIEW_FINALIZE_TIMEOUT_MS`
  - All `phase-1` / `phase-2` / `two-phase` prose replaced with
    "the immediate emit", "the deferred render", "the persist step",
    "the deferred preview", etc. Skill-feature `phase 1/2` references
    (different feature) left alone.

Tests: 10 new (4 lazy-sweep × preview endpoint, 3 cache-invalidation ×
useAttachmentHandler, 3 extraFilter × updateFile data-schemas).
Backend 332/332, frontend 531/531, data-schemas 37/37, lint clean.

* 🛠️ refactor: address comprehensive review (round 3) — stale-cache MAJOR + 3 minors

Comprehensive review on PR #12957 caught a P1 follow-on bug from the
prior `invalidateQueries` fix, plus 3 maintainability findings.

MAJOR: stale React Query cache not actually fixed by `invalidateQueries`
  The previous fix called `invalidateQueries` to flush stale cached
  preview data on cross-turn filename reuse. But `useFilePreview` had
  `refetchOnMount: false`, which made the new observer read the
  stale-marked 'ready' data without refetching. The polling
  `refetchInterval` then evaluated against stale 'ready' → returned
  `false` → polling never started → user stuck on stale content.

  Fix (belt-and-suspenders):
    a) `useAttachmentHandler` switched to `removeQueries` — drops the
       cache entry entirely so the next mount has nothing to read and
       must fetch.
    b) `useFilePreview` no longer sets `refetchOnMount: false`, so the
       React Query default (`true`) kicks in — second line of defense
       if any future codepath observes stale data before the handler
       has a chance to evict.

MINOR: `finalizePreview` JSDoc missing `previewRevision` param
  Added with explanation of the conditional update guard.

MINOR: asymmetric stream-writable guard between SSE protocols
  Chat-completions delegated the gate to `writeAttachmentUpdate`;
  Open Responses inlined `!res.writableEnded && res.headersSent`.
  Extracted `isStreamWritable(res, streamId)` predicate; both paths
  + `writeAttachmentUpdate` now share the single source of truth.

NIT: `(data as Partial<TFile>).file_id` cast repeated 4 times
  Extracted to a `fileId` local at the top of the handler.

Tests: existing 9 invalidate-tests rewritten as remove-tests; +1 new
lock-in test asserts removeQueries is called and invalidateQueries
is NOT (regression guard against round-3 finding). 332 backend pass,
532 frontend pass, lint clean.

Skipped findings (deferred / acceptable):
- MINOR: post-submission pending state has no auto-recovery — the
  `isAnySubmitting` polling gate was the user's explicit design;
  LLM context surfaces failed/pending so the model can volunteer.
  Worth a follow-up if real users hit it.
- NIT: double DB query per preview poll — reviewer marked acceptable;
  changing `fileAccess` middleware is out of scope.

* 🛡️ test: address comprehensive review NITs (initial-emit guard + isStreamWritable coverage)

NIT — chat-completions initial emit skips writableEnded check
  The Open Responses initial emit was switched to use the new
  `isStreamWritable` predicate in the round-3 commit, but the
  chat-completions initial emit kept the older narrower check
  (`streamId || res.headersSent`). On a client disconnect mid-stream
  (`writableEnded === true`) it would still hit `res.write` and
  raise `ERR_STREAM_WRITE_AFTER_END` — caught by the outer IIFE
  catch but logged as noise. Switch this site to `isStreamWritable`
  too so both initial-emit paths share the same gate as the
  deferred update emits.

NIT — `isStreamWritable` not directly unit-tested
  The predicate was only covered indirectly via the deferred-preview
  SSE tests (writableEnded skip, headersSent check). Export from
  `callbacks.js` and add 5 parametric tests pinning down each branch
  (streamId truthy, res null, !headersSent, writableEnded, happy
  path) so a future condition addition can't silently regress.

* 🐛 fix: stuck "Preparing preview…" + inline the chip subtitle

Two related fixes for a stuck-spinner bug a user reported in manual
testing of PR #12957.

**Stuck spinner (the bug)**
The deferred preview render can complete a few seconds AFTER the SSE
stream closes (typical case: PPTX render finishes ~3s after the LLM
emits FINAL). When that happens, the SSE update is silently dropped
(`isStreamWritable` returns false on a closed stream) and polling is
the only recovery path.

The earlier polling gate was `status === 'pending' && isAnySubmitting`,
which mirrored the original design intent ("only query while the LLM
is still generating"). But `isAnySubmitting` flips false the moment
the model emits FINAL — milliseconds before the deferred render
commits. Polling never runs, the chip stays "Preparing preview…"
forever even though the DB has `status: 'ready'` with valid HTML.

Drop the `isAnySubmitting` part of the gate. `useFilePreview`'s
`refetchInterval` is already a function-form that returns `false` on
the first terminal response, so polling auto-stops within one tick of
resolution. The server-side render ceiling (60s) plus the lazy sweep
in the preview endpoint cap the worst case to ~24 polls per pending
attachment. Polling itself never blocks UX — the gate's purpose was
"don't waste cycles", and capping by terminal status is the correct
expression of that.

**Inline the chip subtitle (the visual)**
The previous design rendered "Preparing preview…" as a loose-feeling
spinner+text BELOW the file chip. The chip itself looked done while a
floating annotation said it wasn't.

`FileContainer` gains an optional `subtitle?: ReactNode` prop that
overrides the default file-type label. `Attachment.tsx` passes a
`PreviewStatusSubtitle` (spinner + "Preparing preview…" / alert +
"Preview unavailable") into that slot when the file's preview is
pending or failed. The chip footprint stays identical to its `'ready'`
form — just the second row swaps from "PowerPoint Presentation" to
the status indicator. No floating element, no layout shift.

Tests: regression test pinning down "polling stays enabled after the
LLM finishes" so a future revert can't reintroduce the stuck-spinner
bug. Existing FileContainer tests pass unchanged (subtitle override
is opt-in). 522 frontend tests pass; lint clean.

* 🐛 fix: deferred-preview survives reload + matches artifact card chrome

Fixes the remaining stuck-pending case after the polling gate fix: on
a reloaded conversation, message.attachments come from the DB frozen at
the immediate-persist `status: 'pending'`, but `messageAttachmentsMap`
is empty because no SSE handler ever fired for that messageId. Polling
now INSERTS a new live entry when no record matches the file_id, and
`useAttachments` merges live entries onto DB entries by file_id so the
resolved text/textFormat reach `artifactTypeForAttachment` and the
chip routes through the proper PanelArtifact card.

Also replaces the small file chip used during the pending state with
a PreviewPlaceholderCard that mirrors ToolArtifactCard chrome, so the
transition to the resolved PanelArtifact no longer reshapes the UI.

*  feat: auto-open panel when deferred preview resolves pending→ready

The legacy auto-open path is gated only on `isSubmitting`, so an
office-file preview that resolves *after* the SSE stream closes would
render in place but never auto-open the panel — even though that's
exactly the moment the result becomes meaningful to the user. Adds a
per-file_id one-shot signal that `useAttachmentPreviewSync` flips on
the pending→ready edge; `ToolArtifactCard` consumes it on mount and
auto-opens regardless of submission state. The signal is *only* set on
the actual transition (history loads of pre-resolved files don't
trigger it) and is consumed once (panel close + reopen on the same
card stays user-controlled).

* 🐛 fix: drop placeholder Terminal overlay + scope auto-open to fresh resolutions

Two fixes for issues spotted in manual testing of the deferred-preview
auto-open feature:

1. PreviewPlaceholderCard was passing `file={attachment}` to FilePreview,
   which triggered SourceIcon's Terminal overlay (`metadata.fileIdentifier`
   is set on every code-execution file). The artifact card itself doesn't
   show that overlay; the placeholder shouldn't either, so the
   pending→resolved transition is visually seamless.

2. The `previewJustResolved` flag flipped on every pending→ready
   transition observed by the polling hook — including stale-pending
   DB records that resolve via the first poll on a *history load*.
   Conversations whose immediate-persist snapshot left attachments at
   `status: 'pending'` would yank the panel open every revisit.
   Adds `mountedDuringStreamRef` to the hook (mirroring ToolArtifactCard)
   so the flag fires only when the hook itself was mounted during an
   active turn — preserving the pre-PR contract that the panel only
   auto-opens for results the user is actively waiting on, never for
   history.

* 🐛 fix: don't downgrade preview to failed when only the SSE emit throws

Codex P2 finding on PR #12957: the original chain placed `.catch` after
`.then(onResolved)`, so a throw inside `onResolved` (transport-side
errors — SSE write race after stream close, an emitter listener
throwing) would propagate into the finalize catch and persist
`status: 'failed'` / `previewError: 'unexpected'`. That surfaced
"preview unavailable" in the UI for a perfectly valid file, and
degraded next-turn LLM context to reflect a non-existent failure.

Wraps `onResolved` in its own try/catch so emit errors are logged but
do not affect the file's persisted status. Extraction success and
emit success are now independent: if extraction succeeds and
`finalizePreview` writes the terminal status, the polling layer / next
page load surfaces the resolved preview even if this turn's SSE emit
didn't land.

* 🛡️ fix: run boot-time orphan sweep under system tenant context

Codex P2 finding on PR #12957: `File` is tenant-isolated, so under
`TENANT_ISOLATION_STRICT=true` the boot-time `sweepOrphanedPreviews`
threw `[TenantIsolation] Query attempted without tenant context in
strict mode` and the recovery path silently failed every restart.
Stale `status: 'pending'` records would be stuck until a user happened
to poll the preview endpoint and trigger the lazy sweep — which only
covers the file the user is currently looking at, not the bulk
candidate set the boot sweep is designed to recover.

Wraps the sweep in `runAsSystem(...)` in both boot paths
(`api/server/index.js` and `api/server/experimental.js`) and pins the
contract with regression tests in `file.spec.ts` — one test asserts
the bare call throws under strict mode, the other asserts the
`runAsSystem`-wrapped call succeeds.

* 🧹 chore: trim verbose comments from previous commit

* 🧹 chore: address review findings (dead branch, lazy-sweep cutoff, stale JSDoc)

- finalizePreview: drop unreachable !isOfficeBucket branch (caller
  already gates on hasOfficeHtmlPath, so this path is always office)
- preview endpoint: drop lazy-sweep cutoff from 5min to 2min — anything
  past the 60s render ceiling is definitively orphaned, and per-request
  sweep can be tighter than the per-instance boot sweep
- strip stale `isSubmitting` references from JSDoc in 3 spots (the
  client-side gate was removed in 9a65840)

Skipped: function-length (#3) and client-side polling cap (#4) —
refactors without correctness/perf wins; remaining NITs.

* 🧹 fix: trim 1 query off pending polls + clear stale lifecycle on cross-shape updates

- Preview endpoint: reuse fileAccess middleware's record for the
  lifecycle check; only re-fetch with text on the terminal ready
  response. Cuts the typical poll lifecycle from 2(N+1) to N+1
  queries, since the vast majority of polls hit while pending and
  don't need text at all.
- processCodeOutput non-office branch: explicitly null out status,
  previewError, previewRevision (codex P2). Without this, an update at
  the same (filename, conversationId) where the prior emit was an
  office file leaves stale lifecycle fields and the client renders
  the wrong state for the now non-office artifact.
- Tests: rewire preview.spec mocks for the new shape, add boundary
  test pinning the 2min cutoff, add regression test for the
  cross-shape update.

* 🐛 fix: keep polling on transient errors but cap permanently-broken endpoint

Codex P2: the previous `data?.status === 'pending' ? 2500 : false` gate
killed polling on the first transient error. With `retry: false`, a 500
left `data` undefined, the callback returned false, and the chip was
stuck "Preparing preview…" forever — exactly the bug the polling layer
was supposed to recover from.

Inverts the gate: stop on terminal success (`ready`/`failed`) or after
5 consecutive errors. Transient errors keep retrying; a permanently
broken endpoint caps at ~12.5s instead of polling forever. Predicate
extracted as `previewRefetchInterval` for direct unit testing without
fighting React Query's timer machinery.

*  feat: render pending-preview files in their own row

Pending deferred-preview chips now bucket into a separate row above
the resolved attachments — reads as "this is still happening" rather
than mixing with completed downloads. Once status flips to ready, the
chip re-buckets into panelArtifacts; failed re-buckets into the file
row alongside other downloads.

* 🎨 fix: render pending-preview chips in the panel-artifact row, not the file row

Previous bucketing put pending chips in the file row (since
`artifactTypeForAttachment` returns null for empty-text records). The
pending placeholder is a future panel artifact — sharing the row keeps
the chip in place when it resolves instead of jumping rows.

Plain files still get their own row.

* 🐛 fix: phase-1 SSE replay must not regress a resolved attachment

Codex P1: useEventHandlers.finalHandler iterates
responseMessage.attachments at stream end and dispatches each through
the attachment handler. Those records are the immediate-persist
snapshot (status:pending, text:null) — if a deferred update has
already moved the same file_id to ready/failed, the existing merge
let the pending fields win and downgraded the resolved record. Result:
chip flickers back to pending and polling restarts until the lazy
sweep corrects.

Pin the terminal lifecycle fields (status, text, textFormat,
previewError) when existing is ready/failed and incoming is pending.
Other field updates still go through.

* 🐛 fix: track preview-poll error cap outside React Query state

Codex P2: the previous cap relied on `query.state.fetchFailureCount`,
but React Query v4's reducer resets that to 0 on every fetch dispatch
(the `'fetch'` action). With `retry: false`, each failed poll left
count at 1 and the next dispatch reset it back to 0, so the `>= 5`
branch never fired and a permanently-broken endpoint polled forever.

Track consecutive errors in a module-level Map keyed by file_id,
incremented in a thin `fetchFilePreview` wrapper around the data
service call. The Map is cleared on success and on cap-stop, so
memory is bounded by in-flight pending file_ids per session.
2026-05-06 03:04:19 -04:00

1568 lines
64 KiB
JavaScript

// Configurable file size limit for tests - use a getter so it can be changed per test
const fileSizeLimitConfig = { value: 20 * 1024 * 1024 }; // Default 20MB
// Mock librechat-data-provider with configurable file size limit
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
mergeFileConfig: jest.fn((config) => {
const merged = actual.mergeFileConfig(config);
// Override the serverFileSizeLimit with our test value
return {
...merged,
get serverFileSizeLimit() {
return fileSizeLimitConfig.value;
},
};
}),
getEndpointFileConfig: jest.fn((options) => {
const config = actual.getEndpointFileConfig(options);
// Override fileSizeLimit with our test value
return {
...config,
get fileSizeLimit() {
return fileSizeLimitConfig.value;
},
};
}),
};
});
const { FileContext } = require('librechat-data-provider');
// Mock uuid
jest.mock('uuid', () => ({
v4: jest.fn(() => 'mock-uuid-1234'),
}));
// Mock axios — process.js now uses createAxiosInstance() from @librechat/api
const mockAxios = jest.fn();
mockAxios.post = jest.fn();
mockAxios.isAxiosError = jest.fn(() => false);
const mockClassifyCodeArtifact = jest.fn(() => 'other');
const mockExtractCodeArtifactText = jest.fn(async () => null);
const mockGetExtractedTextFormat = jest.fn((_name, _mime, text) => (text == null ? null : 'text'));
/* `hasOfficeHtmlPath` gates the persist-then-render split: when true, processCodeOutput
* returns `{ file, finalize }` with the file persisted at `status: 'pending'`
* and `finalize` runs the background extraction. Default false here so the
* legacy single-phase tests below (txt/png/etc) exercise the inline path
* unchanged. The dedicated office/finalize describe block toggles it on. */
const mockHasOfficeHtmlPath = jest.fn(() => false);
/* Pass-through `withTimeout`: tests don't drive timeouts here (those live
* in promise.spec.ts and the finalizePreview unit tests below). */
const passthroughWithTimeout = async (promise) => promise;
jest.mock('@librechat/api', () => {
const http = require('http');
const https = require('https');
return {
logAxiosError: jest.fn(),
getBasePath: jest.fn(() => ''),
sanitizeArtifactPath: jest.fn((name) => name),
flattenArtifactPath: jest.fn((name) => name.replace(/\//g, '__')),
createAxiosInstance: jest.fn(() => mockAxios),
withTimeout: (...args) => passthroughWithTimeout(...args),
hasOfficeHtmlPath: (...args) => mockHasOfficeHtmlPath(...args),
/**
* Arrow-function indirection (vs. a direct `jest.fn()` reference) so
* tests can per-case `mockReturnValueOnce` / `mockImplementationOnce`
* on `mockClassifyCodeArtifact` / `mockExtractCodeArtifactText`.
* `jest.mock(...)` is hoisted above the outer `const` declarations
* at parse time, so a direct reference here would capture
* `undefined`; the arrow defers the binding to call time. The
* direct-`jest.fn()` mocks below stay constant per file.
*/
classifyCodeArtifact: (...args) => mockClassifyCodeArtifact(...args),
extractCodeArtifactText: (...args) => mockExtractCodeArtifactText(...args),
/* `processCodeOutput` derives the `textFormat` trust flag for
* `IMongoFile` from this helper — Codex P1 review on PR #12934.
* The mock returns 'text' for non-null extractor output and null
* otherwise so the downstream `file.textFormat` field is set to
* a believable shape without modeling the office-HTML branch
* (the dispatcher under test isn't exercising that path). Per-
* test overrides via `mockGetExtractedTextFormat.mockReturnValue`
* if a case needs to assert the 'html' value. */
getExtractedTextFormat: (...args) => mockGetExtractedTextFormat(...args),
codeServerHttpAgent: new http.Agent({ keepAlive: false }),
codeServerHttpsAgent: new https.Agent({ keepAlive: false }),
};
});
jest.mock('@librechat/data-schemas', () => ({
logger: {
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('@librechat/agents', () => ({
getCodeBaseURL: jest.fn(() => 'https://code-api.example.com'),
}));
// Mock models
const mockClaimCodeFile = jest.fn();
jest.mock('~/models', () => ({
createFile: jest.fn().mockResolvedValue({}),
getFiles: jest.fn(),
updateFile: jest.fn(),
claimCodeFile: (...args) => mockClaimCodeFile(...args),
}));
// Mock permissions (must be before process.js import)
jest.mock('~/server/services/Files/permissions', () => ({
filterFilesByAgentAccess: jest.fn((options) => Promise.resolve(options.files)),
}));
// Mock strategy functions
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
// Mock convertImage
jest.mock('~/server/services/Files/images/convert', () => ({
convertImage: jest.fn(),
}));
// Mock determineFileType
jest.mock('~/server/utils', () => ({
determineFileType: jest.fn(),
}));
const http = require('http');
const https = require('https');
const { createFile, getFiles } = require('~/models');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { convertImage } = require('~/server/services/Files/images/convert');
const { determineFileType } = require('~/server/utils');
const { logger } = require('@librechat/data-schemas');
const { codeServerHttpAgent, codeServerHttpsAgent } = require('@librechat/api');
const { processCodeOutput, getSessionInfo, readSandboxFile, primeFiles } = require('./process');
describe('Code Process', () => {
const mockReq = {
user: { id: 'user-123' },
config: {
fileConfig: {},
fileStrategy: 'local',
imageOutputType: 'webp',
},
};
const baseParams = {
req: mockReq,
id: 'file-id-123',
name: 'test-file.txt',
apiKey: 'test-api-key',
toolCallId: 'tool-call-123',
conversationId: 'conv-123',
messageId: 'msg-123',
session_id: 'session-123',
};
beforeEach(() => {
jest.clearAllMocks();
// Default mock: atomic claim returns a new file record (no existing file)
mockClaimCodeFile.mockResolvedValue({
file_id: 'mock-uuid-1234',
user: 'user-123',
});
getFiles.mockResolvedValue(null);
createFile.mockResolvedValue({});
getStrategyFunctions.mockReturnValue({
saveBuffer: jest.fn().mockResolvedValue('/uploads/mock-file-path.txt'),
});
determineFileType.mockResolvedValue({ mime: 'text/plain' });
});
describe('atomic file claim (via processCodeOutput)', () => {
it('should reuse file_id from existing record via atomic claim', async () => {
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-file-id',
filename: 'test-file.txt',
usage: 2,
createdAt: '2024-01-01T00:00:00.000Z',
});
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const { file: result } = await processCodeOutput(baseParams);
expect(mockClaimCodeFile).toHaveBeenCalledWith({
filename: 'test-file.txt',
conversationId: 'conv-123',
file_id: 'mock-uuid-1234',
user: 'user-123',
});
expect(result.file_id).toBe('existing-file-id');
expect(result.usage).toBe(3);
expect(result.createdAt).toBe('2024-01-01T00:00:00.000Z');
});
it('should create new file when no existing file found', async () => {
mockClaimCodeFile.mockResolvedValue({
file_id: 'mock-uuid-1234',
user: 'user-123',
});
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const { file: result } = await processCodeOutput(baseParams);
expect(result.file_id).toBe('mock-uuid-1234');
expect(result.usage).toBe(1);
});
});
describe('processCodeOutput', () => {
describe('image file processing', () => {
it('should process image files using convertImage', async () => {
const imageParams = { ...baseParams, name: 'chart.png' };
const imageBuffer = Buffer.alloc(500);
mockAxios.mockResolvedValue({ data: imageBuffer });
const convertedFile = {
filepath: '/uploads/converted-image.webp',
bytes: 400,
};
convertImage.mockResolvedValue(convertedFile);
const { file: result } = await processCodeOutput(imageParams);
expect(convertImage).toHaveBeenCalledWith(
mockReq,
imageBuffer,
'high',
'mock-uuid-1234.png',
);
expect(result.type).toBe('image/webp');
expect(result.context).toBe(FileContext.execute_code);
expect(result.filename).toBe('chart.png');
});
it('should update existing image file with cache-busted filepath', async () => {
const imageParams = { ...baseParams, name: 'chart.png' };
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-img-id',
usage: 1,
createdAt: '2024-01-01T00:00:00.000Z',
});
const imageBuffer = Buffer.alloc(500);
mockAxios.mockResolvedValue({ data: imageBuffer });
convertImage.mockResolvedValue({ filepath: '/images/user-123/existing-img-id.webp' });
const { file: result } = await processCodeOutput(imageParams);
expect(convertImage).toHaveBeenCalledWith(
mockReq,
imageBuffer,
'high',
'existing-img-id.png',
);
expect(result.file_id).toBe('existing-img-id');
expect(result.usage).toBe(2);
expect(result.filepath).toMatch(/^\/images\/user-123\/existing-img-id\.webp\?v=\d+$/);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Updating existing file'),
);
});
});
describe('non-image file processing', () => {
it('should process non-image files using saveBuffer', async () => {
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved-file.txt');
getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer });
determineFileType.mockResolvedValue({ mime: 'text/plain' });
const { file: result } = await processCodeOutput(baseParams);
expect(mockSaveBuffer).toHaveBeenCalledWith({
userId: 'user-123',
buffer: smallBuffer,
fileName: 'mock-uuid-1234__test-file.txt',
basePath: 'uploads',
});
expect(result.type).toBe('text/plain');
expect(result.filepath).toBe('/uploads/saved-file.txt');
expect(result.bytes).toBe(100);
});
it('preserves nested directory paths in the DB record while flattening the storage key', async () => {
/* Regression test for the silent-data-loss path: when codeapi reports a
* file with a nested name like "test_folder/test_file.txt", LibreChat
* used to feed it through `sanitizeFilename` (basename-only), which
* persisted "test_file.txt" to the DB and made the file un-locatable on
* the next prime() (cat /mnt/data/test_folder/test_file.txt would
* 404). The fix: keep the path on the DB record (so primeFiles can
* place it back at the same nested location), but flatten it for the
* storage key (saveBuffer strategies key by single component). */
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved.txt');
getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer });
const { file: result } = await processCodeOutput({
...baseParams,
name: 'test_folder/test_file.txt',
});
// Storage key flattens `/` to `__` so on-disk strategies don't
// accidentally create real subdirectories under uploads/.
expect(mockSaveBuffer).toHaveBeenCalledWith(
expect.objectContaining({
fileName: 'mock-uuid-1234__test_folder__test_file.txt',
}),
);
// DB row keeps the nested path verbatim — that's what primeFiles
// ships back to the sandbox on the next turn.
expect(result.filename).toBe('test_folder/test_file.txt');
// Claim is also keyed by the path-preserving name so the
// (filename, conversationId) compound key stays consistent.
expect(mockClaimCodeFile).toHaveBeenCalledWith(
expect.objectContaining({ filename: 'test_folder/test_file.txt' }),
);
});
it('passes a NAME_MAX-aware budget to flattenArtifactPath when composing the storage key', async () => {
/* Codex review P1: per-segment caps on the path-preserving form
* aren't enough — once the segments are joined with `__` for the
* storage key, deeply-nested or moderately long paths can still
* exceed filesystem NAME_MAX (255) and cause `ENAMETOOLONG` in
* saveBuffer. processCodeOutput must pass a file_id-aware budget
* to flattenArtifactPath so the cap holds end-to-end. The unit
* tests in `packages/api/src/utils/files.spec.ts` cover the
* truncation logic itself; this test covers the integration. */
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved.bin');
getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer });
const flattenSpy = require('@librechat/api').flattenArtifactPath;
flattenSpy.mockClear();
await processCodeOutput({ ...baseParams, name: 'a/b/c.csv' });
// The handler should call flattenArtifactPath with both the
// safeName AND a budget = NAME_MAX (255) minus the prefix
// (`${file_id}__`). file_id mock is `mock-uuid-1234` (14 chars),
// so the budget should be 255 - 14 - 2 = 239.
expect(flattenSpy).toHaveBeenCalledWith(expect.any(String), 239);
});
it('passes the basename (not the full nested path) to classifyCodeArtifact and extractCodeArtifactText', async () => {
/* Codex review P2: with the path-preserving sanitizer, `safeName`
* can be a nested string like `reports.v1/Makefile`. The
* classifier reads `extensionOf` against the full string, which
* sees `.v1/Makefile` after the dotted-dir's first dot and
* misclassifies the file as `other` (so text extraction is
* skipped). Pass `path.basename(safeName)` instead. */
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved.txt');
getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer });
await processCodeOutput({
...baseParams,
name: 'reports.v1/Makefile',
});
expect(mockClassifyCodeArtifact).toHaveBeenCalledWith('Makefile', expect.any(String));
expect(mockExtractCodeArtifactText).toHaveBeenCalledWith(
expect.any(Buffer),
'Makefile',
expect.any(String),
expect.any(String),
);
});
it('should detect MIME type from buffer', async () => {
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
determineFileType.mockResolvedValue({ mime: 'application/pdf' });
const { file: result } = await processCodeOutput({ ...baseParams, name: 'document.pdf' });
expect(determineFileType).toHaveBeenCalledWith(smallBuffer, true);
expect(result.type).toBe('application/pdf');
});
it('should fallback to application/octet-stream for unknown types', async () => {
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
determineFileType.mockResolvedValue(null);
const { file: result } = await processCodeOutput({ ...baseParams, name: 'unknown.xyz' });
expect(result.type).toBe('application/octet-stream');
});
});
describe('inline text extraction', () => {
it('should populate text on the file when extractor returns content', async () => {
const buffer = Buffer.from('hello world\n', 'utf-8');
mockAxios.mockResolvedValue({ data: buffer });
determineFileType.mockResolvedValue({ mime: 'text/plain' });
mockClassifyCodeArtifact.mockReturnValueOnce('utf8-text');
mockExtractCodeArtifactText.mockResolvedValueOnce('hello world\n');
const { file: result } = await processCodeOutput({ ...baseParams, name: 'note.txt' });
expect(mockClassifyCodeArtifact).toHaveBeenCalledWith('note.txt', 'text/plain');
expect(mockExtractCodeArtifactText).toHaveBeenCalledWith(
buffer,
'note.txt',
'text/plain',
'utf8-text',
);
expect(result.text).toBe('hello world\n');
expect(createFile).toHaveBeenCalledWith(
expect.objectContaining({ text: 'hello world\n' }),
true,
);
});
it('should set text to null when extractor returns null so updates clear stale values', async () => {
const buffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: buffer });
determineFileType.mockResolvedValue({ mime: 'application/octet-stream' });
mockClassifyCodeArtifact.mockReturnValueOnce('other');
mockExtractCodeArtifactText.mockResolvedValueOnce(null);
const { file: result } = await processCodeOutput({ ...baseParams, name: 'archive.zip' });
expect(result.text).toBeNull();
const createCall = createFile.mock.calls[0][0];
expect(createCall.text).toBeNull();
});
it('should overwrite a previously-stored text value when re-emitting a now-binary file', async () => {
// Same filename + conversationId already has a stored text value;
// claimCodeFile returns the existing record (isUpdate path).
mockClaimCodeFile.mockResolvedValueOnce({
file_id: 'existing-id',
filename: 'output.bin',
usage: 1,
createdAt: '2024-01-01T00:00:00.000Z',
});
const binaryBuffer = Buffer.from([0x00, 0xff, 0x00, 0xff]);
mockAxios.mockResolvedValue({ data: binaryBuffer });
determineFileType.mockResolvedValue({ mime: 'application/octet-stream' });
mockClassifyCodeArtifact.mockReturnValueOnce('other');
mockExtractCodeArtifactText.mockResolvedValueOnce(null);
await processCodeOutput({ ...baseParams, name: 'output.bin' });
// null (not omitted) so $set clears any prior `text` value.
const createCall = createFile.mock.calls[0][0];
expect(createCall).toHaveProperty('text', null);
});
it('should not invoke text extraction for image files', async () => {
const imageBuffer = Buffer.alloc(500);
mockAxios.mockResolvedValue({ data: imageBuffer });
convertImage.mockResolvedValue({ filepath: '/uploads/x.webp', bytes: 400 });
await processCodeOutput({ ...baseParams, name: 'chart.png' });
expect(mockClassifyCodeArtifact).not.toHaveBeenCalled();
expect(mockExtractCodeArtifactText).not.toHaveBeenCalled();
});
it('clears deferred-preview lifecycle fields so a prior office record at this file_id stops looking pending', async () => {
/* Codex P2: same (filename, conversationId) was previously an
* office artifact, leaving status/previewError/previewRevision
* populated. The non-office update must reset them or the
* client renders the wrong state for the now non-office file. */
mockClaimCodeFile.mockResolvedValueOnce({
file_id: 'reused-id',
filename: 'output.txt',
usage: 1,
createdAt: '2024-01-01T00:00:00.000Z',
});
mockAxios.mockResolvedValue({ data: Buffer.from('hello') });
determineFileType.mockResolvedValue({ mime: 'text/plain' });
mockClassifyCodeArtifact.mockReturnValueOnce('text');
mockHasOfficeHtmlPath.mockReturnValueOnce(false);
mockExtractCodeArtifactText.mockResolvedValueOnce('hello');
await processCodeOutput({ ...baseParams, name: 'output.txt' });
const createCall = createFile.mock.calls[0][0];
expect(createCall).toHaveProperty('status', null);
expect(createCall).toHaveProperty('previewError', null);
expect(createCall).toHaveProperty('previewRevision', null);
});
});
describe('file size limit enforcement', () => {
it('should fallback to download URL when file exceeds size limit', async () => {
// Set a small file size limit for this test
fileSizeLimitConfig.value = 1000; // 1KB limit
const largeBuffer = Buffer.alloc(5000); // 5KB - exceeds 1KB limit
mockAxios.mockResolvedValue({ data: largeBuffer });
const { file: result } = await processCodeOutput(baseParams);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('exceeds size limit'));
expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123');
expect(result.expiresAt).toBeDefined();
// Should not call createFile for oversized files (fallback path)
expect(createFile).not.toHaveBeenCalled();
// Reset to default for other tests
fileSizeLimitConfig.value = 20 * 1024 * 1024;
});
});
describe('fallback behavior', () => {
it('should fallback to download URL when saveBuffer is not available', async () => {
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
getStrategyFunctions.mockReturnValue({ saveBuffer: null });
const { file: result } = await processCodeOutput(baseParams);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('saveBuffer not available'),
);
expect(result.filepath).toContain('/api/files/code/download/');
expect(result.filename).toBe('test-file.txt');
});
it('should fallback to download URL on axios error', async () => {
mockAxios.mockRejectedValue(new Error('Network error'));
const { file: result } = await processCodeOutput(baseParams);
expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123');
expect(result.conversationId).toBe('conv-123');
expect(result.messageId).toBe('msg-123');
expect(result.toolCallId).toBe('tool-call-123');
});
});
describe('usage counter increment', () => {
it('should set usage to 1 for new files', async () => {
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const { file: result } = await processCodeOutput(baseParams);
expect(result.usage).toBe(1);
});
it('should increment usage for existing files', async () => {
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-id',
usage: 5,
createdAt: '2024-01-01',
});
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const { file: result } = await processCodeOutput(baseParams);
expect(result.usage).toBe(6);
});
it('should handle existing file with undefined usage', async () => {
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-id',
createdAt: '2024-01-01',
});
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const { file: result } = await processCodeOutput(baseParams);
expect(result.usage).toBe(1);
});
});
describe('metadata and file properties', () => {
it('should include fileIdentifier in metadata', async () => {
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const { file: result } = await processCodeOutput(baseParams);
expect(result.metadata).toEqual({
fileIdentifier: 'session-123/file-id-123',
});
});
it('should set correct context for code-generated files', async () => {
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const { file: result } = await processCodeOutput(baseParams);
expect(result.context).toBe(FileContext.execute_code);
});
it('should include toolCallId and messageId in result', async () => {
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const { file: result } = await processCodeOutput(baseParams);
expect(result.toolCallId).toBe('tool-call-123');
expect(result.messageId).toBe('msg-123');
});
it('should call createFile with upsert enabled', async () => {
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
await processCodeOutput(baseParams);
expect(createFile).toHaveBeenCalledWith(
expect.objectContaining({
file_id: 'mock-uuid-1234',
context: FileContext.execute_code,
}),
true, // upsert flag
);
});
});
describe('persistedMessageId (regression for cross-turn priming)', () => {
/**
* `getCodeGeneratedFiles` filters by `messageId IN <thread message ids>`
* to scope files to the current branch. If `processCodeOutput` overwrote
* the file's `messageId` with the current run's id on every update, a
* file re-touched by a later turn (e.g. a failed read attempt that
* re-shells the same filename) would lose its link to the assistant
* message that originally produced it. Subsequent turns then can't find
* it via `getCodeGeneratedFiles`, the priming chain has nothing to seed,
* and the model thinks its own prior-turn artifact disappeared.
*
* Contract:
* - On UPDATE (claimCodeFile returned an existing record): the persisted
* `messageId` is `claimed.messageId` (preserved). Falls back to the
* current run's `messageId` when the existing record predates the
* `messageId` field (legacy data).
* - On CREATE (new file): the persisted `messageId` is the current run's.
* - The runtime return value ALWAYS uses the current run's `messageId`
* via `Object.assign(file, { messageId, toolCallId })` so the artifact
* attaches to the correct tool_call in the live response.
*/
/**
* `processCodeOutput` mutates the file object after `createFile` returns
* (`Object.assign(file, { messageId, toolCallId })`) so the runtime
* caller sees the live messageId on the response. Reading
* `createFile.mock.calls[0][0]` directly would therefore reflect the
* post-mutation state because JS captures by reference. To assert
* what was actually PERSISTED, snapshot the args at call time.
*/
function snapshotCreateFileArgs() {
const snapshots = [];
createFile.mockImplementation(async (file) => {
snapshots.push({ ...file });
return {};
});
return snapshots;
}
it('preserves the original messageId in the persisted record on UPDATE', async () => {
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-id',
filename: 'sentinel.txt',
usage: 1,
createdAt: '2024-01-01T00:00:00.000Z',
messageId: 'turn-1-original-msg',
});
const persisted = snapshotCreateFileArgs();
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
await processCodeOutput({
...baseParams,
name: 'sentinel.txt',
messageId: 'turn-2-current-run-msg',
});
expect(persisted[0].messageId).toBe('turn-1-original-msg');
});
it('falls back to current run messageId on UPDATE when claimed.messageId is undefined (legacy record)', async () => {
// Legacy record predates the persistedMessageId tracking.
mockClaimCodeFile.mockResolvedValue({
file_id: 'legacy-id',
filename: 'legacy.txt',
usage: 1,
createdAt: '2024-01-01T00:00:00.000Z',
// messageId intentionally absent
});
const persisted = snapshotCreateFileArgs();
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
await processCodeOutput({
...baseParams,
name: 'legacy.txt',
messageId: 'turn-N-current-run-msg',
});
expect(persisted[0].messageId).toBe('turn-N-current-run-msg');
});
it('uses the current run messageId on CREATE (no claimed record)', async () => {
mockClaimCodeFile.mockResolvedValue({
file_id: 'mock-uuid-1234',
user: 'user-123',
});
const persisted = snapshotCreateFileArgs();
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
await processCodeOutput({
...baseParams,
messageId: 'turn-1-create-msg',
});
expect(persisted[0].messageId).toBe('turn-1-create-msg');
});
it('returns the CURRENT run messageId in the runtime response even on UPDATE (artifact attribution)', async () => {
// The persisted DB record keeps the original messageId, but the
// returned object surfaces the live messageId so the artifact lands
// on the correct tool_call in this run's response.
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-id',
filename: 'sentinel.txt',
usage: 1,
createdAt: '2024-01-01T00:00:00.000Z',
messageId: 'turn-1-original-msg',
});
const persisted = snapshotCreateFileArgs();
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
const { file: result } = await processCodeOutput({
...baseParams,
name: 'sentinel.txt',
messageId: 'turn-2-current-run-msg',
});
// DB preserves original
expect(persisted[0].messageId).toBe('turn-1-original-msg');
// Runtime return surfaces the live (current) messageId
expect(result.messageId).toBe('turn-2-current-run-msg');
});
it('preserves the original messageId on UPDATE for image files too', async () => {
// Same contract as text files; the image branch builds its own file
// record and would silently regress if the ternary diverged there.
mockClaimCodeFile.mockResolvedValue({
file_id: 'existing-img',
filename: 'chart.png',
usage: 1,
createdAt: '2024-01-01T00:00:00.000Z',
messageId: 'turn-1-image-msg',
});
const persisted = snapshotCreateFileArgs();
const imageBuffer = Buffer.alloc(500);
mockAxios.mockResolvedValue({ data: imageBuffer });
convertImage.mockResolvedValue({
filepath: '/uploads/chart.webp',
bytes: 400,
});
await processCodeOutput({
...baseParams,
name: 'chart.png',
messageId: 'turn-2-current-img-msg',
});
expect(persisted[0].messageId).toBe('turn-1-image-msg');
});
});
describe('socket pool isolation', () => {
it('should pass dedicated keepAlive:false agents to axios for processCodeOutput', async () => {
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
await processCodeOutput(baseParams);
const callConfig = mockAxios.mock.calls[0][0];
expect(callConfig.httpAgent).toBe(codeServerHttpAgent);
expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent);
expect(callConfig.httpAgent).toBeInstanceOf(http.Agent);
expect(callConfig.httpsAgent).toBeInstanceOf(https.Agent);
expect(callConfig.httpAgent.keepAlive).toBe(false);
expect(callConfig.httpsAgent.keepAlive).toBe(false);
});
it('should pass dedicated keepAlive:false agents to axios for getSessionInfo', async () => {
mockAxios.mockResolvedValue({
data: [{ name: 'sess/fid', lastModified: new Date().toISOString() }],
});
await getSessionInfo('sess/fid', 'api-key');
const callConfig = mockAxios.mock.calls[0][0];
expect(callConfig.httpAgent).toBe(codeServerHttpAgent);
expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent);
expect(callConfig.httpAgent.keepAlive).toBe(false);
expect(callConfig.httpsAgent.keepAlive).toBe(false);
});
});
describe('deferred-preview flow (office-bucket files)', () => {
/* Office-bucket files (DOCX/XLSX/etc.) split into:
* the initial emit (await): persist `text: null, status: 'pending'`,
* return `{ file, finalize }` so the caller can ship the
* attachment to the client immediately;
* the deferred render (background): finalize() invokes the extractor and
* transitions the record to 'ready' (with text/textFormat) or
* 'failed' (with previewError). The agent's final response
* never blocks on the deferred render.
*
* The `hasOfficeHtmlPath` mock is the gate. Other tests keep it
* at `false` (legacy single-phase path); we flip it on here. */
const { updateFile } = require('~/models');
beforeEach(() => {
mockHasOfficeHtmlPath.mockReturnValue(true);
updateFile.mockResolvedValue({ file_id: 'mock-uuid-1234', status: 'ready' });
});
afterEach(() => {
mockHasOfficeHtmlPath.mockReturnValue(false);
});
it('persists the initial emit with status:pending and text:null, deferring extraction', async () => {
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
determineFileType.mockResolvedValue({
mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const result = await processCodeOutput({ ...baseParams, name: 'data.xlsx' });
expect(result.file).toMatchObject({
file_id: 'mock-uuid-1234',
filename: 'data.xlsx',
status: 'pending',
text: null,
textFormat: null,
});
expect(typeof result.finalize).toBe('function');
// Extractor MUST NOT have been called yet — that's deferred preview work.
expect(mockExtractCodeArtifactText).not.toHaveBeenCalled();
// Persisted record with the pending status.
expect(createFile).toHaveBeenCalledWith(
expect.objectContaining({ status: 'pending', text: null, textFormat: null }),
true,
);
});
it('finalize() runs the extractor, transitions to ready with text+textFormat on success', async () => {
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
determineFileType.mockResolvedValue({
mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
mockExtractCodeArtifactText.mockResolvedValueOnce('<table><tr><td>1</td></tr></table>');
mockGetExtractedTextFormat.mockReturnValueOnce('html');
const { finalize } = await processCodeOutput({ ...baseParams, name: 'data.xlsx' });
await finalize();
expect(mockExtractCodeArtifactText).toHaveBeenCalledTimes(1);
/* Update is conditional on `previewRevision` so an older render
* can't overwrite a newer turn's record on cross-turn filename
* reuse. The uuid mock returns the same value for every v4()
* call, so file_id and previewRevision happen to coincide here
* — what matters is the second arg carries the revision filter. */
expect(updateFile).toHaveBeenCalledWith(
{
file_id: 'mock-uuid-1234',
text: '<table><tr><td>1</td></tr></table>',
textFormat: 'html',
status: 'ready',
previewError: null,
},
{ previewRevision: 'mock-uuid-1234' },
);
});
it('finalize() transitions to failed with previewError when extractor returns null (HTML-or-null contract)', async () => {
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
determineFileType.mockResolvedValue({
mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
mockExtractCodeArtifactText.mockResolvedValueOnce(null);
// Office bucket + null text → must be 'failed', NEVER raw text fallback
// (PR #12934 SEC fix: prevents <script> in cell text from rendering as HTML).
mockHasOfficeHtmlPath.mockReturnValue(true);
const { finalize } = await processCodeOutput({ ...baseParams, name: 'data.xlsx' });
await finalize();
expect(updateFile).toHaveBeenCalledWith(
expect.objectContaining({
file_id: 'mock-uuid-1234',
text: null,
status: 'failed',
previewError: 'parser-error',
}),
{ previewRevision: 'mock-uuid-1234' },
);
});
it('finalize() transitions to failed with previewError:timeout when the outer timeout rejects', async () => {
/* The passthrough `withTimeout` mock at the file scope returns
* its inner promise unchanged, so the only way the catch branch
* fires here is if the extractor itself throws. The real
* production path: `extractCodeArtifactText` swallows its own
* errors and returns null, so any throw reaching `finalizePreview`
* came from the outer `withTimeout` rejection. Simulate it by
* having the extractor throw with the same shape. */
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
determineFileType.mockResolvedValue({
mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
mockExtractCodeArtifactText.mockImplementationOnce(async () => {
throw new Error('Preview extraction exceeded 60000ms');
});
const { finalize } = await processCodeOutput({ ...baseParams, name: 'data.xlsx' });
await finalize();
expect(updateFile).toHaveBeenCalledWith(
expect.objectContaining({
file_id: 'mock-uuid-1234',
status: 'failed',
previewError: 'timeout',
}),
{ previewRevision: 'mock-uuid-1234' },
);
});
it('survives a failing updateFile in finalize() without throwing', async () => {
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
determineFileType.mockResolvedValue({
mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
mockExtractCodeArtifactText.mockResolvedValueOnce('<table></table>');
mockGetExtractedTextFormat.mockReturnValueOnce('html');
updateFile.mockRejectedValueOnce(new Error('mongo down'));
const { finalize } = await processCodeOutput({ ...baseParams, name: 'data.xlsx' });
await expect(finalize()).resolves.toBeNull();
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('failed to persist preview result'),
);
});
});
describe('legacy single-phase flow (non-office files)', () => {
/* Lock in that non-office files (txt/json/pdf/binary) keep the
* inline extract+create flow with NO finalize key — the caller
* gets a fully-resolved record, no background work to run. */
it('returns no finalize key for plain text', async () => {
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const result = await processCodeOutput({ ...baseParams, name: 'note.txt' });
expect(result.finalize).toBeUndefined();
expect(result.file).toMatchObject({ filename: 'note.txt' });
});
it('returns no finalize key for the size-limit fallback', async () => {
mockAxios.mockResolvedValue({ data: Buffer.alloc(100 * 1024 * 1024) });
const result = await processCodeOutput(baseParams);
expect(result.finalize).toBeUndefined();
expect(result.file.filepath).toContain('/api/files/code/download/');
});
it('returns no finalize key for the saveBuffer-unavailable fallback', async () => {
getStrategyFunctions.mockReturnValueOnce({});
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const result = await processCodeOutput(baseParams);
expect(result.finalize).toBeUndefined();
expect(result.file.filepath).toContain('/api/files/code/download/');
});
it('returns no finalize key for the axios-error fallback', async () => {
mockAxios.mockRejectedValue(new Error('network'));
const result = await processCodeOutput(baseParams);
expect(result.finalize).toBeUndefined();
expect(result.file.filepath).toContain('/api/files/code/download/');
});
});
});
describe('runPreviewFinalize', () => {
/* The runtime pairing for `processCodeOutput`'s `finalize` thunk.
* `finalizePreview` is designed to never throw (translates errors
* to `status: 'failed'` internally). The helper's catch is the
* safety net for unexpected programming errors that would
* otherwise leave the DB record stuck at `status: 'pending'`
* forever — we attempt a best-effort `updateFile` to mark it
* `'failed'` with `previewError: 'unexpected'` so the UI stops
* polling and the next-turn LLM context surfaces the failure.
* (Codex audit on PR #12957 Finding 4.) */
const { runPreviewFinalize } = require('./process');
const { updateFile } = require('~/models');
beforeEach(() => {
updateFile.mockReset();
updateFile.mockResolvedValue({});
});
it('is a no-op when finalize is undefined (non-office files)', () => {
expect(() =>
runPreviewFinalize({ finalize: undefined, fileId: 'fid-1', onResolved: jest.fn() }),
).not.toThrow();
expect(updateFile).not.toHaveBeenCalled();
});
it('calls onResolved with the resolved record on success', async () => {
const onResolved = jest.fn();
const finalize = jest
.fn()
.mockResolvedValue({ file_id: 'fid-1', status: 'ready', text: '<x/>' });
runPreviewFinalize({ finalize, fileId: 'fid-1', onResolved });
await new Promise((resolve) => setImmediate(resolve));
expect(onResolved).toHaveBeenCalledWith(
expect.objectContaining({ file_id: 'fid-1', status: 'ready' }),
);
expect(updateFile).not.toHaveBeenCalled();
});
it('skips onResolved when finalize resolves to null (DB write failed inside finalizePreview)', async () => {
const onResolved = jest.fn();
const finalize = jest.fn().mockResolvedValue(null);
runPreviewFinalize({ finalize, fileId: 'fid-1', onResolved });
await new Promise((resolve) => setImmediate(resolve));
expect(onResolved).not.toHaveBeenCalled();
});
it('marks the record as failed (previewError: "unexpected") when finalize throws', async () => {
const onResolved = jest.fn();
const finalize = jest.fn().mockRejectedValue(new Error('unexpected ref error'));
runPreviewFinalize({
finalize,
fileId: 'fid-boom',
previewRevision: 'rev-A',
onResolved,
});
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
expect(onResolved).not.toHaveBeenCalled();
/* Defensive update is conditional on the same `previewRevision`
* the deferred render started with — a newer turn that has
* since rotated the revision is left untouched. */
expect(updateFile).toHaveBeenCalledWith(
{
file_id: 'fid-boom',
status: 'failed',
previewError: 'unexpected',
},
{ previewRevision: 'rev-A' },
);
expect(logger.error).toHaveBeenCalledWith(
'Error rendering deferred preview:',
expect.any(Error),
);
});
it('logs but does not throw when the defensive updateFile itself fails', async () => {
const onResolved = jest.fn();
const finalize = jest.fn().mockRejectedValue(new Error('original error'));
updateFile.mockRejectedValueOnce(new Error('mongo down'));
runPreviewFinalize({ finalize, fileId: 'fid-doublefail', onResolved });
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
expect(onResolved).not.toHaveBeenCalled();
// Two logger.error calls: one for the original throw, one for the failed mark.
expect(logger.error.mock.calls.some((c) => /also failed to mark/.test(c[0]))).toBe(true);
});
it('does not attempt the defensive updateFile when fileId is missing', async () => {
const finalize = jest.fn().mockRejectedValue(new Error('boom'));
runPreviewFinalize({ finalize, fileId: undefined });
await new Promise((resolve) => setImmediate(resolve));
expect(updateFile).not.toHaveBeenCalled();
});
it('skips onResolved gracefully when caller omits it (e.g., tools.js direct endpoint)', async () => {
const finalize = jest.fn().mockResolvedValue({ file_id: 'fid-1', status: 'ready' });
// No onResolved — non-streaming caller.
expect(() => runPreviewFinalize({ finalize, fileId: 'fid-1' })).not.toThrow();
await new Promise((resolve) => setImmediate(resolve));
expect(updateFile).not.toHaveBeenCalled();
});
it('does NOT downgrade the file to failed when finalize succeeds but onResolved throws', async () => {
/* Regression for the codex P2 finding: the original chain put the
* `.catch` after `.then(onResolved)`, so a throw inside
* `onResolved` (transport-side: SSE write race after stream
* close, an emitter listener throwing) propagated into the
* finalize catch and persisted `status: 'failed'` /
* `previewError: 'unexpected'` — even though extraction
* succeeded and the file was already on disk and marked ready.
* That surfaced "preview unavailable" in the UI for a perfectly
* valid file, and degraded next-turn LLM context. The fix wraps
* `onResolved` in its own try/catch so emit errors stay isolated
* from finalize errors. */
const onResolved = jest.fn(() => {
throw new Error('SSE write after stream closed');
});
const finalize = jest.fn().mockResolvedValue({
file_id: 'fid-emit-throw',
status: 'ready',
text: '<table>x</table>',
});
runPreviewFinalize({
finalize,
fileId: 'fid-emit-throw',
previewRevision: 'rev-A',
onResolved,
});
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
expect(onResolved).toHaveBeenCalledTimes(1);
/* The defensive "mark failed" path MUST NOT fire — the file is
* resolved and on disk; only the SSE emit failed. */
expect(updateFile).not.toHaveBeenCalled();
/* Emit error is logged so the failure is observable in the
* server log without affecting UX. */
expect(
logger.error.mock.calls.some((c) => /onResolved threw for fid-emit-throw/.test(c[0])),
).toBe(true);
});
});
describe('readSandboxFile', () => {
/**
* `readSandboxFile` shells `cat <file_path>` through the sandbox
* `/exec` endpoint. The `file_path` argument is model-controlled, so
* the single-quote escaping is a security boundary — a regression
* here would let a malicious filename break out of the `cat`
* argument and inject arbitrary shell. Lock the contract in tests.
*/
/** Pull the bash code that the helper would send to /exec, given
* the file_path that the model supplied. */
function execCodeFor(file_path) {
mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } });
return readSandboxFile({ file_path }).then(() => {
const postData = mockAxios.mock.calls[0][0].data;
return postData.code;
});
}
describe('shell quoting (security boundary)', () => {
it('wraps a plain filename in single quotes', async () => {
const code = await execCodeFor('/mnt/data/sentinel.txt');
expect(code).toBe(`cat '/mnt/data/sentinel.txt'`);
});
it("escapes a literal single-quote in the filename via the standard '\\'' sequence", async () => {
// Adversarial filename: `quote'breakout.txt`. Naive
// single-quoting would terminate the quoted string and
// inject the trailing `breakout.txt'` as shell tokens.
const code = await execCodeFor(`/mnt/data/quote'breakout.txt`);
// Expected escape: end the string, escape a literal quote,
// start a new string. POSIX-portable.
expect(code).toBe(`cat '/mnt/data/quote'\\''breakout.txt'`);
});
it('does not interpret command substitution syntax inside the quoted argument', async () => {
// `$(rm -rf /)` would expand if the path were unquoted or
// double-quoted. Inside POSIX single-quotes it stays literal.
const code = await execCodeFor('/mnt/data/$(rm -rf /).txt');
expect(code).toBe(`cat '/mnt/data/$(rm -rf /).txt'`);
});
it('does not expand backtick command substitution inside the quoted argument', async () => {
const code = await execCodeFor('/mnt/data/`whoami`.txt');
expect(code).toBe(`cat '/mnt/data/\`whoami\`.txt'`);
});
it('keeps newlines literal inside the quoted argument', async () => {
const code = await execCodeFor('/mnt/data/line1\nline2.txt');
expect(code).toBe(`cat '/mnt/data/line1\nline2.txt'`);
});
it('keeps spaces and other shell metacharacters literal', async () => {
const code = await execCodeFor('/mnt/data/file ; ls -la /etc/passwd');
expect(code).toBe(`cat '/mnt/data/file ; ls -la /etc/passwd'`);
});
it('handles multiple consecutive single-quotes', async () => {
const code = await execCodeFor(`a''b`);
// Each `'` becomes the 4-char escape sequence.
expect(code).toBe(`cat 'a'\\'''\\''b'`);
});
});
describe('payload shape', () => {
it('POSTs to /exec on the configured codeapi base URL with bash language', async () => {
mockAxios.mockResolvedValueOnce({ data: { stdout: 'ok', stderr: '' } });
await readSandboxFile({ file_path: '/mnt/data/x.txt' });
const call = mockAxios.mock.calls[0][0];
expect(call.method).toBe('post');
expect(call.url).toBe('https://code-api.example.com/exec');
expect(call.data.lang).toBe('bash');
});
it('omits session_id and files when not provided', async () => {
mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } });
await readSandboxFile({ file_path: '/mnt/data/x.txt' });
const data = mockAxios.mock.calls[0][0].data;
expect(data).not.toHaveProperty('session_id');
expect(data).not.toHaveProperty('files');
});
it('forwards session_id when provided so the read lands in the seeded sandbox', async () => {
mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } });
await readSandboxFile({
file_path: '/mnt/data/x.txt',
session_id: 'sess-XYZ',
});
expect(mockAxios.mock.calls[0][0].data.session_id).toBe('sess-XYZ');
});
it('forwards files (non-empty array) so prior-turn artifacts are mounted', async () => {
mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } });
const files = [{ id: 'f1', name: 'sentinel.txt', session_id: 'sess-XYZ' }];
await readSandboxFile({
file_path: '/mnt/data/sentinel.txt',
session_id: 'sess-XYZ',
files,
});
expect(mockAxios.mock.calls[0][0].data.files).toEqual(files);
});
it('omits files when an empty array is provided (cleaner payload)', async () => {
mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } });
await readSandboxFile({
file_path: '/mnt/data/x.txt',
session_id: 'sess-XYZ',
files: [],
});
expect(mockAxios.mock.calls[0][0].data).not.toHaveProperty('files');
});
it('uses dedicated keepAlive:false agents (matches processCodeOutput pool isolation)', async () => {
mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } });
await readSandboxFile({ file_path: '/mnt/data/x.txt' });
const call = mockAxios.mock.calls[0][0];
expect(call.httpAgent).toBe(codeServerHttpAgent);
expect(call.httpsAgent).toBe(codeServerHttpsAgent);
});
});
describe('response handling', () => {
it('returns { content: stdout } on success', async () => {
mockAxios.mockResolvedValueOnce({
data: { stdout: 'sentinel-XYZ-1234\n', stderr: '' },
});
const result = await readSandboxFile({ file_path: '/mnt/data/sentinel.txt' });
expect(result).toEqual({ content: 'sentinel-XYZ-1234\n' });
});
it('returns null when getCodeBaseURL is not configured', async () => {
const { getCodeBaseURL } = require('@librechat/agents');
getCodeBaseURL.mockReturnValueOnce('');
const result = await readSandboxFile({ file_path: '/mnt/data/x.txt' });
expect(result).toBeNull();
expect(mockAxios).not.toHaveBeenCalled();
});
it('returns null when stdout is missing entirely (no content to surface)', async () => {
// stdout absent + no stderr = nothing to report; caller turns this
// into a model-visible "Failed to read" message.
mockAxios.mockResolvedValueOnce({ data: { stderr: '' } });
const result = await readSandboxFile({ file_path: '/mnt/data/x.txt' });
expect(result).toBeNull();
});
it('throws when the command writes to stderr with no stdout (exposes the error to the caller)', async () => {
mockAxios.mockResolvedValueOnce({
data: { stdout: '', stderr: 'cat: /mnt/data/missing.txt: No such file or directory\n' },
});
await expect(readSandboxFile({ file_path: '/mnt/data/missing.txt' })).rejects.toThrow(
'cat: /mnt/data/missing.txt: No such file or directory',
);
});
it('returns stdout even when stderr is also present (stdout wins on partial-success)', async () => {
// Some `cat` builds emit warnings on stderr while still producing
// stdout (e.g. unusual line endings). Surface the content.
mockAxios.mockResolvedValueOnce({
data: { stdout: 'partial', stderr: 'warning: ...' },
});
const result = await readSandboxFile({ file_path: '/mnt/data/x.txt' });
expect(result).toEqual({ content: 'partial' });
});
it('rethrows axios transport errors after logging via logAxiosError', async () => {
const { logAxiosError } = require('@librechat/api');
const transportError = Object.assign(new Error('connect ECONNREFUSED'), {
code: 'ECONNREFUSED',
});
mockAxios.mockRejectedValueOnce(transportError);
await expect(readSandboxFile({ file_path: '/mnt/data/x.txt' })).rejects.toBe(
transportError,
);
expect(logAxiosError).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('/mnt/data/x.txt'),
error: transportError,
}),
);
});
});
describe('timeout', () => {
it('uses the same 15s timeout as processCodeOutput (consistent code-server SLA)', async () => {
mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } });
await readSandboxFile({ file_path: '/mnt/data/x.txt' });
expect(mockAxios.mock.calls[0][0].timeout).toBe(15000);
});
});
});
describe('primeFiles reupload pushes FRESH sandbox ids (Pass-N review P2)', () => {
/**
* Regression: when a primed code file is missing/expired in the
* sandbox (`getSessionInfo` returns null), `primeFiles` re-uploads
* the file via `handleFileUpload` and persists the new
* `fileIdentifier`. Before the fix, the in-memory `files[]` array
* (now consumed by `buildInitialToolSessions` to seed
* `Graph.sessions`) still received the STALE `(session_id, id)`
* parsed from the original `fileIdentifier` at the top of the
* loop. The DB record was correct but the seed referenced a
* sandbox object that no longer existed — the first tool call
* 404'd trying to mount it until the next turn re-read metadata.
*
* Fix: parse the FRESH `fileIdentifier` returned by upload and
* push those ids into both the dedupe Map and the seed list.
*/
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { updateFile, getFiles } = require('~/models');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
/**
* Mock the full strategy pair. `primeFiles` calls
* `getStrategyFunctions(file.source)` for the download stream and
* `getStrategyFunctions(FileSources.execute_code)` for the code-env
* upload — both go through the same factory in production.
*/
function setupReuploadMocks(newFileIdentifier) {
const handleFileUpload = jest.fn().mockResolvedValue(newFileIdentifier);
const getDownloadStream = jest.fn().mockResolvedValue('mock-stream');
getStrategyFunctions.mockImplementation((source) => {
if (source === 'execute_code') return { handleFileUpload };
return { getDownloadStream };
});
updateFile.mockResolvedValue({});
filterFilesByAgentAccess.mockImplementation(({ files }) => Promise.resolve(files));
// getSessionInfo is mocked at module level via mockAxios; return null
// to force the reupload path. Each `getSessionInfo` call hits axios.
mockAxios.mockResolvedValue({ data: null });
return { handleFileUpload, getDownloadStream };
}
it('seed receives FRESH session_id + id parsed off the new fileIdentifier on reupload', async () => {
const dbFile = {
file_id: 'librechat-file-id',
filename: 'sentinel.txt',
filepath: '/uploads/sentinel.txt',
source: 'local',
context: 'execute_code',
metadata: {
/* Stale sandbox ref — this is what `getSessionInfo` will 404 on. */
fileIdentifier: 'OLD_SESSION/OLD_ID',
},
};
getFiles.mockResolvedValue([dbFile]);
setupReuploadMocks('NEW_SESSION/NEW_ID');
const result = await primeFiles({
req: { user: { id: 'user-123', role: 'USER' } },
tool_resources: {
execute_code: { file_ids: ['librechat-file-id'], files: [] },
},
agentId: 'agent-id',
});
// The seed list (consumed by buildInitialToolSessions) MUST carry
// the post-reupload ids — not the stale pre-reupload ones.
expect(result.files).toEqual([
{ id: 'NEW_ID', session_id: 'NEW_SESSION', name: 'sentinel.txt' },
]);
});
it('persists the new fileIdentifier on the DB record (existing behavior, regression-locked)', async () => {
const dbFile = {
file_id: 'librechat-file-id',
filename: 'sentinel.txt',
filepath: '/uploads/sentinel.txt',
source: 'local',
context: 'execute_code',
metadata: { fileIdentifier: 'OLD_SESSION/OLD_ID' },
};
getFiles.mockResolvedValue([dbFile]);
setupReuploadMocks('NEW_SESSION/NEW_ID');
await primeFiles({
req: { user: { id: 'user-123', role: 'USER' } },
tool_resources: {
execute_code: { file_ids: ['librechat-file-id'], files: [] },
},
agentId: 'agent-id',
});
expect(updateFile).toHaveBeenCalledWith(
expect.objectContaining({
file_id: 'librechat-file-id',
metadata: expect.objectContaining({ fileIdentifier: 'NEW_SESSION/NEW_ID' }),
}),
);
});
});
describe('primeFiles toolContext surfaces preview status to the LLM', () => {
/* When a prior-turn code-execution file's HTML preview never resolved
* (still pending, or failed), the agent context for this turn must
* carry that signal so the model can tell the user "you can still
* download it, but the preview isn't available." Otherwise the model
* would refer to the file as if everything is fine and the user gets
* a confusing UI mismatch. */
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { getFiles } = require('~/models');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
function makeFile(overrides) {
return {
file_id: `fid-${overrides.status ?? 'ready'}`,
filename: `data-${overrides.status ?? 'ready'}.xlsx`,
filepath: `/uploads/${overrides.status ?? 'ready'}.xlsx`,
source: 'local',
context: 'execute_code',
metadata: { fileIdentifier: 'CURRENT_SESSION/CURRENT_ID' },
...overrides,
};
}
function setupSessionInfoOk() {
/* `getSessionInfo` returns `lastModified`; `checkIfActive` parses
* that as a Date and decides whether the sandbox copy is still
* fresh (under 23 hours). Use `now` so we always go straight to
* `pushFile` and exercise the toolContext annotation logic. */
mockAxios.mockResolvedValue({ data: { lastModified: new Date().toISOString() } });
getStrategyFunctions.mockReturnValue({});
filterFilesByAgentAccess.mockImplementation(({ files }) => Promise.resolve(files));
}
it('annotates a pending file with "(preview not yet generated)"', async () => {
setupSessionInfoOk();
getFiles.mockResolvedValue([makeFile({ status: 'pending' })]);
const result = await primeFiles({
req: { user: { id: 'user-123', role: 'USER' } },
tool_resources: { execute_code: { file_ids: ['fid-pending'], files: [] } },
agentId: 'agent-id',
});
expect(result.toolContext).toContain('data-pending.xlsx');
expect(result.toolContext).toContain('(preview not yet generated)');
});
it('annotates a failed file with "(preview unavailable: <reason>)"', async () => {
setupSessionInfoOk();
getFiles.mockResolvedValue([makeFile({ status: 'failed', previewError: 'timeout' })]);
const result = await primeFiles({
req: { user: { id: 'user-123', role: 'USER' } },
tool_resources: { execute_code: { file_ids: ['fid-failed'], files: [] } },
agentId: 'agent-id',
});
expect(result.toolContext).toContain('data-failed.xlsx');
expect(result.toolContext).toContain('(preview unavailable: timeout)');
});
it('falls back to bare "(preview unavailable)" when previewError is absent', async () => {
setupSessionInfoOk();
getFiles.mockResolvedValue([makeFile({ status: 'failed' })]);
const result = await primeFiles({
req: { user: { id: 'user-123', role: 'USER' } },
tool_resources: { execute_code: { file_ids: ['fid-failed'], files: [] } },
agentId: 'agent-id',
});
expect(result.toolContext).toContain('(preview unavailable)');
expect(result.toolContext).not.toContain('(preview unavailable:');
});
it('does not annotate a ready file (no extra suffix)', async () => {
setupSessionInfoOk();
getFiles.mockResolvedValue([makeFile({ status: 'ready' })]);
const result = await primeFiles({
req: { user: { id: 'user-123', role: 'USER' } },
tool_resources: { execute_code: { file_ids: ['fid-ready'], files: [] } },
agentId: 'agent-id',
});
expect(result.toolContext).toContain('data-ready.xlsx');
expect(result.toolContext).not.toContain('preview');
});
it('does not annotate a legacy file (no status field, back-compat)', async () => {
/* Records pre-dating the deferred-preview flow have no `status`. They
* MUST render exactly as before — no suffix at all. */
setupSessionInfoOk();
getFiles.mockResolvedValue([makeFile({})]); // no status override
const result = await primeFiles({
req: { user: { id: 'user-123', role: 'USER' } },
tool_resources: { execute_code: { file_ids: ['fid-ready'], files: [] } },
agentId: 'agent-id',
});
expect(result.toolContext).toContain('data-ready.xlsx');
expect(result.toolContext).not.toContain('preview');
});
});
});