mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🪞 fix: Preserve Model Spec Icons Across Stream Resume and Abort (#13603)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
This commit is contained in:
parent
ae0c187ddd
commit
2a956f143d
18 changed files with 479 additions and 26 deletions
|
|
@ -90,6 +90,7 @@ describe('ResumableAgentController resume metadata', () => {
|
|||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
modelOptions: { model: 'gpt-3.5-turbo' },
|
||||
},
|
||||
},
|
||||
|
|
@ -105,18 +106,122 @@ describe('ResumableAgentController resume metadata', () => {
|
|||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
|
||||
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(conversationId, {
|
||||
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
|
||||
conversationId,
|
||||
responseMessageId: 'follow-up-user_',
|
||||
userMessage: {
|
||||
expect.objectContaining({
|
||||
conversationId,
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-3.5-turbo',
|
||||
responseMessageId: 'follow-up-user_',
|
||||
userMessage: {
|
||||
messageId: 'follow-up-user',
|
||||
parentMessageId: 'original-response',
|
||||
conversationId,
|
||||
text: 'Check Google Workspace availability.',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
initializeClient.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('stores model spec icon fallbacks and agent ids in early resume metadata', async () => {
|
||||
const conversationId = 'conversation-123';
|
||||
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {
|
||||
text: 'Use the resume spec.',
|
||||
messageId: 'follow-up-user',
|
||||
parentMessageId: 'original-response',
|
||||
conversationId,
|
||||
text: 'Check Google Workspace availability.',
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
spec: 'agent-spec',
|
||||
agent_id: 'agent_resume_spec',
|
||||
model_parameters: { model: 'gpt-4.1' },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
initializeClient.mock.invocationCallOrder[0],
|
||||
config: {
|
||||
modelSpecs: {
|
||||
list: [
|
||||
{
|
||||
name: 'agent-spec',
|
||||
preset: {
|
||||
endpoint: 'openAI',
|
||||
iconURL: 'https://example.com/preset-icon.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
headersSent: true,
|
||||
json: jest.fn(() => {
|
||||
res.headersSent = true;
|
||||
}),
|
||||
status: jest.fn(() => res),
|
||||
};
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
|
||||
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
|
||||
conversationId,
|
||||
expect.objectContaining({
|
||||
iconURL: 'https://example.com/preset-icon.png',
|
||||
model: 'agent_resume_spec',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the model spec preset endpoint when no icon URL is configured', async () => {
|
||||
const conversationId = 'conversation-123';
|
||||
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {
|
||||
text: 'Use the endpoint icon.',
|
||||
messageId: 'follow-up-user',
|
||||
parentMessageId: 'original-response',
|
||||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
spec: 'endpoint-icon-spec',
|
||||
model_parameters: { model: 'gpt-4.1' },
|
||||
},
|
||||
},
|
||||
config: {
|
||||
modelSpecs: {
|
||||
list: [
|
||||
{
|
||||
name: 'endpoint-icon-spec',
|
||||
preset: {
|
||||
endpoint: 'anthropic',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
headersSent: true,
|
||||
json: jest.fn(() => {
|
||||
res.headersSent = true;
|
||||
}),
|
||||
status: jest.fn(() => res),
|
||||
};
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
|
||||
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
|
||||
conversationId,
|
||||
expect.objectContaining({
|
||||
iconURL: 'anthropic',
|
||||
model: 'gpt-4.1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -138,6 +243,8 @@ describe('ResumableAgentController resume metadata', () => {
|
|||
mockGenerationJobManager.getResumeState.mockResolvedValue({
|
||||
conversationId,
|
||||
responseMessageId: 'response-message',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
userMessage: {
|
||||
messageId: 'user-message',
|
||||
parentMessageId: 'parent-message',
|
||||
|
|
@ -156,6 +263,7 @@ describe('ResumableAgentController resume metadata', () => {
|
|||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/fallback-icon.png',
|
||||
modelOptions: { model: 'gpt-3.5-turbo' },
|
||||
},
|
||||
},
|
||||
|
|
@ -188,6 +296,90 @@ describe('ResumableAgentController resume metadata', () => {
|
|||
expect.objectContaining({ userId: 'user-123' }),
|
||||
expect.objectContaining({
|
||||
content: [textPart],
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
messageId: 'response-message',
|
||||
parentMessageId: 'user-message',
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses model spec and agent fallbacks when saving partial responses on disconnect', async () => {
|
||||
const conversationId = 'conversation-123';
|
||||
let allSubscribersLeftHandler;
|
||||
mockGenerationJobManager.createJob.mockResolvedValue({
|
||||
createdAt: 1000,
|
||||
readyPromise: Promise.resolve(),
|
||||
abortController: new AbortController(),
|
||||
emitter: {
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'allSubscribersLeft') {
|
||||
allSubscribersLeftHandler = handler;
|
||||
}
|
||||
}),
|
||||
},
|
||||
});
|
||||
mockGenerationJobManager.getResumeState.mockResolvedValue({
|
||||
conversationId,
|
||||
responseMessageId: 'response-message',
|
||||
userMessage: {
|
||||
messageId: 'user-message',
|
||||
parentMessageId: 'parent-message',
|
||||
conversationId,
|
||||
text: 'Use fallback metadata',
|
||||
},
|
||||
});
|
||||
|
||||
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after setup'));
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {
|
||||
text: 'Use fallback metadata',
|
||||
messageId: 'user-message',
|
||||
parentMessageId: 'parent-message',
|
||||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
spec: 'agent-spec',
|
||||
agent_id: 'agent_resume_spec',
|
||||
model_parameters: { model: 'gpt-4.1' },
|
||||
},
|
||||
},
|
||||
config: {
|
||||
modelSpecs: {
|
||||
list: [
|
||||
{
|
||||
name: 'agent-spec',
|
||||
preset: {
|
||||
endpoint: 'openAI',
|
||||
iconURL: 'https://example.com/preset-icon.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
headersSent: true,
|
||||
json: jest.fn(() => {
|
||||
res.headersSent = true;
|
||||
}),
|
||||
status: jest.fn(() => res),
|
||||
};
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
expect(allSubscribersLeftHandler).toEqual(expect.any(Function));
|
||||
|
||||
const textPart = { type: 'text', text: 'Partial response...' };
|
||||
await allSubscribersLeftHandler([textPart]);
|
||||
|
||||
expect(mockSaveMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'user-123' }),
|
||||
expect.objectContaining({
|
||||
content: [textPart],
|
||||
iconURL: 'https://example.com/preset-icon.png',
|
||||
model: 'agent_resume_spec',
|
||||
messageId: 'response-message',
|
||||
parentMessageId: 'user-message',
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Constants, ViolationTypes } = require('librechat-data-provider');
|
||||
const { Constants, ViolationTypes, isEphemeralAgentId } = require('librechat-data-provider');
|
||||
const {
|
||||
sendEvent,
|
||||
getViolationInfo,
|
||||
|
|
@ -101,6 +101,43 @@ function getPreliminaryUserMessage({ messageId, parentMessageId, text }, convers
|
|||
};
|
||||
}
|
||||
|
||||
function getRequestModelSpec(req, endpointOption) {
|
||||
const spec = endpointOption?.spec ?? req.body?.spec;
|
||||
if (typeof spec !== 'string' || spec.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = req.config?.modelSpecs?.list;
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return list.find((modelSpec) => modelSpec?.name === spec);
|
||||
}
|
||||
|
||||
function getModelSpecIconURL(modelSpec) {
|
||||
return modelSpec?.iconURL ?? modelSpec?.preset?.iconURL ?? modelSpec?.preset?.endpoint ?? '';
|
||||
}
|
||||
|
||||
function getEndpointIconURL(req, endpointOption) {
|
||||
const iconURL =
|
||||
endpointOption?.iconURL ?? getModelSpecIconURL(getRequestModelSpec(req, endpointOption));
|
||||
return iconURL || undefined;
|
||||
}
|
||||
|
||||
function getEndpointResponseModel(endpointOption) {
|
||||
return endpointOption?.modelOptions?.model || endpointOption?.model_parameters?.model;
|
||||
}
|
||||
|
||||
function getAgentResponseModel(req, endpointOption) {
|
||||
const agentId = endpointOption?.agent_id || req.body?.agent_id;
|
||||
if (typeof agentId === 'string' && agentId.length > 0 && !isEphemeralAgentId(agentId)) {
|
||||
return agentId;
|
||||
}
|
||||
|
||||
return getEndpointResponseModel(endpointOption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumable Agent Controller - Generation runs independently of HTTP connection.
|
||||
* Returns streamId immediately, client subscribes separately via SSE.
|
||||
|
|
@ -160,15 +197,18 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
|
|||
|
||||
await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo });
|
||||
|
||||
const endpointIconURL = getEndpointIconURL(req, endpointOption);
|
||||
const responseModel = getAgentResponseModel(req, endpointOption);
|
||||
const preliminaryUserMessage = getPreliminaryUserMessage(req.body, conversationId);
|
||||
const preliminaryResponseMessageId = getPreliminaryResponseMessageId(req.body);
|
||||
if (preliminaryUserMessage || preliminaryResponseMessageId) {
|
||||
await GenerationJobManager.updateMetadata(streamId, {
|
||||
conversationId,
|
||||
responseMessageId: preliminaryResponseMessageId,
|
||||
userMessage: preliminaryUserMessage,
|
||||
});
|
||||
}
|
||||
await GenerationJobManager.updateMetadata(streamId, {
|
||||
conversationId,
|
||||
endpoint: endpointOption.endpoint,
|
||||
iconURL: endpointIconURL,
|
||||
model: responseModel,
|
||||
responseMessageId: preliminaryResponseMessageId,
|
||||
userMessage: preliminaryUserMessage,
|
||||
});
|
||||
|
||||
// Note: We no longer use res.on('close') to abort since we send JSON immediately.
|
||||
// The response closes normally after res.json(), which is not an abort condition.
|
||||
|
|
@ -218,7 +258,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
|
|||
isCreatedByUser: false,
|
||||
user: userId,
|
||||
endpoint: endpointOption.endpoint,
|
||||
model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model,
|
||||
iconURL: resumeState.iconURL || endpointIconURL,
|
||||
model: resumeState.model || responseModel,
|
||||
};
|
||||
|
||||
if (req.body?.agent_id) {
|
||||
|
|
@ -809,8 +850,8 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
|
|||
// Store endpoint metadata for abort handling
|
||||
GenerationJobManager.updateMetadata(streamId, {
|
||||
endpoint: endpointOption.endpoint,
|
||||
iconURL: endpointOption.iconURL,
|
||||
model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model,
|
||||
iconURL: getEndpointIconURL(req, endpointOption),
|
||||
model: getAgentResponseModel(req, endpointOption),
|
||||
sender: client?.sender,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ describe('Agent Abort Endpoint', () => {
|
|||
conversationId: jobStreamId,
|
||||
sender: 'TestAgent',
|
||||
endpoint: 'anthropic',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'claude-3',
|
||||
},
|
||||
content: [{ type: 'text', text: 'Partial response...' }],
|
||||
|
|
@ -275,6 +276,7 @@ describe('Agent Abort Endpoint', () => {
|
|||
text: 'Partial response...',
|
||||
sender: 'TestAgent',
|
||||
endpoint: 'anthropic',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'claude-3',
|
||||
unfinished: true,
|
||||
error: false,
|
||||
|
|
|
|||
|
|
@ -283,6 +283,7 @@ router.post('/chat/abort', async (req, res) => {
|
|||
text: text || '',
|
||||
sender: jobData.sender || 'AI',
|
||||
endpoint: jobData.endpoint,
|
||||
iconURL: jobData.iconURL,
|
||||
model: jobData.model,
|
||||
unfinished: true,
|
||||
error: false,
|
||||
|
|
|
|||
|
|
@ -542,7 +542,11 @@ const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) =
|
|||
/>
|
||||
</div>
|
||||
|
||||
{isExpanded && <div className="max-h-[42vh] overflow-y-auto">{renderProjectsBody()}</div>}
|
||||
{isExpanded && (
|
||||
<div className="scrollbar-gutter-stable max-h-[42vh] overflow-y-auto">
|
||||
{renderProjectsBody()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProjectCreateDialog
|
||||
open={isCreateOpen}
|
||||
|
|
|
|||
|
|
@ -1151,6 +1151,8 @@ describe('useResumableSSE - 404 error path', () => {
|
|||
replayEvents: [replayEvent],
|
||||
responseMessageId: 'follow-up-response',
|
||||
conversationId: CONV_ID,
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
userMessage: {
|
||||
messageId: 'follow-up-user',
|
||||
parentMessageId: 'original-response',
|
||||
|
|
@ -1242,6 +1244,8 @@ describe('useResumableSSE - 404 error path', () => {
|
|||
],
|
||||
responseMessageId: 'follow-up-response',
|
||||
conversationId: CONV_ID,
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
userMessage: {
|
||||
messageId: 'follow-up-user',
|
||||
parentMessageId: 'original-response',
|
||||
|
|
@ -1272,6 +1276,8 @@ describe('useResumableSSE - 404 error path', () => {
|
|||
messageId: 'follow-up-response',
|
||||
parentMessageId: 'follow-up-user',
|
||||
content: expect.any(Array),
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -204,6 +204,63 @@ describe('useResumeOnLoad', () => {
|
|||
expect(observedSubmissions[observedSubmissions.length - 1]).toBe(submission);
|
||||
});
|
||||
|
||||
it('restores model spec icon metadata on the resumed assistant placeholder', async () => {
|
||||
const observedSubmissions: Array<TSubmission | null> = [];
|
||||
mockUseStreamStatus.mockReturnValue({
|
||||
isSuccess: true,
|
||||
isFetching: false,
|
||||
data: {
|
||||
active: true,
|
||||
status: 'running',
|
||||
streamId: CONVERSATION_ID,
|
||||
resumeState: {
|
||||
runSteps: [],
|
||||
aggregatedContent: [{ type: 'text', text: 'Streaming...' }],
|
||||
responseMessageId: RESPONSE_MESSAGE_ID,
|
||||
conversationId: CONVERSATION_ID,
|
||||
sender: 'Spec Agent',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
userMessage: {
|
||||
messageId: USER_MESSAGE_ID,
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
conversationId: CONVERSATION_ID,
|
||||
text: 'Hello',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderUseResumeOnLoad({
|
||||
messages: [
|
||||
buildUserMessage(CONVERSATION_ID),
|
||||
{
|
||||
messageId: RESPONSE_MESSAGE_ID,
|
||||
parentMessageId: USER_MESSAGE_ID,
|
||||
conversationId: CONVERSATION_ID,
|
||||
text: '',
|
||||
isCreatedByUser: false,
|
||||
iconURL: '',
|
||||
model: '',
|
||||
} as TMessage,
|
||||
],
|
||||
onSubmission: (currentSubmission) => observedSubmissions.push(currentSubmission),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(observedSubmissions[observedSubmissions.length - 1]?.initialResponse).toEqual(
|
||||
expect.objectContaining({
|
||||
messageId: RESPONSE_MESSAGE_ID,
|
||||
sender: 'Spec Agent',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the branch that owns a pending OAuth resume user message', async () => {
|
||||
const rootUser = buildUserMessage(CONVERSATION_ID, 'root-user');
|
||||
const branchOneResponse = {
|
||||
|
|
|
|||
|
|
@ -208,6 +208,9 @@ const shouldHydrateMessage = (message: TMessage) =>
|
|||
const hydrateMessageConversationId = (message: TMessage, conversationId: string): TMessage =>
|
||||
shouldHydrateMessage(message) ? { ...message, conversationId } : message;
|
||||
|
||||
const preferDefinedString = (value?: string | null, fallback?: string): string | undefined =>
|
||||
value != null && value !== '' ? value : fallback;
|
||||
|
||||
const getOptimisticMessages = (
|
||||
submission: TSubmission,
|
||||
conversationId: string,
|
||||
|
|
@ -295,6 +298,8 @@ const buildResumeEventSubmission = (
|
|||
resumeState.aggregatedContent ??
|
||||
(currentSubmission.initialResponse as TMessage | undefined)?.content,
|
||||
sender: resumeState.sender ?? currentSubmission.initialResponse?.sender,
|
||||
iconURL: preferDefinedString(currentSubmission.initialResponse?.iconURL, resumeState.iconURL),
|
||||
model: preferDefinedString(currentSubmission.initialResponse?.model, resumeState.model),
|
||||
isCreatedByUser: false,
|
||||
} as TMessage;
|
||||
|
||||
|
|
@ -652,6 +657,11 @@ export default function useResumableSSE(
|
|||
const responseMessage = {
|
||||
...messages[responseIdx],
|
||||
content: data.resumeState.aggregatedContent,
|
||||
iconURL: preferDefinedString(
|
||||
messages[responseIdx]?.iconURL,
|
||||
data.resumeState.iconURL,
|
||||
),
|
||||
model: preferDefinedString(messages[responseIdx]?.model, data.resumeState.model),
|
||||
} as TMessage;
|
||||
const updated = mergeResumeMessages(messages, userMessage, responseMessage);
|
||||
console.log('[ResumableSSE] SYNC updating message', {
|
||||
|
|
@ -672,6 +682,8 @@ export default function useResumableSSE(
|
|||
text: '',
|
||||
content: data.resumeState.aggregatedContent,
|
||||
isCreatedByUser: false,
|
||||
iconURL: data.resumeState.iconURL,
|
||||
model: data.resumeState.model,
|
||||
} as TMessage;
|
||||
setMessages(mergeResumeMessages(messages, userMessage, newMessage));
|
||||
resetContentHandler();
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ function getResumeBranchTargetMessageId(
|
|||
return resumeState.userMessage?.parentMessageId;
|
||||
}
|
||||
|
||||
function preferDefinedString(value?: string | null, fallback?: string): string | undefined {
|
||||
return value != null && value !== '' ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a submission object from resume state for reconnected streams.
|
||||
* This provides the minimum data needed for useResumableSSE to subscribe.
|
||||
|
|
@ -136,7 +140,8 @@ function buildSubmissionFromResumeState(
|
|||
isCreatedByUser: false,
|
||||
role: 'assistant',
|
||||
sender: existingResponseMessage?.sender ?? resumeState.sender,
|
||||
model: existingResponseMessage?.model,
|
||||
model: preferDefinedString(existingResponseMessage?.model, resumeState.model),
|
||||
iconURL: preferDefinedString(existingResponseMessage?.iconURL, resumeState.iconURL),
|
||||
} as TMessage;
|
||||
|
||||
const conversation: TConversation = {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,13 @@ modelSpecs:
|
|||
endpoint: 'Mock Provider B'
|
||||
model: 'mock-model-b'
|
||||
|
||||
- name: 'e2e-icon-spec'
|
||||
label: 'E2E Icon Spec'
|
||||
iconURL: '/assets/openai.svg'
|
||||
preset:
|
||||
endpoint: 'Mock Provider A'
|
||||
model: 'mock-model-a'
|
||||
|
||||
- name: 'e2e-skill-scope'
|
||||
label: 'E2E Skill Scope'
|
||||
preset:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const ASSERT_PROVIDER_FILE_MARKER = 'E2E_ASSERT_PROVIDER_FILE:';
|
|||
const REPLY_MARKER = 'E2E_REPLY:';
|
||||
const COUNTED_REPLY_MARKER = 'E2E_COUNTED_REPLY:';
|
||||
const SLOW_REPLY_MARKER = 'E2E_SLOW_REPLY:';
|
||||
const RESUME_ICON_REPLY_MARKER = 'E2E_RESUME_ICON_REPLY:';
|
||||
const FORCED_ERROR_MARKER = 'E2E_FORCED_ERROR:';
|
||||
const CREATE_FILE_AUTHORING_FINAL_TEXT = 'E2E file authoring complete';
|
||||
const EDIT_FILE_AUTHORING_FINAL_TEXT = 'E2E file edit complete';
|
||||
|
|
@ -28,6 +29,8 @@ const MODEL_SPEC_SKILL_ASSERTION_FINAL_TEXT = 'E2E model spec skill assertion pa
|
|||
const PROVIDER_FILE_ASSERTION_FINAL_TEXT = 'E2E provider file assertion passed';
|
||||
const SLOW_CHUNK_DELAY_MS = Number(process.env.MOCK_LLM_SLOW_CHUNK_DELAY_MS) || 35;
|
||||
const SLOW_REPLY_CHUNKS = 160;
|
||||
const RESUME_ICON_CHUNK_DELAY_MS = Number(process.env.MOCK_LLM_RESUME_ICON_CHUNK_DELAY_MS) || 60;
|
||||
const RESUME_ICON_REPLY_CHUNKS = 240;
|
||||
const CREATE_FILE_TOOL_NAME = 'create_file';
|
||||
const EDIT_FILE_TOOL_NAME = 'edit_file';
|
||||
const BASH_TOOL_NAME = 'bash_tool';
|
||||
|
|
@ -261,6 +264,18 @@ function replyResponses(text) {
|
|||
};
|
||||
}
|
||||
|
||||
const resumeIconName = getMarkerValue(text, RESUME_ICON_REPLY_MARKER);
|
||||
if (resumeIconName) {
|
||||
const chunks = Array.from(
|
||||
{ length: RESUME_ICON_REPLY_CHUNKS },
|
||||
(_, index) => `chunk-${String(index).padStart(3, '0')}`,
|
||||
).join(' ');
|
||||
return {
|
||||
responses: [`E2E resume icon reply ${resumeIconName} ${chunks}`],
|
||||
sleep: RESUME_ICON_CHUNK_DELAY_MS,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export async function selectModelSpec(page: Page, label: string) {
|
|||
return;
|
||||
}
|
||||
await trigger.click();
|
||||
await page.getByRole('option', { name: new RegExp(`^${escapeRegExp(label)}\\b`) }).click();
|
||||
await page.getByRole('option', { name: new RegExp(`(^|\\s)${escapeRegExp(label)}\\b`) }).click();
|
||||
await expect(trigger).toContainText(label);
|
||||
}
|
||||
|
||||
|
|
|
|||
82
e2e/specs/mock/model-spec-icons.spec.ts
Normal file
82
e2e/specs/mock/model-spec-icons.spec.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { NEW_CHAT_PATH, selectModelSpec, sendMessage } from './helpers';
|
||||
|
||||
const ICON_SPEC_LABEL = 'E2E Icon Spec';
|
||||
const ICON_SPEC_URL = '/assets/openai.svg';
|
||||
|
||||
const uniqueLabel = (name: string) => `${name}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
const iconPrompt = (label: string) => `E2E_RESUME_ICON_REPLY:${label}`;
|
||||
const iconReplyPrefix = (label: string) => `E2E resume icon reply ${label}`;
|
||||
|
||||
const assistantMessage = (page: Page, text: string) =>
|
||||
page.locator('.message-render').filter({ hasText: text }).last();
|
||||
|
||||
const modelSpecIcon = (message: Locator) => message.locator(`img[src$="${ICON_SPEC_URL}"]`);
|
||||
|
||||
async function openIconSpecChat(page: Page) {
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
await selectModelSpec(page, ICON_SPEC_LABEL);
|
||||
}
|
||||
|
||||
async function sendIconSpecStream(page: Page, label: string) {
|
||||
const response = await sendMessage(page, iconPrompt(label));
|
||||
expect(response.ok()).toBeTruthy();
|
||||
await expect(page).toHaveURL(/\/c\/[0-9a-fA-F-]{36}(?:\?.*)?$/);
|
||||
|
||||
const reply = iconReplyPrefix(label);
|
||||
const message = assistantMessage(page, reply);
|
||||
await expect(message.getByText(reply)).toBeVisible({ timeout: 30000 });
|
||||
await expect(modelSpecIcon(message)).toBeVisible();
|
||||
|
||||
return { conversationUrl: page.url(), reply };
|
||||
}
|
||||
|
||||
test.describe('model spec message icons', () => {
|
||||
test('preserves iconURL when resuming an active stream after navigation', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
const label = uniqueLabel('resume-icon');
|
||||
|
||||
await openIconSpecChat(page);
|
||||
const { conversationUrl, reply } = await sendIconSpecStream(page, label);
|
||||
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
await page.goto(conversationUrl, { timeout: 10000 });
|
||||
|
||||
const resumedMessage = assistantMessage(page, reply);
|
||||
await expect(resumedMessage.getByText(reply)).toBeVisible({ timeout: 30000 });
|
||||
await expect(modelSpecIcon(resumedMessage)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Stop generating' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('preserves iconURL when aborting an active model spec stream', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
const label = uniqueLabel('abort-icon');
|
||||
|
||||
await openIconSpecChat(page);
|
||||
const { reply } = await sendIconSpecStream(page, label);
|
||||
|
||||
const [abortResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'POST' &&
|
||||
response.url().includes('/api/agents/chat/abort'),
|
||||
{ timeout: 30000 },
|
||||
),
|
||||
page.getByRole('button', { name: 'Stop generating' }).click(),
|
||||
]);
|
||||
expect(abortResponse.ok()).toBeTruthy();
|
||||
await expect(page.getByRole('button', { name: 'Stop generating' })).toBeHidden({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const abortedMessage = assistantMessage(page, reply);
|
||||
await expect(abortedMessage.getByText(reply)).toBeVisible();
|
||||
await expect(modelSpecIcon(abortedMessage)).toBeVisible();
|
||||
|
||||
await page.reload({ timeout: 10000 });
|
||||
const reloadedMessage = assistantMessage(page, reply);
|
||||
await expect(reloadedMessage.getByText(reply)).toBeVisible({ timeout: 30000 });
|
||||
await expect(modelSpecIcon(reloadedMessage)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -468,6 +468,10 @@ class GenerationJobManagerClass {
|
|||
userMessage: jobData.userMessage,
|
||||
responseMessageId: jobData.responseMessageId,
|
||||
sender: jobData.sender,
|
||||
endpoint: jobData.endpoint,
|
||||
iconURL: jobData.iconURL,
|
||||
model: jobData.model,
|
||||
promptTokens: jobData.promptTokens,
|
||||
},
|
||||
readyPromise: runtime.readyPromise,
|
||||
resolveReady: runtime.resolveReady,
|
||||
|
|
@ -770,6 +774,9 @@ class GenerationJobManagerClass {
|
|||
conversationId: jobData.conversationId,
|
||||
content: abortContent,
|
||||
sender: jobData.sender ?? 'AI',
|
||||
endpoint: jobData.endpoint,
|
||||
iconURL: jobData.iconURL,
|
||||
model: jobData.model,
|
||||
unfinished: true,
|
||||
error: false,
|
||||
isCreatedByUser: false,
|
||||
|
|
@ -1348,6 +1355,8 @@ class GenerationJobManagerClass {
|
|||
responseMessageId: jobData.responseMessageId,
|
||||
conversationId: jobData.conversationId,
|
||||
sender: jobData.sender,
|
||||
iconURL: jobData.iconURL,
|
||||
model: jobData.model,
|
||||
titleEvent,
|
||||
replayEvents,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
/* eslint jest/no-standalone-expect: ["error", { "additionalTestBlockFunctions": ["testRedis"] }] */
|
||||
import type { Redis, Cluster } from 'ioredis';
|
||||
import type { ServerSentEvent, StreamEvent, CreatedEvent } from '~/types';
|
||||
import {
|
||||
ioredisClient as staticRedisClient,
|
||||
keyvRedisClient as staticKeyvClient,
|
||||
keyvRedisClientReady,
|
||||
} from '~/cache/redisClients';
|
||||
import { InMemoryEventTransport } from '~/stream/implementations/InMemoryEventTransport';
|
||||
import { RedisEventTransport } from '~/stream/implementations/RedisEventTransport';
|
||||
import { InMemoryJobStore } from '~/stream/implementations/InMemoryJobStore';
|
||||
|
|
@ -8,11 +13,6 @@ import { GenerationJobManagerClass } from '~/stream/GenerationJobManager';
|
|||
import { RedisJobStore } from '~/stream/implementations/RedisJobStore';
|
||||
import { createStreamServices } from '~/stream/createStreamServices';
|
||||
import { GenerationJobManager } from '~/stream/GenerationJobManager';
|
||||
import {
|
||||
ioredisClient as staticRedisClient,
|
||||
keyvRedisClient as staticKeyvClient,
|
||||
keyvRedisClientReady,
|
||||
} from '~/cache/redisClients';
|
||||
|
||||
/** Suppress winston Console transport output (survives jest.resetModules) */
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
|
|
@ -404,11 +404,21 @@ describe('GenerationJobManager Integration Tests', () => {
|
|||
await GenerationJobManager.updateMetadata(streamId, {
|
||||
sender: 'ConsistencyAgent',
|
||||
responseMessageId: 'resp-123',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
});
|
||||
|
||||
const updated = await GenerationJobManager.getJob(streamId);
|
||||
expect(updated?.metadata?.sender).toBe('ConsistencyAgent');
|
||||
expect(updated?.metadata?.responseMessageId).toBe('resp-123');
|
||||
expect(updated?.metadata?.iconURL).toBe('https://example.com/spec-icon.png');
|
||||
expect(updated?.metadata?.model).toBe('gpt-4.1');
|
||||
|
||||
const resumeState = await GenerationJobManager.getResumeState(streamId);
|
||||
expect(resumeState?.sender).toBe('ConsistencyAgent');
|
||||
expect(resumeState?.responseMessageId).toBe('resp-123');
|
||||
expect(resumeState?.iconURL).toBe('https://example.com/spec-icon.png');
|
||||
expect(resumeState?.model).toBe('gpt-4.1');
|
||||
|
||||
await GenerationJobManager.completeJob(streamId);
|
||||
|
||||
|
|
|
|||
|
|
@ -345,6 +345,9 @@ describe('AbortJob - Text and CollectedUsage', () => {
|
|||
await GenerationJobManager.createJob(streamId, 'user-1');
|
||||
await GenerationJobManager.updateMetadata(streamId, {
|
||||
responseMessageId: 'response-message-1',
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
userMessage: {
|
||||
messageId: 'user-message-1',
|
||||
parentMessageId: 'parent-message-1',
|
||||
|
|
@ -393,6 +396,9 @@ describe('AbortJob - Text and CollectedUsage', () => {
|
|||
responseMessage: expect.objectContaining({
|
||||
messageId: 'response-message-1',
|
||||
content: [],
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ export interface ResumeState {
|
|||
responseMessageId?: string;
|
||||
conversationId?: string;
|
||||
sender?: string;
|
||||
iconURL?: string;
|
||||
model?: string;
|
||||
titleEvent?: {
|
||||
event: 'title';
|
||||
data?: {
|
||||
|
|
|
|||
|
|
@ -217,6 +217,8 @@ export namespace Agents {
|
|||
responseMessageId?: string;
|
||||
conversationId?: string;
|
||||
sender?: string;
|
||||
iconURL?: string;
|
||||
model?: string;
|
||||
titleEvent?: {
|
||||
event: 'title';
|
||||
data?: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue