Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6f93ddb
📴 fix: Persist Cancelled Compactions With Their Identity
berry-13 Sep 28, 2026
bd24612
🧪 test: Build the Cancelled-Compaction Leaf on a Real Conversation
berry-13 Sep 28, 2026
cc3851f
🧪 test: Assert the Withheld Controls on the Live Cancelled Compaction
berry-13 Sep 28, 2026
dd20c8a
📴 fix: Keep Live Disconnect Compaction Rows Free of a Synthesized Fai…
berry-13 Sep 28, 2026
5a6b8b7
🧪 test: Cover the Compaction Flag in the Redis Job Round Trip
berry-13 Sep 28, 2026
92d0b0f
📴 fix: Keep a Stopped Compaction Honest and Its Anchor Untouched
berry-13 Sep 28, 2026
a83adc8
📴 fix: Finalize Compaction Snapshots and Move Abort Policy Into packa…
berry-13 Sep 28, 2026
851017d
🔕 fix: Inspect Every Part When Finalizing and Reuse the Loaded Row
berry-13 Sep 28, 2026
4bd15d9
📴 fix: Settle Finalized Compaction Rows and Verify Their Anchor
berry-13 Sep 28, 2026
ed11f82
📴 fix: Withhold the Unanchored Abort Final and Settle Marked Snapshots
berry-13 Sep 28, 2026
ea266b5
📴 fix: Consolidate the Compaction Abort Policy Into One Injected Oper…
berry-13 Sep 28, 2026
bb9ebea
📴 fix: Settle the Live Compaction Row Past an Anchor-Shaped Collision
berry-13 Sep 28, 2026
e223e91
📴 fix: Lift the Failed-Turn Settlement Into the Compaction Module
berry-13 Sep 28, 2026
3490456
📴 fix: Keep a Removed Round's Failure and Guard Settled Jobs From Sna…
berry-13 Sep 28, 2026
8d53e95
⚡ perf: Read Only the Anchor Id in the Abort Existence Check
berry-13 Sep 28, 2026
e4c194b
📴 fix: Withhold the Abort Final Only When a Row Needed Writing
berry-13 Sep 28, 2026
c77c793
📴 fix: Stamp Legacy Failure Parts When Settling a Compaction Row
berry-13 Sep 28, 2026
b724f5e
📴 fix: Redirect the Collision Error Row to the Live Response Id
berry-13 Sep 28, 2026
fb85ea6
📴 fix: Withhold the Anchor-Colliding Error Row and Keep Pending Rows …
berry-13 Sep 28, 2026
2702225
📴 fix: Scope the Settled-Job Guard to Compactions and Copy Stamped Parts
berry-13 Sep 28, 2026
2485ce8
📴 fix: Decide the Disconnect Snapshot in the Compaction Module
berry-13 Sep 28, 2026
86e701b
📴 fix: Keep the Snapshot When the Terminal Write Settled for Reconcil…
berry-13 Sep 28, 2026
418342c
📴 fix: Promote a Reconciled Compaction Snapshot to the Terminal Row
berry-13 Sep 28, 2026
929997a
📴 fix: Anchor and Envelope the Reconciled Snapshot by Its Claim
berry-13 Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ jest.mock('@librechat/data-schemas', () => ({
jest.mock('@librechat/api', () => ({
getAgentErrorMetadata: (...args) =>
jest.requireActual('@librechat/api').getAgentErrorMetadata(...args),
markAbortedCompactionContent: (...args) =>
jest.requireActual('@librechat/api').markAbortedCompactionContent(...args),
isSettledJobRecord: (...args) => jest.requireActual('@librechat/api').isSettledJobRecord(...args),
resolveDisconnectSnapshotMode: (...args) =>
jest.requireActual('@librechat/api').resolveDisconnectSnapshotMode(...args),
resolveReconciledSnapshotEnvelope: (...args) =>
jest.requireActual('@librechat/api').resolveReconciledSnapshotEnvelope(...args),
settleExistingRowsBeforeErrorTurn: (...args) =>
jest.requireActual('@librechat/api').settleExistingRowsBeforeErrorTurn(...args),
sendEvent: jest.fn(),
isScheduleFireRequest: jest.fn(() => false),
exemptFromConcurrencyLimiter: jest.fn(() => false),
Expand Down Expand Up @@ -163,6 +172,7 @@ describe('ResumableAgentController tenant context', () => {
const firePartialDisconnect = async (
user,
jobRecord = { createdAt: 1000, contextMeta: partialContextMeta },
{ body = {}, aggregatedContent = [{ type: 'text', text: 'Partial response' }] } = {},
) => {
let allSubscribersLeftHandler;
mockGenerationJobManager.getJobStore.mockReturnValue({
Expand Down Expand Up @@ -210,6 +220,7 @@ describe('ResumableAgentController tenant context', () => {
endpoint: 'agents',
modelOptions: { model: 'gpt-4.1' },
},
...body,
},
config: {},
};
Expand All @@ -222,7 +233,7 @@ describe('ResumableAgentController tenant context', () => {
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(allSubscribersLeftHandler).toEqual(expect.any(Function));

await allSubscribersLeftHandler([{ type: 'text', text: 'Partial response' }]);
await allSubscribersLeftHandler(aggregatedContent);
return tenantSeenBySave;
};

Expand Down Expand Up @@ -268,4 +279,180 @@ describe('ResumableAgentController tenant context', () => {
expect(tenantSeenBySave).toBeUndefined();
expect(mockSaveMessage).toHaveBeenCalledTimes(1);
});

/** A cancelled compaction's partial row is built here, not by sendCompletion,
* so it carries no marker unless the disconnect path stamps one: without it
* the row reads as an answer to the message it hangs off and keeps that
* message's rerun controls. */
it('stamps a partial response saved on disconnect with the compaction identity', async () => {
await firePartialDisconnect(
{ id: 'user-123' },
{ createdAt: 1000 },
{
body: { compact: true },
aggregatedContent: [
{
type: 'summary',
content: [{ type: 'text', text: 'Half a summary' }],
summarizing: true,
},
],
},
);

const [, savedMessage] = mockSaveMessage.mock.calls[0];
expect(savedMessage).toMatchObject({
messageId: 'response-message',
unfinished: true,
error: false,
content: [{ type: 'summary', summarizing: true, initiatedBy: 'user' }],
});
});

/** The disconnect save runs while the generation is still live and the
* completing run overwrites the row, so it must not report a failure that
* has not happened: no typed failure is invented for a compaction whose
* snapshot carries no summary or error part. */
it('saves a non-outcome compaction partial on disconnect without a synthesized failure', async () => {
await firePartialDisconnect(
{ id: 'user-123' },
{ createdAt: 1000 },
{
body: { compact: true },
aggregatedContent: [{ type: 'think', think: 'Picking what to summarize' }],
},
);

const [, savedMessage] = mockSaveMessage.mock.calls[0];
expect(savedMessage).toMatchObject({
unfinished: true,
error: false,
content: [{ type: 'think', think: 'Picking what to summarize' }],
});
expect(savedMessage.content).toHaveLength(1);
});
/** The settling path (completion, error, abort) owns the compaction's final
* row: a disconnect snapshot landing after it would reopen the settled
* turn as an unfinished response. */
it('skips the compaction partial save when the job record has settled', async () => {
await firePartialDisconnect(
{ id: 'user-123' },
{ createdAt: 1000, status: 'error' },
{
body: { compact: true },
aggregatedContent: [{ type: 'text', text: 'Partial response' }],
},
);

expect(mockSaveMessage).not.toHaveBeenCalled();
});

/** Ordinary turns keep the pre-change behavior exactly: their snapshot is
* the fallback row even when the job record has settled, because the
* terminal row write may still fail. */
it('still persists an ordinary partial save when the job record has settled', async () => {
await firePartialDisconnect(
{ id: 'user-123' },
{ createdAt: 1000, status: 'error' },
{ aggregatedContent: [{ type: 'text', text: 'Partial response' }] },
);

expect(mockSaveMessage).toHaveBeenCalledTimes(1);
const [, savedMessage] = mockSaveMessage.mock.calls[0];
expect(savedMessage).toMatchObject({ unfinished: true, error: false });
});
/** The terminal write failed and settled for a reconciliation frame: the
* snapshot is the turn's only row, so it persists with the terminal
* outcome (the typed failure for content with no summary) and envelope. */
it('promotes a reconciled compaction snapshot to the terminal row', async () => {
mockGetMessages.mockResolvedValue([{ _id: 'existing-anchor' }]);
await firePartialDisconnect(
{ id: 'user-123' },
{
createdAt: 1000,
status: 'error',
finalEvent: JSON.stringify({ final: true, reconcile: true }),
},
{
body: { compact: true },
aggregatedContent: [{ type: 'think', think: 'Picking what to summarize' }],
},
);

expect(mockSaveMessage).toHaveBeenCalledTimes(1);
const [, savedMessage] = mockSaveMessage.mock.calls[0];
expect(savedMessage).toMatchObject({ unfinished: false, error: true });
expect(savedMessage.content).toEqual([
{ type: 'think', think: 'Picking what to summarize' },
expect.objectContaining({ type: 'error', initiatedBy: 'user' }),
]);
});
/** Without a persisted anchor the promotion is withheld: the fallback must
* not recreate the orphan the absent-anchor abort suppressed. */
it('withholds a reconciled snapshot whose anchor was never persisted', async () => {
await firePartialDisconnect(
{ id: 'user-123' },
{
createdAt: 1000,
status: 'error',
finalEvent: JSON.stringify({ final: true, reconcile: true }),
},
{
body: { compact: true },
aggregatedContent: [{ type: 'text', text: 'Partial response' }],
},
);

expect(mockSaveMessage).not.toHaveBeenCalled();
});

/** An empty snapshot on a reconciled compaction still records the turn:
* the terminal synthesis appends the typed failure from nothing. */
it('synthesizes the terminal outcome for an empty reconciled compaction snapshot', async () => {
mockGetMessages.mockResolvedValue([{ _id: 'existing-anchor' }]);
await firePartialDisconnect(
{ id: 'user-123' },
{
createdAt: 1000,
status: 'error',
finalEvent: JSON.stringify({ final: true, reconcile: true }),
},
{ body: { compact: true }, aggregatedContent: [] },
);

expect(mockSaveMessage).toHaveBeenCalledTimes(1);
const [, savedMessage] = mockSaveMessage.mock.calls[0];
expect(savedMessage).toMatchObject({ unfinished: false, error: true });
expect(savedMessage.content).toEqual([
expect.objectContaining({ type: 'error', initiatedBy: 'user' }),
]);
});

/** A completed claim's promoted snapshot settles as a finished row, not an
* error. */
it('settles a completed reconciled snapshot as a finished row', async () => {
mockGetMessages.mockResolvedValue([{ _id: 'existing-anchor' }]);
await firePartialDisconnect(
{ id: 'user-123' },
{
createdAt: 1000,
status: 'complete',
finalEvent: JSON.stringify({ final: true, reconcile: true }),
},
{
body: { compact: true },
aggregatedContent: [
{
type: 'summary',
content: [{ type: 'text', text: 'A finished checkpoint.' }],
boundary: { messageId: 'm', contentIndex: 0 },
},
],
},
);

expect(mockSaveMessage).toHaveBeenCalledTimes(1);
const [, savedMessage] = mockSaveMessage.mock.calls[0];
expect(savedMessage).toMatchObject({ unfinished: false, error: false });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,15 @@ jest.mock('@librechat/api', () => ({
).getSteerRecoveryFailure,
getAgentErrorMetadata: (...args) =>
jest.requireActual('@librechat/api').getAgentErrorMetadata(...args),
markAbortedCompactionContent: (...args) =>
jest.requireActual('@librechat/api').markAbortedCompactionContent(...args),
isSettledJobRecord: (...args) => jest.requireActual('@librechat/api').isSettledJobRecord(...args),
resolveDisconnectSnapshotMode: (...args) =>
jest.requireActual('@librechat/api').resolveDisconnectSnapshotMode(...args),
resolveReconciledSnapshotEnvelope: (...args) =>
jest.requireActual('@librechat/api').resolveReconciledSnapshotEnvelope(...args),
settleExistingRowsBeforeErrorTurn: (...args) =>
jest.requireActual('@librechat/api').settleExistingRowsBeforeErrorTurn(...args),
sendEvent: jest.fn(),
logAgentMemorySnapshot: jest.fn(),
isScheduleFireRequest: (...args) => mockIsScheduleFireRequest(...args),
Expand Down
107 changes: 81 additions & 26 deletions api/server/controllers/agents/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ const {
resolvePersistableCodeEnvironmentDecision,
getFailedTurnTraceFields,
resolveFailedTurnContent,
settleExistingRowsBeforeErrorTurn,
resolveDisconnectSnapshotMode,
resolveReconciledSnapshotEnvelope,
markAbortedCompactionContent,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
const {
Expand Down Expand Up @@ -426,23 +430,6 @@ async function saveErrorTurn(
}

const userId = req.user.id;
const existing = await getMessages(
{ user: userId, messageId: errorMessageId, conversationId },
'_id',
);
if (existing.length > 0) {
return;
}
if (liveResponseMessageId != null && liveResponseMessageId !== errorMessageId) {
const partial = await getMessages(
{ user: userId, messageId: liveResponseMessageId, conversationId },
'_id',
);
if (partial.length > 0) {
return;
}
}

const reqCtx = {
userId,
isTemporary:
Expand All @@ -453,6 +440,29 @@ async function saveErrorTurn(
req?._agentEventBindingRetention?.expiredAt ?? req?.resolvedConversation?.expiredAt,
interfaceConfig: req?.config?.interfaceConfig,
};
/** The existing-row settlement (which row a failed turn settles, and
* whether its error row may be written at all) lives in @librechat/api;
* this supplies the caller's reads and write. */
const settlement = await settleExistingRowsBeforeErrorTurn(req.body, {
userId,
conversationId,
errorMessageId,
liveResponseMessageId,
getMessages,
saveFinalizedTurn: (message) =>
saveMessage(reqCtx, message, {
context: 'api/server/controllers/agents/request.js - finalize failed compaction turn',
}),
});
if (settlement.covered) {
return;
}
/** The anchor-shaped collision redirects the error row to the failed
* run's own response id, so it can never overwrite the anchor. */
if (settlement.errorRowMessageId != null) {
errorMessageId = settlement.errorRowMessageId;
}

const context = 'api/server/controllers/agents/request.js - failed turn';
const endpoint = endpointOption?.endpoint;
const model = getAgentResponseModel(req, endpointOption);
Expand Down Expand Up @@ -1663,6 +1673,9 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
* no user message of its own, the response parented onto an
* existing message. A reconnecting client rebuilds it that way. */
...((isRegenerate || isCompaction) && { isRegenerate: true }),
/** The job record is where the abort paths learn the turn was a
* compaction: they run after the request that created the job. */
...(isCompaction && { compact: true }),
...(scheduleId
? {
scheduleId,
Expand Down Expand Up @@ -1849,13 +1862,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
* overwrite this with the complete response using the same messageId pattern.
*/
job.emitter.on('allSubscribersLeft', async (aggregatedContent) => {
if (partialResponseSaved || !aggregatedContent || aggregatedContent.length === 0) {
return;
}

const persistableContent = filterPersistableAbortContent(aggregatedContent);
if (persistableContent.length === 0) {
logger.debug('[ResumableAgentController] No persistable content to save partial response');
/** Empty content is rejected only after the snapshot mode is known: a
* reconciled compaction synthesizes its terminal outcome from nothing,
* while a live run has nothing persistable. */
if (partialResponseSaved || !aggregatedContent) {
return;
Comment on lines +1868 to 1869

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Finalize snapshots saved before reconciliation

When the first disconnect occurs while a compaction is live, this handler saves an unfinished: true snapshot and sets partialResponseSaved; if the completed response write later fails and the terminal claim settles to a reconciliation frame, there may be no further disconnect callback, and any callback that does occur returns here before resolveDisconnectSnapshotMode can promote the row. Unlike the previously reviewed delayed-first-callback ordering, the fresh evidence is this successful-earlier-callback ordering: a completed job can remain permanently stored as an unfinished live response after reload. Settle the already-saved snapshot from the terminal persistence-failure path, or allow reconciliation to reprocess it.

AGENTS.md reference: AGENTS.md:L45-L51

Useful? React with 👍 / 👎.

}

Expand All @@ -1868,6 +1878,47 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
return;
}

/** How this snapshot may persist is decided in @librechat/api: live
* runs keep the marker-only shape, a failed terminal write (settled
* for a reconciliation frame) promotes the snapshot to the turn's
* terminal row, and a durably settled compaction withholds it. */
const snapshotMode = await resolveDisconnectSnapshotMode(
isCompaction,
jobRecord,
jobCreatedAt,
{
anchorExists: async () =>
(
await getMessages(
{
user: userId,
messageId: resumeState.userMessage.messageId,
conversationId,
},
'_id',
)
).length > 0,
},
);
if (snapshotMode === 'skip') {
logger.debug(
'[ResumableAgentController] Skipping compaction partial save for a settled job',
);
return;
}
if (snapshotMode !== 'terminal' && aggregatedContent.length === 0) {
return;
}
const persistableContent = markAbortedCompactionContent(
filterPersistableAbortContent(aggregatedContent),
isCompaction,
{ synthesizeFailure: snapshotMode === 'terminal' },
Comment on lines +1912 to +1915

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let empty reconciled compactions reach terminal synthesis

When Stop or the error path reconciles after its primary write fails before any content was emitted, closing the remaining subscriber invokes this handler with aggregatedContent: [], but the initial length guard returns before this terminal-mode synthesis can append compaction_failed. The reconciliation frame is then durable while no response row records the cancelled compaction at all. Load the terminal mode before rejecting empty content so reconciled compactions can synthesize their terminal outcome, while still skipping empty snapshots for live runs.

AGENTS.md reference: AGENTS.md:L49-L52

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 929997a. Empty content is rejected only for live snapshots; a promoted terminal snapshot synthesizes the typed failure from nothing, with a controller regression.

);
if (persistableContent.length === 0) {
logger.debug('[ResumableAgentController] No persistable content to save partial response');
return;
}

partialResponseSaved = true;
const responseConversationId = resumeState.conversationId || conversationId;
/** The run publishes its calibration and fading tiers onto the job; a
Expand All @@ -1885,8 +1936,12 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
parentMessageId: resumeState.userMessage.messageId,
sender: client?.sender ?? 'AI',
content: persistableContent,
unfinished: true,
error: false,
/** A snapshot promoted to the terminal row settles with the
* envelope its reconciled claim's status dictates; a live-run
* snapshot keeps the live shape. */
...(snapshotMode === 'terminal'
? resolveReconciledSnapshotEnvelope(jobRecord?.status)
: { unfinished: true, error: false }),
isCreatedByUser: false,
user: userId,
endpoint: endpointOption.endpoint,
Expand Down
Loading
Loading