LibreChat/api/server/controllers/agents/__tests__/resume.spec.js
Danny Avila 6dbf9d5ad3
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
🪝 feat: Human-in-the-Loop Runtime - Tool Approval + Ask-User-Question (Slice B) (#13942)
* chore: add @langchain/langgraph-checkpoint-mongodb for HITL durable resume

* feat: HITL tool approval runtime — backend (Slice B)

- endpoints.agents.checkpointer config + durable Mongo checkpointer (seam over the app
  connection; SDK MemorySaver fallback) with a TTL index + deleteThread pruning
- HITL run wiring (PreToolUse policy hook + humanInTheLoop) attached in createRun, fully
  inert when toolApproval.enabled is off
- interrupt gate (pause job -> requires_action + emit on_pending_action) and a resume
  route that rebuilds the run from the durable checkpoint and run.resume()s it
- atomic single-winner resolve; agent-consistency guard; expireStaleApprovals terminal
  event; checkpoint pruned on every non-paused completion (thread_id == conversationId)

* feat: HITL tool approval UI — frontend (Slice B)

approve/reject/edit/respond + ask-user controls in the tool card (OAuth-button precedent),
batch-aware single submit, live + reconnect (resumeState.pendingAction) wiring, and resume
mutations posting to /agents/chat/resume.

* fix(hitl): decouple ApprovalProvider from chat context

ApprovalProvider is now pure state (safe to mount in provider-less / shared / test
renders); the context-dependent submit moved to a useResumeSubmit hook the cards call.
Part imports getAskUserQuestionPart from ~/utils/approval directly so suites that
partial-mock ~/utils render Part without throwing.

* fix(hitl): address Codex review — backend

- P1: enforce per-tool allowed_decisions on resume (reject a crafted decision the
  policy disallows) via findDisallowedDecisions
- prune the durable checkpoint on user-abort of a paused run, and before a fresh
  HITL turn, so a new turn cannot rehydrate an expired/aborted interrupt (thread_id
  is the stable conversationId)
- persist + use isTemporary and the original parentMessageId on resume (temporary
  chats stay temporary; initializeAgent scopes thread files off the right parent)
- generate a deferred first-turn title BEFORE completeJob so its event reaches the
  client and the final event carries the real title
- moderateText: skip when there is no text (tool-approval resume) and moderate the
  ask-user answer, instead of denying on an empty input

* fix(hitl): address Codex review — frontend

- render ToolApproval for ANY paused agent tool card (bash/code/file/etc.), not just
  the generic ToolCall, by wrapping the tool-card branch in Part (moved the rendering
  out of ToolCall)
- findPendingActionMessageIndex only matches an assistant message, never the user
  message (the underscore-strip could target the user bubble before the assistant
  placeholder exists)

* fix(hitl): address Codex re-review

- title eligibility checks the user message’s parent (first turn), not the response’s
  parent — the previous check could never be true and skipped title generation
- use client.buildResponseMetadata() for the resumed message so contextUsage /
  thoughtSignatures survive (the abort-only helper dropped them)
- moderate decisions[].responseText (the respond action’s user text)
- give /chat/abort req.config (configMiddleware) so the HITL checkpoint prune on abort
  actually runs
- read resume state BEFORE setContentParts so the in-memory store does not lose the
  pre-pause seed content
- count resumes against LIMIT_CONCURRENT_MESSAGES (increment/decrement) so paused-then-
  resumed turns cannot bypass the limit
- require actionId on resume so a body without it cannot resolve the current action

* fix(hitl): address Codex re-review (round 3) — resume fidelity

Bring the lean resume path to parity with sendMessage for things it bypassed:
- carry userMCPAuthMap into the rebuilt run so approved MCP tools keep the user's creds
- seed initialSessions (buildInitialToolSessions) so approved code/file/skill tools have
  the pre-pause uploaded-file context (esp. cross-replica / after restart)
- await client.artifactPromises and persist them as response attachments (else tool
  artifacts created after the pause vanish on reload / for late subscribers)
- merge metadata: cumulative usage (+ summary marker) from the job, contextUsage /
  thoughtSignatures from the client — fixes the round-2 regression that underreported
  post-resume cost

* fix(hitl): address Codex re-review (round 4) — resume hardening

- resume: require an EXACT paused agent_id match (reject omitted/ephemeral
  agent_id, not just a different one) and reject an endpoint mismatch, so a
  request can't rebuild the claimed checkpoint on a different graph
- moderateText: also moderate a tool-approval decision's reject `reason` and
  stringified `editedArguments`, not just `responseText`
- request: re-mark the paused response `unfinished:true` after BaseClient saves
  it as completed, so an expired / never-resumed approval doesn't leave a
  "finished" response in history; the resume path overwrites it on success

* test(hitl): route-level integration test for the resume controller

Adds api/server/controllers/agents/__tests__/resume.spec.js, a supertest
integration test that drives the real ResumeAgentController over the full
pause -> approve -> resume -> finalize lifecycle with the SDK run, durable
checkpointer, Mongo, and concurrency cache mocked. The pure decision/liveness
helpers run for real via requireActual, so the guard ladder is exercised end to
end rather than stubbed.

25 cases covering:
- the authorization / staleness / agent-and-endpoint / actionId guard ladder
- tool_approval validation (undecided tool call, policy-disallowed decision)
- ask_user_question answer requirement
- the concurrency gate (429) and the atomic single-winner claim (409)
- the happy path: ACK, run reconstruction, decision->SDK mapping, finalize
  (save the now-finished response, emit done, complete job, prune checkpoint)
- first-turn title generation before stream completion
- re-pause (no double finalize), abort-during-resume (no double finalize),
  and the resume-failure terminal path (emitError + completeJob + prune)

* test(hitl): strengthen resume coverage + add approval util tests

Acts on a self-audit of the new resume integration test.

resume.spec.js (25 -> 32 cases):
- replace the tautological emitDone assertion (it only checked the hardcoded
  `final: true`) with a structural check of the finalEvent payload —
  responseMessage content/id/unfinished, requestMessage identity, title
- cover the previously-unwalked finalize branches: tool-artifact attachments
  (null-filtered), the aggregatedContent fallback when live content is empty,
  and client response-metadata attachment
- add guard cases: unsupported pending-action type (400) and the
  pre-multi-tenancy null-tenantId pass-through (must not 403)
- add error-path cases: first-turn title generation throwing must still
  finalize, and a completeJob failure during a resume error must force a
  terminal job state via the last-resort updateJob

client/src/utils/approval.spec.ts (new, 15 cases):
- applyPendingAction tool_approval: join by tool_call_id not position,
  skip completed calls, default allowed_decisions to [], referential
  stability when nothing changes
- applyPendingAction ask_user_question: append, idempotent replace on replay,
  non-array content coercion
- getAskUserQuestionPart type guard; findPendingActionMessageIndex
  assistant-only resolution (never resolves to the user bubble)

* fix(hitl): address Codex re-review (round 5)

Five findings verified against the code before fixing:

- resume: require an EXACT endpoint match (like agent_id) — a resume that OMITS
  endpoint must not fall through, since the shared chat middleware treats a
  missing/non-agents endpoint as the ephemeral agent and could rebuild the
  claimed checkpoint on a different graph
- resume: filter malformed content parts before saving the finished response,
  matching the normal AgentClient path (a resumed turn could otherwise persist
  an empty/invalid tool_call part that breaks reload/rendering)
- resume: accumulate tool artifacts across pause segments — persist them on
  re-pause and MERGE (not overwrite) at finalize, so artifacts produced before
  a second approval pause aren't dropped by the next rebuilt client
- approval (client): findPendingActionMessageIndex returns -1 when a provided
  responseMessageId isn't found, so the caller retries instead of attaching the
  prompt/approval to a prior assistant reply; fall back to the last assistant
  only when no responseMessageId is given
- RedisJobStore: make appendChunk extend-only (XADD + EXPIRE-if-shorter via a
  single eval) so the on_pending_action chunk emitted after a pause can't reset
  the chunk-stream TTL back to the running window and evict pre-pause content
  before the approval is resolved

Tests: +endpoint-omitted/unsupported-type/malformed-filter/attachment-merge/
re-pause-persist cases in resume.spec.js (36); ask-retry -1 semantics in
approval.spec.ts (16); extend-only TTL assertion in the RedisJobStore Redis
integration spec.

* test(hitl): mongodb-memory-server integration test for the checkpointer seam

The checkpointer unit spec covers config/selection with no DB connection; this
exercises the durable Mongo seam against a real (in-memory) MongoDB — the part
correctness actually depends on:

- getAgentCheckpointer builds a real MongoDBSaver when Mongo is connected and
  setup() creates the TTL index (expireAfterSeconds) on the checkpoint collection
- memory type returns undefined (SDK MemorySaver fallback) even when connected
- saver is memoized per resolved config
- deleteAgentCheckpoint prunes a thread's persisted checkpoint (the cross-turn
  isolation guarantee: turn N+1 on the same conversationId can't rehydrate it)
- pruning is thread-scoped — deleting one conversation leaves others intact
- undefined threadId is a no-op

* fix(hitl): address Codex re-review (round 6)

Four findings verified against the code before fixing:

- messageFilterPii: scan the resume payload's user-authored text (ask-user
  `answer`, and a tool-approval decision's `respond` text, `reject` reason, and
  edited tool arguments) — the shared /resume route ran through the PII filter
  but it only inspected req.body.text, so a blocked token rode the resume
  payload back into the model/tool (mirrors the earlier moderateText fix)
- resume: re-prime skill files invoked in the pre-pause segment before rebuilding
  the run, so an approved code/file-backed tool keeps the injected skill-file
  session refs instead of running without them (mirrors the normal path's
  primeInvokedSkills; the pre-pause content stands in for the message payload)
- hitl: pin the graph identity. Persist a fingerprint of the graph-determining
  request fields (endpoint, agent_id, model, spec, ephemeralAgent — normalized)
  on the pending action at pause, and reject a resume whose recomputed
  fingerprint differs. This closes the ephemeral-agent gap, where agent_id is
  undefined so the id guard can't tell two ephemeral configs apart
- resume: reject incomplete edit/respond decisions (findIncompleteDecisions) —
  an `edit` without an object editedArguments or a `respond` without non-empty
  responseText is 400'd before mapping, rather than defaulting to {} / '' and
  resuming with behavior the user never approved

Tests: incomplete-decision + fingerprint match/mismatch cases in resume.spec.js
(41); findIncompleteDecisions + computeAgentRequestFingerprint unit tests; and
resume-field PII cases in messageFilterPii.spec.ts.

* fix(hitl): address Codex re-review (round 7)

Four findings verified against the code before fixing:

- RedisJobStore: clear `agent_id` on createJob (add it to staleHitlFields). The
  job hash is keyed by conversationId and reused across turns; updateMetadata
  only writes agent_id when truthy, so a conversation that switched from a saved
  agent to an ephemeral/no-agent turn kept the old id and the resume guard
  rejected the valid pause as a different agent. (real correctness bug)
- fingerprint: include `promptPrefix` in computeAgentRequestFingerprint, and
  re-send it on resume (ResumeAgentFields + buildResumeFields). Ephemeral agents
  derive their system instructions from promptPrefix, so a resume changing it
  previously passed the pin and rebuilt different instructions. (completes the
  round-6 fingerprint)
- resume: the re-pause branch now persists the segment's accumulated CONTENT
  (filtered), not just artifacts, so an approval that expires/reaps without a
  final resume no longer loses everything streamed during the resumed segment.
- request: carry `manualSkills`/`alwaysAppliedSkills` on the persisted user
  message so a resumed turn's reconstructed requestMessage keeps its skill pills
  instead of dropping them until a full reload.

Deferred (narrow, no safe contained fix yet — see PR thread replies):
- resume rebuild without `addedConvo` for a multi-conversation/added-agent pane
- cross-replica re-prime of manually-selected (not model-invoked) skill files

Tests: stale-agent createJob clearing (Redis integration), promptPrefix
fingerprint match/mismatch (resume.spec.js + policy.spec.ts), re-pause content
persistence (resume.spec.js).

* fix(hitl): address Codex re-review (round 8)

Five findings verified against the code before fixing; the headline is a durable-
resume correctness fix (the fingerprint had surfaced it as a 403):

- resume durability (the important one): persist the graph-determining request
  fields (endpoint, agent_id, model, spec, promptPrefix, ephemeralAgent) on the
  pending action as `resumeContext`, and REPLAY them onto the resume request via
  a router-level middleware that runs before buildEndpointOption. The client
  can't reconstruct the ephemeral-agent config after a reload/cross-session, so
  the round-6/7 fingerprint would 403 a valid durable resume — and even without
  it the rebuilt agent would lose its tools. Replaying server-side rebuilds the
  SAME graph regardless of client state (and a crafted resume can't swap it; the
  fingerprint still matches because the body is restored first).
- RedisJobStore: also clear `isTemporary` on createJob (same class as agent_id):
  a prior temporary turn's flag would otherwise survive a reused conversation
  hash and a later non-temporary resume would save its response as temporary.
- resume: persist `contextMeta` (context-window calibration) onto the saved
  response like BaseClient does, so the next turn can seed its pruner.
- request: carry manualSkills/alwaysAppliedSkills into the onStart metadata
  update (not just the preliminary one it overwrites), so a resumed turn's
  requestMessage keeps its skill pills.

Deferred (narrow — see thread reply):
- saved-agent edited WHILE a run is paused: agent_id matches but the definition
  changed; needs an agent version/config hash, which is a larger change for a
  narrow window.

Tests: resumeContext pick/apply + round-trip (policy.spec.ts), contextMeta +
manualSkills-on-requestMessage (resume.spec.js), isTemporary clearing (Redis
integration).

* style(hitl): prettier line-wrap in policy.spec.ts (R8 lint fix)

* fix(hitl): address Codex re-review (round 9)

Five findings, all fixed (addedConvo — deferred in rounds 7/8 — is now trivial
thanks to the round-8 replay):

- replay addedConvo: add it to RESUME_CONTEXT_KEYS so the resume middleware
  restores the parallel/secondary-agent config from the paused request; the
  client can't reconstruct it, and it determines the rebuilt graph.
- skill pills (the real fix this time): the round-8 onStart metadata write was
  overwritten by trackUserMessage (the authoritative userMessage writer). Carry
  manualSkills/alwaysAppliedSkills in the emitted `created` message and persist
  them in trackUserMessage; widen UserMessageMeta + SerializableJobData.userMessage.
- execute-code files on resume: seed the paused user message's own files onto
  req.body.files before initializeClient — they're excluded from the
  parent-walk code-session rebuild, so an approved code/read-file tool would
  otherwise resume without them.
- in-memory pending-action UI: route ApprovalEvents.ON_PENDING_ACTION in the
  resume replay/pending-event loops to applyPendingActionToMessages (mirror the
  live handler), so a pause that lands in the snapshot window still renders its
  approval controls instead of sitting paused with no UI.
- abort isTemporary: the /chat/abort partial-save now sources isTemporary from
  the job metadata, not req.body (the stop button posts only conversationId), so
  aborting a paused temporary chat no longer persists an orphaned partial.

Tests: addedConvo in pickResumeContext (policy.spec.ts), file-restore on resume
(resume.spec.js), abort-from-job-isTemporary (abort.spec.js).

* fix(hitl): address Codex re-review (round 10) — resume/expiry races

Three concurrency/coherence findings, verified against the code before fixing:

- expiry-sweep CAS scope: both stale-approval sweeps (GenerationJobManager
  expireStaleApprovals and the RedisJobStore requires_action cleanup) called
  expire()/transitionStatus WITHOUT the observed pendingAction.actionId, so the
  CAS only checked status===requires_action. Between the read and the CAS a user
  could resolve the observed action and the run re-pause on a FRESH action; the
  stale sweep would then abort that valid new pause. Now both pass the observed
  actionId as expectActionId, so the CAS only fires for the action read as stale
  (a re-paused action has a different id → no-op).
- resume graph cache: resumeCompletion cached the rebuilt graph (created with
  messages:[]) via setGraph; RedisJobStore.getContentParts prefers a cached
  graph over reconstructing from the chunk log, so a same-replica reload/status
  poll mid-resume returned aggregatedContent missing the pre-pause content. Skip
  setGraph on resume so introspection falls back to the complete chunk
  reconstruction (setContentParts still seeds the in-memory store).
- pending-action UI: applyPendingActionToMessages scheduled a SINGLE
  animation-frame retry then dropped the pending action; Recoil/React updates can
  take several frames under load, leaving a valid requires_action run with no
  approval controls. Retry across frames (bounded at 120) until the target
  message commits.

Test: expire() with a mismatched expectedActionId no-ops while the matching id
expires (pendingAction.spec.ts).

* chore(deps): update @librechat/agents to version 3.2.53 and @langchain/langgraph to version 1.4.7 in package-lock.json and related package.json files

* refactor(hitl): add resolveToolApprovalPolicy seam for layered policy

Extract the single point where tool-approval policy is resolved for a turn
(`resolveToolApprovalPolicy`) and route the run call site through it instead
of reading `endpoints.agents.toolApproval` inline.

Behaviour-preserving: only the `endpoint` layer is wired today, so the result
is identical to reading the app policy directly. The `agent` and `skills`
layers are reserved seams with documented precedence (endpoint owns the
`enabled` kill switch; agent overrides mode/allow/deny/ask/reason; skills may
only tighten), so future per-agent and per-skill policy plumbing lands in one
function rather than at the `createRun` site. Adds focused unit tests.

* fix(hitl): address Codex re-review (round 11) — resume hardening

F1 (P2, security) — applyResumeContext now DELETES any RESUME_CONTEXT_KEY
absent from the persisted context, so the resume body carries exactly the
graph-determining fields the pause had. Previously only defined keys were
overwritten, leaving a client-supplied `addedConvo` (which the request
fingerprint does not cover) in place — a crafted resume could rebuild a
single-agent checkpoint as a different multi-agent graph/tool set.

F3 (P2) — the resume route ACKs (res.json) before initializeClient, so a
post-ACK getMCPRequestContext(req, res) saw the response as finished and
returned undefined, leaving the resumed run without its run-scoped MCP
connection store (approved MCP / OAuth-overlay tools then ran without their
request-scoped connections). Pre-seed the store with a null res +
cleanupOnResponse:false before the ACK and tear it down in the finally,
mirroring the normal stream path (request.js). userMCPAuthMap was already
preserved separately, so credentials were not lost — only the connection store.

Declined: the ApprovalContext NEW_CONVO guard (P2) is a false positive — the
`created` SSE event updates the conversation atom before any pause renders, so
the id is concrete by click time (details in the PR thread).

Tests: policy.spec (absent-key delete) + resume.spec (MCP context pre-seed/cleanup order).

* fix(hitl): address Codex re-review (round 12) — resume fidelity + multi-tool UI

F4 (P2) — temporal prompt vars: resume rebuilt the agent without restoring
req.conversationCreatedAt or req.body.timezone, so {{current_datetime}}-style
vars compiled a different system prompt than the paused graph (resume wall-clock,
unzoned). Add 'timezone' to RESUME_CONTEXT_KEYS (persisted at pause, replayed by
the resume middleware) and restore conversationCreatedAt from the convo before
initializeClient — mirroring the normal path's resolveConversationCreatedAt.

F5 (P2) — multi-tool approval: applyPendingActionToMessages stopped retrying once
ANY tool-call part was tagged, so siblings that rendered on later frames never got
approval controls and the resume route 400'd the partial batch. Add
countTaggedApprovalParts and keep the bounded RAF retry going until every
action_request is tagged (ask_user_question unchanged — one synthetic part).

F6 (P3) — Edit accepted `null`/`[]` (valid JSON, non-object), enabling Submit for
a value the resume route rejects via findIncompleteDecisions. Mirror the server's
plain-object check in the client (store + editIsValid) so Submit only enables for
an accepted value.

Tests: policy.spec (timezone round-trip), resume.spec (conversationCreatedAt
restore), approval.spec (countTaggedApprovalParts).

* fix(hitl): address Codex re-review (round 13) — recurse into subagent approvals

F9 (P2) — a tool paused INSIDE a subagent has its tool_call_id in the parent
subagent tool_call's nested `subagent_content`, not as a top-level message part.
applyToolApproval and countTaggedApprovalParts only scanned top-level content, so
the approval never attached and the round-12 retry loop counted 0 tagged parts and
spun to its frame cap with no controls. Both now recurse into `subagent_content`
(immutably, so React refs update): the nested call gets tagged and is counted, so
the retry terminates. Added approval.spec cases for the nested tag + count.

Note: surfacing the interactive approve/reject controls inside the subagent view is
a deliberate follow-up — ToolApproval -> useResumeSubmit -> useChatContext crashes
when rendered in the portaled subagent dialog (outside the chat/approval providers),
so that needs the controls scoped to the in-provider inline render (or the dialog
wrapped with the providers). This commit fixes the data/traversal layer only.

F7 (discovered-tool history on resume) and F8 (redis chunk TTL pause race) were
verified false positives — see the PR threads.

* fix(hitl): address Codex re-review (round 14) — resume fidelity + expiry relay

F13 (P2) — manualSkills are graph-determining (skill allowed-tools union into the
tool set before tools load) but weren't replayed, so a reload lost the skill tools
and a crafted resume could inject a different skill past the fingerprint. Add
'manualSkills' to RESUME_CONTEXT_KEYS (same replay-only pattern as timezone/
addedConvo; the delete-absent half blocks injection). Not alwaysAppliedSkills —
that's resolved server-side from the DB, not req.body.

F12 (P2) — the resume final SSE built requestMessage from job.metadata.userMessage
(persisted without files), so attachments vanished from the user bubble on resume.
Spread the already-restored req.body.files onto it, matching the normal path.

F11 (P2) — multi-replica approval expiry: RedisJobStore.cleanupRequiresActionIndex
on another replica can win the requires_action->aborted CAS (it sets the hash error
but has no event transport), and the local sweep then skips because the job is no
longer requires_action, so a client subscribed here never gets the terminal error
until the reap path. expireStaleApprovals now relays APPROVAL_EXPIRED_ERROR for a
locally-subscribed job already aborted FOR approval expiry (error-string gated,
idempotent via the errorEvent flag). emitError already publishes cross-replica.

Tests: policy.spec (manualSkills round-trip + inject-drop), resume.spec (final
requestMessage carries restored files).

* fix(hitl): render approval controls for subagent-nested tool pauses (F10)

Round-13 made applyToolApproval/countTaggedApprovalParts recurse into
subagent_content (data), but SubagentDialogPart rendered nested TOOL_CALL parts
with <ToolCall> only and never mounted <ToolApproval>, so a tool paused inside a
subagent showed no controls and the run was unresolvable.

Render <ToolApproval> in SubagentDialogPart's TOOL_CALL branch when the nested
tool_call carries an approval and isn't yet resolved, mirroring the top-level
Part.tsx render. The subagent dialog portals (OGDialog → ReactDOM.createPortal),
but React context flows through the React tree, not the DOM tree, so ToolApproval
resolves ApprovalProvider/ChatContext and the controls work + submit.

Also harden useResumeSubmit: read ChatContext via useContext (non-throwing)
instead of the throwing useChatContext wrapper, so the cards never crash when
rendered outside a ChatContext.Provider (e.g. a search/citation render that passes
chat context as a prop) — they degrade to inert (buildResumeFields returns null).

* style(hitl): re-sort run.ts imports after dev rebase

* fix(hitl): address Codex re-review (round 15) — resume content fidelity

F14 (P2) — hide_sequential_outputs was applied in chatCompletion before
saving/emitting content but not on resume, so a sequential-agent chain that
pauses for HITL and resumes persisted/emitted intermediate outputs the setting
is meant to hide. Extracted the filter into applyHideSequentialOutputsFilter()
and call it from both chatCompletion and resumeCompletion (after handleRunInterrupt,
covering the finalize + re-pause reads of client.contentParts).

F16 (P2) — on a reloaded HITL pause, the DB already holds the paused user row +
partial assistant row; useResumeOnLoad fed those as submission.messages, then
finalHandler/createdHandler appended the same pair via requestMessage/responseMessage,
duplicating the turn (buildTree doesn't dedupe children by messageId). buildSubmission-
FromResumeState now strips the paused user/response rows (by messageId, incl. the
padded/unpadded response id) from submission.messages — they're re-supplied by the
placeholders + final event. Frontend-only; live (non-reload) pause path untouched.

Deferred: F15 (collapsed-card subagent approval registration/visibility) — see thread.

Tests: client.test (filter keeps last + tool_call parts / no-op when off),
useResumeOnLoad.spec (paused pair stripped from submission.messages).

* fix(hitl): address Codex re-review (round 16) — chunk TTL, slot, job replacement

F17 (P2) — chunk-stream TTL on pause-before-chunk. CHUNK_APPEND_LUA derived its
ceiling only from the chunk key's current TTL, so when the chunks key didn't exist
at pause (fire-and-forget append in flight, or an ask-user pause before any chunk),
the on_pending_action append created the stream with only the 20m running TTL while
the approval window is 24h — content evicted before resume. The Lua now also reads
the job key (KEYS[2]); when status == requires_action it takes max(running, TTL(jobKey))
(the approval window transitionStatus set), else the running TTL. Extend-only preserved;
gated on paused status so normal runs never inflate. Both keys share {streamId} (cluster-safe).

F19 (P2) — with LIMIT_CONCURRENT_MESSAGES, the approval prompt was emitted before the
original request released its slot, so a fast Approve got /resume 429'd. handleRunInterrupt
now releases the slot (idempotent via pendingRequestReleased) right after the pause, before
the prompt; the request.js pause branch and resume.js finally only release if it didn't
(no double-release).

F20 (P2) — finalizeResumedTurn never checked the job wasn't replaced before emitDone/
completeJob/saveMessage, so a stale resume could clobber a newer turn that reused the
conversationId. Added the createdAt guard the normal request path uses (skip finalization
when the live job's createdAt != the paused job's).

Deferred: F18 (subagent_content not reconstructed on Redis resume) — joins the subagent
cluster (F15). See thread.

Tests: RedisJobStore integration (pause-before-chunk gets approval TTL; running stays short),
resume.spec (skip finalization on replacement; no double slot release on re-pause).

* 🛡️ fix: Guard HITL terminal side-effects against job replacement

Jobs are keyed by streamId == conversationId, so a new request REPLACES the
running one on the same conversation. The replaced generation's tail must not
clobber the live generation's state. Each path now re-reads the live job and
compares createdAt against the generation's captured identity before acting.

- Thread the generation's createdAt onto the client (request.js + resume.js)
  as client.jobCreatedAt — the identity every guard compares against.
- handleRunInterrupt: skip approvals.pause when this run is no longer the live
  job, so a stale interrupt can't flip the NEWER job to requires_action.
- chatCompletion finally: skip the checkpoint prune when replaced, so an older
  run's late finally can't delete the newer run's resume checkpoint.
- resume catch-path: gate emitError/completeJob/prune behind a stillLive check
  (fail-open if the read throws), mirroring finalizeResumedTurn's success guard.
- Persist the turn's uploaded files on job.metadata.userMessage (authoritative
  trackUserMessage writer) and prefer them on resume over the user DB row, whose
  save can still be racing a fast /resume.

Tests: 13 guard-predicate cases in jobReplacement.spec.js.

* 🔁 fix: Harden HITL resume — ownership re-check, file seeding, deferred-tool replay

Three follow-ups to the round-17 job-replacement guards (Codex review 4594099963):

- G1 (resume.js): the success-path ownership guard runs at the START of
  finalizeResumedTurn, but saveMessage + first-turn title generation await long
  enough for a new request to replace the job on the same conversationId. Re-read
  the live job immediately before emitDone/completeJob/prune so the terminal writes
  can't tear down the REPLACEMENT job — mirrors the catch-path guard.

- G2 (request.js): onStart's metadata/chunk writes that persist the turn's files
  are fire-and-forget, so a fast approval could read job.metadata.userMessage before
  files landed. Seed files into getPreliminaryUserMessage instead — that write is
  AWAITED before the run starts, so files are durable before any interrupt can emit.

- G3 (run.ts + client.js + resume.js + IJobStore.ts): the resumed graph is rebuilt
  with messages: [], so createRun's tool_search-discovery scan finds nothing. A
  deferred tool discovered earlier in the turn (and targeted by the paused call) was
  therefore absent from the rebuilt schema-only toolMap — resume would throw "unknown
  tool" (no loadRuntimeTools fallback is wired). Capture discovered tool names at
  pause via extractDiscoveredToolsFromHistory(run.getRunMessages()), persist them on
  job.metadata.discoveredTools, and replay them into createRun's new discoveredToolNames
  input (merged with message-extracted names, gated on hasAnyDeferredTools — inert
  otherwise). A new createRun test proves the deferred tool is promoted with the replay
  and absent without it (reproducing the bug).

Tests: real createRun deferred-replay suite (run-summarization.test.ts) + G1/G2/G3
guard predicates (jobReplacement.spec.js). Full suite green.

* 🔒 fix: Close HITL resume metadata + file-substitution + pause-race gaps

Four findings on the round-18 commit (Codex review 4594430222):

- H1 (P1, regression in round-18 G3): the discoveredTools captured at pause never
  reached resume — three metadata allowlists dropped it: GenerationJobManager
  .updateMetadata, RedisJobStore.deserializeJob, and buildJobFacade (plus the
  GenerationJobMetadata type). Added discoveredTools to all four, so the deferred-tool
  replay actually works end-to-end (in-memory store already kept it via Object.assign).

- H2 (P2, security): /resume honored a client-supplied `files` array, letting a crafted
  client resume an approved code/read-file tool against a DIFFERENT file set than the one
  approved (files aren't in the resume fingerprint/context). Resume now ALWAYS sources
  files from the paused job (metadata → DB row), clearing any client-supplied set.

- H3 (P2, ephemeral fidelity): non-default model parameters (temperature, max tokens,
  custom endpoint params) were lost on resume — ephemeral agents derive them from the
  request body, which the resume payload omits. Capture the resolved model_parameters in
  resumeContext at pause and replay them onto the body on resume (excluding `model`, which
  is replayed via the fingerprinted RESUME_CONTEXT_KEYS path). Saved agents already source
  these from the DB.

- H4 (P2, Redis race): a pause landing between the resume snapshot and the Pub/Sub
  subscription reached neither resumeState.pendingAction nor (Redis) pendingEvents, and
  approval events aren't persisted to replayEvents — the client attached to a paused job
  with no approval UI. subscribeWithResume now re-reads the live job AFTER subscribing and
  surfaces the pending action if the snapshot missed it (live read, no staleness).

Tests: discoveredTools metadata round-trip + subscribeWithResume re-read (pendingAction
.spec.ts); client-file substitution rejection (resume.spec.js); model-parameter replay
predicate (jobReplacement.spec.js).

* 🧹 fix: Clear stale discovered tools, release slot on claim error, extend run-step TTL

Three follow-ups on the round-19 commit (Codex review 4594783691):

- I1 (P2): the round-19 discoveredTools field wasn't cleared on Redis streamId reuse.
  HSET only overwrites listed fields and handleRunInterrupt only writes discoveredTools
  when THIS turn discovers a deferred tool — so a replacement turn that pauses without its
  own discovery inherited the prior run's tool names and force-loaded undiscovered deferred
  tools on resume. Added discoveredTools to createJob's staleHitlFields HDEL list (the
  in-memory store already builds a fresh object, so it was Redis-only).

- I2 (P2): with LIMIT_CONCURRENT_MESSAGES, approvals.resolve runs after the slot increment
  but before the run's try/finally, so a store/Redis error there leaked the slot until the
  counter TTL expired (spurious 429s on retry of the still-paused approval). Wrapped the
  claim in try/catch that decrements the slot and returns 500.

- I3 (P3): saveRunSteps did SET ... EX running unconditionally, resetting the run-steps key
  to the 20-min running TTL even while the job is paused for the longer approval window —
  a reload after that window lost the tool timeline. Now uses a paused-window TTL script
  mirroring the chunk-stream no-shrink behavior (extends to the approval window when the
  job hash is requires_action).

Also fixes a latent strict-tsc cast error in the round-19 pendingAction test.

Tests: claim-throws-releases-slot (resume.spec.js); discoveredTools cleared on reuse +
saveRunSteps preserves the paused TTL (RedisJobStore integration, USE_REDIS).

* 🛡️ fix: Guard fast-resume save race, gate HITL to resumable routes, expire on stale submit

Three findings on the round-20 commit (Codex review 4595045652):

- J2 (P1): a fast /resume can claim + finalize the COMPLETED response while the original
  request's pause branch is still awaiting `response.databasePromise`; the later
  unfinished-save then overwrites the completed content. Re-check the job is still paused on
  THIS generation's action (a claim leaves requires_action; a replacement bumps createdAt)
  before marking the row unfinished; fail open on a read error.

- J3 (P1): the tool-approval wiring (humanInTheLoop + PreToolUse hook + checkpointer) was
  applied to EVERY createRun caller when toolApproval.enabled, but the OpenAI-compatible and
  Responses controllers never inspect run.getInterrupt() or persist a pending action — an
  approval-gated tool would pause there with no approval surface or resume endpoint and the
  route would emit a normal final response / [DONE] with the tool call dangling. Gate the
  wiring on a new createRun `hitlCapable` flag, set only by AgentClient (chat + resume).

- J4 (P2): a stale-action 409 on submit returned without driving expiry, leaving the job
  requires_action with a dead action until the periodic sweeper ran — any attached SSE client
  got no terminal event and the stream appeared to hang. Extracted GenerationJobManager
  .expireApproval(streamId, actionId) (expire CAS + terminal SSE, shared with the sweeper) and
  call it from the resume route when the observed action is stale.

J1 (nested subagent approval controls not mounting while the details dialog is closed) is a
valid frontend issue in the deferred subagent-HITL path — tracked separately (replied on the
thread) since the fix touches the shared dialog primitive and needs UI verification.

Tests: HITL-gate both directions (run-summarization.test.ts); expire-on-stale-submit
(resume.spec.js); fast-resume unfinished-save guard predicate (jobReplacement.spec.js).

* 💄 style: Wrap captureAgents signature to satisfy prettier (CI lint)
2026-06-29 16:56:41 -04:00

1114 lines
45 KiB
JavaScript

/**
* Integration tests for the HITL resume controller (POST /agents/chat/resume).
*
* Drives the real `ResumeAgentController` end-to-end over supertest with the SDK
* run, durable checkpointer, Mongo, and concurrency cache mocked out. The pure
* decision/liveness helpers (`isPendingActionStale`, `mapToolApprovalResolutions`,
* `findUndecidedToolCalls`, `findDisallowedDecisions`, `buildAbortedResponseMetadata`,
* `sanitizeMessageForTransmit`) run for real via `requireActual`, so the test
* exercises the actual guard ladder and the pause -> approve -> resume -> finalize
* lifecycle rather than re-implemented stubs.
*
* Covers:
* - the authorization / staleness / agent-and-endpoint / actionId guard ladder
* - tool_approval validation (undecided, policy-disallowed decision)
* - ask_user_question answer requirement
* - concurrency gate (429) and the atomic single-winner claim (409)
* - the happy path: ACK, run reconstruction, resumeCompletion, finalize (save the
* now-finished response, emit done, complete job, prune checkpoint)
* - re-pause (no double finalize), abort-during-resume (no double finalize),
* and the resume-failure terminal path
*/
const express = require('express');
const request = require('supertest');
const { Constants } = require('librechat-data-provider');
const USER_ID = 'user-1';
const TENANT_ID = 'tenant-1';
const AGENT_ID = 'agent-abc';
const CONVO_ID = 'convo-123';
const ACTION_ID = 'action-xyz';
const RESPONSE_MSG_ID = 'resp-1';
const USER_MSG_ID = 'umsg-1';
const THREAD_PARENT_ID = 'thread-parent-1';
const mockLogger = {
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
};
const mockJobStore = {
getJob: jest.fn(),
updateJob: jest.fn(),
};
const mockGenerationJobManager = {
getJob: jest.fn(),
getJobStore: jest.fn(() => mockJobStore),
getResumeState: jest.fn(),
setContentParts: jest.fn(),
emitChunk: jest.fn(),
emitDone: jest.fn(),
emitError: jest.fn(),
completeJob: jest.fn(),
expireApproval: jest.fn(),
approvals: { resolve: jest.fn() },
};
const mockDeleteAgentCheckpoint = jest.fn();
const mockDecrementPendingRequest = jest.fn();
const mockCheckAndIncrementPendingRequest = jest.fn();
const mockSaveMessage = jest.fn();
const mockGetConvo = jest.fn();
const mockGetMessages = jest.fn();
const mockDisposeClient = jest.fn();
const mockGetMCPRequestContext = jest.fn();
const mockCleanupMCPRequestContextForReq = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: mockLogger,
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
GenerationJobManager: mockGenerationJobManager,
deleteAgentCheckpoint: (...args) => mockDeleteAgentCheckpoint(...args),
decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
checkAndIncrementPendingRequest: (...args) => mockCheckAndIncrementPendingRequest(...args),
}));
jest.mock('~/models', () => ({
saveMessage: (...args) => mockSaveMessage(...args),
getConvo: (...args) => mockGetConvo(...args),
getMessages: (...args) => mockGetMessages(...args),
}));
jest.mock('~/server/cleanup', () => ({
disposeClient: (...args) => mockDisposeClient(...args),
}));
jest.mock('~/server/services/MCPRequestContext', () => ({
getMCPRequestContext: (...args) => mockGetMCPRequestContext(...args),
cleanupMCPRequestContextForReq: (...args) => mockCleanupMCPRequestContextForReq(...args),
}));
// Import after mocks
const ResumeAgentController = require('~/server/controllers/agents/resume');
/** Drain the microtask + immediate queues so the post-ACK continuation settles. */
const flush = () => new Promise((resolve) => setImmediate(resolve));
/** A live, resolvable paused tool-approval job (single tool call `tc1`). */
function makeToolApprovalJob(overrides = {}) {
const metaOverrides = overrides.metadata ?? {};
const pendingOverrides = metaOverrides.pendingAction ?? {};
return {
status: 'requires_action',
abortController: new AbortController(),
...overrides,
metadata: {
userId: USER_ID,
tenantId: TENANT_ID,
agent_id: AGENT_ID,
endpoint: 'agents',
responseMessageId: RESPONSE_MSG_ID,
sender: 'TestAgent',
iconURL: 'https://example.com/icon.png',
model: 'claude-test',
isTemporary: false,
userMessage: {
messageId: USER_MSG_ID,
parentMessageId: THREAD_PARENT_ID,
text: 'please run the tool',
},
...metaOverrides,
pendingAction: {
actionId: ACTION_ID,
expiresAt: Date.now() + 60_000,
payload: {
type: 'tool_approval',
action_requests: [{ tool_call_id: 'tc1' }],
review_configs: [{ tool_call_id: 'tc1', allowed_decisions: ['approve', 'reject'] }],
},
...pendingOverrides,
},
},
};
}
/** A live, resolvable paused ask-user-question job. */
function makeAskUserJob(overrides = {}) {
const job = makeToolApprovalJob(overrides);
job.metadata.pendingAction.payload = {
type: 'ask_user_question',
question: 'What should I name the file?',
};
return job;
}
/** A mock reconstructed client for the post-ACK path. */
function makeClient(overrides = {}) {
return {
sender: 'TestAgent',
contentParts: [{ type: 'text', text: 'resumed answer' }],
artifactPromises: [],
pendingApproval: false,
buildResponseMetadata: jest.fn(() => null),
resumeCompletion: jest.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe('ResumeAgentController (POST /agents/chat/resume)', () => {
let app;
let mockInitializeClient;
let mockAddTitle;
let capturedInit;
let settle;
let settled;
beforeEach(() => {
jest.clearAllMocks();
capturedInit = null;
mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: true });
mockDecrementPendingRequest.mockResolvedValue(undefined);
mockDeleteAgentCheckpoint.mockResolvedValue(undefined);
mockCleanupMCPRequestContextForReq.mockResolvedValue(undefined);
mockSaveMessage.mockResolvedValue(undefined);
mockGetConvo.mockResolvedValue(null);
mockGetMessages.mockResolvedValue([]);
mockJobStore.getJob.mockResolvedValue({ tokenUsage: null, contextUsage: null });
mockJobStore.updateJob.mockResolvedValue(undefined);
mockGenerationJobManager.getResumeState.mockResolvedValue({ aggregatedContent: [] });
mockGenerationJobManager.emitDone.mockResolvedValue(undefined);
mockGenerationJobManager.emitError.mockResolvedValue(undefined);
mockGenerationJobManager.emitChunk.mockResolvedValue(undefined);
mockGenerationJobManager.completeJob.mockResolvedValue(undefined);
mockGenerationJobManager.approvals.resolve.mockResolvedValue(true);
// `decrementPendingRequest` runs in the controller's `finally` on every
// post-ACK path, so resolving on it signals the async continuation is done.
settled = new Promise((resolve) => {
settle = resolve;
});
mockDecrementPendingRequest.mockImplementation(async () => {
settle();
});
mockAddTitle = jest.fn().mockResolvedValue(undefined);
mockInitializeClient = jest.fn(async ({ req }) => {
// Capture the request state the controller seeds BEFORE reconstruction.
capturedInit = {
parentMessageId: req.body.parentMessageId,
files: req.body.files,
conversationCreatedAt: req.conversationCreatedAt,
timezone: req.body.timezone,
};
return { client: makeClient(), userMCPAuthMap: { server1: { token: 't' } } };
});
app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = { id: USER_ID, tenantId: TENANT_ID };
req.config = {
endpoints: { agents: { checkpointer: { type: 'mongo' } } },
interfaceConfig: {},
};
next();
});
app.post('/api/agents/chat/resume', (req, res, next) =>
ResumeAgentController(req, res, next, mockInitializeClient, mockAddTitle),
);
});
const post = (body) => request(app).post('/api/agents/chat/resume').send(body);
const approveBody = (extra = {}) => ({
conversationId: CONVO_ID,
actionId: ACTION_ID,
agent_id: AGENT_ID,
endpoint: 'agents',
decisions: [{ tool_call_id: 'tc1', decision: 'approve' }],
...extra,
});
describe('temporal context restore', () => {
it('restores req.conversationCreatedAt from the convo before initializeClient', async () => {
// Temporal prompt vars must resolve against the paused anchor, not resume wall-clock.
mockGetConvo.mockResolvedValue({ createdAt: new Date('2020-01-02T03:04:05.000Z') });
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody());
expect(res.status).toBe(200);
await settled;
expect(capturedInit.conversationCreatedAt).toBe('2020-01-02T03:04:05.000Z');
});
it('leaves conversationCreatedAt unset when the convo lookup yields nothing', async () => {
mockGetConvo.mockResolvedValue(null);
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody());
expect(res.status).toBe(200);
await settled;
expect(capturedInit.conversationCreatedAt).toBeUndefined();
});
});
describe('MCP request-context lifecycle', () => {
it('pre-seeds the run-scoped MCP context before initializeClient and tears it down after', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody());
expect(res.status).toBe(200);
await settled; // the controller's finally has run
// Seeded with a null `res` + cleanupOnResponse:false so the post-ACK tool load
// finds the existing store instead of getting undefined (res is already finished).
expect(mockGetMCPRequestContext).toHaveBeenCalledWith(expect.anything(), undefined, {
cleanupOnResponse: false,
});
// ...and seeded BEFORE the client (hence tool loading) is built.
expect(mockGetMCPRequestContext.mock.invocationCallOrder[0]).toBeLessThan(
mockInitializeClient.mock.invocationCallOrder[0],
);
// ...then torn down exactly once in the finally.
expect(mockCleanupMCPRequestContextForReq).toHaveBeenCalledTimes(1);
});
});
describe('request guards (rejected before claiming the action)', () => {
it('400 when conversationId is missing', async () => {
const res = await post({ actionId: ACTION_ID });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/conversationId is required/i);
expect(mockGenerationJobManager.getJob).not.toHaveBeenCalled();
});
it('400 when conversationId is the "new" placeholder', async () => {
const res = await post({ conversationId: 'new', actionId: ACTION_ID });
expect(res.status).toBe(400);
expect(mockGenerationJobManager.getJob).not.toHaveBeenCalled();
});
it('404 when there is no paused job for the conversation', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(null);
const res = await post(approveBody());
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/no paused generation/i);
});
it('403 when the job belongs to another user', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(
makeToolApprovalJob({ metadata: { userId: 'someone-else' } }),
);
const res = await post(approveBody());
expect(res.status).toBe(403);
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('403 on a tenant mismatch', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(
makeToolApprovalJob({ metadata: { tenantId: 'other-tenant' } }),
);
const res = await post(approveBody());
expect(res.status).toBe(403);
});
it('403 when the resume omits the paused agent_id', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody({ agent_id: undefined }));
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/different agent/i);
});
it('403 when the resume claims a different agent_id', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody({ agent_id: 'agent-other' }));
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/different agent/i);
});
it('403 when the resume claims a different endpoint', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody({ endpoint: 'bedrock' }));
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/different endpoint/i);
});
it('403 when the resume OMITS the paused endpoint (no fall-through to ephemeral)', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody({ endpoint: undefined }));
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/different endpoint/i);
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('409 when the job is not in requires_action (already terminal; no expire)', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob({ status: 'running' }));
const res = await post(approveBody());
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/no live pending action/i);
// Already resolved/terminal — nothing to expire.
expect(mockGenerationJobManager.expireApproval).not.toHaveBeenCalled();
});
it('409 AND drives expiry when the pending action has expired (stale)', async () => {
const job = makeToolApprovalJob();
job.metadata.pendingAction.expiresAt = Date.now() - 1_000;
mockGenerationJobManager.getJob.mockResolvedValue(job);
const res = await post(approveBody());
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/no live pending action/i);
// The stale action is expired NOW (expire CAS + terminal SSE) so an attached SSE
// client gets a terminal event instead of hanging until the periodic sweeper runs.
expect(mockGenerationJobManager.expireApproval).toHaveBeenCalledWith(CONVO_ID, ACTION_ID);
});
it('400 when actionId is missing', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody({ actionId: undefined }));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/actionId is required/i);
});
it('409 when actionId targets a stale action', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody({ actionId: 'stale-action' }));
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/stale action/i);
});
it('400 when a tool call is left undecided', async () => {
const job = makeToolApprovalJob();
job.metadata.pendingAction.payload.action_requests = [
{ tool_call_id: 'tc1' },
{ tool_call_id: 'tc2' },
];
job.metadata.pendingAction.payload.review_configs = [
{ tool_call_id: 'tc1', allowed_decisions: ['approve', 'reject'] },
{ tool_call_id: 'tc2', allowed_decisions: ['approve', 'reject'] },
];
mockGenerationJobManager.getJob.mockResolvedValue(job);
const res = await post(approveBody()); // only decides tc1
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/must be decided/i);
expect(res.body.undecided).toEqual(['tc2']);
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('403 when a decision is not permitted by the tool policy', async () => {
const job = makeToolApprovalJob();
// Policy restricts tc1 to reject only; an `approve` must be refused.
job.metadata.pendingAction.payload.review_configs = [
{ tool_call_id: 'tc1', allowed_decisions: ['reject'] },
];
mockGenerationJobManager.getJob.mockResolvedValue(job);
const res = await post(approveBody());
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not permitted/i);
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('400 when an edit decision omits editedArguments', async () => {
const job = makeToolApprovalJob();
job.metadata.pendingAction.payload.review_configs = [
{ tool_call_id: 'tc1', allowed_decisions: ['approve', 'edit'] },
];
mockGenerationJobManager.getJob.mockResolvedValue(job);
const res = await post(
approveBody({ decisions: [{ tool_call_id: 'tc1', decision: 'edit' }] }),
);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/editedArguments/i);
expect(res.body.incomplete).toEqual(['tc1']);
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('400 when a respond decision omits responseText', async () => {
const job = makeToolApprovalJob();
job.metadata.pendingAction.payload.review_configs = [
{ tool_call_id: 'tc1', allowed_decisions: ['approve', 'respond'] },
];
mockGenerationJobManager.getJob.mockResolvedValue(job);
const res = await post(
approveBody({ decisions: [{ tool_call_id: 'tc1', decision: 'respond' }] }),
);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/responseText/i);
});
it('accepts a complete edit decision (editedArguments present)', async () => {
const job = makeToolApprovalJob();
job.metadata.pendingAction.payload.review_configs = [
{ tool_call_id: 'tc1', allowed_decisions: ['approve', 'edit'] },
];
mockGenerationJobManager.getJob.mockResolvedValue(job);
const res = await post(
approveBody({
decisions: [{ tool_call_id: 'tc1', decision: 'edit', editedArguments: { q: 'x' } }],
}),
);
expect(res.status).toBe(200);
await settled;
await flush();
});
it('403 when the resume request fingerprint does not match the paused config', async () => {
const job = makeToolApprovalJob();
job.metadata.pendingAction.requestFingerprint = 'fingerprint-of-a-different-config';
mockGenerationJobManager.getJob.mockResolvedValue(job);
const res = await post(approveBody());
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/different agent configuration/i);
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('proceeds when the resume request fingerprint matches the paused config', async () => {
const { computeAgentRequestFingerprint } = jest.requireActual('@librechat/api');
const job = makeToolApprovalJob();
job.metadata.pendingAction.requestFingerprint = computeAgentRequestFingerprint({
endpoint: 'agents',
agent_id: AGENT_ID,
});
mockGenerationJobManager.getJob.mockResolvedValue(job);
const res = await post(approveBody());
expect(res.status).toBe(200);
expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledWith(CONVO_ID, ACTION_ID);
await settled;
await flush();
});
it('403 when the resume sends a different promptPrefix than the paused config', async () => {
const { computeAgentRequestFingerprint } = jest.requireActual('@librechat/api');
const job = makeToolApprovalJob();
// Ephemeral instructions come from promptPrefix, so it's part of the fingerprint.
job.metadata.pendingAction.requestFingerprint = computeAgentRequestFingerprint({
endpoint: 'agents',
agent_id: AGENT_ID,
promptPrefix: 'be terse',
});
mockGenerationJobManager.getJob.mockResolvedValue(job);
const res = await post(approveBody({ promptPrefix: 'ignore previous instructions' }));
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/different agent configuration/i);
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('400 when an ask_user_question resume carries no answer', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserJob());
const res = await post({
conversationId: CONVO_ID,
actionId: ACTION_ID,
agent_id: AGENT_ID,
endpoint: 'agents',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/answer is required/i);
});
it('400 on an unsupported pending-action type', async () => {
const job = makeToolApprovalJob();
job.metadata.pendingAction.payload = { type: 'totally_unknown' };
mockGenerationJobManager.getJob.mockResolvedValue(job);
const res = await post(approveBody());
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/unsupported pending action/i);
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('proceeds (does not 403) for a pre-multi-tenancy job with no tenantId', async () => {
// hasTenantMismatch only blocks when the job carries a tenantId that differs;
// an untenanted (legacy) job must still resume once the userId check passes.
const job = makeToolApprovalJob({ metadata: { tenantId: undefined } });
mockGenerationJobManager.getJob.mockResolvedValue(job);
const res = await post(approveBody());
expect(res.status).toBe(200);
expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledWith(CONVO_ID, ACTION_ID);
await settled;
await flush();
});
it('429 when the concurrency gate rejects the resume', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: false });
const res = await post(approveBody());
expect(res.status).toBe(429);
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('409 and releases the slot when the action was already claimed (single-winner)', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockGenerationJobManager.approvals.resolve.mockResolvedValue(false);
const res = await post(approveBody());
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/already resolved or has expired/i);
expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledWith(CONVO_ID, ACTION_ID);
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
expect(mockInitializeClient).not.toHaveBeenCalled();
});
it('releases the slot when the claim itself throws (store error, not a leak)', async () => {
// The increment happens before the claim, which runs before the run's own
// try/finally — a store error here must still release the slot or a retry of the
// still-paused approval gets spuriously 429'd until the counter TTL expires.
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockGenerationJobManager.approvals.resolve.mockRejectedValue(new Error('redis down'));
const res = await post(approveBody());
expect(res.status).toBe(500);
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
expect(mockInitializeClient).not.toHaveBeenCalled();
});
});
describe('happy path: approve -> reconstruct -> resume -> finalize', () => {
it('ACKs immediately and claims the action atomically with the submitted actionId', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody());
expect(res.status).toBe(200);
expect(res.body).toEqual({
streamId: CONVO_ID,
conversationId: CONVO_ID,
status: 'resuming',
});
expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledWith(CONVO_ID, ACTION_ID);
await settled;
await flush();
});
it('seeds the thread parent before reconstruction and maps the decision to the SDK', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
await post(approveBody());
await settled;
await flush();
// initializeAgent scopes thread files off req.body.parentMessageId, seeded
// from the paused user message's parent before initializeClient runs.
expect(capturedInit.parentMessageId).toBe(THREAD_PARENT_ID);
expect(mockInitializeClient).toHaveBeenCalledTimes(1);
const client = await mockInitializeClient.mock.results[0].value.then((r) => r.client);
expect(client.resumeCompletion).toHaveBeenCalledWith(
expect.objectContaining({
resumeValue: { tc1: { type: 'approve' } },
userMCPAuthMap: { server1: { token: 't' } },
}),
);
});
it('restores the paused user message files before reconstruction (execute-code files)', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
// The resume body carries no files; the controller must source them from the
// persisted user message so an approved code/read-file tool keeps its uploads.
mockGetMessages.mockResolvedValue([{ files: [{ file_id: 'f1' }] }]);
await post(approveBody());
await settled;
await flush();
expect(capturedInit.files).toEqual([{ file_id: 'f1' }]);
});
it('ignores client-supplied resume files, sourcing from the paused job (security)', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
// The paused turn's authoritative files (DB row); a crafted client tries to swap them.
mockGetMessages.mockResolvedValue([{ files: [{ file_id: 'paused' }] }]);
await post(approveBody({ files: [{ file_id: 'attacker-supplied' }] }));
await settled;
await flush();
// The crafted client files must NOT reach initializeAgent — only the paused set.
expect(capturedInit.files).toEqual([{ file_id: 'paused' }]);
});
it('clears client-supplied resume files when the paused turn had none (security)', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockGetMessages.mockResolvedValue([{ files: [] }]); // the paused turn had no files
await post(approveBody({ files: [{ file_id: 'attacker-supplied' }] }));
await settled;
await flush();
expect(capturedInit.files).toEqual([]);
});
it('prefers job-metadata files over both the client body and the DB row', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(
makeToolApprovalJob({
metadata: {
userMessage: {
messageId: USER_MSG_ID,
parentMessageId: THREAD_PARENT_ID,
text: 'x',
files: [{ file_id: 'meta' }],
},
},
}),
);
mockGetMessages.mockResolvedValue([{ files: [{ file_id: 'db' }] }]);
await post(approveBody({ files: [{ file_id: 'attacker-supplied' }] }));
await settled;
await flush();
expect(capturedInit.files).toEqual([{ file_id: 'meta' }]);
});
it('carries the restored files onto the final requestMessage (user bubble keeps attachments)', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
// job.metadata.userMessage is persisted without files; the final SSE must still
// carry the restored uploads or the user bubble loses its attachments on resume.
mockGetMessages.mockResolvedValue([{ files: [{ file_id: 'f1', filename: 'a.pdf' }] }]);
await post(approveBody());
await settled;
await flush();
const [, finalEvent] = mockGenerationJobManager.emitDone.mock.calls[0];
expect(finalEvent.requestMessage).toMatchObject({
messageId: USER_MSG_ID,
isCreatedByUser: true,
files: [{ file_id: 'f1', filename: 'a.pdf' }],
});
});
it('persists the finished response, emits done, completes the job, and prunes the checkpoint', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
await post(approveBody());
await settled;
await flush();
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.objectContaining({ userId: USER_ID, isTemporary: false }),
expect.objectContaining({
messageId: RESPONSE_MSG_ID,
parentMessageId: USER_MSG_ID,
conversationId: CONVO_ID,
content: [{ type: 'text', text: 'resumed answer' }],
unfinished: false,
error: false,
isCreatedByUser: false,
user: USER_ID,
agent_id: AGENT_ID,
}),
expect.objectContaining({
context: 'api/server/controllers/agents/resume.js - resumed response end',
}),
);
// Assert the finalEvent STRUCTURE, not just the hardcoded `final: true` literal —
// a `final: true`-only check would still pass if the entire content / title /
// requestMessage build in finalizeResumedTurn were deleted.
const [doneStreamId, finalEvent] = mockGenerationJobManager.emitDone.mock.calls[0];
expect(doneStreamId).toBe(CONVO_ID);
expect(finalEvent).toMatchObject({
final: true,
conversation: { conversationId: CONVO_ID },
responseMessage: {
messageId: RESPONSE_MSG_ID,
content: [{ type: 'text', text: 'resumed answer' }],
unfinished: false,
},
requestMessage: { messageId: USER_MSG_ID, isCreatedByUser: true },
});
expect(typeof finalEvent.title).toBe('string');
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith(CONVO_ID, { type: 'mongo' });
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
expect(mockDisposeClient).toHaveBeenCalledTimes(1);
});
it('skips finalization (no save/emitDone/complete) when the job was replaced mid-resume', async () => {
// The paused job has createdAt 1000; a concurrent request reused this conversationId,
// so the live job now has a different createdAt — finalizing would clobber the newer
// turn's job. The finally still runs (slot release), so `settled` resolves.
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob({ createdAt: 1000 }));
mockJobStore.getJob.mockResolvedValue({
tokenUsage: null,
contextUsage: null,
createdAt: 2000,
});
await post(approveBody());
await settled;
await flush();
expect(mockSaveMessage).not.toHaveBeenCalled();
expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
});
it('does not release the slot in the finally when the client already released it on pause', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
// Simulate handleRunInterrupt having released the concurrency slot on a re-pause.
mockInitializeClient.mockResolvedValue({
client: makeClient({ pendingRequestReleased: true }),
userMCPAuthMap: {},
});
let disposed;
const disposedP = new Promise((resolve) => {
disposed = resolve;
});
mockDisposeClient.mockImplementation(() => disposed());
await post(approveBody());
await disposedP;
await flush();
// The finally must NOT double-release — handleRunInterrupt already did.
expect(mockDecrementPendingRequest).not.toHaveBeenCalled();
});
it('persists tool artifacts produced by the resumed continuation as attachments', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const artifact = { type: 'image', file_id: 'img-1' };
// The lean resume path bypasses BaseClient.sendMessage's artifact await, so the
// controller must await client.artifactPromises itself (and drop null results).
mockInitializeClient.mockResolvedValue({
client: makeClient({
artifactPromises: [Promise.resolve(artifact), Promise.resolve(null)],
}),
userMCPAuthMap: {},
});
await post(approveBody());
await settled;
await flush();
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ attachments: [artifact] }),
expect.anything(),
);
});
it('falls back to the aggregated store content when the live client content is empty', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
// No live content on the rebuilt client → the saved response must use the
// pre-pause aggregated content from the store, not an empty array.
mockInitializeClient.mockResolvedValue({
client: makeClient({ contentParts: [] }),
userMCPAuthMap: {},
});
mockGenerationJobManager.getResumeState.mockResolvedValue({
aggregatedContent: [{ type: 'text', text: 'from-store' }],
});
await post(approveBody());
await settled;
await flush();
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ content: [{ type: 'text', text: 'from-store' }] }),
expect.anything(),
);
});
it('strips malformed tool_call parts from the saved content', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockInitializeClient.mockResolvedValue({
client: makeClient({
contentParts: [
{ type: 'text', text: 'kept' },
{ type: 'tool_call' }, // malformed: no tool_call payload — must be filtered
],
}),
userMCPAuthMap: {},
});
await post(approveBody());
await settled;
await flush();
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ content: [{ type: 'text', text: 'kept' }] }),
expect.anything(),
);
});
it('merges previously persisted attachments with the resumed segment artifacts', async () => {
const priorArtifact = { type: 'image', file_id: 'prior-1' };
const newArtifact = { type: 'image', file_id: 'new-1' };
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
// An earlier pause segment already saved an attachment on the response row.
mockGetMessages.mockResolvedValue([{ attachments: [priorArtifact] }]);
mockInitializeClient.mockResolvedValue({
client: makeClient({ artifactPromises: [Promise.resolve(newArtifact)] }),
userMCPAuthMap: {},
});
await post(approveBody());
await settled;
await flush();
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ attachments: [priorArtifact, newArtifact] }),
expect.anything(),
);
});
it('persists the resumed run context calibration (contextMeta) onto the saved response', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const contextMeta = { calibrationRatio: 0.8 };
mockInitializeClient.mockResolvedValue({
client: makeClient({ contextMeta }),
userMCPAuthMap: {},
});
await post(approveBody());
await settled;
await flush();
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ contextMeta }),
expect.anything(),
);
});
it('carries manualSkills/alwaysAppliedSkills onto the resumed requestMessage', async () => {
const job = makeToolApprovalJob();
job.metadata.userMessage.manualSkills = ['skill-a'];
job.metadata.userMessage.alwaysAppliedSkills = ['skill-b'];
mockGenerationJobManager.getJob.mockResolvedValue(job);
await post(approveBody());
await settled;
await flush();
const [, finalEvent] = mockGenerationJobManager.emitDone.mock.calls[0];
expect(finalEvent.requestMessage).toMatchObject({
manualSkills: ['skill-a'],
alwaysAppliedSkills: ['skill-b'],
});
});
it('attaches client response metadata to the saved message when present', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const contextUsage = { tokenCount: 1234 };
mockInitializeClient.mockResolvedValue({
client: makeClient({ buildResponseMetadata: jest.fn(() => ({ contextUsage })) }),
userMCPAuthMap: {},
});
await post(approveBody());
await settled;
await flush();
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ metadata: expect.objectContaining({ contextUsage }) }),
expect.anything(),
);
});
it('resumes an ask_user_question with the free-form answer', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserJob());
const res = await post({
conversationId: CONVO_ID,
actionId: ACTION_ID,
agent_id: AGENT_ID,
endpoint: 'agents',
answer: 'call it report.pdf',
});
expect(res.status).toBe(200);
await settled;
await flush();
const client = await mockInitializeClient.mock.results[0].value.then((r) => r.client);
expect(client.resumeCompletion).toHaveBeenCalledWith(
expect.objectContaining({ resumeValue: { answer: 'call it report.pdf' } }),
);
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
});
it('generates a title for a first-turn pause before completing the stream', async () => {
const job = makeToolApprovalJob();
job.metadata.userMessage.parentMessageId = Constants.NO_PARENT;
mockGenerationJobManager.getJob.mockResolvedValue(job);
mockGetConvo.mockResolvedValue({ title: 'New Chat' });
await post(approveBody());
await settled;
await flush();
expect(mockAddTitle).toHaveBeenCalledTimes(1);
// Title is emitted (and the job completed) — order matters but both must happen.
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
});
it('still finalizes the turn when first-turn title generation throws', async () => {
const job = makeToolApprovalJob();
job.metadata.userMessage.parentMessageId = Constants.NO_PARENT;
mockGenerationJobManager.getJob.mockResolvedValue(job);
mockGetConvo.mockResolvedValue({ title: 'New Chat' });
// Title generation is best-effort: a throw must not break the resumed turn.
mockAddTitle.mockRejectedValue(new Error('title service down'));
await post(approveBody());
await settled;
await flush();
expect(mockLogger.error).toHaveBeenCalled();
expect(mockSaveMessage).toHaveBeenCalledTimes(1);
expect(mockGenerationJobManager.emitDone).toHaveBeenCalledWith(CONVO_ID, expect.any(Object));
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
});
});
describe('non-finalizing outcomes', () => {
it('re-pause: does not finalize when the run pauses again', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockInitializeClient.mockResolvedValue({
client: makeClient({ pendingApproval: true }),
userMCPAuthMap: {},
});
const res = await post(approveBody());
expect(res.status).toBe(200);
await settled;
await flush();
// It persists progress (unfinished) but must NOT finalize the turn.
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ unfinished: true }),
expect.anything(),
);
expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
expect(mockDeleteAgentCheckpoint).not.toHaveBeenCalled();
// The slot is still released and the client disposed.
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
expect(mockDisposeClient).toHaveBeenCalledTimes(1);
});
it('re-pause: persists the segment content (unfinished) so an expiring re-pause keeps it', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockInitializeClient.mockResolvedValue({
client: makeClient({
pendingApproval: true,
contentParts: [{ type: 'text', text: 'streamed this segment' }],
artifactPromises: [],
}),
userMCPAuthMap: {},
});
const res = await post(approveBody());
expect(res.status).toBe(200);
await settled;
await flush();
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
content: [{ type: 'text', text: 'streamed this segment' }],
unfinished: true,
}),
expect.objectContaining({
context: 'api/server/controllers/agents/resume.js - re-pause progress persist',
}),
);
expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
});
it('re-pause: persists artifacts produced before pausing again (unfinished)', async () => {
const artifact = { type: 'image', file_id: 'seg-1' };
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockInitializeClient.mockResolvedValue({
client: makeClient({
pendingApproval: true,
artifactPromises: [Promise.resolve(artifact)],
}),
userMCPAuthMap: {},
});
const res = await post(approveBody());
expect(res.status).toBe(200);
await settled;
await flush();
// No finalize, but the segment's artifact is persisted unfinished so the next
// resume's finalize can merge it (otherwise the fresh client drops it).
expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ attachments: [artifact], unfinished: true }),
expect.objectContaining({
context: 'api/server/controllers/agents/resume.js - re-pause progress persist',
}),
);
});
it('abort-during-resume: lets the abort route finalize, does not double-save', async () => {
const job = makeToolApprovalJob();
mockGenerationJobManager.getJob.mockResolvedValue(job);
mockInitializeClient.mockImplementation(async () => {
job.abortController.abort();
return { client: makeClient(), userMCPAuthMap: {} };
});
const res = await post(approveBody());
expect(res.status).toBe(200);
await settled;
await flush();
expect(mockSaveMessage).not.toHaveBeenCalled();
expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
});
it('resume failure: emits an error, finalizes the job, and prunes the checkpoint', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockInitializeClient.mockResolvedValue({
client: makeClient({
resumeCompletion: jest.fn().mockRejectedValue(new Error('boom')),
}),
userMCPAuthMap: {},
});
const res = await post(approveBody());
expect(res.status).toBe(200); // already ACKed before the failure
await settled;
await flush();
expect(mockGenerationJobManager.emitError).toHaveBeenCalledWith(CONVO_ID, 'boom');
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID, 'boom');
expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith(CONVO_ID, { type: 'mongo' });
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
expect(mockSaveMessage).not.toHaveBeenCalled();
});
it('forces a terminal job state when completeJob also fails during a resume error', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockInitializeClient.mockResolvedValue({
client: makeClient({
resumeCompletion: jest.fn().mockRejectedValue(new Error('boom')),
}),
userMCPAuthMap: {},
});
// The error path's completeJob also fails → last-resort updateJob must force a
// terminal state so the job isn't orphaned in `running`.
mockGenerationJobManager.completeJob.mockRejectedValue(new Error('complete failed'));
await post(approveBody());
await settled;
await flush();
expect(mockJobStore.updateJob).toHaveBeenCalledWith(
CONVO_ID,
expect.objectContaining({ status: 'error', error: 'Resume failed' }),
);
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
});
});
});