mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-05 05:36:36 +00:00
🧅 feat: Reveal Subagent Thread History and Event Detail on Demand (#15283)
* ✨ feat: Refine Subagent Thread Activity UX
* test: align subagent completion lifecycle
* chore: satisfy subagent panel static checks
* fix: harden subagent activity pagination
* fix: preserve paged subagent history
* fix: surface unrecoverable activity boundaries
* fix: rebase paged activity after live advances
* fix: bound child activity history state
* fix: preserve rebased child history order
* fix: retain unavailable child history boundary
* fix: fence child history cursor generations
* fix: preserve subagent history continuity
* fix: retain live turns during history rebase
* fix: close subagent history edge cases
* fix: harden subagent history phase transitions
This commit is contained in:
parent
151dc9e03e
commit
41ba808e6f
22 changed files with 2025 additions and 134 deletions
|
|
@ -490,6 +490,9 @@ class BaseClient {
|
|||
sender: 'User',
|
||||
text,
|
||||
isCreatedByUser: true,
|
||||
...(this.options?.req?._agentEventTriggerProjection != null && {
|
||||
subagentTriggerProjection: this.options.req._agentEventTriggerProjection,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,26 @@ describe('BaseClient', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('persists only the host-authored external event display projection on the user turn', () => {
|
||||
const projection = {
|
||||
version: 1,
|
||||
eventType: 'chess.turn.ready',
|
||||
sourceType: 'speed-chess',
|
||||
occurredAt: new Date('2026-08-21T12:00:00.000Z'),
|
||||
expectedActionToolName: 'submit_move',
|
||||
};
|
||||
TestClient.options.req = { _agentEventTriggerProjection: projection };
|
||||
|
||||
expect(
|
||||
TestClient.createUserMessage({
|
||||
messageId: 'event:user',
|
||||
parentMessageId: 'parent',
|
||||
conversationId: 'event-thread',
|
||||
text: 'Private event payload',
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ subagentTriggerProjection: projection }));
|
||||
});
|
||||
|
||||
test('returns the input messages without instructions when addInstructions() is called with empty instructions', () => {
|
||||
const messages = [{ content: 'Hello' }, { content: 'How are you?' }, { content: 'Goodbye' }];
|
||||
const instructions = '';
|
||||
|
|
|
|||
|
|
@ -4021,9 +4021,9 @@ describe('ResumableAgentController resume metadata', () => {
|
|||
});
|
||||
const event = {
|
||||
id: 'game-1:ply-8',
|
||||
type: 'chess.turn',
|
||||
type: 'chess.\u202Eturn',
|
||||
occurredAt: Date.now(),
|
||||
source: { id: 'speed-chess', type: 'mcp' },
|
||||
source: { id: 'speed-chess', type: 'm\u2066cp' },
|
||||
payload: { gameId: 'game-1', expectedPly: 8 },
|
||||
};
|
||||
const req = {
|
||||
|
|
@ -4037,7 +4037,7 @@ describe('ResumableAgentController resume metadata', () => {
|
|||
deliveryKey: 'req-event-fork',
|
||||
target: { bindingId: 'binding-1' },
|
||||
event,
|
||||
expectedAction: { toolName: 'submit_move', argumentSubset: { expectedPly: 8 } },
|
||||
expectedAction: { toolName: 'submit_\u200Fmove', argumentSubset: { expectedPly: 8 } },
|
||||
},
|
||||
},
|
||||
config: {
|
||||
|
|
@ -4064,7 +4064,7 @@ describe('ResumableAgentController resume metadata', () => {
|
|||
conversationId: 'child-conversation',
|
||||
invocationId: 'req-event-fork',
|
||||
event,
|
||||
expectedAction: { toolName: 'submit_move', argumentSubset: { expectedPly: 8 } },
|
||||
expectedAction: { toolName: 'submit_\u200Fmove', argumentSubset: { expectedPly: 8 } },
|
||||
}),
|
||||
{
|
||||
getSnapshot: expect.any(Function),
|
||||
|
|
@ -4079,6 +4079,13 @@ describe('ResumableAgentController resume metadata', () => {
|
|||
},
|
||||
);
|
||||
expect(mockExecuteAgentEventActor.mock.calls[0][0]).not.toHaveProperty('tenantId');
|
||||
expect(req._agentEventTriggerProjection).toEqual({
|
||||
version: 1,
|
||||
eventType: 'chess. turn',
|
||||
sourceType: 'm cp',
|
||||
occurredAt: new Date(event.occurredAt),
|
||||
expectedActionToolName: 'submit_ move',
|
||||
});
|
||||
expect(client).toMatchObject({
|
||||
checkpointNamespace: 'event-actor/fork',
|
||||
eventActorCheckpointId: 'checkpoint-base',
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ function getPreliminaryResponseMessageId({ messageId, responseMessageId }) {
|
|||
function getPreliminaryUserMessage(
|
||||
{ messageId, parentMessageId, text, quotes, files, manualSkills, alwaysAppliedSkills },
|
||||
conversationId,
|
||||
subagentTriggerProjection,
|
||||
) {
|
||||
if (typeof messageId !== 'string' || messageId.length === 0) {
|
||||
return null;
|
||||
|
|
@ -170,6 +171,36 @@ function getPreliminaryUserMessage(
|
|||
...(Array.isArray(manualSkills) && manualSkills.length > 0 && { manualSkills }),
|
||||
...(Array.isArray(alwaysAppliedSkills) &&
|
||||
alwaysAppliedSkills.length > 0 && { alwaysAppliedSkills }),
|
||||
...(subagentTriggerProjection != null && { subagentTriggerProjection }),
|
||||
};
|
||||
}
|
||||
|
||||
const DISPLAY_IDENTITY_CONTROLS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
|
||||
|
||||
function sanitizeEventDisplayIdentity(value) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const bounded = Array.from(value).slice(0, 512).join('');
|
||||
const sanitized = bounded.normalize('NFC').replace(DISPLAY_IDENTITY_CONTROLS, ' ').trim();
|
||||
return sanitized.length === 0 ? undefined : Array.from(sanitized).slice(0, 256).join('');
|
||||
}
|
||||
|
||||
function getAgentEventTriggerProjection(agentEventDelivery) {
|
||||
const event = agentEventDelivery?.event;
|
||||
const occurredAt = new Date(event?.occurredAt);
|
||||
const eventType = sanitizeEventDisplayIdentity(event?.type);
|
||||
const sourceType = sanitizeEventDisplayIdentity(event?.source?.type);
|
||||
if (eventType == null || sourceType == null || Number.isNaN(occurredAt.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
const expectedActionToolName = sanitizeEventDisplayIdentity(
|
||||
agentEventDelivery?.expectedAction?.toolName,
|
||||
);
|
||||
return {
|
||||
version: 1,
|
||||
eventType,
|
||||
sourceType,
|
||||
occurredAt,
|
||||
...(expectedActionToolName == null ? {} : { expectedActionToolName }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +302,7 @@ async function saveErrorTurn(
|
|||
alwaysAppliedSkills: req.body.alwaysAppliedSkills,
|
||||
}),
|
||||
}
|
||||
: getPreliminaryUserMessage(req.body, conversationId);
|
||||
: getPreliminaryUserMessage(req.body, conversationId, req._agentEventTriggerProjection);
|
||||
if (!userMessage) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1271,6 +1302,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
|
|||
rawAgentEventDelivery.deliveryKey === clientRequestId
|
||||
? rawAgentEventDelivery
|
||||
: undefined;
|
||||
req._agentEventTriggerProjection = getAgentEventTriggerProjection(agentEventDelivery);
|
||||
|
||||
try {
|
||||
logger.debug(`[ResumableAgentController] Creating job`, {
|
||||
|
|
@ -1285,6 +1317,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
|
|||
const preliminaryUserMessage = getPreliminaryUserMessage(
|
||||
{ ...req.body, messageId: preallocatedUserMessageId },
|
||||
conversationId,
|
||||
req._agentEventTriggerProjection,
|
||||
);
|
||||
const job = await GenerationJobManager.createJob(streamId, userId, conversationId, {
|
||||
startupTelemetry,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue