From caaa578a2798e70bd4f1d07ac0a8662359de0868 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:33:10 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=93=B4=20fix:=20Persist=20Cancelled?= =?UTF-8?q?=20Compactions=20With=20Their=20Identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request.partialDisconnect.spec.js | 35 +++++- .../__tests__/request.resumeMetadata.spec.js | 2 + api/server/controllers/agents/request.js | 9 +- .../compaction-rerun-controls.spec.ts | 108 ++++++++++++++++ packages/api/src/agents/compaction.spec.ts | 60 +++++++++ packages/api/src/agents/compaction.ts | 36 ++++++ .../api/src/stream/GenerationJobManager.ts | 8 ++ .../__tests__/abortCompactionIdentity.spec.ts | 117 ++++++++++++++++++ .../stream/implementations/RedisJobStore.ts | 1 + .../api/src/stream/interfaces/IJobStore.ts | 4 + packages/api/src/stream/metadata.ts | 3 + packages/api/src/types/stream.ts | 3 + 12 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js index 43e0f23daa6..2bdee75d661 100644 --- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js +++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js @@ -49,6 +49,8 @@ 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), sendEvent: jest.fn(), isScheduleFireRequest: jest.fn(() => false), exemptFromConcurrencyLimiter: jest.fn(() => false), @@ -163,6 +165,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({ @@ -210,6 +213,7 @@ describe('ResumableAgentController tenant context', () => { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' }, }, + ...body, }, config: {}, }; @@ -222,7 +226,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; }; @@ -268,4 +272,33 @@ 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' }], + }); + }); }); diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index ef482d04aec..f5e3512648c 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -257,6 +257,8 @@ jest.mock('@librechat/api', () => ({ ).getSteerRecoveryFailure, getAgentErrorMetadata: (...args) => jest.requireActual('@librechat/api').getAgentErrorMetadata(...args), + markAbortedCompactionContent: (...args) => + jest.requireActual('@librechat/api').markAbortedCompactionContent(...args), sendEvent: jest.fn(), logAgentMemorySnapshot: jest.fn(), isScheduleFireRequest: (...args) => mockIsScheduleFireRequest(...args), diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 7135ca4113d..f9e59351a3e 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -52,6 +52,7 @@ const { resolvePersistableCodeEnvironmentDecision, getFailedTurnTraceFields, resolveFailedTurnContent, + markAbortedCompactionContent, } = require('@librechat/api'); const { disposeClient } = require('~/server/cleanup'); const { @@ -1663,6 +1664,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, @@ -1853,7 +1857,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit return; } - const persistableContent = filterPersistableAbortContent(aggregatedContent); + const persistableContent = markAbortedCompactionContent( + filterPersistableAbortContent(aggregatedContent), + isCompaction, + ); if (persistableContent.length === 0) { logger.debug('[ResumableAgentController] No persistable content to save partial response'); return; diff --git a/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts b/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts index ef0bb10477a..7f15f9df690 100644 --- a/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts +++ b/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts @@ -117,6 +117,60 @@ async function compactWithEmptySummarizer(page: Page, request: APIRequestContext return { conversationId, compactionId: compactionId as string }; } +/** + * A real cancelled compaction: the fixture summarizer is held silent so the run + * is still summarizing when the composer's Stop fires, and the branch ends in + * a user message, the shape whose Regenerate would otherwise answer that user + * turn instead of redoing the compaction. Returns the conversation and the + * turn the cancelled compaction persisted. + */ +async function cancelCompactionOnUserLeaf(page: Page, request: APIRequestContext, label: string) { + const { answerId, messages } = precedingTurns(label); + const leafUserId = randomUUID(); + const conversationId = await seedBranch([ + ...messages, + { + messageId: leafUserId, + parentMessageId: answerId, + text: `Compact this before answering ${label}`, + isCreatedByUser: true, + sender: 'User', + }, + ]); + + const behavior = await request.post(`${LABEL_SERVER}/__e2e/behavior`, { + data: { mode: 'ok', delayMs: 60_000 }, + }); + expect(behavior.ok()).toBeTruthy(); + + await page.goto(`/c/${conversationId}`); + await page.getByTestId('token-usage').click(); + await page.getByRole('button', { name: 'Compact context' }).click(); + const stop = page.getByTestId('stop-generation-button'); + await expect(stop).toBeVisible(); + await stop.click(); + + /** The Stop route persists the aborted turn before it publishes the final + * event, so the row is in storage once the stop settles. */ + let compactionId: string | undefined; + await expect + .poll( + () => + withMongo(async (db) => { + const row = await db.collection('messages').findOne({ + conversationId, + parentMessageId: leafUserId, + isCreatedByUser: false, + }); + compactionId = row?.messageId as string | undefined; + return compactionId != null; + }), + { timeout: 20_000 }, + ) + .toBeTruthy(); + return { conversationId, compactionId: compactionId as string }; +} + test.describe('compaction rerun controls', () => { /** `compactWithEmptySummarizer` switches the shared fixture summarizer to * blank output before it returns, so a failure inside it would leave every @@ -315,6 +369,60 @@ test.describe('compaction rerun controls', () => { } }); + /* A real cancelled run, not a seeded row: the abort path owns the stopped + turn, so the marker has to be stamped where the aborted content is + assembled or the row keeps the user turn's rerun controls live. */ + test('a cancelled compaction on a user leaf offers no rerun controls @scenario:cancelled-compaction-on-user-turn-offers-no-rerun-controls', async ({ + page, + request, + }) => { + const { conversationId, compactionId } = await cancelCompactionOnUserLeaf( + page, + request, + 'cancelled-live', + ); + try { + const row = page.locator(`[id="${compactionId}"]`); + await expect(row).toBeVisible(); + await row.hover(); + + await expect(row.getByText('Could not compact the context', { exact: false })).toBeVisible(); + /* Replaying the user turn behind it would answer that message again rather + than redo the compaction, so the marker withholds the controls here too. */ + await expect(page.locator(`[id="edit-${compactionId}"]`)).toHaveCount(0); + await expect(page.getByTestId('regenerate-generation-button')).toHaveCount(0); + await expect(page.getByTestId('continue-generation-button')).toHaveCount(0); + /* The redo path a compaction keeps is the indicator's own action. */ + await page.getByTestId('token-usage').click(); + await expect(page.getByRole('button', { name: 'Compact context' })).toBeEnabled(); + } finally { + await cleanup(conversationId); + } + }); + + /* The same cancelled turn read back from storage: the row the abort path + persisted has to carry the marker a reload rebuilds it from. */ + test('a cancelled compaction on a user leaf stays free of rerun controls after a reload @scenario:cancelled-compaction-on-user-turn-survives-reload-without-rerun-controls', async ({ + page, + request, + }) => { + const { conversationId, compactionId } = await cancelCompactionOnUserLeaf( + page, + request, + 'cancelled-reload', + ); + try { + const row = await openRow(page, conversationId, compactionId); + + await expect(row.getByText('Could not compact the context', { exact: false })).toBeVisible(); + await expect(page.locator(`[id="edit-${compactionId}"]`)).toHaveCount(0); + await expect(page.getByTestId('regenerate-generation-button')).toHaveCount(0); + await expect(page.getByTestId('continue-generation-button')).toHaveCount(0); + } finally { + await cleanup(conversationId); + } + }); + test('a turn that only auto-summarized keeps its rerun controls @scenario:auto-summarized-turn-keeps-rerun-controls', async ({ page, }) => { diff --git a/packages/api/src/agents/compaction.spec.ts b/packages/api/src/agents/compaction.spec.ts index a8910eeb3e0..cdd487fab1e 100644 --- a/packages/api/src/agents/compaction.spec.ts +++ b/packages/api/src/agents/compaction.spec.ts @@ -15,6 +15,7 @@ import { dropUnusableSummaryParts, findCheckpointSummaryPart, getSummaryPartText, + markAbortedCompactionContent, markCompactionOutcome, resolveFailedTurnContent, resolveCheckpointMessage, @@ -317,6 +318,65 @@ describe('resolveCheckpointMessage', () => { }); }); +describe('markAbortedCompactionContent', () => { + const partialSummary = (text: string): TMessageContentParts => ({ + type: ContentTypes.SUMMARY, + /** Streamed deltas never carry a boundary; a stopped round keeps them. */ + content: [{ type: ContentTypes.TEXT, text }], + summarizing: true, + }); + + /** The abort path owns a cancelled run's row: its partial summary is kept + * (the turn is unfinished, not failed) but must still carry the marker, or + * on a branch ending in a user message the row keeps a Regenerate that + * answers that user turn instead of redoing the compaction. */ + it('marks the partial summary a stopped compaction had streamed', () => { + const parts = [partialSummary('Half a summary')]; + + markAbortedCompactionContent(parts, true); + + expect(parts).toHaveLength(1); + expect(parts[0]).toMatchObject({ initiatedBy: 'user', summarizing: true }); + }); + + /** Every part that can carry the marker gets it: the row's identity must not + * depend on which of its parts a reader inspects first. */ + it('marks an error part the stopped run had already recorded', () => { + const parts: TMessageContentParts[] = [ + partialSummary('Half a summary'), + { type: ContentTypes.ERROR, error: 'Something else failed first' }, + ]; + + markAbortedCompactionContent(parts, true); + + expect(parts[0]).toMatchObject({ initiatedBy: 'user' }); + expect(parts[1]).toMatchObject({ initiatedBy: 'user' }); + }); + + /** A run stopped before any part streamed still needs an identifiable row: + * an empty one reads as an answer to the message it hangs off. */ + it('records the typed failure when nothing streamed before the stop', () => { + const parts: TMessageContentParts[] = []; + + markAbortedCompactionContent(parts, true); + + expect(parts).toEqual([ + { + type: ContentTypes.ERROR, + error: JSON.stringify({ type: ErrorTypes.COMPACTION_FAILED }), + initiatedBy: 'user', + }, + ]); + }); + + it('returns content from a turn that was not a compaction unchanged', () => { + const parts = [partialSummary('An automatic detour partial')]; + + expect(markAbortedCompactionContent(parts, false)).toBe(parts); + expect(parts[0]).not.toHaveProperty('initiatedBy'); + }); +}); + describe('findCheckpointSummaryPart', () => { const legacySummary = { type: ContentTypes.SUMMARY, text: 'Summary of conversation' }; diff --git a/packages/api/src/agents/compaction.ts b/packages/api/src/agents/compaction.ts index 241c394965f..39b3149fb9e 100644 --- a/packages/api/src/agents/compaction.ts +++ b/packages/api/src/agents/compaction.ts @@ -196,6 +196,42 @@ export function resolveFailedTurnContent( return { content: compactionFailureContent(errorText) }; } +/** + * The content an aborted compaction persists: the run's stream-aggregated + * parts, carrying the marker that keeps the turn identifiable as a compaction. + * The abort path owns a cancelled run's row (a stopped turn is unfinished, not + * failed) and nothing else on that path knows the request was a compaction, so + * without this the row reads as an answer to the message it hangs off and keeps + * that message's rerun controls: on a branch ending in a user message, + * Regenerate would answer the user turn behind the compaction instead of + * redoing it. + * + * Every summary and error part is marked, the same stamp `markCompactionOutcome` + * puts on a completed run's outcome; a run stopped before any part streamed + * records the typed failure so the row still carries its identity. The parts + * are otherwise untouched, keeping the row's `unfinished` shape, and content + * from a turn that was not a compaction is returned unchanged. + */ +export function markAbortedCompactionContent( + contentParts: TMessageContentParts[], + isCompaction: boolean, +): TMessageContentParts[] { + if (!isCompaction) { + return contentParts; + } + let markedOutcome = false; + for (const part of contentParts) { + if (part?.type === ContentTypes.SUMMARY || part?.type === ContentTypes.ERROR) { + part.initiatedBy = 'user'; + markedOutcome = true; + } + } + if (!markedOutcome) { + contentParts.push(...compactionFailureContent()); + } + return contentParts; +} + /** * Stamps `initiatedBy: 'user'` on the part that carries a manual compaction's * outcome, which is the turn's only record of having been one: the run emits no diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 749db957bed..07267190587 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -91,6 +91,7 @@ import { } from './internal/timing'; import { filterPersistableAbortContent } from './abortContent'; import { toClientPendingAction } from '~/agents/hitl/policy'; +import { markAbortedCompactionContent } from '~/agents/compaction'; import { ApprovalLifecycle, pausePersistenceActionId } from './ApprovalLifecycle'; import { projectPendingMCPOAuthPrompts } from '~/mcp/oauth/resume'; import { sanitizeJobMetadata } from './metadata'; @@ -4799,6 +4800,13 @@ class GenerationJobManagerClass { // Filter only after the transform so sparse/empty/OAuth parts cannot // shift a retained ID-less ask answer onto a different tool call. abortContent = filterPersistableAbortContent(content); + // A stopped compaction is unfinished rather than failed, so the row keeps + // the abort shape, plus the marker that keeps it identifiable as the + // compaction's own turn instead of an answer to its parent. + abortContent = markAbortedCompactionContent( + abortContent as TMessageContentParts[], + jobData.compact === true, + ); shouldPersistAbortContent = abortContent.length > 0; text = shouldPersistAbortContent ? parseTextParts(abortContent as TMessageContentParts[], false, { diff --git a/packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts b/packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts new file mode 100644 index 00000000000..fa3c9c24cc4 --- /dev/null +++ b/packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts @@ -0,0 +1,117 @@ +/** + * A Stop persists the aborted turn from job data alone, so the row only knows + * the run was a compaction through the job's `compact` metadata. Without the + * marker the stopped row reads as an answer to the message it hangs off and + * keeps that message's rerun controls: on a branch ending in a user message, + * Regenerate would answer the user turn behind the compaction. + */ +import { ContentTypes, ErrorTypes } from 'librechat-data-provider'; +import type { Agents } from 'librechat-data-provider'; + +/** Suppress winston Console transport output (survives jest.resetModules) */ +jest.spyOn(console, 'log').mockImplementation(); + +const COMPACTION_FAILED_ERROR = JSON.stringify({ type: ErrorTypes.COMPACTION_FAILED }); + +/** Streamed deltas never carry a boundary; a stopped round keeps them. */ +const partialSummary: Agents.MessageContentComplex = { + type: ContentTypes.SUMMARY, + content: [{ type: ContentTypes.TEXT, text: 'Half a summary' }], + summarizing: true, +}; + +const partialText: Agents.MessageContentComplex = { type: ContentTypes.TEXT, text: 'Partial' }; + +/** The abort result's `finalEvent` is `unknown` to the interface; the fields + * these tests read are the ones the abort FINAL always carries. */ +type AbortFinalEvent = { + responseMessage?: { unfinished?: boolean; error?: boolean } | null; + earlyAbort?: boolean; +}; + +async function configureManager() { + const { GenerationJobManager } = await import('../GenerationJobManager'); + const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore'); + const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport'); + + const jobStore = new InMemoryJobStore(); + GenerationJobManager.configure({ + jobStore, + eventTransport: new InMemoryEventTransport(), + isRedis: false, + cleanupOnComplete: false, + }); + GenerationJobManager.initialize(); + return { manager: GenerationJobManager, jobStore }; +} + +describe('abortJob compaction identity', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('stamps the partial summary a stopped compaction had streamed', async () => { + const { manager, jobStore } = await configureManager(); + const streamId = 'abort-compaction-partial'; + const job = await manager.createJob(streamId, 'user-1', 'conversation-1', { + initialMetadata: { compact: true }, + }); + jobStore.setContentParts(streamId, [partialSummary], job.createdAt); + + const result = await manager.abortJob(streamId); + const finalEvent = result.finalEvent as AbortFinalEvent; + + expect(result.success).toBe(true); + /** The row keeps its unfinished shape; only the marker is added. */ + expect(finalEvent.responseMessage).toMatchObject({ unfinished: true, error: false }); + expect(result.content).toEqual([ + { ...partialSummary, initiatedBy: 'user' } as Agents.MessageContentComplex, + ]); + + await manager.destroy(); + }); + + /** A run stopped before any part streamed still needs an identifiable row: + * an empty one reads as an answer to the message it hangs off. */ + it('records the typed failure when a stopped compaction streamed nothing', async () => { + const { manager } = await configureManager(); + const streamId = 'abort-compaction-empty'; + await manager.createJob(streamId, 'user-1', 'conversation-1', { + initialMetadata: { compact: true }, + }); + + const result = await manager.abortJob(streamId); + const finalEvent = result.finalEvent as AbortFinalEvent; + const expectedContent: Agents.MessageContentComplex[] = [ + { + type: ContentTypes.ERROR, + error: COMPACTION_FAILED_ERROR, + initiatedBy: 'user', + }, + ]; + + expect(result.success).toBe(true); + expect(result.content).toEqual(expectedContent); + /** The typed failure makes the row persistable, so this is not an early + * abort: the compaction's turn exists and must reach storage. */ + expect(finalEvent.responseMessage).not.toBeNull(); + expect(finalEvent.earlyAbort).not.toBe(true); + + await manager.destroy(); + }); + + it('leaves a stopped ordinary turn without the marker', async () => { + const { manager, jobStore } = await configureManager(); + const streamId = 'abort-ordinary-turn'; + const job = await manager.createJob(streamId, 'user-1', 'conversation-1'); + jobStore.setContentParts(streamId, [partialText], job.createdAt); + + const result = await manager.abortJob(streamId); + + expect(result.success).toBe(true); + expect(result.content).toEqual([partialText]); + expect(result.content[0]).not.toHaveProperty('initiatedBy'); + + await manager.destroy(); + }); +}); diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index 4d1f74db770..8855ed9456e 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -5345,6 +5345,7 @@ export class RedisJobStore implements IJobStoreV2 { userMessage: data.userMessage ? JSON.parse(data.userMessage) : undefined, responseMessageId: data.responseMessageId || undefined, isRegenerate: data.isRegenerate != null ? data.isRegenerate === '1' : undefined, + compact: data.compact != null ? data.compact === '1' : undefined, mcpRequestBody: data.mcpRequestBody ? JSON.parse(data.mcpRequestBody) : undefined, userSubmittedPaths: data.userSubmittedPaths ? JSON.parse(data.userSubmittedPaths) : undefined, userSubmittedMessageFieldPaths: data.userSubmittedMessageFieldPaths diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts index 2e7d471b360..3952a2b9ff6 100644 --- a/packages/api/src/stream/interfaces/IJobStore.ts +++ b/packages/api/src/stream/interfaces/IJobStore.ts @@ -169,6 +169,9 @@ export interface SerializableJobData { /** Whether this generation replaces an existing assistant branch. */ isRegenerate?: boolean; + /** Whether this generation is a manual context compaction; the abort paths + * read it to stamp the stopped row with the compaction's identity. */ + compact?: boolean; /** Exact normalized MCP placeholder identity for this turn. */ mcpRequestBody?: MCPRuntimeRequestBody; /** Exact assistant-message fields authored by the user during this running job. */ @@ -448,6 +451,7 @@ export type JobMetadataPatch = Partial< SerializableJobData, | 'responseMessageId' | 'isRegenerate' + | 'compact' | 'mcpRequestBody' | 'userSubmittedPaths' | 'userSubmittedMessageFieldPaths' diff --git a/packages/api/src/stream/metadata.ts b/packages/api/src/stream/metadata.ts index a3bdbcbf041..c1a01f168a2 100644 --- a/packages/api/src/stream/metadata.ts +++ b/packages/api/src/stream/metadata.ts @@ -9,6 +9,9 @@ export function sanitizeJobMetadata(metadata: Partial): J if (metadata.isRegenerate !== undefined) { patch.isRegenerate = metadata.isRegenerate; } + if (metadata.compact !== undefined) { + patch.compact = metadata.compact; + } if (metadata.mcpRequestBody) { patch.mcpRequestBody = metadata.mcpRequestBody; } diff --git a/packages/api/src/types/stream.ts b/packages/api/src/types/stream.ts index d3096cb1c82..8105192f7ab 100644 --- a/packages/api/src/types/stream.ts +++ b/packages/api/src/types/stream.ts @@ -31,6 +31,9 @@ export interface GenerationJobMetadata { responseMessageId?: string; /** Whether this generation replaces an existing assistant branch. */ isRegenerate?: boolean; + /** Whether this generation is a manual context compaction; the abort paths + * read it to stamp the stopped row with the compaction's identity. */ + compact?: boolean; /** Exact normalized MCP placeholder identity for this turn. Persisted so HITL * resume does not reconstruct a different parent or overridden conversation. */ mcpRequestBody?: MCPRuntimeRequestBody; From 4a26b9d64dc71e6450c804b9d3efc55ccc27d131 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:42:30 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A7=AA=20test:=20Build=20the=20Cancel?= =?UTF-8?q?led-Compaction=20Leaf=20on=20a=20Real=20Conversation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compaction-rerun-controls.spec.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts b/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts index 7f15f9df690..c10ed7ee1aa 100644 --- a/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts +++ b/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts @@ -123,15 +123,30 @@ async function compactWithEmptySummarizer(page: Page, request: APIRequestContext * a user message, the shape whose Regenerate would otherwise answer that user * turn instead of redoing the compaction. Returns the conversation and the * turn the cancelled compaction persisted. + * + * The conversation is created through the composer (a seeded one carries no + * model, so its compact submission never starts) and the user leaf is seeded + * onto the answer: seeded rows are newer, so the leaf reads as latest. */ async function cancelCompactionOnUserLeaf(page: Page, request: APIRequestContext, label: string) { - const { answerId, messages } = precedingTurns(label); + await page.goto('/c/new'); + await sendMessageAndWaitForCompletion(page, `tell me about ${label}`); + const conversationId = new URL(page.url()).pathname.replace('/c/', ''); + expect(conversationId).not.toBe('new'); + + const answerId = await withMongo(async (db) => { + const row = await db + .collection('messages') + .findOne({ conversationId, isCreatedByUser: false }, { sort: { createdAt: -1 } }); + return row?.messageId as string | undefined; + }); + expect(answerId).toBeTruthy(); + const leafUserId = randomUUID(); - const conversationId = await seedBranch([ - ...messages, + await seedMessages(userEmail, conversationId, [ { messageId: leafUserId, - parentMessageId: answerId, + parentMessageId: answerId as string, text: `Compact this before answering ${label}`, isCreatedByUser: true, sender: 'User', @@ -144,10 +159,13 @@ async function cancelCompactionOnUserLeaf(page: Page, request: APIRequestContext expect(behavior.ok()).toBeTruthy(); await page.goto(`/c/${conversationId}`); + await expect( + messagesView(page).getByText(`Compact this before answering ${label}`), + ).toBeVisible(); await page.getByTestId('token-usage').click(); await page.getByRole('button', { name: 'Compact context' }).click(); const stop = page.getByTestId('stop-generation-button'); - await expect(stop).toBeVisible(); + await expect(stop).toBeVisible({ timeout: 20_000 }); await stop.click(); /** The Stop route persists the aborted turn before it publishes the final From f365875f592c26dd0917ce7683a25c79c810ae6b Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:26:57 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A7=AA=20test:=20Assert=20the=20Withh?= =?UTF-8?q?eld=20Controls=20on=20the=20Live=20Cancelled=20Compaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts b/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts index c10ed7ee1aa..d66c18b697e 100644 --- a/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts +++ b/e2e/specs/mock/scenarios/compaction-rerun-controls.spec.ts @@ -404,9 +404,10 @@ test.describe('compaction rerun controls', () => { await expect(row).toBeVisible(); await row.hover(); - await expect(row.getByText('Could not compact the context', { exact: false })).toBeVisible(); /* Replaying the user turn behind it would answer that message again rather - than redo the compaction, so the marker withholds the controls here too. */ + than redo the compaction, so the marker withholds the controls here too. + The stored outcome text is the reload scenario's to check: the live row + renders the turn it streamed, which stopped before its first delta. */ await expect(page.locator(`[id="edit-${compactionId}"]`)).toHaveCount(0); await expect(page.getByTestId('regenerate-generation-button')).toHaveCount(0); await expect(page.getByTestId('continue-generation-button')).toHaveCount(0); From e187e42ece9fc1f97486573ecbc2602bd78fa9aa Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:31:48 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=93=B4=20fix:=20Keep=20Live=20Disconn?= =?UTF-8?q?ect=20Compaction=20Rows=20Free=20of=20a=20Synthesized=20Failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request.partialDisconnect.spec.js | 23 ++++++++++++ api/server/controllers/agents/request.js | 4 ++ packages/api/src/agents/compaction.spec.ts | 37 +++++++++++++++++++ packages/api/src/agents/compaction.ts | 28 ++++++++++---- .../__tests__/abortCompactionIdentity.spec.ts | 30 +++++++++++++++ 5 files changed, 114 insertions(+), 8 deletions(-) diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js index 2bdee75d661..a06c3973d9d 100644 --- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js +++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js @@ -301,4 +301,27 @@ describe('ResumableAgentController tenant context', () => { 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); + }); }); diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index f9e59351a3e..f9154b10565 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -1857,9 +1857,13 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit return; } + /** The run is still live here: mark what streamed, but leave the outcome + * to whichever path settles the turn (the terminal abort synthesizes + * the typed failure a stopped compaction with no summary needs). */ const persistableContent = markAbortedCompactionContent( filterPersistableAbortContent(aggregatedContent), isCompaction, + { synthesizeFailure: false }, ); if (persistableContent.length === 0) { logger.debug('[ResumableAgentController] No persistable content to save partial response'); diff --git a/packages/api/src/agents/compaction.spec.ts b/packages/api/src/agents/compaction.spec.ts index cdd487fab1e..5c81c36603c 100644 --- a/packages/api/src/agents/compaction.spec.ts +++ b/packages/api/src/agents/compaction.spec.ts @@ -326,6 +326,14 @@ describe('markAbortedCompactionContent', () => { summarizing: true, }); + /** The summarizer opens the part when its round starts, so a stop can land + * between that and the first delta. */ + const emptySummaryPlaceholder = (): TMessageContentParts => ({ + type: ContentTypes.SUMMARY, + content: [], + summarizing: true, + }); + /** The abort path owns a cancelled run's row: its partial summary is kept * (the turn is unfinished, not failed) but must still carry the marker, or * on a branch ending in a user message the row keeps a Regenerate that @@ -353,6 +361,24 @@ describe('markAbortedCompactionContent', () => { expect(parts[1]).toMatchObject({ initiatedBy: 'user' }); }); + /** A summary placeholder with no text is not an outcome: the run produced + * nothing to keep, so the typed failure is the turn's identity. The + * placeholder itself is still marked. */ + it('records the typed failure beside a summary placeholder that streamed nothing', () => { + const parts = [emptySummaryPlaceholder()]; + + markAbortedCompactionContent(parts, true); + + expect(parts).toEqual([ + { ...emptySummaryPlaceholder(), initiatedBy: 'user' }, + { + type: ContentTypes.ERROR, + error: JSON.stringify({ type: ErrorTypes.COMPACTION_FAILED }), + initiatedBy: 'user', + }, + ]); + }); + /** A run stopped before any part streamed still needs an identifiable row: * an empty one reads as an answer to the message it hangs off. */ it('records the typed failure when nothing streamed before the stop', () => { @@ -369,6 +395,17 @@ describe('markAbortedCompactionContent', () => { ]); }); + /** The disconnect save runs while the generation is still live and the + * completion path overwrites the row: inventing a failure there would + * report one that never happened. */ + it('marks without synthesizing a failure for a non-terminal snapshot', () => { + const parts: TMessageContentParts[] = [emptySummaryPlaceholder()]; + + markAbortedCompactionContent(parts, true, { synthesizeFailure: false }); + + expect(parts).toEqual([{ ...emptySummaryPlaceholder(), initiatedBy: 'user' }]); + }); + it('returns content from a turn that was not a compaction unchanged', () => { const parts = [partialSummary('An automatic detour partial')]; diff --git a/packages/api/src/agents/compaction.ts b/packages/api/src/agents/compaction.ts index 39b3149fb9e..ecb11769002 100644 --- a/packages/api/src/agents/compaction.ts +++ b/packages/api/src/agents/compaction.ts @@ -207,26 +207,38 @@ export function resolveFailedTurnContent( * redoing it. * * Every summary and error part is marked, the same stamp `markCompactionOutcome` - * puts on a completed run's outcome; a run stopped before any part streamed - * records the typed failure so the row still carries its identity. The parts - * are otherwise untouched, keeping the row's `unfinished` shape, and content - * from a turn that was not a compaction is returned unchanged. + * puts on a completed run's outcome; a summary placeholder the run opened but + * never streamed text into counts as no outcome. A terminal abort (Stop) with + * no outcome records the typed failure so the row still carries its identity; + * a non-terminal snapshot (`synthesizeFailure: false`, the disconnect save the + * run may still complete and overwrite) marks what is there and invents + * nothing. The parts are otherwise untouched, keeping the row's `unfinished` + * shape, and content from a turn that was not a compaction is returned + * unchanged. */ export function markAbortedCompactionContent( contentParts: TMessageContentParts[], isCompaction: boolean, + { synthesizeFailure = true }: { synthesizeFailure?: boolean } = {}, ): TMessageContentParts[] { if (!isCompaction) { return contentParts; } - let markedOutcome = false; + let hasOutcome = false; for (const part of contentParts) { - if (part?.type === ContentTypes.SUMMARY || part?.type === ContentTypes.ERROR) { + if (part?.type === ContentTypes.SUMMARY) { part.initiatedBy = 'user'; - markedOutcome = true; + if (isSummaryPartWithText(part)) { + hasOutcome = true; + } + continue; + } + if (part?.type === ContentTypes.ERROR) { + part.initiatedBy = 'user'; + hasOutcome = true; } } - if (!markedOutcome) { + if (!hasOutcome && synthesizeFailure) { contentParts.push(...compactionFailureContent()); } return contentParts; diff --git a/packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts b/packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts index fa3c9c24cc4..c59c2565f63 100644 --- a/packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts +++ b/packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts @@ -100,6 +100,36 @@ describe('abortJob compaction identity', () => { await manager.destroy(); }); + /** A placeholder the summarizer opened but never streamed text into is not + * an outcome: the typed failure is what makes the stopped row identifiable. */ + it('records the typed failure beside an empty summary placeholder', async () => { + const { manager, jobStore } = await configureManager(); + const streamId = 'abort-compaction-placeholder'; + const placeholder: Agents.MessageContentComplex = { + type: ContentTypes.SUMMARY, + content: [], + summarizing: true, + }; + const job = await manager.createJob(streamId, 'user-1', 'conversation-1', { + initialMetadata: { compact: true }, + }); + jobStore.setContentParts(streamId, [placeholder], job.createdAt); + + const result = await manager.abortJob(streamId); + + expect(result.success).toBe(true); + expect(result.content).toEqual([ + { ...placeholder, initiatedBy: 'user' } as Agents.MessageContentComplex, + { + type: ContentTypes.ERROR, + error: COMPACTION_FAILED_ERROR, + initiatedBy: 'user', + }, + ]); + + await manager.destroy(); + }); + it('leaves a stopped ordinary turn without the marker', async () => { const { manager, jobStore } = await configureManager(); const streamId = 'abort-ordinary-turn'; From 4cb38051b01f7632e91cba04aeb180418a32c32a Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:44:43 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A7=AA=20test:=20Cover=20the=20Compac?= =?UTF-8?q?tion=20Flag=20in=20the=20Redis=20Job=20Round=20Trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/stream/__tests__/RedisJobStore.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/api/src/stream/__tests__/RedisJobStore.spec.ts b/packages/api/src/stream/__tests__/RedisJobStore.spec.ts index 72b873051c8..86a0c0a615a 100644 --- a/packages/api/src/stream/__tests__/RedisJobStore.spec.ts +++ b/packages/api/src/stream/__tests__/RedisJobStore.spec.ts @@ -335,6 +335,7 @@ describe('RedisJobStore', () => { iconURL: 'https://example.com/icon.png', model: 'test-model', agent_id: 'agent-1', + compact: true, isTemporary: false, retentionExpiresAt: '2030-01-01T00:00:00.000Z', agentEventDeliveryKey: 'completion-delivery-1', @@ -393,6 +394,9 @@ describe('RedisJobStore', () => { * degrading to ordinary steering in every Redis deployment. */ expect(job.preemptCapable).toBe(true); + /** The abort paths read this flag from a reloaded job, so it has to + * survive the Redis round trip for a stopped compaction to be stamped. */ + expect(job.compact).toBe(true); expect(job.steerQuotesExecutionId).toBe('exec-1'); expect(job.generationProtocolVersion).toBe(2); expect(job.checkpointNamespace).toEqual(expect.any(String)); From a0129e995cd632e0ae115354720377252abf8fd3 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:48:42 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=93=B4=20fix:=20Keep=20a=20Stopped=20?= =?UTF-8?q?Compaction=20Honest=20and=20Its=20Anchor=20Untouched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/agents/__tests__/abort.spec.js | 63 +++++++++++++++++++ api/server/routes/agents/index.js | 25 +++++--- packages/api/src/agents/compaction.spec.ts | 52 ++++++++++----- packages/api/src/agents/compaction.ts | 51 ++++++++++----- .../__tests__/abortCompactionIdentity.spec.ts | 15 ++--- 5 files changed, 157 insertions(+), 49 deletions(-) diff --git a/api/server/routes/agents/__tests__/abort.spec.js b/api/server/routes/agents/__tests__/abort.spec.js index e31bdc95c7d..20eadfebb22 100644 --- a/api/server/routes/agents/__tests__/abort.spec.js +++ b/api/server/routes/agents/__tests__/abort.spec.js @@ -358,6 +358,69 @@ describe('Agent Abort Endpoint', () => { }), ).resolves.toBe(false); }); + + /** A compaction's `userMessage` is the persisted leaf projected for + * identity only (`projectCompactionAnchor`), so upserting it would + * erase a user leaf's text or turn an assistant leaf into an empty + * user row: Stop writes only the aborted response. */ + it('skips the anchor prerequisite when an aborted compaction persists its row', async () => { + const jobStreamId = 'test-stream-compact'; + const anchorId = 'persisted-leaf-1'; + const compactionRowId = 'compaction-response-1'; + + mockGenerationJobManager.getJob.mockResolvedValue({ + metadata: { userId: 'test-user-123', generationProtocolVersion: 2 }, + }); + + const abortResult = { + success: true, + jobData: { + compact: true, + createdEventEmitted: true, + userMessage: { + messageId: anchorId, + parentMessageId: 'older-response', + conversationId: jobStreamId, + text: '', + }, + responseMessageId: compactionRowId, + conversationId: jobStreamId, + endpoint: 'agents', + sender: 'TestAgent', + model: 'agent-1', + }, + content: [ + { + type: 'error', + error: JSON.stringify({ type: 'compaction_failed' }), + initiatedBy: 'user', + }, + ], + text: '', + }; + mockGenerationJobManager.abortJob.mockImplementation(async (_streamId, options) => { + await options.beforePublish(abortResult); + 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).toHaveBeenCalledTimes(1); + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + messageId: compactionRowId, + parentMessageId: anchorId, + unfinished: true, + isCreatedByUser: false, + }), + expect.objectContaining({ context: expect.stringContaining('abort endpoint') }), + ); + }); }); describe('Partial Response Saving', () => { diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 52a4c34eeb1..3241d470baa 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -764,6 +764,10 @@ router.post('/chat/abort', configMiddleware, 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; if ( jobData?.userMessage?.messageId && @@ -827,16 +831,19 @@ router.post('/chat/abort', configMiddleware, async (req, res, next) => { * with neither row stored. Both writes are idempotent upserts; * await the user prerequisite first, but still attempt the child * write and checkpoint cleanup so every independently useful - * operation gets a chance to succeed. */ - try { - const persistedRequest = await saveMessage(messageContext, requestMessage, { - context: 'api/server/routes/agents/index.js - abort user prerequisite', - }); - if (!persistedRequest) { - throw new Error('Abort user prerequisite was not persisted'); + * operation gets a chance to succeed. A compaction skips the + * prerequisite: its anchor is the persisted leaf itself. */ + if (shouldPersistAnchor) { + try { + const persistedRequest = await saveMessage(messageContext, requestMessage, { + context: 'api/server/routes/agents/index.js - abort user prerequisite', + }); + if (!persistedRequest) { + throw new Error('Abort user prerequisite was not persisted'); + } + } catch (error) { + persistenceErrors.push(error); } - } catch (error) { - persistenceErrors.push(error); } try { diff --git a/packages/api/src/agents/compaction.spec.ts b/packages/api/src/agents/compaction.spec.ts index 5c81c36603c..5e6c2b924a4 100644 --- a/packages/api/src/agents/compaction.spec.ts +++ b/packages/api/src/agents/compaction.spec.ts @@ -334,17 +334,36 @@ describe('markAbortedCompactionContent', () => { summarizing: true, }); - /** The abort path owns a cancelled run's row: its partial summary is kept - * (the turn is unfinished, not failed) but must still carry the marker, or - * on a branch ending in a user message the row keeps a Regenerate that - * answers that user turn instead of redoing the compaction. */ - it('marks the partial summary a stopped compaction had streamed', () => { + const completedSummary = (text: string): TMessageContentParts => ({ + type: ContentTypes.SUMMARY, + content: [{ type: ContentTypes.TEXT, text }], + boundary: completedBoundary, + }); + + /** The abort path owns a cancelled run's row: its partial summary must still + * carry the marker, or on a branch ending in a user message the row keeps a + * Regenerate that answers that user turn instead of redoing the compaction. + * The truncated prefix is kept but marked failed, or its label presents it + * as a finished checkpoint. */ + it('marks the partial summary a stopped compaction had streamed as failed', () => { const parts = [partialSummary('Half a summary')]; markAbortedCompactionContent(parts, true); expect(parts).toHaveLength(1); - expect(parts[0]).toMatchObject({ initiatedBy: 'user', summarizing: true }); + expect(parts[0]).toMatchObject({ initiatedBy: 'user', failed: true, summarizing: true }); + }); + + /** A round that finished before the Stop landed is a real checkpoint: the + * race is not a failure. */ + it('marks a summary that completed before the stop without failing it', () => { + const parts = [completedSummary('Finished before the stop.')]; + + markAbortedCompactionContent(parts, true); + + expect(parts).toHaveLength(1); + expect(parts[0]).toMatchObject({ initiatedBy: 'user' }); + expect(parts[0]).not.toHaveProperty('failed'); }); /** Every part that can carry the marker gets it: the row's identity must not @@ -357,20 +376,19 @@ describe('markAbortedCompactionContent', () => { markAbortedCompactionContent(parts, true); - expect(parts[0]).toMatchObject({ initiatedBy: 'user' }); + expect(parts[0]).toMatchObject({ initiatedBy: 'user', failed: true }); expect(parts[1]).toMatchObject({ initiatedBy: 'user' }); }); - /** A summary placeholder with no text is not an outcome: the run produced - * nothing to keep, so the typed failure is the turn's identity. The - * placeholder itself is still marked. */ - it('records the typed failure beside a summary placeholder that streamed nothing', () => { + /** A summary placeholder with no text is not an outcome: nothing of the + * round survived to show, so the typed failure is the row's whole + * outcome. */ + it('replaces a summary placeholder that streamed nothing with the typed failure', () => { const parts = [emptySummaryPlaceholder()]; markAbortedCompactionContent(parts, true); expect(parts).toEqual([ - { ...emptySummaryPlaceholder(), initiatedBy: 'user' }, { type: ContentTypes.ERROR, error: JSON.stringify({ type: ErrorTypes.COMPACTION_FAILED }), @@ -396,14 +414,14 @@ describe('markAbortedCompactionContent', () => { }); /** The disconnect save runs while the generation is still live and the - * completion path overwrites the row: inventing a failure there would - * report one that never happened. */ - it('marks without synthesizing a failure for a non-terminal snapshot', () => { - const parts: TMessageContentParts[] = [emptySummaryPlaceholder()]; + * completion path overwrites the row: it stamps identity and rewrites + * nothing else, not even the failure flag of a still-streaming part. */ + it('marks a non-terminal snapshot without failing or synthesizing anything', () => { + const parts: TMessageContentParts[] = [partialSummary('Half a summary')]; markAbortedCompactionContent(parts, true, { synthesizeFailure: false }); - expect(parts).toEqual([{ ...emptySummaryPlaceholder(), initiatedBy: 'user' }]); + expect(parts).toEqual([{ ...partialSummary('Half a summary'), initiatedBy: 'user' }]); }); it('returns content from a turn that was not a compaction unchanged', () => { diff --git a/packages/api/src/agents/compaction.ts b/packages/api/src/agents/compaction.ts index ecb11769002..063870965ac 100644 --- a/packages/api/src/agents/compaction.ts +++ b/packages/api/src/agents/compaction.ts @@ -206,15 +206,14 @@ export function resolveFailedTurnContent( * Regenerate would answer the user turn behind the compaction instead of * redoing it. * - * Every summary and error part is marked, the same stamp `markCompactionOutcome` - * puts on a completed run's outcome; a summary placeholder the run opened but - * never streamed text into counts as no outcome. A terminal abort (Stop) with - * no outcome records the typed failure so the row still carries its identity; - * a non-terminal snapshot (`synthesizeFailure: false`, the disconnect save the - * run may still complete and overwrite) marks what is there and invents - * nothing. The parts are otherwise untouched, keeping the row's `unfinished` - * shape, and content from a turn that was not a compaction is returned - * unchanged. + * A terminal abort (Stop) settles the turn, so it applies the completed run's + * outcome rules: a usable summary is marked as the outcome; a partial one + * keeps its text but is marked `failed`, or its label would present the + * truncated prefix as a finished checkpoint; a placeholder that never streamed + * text goes, leaving the typed failure as the row's outcome. A non-terminal + * snapshot (`synthesizeFailure: false`, the disconnect save the run may still + * complete and overwrite) marks what is there and rewrites nothing else. + * Content from a turn that was not a compaction is returned unchanged. */ export function markAbortedCompactionContent( contentParts: TMessageContentParts[], @@ -225,18 +224,38 @@ export function markAbortedCompactionContent( return contentParts; } let hasOutcome = false; - for (const part of contentParts) { - if (part?.type === ContentTypes.SUMMARY) { - part.initiatedBy = 'user'; - if (isSummaryPartWithText(part)) { - hasOutcome = true; - } + for (let index = contentParts.length - 1; index >= 0; index -= 1) { + const part = contentParts[index]; + if (part == null) { continue; } - if (part?.type === ContentTypes.ERROR) { + if (part.type === ContentTypes.ERROR) { part.initiatedBy = 'user'; hasOutcome = true; + continue; + } + if (part.type !== ContentTypes.SUMMARY) { + continue; + } + /** The usability predicate's false side narrows the part's type away, so + * the reference is taken before it runs. */ + const summary = part; + if (isUsableSummaryPart(part)) { + summary.initiatedBy = 'user'; + hasOutcome = true; + continue; + } + if (!synthesizeFailure) { + summary.initiatedBy = 'user'; + continue; + } + if (isSummaryPartWithText(summary)) { + summary.initiatedBy = 'user'; + summary.failed = true; + hasOutcome = true; + continue; } + contentParts.splice(index, 1); } if (!hasOutcome && synthesizeFailure) { contentParts.push(...compactionFailureContent()); diff --git a/packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts b/packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts index c59c2565f63..12da6035a79 100644 --- a/packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts +++ b/packages/api/src/stream/__tests__/abortCompactionIdentity.spec.ts @@ -50,7 +50,7 @@ describe('abortJob compaction identity', () => { jest.resetModules(); }); - it('stamps the partial summary a stopped compaction had streamed', async () => { + it('stamps the partial summary a stopped compaction had streamed as failed', async () => { const { manager, jobStore } = await configureManager(); const streamId = 'abort-compaction-partial'; const job = await manager.createJob(streamId, 'user-1', 'conversation-1', { @@ -62,10 +62,12 @@ describe('abortJob compaction identity', () => { const finalEvent = result.finalEvent as AbortFinalEvent; expect(result.success).toBe(true); - /** The row keeps its unfinished shape; only the marker is added. */ + /** The row keeps its unfinished shape; the partial summary keeps its text + * but reads as failed, or its label presents the truncated prefix as a + * finished checkpoint. */ expect(finalEvent.responseMessage).toMatchObject({ unfinished: true, error: false }); expect(result.content).toEqual([ - { ...partialSummary, initiatedBy: 'user' } as Agents.MessageContentComplex, + { ...partialSummary, initiatedBy: 'user', failed: true } as Agents.MessageContentComplex, ]); await manager.destroy(); @@ -100,9 +102,9 @@ describe('abortJob compaction identity', () => { await manager.destroy(); }); - /** A placeholder the summarizer opened but never streamed text into is not - * an outcome: the typed failure is what makes the stopped row identifiable. */ - it('records the typed failure beside an empty summary placeholder', async () => { + /** A placeholder the summarizer opened but never streamed text into carries + * nothing to show: the typed failure replaces it as the row's outcome. */ + it('replaces an empty summary placeholder with the typed failure', async () => { const { manager, jobStore } = await configureManager(); const streamId = 'abort-compaction-placeholder'; const placeholder: Agents.MessageContentComplex = { @@ -119,7 +121,6 @@ describe('abortJob compaction identity', () => { expect(result.success).toBe(true); expect(result.content).toEqual([ - { ...placeholder, initiatedBy: 'user' } as Agents.MessageContentComplex, { type: ContentTypes.ERROR, error: COMPACTION_FAILED_ERROR,