Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c7bd2d1
📴 fix: Finalize Compaction Snapshots and Move Abort Policy Into packa…
berry-13 Sep 28, 2026
4808452
🔕 fix: Inspect Every Part When Finalizing and Reuse the Loaded Row
berry-13 Sep 28, 2026
82f630a
📴 fix: Settle Finalized Compaction Rows and Verify Their Anchor
berry-13 Sep 28, 2026
0cf711e
📴 fix: Withhold the Unanchored Abort Final and Settle Marked Snapshots
berry-13 Sep 28, 2026
4970dab
📴 fix: Consolidate the Compaction Abort Policy Into One Injected Oper…
berry-13 Sep 28, 2026
d0cc1b5
📴 fix: Settle the Live Compaction Row Past an Anchor-Shaped Collision
berry-13 Sep 28, 2026
a8b5b0f
📴 fix: Lift the Failed-Turn Settlement Into the Compaction Module
berry-13 Sep 28, 2026
6ef4a08
📴 fix: Keep a Removed Round's Failure and Guard Settled Jobs From Sna…
berry-13 Sep 28, 2026
6709a54
⚡ perf: Read Only the Anchor Id in the Abort Existence Check
berry-13 Sep 28, 2026
ef67844
📴 fix: Withhold the Abort Final Only When a Row Needed Writing
berry-13 Sep 28, 2026
f2dceb4
test: Cover Stopped Compaction Finalization and the Missing Anchor in…
berry-13 Oct 2, 2026
3a70382
fix: Withhold a Stopped Compaction That Carries No Anchor Id
berry-13 Oct 2, 2026
d4e8dfb
fix: Settle Stopped Compactions, Announce Finalized Rows, and Own the…
berry-13 Oct 2, 2026
c725b37
test: Accept the Model Spec Query on the Stopped Reply Conversation URL
berry-13 Oct 2, 2026
1cfb300
fix: Report the Anchor Read Failure Beside a Withheld Compaction Abort
berry-13 Oct 2, 2026
e0f8e94
fix: Synthesize a Compaction Failure Only for a Round After the Lates…
berry-13 Oct 2, 2026
73acf4a
fix: Skip the Anchor Read When a Stopped Turn Writes No Row
berry-13 Oct 2, 2026
9eca66c
fix: Decide the Disconnect Snapshot Gate in packages/api
berry-13 Oct 3, 2026
4a96922
fix: Leave an Already Settled Compaction Row to the Path That Settled It
berry-13 Oct 4, 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 @@ -57,6 +57,10 @@ jest.mock('@librechat/api', () => ({
resolveResumableRetention: jest.requireActual('@librechat/api').resolveResumableRetention,
markAbortedCompactionContent: (...args) =>
jest.requireActual('@librechat/api').markAbortedCompactionContent(...args),
resolveDisconnectSnapshotMode: (...args) =>
jest.requireActual('@librechat/api').resolveDisconnectSnapshotMode(...args),
settleExistingRowsBeforeErrorTurn: (...args) =>
jest.requireActual('@librechat/api').settleExistingRowsBeforeErrorTurn(...args),
sendEvent: jest.fn(),
persistedReasoningOverrideFields:
jest.requireActual('@librechat/api').persistedReasoningOverrideFields,
Expand Down Expand Up @@ -342,4 +346,16 @@ describe('ResumableAgentController tenant context', () => {
});
expect(savedMessage.content).toHaveLength(1);
});
/** The settling path (completion, error, abort) owns the final row: a
* disconnect snapshot landing after it would reopen the settled turn as
* an unfinished response. */
it('skips the 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).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ jest.mock('@librechat/api', () => ({
resolveResumableRetention: jest.requireActual('@librechat/api').resolveResumableRetention,
markAbortedCompactionContent: (...args) =>
jest.requireActual('@librechat/api').markAbortedCompactionContent(...args),
resolveDisconnectSnapshotMode: (...args) =>
jest.requireActual('@librechat/api').resolveDisconnectSnapshotMode(...args),
settleExistingRowsBeforeErrorTurn: (...args) =>
jest.requireActual('@librechat/api').settleExistingRowsBeforeErrorTurn(...args),
sendEvent: jest.fn(),
/** Real, because whether a skipped-persistence turn may raise an indicator is under test. */
isAnnounceableReply: jest.requireActual('@librechat/api').isAnnounceableReply,
Expand Down
59 changes: 42 additions & 17 deletions api/server/controllers/agents/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ const {
stampPreliminaryPrivateTextMessage,
announceReply,
announceErrorTurn,
settleExistingRowsBeforeErrorTurn,
resolveDisconnectSnapshotMode,
markAbortedCompactionContent,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
Expand Down Expand Up @@ -458,23 +460,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 @@ -485,6 +470,35 @@ 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 coveredByExistingRow = 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',
}),
announceSettledTurn: (messageId) =>
announceErrorTurn(
{ stampConvoLastResponse },
{
userId,
conversationId,
messageId,
isTemporary: reqCtx.isTemporary,
context: 'AgentController - finalized failed compaction turn',
},
),
});
if (coveredByExistingRow) {
return;
Comment on lines +498 to +499

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Announce the finalized partial error row

When a disconnected compaction has already saved a partial row and generation then fails, settleExistingRowsBeforeErrorTurn finalizes that row and this early return skips both saveConvo and announceErrorTurn below. Consequently lastResponseAt is not stamped and another device has no notification that the persisted turn ended, even though the ordinary error-row path explicitly performs that announcement. Announce the finalized live row before returning so failure and restored-session behavior remain consistent.

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 c95e57e. settleExistingRowsBeforeErrorTurn takes an injected announceSettledTurn and calls it only when it actually finalized the live row; request.js passes announceErrorTurn with stampConvoLastResponse. compaction.spec.ts covers both the announce and the no-write case.

}

const context = 'api/server/controllers/agents/request.js - failed turn';
const endpoint = endpointOption?.endpoint;
const model = getAgentResponseModel(req, endpointOption);
Expand Down Expand Up @@ -596,6 +610,13 @@ async function saveErrorTurn(
}
}

/**
* The disconnect save is marker-only while the run is still live; a failed
* turn is what settles it, so a compaction's partial row is finalized here
* with the terminal outcome instead of keeping the snapshot's live-run
* marking. The decision lives in @librechat/api; this is the wiring, reusing
* the row the caller already loaded.
*/
function classifyScheduledFailure(error, aborted = false) {
if (aborted || error?.code === 'SCHEDULE_NO_LONGER_ACTIVE') {
return { status: 'interrupted', error: error?.message };
Expand Down Expand Up @@ -1937,6 +1958,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
* record is the source, since the client-facing resume snapshot never
* carries server-private state. */
const contextMeta = jobRecord?.createdAt === jobCreatedAt ? jobRecord.contextMeta : undefined;
if (resolveDisconnectSnapshotMode(jobRecord, jobCreatedAt) === 'skip') {
logger.debug('[ResumableAgentController] Skipping partial response save for a settled job');
return;
}

try {
const partialMessage = {
Expand Down
64 changes: 63 additions & 1 deletion api/server/routes/agents/__tests__/abort.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const mockSaveMessage = jest.fn();
const mockHasPersistedPrivateText = jest.fn();
const mockGetPrivateMessageTexts = jest.fn();
const mockSaveConvo = jest.fn();
const mockGetMessages = jest.fn(async () => [{ _id: 'existing-anchor' }]);

const mockRecordScheduleOutcome = jest.fn();
const mockBeginScheduledStop = jest.fn();
Expand Down Expand Up @@ -55,6 +56,7 @@ jest.mock('~/models', () => ({
(await mockHasPersistedPrivateText(...args)) ? 'protected-row-id' : null,
getPrivateMessageTexts: (...args) => mockGetPrivateMessageTexts(...args),
saveConvo: (...args) => mockSaveConvo(...args),
getMessages: (...args) => mockGetMessages(...args),
}));

jest.mock('~/server/services/Schedules', () => ({
Expand Down Expand Up @@ -437,12 +439,72 @@ describe('Agent Abort Endpoint', () => {
expect.objectContaining({
messageId: compactionRowId,
parentMessageId: anchorId,
unfinished: true,
unfinished: false,
isCreatedByUser: false,
}),
expect.objectContaining({ context: expect.stringContaining('abort endpoint') }),
);
});

/** Stop can win the race before the branch loaded, so the projected
* anchor names a row that was never written: a response persisted
* there would be orphaned on reload. */
it('persists nothing when the compaction anchor was never written', async () => {
mockGetMessages.mockResolvedValueOnce([]);
const jobStreamId = 'test-stream-compact-unanchored';

mockGenerationJobManager.getJob.mockResolvedValue({
metadata: { userId: 'test-user-123', generationProtocolVersion: 2 },
});

const abortResult = {
success: true,
jobData: {
compact: true,
createdEventEmitted: true,
userMessage: {
messageId: 'never-persisted-leaf',
parentMessageId: 'older-response',
conversationId: jobStreamId,
text: '',
},
responseMessageId: 'compaction-response-2',
conversationId: jobStreamId,
endpoint: 'agents',
sender: 'TestAgent',
model: 'agent-1',
},
content: [
{
type: 'error',
error: JSON.stringify({ type: 'compaction_failed' }),
initiatedBy: 'user',
},
],
text: '',
};
let beforePublishError;
mockGenerationJobManager.abortJob.mockImplementation(async (_streamId, options) => {
try {
await options.beforePublish(abortResult);
} catch (error) {
/** The manager catches this failure and publishes a
* reconciliation frame instead of the normal FINAL. */
beforePublishError = error;
}
return abortResult;
});

const response = await request(app)
.post('/api/agents/chat/abort')
.set('X-LibreChat-Generation-Protocol', '2')
.send({ conversationId: jobStreamId, generationProtocolVersion: 2 });

expect(response.status).toBe(200);
expect(mockSaveMessage).not.toHaveBeenCalled();
expect(beforePublishError).toBeInstanceOf(Error);
expect(beforePublishError.message).toContain('anchor unavailable');
});
});

describe('Partial Response Saving', () => {
Expand Down
22 changes: 15 additions & 7 deletions api/server/routes/agents/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
TERMINAL_PUBLICATION_RECONNECT_ERROR,
hasPersistableAbortContent,
announceStoppedReply,
resolveAbortedTurnPersistence,
buildAbortedResponseMetadata,
isPendingActionStale,
toClientPendingAction,
Expand Down Expand Up @@ -56,6 +57,7 @@ const {
} = require('~/server/controllers/agents/protocol');
const {
getFiles,
getMessages,
saveMessage,
saveConvo,
getPersistedPrivateTextId,
Expand Down Expand Up @@ -789,15 +791,21 @@ router.post('/chat/abort', chatConfigMiddleware, async (req, res, next) => {
* its parent and the preliminary-parent fence correctly rejects it. */
const shouldPersistAbortedTurn =
hasPersistableAbortContent(content) || jobData?.createdEventEmitted === true;
/** A compaction's `userMessage` is the already-persisted leaf
* projected for identity only; upserting it would erase a user
* leaf's text or turn an assistant leaf into an empty user row. */
const shouldPersistAnchor = jobData?.compact !== true;
/** The stopped turn's persistence plan (which rows to write, and
* whether the normal FINAL must be withheld for a reconciliation
* frame instead) comes from @librechat/api, decided from the
* compaction anchor this route reads. */
const abortPersistencePlan = await resolveAbortedTurnPersistence(
jobData,
shouldPersistAbortedTurn,
{ userId: req?.user?.id, getMessages },
);
persistenceErrors.push(...abortPersistencePlan.persistenceErrors);

if (
jobData?.userMessage?.messageId &&
jobData?.responseMessageId &&
shouldPersistAbortedTurn
abortPersistencePlan.writeResponseRow

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Persist stopped compactions as settled

When a compaction with a persisted anchor is stopped, this branch writes the response with unfinished: true at line 821. Since abortJob has already won terminal ownership, the request controller exits without a later final save, so the row remains unfinished after reload and the new E2E assertion in compaction-abort-finalize.spec.ts:102 cannot pass. Persist compaction aborts with a settled envelope while retaining the unfinished behavior for ordinary stopped replies.

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 c95e57e. The abort plan now carries responseUnfinished (false for a compaction, true for an ordinary turn) and the route writes it. The e2e scenario stopped-compaction-settles-under-its-anchor reproduced this on fbaf453 (stored row unfinished: true on all three projects) and passes on 170672d; stopped-reply-keeps-turn-and-partial-answer covers the ordinary path.

) {
const messageContext = {
userId: req?.user?.id,
Expand Down Expand Up @@ -827,7 +835,7 @@ router.post('/chat/abort', chatConfigMiddleware, async (req, res, next) => {
endpoint: jobData.endpoint,
iconURL: jobData.iconURL,
model: jobData.model,
unfinished: true,
unfinished: abortPersistencePlan.responseUnfinished,
Comment thread
berry-13 marked this conversation as resolved.
error: false,
isCreatedByUser: false,
...(Array.isArray(jobData.userSubmittedPaths) &&
Expand Down Expand Up @@ -859,7 +867,7 @@ router.post('/chat/abort', chatConfigMiddleware, async (req, res, next) => {
* operation gets a chance to succeed. A compaction skips the
* prerequisite: its anchor is the persisted leaf itself. */
let persistedRequestId;
if (shouldPersistAnchor) {
if (abortPersistencePlan.writeUserRow) {
try {
const persistedRequest = await saveAbortedUserMessage(
{ saveMessage, getPersistedPrivateTextId, getPrivateMessageTexts },
Expand Down
Loading
Loading